monad-logger-aeson-0.4.1.0: library/Control/Monad/Logger/Aeson/Internal.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE StrictData #-}
module Control.Monad.Logger.Aeson.Internal
( -- * Disclaimer
-- $disclaimer
-- ** @Message@-related
Message(..)
, SeriesElem(..)
, 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.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 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_fast_logger(3,0,1)
import System.Log.FastLogger.Internal (LogStr(..))
#else
import System.Log.FastLogger (LogStr, fromLogStr)
#endif
#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
-- | A single key-value pair, where the value is encoded JSON. This is a more
-- restricted version of 'Series': a 'SeriesElem' is intended to encapsulate
-- exactly one key-value pair, whereas a 'Series' encapsulates zero or more
-- key-value pairs. 'SeriesElem' values can be created via '(.=)' from @aeson@.
--
-- While a 'SeriesElem' most often will map to a single pair, note that a
-- 'Semigroup' instance is available for performance's sake. The 'Semigroup'
-- instance is useful when multiple pairs are grouped together and then shared
-- across multiple logging calls. In that case, the cost of combining the pairs
-- in the group must only be paid once.
--
-- @since 0.3.0.0
newtype SeriesElem = UnsafeSeriesElem
{ unSeriesElem :: Series
}
-- | @since 0.3.0.0
#if MIN_VERSION_aeson(2, 2, 0)
instance KeyValue Encoding SeriesElem where
(.=) = explicitToField Aeson.toEncoding
{-# INLINE (.=) #-}
explicitToField f name value =
UnsafeSeriesElem $ Aeson.pair name $ f value
{-# INLINE explicitToField #-}
#else
deriving newtype instance KeyValue SeriesElem
#endif
-- | @since 0.3.1.0
deriving newtype instance Semigroup SeriesElem
-- | 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"
<*> (pure (,) <*> (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 -> [SeriesElem]
keyMapToSeriesList =
fmap (uncurry (.=)) . 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 'SeriesElem' 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 :# [SeriesElem]
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 = logStrToLBS logStr
logStrToLBS :: LogStr -> LBS.ByteString
logStrToLBS =
#if MIN_VERSION_fast_logger(3,0,1)
-- Use (presumably) faster/better conversion if we have new enough fast-logger
Builder.toLazyByteString . unLogStr
where
unLogStr (LogStr _ builder) = builder
#else
LBS.fromStrict . fromLogStr
#endif
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 $ foldMap unSeriesElem 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.