about things notes.zzstoatzz.io
notes
notes languages ziglang logging.md
5.5 kB

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

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