Synopsis
monad-logger-aeson
provides structured JSON logging using monad-logger
's
interface. Specifically, it is intended to be a (largely) drop-in replacement
for monad-logger
's Control.Monad.Logger.CallStack
module.
For additional detail on the library, please see the Haddocks, the
announcement blog post, and the remainder of this README.
Crash course
Assuming we have the following monad-logger
-based code:
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE OverloadedStrings #-}
module Main
( main
) where
import Control.Monad.Logger.CallStack
import Data.Text (pack)
doStuff :: (MonadLogger m) => Int -> m ()
doStuff x = do
logDebug $ "Doing stuff: x=" <> pack (show x)
main :: IO ()
main = do
runStdoutLoggingT do
doStuff 42
logInfo "Done"
We would get something like this log output:
[Debug] Doing stuff: x=42 @(main:Main app/readme-example.hs:12:3)
[Info] Done @(main:Main app/readme-example.hs:18:5)
We can change our import from this:
import Control.Monad.Logger.CallStack
To this:
import Control.Monad.Logger.Aeson
In changing the import, we'll have one compiler error to address:
monad-logger-aeson/app/readme-example.hs:12:35: error:
• Couldn't match expected type ‘Message’
with actual type ‘Data.Text.Internal.Text’
• In the second argument of ‘(<>)’, namely ‘pack (show x)’
In the second argument of ‘($)’, namely
‘"Doing stuff: x=" <> pack (show x)’
In a stmt of a 'do' block:
logDebug $ "Doing stuff: x=" <> pack (show x)
|
12 | logDebug $ "Doing stuff: x=" <> pack (show x)
|
This indicates that we need to provide the logDebug
call a Message
rather
than a Text
value. This compiler error gives us a choice depending upon our
current time constraints: we can either go ahead and convert this Text
value
to a "proper" Message
by moving the metadata it encodes into structured data
(i.e. a [Series]
value, where Series
is an aeson
key and encoded value),
or we can defer doing that for now by tacking on an empty [Series]
value.
We'll opt for the former here:
logDebug $ "Doing stuff" :# ["x" .= x]
Note that the logInfo
call did not give us a compiler error, as Message
has
an IsString
instance.
Our log output now looks like this (formatted for readability here with jq
):
{
"time": "2022-05-15T20:52:15.5559417Z",
"level": "debug",
"location": {
"package": "main",
"module": "Main",
"file": "app/readme-example.hs",
"line": 11,
"char": 3
},
"message": {
"text": "Doing stuff",
"meta": {
"x": 42
}
}
}
{
"time": "2022-05-15T20:52:15.5560448Z",
"level": "info",
"location": {
"package": "main",
"module": "Main",
"file": "app/readme-example.hs",
"line": 17,
"char": 5
},
"message": {
"text": "Done"
}
}
Voilà! Now our Haskell code is using structured logging. Our logs are fit for
parsing, ingestion into our log aggregation/analysis service of choice, etc.
Goals
The following goals have underpinned the development of monad-logger-aeson
:
- Structured logging must be easy to add to existing Haskell codebases
- Structured logging should be performant
We believe we have achieved goal 1 by targeting monad-logger
's
MonadLogger
/LoggingT
interface. There are many interesting logging libraries
to choose from in Haskell: monad-logger
, di
, logging-effect
, katip
, and
so on. Both by comparing the reverse dependency list for monad-logger
with
the other logging libraries' reverse dependency lists, and also consulting our
personal experiences working on Haskell codebases, monad-logger
would seem to
be the most prevalent logging library in the wild. In developing our library as
a (largely) drop-in replacement for monad-logger
, we hope to empower
Haskellers using this popular logging interface to add structured logging to
their programs with minimal fuss.
We believe we have achieved goal 2 by directly representing in-flight Message
values using a fixed aeson
object Encoding
, by never (internally) converting
anything to intermediate Value
s, and by never parsing these in-flight log
messages when assembling the final logged message. Regarding the latter point,
we need to know the origin of an input LogStr
(i.e. is it from
monad-logger-aeson
or not?). If we know an input LogStr
came from
monad-logger-aeson
, then we know the LogStr
is an aeson
object Encoding
of a Message
, and so we can pass this encoding along untouched as a piece of
the final log message's encoding. If we know an input LogStr
did not come from
monad-logger-aeson
, then we can scoop this LogStr
up into a text-only
Message
, encode that, and pass the encoding along as a piece of the final log
message's encoding. A straightforward and relatively expensive implementation of
determining a LogStr
's origin would involve parsing of in-flight log messages
back into Message
values. Rather than resort to parsing every in-flight
message, we simply check the first 9 characters of the LogStr
for a match with
{"text":"
. Yes, there is the possibility that a monad-logger
user logs out a
LogStr
with this same prefix and that LogStr
makes its way into a
monad-logger-aeson
user's LoggingT
runner function. This would cause
monad-logger-aeson
to erroneously assume the message's origin is
monad-logger-aeson
. We feel this possibility is overall unlikely, and have
accepted this as a tradeoff in the design space of the library. While we believe
the principles described previously should provide good performance, please note
that benchmarks do not yet exist for this library. Caveat emptor!