Home > Mobile >  Efficient way of switching between different implementations of a logging function in Haskell?
Efficient way of switching between different implementations of a logging function in Haskell?

Time:04-08

My Haskell webapp starts with a particular logging configuration. Once the webserver starts, the logging configuration cannot be changed. Here's what the logging config controls:

  • logging output format: text logs (mostly used for local dev) vs JSON logs (mostly used for production env)
  • whether to compute and emit aggregate metrics for each req/res, like number of SQL statements, number of downstream HTTP requests made, time spent in SQL, time spent in API calls, etc.
  • log verbosity: in development env the actual SQL statements executed, detailed req/res logs of API calls made to downstream systems, etc. are logged. In production environment, these are skipped.

The naive way of writing a logging function would check, for every log statement, the logging config to determine (a) whether to output the log at all, (b) which format to use - text vs json, and (c) whether to update the aggregate metrics

Somehow this seems very inefficient to me.

Is it possible to efficiently do the following:

  • upon application startup, depending upon the logging config, choose one of several implementations
  • somehow inject this implementation into the application's monad (which might be an instance of MonadLogger), such that the compiler is still able to do any optimizations that it would have done otherwise.

CodePudding user response:

Sure. Compare the following two functions:

convertData :: Loggable a => Bool -> a -> ByteString
convertData b x = case b of
    False -> viaString x
    True -> viaJSON x

convertCode :: Loggable a => Bool -> a -> ByteString
convertCode b = case b of
    False -> \x -> viaString x
    True -> \x -> viaJSON x

In GHC, if you write convertData False, then it will check and re-check this Bool on each call; while if you write convertCode False, the Bool is checked once (and a function that has already "internalized" that Bool is returned).

This generalizes to your other requirements as well. Simply write your logging function as a function which takes a logging config, does all its case statements and other checks immediately, then in each resulting case returns a specialized logging function that accepts the remaining arguments.

(Why the names convertData and convertCode? Because they are two versions of a convert function; one stores its config explicitly as closed-over data, while the other stores its config implicitly in the program counter.)

CodePudding user response:

The basic approach you propose, where you do all the checks in the logging action has essentially this form:

log :: IO ()
log = do
   b <- isLogEnabled
   if b
   then actuallyLog
   else return ()  -- do nothing

main :: IO ()
main = do
   let config = Config { logAction = log , ... }
   runServer config

You can, and should, move the check isLogEnabled in the main action:

actuallyLog :: IO ()
actuallyLog = do ...

doNothing :: IO ()
doNothing = return ()

main :: IO ()
main = do
   b <- isLogEnabled  -- once and for all
   let log :: IO ()
       log = if b then actuallyLog else doNothing
   let config = Config { logAction = log , ... }
   runServer config

The if inside log will also be evaluated only once, effectively defining log = actuallyLog or log = doNothing with no overhead (after the first use).

  • Related