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 #
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:
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:
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
why direct-emit APIs are an anti-pattern #
if your library exposes its own mylib.info(...) / mylib.warn(...):
- it competes with
std.loginstead of integrating - callers can't compose their own
logFnwithout duplicating output - stdlib code (e.g.
std.http,std.crypto) that usesstd.logwon't reach your backend
correct shape for an OTEL-style backend client:
- expose a
logFnfunction with thestd.options.logFnsignature - caller wires
pub const std_options: std.Options = .{ .logFn = mylib.logFn } - 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:
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