# logging > written against zig 0.16. zig has one canonical way to do logging — `std.log` plus an application-level `std.options.logFn` hook. Library code calls `std.log.info(...)` and the application decides what happens. ## the idiom ```zig const std = @import("std"); // in any library or app code: std.log.info("doing the thing: {s}", .{name}); std.log.warn("retrying: {}", .{err}); std.log.err("gave up", .{}); std.log.debug("internal: x={d}", .{x}); // libraries that want to namespace their logs: const log = std.log.scoped(.libfoo); log.info("foo opened: {s}", .{path}); // shows up as info(libfoo) ``` four levels: `err`, `warn`, `info`, `debug` (matches OTEL severities). scopes are `@EnumLiteral()` values, default `.default`. ## the extension point: `std.options.logFn` every `std.log.*` call routes through `std.options.logFn`. the application sets it once: ```zig pub const std_options: std.Options = .{ .log_level = .info, .logFn = my_log_fn, }; fn my_log_fn( comptime level: std.log.Level, comptime scope: @EnumLiteral(), comptime format: []const u8, args: anytype, ) void { // do whatever — write to a file, ship to a backend, both, neither } ``` note that `level`, `scope`, and `format` are all comptime parameters. messages disabled at compile time (`!logEnabled(level, scope)`) compile away to nothing — zero runtime cost. compare to python `logging.getLogger().info(...)` which has runtime overhead even when disabled. defaults: `std.log.defaultLog` writes to stderr with ANSI colors. that's it. ## consequence: there is no equivalent of python `logger.handlers.append(...)` zig's design is **single global hook**, not multi-handler. if you want both stderr AND a backend, your `logFn` has to fan out: ```zig fn my_log_fn(comptime level, comptime scope, comptime fmt, args) void { std.log.defaultLog(level, scope, fmt, args); // keep stderr backend.emit(level, scope, fmt, args); // also ship to OTLP } ``` ## why direct-emit APIs are an anti-pattern if your library exposes its own `mylib.info(...)` / `mylib.warn(...)`: - it competes with `std.log` instead of integrating - callers can't compose their own `logFn` without duplicating output - stdlib code (e.g. `std.http`, `std.crypto`) that uses `std.log` won't reach your backend correct shape for an OTEL-style backend client: 1. expose a `logFn` function with the `std.options.logFn` signature 2. caller wires `pub const std_options: std.Options = .{ .logFn = mylib.logFn }` 3. their own `std.log.*` calls reach you, and stdlib calls do too — for free bridging into the OTEL log API is exactly this: a drop-in `logFn` that emits OTEL log records via the global `LoggerProvider`. the backend client just has to set up the provider; the bridge does the rest. ## a replacement `logFn` must keep writing to stderr because the hook is single and global, a bridge that only emits to a backend has *removed* stderr, not added a destination. the process's own logs (`fly logs`, journald, a terminal) go silent the moment export comes up, and nobody notices until the backend is the thing that is down. a bridge needs a fan-out option and it should default on in production. ## what a log record should carry the record is only as useful as its correlation: the **active span's trace/span id** (so it renders under the operation that produced it — the bridge cannot know the current span, so the client that tracks one hands it a `span_context_fn`), the **scope as `code.module.name`** (the semconv key other languages' clients use for the same idea, so cross-language dashboards group alike), and `severity_text`. `code.file.path`/`code.line.number` are not available: `std.log` carries no source location. ## flush before you exit batched exporters send on a timer (typically 500 ms). `std.process.exit` after `log.err("watchdog: no progress, exiting")` loses exactly the line that explains the restart. every exit path — watchdogs, fatal-error exits — must flush first, and a `std.debug.FullPanic` hook that logs the message and flushes before delegating to `std.debug.defaultPanic` covers panics. ## OTEL alignment OTEL has three signal types: traces, metrics, logs. each gets its own SDK pipeline (provider → batch processor → exporter). zig's `std.log` maps cleanly onto OTEL logs because the level/scope/format/args shape is structurally similar to an OTEL log record (severity / instrumentation scope / body / attributes). Severity mapping (from `otel-zig/src/sdk/logs/std_log_bridge.zig`): | std.log.Level | OTEL Severity | numeric | |---------------|-------------------|---------| | `.err` | `Severity.error` | 17 | | `.warn` | `Severity.warn` | 13 | | `.info` | `Severity.info` | 9 | | `.debug` | `Severity.debug` | 5 | ## spans across tasks: propagate the parent explicitly an OTEL sdk keeps "the current span" in thread-local state, so a span opened inside `io.concurrent` (or any spawned thread) starts with no parent and lands as a trace root. OTEL's answer everywhere is explicit context propagation — the spawning side captures the current context and the task starts its span under it (java `Context.current().wrap(runnable)`, python `context.attach`, rust `tracing`'s `Span::current()` handed into the task). zig has no ambient context to smuggle, so the explicit form is the only honest one: ```zig const parent = logfire.currentContext(); // on the spawning thread var fut = try io.concurrent(leg, .{ parent, ... }); // in leg(): const span = logfire.spanWithParent(parent, "search.keyword_leg", .{}); defer span.end(); // nested spans inside now attach here ``` the symptom of getting this wrong: one concurrent leg's span shows up as a trace root while its sibling nests correctly. ## comparison with other ecosystems | ecosystem | standard log facade | OTEL backend bridge | |---|---|---| | python | `logging.Logger` + `Handler` | `LogfireLoggingHandler(Handler)` | | rust | `log::Log` trait + `set_logger` | `LogfireLogger: log::Log` then `log::set_logger(logger)` | | zig | `std.log` + `std.options.logFn` | a `logFn` impl + `pub const std_options = .{ .logFn = ... }` | all three share the structure: language-standard logging facade → bridge into OTEL log API → batch → OTLP exporter → collector. zig's expression of it is the simplest because the hook is a single function pointer, not a class hierarchy. ## sources - logfire-zig (README, 2026-09-02, v0.3.0) / otel-zig (`src/sdk/logs/std_log_bridge.zig`, `otelLogFn`) - typeahead — `docs/operations.md`, "observability" - ziglang — lib/std/log.zig