packages feed

freckle-app-1.0.0.1: 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 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 Data.Text (Text)
import Data.Text.Encoding (decodeUtf8)
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