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)
)

See File System for how to obtain a FileSystem layer on each platform.

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()])
})
)

Every log helper lives on the Effect namespace and returns an Effect<void>. The level helpers below are all thin wrappers over Effect.logWithLevel.

Logs one or more values at the default Info level.

import { Effect } from "effect"
Effect.log("Result:", 4)
// => timestamp=... level=INFO fiber=#0 message="Result: 4"

Logs at the Fatal level — the system is unusable and needs immediate attention.

import { Effect } from "effect"
Effect.logFatal("event loop is wedged")
// => timestamp=... level=FATAL fiber=#0 message="event loop is wedged"

Logs at the Error level for failures that should be investigated.

import { Effect } from "effect"
Effect.logError("payment provider timeout")
// => timestamp=... level=ERROR fiber=#0 message="payment provider timeout"

Logs at the Warn level for conditions that may indicate a problem.

import { Effect } from "effect"
Effect.logWarning("inventory is low")
// => timestamp=... level=WARN fiber=#0 message="inventory is low"

Logs at the Info level for normal-operation messages.

import { Effect } from "effect"
Effect.logInfo("user logged in", { userId: 123 })
// => timestamp=... level=INFO fiber=#0 message="user logged in" userId=123

Logs at the Debug level. Suppressed by default, since the default minimum log level is Info.

import { Effect } from "effect"
Effect.logDebug("cache miss for key user:123")
// => (dropped unless MinimumLogLevel <= Debug)

Logs at the Trace level — the most verbose. Also suppressed by default.

import { Effect } from "effect"
Effect.logTrace("entering retry loop, attempt 1")
// => (dropped unless MinimumLogLevel <= Trace)

Logs at a level chosen at runtime. Pass a LogLevel.Severity to get a logging function; calling it with no argument is exactly Effect.log.

import { Effect, LogLevel } from "effect"
const emit = (level: LogLevel.Severity, msg: string) =>
Effect.logWithLevel(level)(msg)
emit("Warn", "disk almost full")
// => timestamp=... level=WARN fiber=#0 message="disk almost full"

Attaches key/value context to every log record produced by the effect. Supports both a single key, value pair and a Record of annotations; annotations nest and merge with outer ones.

import { Effect } from "effect"
const work = Effect.logInfo("processing")
// Record form
work.pipe(Effect.annotateLogs({ userId: "u1", op: "data-processing" }))
// => ... message="processing" userId=u1 op=data-processing
// key/value form
work.pipe(Effect.annotateLogs("requestId", "req-456"))
// => ... message="processing" requestId=req-456

Adds annotations to the current Scope rather than to a single effect. The annotations apply to every log line until the scope closes, then the previous annotations are restored. Requires a Scope.

import { Effect } from "effect"
Effect.scoped(
Effect.gen(function*() {
yield* Effect.log("before")
yield* Effect.annotateLogsScoped({ requestId: "req-123" })
yield* Effect.log("inside scope") // => ... requestId=req-123
})
)

Opens a named timing span. Each log line emitted while the span is open gains a <label>=<ms>ms field measuring the elapsed time since the span started. Spans nest, so inner log lines carry both their own and any enclosing spans.

import { Effect } from "effect"
Effect.gen(function*() {
yield* Effect.sleep("50 millis")
yield* Effect.log("done")
}).pipe(Effect.withLogSpan("task"))
// => ... message="done" task=50ms

Overrides the logger for one region of the program, adding the given logger to the active set for the wrapped effect only.

import { Effect, Logger } from "effect"
const audit = Logger.make((options) =>
console.log(`[AUDIT] ${options.message}`)
)
Effect.logInfo("sensitive action").pipe(Effect.withLogger(audit))
// => [AUDIT] sensitive action

The LogLevel module defines the severity model and the comparison helpers used for filtering. Severities are ordered Fatal (most severe) down to Trace (least severe), with All and None as sentinel bounds.

The full union of level strings, including the All and None sentinels.

import type { LogLevel } from "effect"
const level: LogLevel.LogLevel = "Error"
// type = "All" | "Fatal" | "Error" | "Warn" | "Info" | "Debug" | "Trace" | "None"

The subset of concrete, emittable severities — excludes All and None. Use it when only real message levels are valid (e.g. Effect.logWithLevel).

import type { LogLevel } from "effect"
const severity: LogLevel.Severity = "Warn"
// type = "Fatal" | "Error" | "Warn" | "Info" | "Debug" | "Trace"

The runtime array of every level, in severity order from All through the concrete levels to None.

import { LogLevel } from "effect"
console.log(LogLevel.values)
// => ["All", "Fatal", "Error", "Warn", "Info", "Debug", "Trace", "None"]

An Order<LogLevel> over severity. Useful with Array.sort and the Order combinators.

import { LogLevel } from "effect"
console.log(LogLevel.Order("Error", "Info")) // => 1 (Error more severe)
console.log(LogLevel.Order("Debug", "Error")) // => -1
console.log(LogLevel.Order("Info", "Info")) // => 0

