packages feed

lager-0.1.0.0: lib/Lager.hs

{-# LANGUAGE DeriveGeneric     #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Concurrent Lager 🍺
--
-- @
-- import Lager
--
-- main =
--   'withLager' \"APP\" ['File' 'Info' \"log.txt\"] $ \\l -> do
--     'logDebug'   l "Cheers! 🍻"
--     'logWarning' l "Warning!"
-- @
module Lager
  ( -- * Logging
    Lager
  , withLager
  , newLager
  , newLagerSTM
  , runLager
  , drinkLager
  , logDebug
  , logInfo
  , logNotice
  , logWarning
  , logErr
  , logCrit
  , logAlert
  , logEmerg
  , logSTM
  , logStream
  , logSub
  , Msg(..)
  , -- * Target
    Target(..)
  , defConsole
  , Level(..)
  , defLevelRGB
  , -- * Exception
    LagerException(..)
  ) where

import Control.Applicative
import Control.Concurrent
import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Exception
import Control.Monad
import Data.Functor
import Data.Ord
import Data.Text.Lazy (Text)
import qualified Data.Text.Lazy    as T
import qualified Data.Text.Lazy.IO as T
import GHC.Generics
import Prettyprinter
import Prettyprinter.Render.Terminal
import System.IO

-- | Logging handle
data Lager = Lager
  { nm :: Text        -- source name
  , tg :: [Target]
  , wc :: TChan Msg   -- write chan
  , rc :: [TChan Msg] -- read chans
  , drink :: TVar Bool
  , drunk :: TVar Bool
  }

-- | Log message
data Msg = Msg
  { lvl :: Level
  , txt :: Text -- ^ message
  , src :: Text -- ^ logger source
  }

-- | Acquire a new handle
newLager :: Text -> [Target] -> IO Lager
newLager nm' = atomically . newLagerSTM nm'

-- | Acquire a new handle in STM
newLagerSTM :: Text -> [Target] -> STM Lager
newLagerSTM nm' tgt' = do
  wc' <- newBroadcastTChan
  Lager nm' tgt' wc'
    <$> replicateM (length tgt') (dupTChan wc')
    <*> newTVar False
    <*> newTVar False

-- | Acquire a 'newLager', concurrently 'runLager',
-- then finally 'drinkLager' if the body terminates
-- or throws an exception.
withLager :: Text -> [Target] -> (Lager -> IO a) -> IO a
withLager nm' tgt' k = do
  l <- newLager nm' tgt'
  either id id
    <$> race (k l `finally` drinkLager l)
             (runLager l >> forever (threadDelay maxBound))

-- | Finish logging. Blocks until all outputs are written by 'runLager'.
drinkLager :: Lager -> IO ()
drinkLager l = do
  atomically $ writeTVar (drink l) True
  atomically $ checkDrunk l

checkDrink :: Lager -> STM ()
checkDrink = check <=< readTVar . drink

checkDrunk :: Lager -> STM ()
checkDrunk = check <=< readTVar . drunk

logDebug :: Lager -> Text -> IO ()
logDebug l = lager l Debug

logInfo :: Lager -> Text -> IO ()
logInfo l = lager l Info

logNotice :: Lager -> Text -> IO ()
logNotice l = lager l Notice

logWarning :: Lager -> Text -> IO ()
logWarning l = lager l Warning

logErr :: Lager -> Text -> IO ()
logErr l = lager l Err

logCrit :: Lager -> Text -> IO ()
logCrit l = lager l Crit

logAlert :: Lager -> Text -> IO ()
logAlert l = lager l Alert

logEmerg :: Lager -> Text -> IO ()
logEmerg l = lager l Emerg

-- | Log text in IO
lager :: Lager -> Level -> Text -> IO ()
lager l lvl' = atomically . logSTM l lvl'

-- | Log text in STM
logSTM :: Lager -> Level -> Text -> STM ()
logSTM l lvl' msg = do
  throwIfDrunk l
  writeTChan (wc l) $ Msg lvl' msg (nm l)

-- | Extend the logger name
logSub :: Text -> Lager -> Lager
logSub nm' l = Lager nm'' [] (wc l) [] (drink l) (drunk l)
  where
    nm'' | T.null nm'    = nm l
         | T.null (nm l) = nm'
         | otherwise     = nm l <> "|" <> nm'

-- | Stream log messages
logStream :: Lager -> (IO Msg -> IO a) -> IO a
logStream l k = do
  r <- atomically $ throwIfDrunk l *> dupTChan (wc l)
  k  $ atomically $ throwIfDrunk l *> readTChan  r

throwIfDrunk :: Lager -> STM ()
throwIfDrunk l = do
  isDrunk <- readTVar $ drunk l
  when isDrunk $ throwSTM LagerDaemonTerminated

-- | Log level
data Level
  = Emerg -- ^ emergency
  | Alert
  | Crit  -- ^ critcal
  | Err   -- ^ error
  | Warning
  | Notice
  | Info
  | Debug
  deriving (Enum, Eq, Generic, Read, Show)

-- | 'Debug' < 'Emerg'
instance Ord Level where
  compare = comparing $ negate . fromEnum

annLevelColor :: Level -> [(Level, Color)] -> Doc AnsiStyle -> Doc AnsiStyle
annLevelColor l m = case lookup l m of
  Just c  -> annotate $ color c
  Nothing -> id

-- | Default 'Console' color schema
defLevelRGB :: [(Level, Color)]
defLevelRGB =
  [ (Emerg, Red), (Alert, Red), (Crit, Red), (Err, Red)
  , (Warning, Yellow), (Notice, Green), (Debug, Cyan)
  ]

-- | Log output
data Target
  = Console Level [(Level, Color)] -- ^ stdout, optional RGB
  | Journal Level                  -- ^ journald stdout
  | File Level FilePath
  deriving (Eq, Generic, Show)

-- | Default 'Console' target with 'Info' log level
-- and 'defLevelRGB' color schema.
defConsole :: Target
defConsole = Console Info defLevelRGB

-- | Run logging daemon
runLager :: Lager -> IO ()
runLager l =
  mapConcurrently_ (runTarget l) (zip (tg l) (rc l))
    `finally` atomically (writeTVar (drunk l) True)

runTarget :: Lager -> (Target, TChan Msg) -> IO ()
runTarget lgr (t, c) = case t of
  Console l m -> runHandle lgr stdout (renderConsoleRGB m) l c
  Journal l -> runHandle lgr stdout renderJournal l c
  File l path ->
    withFile path WriteMode $ \hndl ->
      runHandle lgr hndl renderConsole l c

renderJournal :: Msg -> Text
renderJournal msg =
  "<" <> T.pack (show $ fromEnum $ lvl msg) <> "> " <> renderConsole msg

renderConsole :: Msg -> Text
renderConsole msg
  | T.null (src msg) = txt msg
  | otherwise        = "[" <> src msg <> "] " <> txt msg

renderConsoleRGB :: [(Level, Color)] -> Msg -> Text
renderConsoleRGB m msg =
  renderLazy $
  layoutPretty defaultLayoutOptions $
  annLevelColor (lvl msg) m $
  pretty $
  renderConsole msg

runHandle
  :: Lager
  -> Handle
  -> (Msg -> Text)
  -> Level
  -> TChan Msg
  -> IO ()
runHandle lgr hndl render l c = loop
  where
    loop = join $ atomically $ do
      m <- Just `fmap` readTChan c <|> (checkDrink lgr $> Nothing)
      case m of
        Just a  -> return $ logHandle hndl render l a >> loop
        Nothing -> do
          msgs <- listTChan c
          return $ mapM_ (logHandle hndl render l) msgs

logHandle :: Handle -> (Msg -> Text) -> Level -> Msg -> IO ()
logHandle hndl render l msg
  | not (visible l msg) = return ()
  | otherwise           = T.hPutStrLn hndl $ render msg

listTChan :: TChan a -> STM [a]
listTChan t = loop
  where
    loop = do
      isEmpty <- isEmptyTChan t
      if isEmpty
        then return []
        else (:) <$> readTChan t <*> loop

visible :: Level -> Msg -> Bool
visible lvl' msg = lvl msg >= lvl'

data LagerException
  = LagerDaemonTerminated
    -- ^ the daemon is already closed due to 'drinkLager'

instance Show LagerException where
  show LagerDaemonTerminated = "lager: daemon terminated"

instance Exception LagerException