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 | Error
An 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 String
A 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.
= Log $ \(Event level message) ->
consoleLog <> "\n")
hPutStr (standardStream level) (message
Info = stdout
standardStream Error = stderr standardStream
This 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.
= Log $ \(Event level message) ->
fileLog path 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.
= Log (\_ -> return ()) nullLog
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.
log = Log $ \event ->
formattedLog topic log (formatEvent topic event)
record
Event level msg) = Event level msg'
formatEvent topic (where
= paren (topic ! levelString level) ! msg msg'
We 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.
= "(" <> x <> ")"
paren x
! y = x <> " " <> y
x
Info = "info"
levelString Error = "error" levelString
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.
= Log $ \event ->
multiLog log1 log2 do
record log1 event record log2 event
The 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 = nullLog
This recoverFromException
function converts an IO a
action to an IO (Maybe a)
action.
log action =
recoverFromException do
<- tryAny action
result
case result of
If action
throws an exception, we write an error event to the log and return Nothing
.
Left e ->
do
log (Event Error (displayException e))
record return Nothing
If action
returns a result, then we return Just
that result.
Right x ->
return (Just x)
Our demo program involves two logs.
=
main do
The 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
Event Info "Starting") record bootLog (
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
.
<- recoverFromException bootLog initFileLog fileLog
Now 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 fileLog
Here we record one event, and the demonstration ends (though we ask you to imagine that the rest of an application continues from here).
Event Info "Application started")
record appLog (
-- ...
Finally, we must return to the question of setting up the file log.
=
initFileLog do
Let us say that we expect our program to be run with two environment variables, INFO
and ERROR
, specifying the desired log file paths.
<- envLogPath "INFO"
infoPath <- envLogPath "ERROR"
errorPath
let
Info = infoPath
path Error = errorPath
path
return (fileLog path)
=
envLogPath varName do
The getEnv
function obtains the value of an environment variable, throwing an exception if no definition for this variable is present in the process environment.
<- getEnv varName
path
assertWritable pathreturn path
This 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 do
If the file does not exist, getPermissions throws an exception.
<- getPermissions path permissions
If 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 started
To 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 started
Finally, 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 started
Next: Mutable references