Strict (===) equivalence between levels; each level only matches itself.

import { LogLevel } from "effect"
console.log(LogLevel.Equivalence("Error", "Error")) // => true
console.log(LogLevel.Equivalence("Error", "Info")) // => false

Projects a level to its internal numeric sort key (Trace0, Debug10000, … Fatal50000, with All/None as the safe-integer bounds). Treat these as sort keys, not external severity numbers.

import { LogLevel } from "effect"
console.log(LogLevel.getOrdinal("Info")) // => 20000
console.log(LogLevel.getOrdinal("Error")) // => 40000

true when the first level is strictly more severe than the second. Curried and data-first forms are both available.

import { LogLevel } from "effect"
console.log(LogLevel.isGreaterThan("Error", "Info")) // => true
console.log(LogLevel.isGreaterThan("Debug", "Error")) // => false

true when the first level meets or exceeds the second — the core of minimum-level filtering.

import { LogLevel } from "effect"
console.log(LogLevel.isGreaterThanOrEqualTo("Error", "Info")) // => true
console.log(LogLevel.isGreaterThanOrEqualTo("Debug", "Info")) // => false

true when the first level is strictly less severe (more verbose) than the second.

import { LogLevel } from "effect"
console.log(LogLevel.isLessThan("Trace", "Info")) // => true
console.log(LogLevel.isLessThan("Error", "Info")) // => false

true when the first level is at or below the second — useful for maximum-level (suppress-verbose) filtering.

import { LogLevel } from "effect"
console.log(LogLevel.isLessThanOrEqualTo("Debug", "Info")) // => true
console.log(LogLevel.isLessThanOrEqualTo("Error", "Info")) // => false

An effect that reports whether a level would currently be emitted, by reading the fiber’s References.MinimumLogLevel.

import { Effect, LogLevel, References } from "effect"
Effect.gen(function*() {
const debug = yield* LogLevel.isEnabled("Debug") // => false at default Info
const error = yield* LogLevel.isEnabled("Error") // => true
console.log({ debug, error })
}).pipe(Effect.provideService(References.MinimumLogLevel, "Info"))

A Logger<Message, Output> receives each runtime log event (as Options) and turns it into an output: a string, a structured object, a console write, a file write, or a trace span event.

The logger interface. Its single log(options) method maps a log event to Output. Loggers are plain values; install them with Logger.layer.

import type { Logger } from "effect"
// A logger that consumes any message and produces a string.
declare const fmt: Logger.Logger<unknown, string>

The per-event record passed to a logger: message, logLevel, cause, fiber, and date. Read annotations/spans off the fiber via fiber.getRef(References.CurrentLogAnnotations) / CurrentLogSpans.

import { Logger } from "effect"
const logger = Logger.make((options /* Logger.Options<unknown> */) => {
console.log(options.logLevel, options.date.toISOString(), options.message)
})
// => Info 2025-01-03T14:22:47.570Z hello

Type guard for logger values.

import { Logger } from "effect"
const l = Logger.make(() => {})
console.log(Logger.isLogger(l)) // => true
console.log(Logger.isLogger("nope")) // => false

Creates a logger from a function over Options. Return whatever output type you need; return undefined (or simply don’t write) to skip an event.

import { Logger } from "effect"
const textLogger = Logger.make((options) =>
`${options.date.toISOString()} [${options.logLevel}] ${options.message}`
)
// textLogger.log(...) => "2025-01-03T... [Info] hello"

Transforms a logger’s output without rewriting its logic — e.g. wrap a JSON string in an envelope.

import { Logger } from "effect"
const enveloped = Logger.map(
Logger.formatJson,
(json) => `{"service":"api","entry":${json}}`
)
// enveloped.log(...) => {"service":"api","entry":{"message":["hi"],...}}

The runtime’s built-in logger (pretty console output). Installed by default alongside tracerLogger.

import { Effect, Logger } from "effect"
Effect.log("hello").pipe(
Effect.provide(Logger.layer([Logger.defaultLogger]))
)
// => [09:37:17.579] INFO (#0): hello

Formatter producing a single-line string with double-quoted values. Output only — wrap it with a sink to write it.

import { Logger } from "effect"
Logger.formatSimple.log
// => timestamp=2025-01-03T14:22:47.570Z level=Info fiber=#1 message=hello

Formatter producing logfmt key=value output (JSON-stringified values). Output only.

import { Logger } from "effect"
Logger.formatLogFmt.log
// => timestamp=2025-01-03T14:22:47.570Z level=Info fiber=#1 message=hello

Formatter producing a structured object with message, level, timestamp, cause, annotations, spans, and fiberId fields. Output only.

import { Logger } from "effect"
Logger.formatStructured.log
// => { message: "hello", level: "INFO", timestamp: "2025-01-03T...",
// cause: undefined, annotations: { key: "value" },
// spans: { label: 0 }, fiberId: "#1" }

formatStructured serialized to a single JSON line (Logger.map over the structured formatter). Output only.

