Logger
Logger module: structured logging with levels, scoped context, and pluggable appenders.
Log levels (ascending severity): Debug < Info < Warn < Error. The global level filter is set with set_level/1; messages below the current level are silently discarded.
v2 adds rich field values and scoped context:
Logger.with_field("user_id", Logger.i(42))
Logger.with_field("ms", Logger.f(12.5))
Logger.info("request done")The structured fields propagate to every subsequent log call until the field stack is popped. Use with_scope for automatic cleanup:
Logger.with_scope([Logger.LogField("request_id", Logger.s("abc"))],
fn _ -> handle_request())With no appender registered, output goes to stderr in the format: [LEVEL] message {key=value, key2=value2}
v1 API (still supported): Logger.set_level(Logger.Info) Logger.with_context("request_id", "abc123") -- equivalent to with_field("...", LStr(...)) Logger.info("handling request") Logger.warn("slow query") Logger.error("db connection lost") Logger.log_with(Logger.Info, "user signed in", [("user", "alice")])
Logging calls write output, so their docs show plain code; the level helpers, value constructors, entry accessors and formatters are pure and carry REPL examples.
Types
Rich value type so structured formatters (JSON, logfmt) preserve the original type instead of stringifying everything.
A log entry built and passed to every registered appender. Fields are in stack order — most recently pushed at the head.
An appender consumes log entries and writes them somewhere. The string is a name used by remove_appender. Multiple appenders fire newest registration first (re-registering a name moves it to the front).
What the runtime hands a registered callback: the entry with its level still spelled as the dispatched string ("INFO", ...). Both backends pass this, and deliver builds the LogEntry in March, because the compiled runtime cannot build a Level value itself: the compiler picks its constructor tags, and they are not 0..3 when another type is also named Level. The runtime builds this cell with tag 0, which holds only while no other type is named AppenderCall (a colliding name gets global tags). It is a constructor rather than a tuple because destructuring a tuple and moving its fields on leaks them compiled (specs/todos/2026-09-28-compiled-tuple-destructure-leaks-moved-fields.md).
Functions
Field value constructor: an Atom (LAtom).
Examples
march> Logger.a(:ok)
LAtom(:ok)Register an appender. Replaces any prior appender with the same name (and moves it to the front: appenders fire newest first). While any appender is registered, the default stderr line is no longer printed.
Examples
march> Logger.add_appender(Logger.appender_callback("sink", fn _ -> ()))
march> Logger.list_appenders()
["sink"]
march> Logger.clear_appenders()A custom appender that calls the user's on_entry callback for each log entry. Use to forward entries to OpenTelemetry exporters, in-process telemetry buses, or test harnesses. name is what remove_appender and list_appenders use.
Logger.add_appender(Logger.appender_callback("collector", fn e ->
Telemetry.emit(Logger.entry_message(e))))An appender that appends each formatted entry as a new line to path; the appender is named "file:" ++ path. Open / write / close happens per call (slow but simple); a buffered variant lives in a future commit. A failed write is silently ignored.
Logger.add_appender(Logger.appender_file("/var/log/app.log", Logger.format_json))An appender that writes formatted entries to stderr. Pass any formatter (e.g. Logger.format_json) to control wire format.
Logger.add_appender(Logger.appender_stderr(Logger.format_json))An appender, named "stdout", that writes each formatted entry to stdout followed by a newline.
Logger.add_appender(Logger.appender_stdout(Logger.format_logfmt))Field value constructor: a Bool (LBool).
Examples
march> Logger.b(true)
LBool(true)Remove every registered appender. Future logs will fall back to the v1 stderr text format.
Examples
march> Logger.add_appender(Logger.appender_callback("one", fn _ -> ()))
march> Logger.clear_appenders()
march> Logger.list_appenders()
[]Remove all log fields (v1 + v2): empties the whole context stack.
Examples
march> Logger.with_field("k", Logger.i(1))
march> Logger.clear_context()
march> Logger.field_count()
0Remove a module-level override; the module reverts to the global level. No-op if none is set.
Examples
march> Logger.set_module_level("MyApp.Db", Logger.Debug)
march> Logger.clear_module_level("MyApp.Db")
march> Logger.level_for("MyApp.Db")
InfoReturn the current log context as a List(LogField), most-recent push at the head. Useful for inspecting state in tests or for copying the current context into another actor's mailbox. Empty when nothing has been pushed.
Examples
march> Logger.current_fields()
[]Return the parent_span_id from the log context, if set to a string value (with_span at the top level pushes it as LNull, which reads as None).
Examples
march> Logger.with_trace_context("trace-abc", "span-xyz", Some("parent-uvw"))
march> Option.unwrap_or(Logger.current_parent_span_id(), "unset")
"parent-uvw"
march> Logger.clear_context()Return the current span_id from the log context (the most recent push), if set to a string value.
Examples
march> Logger.with_trace_context("trace-abc", "span-xyz", None)
march> Option.unwrap_or(Logger.current_span_id(), "unset")
"span-xyz"
march> Logger.clear_context()Return the current trace_id from the log context (the most recent push), if set to a string value.
Examples
march> Logger.with_trace_context("trace-abc", "span-xyz", None)
march> Option.unwrap_or(Logger.current_trace_id(), "unset")
"trace-abc"
march> Logger.clear_context()
march> Logger.current_trace_id()
NoneLog a Debug-level message (dropped unless the global level is Debug). Context fields are attached.
Logger.debug("cache miss")The entry's fields: call-site fields first, then the context stack (most recent push first). Duplicate keys are kept.
Examples
march> Logger.entry_fields(Logger.LogEntry(Logger.Info, "m", 0, "", [Logger.LogField("n", Logger.i(3))]))
[LogField("n", LInt(3))]The entry's level.
Examples
march> Logger.entry_level(Logger.LogEntry(Logger.Warn, "slow", 0, "", []))
WarnThe entry's message text.
Examples
march> Logger.entry_message(Logger.LogEntry(Logger.Info, "login ok", 0, "", []))
"login ok"The entry's source: the module name given to log_in, or "" for the plain info/warn/... calls.
Examples
march> Logger.entry_source(Logger.LogEntry(Logger.Info, "m", 0, "MyApp", []))
"MyApp"The entry's timestamp in milliseconds since the Unix epoch (set by the runtime when the entry is dispatched).
Examples
march> Logger.entry_ts_ms(Logger.LogEntry(Logger.Info, "m", 1000, "", []))
1000Log an Error-level message, with the current context fields.
Logger.error("db connection lost")Field value constructor: a Float (LFloat).
Examples
march> Logger.f(1.5)
LFloat(1.5)Return the current depth of the field stack (duplicate keys each count). Useful for tests.
Examples
march> Logger.with_fields([Logger.LogField("k", Logger.i(1)), Logger.LogField("k", Logger.i(2))])
march> Logger.field_count()
2
march> Logger.clear_context()Format a level and message as a simple string: [LEVEL] message.
Examples
march> Logger.format_entry(Logger.Warn, "disk low")
"[WARN] disk low"Single-line JSON formatter, OpenTelemetry-friendly.
Example output: {"ts":1681400000000,"level":"info","msg":"login","src":"MyApp", "trace_id":"…","span_id":"…","attributes":{"user_id":42}}
The reserved field names trace_id, span_id, and parent_span_id appear at the top level of the JSON object so log shippers that speak OTLP can pluck them directly; everything else lands in the attributes sub-object (omitted when empty). Fields are de-duplicated by key, keeping the most recent push (the first occurrence); src is included only when the source is non-empty.
Examples
march> Logger.format_json(Logger.LogEntry(Logger.Info, "login", 1000, "", [Logger.LogField("trace_id", Logger.s("t1")), Logger.LogField("n", Logger.i(2)), Logger.LogField("n", Logger.i(1))]))
"{\"ts\":1000,\"level\":\"info\",\"msg\":\"login\",\"trace_id\":\"t1\",\"attributes\":{\"n\":2}}"logfmt-style formatter (Heroku / Grafana convention): level=info ts=1681400000000 msg="hello world" k=v The message is always quoted (inner " escaped as \"); src= is added when the source is non-empty; string field values are quoted only when they contain a space or a ".
Examples
march> Logger.format_logfmt(Logger.LogEntry(Logger.Warn, "slow query", 1000, "Db", [Logger.LogField("user", Logger.s("ann smith")), Logger.LogField("ms", Logger.i(42))]))
"level=warn ts=1000 msg=\"slow query\" src=Db user=\"ann smith\" ms=42"Default text formatter. Mirrors the v1 output: [LEVEL] message {key=value, key2=value2} Fields appear in stack order, duplicates included; string values are not quoted. The braces are omitted when there are no fields; the timestamp and source are not shown.
Examples
march> Logger.format_text(Logger.LogEntry(Logger.Info, "login ok", 0, "", [Logger.LogField("user", Logger.s("ann")), Logger.LogField("n", Logger.i(3))]))
"[INFO] login ok {user=ann, n=3}"
march> Logger.format_text(Logger.LogEntry(Logger.Error, "boom", 0, "", []))
"[ERROR] boom"Get the current global minimum log level (Info unless set_level was called).
Examples
march> Logger.get_level()
InfoField value constructor: an Int (LInt).
Examples
march> Logger.i(42)
LInt(42)Log an Info-level message, with the current context fields.
Logger.info("server started")Inverse of level_to_int, clamping out-of-range input: n <= 0 is Debug, 1 Info, 2 Warn, and anything >= 3 Error.
Examples
march> Logger.int_to_level(1)
Info
march> Logger.int_to_level(7)
Error
march> Logger.int_to_level(-1)
DebugReturn true if level is at least as severe as min_level.
Examples
march> Logger.level_enabled(Logger.Warn, Logger.Info)
true
march> Logger.level_enabled(Logger.Debug, Logger.Info)
falseReturn the effective level for module — the override if one is set, otherwise the global level (get_level).
Examples
march> Logger.level_for("MyApp.Unconfigured")
InfoParse a log level from a string (case-insensitive); "warning" is accepted as Warn. Returns None for unknown strings.
Examples
march> Logger.level_from_string("WARNING")
Some(Warn)
march> Logger.level_from_string("trace")
NoneReturn the numeric rank of a log level: Debug=0, Info=1, Warn=2, Error=3.
Examples
march> Logger.level_rank(Logger.Error)
3The numeric level: Debug=0, Info=1, Warn=2, Error=3 (same as level_rank). This is the value the runtime's level filter stores.
Examples
march> Logger.level_to_int(Logger.Warn)
2The upper-case level name used in log lines: "DEBUG", "INFO", "WARN" or "ERROR".
Examples
march> Logger.level_to_string(Logger.Warn)
"WARN"Return the names of all currently registered appenders, newest first ([] when none is registered).
Examples
march> Logger.add_appender(Logger.appender_callback("first", fn _ -> ()))
march> Logger.add_appender(Logger.appender_callback("second", fn _ -> ()))
march> Logger.list_appenders()
["second", "first"]
march> Logger.clear_appenders()Log a message at the given level, subject to the global level filter. With no appender registered it prints [LEVEL] msg {k=v, ...} to stderr; otherwise each appender receives a LogEntry (source "").
Logger.log(Logger.Warn, "disk 90% full")Log at level only if level is enabled relative to min_level. The message must also pass the global level (set_level), like any log call.
Logger.log_if(Logger.Debug, Logger.Info, "skipped: Debug < Info")Log a message scoped to module. The level is filtered by the per-module override (set via set_module_level), falling back to the global level if no override is registered. The module name appears as the source of the LogEntry — formatters that include source render it as src=… (logfmt) or "src": "…" (json).
Logger.log_in("MyApp.Worker", Logger.Debug, "task done",
[Logger.LogField("ms", Logger.i(42))])fields are attached ahead of the context fields, for this entry only.
Log a message at the given level with extra metadata. metadata is a List((String, String)) of key-value pairs, attached as string fields ahead of the context fields (for this entry only).
Logger.log_with(Logger.Info, "request done", [("status", "200"), ("ms", "42")])Field value constructor: an explicit null (LNull), rendered as null.
Examples
march> Logger.nul()
LNullRemove a registered appender by name. No-op if absent.
Examples
march> Logger.add_appender(Logger.appender_callback("tmp", fn _ -> ()))
march> Logger.remove_appender("tmp")
march> Logger.list_appenders()
[]Field value constructor: a String (LStr).
Examples
march> Logger.s("v")
LStr("v")Set the global minimum log level. Messages below this level are discarded by debug/info/warn/error/log/log_with; log_in consults per-module overrides first. The default is Info.
Examples
march> Logger.set_level(Logger.Warn)
march> Logger.level_to_string(Logger.get_level())
"WARN"
march> Logger.set_level(Logger.Info)Override the log level for a specific module. Messages logged via log_in(module, …) consult this map first; messages logged via the bare info/debug/etc. use the global level (set with set_level).
Logger.set_module_level("MyApp.NoisyWorker", Logger.Debug)Examples
march> Logger.set_module_level("MyApp.NoisyWorker", Logger.Error)
march> Logger.level_to_string(Logger.level_for("MyApp.NoisyWorker"))
"ERROR"
march> Logger.clear_module_level("MyApp.NoisyWorker")Log a Warn-level message, with the current context fields.
Logger.warn("slow query")v1 helper: add a (key, String) pair to the log context. Equivalent to with_field(key, LStr(value)). Retained for backward compatibility with existing callers.
Examples
march> Logger.with_context("request_id", "abc123")
march> Logger.format_text(Logger.LogEntry(Logger.Info, "m", 0, "", Logger.current_fields()))
"[INFO] m {request_id=abc123}"
march> Logger.clear_context()Push a typed field onto the log context. Subsequent log entries carry it until either clear_context is called, or the enclosing with_scope exits.
Logger.with_field("user_id", Logger.i(42))
Logger.info("login")
-- emits (no appender registered): [INFO] login {user_id=42}Examples
march> Logger.with_field("user_id", Logger.i(42))
march> Logger.current_trace_id()
None
march> Logger.field_count()
1
march> Logger.clear_context()Push a list of typed fields onto the log context in one call. Order is preserved: the first element of fields is at the BOTTOM of the pushed batch (formatters render fields in stack order), so current_fields lists them reversed.
Examples
march> Logger.with_fields([Logger.LogField("a", Logger.i(1)), Logger.LogField("b", Logger.i(2))])
march> Logger.format_text(Logger.LogEntry(Logger.Info, "m", 0, "", Logger.current_fields()))
"[INFO] m {b=2, a=1}"
march> Logger.clear_context()Run thunk with fields pushed onto the log context, and pop them back off when thunk returns OR panics. Use this to attach request-scoped context (request_id, span_id, …) without having to remember to clean it up.
Logger.with_scope([Logger.LogField("request_id", Logger.s("abc123"))],
fn _ -> handle_request(req))Returns whatever thunk returns. Pass the thunk as fn _ -> ....
Examples
march> Logger.with_scope([Logger.LogField("x", Logger.i(1))], fn _ -> Logger.field_count())
1
march> Logger.field_count()
0Run thunk inside a tracing span: pushes a fresh span_id, promotes the current span (if any) to parent_span_id, and either inherits the current trace_id or starts a new one (ids are random hex: 16 chars for a span, 32 for a trace). Pushes five fields and pops them all when thunk returns OR panics; returns the thunk's result.
Adds two extra fields for instrumentation:
span_name: LStr(name)span_start_ms: LInt(now_ms())
Logger.with_span("handle_request", fn _ -> do
Logger.info("got request")
process()
end)Push trace_id, span_id, and (optionally) parent_span_id onto the log context. Subsequent log entries carry these fields until they are popped or clear_context is called.
Logger.with_trace_context("trace-abc", "span-xyz", Some("parent-uvw"))Examples
march> Logger.with_trace_context("trace-abc", "span-xyz", None)
march> Logger.field_count()
2
march> Logger.clear_context()