Marton Trencseni
Marton Trencseni

Reputation: 887

Logging pattern in Haskell

I'm writing some code to do logging in Haskell. In imperative languages I would (have) written something like:

log = new Logger();
log.registerEndpoint(new ConsoleEndpoint(settings));
log.registerEndpoint(new FileEndpoint(...));
log.registerEndpoint(new ScribeEndpoint(...));
...
log.warn("beware!")
log.info("hello world");

Maybe even make log a global static so I don't have to pass it around. The actual endpoints and setting would be configured at startup from a config file maybe, eg. one for production, one for development.

What's a good pattern to do something like this in Haskell?

Upvotes: 3

Views: 915

Answers (3)

Gabriella Gonzalez
Gabriella Gonzalez

Reputation: 35089

The pipes package lets you separate data generation from data consumption. You write your program as a producer of log Strings, and then you choose at runtime how to consume those Strings.

For example, let's say you have the following simple program:

import Control.Proxy

program :: (Proxy p) => () -> Producer p String IO r
program () = runIdentityP $ forever $ do
    lift $ putStrLn "Enter a string:"
    str <- lift getLine
    respond $ "User entered: " ++ str

The type says that it is a Producer of Strings (in this case, log strings), that can also invoke IO commands using lift. So for ordinary IO commands that don't involve logging you just use lift. Whenever you need to log something you use the respond command, which produces a String.

This creates an abstract producer of strings that doesn't specify how they are consumed. This lets us defer the choice of how to use the produced Strings later. Whenever we call the respond command, we abstractly hand off our log string to some as-yet-unspecified downstream stage that will handle it for us.

Now let's write a program that takes a Bool flag from the command line that specifies whether or not to write the output to stdout or to the file "my.log".

import System.IO
import Options.Applicative

options :: Parser Bool
options = switch (long "file")

main = do
    useFile <- execParser $ info (helper <*> options) fullDesc
    if useFile
        then do
            withFile "my.log" WriteMode $ \h ->
                runProxy $ program >-> hPutStrLnD h
        else runProxy $ program >-> putStrLnD

If the user does not supply any flag on the command line, useFile defaults to False, indicating that we want to log to stdout. If the user supplies the --file flag, useFile defaults to True, indicating that we want to log to "my.log".

Now, check out the two if branches. The first branch feeds the Strings that program produces into the file using the (>->) operator. Think of hPutStrLnD as something that takes a Handle and creates an abstract consumer of Strings that writes each string to that handle. when we connect program to hPutStrLnD, we send every log string to the file:

$ ./log
Enter a string:
Test<Enter>
User entered: Test
Enter a string:
Apple<Enter>
User entered: Apple
^C
$

The second if branch feeds the Strings to putStrLnD, which just writes them to stdout:

$ ./log --file
Enter a string:
Test<Enter>
Enter a string:
Apple<Enter>
^C
$ cat my.log
User entered: Test
User entered: Apple
$

Despite decoupling generation from production, pipes still streams everything immediately, so the output stages (i.e. hPutStrLnD and putStrLnD) will write out the Strings immediately after they are generated and won't buffer the Strings or wait until the program finishes.

Notice that by decoupling the String generation from the actual logging action, we gain the ability to inject the String consumer dependency at the last moment.

To learn more about how to use pipes, I recommend you read the pipes tutorial.

Upvotes: 6

hzap
hzap

Reputation: 1193

If you only have a fixed set of endpoints, this is a possible design:

data Logger = Logger [LoggingEndpoint]
data LoggingEndpoint = ConsoleEndpoint ... | FileEndpoint ... | ScribeEndpoint ... | ...

Then it should be straightforward to implement this:

logWarn :: Logger -> String -> IO ()
logWarn (Logger endpoints) message = forM_ logToEndpoint endpoints
  where
    logToEndpoint :: LoggingEndpoint -> IO ()
    logToEndpoint (ConsoleEndpoint ...) = ...
    logToEndpoint (FileEndpoint ...) = ...

If you want expandable set of endpoints, there are some ways to do it, the simplest of which is to define LoggingEndpoint as a record of functions, basically a vtable:

data LoggingEndpoint = LoggingEndpoint { 
    logMessage :: String -> IO (),
    ... other methods as needed ...
}

consoleEndpoint :: Settings -> LoggingEndpoint
consoleEndpoint (...) = LoggingEndpoint { 
    logMessage = \message -> ...
    ... etc ...
}

Then, logToEndpoint simply becomes

logToEndpoint ep = logMessage ep message

Upvotes: 5

Adam Bergmark
Adam Bergmark

Reputation: 7536

In Real World Haskell they describe how to use a Writer monad to do just this, much better than I can explain it: http://book.realworldhaskell.org/read/programming-with-monads.html#id649416

Also see the chapter on monad transformers: http://book.realworldhaskell.org/read/monad-transformers.html

Upvotes: 2

Related Questions