packages feed

freckle-app-1.0.2.9: library/Freckle/App/Logging.hs

{-# LANGUAGE TupleSections #-}

module Freckle.App.Logging
  (
  -- * Logging settings
    HasLogging(..)
  , getLogDefaultANSI
  , getLogBehaviors
  , LogLevel
  , LogFormat(..)
  , LogLocation(..)

  -- ** Loading
  , parseEnvLogFormat
  , parseEnvLogLevel
  , parseEnvLogLocation

  -- * 'MonadLogger'-style running
  , runAppLoggerT

  -- * Formats, for use from other Logging libraries
  , formatJsonLogStr
  , formatJsonNoLoc
  , formatJson
  , formatTerminal
  ) where

import Freckle.App.Prelude

import Control.Monad.Logger
import Data.Aeson (ToJSON, encode, object, (.=))
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as BSL
import qualified Freckle.App.Env as Env
import System.Console.ANSI
  ( Color(Blue, Magenta, Red, Yellow)
  , ColorIntensity(Dull)
  , ConsoleLayer(Foreground)
  , SGR(Reset, SetColor)
  , hSupportsANSI
  , setSGRCode
  )
import System.IO (stderr, stdout)

data LogFormat
  = FormatJSON
  -- ^ Emit @{"level": "{level}", "message": "{message}"}@
  | FormatTerminal
  -- ^ Emit @[{level}] @{message}@, possibly colorized

data LogLocation
  = LogStdout
  | LogStderr
  | LogFile FilePath

-- | Class for getting Logging settings from your @app@ type
class HasLogging a where
    getLogLevel :: a -> LogLevel

    getLogFormat :: a -> LogFormat

    getLogLocation :: a -> LogLocation

-- | Provide a pure decision for colorizing output
--
-- This is useful in a context where actively checking for ANSI terminal support
-- is either not possible or too expensive. Given that we support 'LogFile', and
-- so are unlikely to be redirecting terminal output to a file, it is relatively
-- safe to use this determination.
--
getLogDefaultANSI :: HasLogging a => a -> Bool
getLogDefaultANSI app = case (getLogLocation app, getLogFormat app) of
  (LogStdout, FormatTerminal) -> True
  (LogStdout, FormatJSON) -> False
  (LogStderr, FormatTerminal) -> True
  (LogStderr, FormatJSON) -> False
  (LogFile _, FormatTerminal) -> False
  (LogFile _, FormatJSON) -> False

getLogBehaviors :: HasLogging a => a -> IO (ByteString -> IO (), Bool)
getLogBehaviors app = case getLogLocation app of
  LogStdout -> (BS8.hPutStr stdout, ) <$> hSupportsANSI stdout
  LogStderr -> (BS8.hPutStr stderr, ) <$> hSupportsANSI stderr
  LogFile path -> pure (BS8.appendFile path, False)

parseEnvLogLevel :: Env.Parser LogLevel
parseEnvLogLevel = Env.var parse "LOG_LEVEL" $ Env.def LevelWarn
 where
  parse = Env.eitherReader $ \case
    "warn" -> Right LevelWarn
    "error" -> Right LevelError
    "debug" -> Right LevelDebug
    "info" -> Right LevelInfo
    level -> Left $ "unexpected log level: " <> level

parseEnvLogFormat :: Env.Parser LogFormat
parseEnvLogFormat = Env.var parse "LOG_FORMAT" $ Env.def FormatTerminal
 where
  parse = Env.eitherReader $ \case
    "json" -> Right FormatJSON
    "terminal" -> Right FormatTerminal
    format -> Left $ "unexpected format: " <> format

parseEnvLogLocation :: Env.Parser LogLocation
parseEnvLogLocation = Env.var parse "LOG_LOCATION" $ Env.def LogStdout
 where
  parse = Env.eitherReader $ \case
    "stdout" -> Right LogStdout
    "stderr" -> Right LogStderr
    "file" -> Right $ LogFile "fancy.log"
    file -> Right $ LogFile file

runAppLoggerT :: HasLogging a => a -> LoggingT IO b -> IO b
runAppLoggerT app f = do
  (putLogLine, isANSI) <- getLogBehaviors app

  let
    logger = case getLogFormat app of
      FormatJSON -> jsonLogger putLogLine
      FormatTerminal -> ansiLogger putLogLine isANSI

  flip runLoggingT logger
    $ filterLogger (\_ level -> level >= getLogLevel app) f
 where
  jsonLogger putLogLine loc src level str =
    putLogLine $ formatJsonLogStr loc src level str

  ansiLogger putLogLine isANSI loc src level str =
    putLogLine $ formatTerminal isANSI loc src level str

formatJsonLogStr :: Loc -> LogSource -> LogLevel -> LogStr -> ByteString
formatJsonLogStr loc src level =
  formatJson (Just loc) (Just src) level . decodeUtf8 . fromLogStr

formatJsonNoLoc :: ToJSON a => LogLevel -> a -> ByteString
formatJsonNoLoc = formatJson Nothing Nothing

formatJson
  :: ToJSON a => Maybe Loc -> Maybe LogSource -> LogLevel -> a -> ByteString
formatJson loc src level msg = (<> "\n") $ BSL.toStrict $ encode $ object
  [ "loc" .= (locJson <$> loc)
  , "src" .= src
  , "level" .= levelText level
  , "message" .= msg
  ]
 where
  locJson Loc {..} = object
    [ "filename" .= loc_filename
    , "package" .= loc_package
    , "module" .= loc_module
    , "start" .= loc_start
    , "end" .= loc_end
    ]

formatTerminal
  :: ToLogStr a
  => Bool -- ^ Supports escapes?
  -> Loc
  -> LogSource
  -> LogLevel
  -> a
  -> ByteString
formatTerminal isANSI loc src level str = mconcat
  [ esc $ style level
  , BS.snoc levelStr labelEnd
  , esc Reset
  , BS.intercalate (BS.singleton labelEnd) logStr
  , esc Reset
  ]
 where
  labelEnd = fromIntegral $ fromEnum ']'

  (levelStr : logStr) =
    BS.split labelEnd . fromLogStr $ defaultLogStr loc src level $ toLogStr str

  esc x = if isANSI then BS8.pack $ setSGRCode [x] else ""

style :: LogLevel -> SGR
style = \case
  LevelDebug -> SetColor Foreground Dull Magenta
  LevelInfo -> SetColor Foreground Dull Blue
  LevelWarn -> SetColor Foreground Dull Yellow
  LevelError -> SetColor Foreground Dull Red
  LevelOther _ -> Reset

levelText :: LogLevel -> Text
levelText = \case
  LevelDebug -> "Debug"
  LevelInfo -> "Info"
  LevelWarn -> "Warn"
  LevelError -> "Error"
  LevelOther x -> x