# Haskell Logging

> Add structured logging to a Haskell application using `log-effectful`. Covers severity levels (trace/info/attention — no logError), structured payloads, scoped context via `localData`, composition in `main`, and integration with domain errors. Use when adding logs, designing what to log, configuring log backends (stdout/JSON/ElasticSearch), or explaining why there's no `logError`.

- Skill: `ivelten/haskell-logging` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ivelten/haskell-logging`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ivelten/haskell-logging/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: ivelten (https://skillmd.com/u/ivelten)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ivelten/haskell-logging

---


# Logging with `log-effectful`

Use [`log-effectful`](https://hackage.haskell.org/package/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

```cabal
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:

```haskell
{-# 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`

```haskell
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:

1. A **component name** (`"my-project"`) — appears in every log line from this scope. Use it to distinguish subsystems in multi-service projects.
2. A **logger backend** — built-in options include `withSimpleStdOutLogger` (human-readable), `withJsonStdOutLogger` (JSON Lines for log aggregators), and `withElasticSearchLogger`. Backends are monoidal: `logger1 <> logger2` sends to both.
3. A **minimum level** — anything below is discarded entirely. `defaultLogLevel` is `LogInfo`; use `LogTrace` in 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:

```haskell
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:

```haskell
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

- `effectful` setup and effect composition: `haskell-effectful`.
- Designing error types with `ToJSON` for structured logging: `haskell-domain-errors`.

