Using MonadLogger without LoggingT
This article shows how to integrate code that uses the MonadLogger typeclass with a concrete monad that doesn't use the LoggingT transformer, such as a ReaderT IO monad.
This post is likely more useful for people beginning with Haskell than those with more experience. When I was less comfortable with Haskell and its ecosystem, it wasn't obvious to me how to use code with a MonadLogger
constraint without using the LoggingT
transformer. Now that it makes sense, I thought I'd write up my notes. They might help someone who is asking themselves the same question.
What are MonadLogger and LoggingT?
I'll assume you are already familiar with MonadLogger
and LoggingT
, but here is a quick overview if you need it.
The MonadLogger
typeclass from the monad-logger
package provides a generic logging interface that other libraries and applications can use. It is then up to the caller to decide how logging actually happens. For example, the logs could go to stdout
in JSON format, be collected in a list in memory, we could suppress logging altogether, etc. I like to think of it as dependency injection.
The LoggingT
monad transformer is a helper that allows you to wrap a monad and give it logging capability thanks to its MonadLogger
instance. It comes with useful utility functions, such as runStdoutLoggingT, to dispatch the transformer by giving it a concrete logging function.
From what I gathered, MonadLogger
is quite popular and widespread in the Haskell library ecosystem and application code. Libraries like monad-logger-aeson
and Blammo
build upon it and provide additional functionality such as structured logging in JSON, pretty-printing logs during development, and configuration via environment variables.
Background
I was working on a codebase that was using monad transformer stacks. There were between 4 and 6 transformers for each custom monad, sometimes more. Something that looked like this:
newtype App a = App
{unApp :: ReaderT AppConfig (ExceptT AppError (TracingT (LoggingT IO a)))}
I had been reading up on the "ReaderT design pattern" and liked its simplicity and pragmatism.
I was also reading the book Production Haskell by Matt Parsons, which I recommend. It had a few passages on the topic that I found interesting. One discussing the ReaderT pattern:
Fortunately,
ReaderT Env IO
is powerful enough that we don’t need any additional transformers. Every additional transformer provides more choices and complexity, without offering significant extra power. [...] We can write an instance ofMonadLogger
directly to avoid needing theLoggingT
type.Matt Parsons, Production Haskell (2023), "5.5 Embed, don't Stack"
And another one discussing logging libraries:
A common mispattern with this library [
monad-logger
] is incorporating theLoggingT
transformer directly into your own code.LoggingT
is a “minimal” transformer that should be used only when providingMonadLogger
behavior to types you don’t control. Instead, consider writing a manual instance ofMonadLogger
, allowing you more flexibility in how the messages are used and formatted.Matt Parsons, Production Haskell (2023), "17.4 Libraries in Brief"
I wanted to see what it would take to refactor this particular codebase to use the ReaderT pattern and what the result would look like. In other words, to end up with something like this:
newtype App a = App
{unApp :: ReaderT AppEnv IO a}
The codebase had a lot of functions polymorphic on the monad m
and using interfaces such as MonadLogger
. Functions that looked like this:
action :: (MonadConfig m, MonadLogger m, MonadIO m) => m ()
action = do
itemUrl <- configItemUrl <$> getConfig
logInfo $ "Fetching item" :# ["itemUrl" .= itemUrl]
item <- liftIO $ fetchItem itemUrl
-- ...
To get to a single ReaderT
, I had to remove LoggingT
. But all of the examples of logging I had seen so far used LoggingT
.
Haskell has somewhat of a reputation that it lacks tutorial-style and beginner-friendly documentation. And that you must often figure things out by reading the types from the generated Haddock documentation. In all fairness, it's impressive that you can figure things out from the types most of the time, which I did for this particular matter. Few programming languages can boast of having such property. Nevertheless, there is still room for more tutorials. The rest of this post might help fill that gap.
With LoggingT
First, let's briefly look at how using MonadLogger
with LoggingT
looks like. If you are already familiar with this, feel free to skip to the next section.
We'll include LoggingT
in our concrete monad App
. For this example, our monad transformer stack contains only ReaderT
and LoggingT
. But it could have more transformers, as mentioned in the introduction above.
newtype App a = App
{unApp :: ReaderT AppEnv (LoggingT IO) a}
deriving newtype
( -- ...
, MonadIO
, MonadReader AppEnv
, MonadLogger
)
Note that there is a MonadLogger
instance for LoggingT
. There is also an instance for a ReaderT
containing a monad with a MonadLogger
instance. That is the idea behind monad transformers. So we can derive the MonadLogger
instance for App
, we don't need to write it ourselves.
In our runApp
function, we can use one of the "run logging" functions provided by monad-logger
to dispatch the LoggingT
, such as runStdoutLoggingT
(or the runStdoutLoggingT
from monad-logger-aeson
):
runApp :: AppEnv -> App a -> IO a
runApp env action =
runStdoutLoggingT $ runReaderT (unApp action) env
Alternatively, we can inject it into runApp
as a LoggingT IO a -> IO a
parameter. We then defer to the caller, such as our main
function, to decide which "run logging" function to use. This is consistent with injecting other dependencies, like AppEnv
:
runApp :: AppEnv -> (LoggingT IO a -> IO a) -> App a -> IO a
runApp env runLogging action =
runLogging $ runReaderT (unApp action) env
Let's say we have a function that uses logging, something like:
action :: (MonadReader AppEnv m, MonadLogger m, MonadIO m) => m ()
action = do
itemUrl <- asks appItemUrl
logInfo $ "Fetching item" :# ["itemUrl" .= itemUrl]
item <- liftIO $ fetchItem itemUrl
if isInvalidItem item
then
logWarn
$ "Skipping invalid item"
:# [ "itemUrl" .= itemUrl
, "itemId" .= itemId item
]
else processItem item
And it is used in our app:
app :: App ()
app = do
-- ...
action
We can run this using our runApp
function and passing it the required dependencies as arguments:
import Control.Monad.Logger.CallStack (runStdoutLoggingT)
main :: IO ()
main = do
let appEnv =
AppEnv
{ appName = "example"
, appItemUrl = "https://www.example.com/item"
}
runLogging = runStdoutLoggingT
runApp appEnv runLogging app
The example above also works for monad-logger-aeson
, which is meant to be a drop-in replacement for monad-logger
to get JSON structured logging.
Blammo is another useful logging library. It is a wrapper around monad-logger-aeson
, providing features such as environment variable configuration and pretty printing logs during development. Blammo has its own "run logging" functions to dispatch a LoggingT
. We can use runSimpleLoggingT
, which has an equivalent signature to runStdoutLoggingT
:
import Blammo.Logging.Simple (runSimpleLoggingT)
main :: IO ()
main = do
let -- ...
runLogging = runSimpleLoggingT
runApp appEnv runLogging app
For more customization, we can also use runLoggerLoggingT
. We pass it a Blammo.Logger
or an app environment record that contains one. See the Blammo documentation for more details.
Without LoggingT
If we want to call code with a MonadLogger m
constraint without using LoggingT
, we'll still need to run it in a concrete monad with a valid MonadLogger
instance. Let's keep our custom monad App
from the example above, but we'll remove LoggingT
and only keep ReaderT
(also known as the "ReaderT design pattern"):
newtype App a = App
{unApp :: ReaderT AppEnv IO a}
deriving newtype
( -- ...
, MonadIO
, MonadReader AppEnv
)
Note that we can't derive a MonadLogger
instance for App
as we did earlier. We need to implement it ourselves.
Let's look at the definition of the MonadLogger
type class:
class Monad m => MonadLogger m where
monadLoggerLog :: ToLogStr msg => Loc -> LogSource -> LogLevel -> msg -> m ()
Now let's look at how LoggingT
implements it:
instance MonadIO m => MonadLogger (LoggingT m) where
monadLoggerLog a b c d = LoggingT $ \f -> liftIO $ f a b c (toLogStr d)
Or, rewritten for clarity:
instance MonadIO m => MonadLogger (LoggingT m) where
monadLoggerLog :: ToLogStr msg => Loc -> LogSource -> LogLevel -> msg -> LoggingT m ()
monadLoggerLog loc logSource logLevel msg =
LoggingT $ \logFunc ->
liftIO $ logFunc loc logSource logLevel (toLogStr msg)
If we look at how LoggingT
is defined:
newtype LoggingT m a = LoggingT
{runLoggingT :: (Loc -> LogSource -> LogLevel -> LogStr -> IO ()) -> m a}
It looks similar to a ReaderT
, with r
swapped for the logging function Loc -> LogSource -> LogLevel -> LogStr -> IO ()
:
newtype ReaderT r m a = ReaderT
{runReaderT :: r -> m a}
Since our App
is a ReaderT AppEnv
, we should be able to do something similar as long as AppEnv
contains a logging function that we can pull out. And since App
has IO
as the underlying monad, we can call the logging function, which runs in IO
.
Let's first add the logging function to our AppEnv
, aliasing it to LogFunc
for convenience:
type LogFunc = Loc -> LogSource -> LogLevel -> LogStr -> IO ()
data AppEnv = AppEnv
{ -- ...
, appLogFunc :: LogFunc
}
Now, we can implement our custom MonadLogger
instance for App
:
instance MonadLogger App where
monadLoggerLog loc logSource logLevel msg =
App . ReaderT $ \appEnv -> do
let logFunc = appLogFunc appEnv
logFunc loc logSource logLevel (toLogStr msg)
Notice how similar this is to the MonadLogger
instance for LoggingT
shown earlier. On the outside, we reconstruct our monad with App
and ReaderT
. On the inside, the body is the ReaderT
function r -> m a
, in this case, AppEnv -> IO ()
. We extract the logging function from AppEnv
, and we can call it directly since the body is in IO
.
An alternative implementation would be to use the instances we have for App
. The MonadReader AppEnv
instance gives us asks
, and the MonadIO
instance gives us liftIO
. Using these instances would avoid explicitly rebuilding App
with ReaderT
. If our custom monad was more complex than the "ReaderT pattern", this might make things easier. Our new implementation would look like this:
instance MonadLogger App where
monadLoggerLog loc logSource logLevel msg = do
logFunc <- asks appLogFunc
liftIO $ logFunc loc logSource logLevel (toLogStr msg)
Now that we have a valid MonadLogger
instance for App
, we need to find a way to construct a logging function to put into our AppEnv
when initializing the app.
For inspiration, we can look at the source of runStdoutLoggingT
from monad-logger
, which we used when we had LoggingT
as part of our stack:
runStdoutLoggingT :: MonadIO m => LoggingT m a -> m a
runStdoutLoggingT = (`runLoggingT` defaultOutput stdout)
We notice it uses defaultOutput
from the section "utilities for defining your own loggers", which sounds like what we want. The stdout
handle is from base
.
Putting it together, defaultOutput stdout
fits the type of the logging function we want in AppEnv
:
import Control.Monad.Logger.CallStack (defaultOutput)
import System.IO (stdout)
main :: IO ()
main = do
let logFunc = defaultOutput stdout
appEnv =
AppEnv
{ -- ...
, appLogFunc = logFunc
}
runApp appEnv app
The example above works for both monad-logger
and monad-logger-aeson
, after changing the imports appropriately. The latter also provides a defaultOutputWith
if we need more customization.
For Blammo, I needed more work to figure out how to use it. We saw in the previous section that Blammo provides functions such as runSimpleLoggingT
if we use a LoggingT
. But it does not have utilities to construct our own logging function, similar to defaultOutput
from monad-logger
. It also defines a Blammo.Logger
type that is initialized and configurable, by default via environment variables.
Blammo's README has good tutorial-style documentation, with examples of how to customize and integrate it with various frameworks. However, at the time there weren't explicit examples of how to use it without LoggingT
.
I looked at the source of runLoggerLoggingT
and saw it was a bit more involved than runStdoutLoggingT
from monad-logger
:
runLoggerLoggingT
:: (MonadUnliftIO m, HasLogger env) => env -> LoggingT m a -> m a
runLoggerLoggingT env f = (`finally` flushLogStr logger) $ do
runLoggingT
(filterLogger (getLoggerShouldLog logger) f)
(loggerOutput logger $ getLoggerReformat logger)
where
logger = env ^. loggerL
It uses non-exported internals such as loggerOutput
and getLoggerReformat
to construct a MonadLogger
logging function. It also does more than build a logging function. For instance, it flushes logs with a finally
and wraps the LoggingT m
action f
with filterLogger
.
The next place I looked was the examples for RIO and Amazonka, where I noticed the usage of askLoggerIO
from MonadLoggerIO
. This looked promising.
class (MonadLogger m, MonadIO m) => MonadLoggerIO m where
-- | Request the logging function itself.
askLoggerIO :: m (Loc -> LogSource -> LogLevel -> LogStr -> IO ())
LoggingT IO
has an instance of MonadLoggerIO
, which makes sense since we give it the logging function when we run it.
So we can use Blammo's runLoggerLoggingT
or runSimpleLoggingT
to run a small LoggingT IO
action just for the purpose of extracting the logging function with askLoggerIO
:
import Blammo.Logging.Simple (runSimpleLoggingT)
type LogFunc = Loc -> LogSource -> LogLevel -> LogStr -> IO ()
getBlammoLogFunc :: IO LogFunc
getBlammoLogFunc =
runSimpleLoggingT $ do
logFunc <- askLoggerIO
pure logFunc
Or, more concisely:
getBlammoLogFunc :: IO LogFunc
getBlammoLogFunc =
runSimpleLoggingT askLoggerIO
Putting it all together, we can run this IO
action when the app starts to retrieve our logging function and insert it into AppEnv
:
main :: IO ()
main = do
logFunc <- getBlammoLogFunc
let appEnv =
AppEnv
{ -- ...
, appLogFunc = logFunc
}
runApp appEnv app
Let's verify that Blammo's configuration works as expected when running it this way.
Pretty-print by default:
$ cabal run without-loggingt
2023-09-26 18:58:38 [info ] App started appName=example-2c
2023-09-26 18:58:38 [info ] Fetching item itemUrl=https://www.example.com/item
2023-09-26 18:58:38 [warn ] Skipping invalid item itemId=652412308 itemUrl=https://www.example.com/item
Use JSON output:
$ LOG_FORMAT=json cabal run without-loggingt
{"time":"2023-09-26T18:59:32.092596139Z","level":"info","location":{"package":"main","module":"Main","file":"WithoutLoggingT.hs","line":111,"char":3},"message":{"text":"App started","meta":{"appName":"example-2c"}}}
{"time":"2023-09-26T18:59:32.092616247Z","level":"info","location":{"package":"main","module":"Main","file":"WithoutLoggingT.hs","line":97,"char":3},"message":{"text":"Fetching item","meta":{"itemUrl":"https://www.example.com/item"}}}
{"time":"2023-09-26T18:59:32.09262781Z","level":"warn","location":{"package":"main","module":"Main","file":"WithoutLoggingT.hs","line":101,"char":7},"message":{"text":"Skipping invalid item","meta":{"itemUrl":"https://www.example.com/item","itemId":"652412308"}}}
Only display above a certain log level:
$ LOG_LEVEL=warn cabal run without-loggingt
2023-09-26 19:00:22 [warn ] Skipping invalid item itemId=652412308 itemUrl=https://www.example.com/item
Since working on this, Blammo's maintainers have accepted my pull request to add an example of using Blammo without LoggingT to the documentation.
Wrapping up
The TL;DR for running code with a MonadLogger
constraint without using the LoggingT
transformer would be:
- Assuming we are using a custom monad
App
that is aReaderT AppEnv IO
, or some monad that hasMonadReader AppEnv
andMonadIO
instances defined - Add the logging function
Loc -> LogSource -> LogLevel -> LogStr -> IO ()
to the environment typeAppEnv
- Implement a
MonadLogger
instance forApp
, usingasks
fromMonadReader AppEnv
to grab the logging function from the environment, andliftIO
fromMonadIO
to call it - Construct the logging function when initializing the app, and put it in the
AppEnv
created- For
monad-logger
andmonad-logger-aeson
, use the utility functions such asdefaultOutput
with a handle such asstdout
- For
Blammo
, userunSimpleLoggingT
orrunLoggerLoggingT
to run aLoggingT IO
block that returns the logging function by callingaskLoggerIO
fromMonadLoggerIO
- For
You can find the complete working examples with and without LoggingT
used for the article in this Gist.
LoggingT
was only one of the many transformers I removed while refactoring from a monad transformer stack to a single ReaderT
over IO
. I removed ExceptT
by throwing exceptions in IO
. The other transformers were equivalent to ReaderT
, some using an IORef
or similar for any mutable state. For those transformers, I merged their environments into AppEnv
.
Hopefully, some will find this post helpful. Writing it at least helped me sharpen my understanding of how this works.
Finally, this is only one possible way of doing things. My goal was to standardize a codebase on the ReaderT design pattern. However, some may find it easier to add the LoggingT
wrapper into the App
type and dispatch it in runApp
. Of course, that works perfectly fine as well. It is a matter of personal preference or the style adopted by the team.