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.
bun add @beignet/core
Request context
Carry the request actor and tenant in app context:
import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
export type AppContext = {
actor: ActivityActor;
tenant?: ActivityTenant;
requestId: string;
ports: AppPorts;
};
Resolve them at the HTTP boundary from your auth/session provider. Header-based identity is only useful for examples and tests:
import {
createAnonymousActor,
createTenant,
createUserActor,
} from "@beignet/core/ports";
createContext: async ({ req, ports }) => {
const userId = req.headers.get("x-user-id") || undefined;
const tenantId = req.headers.get("x-tenant-id") || undefined;
return {
actor: userId ? createUserActor(userId) : createAnonymousActor(),
tenant: tenantId ? createTenant(tenantId) : undefined,
requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
ports,
};
};
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",
actor: ctx.actor,
tenant: ctx.tenant,
requestId: ctx.requestId,
resource: { type: "post", id: post.id, name: post.slug },
metadata: { publishedAt: post.publishedAt },
});
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",
actor: ctx.actor,
tenant: ctx.tenant,
requestId: ctx.requestId,
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.
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.
Devtools mirror
Devtools can mirror sanitized audit records into the local timeline without becoming the durable audit store:
import { createDevtoolsAuditLog } from "@beignet/devtools";
const audit = createDevtoolsAuditLog({
audit: durableAudit,
devtools: ports.devtools,
});
This wrapper writes through the durable audit port first, then emits a custom
devtools event owned by the audit watcher.
Do not emit devtools audit events from inside an active database transaction unless your adapter defers the devtools 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:
import { createMemoryAuditLog, createUserActor } from "@beignet/core/ports";
const audit = createMemoryAuditLog();
const ctx = {
actor: createUserActor("user_1"),
requestId: "test-request",
ports: {
audit,
},
};
await useCase.run({ ctx, input });
expect(audit.entries).toMatchObject([
{
action: "posts.publish",
actor: { type: "user", id: "user_1" },
requestId: "test-request",
},
]);
Repository or adapter tests should verify the durable table shape separately.