March Docs

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

typeLevelLevel = Debug | Info | Warn | Error#
typeLogValueLogValue =#

Rich value type so structured formatters (JSON, logfmt) preserve the original type instead of stringifying everything.

typeLogFieldLogField = LogField(String, LogValue)#
typeLogEntryLogEntry = LogEntry(Level, String, Int, String, List(LogField))#

A log entry built and passed to every registered appender. Fields are in stack order — most recently pushed at the head.

typeAppenderAppender = Appender(String, LogEntry -> Unit)#

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

typeAppenderCallAppenderCall = AppenderCall(String, String, Int, String, List(LogField))#

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

fnaa(v : Atom) : LogValue do LAtom(v) end#

Field value constructor: an Atom (LAtom).

Examples

march> Logger.a(:ok)
LAtom(:ok)
fnadd_appenderadd_appender(a : Appender) : Unit#

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()
fnappender_callbackappender_callback(name : String, on_entry : LogEntry -> Unit) : Appender#

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))))
fnappender_fileappender_file(path : String, format_fn : LogEntry -> String) : Appender#

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))
fnappender_stderrappender_stderr(format_fn : LogEntry -> String) : Appender#

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))
fnappender_stdoutappender_stdout(format_fn : LogEntry -> String) : Appender#

An appender, named "stdout", that writes each formatted entry to stdout followed by a newline.

Logger.add_appender(Logger.appender_stdout(Logger.format_logfmt))
fnbb(v : Bool) : LogValue do LBool(v) end#

Field value constructor: a Bool (LBool).

Examples

march> Logger.b(true)
LBool(true)
fnclear_appendersclear_appenders() : Unit#

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()
[]
fnclear_contextclear_context()#

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()
0
fnclear_module_levelclear_module_level(module : String) : Unit#

Remove 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")
Info
fncurrent_fieldscurrent_fields() : List(LogField)#

Return 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()
[]
fncurrent_parent_span_idcurrent_parent_span_id() : Option(String)#

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()
fncurrent_span_idcurrent_span_id() : Option(String)#

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()
fncurrent_trace_idcurrent_trace_id() : Option(String)#

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()
None
fndebugdebug(msg) do do_log(Debug, msg, Nil) end#

Log a Debug-level message (dropped unless the global level is Debug). Context fields are attached.

Logger.debug("cache miss")
fnentry_fieldsentry_fields(e) do match e do LogEntry(_, _, _, _, fs) -> fs end end#

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))]
fnentry_levelentry_level(e) do match e do LogEntry(l, _, _, _, _) -> l end end#

The entry's level.

Examples

march> Logger.entry_level(Logger.LogEntry(Logger.Warn, "slow", 0, "", []))
Warn
fnentry_messageentry_message(e) do match e do LogEntry(_, m, _, _, _) -> m end end#

The entry's message text.

Examples

march> Logger.entry_message(Logger.LogEntry(Logger.Info, "login ok", 0, "", []))
"login ok"
fnentry_sourceentry_source(e) do match e do LogEntry(_, _, _, s, _) -> s end end#

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"
fnentry_ts_msentry_ts_ms(e) do match e do LogEntry(_, _, t, _, _) -> t end end#

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, "", []))
1000
fnerrorerror(msg) do do_log(Error, msg, Nil) end#

Log an Error-level message, with the current context fields.

Logger.error("db connection lost")
fnff(v : Float) : LogValue do LFloat(v) end#

Field value constructor: a Float (LFloat).

Examples

march> Logger.f(1.5)
LFloat(1.5)
fnfield_countfield_count() : Int#

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()
fnformat_entryformat_entry(level, msg)#

Format a level and message as a simple string: [LEVEL] message.

Examples

march> Logger.format_entry(Logger.Warn, "disk low")
"[WARN] disk low"
fnformat_jsonformat_json(e : LogEntry) : String#

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}}"
fnformat_logfmtformat_logfmt(e : LogEntry) : String#

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"
fnformat_textformat_text(e : LogEntry) : String#

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"
fnget_levelget_level()#

Get the current global minimum log level (Info unless set_level was called).

Examples

march> Logger.get_level()
Info
fnii(v : Int) : LogValue do LInt(v) end#

Field value constructor: an Int (LInt).

Examples

march> Logger.i(42)
LInt(42)
fninfoinfo(msg) do do_log(Info, msg, Nil) end#

Log an Info-level message, with the current context fields.

Logger.info("server started")
fnint_to_levelint_to_level(n)#

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)
Debug
fnlevel_enabledlevel_enabled(level, min_level)#

Return 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)
false
fnlevel_forlevel_for(module : String) : Level#

Return the effective level for module — the override if one is set, otherwise the global level (get_level).

Examples

march> Logger.level_for("MyApp.Unconfigured")
Info
fnlevel_from_stringlevel_from_string(s)#

Parse 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")
None
fnlevel_ranklevel_rank(l)#

Return the numeric rank of a log level: Debug=0, Info=1, Warn=2, Error=3.

Examples

march> Logger.level_rank(Logger.Error)
3
fnlevel_to_intlevel_to_int(l)#

The 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)
2
fnlevel_to_stringlevel_to_string(l)#

The upper-case level name used in log lines: "DEBUG", "INFO", "WARN" or "ERROR".

Examples

march> Logger.level_to_string(Logger.Warn)
"WARN"
fnlist_appenderslist_appenders() : List(String)#

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()
fnloglog(level, msg) do do_log(level, msg, Nil) end#

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")
fnlog_iflog_if(level, min_level, msg)#

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")
fnlog_inlog_in(module : String, level : Level, msg : String,#

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.

fnlog_withlog_with(level, msg, metadata)#

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")])
fnnulnul() : LogValue do LNull end#

Field value constructor: an explicit null (LNull), rendered as null.

Examples

march> Logger.nul()
LNull
fnremove_appenderremove_appender(name : String) : Unit#

Remove 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()
[]
fnss(v : String) : LogValue do LStr(v) end#

Field value constructor: a String (LStr).

Examples

march> Logger.s("v")
LStr("v")
fnset_levelset_level(level)#

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)
fnset_module_levelset_module_level(module : String, l : Level) : Unit#

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")
fnwarnwarn(msg) do do_log(Warn, msg, Nil) end#

Log a Warn-level message, with the current context fields.

Logger.warn("slow query")
fnwith_contextwith_context(key, value)#

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()
fnwith_fieldwith_field(key : String, value : LogValue) : Unit#

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()
fnwith_fieldswith_fields(fields : List(LogField)) : Unit#

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()
fnwith_scopewith_scope(fields : List(LogField), thunk : () -> a) : a#

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()
0
fnwith_spanwith_span(name : String, thunk : () -> a) : a#

Run 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)
fnwith_trace_contextwith_trace_context(trace_id : String, span_id : String,#

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