Using a custom monad that stores LogAction inside its environment

September 8, 2023 ยท View on GitHub

This tutorial covers the more advanced topic of using the co-log library with a custom application monad.

You can run this tutorial by executing the following command:

cabal new-run tutorial-custom

Preamble: imports and language extensions

Since this is a literate Haskell file, we need to specify all our language extensions and imports up front.

{-# LANGUAGE DerivingStrategies    #-}
{-# LANGUAGE FlexibleContexts      #-}
{-# LANGUAGE FlexibleInstances     #-}
{-# LANGUAGE InstanceSigs          #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE PatternSynonyms       #-}

import Prelude hiding (log)

import Colog (pattern D, HasLog (..), pattern I, LogAction, Message, WithLog, log,
              richMessageAction)
import Control.Monad.IO.Class (MonadIO)
import Control.Monad.Reader (MonadReader, ReaderT (..))

Application environment

If you have a complex Haskell application, then you are likely to also have non-trivial settings that configure your application environment. The environment may store various parameters relevant to your application's behavior. Interestingly, we can store a LogAction inside the same environment to use it for our logging functions.

The environment for your application may look like this:

data Env m = Env
    { envServerPort :: !Int
    , envLogAction  :: !(LogAction m Message)
    }

Several notes about this data type:

  1. It stores different parameters, like the server port.
  2. It stores a LogAction that can log values of the Message type from co-log in the m monad.
  3. Env is parameterized by type variable m which is going to be the application monad.

The next step is to define an instance of the HasLog typeclass for the Env data type. This instance will specify how to get and update the LogAction stored inside the environment.

instance HasLog (Env m) Message m where
    getLogAction :: Env m -> LogAction m Message
    getLogAction = envLogAction
    {-# INLINE getLogAction #-}

    setLogAction :: LogAction m Message -> Env m -> Env m
    setLogAction newLogAction env = env { envLogAction = newLogAction }
    {-# INLINE setLogAction #-}

That's it! co-log requires very little boilerplate.

Application monad

Now let's define our application monad:

newtype App a = App
    { unApp :: ReaderT (Env App) IO a
    } deriving newtype (Functor, Applicative, Monad, MonadIO, MonadReader (Env App))

This monad stores Env parameterized by the monad itself in its context. Nothing special is required here to tell the monad how to use the logger.

Example

co-log relies on the tagless final technique for writing functions. So you define your monadic actions with the WithLog constraint that allows you to perform logging:

example :: WithLog env Message m => m ()
example = do
    log D "First message..."
    log I "Second message..."

The WithLog constraint has three type parameters: the application environment, the type of the message and the monad. Function log takes two parameters: the logger severity and the log message's text.

Running example

Now we are ready to execute this action.

First, let's create an example environment:

simpleEnv :: Env App
simpleEnv = Env
    { envServerPort = 8081
    , envLogAction  = richMessageAction
    }

Then we need to define a function that performs actions of type App:

runApp :: Env App -> App a -> IO a
runApp env app = runReaderT (unApp app) env

Putting it all together, we can specialize the WithLog constraint to our App monad and run our example.

main :: IO ()
main = runApp simpleEnv example

And the output will look like this:

co-log output example