Skip to content

Action Log (Audit Trail)

pidgn.actionLog writes one compact record per request to a pluggable sink. It captures who did what and when — method, path, status, client IP, and a user identifier the handler populates.

It’s a different concern from structuredLogger:

structuredLoggeractionLog
Target audienceEngineers debuggingCompliance / auditors / forensics
FieldsDetailed, debug-orientedCompact, action-oriented
VolumeAll requests at the configured log levelAll requests, small record
Sinkstdout / stderr / fileDatabase, append-only file, SIEM — your choice

You can run both in parallel.

OptionTypeDefaultDescription
sinktype(required)A struct type with pub fn write(line: []const u8) void.
user_assign_key[]const u8"audit_user"Key in ctx.assigns where the handler stored a user id.
client_ip_header[]const u8"X-Forwarded-For"Header to read the client IP from.
const App = pidgn.Router.define(.{
.middleware = &.{
pidgn.session(.{}),
pidgn.actionLog(.{ .sink = pidgn.ActionLogStderrSink }),
// ...
},
.routes = routes,
});

In your handler, set the user identifier before calling ctx.next() — simplest is to do it from an authentication middleware:

fn requireAuth(ctx: *pidgn.Context) !void {
const user = try authenticate(ctx);
// ...
ctx.assign("audit_user", user.id_str);
try ctx.next();
}

A sink is any type with:

pub fn write(line: []const u8) void;

The middleware passes a full line (including trailing newline) per request. Implementations can fire-and-forget or buffer — the middleware doesn’t block on writes.

pidgn.actionLog(.{ .sink = pidgn.ActionLogStderrSink }),

Good for development and for container deployments where stderr gets collected by log infrastructure.

Append-only file:

const FileSink = struct {
pub fn write(line: []const u8) void {
// open-append-close is simplest if volume is low.
// For higher volume, keep a file descriptor open and share it via an atomic.
var buf: [4096]u8 = undefined;
var f = std.fs.cwd().openFile("audit.log", .{ .mode = .write_only }) catch return;
defer f.close();
_ = f.seekFromEnd(0) catch {};
var w = f.writer(&buf);
w.interface.writeAll(line) catch {};
w.interface.flush() catch {};
}
};
pidgn.actionLog(.{ .sink = FileSink }),

Database-backed:

const DbSink = struct {
// Store the repo in a thread-safe place — a static singleton or a shared
// atomic — since `write` has no per-request context.
pub fn write(line: []const u8) void {
audit_repo.insertLine(line) catch {};
}
};

Each entry is a single line ending with \n:

<unix_ts> <method> <path> <status> user=<user_id> ip=<client_ip>

Example:

1716240000 POST /posts/42/delete 302 user=alice ip=1.2.3.4

Keep it that simple unless you have a reason to change it — log parsers prefer predictable shapes.

The middleware doesn’t rotate. Use your sink’s backing store to manage retention — log-rotate on a file, a partition scheme on a DB table, or forwarding to a dedicated service.