Audit and activity logging
Audit logging records business activity that must be explainable later: who did
what, to which resource, in which tenant, and under which request. It is
different from diagnostic logging. LoggerPort helps operators debug runtime
behavior; AuditLogPort gives the application a durable activity trail.
Use Privacy lifecycle to decide audit retention, deletion, anonymization, and which metadata must stay out of durable activity records.
bun add @beignet/core
# Optional for the local devtools timeline:
bun add @beignet/devtoolsAudit records read the actor, tenant, and request ID from app context. See Routes and server for the context blueprint and Authentication for resolving the request actor and session.
Audit port
Add an audit port to application ports:
import type { AuditLogPort } from "@beignet/core/ports";
export type AppTransactionPorts = {
audit: AuditLogPort;
posts: PostRepository;
};
export type AppPorts = {
audit: AuditLogPort;
posts: PostRepository;
uow: UnitOfWorkPort<AppTransactionPorts>;
};Audit entries use stable action names and resource descriptors:
await ctx.ports.audit.record({
action: "posts.publish",
resource: { type: "post", id: post.id, name: post.slug },
metadata: { publishedAt: post.publishedAt },
});Call sites only describe the business activity. Actor, tenant, request ID, and
trace ID come from the ambient request context when the audit port is wrapped
with createAmbientAuditLog(...) (next section). Fields provided explicitly on
an entry always win over ambient values.
Ambient enrichment
Wrap the durable audit port with createAmbientAuditLog(...) from
@beignet/core/server and the actor, tenant, request ID, and trace ID fill in
automatically at record time:
import { createAmbientAuditLog } from "@beignet/core/server";
import { createInstrumentedAuditLog } from "@beignet/core/ports";
const audit = createAmbientAuditLog(
createInstrumentedAuditLog({
audit: createDrizzleAuditLog(db),
instrumentation: ports,
}),
);The server keeps the ambient context current for every execution path:
requests enter it before hooks run and refresh after hooks finalize identity,
and service contexts created with server.createServiceContext(...) enter it
with the service actor, tenant, and fresh correlation IDs. Because enrichment
happens when record(...) runs, the wrapper also works for ports a unit of
work rebuilds per transaction — wrap both construction points. On runtimes
without AsyncLocalStorage, entries pass through unchanged and a missing
actor defaults to an anonymous actor before persistence.
Transaction boundary
For writes, record audit entries inside the same Unit of Work transaction as the state change:
const published = await ctx.ports.uow.transaction(async (tx) => {
const post = await tx.posts.publish(input.slug);
await tx.audit.record({
action: "posts.publish",
resource: { type: "post", id: post.id, name: post.slug },
});
return post;
});This keeps audit records aligned with committed data. If the transaction rolls
back, the audit record rolls back too. For failed attempts that must be audited,
record a separate outcome: "failure" entry in an error path that is designed
for that requirement.
Background contexts
Jobs, listeners, schedules, cron routes, and scripts should receive the same
context shape as HTTP handlers. Declare a service factory in the server's
context blueprint, then build background contexts with
server.createServiceContext(...):
import { createSystemActor } from "@beignet/core/ports";
const ctx = await server.createServiceContext({
actor: createSystemActor("example-background"),
});Creating a service context also enters the ambient request context, so ambient-wrapped audit ports enrich background records the same way they enrich request records.
HTTP hooks
Use beforeHandle for HTTP boundary decisions that must be durably audited
before the response is sent, such as denied access to sensitive routes. Keep
successful business-write audit records in the use case transaction.
const accessAuditHooks = {
name: "access-audit",
beforeHandle: async ({ ctx, contract }) => {
if (contract.metadata?.auth !== "required" || ctx.actor.type === "user") {
return;
}
await ctx.ports.audit.record({
action: `http.${contract.name}.rejected`,
outcome: "failure",
resource: { type: "route", name: contract.name },
metadata: { status: 401 },
});
return {
ctx,
response: {
status: 401,
body: { code: "UNAUTHORIZED", message: "Unauthorized" },
},
};
},
};afterSend is an observation hook. It is useful for best-effort logging,
metrics, and diagnostic mirrors, but Beignet ignores afterSend failures so
they cannot change a response that has already been produced.
Jobs, listeners, and schedules
Background work should audit the durable business activity it owns. A listener audits that it enqueued follow-up work:
export const enqueuePostPublishedEmail = defineListener(PostPublished, {
name: "posts.enqueue-published-email",
async handle({ payload, ctx }) {
await ctx.ports.jobs.dispatch(SendPostPublishedEmailJob, payload);
await ctx.ports.audit.record({
action: "listeners.posts.enqueue-published-email",
resource: { type: "post", id: payload.postId, name: payload.slug },
metadata: { eventName: PostPublished.name },
});
},
});A job audits the external side effect after it succeeds. For non-idempotent side effects such as sending mail, catch and log audit-write failures instead of letting them fail the job: if the mail provider already accepted the message, an audit failure should not make a retry send it again. For stronger guarantees, put delivery state, audit state, and retries behind an idempotent outbox or provider-specific delivery record.
Schedules should audit the work they performed — the records processed, the date covered, the trigger time — not just that the cron endpoint was called.
Recommended fields
Use these fields consistently:
| Field | Purpose |
|---|---|
action | Stable verb such as patients.update or posts.publish |
actor | User, service, system, or anonymous actor that initiated the action |
tenant | Tenant, organization, clinic, workspace, or account boundary |
resource | Domain object affected by the action |
requestId | Request correlation ID for logs, devtools, and support |
outcome | success or failure |
metadata | Small domain details safe to persist |
Do not store secrets, tokens, raw PHI payloads, passwords, or full request bodies in audit metadata. Prefer stable identifiers and small, intentional summaries.
Redaction
createMemoryAuditLog() redacts metadata by default. Durable app adapters
should use redactAuditLogEntry() or createRedactedAuditLog() before writing
metadata to storage:
import { createRedactedAuditLog, redactAuditLogEntry } from "@beignet/core/ports";
const safeAudit = createRedactedAuditLog(durableAudit);
const safeEntry = redactAuditLogEntry(entry);Beignet's shared redactor catches secret-shaped keys such as
authorization, cookie, set-cookie, x-api-key, token, password,
secret, and credentials. It does not know which app-specific fields contain
PHI or PII, so keep audit metadata intentionally small.
Instrumentation mirror
Sanitized audit activity appears in the Audit view of devtools
when the durable port is wrapped with createInstrumentedAuditLog(...):
import { createInstrumentedAuditLog } from "@beignet/core/ports";
const audit = createInstrumentedAuditLog({
audit: durableAudit,
instrumentation: ports,
});The wrapper writes through the durable audit port first, then emits a custom
event owned by the audit watcher. Pass the ports object as instrumentation
so the sink is resolved lazily and observes provider startup order; with no
sink installed, only the durable write happens.
Do not emit instrumentation audit events from inside an active database transaction unless your adapter defers the event until after commit. Otherwise the local timeline can show an audit record for work that later rolls back.
Testing
Use the memory adapter for use-case tests. To assert enriched entries, mirror
production wiring: wrap the memory port with createAmbientAuditLog(...) and
enter an ambient request context for the test identity:
import { createMemoryAuditLog, createUserActor } from "@beignet/core/ports";
import {
clearActiveRequestContext,
createAmbientAuditLog,
enterActiveRequestContext,
} from "@beignet/core/server";
const audit = createMemoryAuditLog();
const actor = createUserActor("user_1");
const ctx = {
actor,
requestId: "test-request",
ports: {
audit: createAmbientAuditLog(audit),
},
};
enterActiveRequestContext({ requestId: "test-request", actor });
await useCase.run({ ctx, input });
clearActiveRequestContext();
expect(audit.entries).toMatchObject([
{
action: "posts.publish",
actor: { type: "user", id: "user_1" },
requestId: "test-request",
},
]);Route tests that go through the server do not need the manual
enterActiveRequestContext(...) call — the server enters and refreshes the
ambient context per request.
Repository or adapter tests should verify the durable table shape separately.