Skip to content

Logging

Logging in Effect is a first-class effect, not a side-effecting console.log. Effect.logInfo, Effect.logError, and friends produce a log record that carries the message, the level, the current fiber, a timestamp, any annotations in scope, and any open log spans. What actually happens to that record — pretty console output, one JSON line per entry, a file, or a remote collector — is decided by the logger you install with a Layer. Your business code stays the same regardless of destination.

import { Effect } from "effect"
// Logging functions are ordinary effects: yield them inside Effect.gen.
const checkout = Effect.gen(function*() {
yield* Effect.logDebug("loading checkout state")
yield* Effect.logInfo("validating cart")
yield* Effect.logWarning("inventory is low for one line item")
yield* Effect.logError("payment provider timeout")
}).pipe(
// Attach structured metadata to every log line emitted by this effect…
Effect.annotateLogs({ service: "checkout-api", route: "POST /checkout" }),
// …and measure how long the whole flow takes (adds checkout=<N>ms).
Effect.withLogSpan("checkout")
)

Because logging goes through the runtime, every record automatically picks up the fiber it ran on, the annotations and spans active at that point, and — when a tracer is installed — the current trace span. You never thread a “logger instance” through your call stack.

Effect has six severities, ordered from most to least severe: Fatal, Error, Warn, Info, Debug, Trace. Each has a dedicated helper, and Effect.log defaults to Info. LogLevel also defines two sentinel levels, All and None, used for filtering thresholds rather than for emitting messages.

import { Effect } from "effect"
const program = Effect.gen(function*() {
yield* Effect.log("default level is Info")
yield* Effect.logFatal("the process cannot continue")
yield* Effect.logError("an operation failed")
yield* Effect.logWarning("something looks off")
yield* Effect.logInfo("normal progress")
yield* Effect.logDebug("detailed diagnostics")
yield* Effect.logTrace("very fine-grained tracing")
})

Each helper accepts one or more values, so you can attach a structured payload directly to the message:

import { Effect } from "effect"
const handle = Effect.fn("handle")(function*(orderId: string) {
// The object is rendered as structured metadata, not stringified into text.
yield* Effect.logInfo("starting checkout", { orderId, attempt: 1 })
})

A log record is only emitted if its severity meets the current minimum log level, controlled by the References.MinimumLogLevel reference (default Info, so Debug and Trace are dropped). Set it for the whole application with a layer, or for a single effect with Effect.provideService.

import { Effect, Layer, References } from "effect"
// Application-wide: only Warn and above survive.
const WarnAndAbove = Layer.succeed(References.MinimumLogLevel, "Warn")
// Per-effect: enable Debug logging for just this subtree.
const verboseSection = Effect.gen(function*() {
yield* Effect.logDebug("this now shows")
}).pipe(
Effect.provideService(References.MinimumLogLevel, "Debug")
)

Annotations attach key/value context to every log record produced by an effect, and they nest: inner annotations merge with outer ones. Log spans record elapsed time, so the duration is appended to each log line emitted while the span is open.

import { Effect } from "effect"
const program = Effect.gen(function*() {
yield* Effect.logInfo("request received")
yield* Effect.sleep("75 millis")
yield* Effect.logInfo("request handled")
}).pipe(
// Every line carries requestId and region…
Effect.annotateLogs({ requestId: "req_42", region: "us-east-1" }),
// …and is tagged with the elapsed time, e.g. http-request=78ms.
Effect.withLogSpan("http-request")
)

This is far more useful than interpolating context into message strings: a structured logger keeps requestId as a real field you can filter and search on downstream.

Effect ships several ready-made loggers. Install them with Logger.layer, which replaces the default set (pass { mergeWithExisting: true } to add to it instead).

import { Logger } from "effect"
// One JSON object per line — ideal for log collectors.
const Json = Logger.layer([Logger.consoleJson])
// Human-readable, colorized output for local development.
const Pretty = Logger.layer([Logger.consolePretty()])
// Structured fields rendered for the console.
const Structured = Logger.layer([Logger.consoleStructured])
// logfmt key=value output.
const LogFmt = Logger.layer([Logger.consoleLogFmt])

The available console loggers are Logger.defaultLogger (the built-in pretty format), Logger.consolePretty, Logger.consoleStructured, Logger.consoleLogFmt, and Logger.consoleJson. Each is built from a formatter (Logger.formatSimple, Logger.formatStructured, Logger.formatLogFmt, Logger.formatJson) combined with an output sink.

Logger.toFile turns a string formatter into a logger that batches output and writes it to a path. It needs a FileSystem, so provide a platform layer.

import { NodeFileSystem } from "@effect/platform-node"
import { Layer, Logger } from "effect"
const FileLogger = Logger.layer([
// Write one simple-formatted line per entry to app.log.
Logger.toFile(Logger.formatSimple, "app.log")
]).pipe(
Layer.provide(NodeFileSystem.layer)
)

For app-specific routing — shipping logs to an external service, rotating files, or coalescing writes — build your own logger. Logger.batched wraps any formatter and flushes accumulated records on a time window, which keeps high-frequency logging from overwhelming a downstream sink.

import { Effect, Logger } from "effect"
// A logger that buffers structured records and flushes them once per second.
const appLogger = Effect.gen(function*() {
yield* Effect.logDebug("initializing app logger")
return yield* Logger.batched(Logger.formatStructured, {
window: "1 second",
flush: Effect.fn(function*(batch) {
// In a real implementation, POST the batch to a logging service.
yield* Effect.sync(() => console.log(`flushing ${batch.length} entries`))
})
})
})
// Logger.layer accepts effects that produce loggers, not just plain loggers.
export const AppLoggerLayer = Logger.layer([appLogger])

To build a logger entirely from scratch, Logger.make gives you the raw record (message, logLevel, cause, date, fiber, and so on) so you can format and route it however you like.

Because Logger.layer accepts effects, you can decide which logger to install based on configuration. Layer.unwrap lets a layer be produced by an effect.

import { Config, Effect, Layer, Logger } from "effect"
export const LoggerLayer = Layer.unwrap(
Effect.gen(function*() {
const env = yield* Config.string("NODE_ENV").pipe(
Config.withDefault("development")
)
// JSON in production for collectors, pretty output locally.
return env === "production"
? Logger.layer([Logger.consoleJson])
: Logger.layer([Logger.consolePretty()])
})
)