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:

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.

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.