monad-logger-aeson-0.1.0.0: library/Control/Monad/Logger/Aeson/Internal.hs
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE StrictData #-}
module Control.Monad.Logger.Aeson.Internal
( -- * Disclaimer
-- $disclaimer
-- ** @Message@-related
Message(..)
, (.@)
, LoggedMessage(..)
, threadContextStore
, logCS
, OutputOptions(..)
, defaultLogStrBS
, defaultLogStrLBS
, messageEncoding
, messageSeries
-- ** @LogItem@-related
, LogItem(..)
, logItemEncoding
-- ** Encoding-related
, pairsEncoding
, pairsSeries
, levelEncoding
, locEncoding
-- ** @monad-logger@ internals
, mkLoggerLoc
, locFromCS
, isDefaultLoc
-- ** Aeson compat
, Key
, KeyMap
, emptyKeyMap
, keyMapFromList
, keyMapToList
, keyMapInsert
, keyMapUnion
) where
import Context (Store)
import Control.Applicative (Applicative(liftA2))
import Control.Monad.Logger (Loc(..), LogLevel(..), MonadLogger(..), ToLogStr(..), LogSource)
import Data.Aeson (KeyValue((.=)), Value(Object), (.:), (.:?), Encoding, FromJSON, ToJSON)
import Data.Aeson.Encoding.Internal (Series(..))
import Data.Aeson.Types (Pair, Parser)
import Data.String (IsString)
import Data.Text (Text)
import Data.Time (UTCTime)
import GHC.Generics (Generic)
import GHC.Stack (SrcLoc(..), CallStack, getCallStack)
import System.Log.FastLogger.Internal (LogStr(..))
import qualified Context
import qualified Control.Monad.Logger as Logger
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Encoding as Aeson
import qualified Data.ByteString.Builder as Builder
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString.Lazy.Char8 as LBS8
import qualified Data.Maybe as Maybe
import qualified Data.String as String
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text.Encoding
import qualified Data.Text.Encoding.Error as Text.Encoding.Error
import qualified System.IO.Unsafe as IO.Unsafe
#if MIN_VERSION_aeson(2, 0, 0)
import Data.Aeson.Key (Key)
import Data.Aeson.KeyMap (KeyMap)
import qualified Data.Aeson.KeyMap as AesonCompat
#else
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as AesonCompat
type Key = Text
type KeyMap v = HashMap Key v
#endif
emptyKeyMap :: KeyMap v
emptyKeyMap = AesonCompat.empty
keyMapFromList :: [(Key, v)] -> KeyMap v
keyMapFromList = AesonCompat.fromList
keyMapToList :: KeyMap v -> [(Key, v)]
keyMapToList = AesonCompat.toList
keyMapInsert :: Key -> v -> KeyMap v -> KeyMap v
keyMapInsert = AesonCompat.insert
keyMapUnion :: KeyMap v -> KeyMap v -> KeyMap v
keyMapUnion = AesonCompat.union
-- | Synonym for '.=' from @aeson@.
--
-- We export this synonym rather than directly exporting '.=' to avoid clashing
-- if the user has open-imported both "Control.Monad.Logger.Aeson" and
-- "Data.Aeson".
--
-- @since 0.1.0.0
(.@) :: (KeyValue kv, ToJSON v) => Key -> v -> kv
(.@) = (.=)
-- | This type is the Haskell representation of each JSON log message produced
-- by this library.
--
-- While we never interact with this type directly when logging messages with
-- @monad-logger-aeson@, we may wish to use this type if we are
-- parsing/processing log files generated by this library.
--
-- @since 0.1.0.0
data LoggedMessage = LoggedMessage
{ loggedMessageTimestamp :: UTCTime
, loggedMessageLevel :: LogLevel
, loggedMessageLoc :: Maybe Loc
, loggedMessageLogSource :: Maybe LogSource
, loggedMessageThreadContext :: KeyMap Value
, loggedMessageText :: Text
, loggedMessageMeta :: KeyMap Value
} deriving stock (Eq, Generic, Ord, Show)
instance FromJSON LoggedMessage where
parseJSON = Aeson.withObject "LoggedMessage" \obj -> do
loggedMessageTimestamp <- obj .: "time"
loggedMessageLevel <- fmap logLevelFromText $ obj .: "level"
loggedMessageLoc <- parseLoc =<< obj .:? "location"
loggedMessageLogSource <- obj .:? "source"
loggedMessageThreadContext <- parsePairs =<< obj .:? "context"
(loggedMessageText, loggedMessageMeta) <- parseMessage =<< obj .: "message"
pure LoggedMessage
{ loggedMessageTimestamp
, loggedMessageLevel
, loggedMessageLoc
, loggedMessageLogSource
, loggedMessageThreadContext
, loggedMessageText
, loggedMessageMeta
}
where
logLevelFromText :: Text -> LogLevel
logLevelFromText = \case
"debug" -> LevelDebug
"info" -> LevelInfo
"warn" -> LevelWarn
"error" -> LevelError
other -> LevelOther other
parseLoc :: Maybe Value -> Parser (Maybe Loc)
parseLoc =
traverse $ Aeson.withObject "Loc" \obj ->
Loc
<$> obj .: "file"
<*> obj .: "package"
<*> obj .: "module"
<*> (liftA2 (,) (obj .: "line") (obj .: "char"))
<*> pure (0, 0)
parsePairs :: Maybe Value -> Parser (KeyMap Value)
parsePairs = \case
Nothing -> pure mempty
Just value -> flip (Aeson.withObject "[Pair]") value \obj -> do
pure obj
parseMessage :: Value -> Parser (Text, KeyMap Value)
parseMessage = Aeson.withObject "Message" \obj ->
(,) <$> obj .: "text" <*> (parsePairs =<< obj .:? "meta")
instance ToJSON LoggedMessage where
toJSON loggedMessage =
Aeson.object $ Maybe.catMaybes
[ Just $ "time" .@ loggedMessageTimestamp
, Just $ "level" .@ logLevelToText loggedMessageLevel
, case loggedMessageLoc of
Nothing -> Nothing
Just loc -> Just $ "location" .@ locToJSON loc
, case loggedMessageLogSource of
Nothing -> Nothing
Just logSource -> Just $ "source" .@ logSource
, if loggedMessageThreadContext == mempty then
Nothing
else
Just $ "context" .@ Object loggedMessageThreadContext
, Just $ "message" .@ messageJSON
]
where
locToJSON :: Loc -> Value
locToJSON loc =
Aeson.object
[ "package" .@ loc_package
, "module" .@ loc_module
, "file" .@ loc_filename
, "line" .@ fst loc_start
, "char" .@ snd loc_start
]
where
Loc { loc_filename, loc_package, loc_module, loc_start } = loc
messageJSON :: Value
messageJSON =
Aeson.object $ Maybe.catMaybes
[ Just $ "text" .@ loggedMessageText
, if loggedMessageMeta == mempty then
Nothing
else
Just $ "meta" .@ Object loggedMessageMeta
]
LoggedMessage
{ loggedMessageTimestamp
, loggedMessageLevel
, loggedMessageLoc
, loggedMessageLogSource
, loggedMessageThreadContext
, loggedMessageText
, loggedMessageMeta
} = loggedMessage
toEncoding loggedMessage = logItemEncoding logItem
where
logItem =
LogItem
{ logItemTimestamp = loggedMessageTimestamp
, logItemLoc = Maybe.fromMaybe Logger.defaultLoc loggedMessageLoc
, logItemLogSource = Maybe.fromMaybe "" loggedMessageLogSource
, logItemLevel = loggedMessageLevel
, logItemThreadContext = loggedMessageThreadContext
, logItemMessageEncoding =
messageEncoding $
loggedMessageText :# keyMapToSeriesList loggedMessageMeta
}
keyMapToSeriesList :: KeyMap Value -> [Series]
keyMapToSeriesList =
fmap (uncurry Aeson.pair . fmap Aeson.toEncoding) . keyMapToList
LoggedMessage
{ loggedMessageTimestamp
, loggedMessageLevel
, loggedMessageLoc
, loggedMessageLogSource
, loggedMessageThreadContext
, loggedMessageText
, loggedMessageMeta
} = loggedMessage
-- | A 'Message' captures a textual component and a metadata component. The
-- metadata component is a list of 'Series' to support tacking on arbitrary
-- structured data to a log message.
--
-- With the @OverloadedStrings@ extension enabled, 'Message' values can be
-- constructed without metadata fairly conveniently, just as if we were using
-- 'Text' directly:
--
-- > logDebug "Some log message without metadata"
--
-- Metadata may be included in a 'Message' via the ':#' constructor:
--
-- @
-- 'Control.Monad.Logger.Aeson.logDebug' $ "Some log message with metadata" ':#'
-- [ "bloorp" '.@' (42 :: 'Int')
-- , "bonk" '.@' ("abc" :: 'Text')
-- ]
-- @
--
-- The mnemonic for the ':#' constructor is that the @#@ symbol is sometimes
-- referred to as a hash, a JSON object can be thought of as a hash map, and
-- so with @:#@ (and enough squinting), we are @cons@-ing a textual message onto
-- a JSON object. Yes, this mnemonic isn't well-typed, but hopefully it still
-- helps!
--
-- @since 0.1.0.0
data Message = Text :# [Series]
infixr 5 :#
instance IsString Message where
fromString string = Text.pack string :# []
instance ToLogStr Message where
toLogStr = toLogStr . Aeson.encodingToLazyByteString . messageEncoding
-- | Thread-safe, global 'Store' that captures the thread context of messages.
--
-- Note that there is a bit of somewhat unavoidable name-overloading here: this
-- binding is called 'threadContextStore' because it stores the thread context
-- (i.e. @ThreadContext@/@MDC@ from Java land) for messages. It also just so
-- happens that the 'Store' type comes from the @context@ package, which is a
-- package providing thread-indexed storage of arbitrary context values. Please
-- don't hate the player!
--
-- @since 0.1.0.0
threadContextStore :: Store (KeyMap Value)
threadContextStore =
IO.Unsafe.unsafePerformIO
$ Context.newStore Context.noPropagation
$ Just
$ emptyKeyMap
{-# NOINLINE threadContextStore #-}
-- | 'OutputOptions' is for use with
-- 'Control.Monad.Logger.Aeson.defaultOutputWith' and enables us to configure
-- the JSON output produced by this library.
--
-- We can get a hold of a value of this type via
-- 'Control.Monad.Logger.Aeson.defaultOutputOptions'.
--
-- @since 0.1.0.0
data OutputOptions = OutputOptions
{ outputAction :: LogLevel -> BS8.ByteString -> IO ()
, -- | Controls whether or not the thread ID is included in each log message's
-- thread context.
--
-- Default: 'False'
--
-- @since 0.1.0.0
outputIncludeThreadId :: Bool
, -- | Allows for setting a "base" thread context, i.e. a set of 'Pair' that
-- will always be present in log messages.
--
-- If we subsequently use 'Control.Monad.Logger.Aeson.withThreadContext' to
-- register some thread context for our messages, if any of the keys in
-- those 'Pair' values overlap with the "base" thread context, then the
-- overlapped 'Pair' values in the "base" thread context will be overridden
-- for the duration of the action provided to
-- 'Control.Monad.Logger.Aeson.withThreadContext'.
--
-- Default: 'mempty'
--
-- @since 0.1.0.0
outputBaseThreadContext :: [Pair]
}
defaultLogStrBS
:: UTCTime
-> KeyMap Value
-> Loc
-> LogSource
-> LogLevel
-> LogStr
-> BS8.ByteString
defaultLogStrBS now threadContext loc logSource logLevel logStr =
LBS.toStrict
$ defaultLogStrLBS now threadContext loc logSource logLevel logStr
defaultLogStrLBS
:: UTCTime
-> KeyMap Value
-> Loc
-> LogSource
-> LogLevel
-> LogStr
-> LBS8.ByteString
defaultLogStrLBS now threadContext loc logSource logLevel logStr =
Aeson.encodingToLazyByteString $ logItemEncoding logItem
where
logItem :: LogItem
logItem =
case LBS8.take 9 logStrLBS of
"{\"text\":\"" ->
mkLogItem
$ Aeson.unsafeToEncoding
$ Builder.lazyByteString logStrLBS
_ ->
mkLogItem
$ messageEncoding
$ decodeLenient logStrLBS :# []
mkLogItem :: Encoding -> LogItem
mkLogItem messageEnc =
LogItem
{ logItemTimestamp = now
, logItemLoc = loc
, logItemLogSource = logSource
, logItemLevel = logLevel
, logItemThreadContext = threadContext
, logItemMessageEncoding = messageEnc
}
decodeLenient =
Text.Encoding.decodeUtf8With Text.Encoding.Error.lenientDecode
. LBS.toStrict
logStrLBS = Builder.toLazyByteString logStrBuilder
LogStr _ logStrBuilder = logStr
logCS
:: (MonadLogger m)
=> CallStack
-> LogSource
-> LogLevel
-> Message
-> m ()
logCS cs logSource logLevel msg =
monadLoggerLog (locFromCS cs) logSource logLevel $ toLogStr msg
data LogItem = LogItem
{ logItemTimestamp :: UTCTime
, logItemLoc :: Loc
, logItemLogSource :: LogSource
, logItemLevel :: LogLevel
, logItemThreadContext :: KeyMap Value
, logItemMessageEncoding :: Encoding
}
logItemEncoding :: LogItem -> Encoding
logItemEncoding logItem =
Aeson.pairs $
(Aeson.pairStr "time" $ Aeson.toEncoding logItemTimestamp)
<> (Aeson.pairStr "level" $ levelEncoding logItemLevel)
<> ( if isDefaultLoc logItemLoc then
mempty
else
Aeson.pairStr "location" $ locEncoding logItemLoc
)
<> ( if Text.null logItemLogSource then
mempty
else
Aeson.pairStr "source" $ Aeson.toEncoding logItemLogSource
)
<> ( if null logItemThreadContext then
mempty
else
Aeson.pairStr "context" $ Aeson.toEncoding logItemThreadContext
)
<> (Aeson.pairStr "message" logItemMessageEncoding)
where
LogItem
{ logItemTimestamp
, logItemLoc
, logItemLogSource
, logItemLevel
, logItemThreadContext
, logItemMessageEncoding
} = logItem
messageEncoding :: Message -> Encoding
messageEncoding = Aeson.pairs . messageSeries
messageSeries :: Message -> Series
messageSeries message =
"text" .@ messageText
<> ( if null messageMeta then
mempty
else
Aeson.pairStr "meta" $ Aeson.pairs $ mconcat messageMeta
)
where
messageText :# messageMeta = message
pairsEncoding :: [Pair] -> Encoding
pairsEncoding = Aeson.pairs . pairsSeries
pairsSeries :: [Pair] -> Series
pairsSeries = mconcat . fmap (uncurry (.@))
levelEncoding :: LogLevel -> Encoding
levelEncoding = Aeson.text . logLevelToText
logLevelToText :: LogLevel -> Text
logLevelToText = \case
LevelDebug -> "debug"
LevelInfo -> "info"
LevelWarn -> "warn"
LevelError -> "error"
LevelOther otherLevel -> otherLevel
locEncoding :: Loc -> Encoding
locEncoding loc =
Aeson.pairs $
(Aeson.pairStr "package" $ Aeson.string loc_package)
<> (Aeson.pairStr "module" $ Aeson.string loc_module)
<> (Aeson.pairStr "file" $ Aeson.string loc_filename)
<> (Aeson.pairStr "line" $ Aeson.int $ fst loc_start)
<> (Aeson.pairStr "char" $ Aeson.int $ snd loc_start)
where
Loc { loc_filename, loc_package, loc_module, loc_start } = loc
-- | Not exported from 'monad-logger', so copied here.
mkLoggerLoc :: SrcLoc -> Loc
mkLoggerLoc loc =
Loc { loc_filename = srcLocFile loc
, loc_package = srcLocPackage loc
, loc_module = srcLocModule loc
, loc_start = ( srcLocStartLine loc
, srcLocStartCol loc)
, loc_end = ( srcLocEndLine loc
, srcLocEndCol loc)
}
-- | Not exported from 'monad-logger', so copied here.
locFromCS :: CallStack -> Loc
locFromCS cs = case getCallStack cs of
((_, loc):_) -> mkLoggerLoc loc
_ -> Logger.defaultLoc
-- | Not exported from 'monad-logger', so copied here.
isDefaultLoc :: Loc -> Bool
isDefaultLoc (Loc "<unknown>" "<unknown>" "<unknown>" (0,0) (0,0)) = True
isDefaultLoc _ = False
-- $disclaimer
--
-- In general, changes to this module will not be reflected in the library's
-- version updates. Direct use of this module should be done with care.