import { Logger } from "effect"
Logger.formatJson.log
// => {"message":["hello"],"level":"INFO","timestamp":"2025-01-03T...",
// "annotations":{"key":"value"},"spans":{"label":0},"fiberId":"#1"}

Wraps a formatter so its output is written with console.log.

import { Logger } from "effect"
const consoleSimple = Logger.withConsoleLog(Logger.formatSimple)
// installing this writes each formatted line via console.log

Like withConsoleLog, but writes with console.error (stderr).

import { Logger } from "effect"
const errToStderr = Logger.withConsoleError(Logger.formatLogFmt)
// each line goes to console.error

Wraps a formatter so each record is written with the console method matching its level: Debugconsole.debug, Infoconsole.info, Traceconsole.trace, Warnconsole.warn, Error/Fatalconsole.error, otherwise console.log.

import { Logger } from "effect"
const leveled = Logger.withLeveledConsole(Logger.formatSimple)
// Effect.logWarning(...) => console.warn(...); Effect.logError(...) => console.error(...)

Pretty, optionally colorized console logger. Takes options for colors, stderr, mode ("browser" | "tty" | "auto"), and formatDate.

import { Logger } from "effect"
Logger.consolePretty({ colors: true })
// => [09:37:17.579] INFO (#1) label=0ms: hello
// key: value

logfmt formatter routed to console.log (withConsoleLog(formatLogFmt)).

import { Logger } from "effect"
Logger.consoleLogFmt
// console.log => timestamp=2025-01-03T... level=INFO fiber=#1 message=info

Structured formatter routed to console.log (withConsoleLog(formatStructured)).

import { Logger } from "effect"
Logger.consoleStructured
// console.log => { message: ["info","message"], level: "INFO", ... }

JSON formatter routed to console.log (withConsoleLog(formatJson)). The common choice for containerized/aggregated logs.

import { Logger } from "effect"
Logger.consoleJson
// console.log => {"message":["hello"],"level":"INFO","timestamp":"...","fiberId":"#1"}

Records each log message as an event on the current trace span. Included in the default logger set, so logs automatically appear on spans unless you replace the loggers without merging. See Tracing.

import { Effect, Logger } from "effect"
Effect.log("step 1").pipe(
Effect.withLogSpan("workflow"),
Effect.provide(Logger.layer([Logger.tracerLogger, Logger.consoleJson]))
)
// the "step 1" log becomes a span event on "workflow"

Wraps a formatter into a scoped logger that buffers output and periodically hands the buffer to flush. Remaining entries are flushed when the scope closes. Returns an Effect (it forks a background flusher), so provide it through Logger.layer([...]).

import { Duration, Effect, Logger } from "effect"
const batched = Logger.batched(Logger.formatJson, {
window: Duration.seconds(5),
flush: (lines) =>
Effect.sync(() => console.log(`flushing ${lines.length} entries`))
})
// => flushing 3 entries (every 5s, plus once on scope close)

Turns a string logger into a scoped logger that batches output and writes it to a file path. Requires FileSystem and Scope; pair it with a string formatter (formatJson, formatLogFmt, formatSimple). Options: flag, mode, batchWindow.

import { NodeFileSystem } from "@effect/platform-node"
import { Duration, Layer, Logger } from "effect"
const FileLogger = Logger.layer([
Logger.formatLogFmt.pipe(
Logger.toFile("/var/log/app.log", {
flag: "a",
batchWindow: Duration.seconds(5)
})
)
]).pipe(Layer.provide(NodeFileSystem.layer))
// appends batched logfmt lines to /var/log/app.log

Builds a Layer that installs the given loggers. Replaces the current set by default; pass { mergeWithExisting: true } to add to it. Accepts both plain loggers and effects that produce loggers (such as batched/toFile results).

import { Logger } from "effect"
// Replace the runtime loggers with JSON-only output.
Logger.layer([Logger.consoleJson])
// Add a logger while keeping the defaults (incl. tracerLogger).
Logger.layer([Logger.consolePretty()], { mergeWithExisting: true })

The Context.Reference holding the active logger set for the current fiber. Read it to inspect, or provide it (which is what Logger.layer does under the hood).

import { Effect, Logger } from "effect"
Effect.gen(function*() {
const loggers = yield* Effect.service(Logger.CurrentLoggers)
console.log(`active loggers: ${loggers.size}`) // => active loggers: 2
})

A Context.Reference<boolean> (default false). Set it to true to route the default logger and the TTY pretty logger to console.error, keeping stdout clean for protocol/data output.

import { Effect, Layer, Logger } from "effect"
const StderrLogs = Layer.succeed(Logger.LogToStderr, true)
Effect.log("goes to stderr").pipe(Effect.provide(StderrLogs))
  • Tracing — correlate logs with distributed traces via Logger.tracerLogger.
  • Metrics — aggregate numeric telemetry.
  • File System — the FileSystem layer needed by Logger.toFile.
  • Configuration — drive logger selection from config.
  • Services & Layers — how layers wire telemetry in.