Skip to content

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.

When a request arrives, the framework middleware:

  1. Extracts the actor identity from the request (JWT, cookie, header, or custom logic)
  2. Creates an AuditContext and stores it in AsyncLocalStorage
  3. 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 lifecycle
Request → Middleware extracts actor → AsyncLocalStorage scope created
└─ Route handler
└─ Service layer
└─ captureLog() ← actorId attached automatically

The context carries more than just the actor. All fields are optional:

FieldTypeDescription
actorIdstringUser or system identifier performing the action.
labelstringHuman-readable label for the event (e.g., "User updated profile").
reasonstringJustification for the action (e.g., "GDPR deletion request").
compliancestring[]Compliance framework tags (e.g., ["soc2", "gdpr"]).
metadataRecord<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.

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:

src/server.ts
import { Hono } from "hono";
import { betterAuditHono } from "@usebetterdev/audit/hono";
const app = new Hono();
// Reads `sub` from Authorization: Bearer <jwt>
app.use("*", betterAuditHono());

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.

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().

Merges additional fields into the current context for the duration of a callback. Override properties take precedence over existing ones:

src/routes/users.ts
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 });
});

Returns the current context, or undefined if called outside a scope:

src/services/user-service.ts
import { getAuditContext } from "@usebetterdev/audit";
function deleteUser(userId: string) {
const context = getAuditContext();
if (context?.actorId) {
logger.info(`User ${userId} deleted by ${context.actorId}`);
}
// ...
}

For code running outside a request — background jobs, cron tasks, CLI scripts — use audit.withContext() to create a context scope manually:

src/jobs/cleanup.ts
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();
},
);
}

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.

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.

  • 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 across await boundaries in async route handlers.

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.

  • 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