Zig 0.17.0

Logging

An app writes two kinds of lines: one per request, which Spider writes, and its own. Both go to stderr, which is where a container's log comes from.

One line per request

spider.logger is a middleware. A new project already uses it, first in the list so that it sees every request:

zig
/// Builds the server and listens. main() calls it, and so do the tests in
/// src/app_test.zig.
pub fn serve(allocator: std.mem.Allocator, io: std.Io) !void {
    // What the server needs before it starts goes here. Posts needs
    // nothing yet.
    _ = allocator;
    _ = io;

    var server = spider.app(.{});
    defer server.deinit();

    // Routes live with their feature (src/features/<name>/routes.zig);
    // mountFeatures registers every feature listed in src/features/mod.zig.
    // `spider routes` prints what got registered.
    try server
        .use(spider.logger)
        // Reads the login cookie (or a bearer token) and tells the routes
        // who is there; a route that is not `.public` needs a login.
        .use(spider.session.middleware())
        .mountFeatures(features)
        .onError(onError)
        // Port and host come from spider.config.zig; the PORT variable
        // (the environment or .env) replaces the port.
        .listen(.{});
}

These are real lines, from the tests of examples/guide:

text
2026-10-11T01:08:09.561Z [200] GET    /whoami  0.1ms  rid=e9bb9c3eed2e9d0f user=- org=-
2026-10-11T01:08:09.562Z [200] GET    /whoami  0.0ms  rid=edge-7f3a user=- org=-
2026-10-11T01:08:09.564Z [201] POST   /photos  0.1ms  rid=62c5554d6cf5b07b user=- org=-
2026-10-11T01:08:09.561Z [302] GET    /whoami  0.0ms  rid=966935a5b3ec2217 user=- org=-
2026-10-11T01:08:09.567Z [400] POST   /photos  0.0ms  rid=783d14f26f44a02c user=- org=-  error=InvalidBoundary
2026-10-11T01:09:23.829Z [200] GET    /ticks  stream 0ms  rid=821c48ffbb00920e user=- org=-

What a line says, in order:

  • The time, in UTC.
  • The status, the method and the path. The query string is left out: it can carry a token.
  • How long the handler and the middlewares took.
  • rid: the id of the request, the same one the answer carries in X-Request-Id and c.requestId() gives. A proxy's id is kept (edge-7f3a above): see Security in production.
  • user and org: who was signed in, as an id and never a name or an e-mail. - for a visitor.
  • error: the name of the error a handler returned, when it returned one.

A stream (SSE or WebSocket) gets its line when it ends, with how long it stayed open after the word stream.

On a terminal the status is colored. In a container's log it is plain text.

Leaving out the noise

Some requests say nothing: a health check every five seconds, a page that polls. Two ways to leave them out, and both keep the line when the request fails (a status of 400 or more), so an error is never hidden.

On the route, with .quiet_log:

zig
/// Mounted by `server.mountFeatures(features)` in src/main.zig. `.public`:
/// the home page stays open once the app has a login (spider g auth).
pub fn build() spider.Group {
    var g = spider.Group.init("");
    _ = g
        .get("/", controller.index, .{ .public = true })
        // The stream that tells a day view its day changed.
        .sseWith("/events/:day", controller.events, .{ .public = true, .quiet_log = true });
    return g;
}

Or by path, for what is not a route of the app (files, a path a middleware answers). spider.loggerWith takes the place of spider.logger:

