One way to observe our software’s behavior is to write programs that emit a message each time some noteworthy event occurs. Appropriate strategies for logging vary with the needs of a particular application, but our example here represents one possible design. This example also shows how to throw, catch, and log an exception.
We will be using the safe-exceptions and directory libraries in this example.
import Control.Exception.Safe (displayException, tryAny)
import Data.Foldable (fold)
import System.Directory (getPermissions, writable)
import System.Environment (getEnv)
import System.IO (hPutStr, stdout, stderr)Often we want to distinguish between different categories of noteworthy event. In this example we have two: if something has gone wrong, we’ll call that an error, and everything else we’ll call info.
data Level = Info | ErrorAn event will consist of a Level plus some message String that we want to see in the log output. We might imagine expanding this type to include additional fields such as a timestamp or some application-specific contextual information.
data Event = Event Level StringA basic log handle need only consist of a function that specifies what action to take to record an event.
data Log = Log { record :: Event -> IO () }This is a log that writes each message to one of the process’s standard output streams. Info messages go to stdout and error messages go to stderr.
consoleLog = Log $ \(Event level message) ->
hPutStr (standardStream level) (message <> "\n")
standardStream Info = stdout
standardStream Error = stderrThis is another log that writes each message to a file. The path parameter is a function which specifies the output file path for each log level.
fileLog path = Log $ \(Event level message) ->
appendFile (path level) (message <> "\n")Sometimes we want to disable logging altogether. In that case, we would use this log whose record function does nothing.
nullLog = Log (\_ -> return ())We can write functions to define one log as a variant of another. Here the formattedLog function returns a log which prefixes the message string with additional information, then writes the modified message to the original log.
formattedLog topic log = Log $ \event ->
record log (formatEvent topic event)
formatEvent topic (Event level msg) = Event level msg'
where
msg' = paren (topic ! levelString level) ! msgWe have written a few small functions to assist formatEvent. The paren function encloses a string in parentheses, and the (!) function joins two strings with a space in between.
paren x = "(" <> x <> ")"
x ! y = x <> " " <> y
levelString Info = "info"
levelString Error = "error"We can also combine multiple logs together. This function constructs a logger that records an event by writing it to each of two other logs.
multiLog log1 log2 = Log $ \event ->
do
record log1 event
record log2 eventThe multiLog function is associative, and it forms a monoid with nullLog as its identity. This allows us to conveniently fold a collection of logs into a single log. We will make use of fold later in this program.
In some circumstances, a program may be able to recover from a partial failure. When a program proceeds after an exception instead of crashing, we usually want to write an event to the log to ensure that the problem does not go unnoticed.
instance Semigroup Log where (<>) = multiLog
instance Monoid Log where mempty = nullLogThis recoverFromException function converts an IO a action to an IO (Maybe a) action.
recoverFromException log action =
do
result <- tryAny action
case result ofIf action throws an exception, we write an error event to the log and return Nothing.
Left e ->
do
record log (Event Error (displayException e))
return NothingIf action returns a result, then we return Just that result.
Right x ->
return (Just x)Our demo program involves two logs.
main =
doThe first, which we call the “boot” log, is used only while the program is first starting up; it provides a place to record any errors that may occur while setting up the more complicated “app” log.
There are a number of ways that logging to a file can go awry – for example, if filesystem permissions disallow writing to the file.
let bootLog = formattedLog "Boot" consoleLog
record bootLog (Event Info "Starting")initFileLog (defined below) produces a Log. By using recoverFromException, we allow our program to recover if it fails. The type of the fileLog variable is Maybe Log.
fileLog <- recoverFromException bootLog initFileLogNow we can assemble the application’s full logging strategy: Each event will be recorded to the console, as well as to the log files (if possible). Both the (<>) and fold functions take advantage of the Log monoid.
let appLog = formattedLog "App" consoleLog <> fold fileLogHere we record one event, and the demonstration ends (though we ask you to imagine that the rest of an application continues from here).
record appLog (Event Info "Application started")
-- ...Finally, we must return to the question of setting up the file log.
initFileLog =
doLet us say that we expect our program to be run with two environment variables, INFO and ERROR, specifying the desired log file paths.
infoPath <- envLogPath "INFO"
errorPath <- envLogPath "ERROR"
let
path Info = infoPath
path Error = errorPath
return (fileLog path)
envLogPath varName =
doThe getEnv function obtains the value of an environment variable, throwing an exception if no definition for this variable is present in the process environment.
path <- getEnv varName
assertWritable path
return pathThis assertWritable action determines whether there exists a file at the given path, and whether we will be able to write to that file.
assertWritable path =
doIf the file does not exist, getPermissions throws an exception.
permissions <- getPermissions pathIf the file is not writable, we throw an exception using fail.
case writable permissions of
True -> return ()
False -> fail ("Log path" ! path ! "is not writable")If we run the example without setting up the log files and environment variables, we see an error in the console log. The message comes from the exception thrown by getEnv.
$ runhaskell logging.hs
(Boot info) Starting
(Boot error) INFO: getEnv: does not exist (no environment variable)
(App info) Application startedTo enable file logging for this program, we have to create the two files and run the program in a context with the INFO and ERROR variables.
This time, in addition to the console output, we can also see that the final event has been recorded to the info log file.
$ touch /tmp/info.txt /tmp/error.txt
$ env INFO=/tmp/info.txt ERROR=/tmp/error.txt runhaskell logging.hs
(Boot info) Starting
(App info) Application started
$ cat /tmp/info.txt
Application startedFinally, we will remove write permissions from one of the log files and run the program once more.
This time the error message we see comes from the exception that we induced using fail.
$ chmod -w /tmp/info.txt
$ env INFO=/tmp/info.txt ERROR=/tmp/error.txt runhaskell logging.hs
(Boot info) Starting
(Boot error) user error (Log path /tmp/info.txt is not writable)
(App info) Application startedNext: Mutable references

