# 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 } ``` source: [lib/std/log.zig](https://github.com/ziglang/zig/blob/master/lib/std/log.zig) ## 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: `otel-zig` provides `sdk.logs.std_log_bridge.otelLogFn` which is exactly this — a drop-in `logFn` that emits OTEL log records via the global `LoggerProvider`. the backend (e.g. logfire-zig) just has to set up the provider; the bridge does the rest. ## 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 ``` (logfire-zig ≥ 0.2.6; the symptom in find-bufo was a `turbopuffer.bm25_search` span with no parent while its sibling vector search nested fine.) ## 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 / otel-zig — std_log_bridge.otelLogFn - ziglang — lib/std/log.zig