zig
/// What a main() would do to serve. The tests start it once
/// (`spider.testing.start(run)`) and share it.
pub fn run() !void {
    // One client for the bucket, made once. In an app the values come from
    // the environment: `spider.r2.R2.initFromEnv(io)`.
    // (`io` is the one main() received; this package has no main.)
    const io = std.Options.debug_io;
    store = try spider.r2.R2.init(io, .{
        .account_id = "0a1b2c3d4e5f",
        .access_key = "an-access-key-id",
        .secret_key = "a-secret-access-key",
        .bucket = "guide-files",
        .pub_url = "https://files.guide.example",
    });
    defer store.deinit();

    var server = spider.app(.{});
    defer server.deinit();

    try server
        .use(spider.loggerWith(.{ .quiet_paths = &.{ "/up", "/assets/**" } }))
        .use(spider.forceHttps(.{ .default_base_url = "https://guide.example" }))
        .use(spider.varyHtmx)
        .get("/whoami", whoami, .{ .public = true })
        .post("/notes", addNote, .{ .public = true })
        .post("/hooks/payments", paymentHook, .{ .public = true, .allow_http = true })
        .post("/photos", addPhoto, .{ .public = true })
        .get("/photos/report", photoReport, .{ .public = true })
        .sseWith("/ticks", ticks, .{ .public = true })
        .wsWith("/rooms/:room/chat", chat, .{ .public = true })
        .wsWith("/staff/feed", staffFeed, .{ .authenticated = true })
        .get("/orders.xlsx", exportOrders, .{ .public = true })
        .post("/orders/import", importOrders, .{ .public = true })
        .get("/tickets/:code/qr.svg", ticketQr, .{ .public = true })
        .post("/avatars/sign", signAvatarUpload, .{ .public = true })
        .get("/invoices/:number", invoice, .{ .public = true })
        .post("/reports", keepReport, .{ .public = true })
        .get("/auth/google", googleLogin, .{ .public = true })
        .get("/auth/google/callback", googleCallback, .{ .public = true })
        .get("/shop", shop, .{ .public = true })
        .get("/shop/typo", shopTypo, .{ .public = true })
        .get("/shop/receipt", receipt, .{ .public = true })
        .get("/tasks", tasks, .{ .public = true })
        .post("/tasks", addTask, .{ .public = true })
        .post("/tasks/:id/archive", archiveTask, .{ .public = true })
        .onError(spider.errorHandler(.{}))
        .listen(.{});
}

In a pattern, * is one segment of the path and ** at the end is everything under it: "/tickets/*/presence", "/assets/**".

The app's own lines

Use std.log, as any Zig program does:

zig
/// Releases the holds that ran out and tells the pages showing their days.
/// It runs on a thread of its own, outside any request, so it brings its
/// own memory and reports its own errors: there is nobody to return one to.
pub fn expireHolds(hub: *spider.Hub) void {
    var arena_state: std.heap.ArenaAllocator = .init(std.heap.smp_allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const days = repository.expireStale(arena) catch |err| {
        std.log.warn("bookings: could not release the holds that ran out: {s}", .{@errorName(err)});
        return;
    };
    for (days) |day| {
        const channel = std.fmt.allocPrint(arena, "day:{s}", .{day}) catch continue;
        hub.emitTo(channel, "changed", .{ .day = day });
    }
    if (days.len > 0) std.log.info("bookings: {d} hold(s) ran out and were released", .{days.len});
}

By default those lines have no time. spider.logFn adds it, in the format of the request lines:

zig
/// In an app this line goes in src/main.zig, next to `pub fn main`: std.log
/// reads it from there.
pub const std_options: std.Options = .{ .logFn = spider.logFn };
text
2026-10-11T01:09:14.868Z info: 3 hold(s) ran out and were released
2026-10-11T01:09:14.868Z warning: the webhook was not told about booking 53
2026-10-11T01:09:14.868Z error (mail): the provider answered 502

(mail) is a scope: const log = std.log.scoped(.mail); gives a logger whose lines carry that word.

Two things to know about levels:

  • A release build drops the debug lines and keeps the rest. .log_level = .debug in std_options keeps them too.
  • A test fails when the code it runs logs at .err. Code that fails on purpose in a test logs at .warn.

What not to log

A log is read by more people than a database. Do not write passwords, tokens, cookies or the body of a request to it, and prefer an id to a name or an e-mail. Spider's own lines follow that: no query string, no body, ids for who is signed in.