Actor Context
Every audit log entry should record who performed the action. Better Audit uses Node.js AsyncLocalStorage to propagate actor identity through your entire request lifecycle — once the middleware extracts an actor, every captureLog() call in that request automatically receives it. No manual passing required.
How it works
Section titled “How it works”When a request arrives, the framework middleware:
- Extracts the actor identity from the request (JWT, cookie, header, or custom logic)
- Creates an
AuditContextand stores it inAsyncLocalStorage - Runs the rest of the request inside that scope
Any code that calls captureLog() — whether in a route handler, a service layer, or a deeply nested utility — automatically picks up the context. When the request ends, the scope is cleaned up.
Request → Middleware extracts actor → AsyncLocalStorage scope created └─ Route handler └─ Service layer └─ captureLog() ← actorId attached automaticallyThe AuditContext type
Section titled “The AuditContext type”The context carries more than just the actor. All fields are optional:
| Field | Type | Description |
|---|---|---|
actorId | string | User or system identifier performing the action. |
label | string | Human-readable label for the event (e.g., "User updated profile"). |
reason | string | Justification for the action (e.g., "GDPR deletion request"). |
compliance | string[] | Compliance framework tags (e.g., ["soc2", "gdpr"]). |
metadata | Record<string, unknown> | Arbitrary key-value data merged into each entry. |
When captureLog() runs, per-call fields override context fields. For example, passing actorId directly to captureLog() takes precedence over the context’s actorId.
Actor extraction
Section titled “Actor extraction”The middleware accepts an extractor option that controls how it identifies the actor from each request. With no configuration, it decodes the sub claim from the Authorization: Bearer <jwt> header:
import { Hono } from "hono";import { betterAuditHono } from "@usebetterdev/audit/hono";
const app = new Hono();
// Reads `sub` from Authorization: Bearer <jwt>app.use("*", betterAuditHono());import express from "express";import { betterAuditExpress } from "@usebetterdev/audit/express";
const app = express();
// Reads `sub` from Authorization: Bearer <jwt>app.use(betterAuditExpress());Better Audit ships three built-in extractors — fromBearerToken, fromHeader, and fromCookie — and supports custom extractor functions for full control. See the Adapters guide for the full extractor reference, custom extractors, and error handling options.
Enriching context mid-request
Section titled “Enriching context mid-request”The middleware sets the initial context, but you can add more fields later in the request lifecycle using mergeAuditContext() and read the current context with getAuditContext().
mergeAuditContext()
Section titled “mergeAuditContext()”Merges additional fields into the current context for the duration of a callback. Override properties take precedence over existing ones:
import { Hono } from "hono";import { mergeAuditContext } from "@usebetterdev/audit";
const app = new Hono();
app.delete("/users/:id", async (c) => { const reason = c.req.header("x-deletion-reason");
await mergeAuditContext( { reason, compliance: ["gdpr"] }, async () => { // All captureLog() calls inside this callback // include reason and compliance tags await deleteUser(c.req.param("id")); }, );
return c.json({ ok: true });});import express from "express";import { mergeAuditContext } from "@usebetterdev/audit";
const router = express.Router();
router.delete("/users/:id", async (req, res, next) => { try { const reason = req.headers["x-deletion-reason"] as string | undefined;
await mergeAuditContext( { reason, compliance: ["gdpr"] }, async () => { // All captureLog() calls inside this callback // include reason and compliance tags await deleteUser(req.params.id); }, );
res.json({ ok: true }); } catch (error) { next(error); }});getAuditContext()
Section titled “getAuditContext()”Returns the current context, or undefined if called outside a scope:
import { getAuditContext } from "@usebetterdev/audit";
function deleteUser(userId: string) { const context = getAuditContext(); if (context?.actorId) { logger.info(`User ${userId} deleted by ${context.actorId}`); } // ...}audit.withContext()
Section titled “audit.withContext()”For code running outside a request — background jobs, cron tasks, CLI scripts — use audit.withContext() to create a context scope manually:
import { audit } from "../audit.js";
async function runCleanupJob() { await audit.withContext( { actorId: "system:cleanup-job", reason: "Scheduled daily cleanup", compliance: ["gdpr"], metadata: { jobId: "cleanup-2025-01-15" }, }, async () => { // All captureLog() calls here receive the context await deactivateExpiredAccounts(); }, );}Behavior and design decisions
Section titled “Behavior and design decisions”Fail-open
Section titled “Fail-open”Extraction errors never crash the request. If an extractor throws or returns undefined, the request proceeds normally — audit entries are captured without an actorId. Use the onError option on the middleware to log extraction failures. See Adapters — Error handling for details.
Request isolation
Section titled “Request isolation”Each request gets its own AsyncLocalStorage scope. Concurrent requests never leak context between each other — even under high concurrency, each request’s actorId stays isolated.
Scope lifetime
Section titled “Scope lifetime”- Hono: The scope naturally ends when the middleware’s
next()completes. - Express: The adapter keeps the scope open until the response finishes (via
response.on('finish'/'close')). This ensures context survives acrossawaitboundaries in async route handlers.
Troubleshooting
Section titled “Troubleshooting”If actorId is undefined, context disappears after await, or you see the wrong actor in logs, see the Troubleshooting guide for detailed diagnosis and fixes.
Next steps
Section titled “Next steps”- Troubleshooting — common actor context issues and how to fix them
- Adapters — extractors, custom extractors, error handling, and API reference
- Configuration — enrichment, retention, hooks, and ORM adapter tuning
- Quick Start — working example with ORM + framework middleware in one page