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/devtools

Audit 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.

Use these fields consistently:

FieldPurpose
actionStable verb such as patients.update or posts.publish
actorUser, service, system, or anonymous actor that initiated the action
tenantTenant, organization, clinic, workspace, or account boundary
resourceDomain object affected by the action
requestIdRequest correlation ID for logs, devtools, and support
outcomesuccess or failure
metadataSmall 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.