Logging with log-effectful
Use log-effectful (with log-base as the underlying primitives). It's maintained alongside effectful and integrates as a native effect — no MonadLogger adapters, no instance gymnastics.
Other logging libraries (Katip, Blammo, monad-logger, co-log) are designed for mtl-style stacks; using them with effectful means writing or depending on adapter packages, which is friction this skill deliberately avoids.
Dependencies
build-depends:
, log-effectful ^>=1.0
, log-base ^>=0.12
The pattern
Logging is just another effect in your effect list. Add Log :> es wherever you want to emit logs:
{-# LANGUAGE OverloadedStrings #-}
import Effectful
import Effectful.Log
import Log (logAttention_, logInfo, logTrace_)
import Data.Aeson (object, (.=))
registerUser
:: ( UserStore :> es
, Log :> es
, Error RegistrationError :> es
)
=> Email -> Eff es User
registerUser email = do
logTrace_ "Starting registration"
existing <- getUser (emailToUserId email)
case existing of
Just _ -> do
logAttention_ ("Duplicate registration attempt: " <> coerce email)
throwError (EmailAlreadyExists email)
Nothing -> do
let user = newUser email
saveUser user
logInfo "User registered" $ object
[ "user_id" .= userId user
, "email" .= userEmail user
]
pure user
Two important conventions:
- Functions with trailing
_(logInfo_,logTrace_,logAttention_) take only a message string. No structured payload. - Functions without
_(logInfo,logAttention) take a message and a JSON-serializable payload. Use these whenever there's structured context worth indexing on (user IDs, request IDs, error details).
Severity levels
log-base keeps the level set small and opinionated:
logTrace_/logTrace— verbose diagnostic output. Normally disabled in production.logInfo_/logInfo— expected events: user actions, request lifecycle, state changes.logAttention_/logAttention— something noteworthy but not necessarily a failure: degraded mode, fallback, retry, business rule violation.
Notably absent: there's no logError or logFatal. The library's philosophy is logs describe, exceptions control flow. Failures that affect program flow propagate via the Error effect or as exceptions; logging is purely for observability.
Don't simulate logError by piling severity levels — if something is a genuine error, model it as a domain error (see haskell-domain-errors) and let it flow through the Error effect. The log line at the boundary catches it. If you're tempted to write logError, you almost certainly want throwError instead, with a logAttention at the boundary that catches and reports it.
Composing in main
import Effectful.Log
import Log.Backend.StandardOutput (withSimpleStdOutLogger)
main :: IO ()
main = do
withSimpleStdOutLogger $ \logger -> do
pool <- createPool dbConfig
result <- runEff
. runReader pool
. runLog "my-project" logger defaultLogLevel
. runErrorNoCallStack @RegistrationError
. runUserStorePostgres
$ registerUser someEmail
print result
runLog takes three arguments:
- A component name (
"my-project") — appears in every log line from this scope. Use it to distinguish subsystems in multi-service projects. - A logger backend — built-in options include
withSimpleStdOutLogger(human-readable),withJsonStdOutLogger(JSON Lines for log aggregators), andwithElasticSearchLogger. Backends are monoidal:logger1 <> logger2sends to both. - A minimum level — anything below is discarded entirely.
defaultLogLevelisLogInfo; useLogTracein development for verbose output.
Scoped context with localData
This is where structured logging earns its keep. Rather than passing identifiers through every function signature just to include them in log lines, use localData to attach context to a scope:
import Effectful.Log (localData)
handleRequest :: (Log :> es, ...) => RequestId -> UserId -> Eff es Response
handleRequest reqId uid = localData ["request_id" .= reqId, "user_id" .= uid] $ do
logInfo_ "Processing request"
result <- processBusinessLogic
logInfo_ "Request complete"
pure result
Every logInfo_, logTrace_, etc. inside the localData block automatically picks up request_id and user_id in the structured payload.
Use localData at every boundary that introduces relevant context:
- Request handler: attach
request_id, authenticated user. - Background job: attach
job_id,job_type. - Batch processing loop: attach
batch_id,item_index.
Integration with domain errors
Because domain errors already have ToJSON instances (see haskell-domain-errors), logging them is direct:
processOrder :: (Log :> es, Error OrderError :> es) => Order -> Eff es Receipt
processOrder order = do
result <- tryError (chargePayment order)
case result of
Right receipt -> pure receipt
Left (_, err) -> do
logAttention "Payment failed" (toJSON err)
throwError err -- re-throw; logging is observation, not handling
The structured payload preserves every field of the error constructor — no information lost between the domain layer and the log aggregator.
When NOT to reach for log-effectful
For small scripts (under ~300 lines, single-purpose tools), putStrLn or Text.IO.putStrLn is fine. log-effectful earns its complexity when there are multiple components emitting logs, structured output is consumed by aggregation tooling, or log levels need filtering. Don't impose the ceremony on a script that logs three lines.
Related
effectfulsetup and effect composition:haskell-effectful.- Designing error types with
ToJSONfor structured logging:haskell-domain-errors.