---
name: build-audit-trails
description: >
  Design tamper-aware audit trails on top of OpenTelemetry spans using
  autotel. Covers what counts as auditable, the audit-only span discipline,
  signing and tamper-detection, denial logging, redaction, retention,
  separation of concerns from operational telemetry, and framework wiring
  (Next.js, Nuxt, Hono, Express, Cloudflare Workers).
license: MIT
---

# Build audit trails

An _audit trail_ is a record of who did what to which resource, when, and whether it was permitted — durable, tamper-evident, and admissible. Operational telemetry (latency, errors, span shapes) is for engineers; audit trails are for compliance, security, and forensics. They overlap technically but differ on every other axis.

autotel lets you express both with the same primitive — a span — but you should keep them on **separate processors** so an audit event never gets dropped by sampling, never gets redacted by a debug rule, and never goes to the same backend as your ops data.

## When to use

- Implementing GDPR / HIPAA / SOC2 / PCI-DSS / ISO 27001 / GxP compliance
- Adding "who did what" trails for admin actions, access reviews, payments
- Recording authorization decisions (allow + deny)
- Building immutable evidence for incident response

## The audit span discipline

An auditable event has six required parts:

| Field               | OTel attribute                                                  | Example                                       |
| ------------------- | --------------------------------------------------------------- | --------------------------------------------- |
| When                | (span timestamp)                                                | `2026-05-04T17:23:11.412Z`                    |
| Who                 | `enduser.id` + `enduser.role`                                   | `usr_42`, `admin`                             |
| Where (acting from) | `client.address`, `network.peer.address`, `user_agent.original` | `203.0.113.5`, `Chrome 121`                   |
| What                | `audit.action`                                                  | `secret.read`, `policy.update`, `user.delete` |
| Which resource      | `audit.resource.type` + `audit.resource.id`                     | `secret`, `sec_abc`                           |
| Outcome             | `audit.outcome` (`allow` / `deny`) + `audit.reason`             | `deny`, `MFA required`                        |

Plus useful optional fields: `audit.policy.id` (which policy made the call), `audit.evidence` (linked artefact id), `audit.actor.session.id`.

## Step 1: Define a typed `audit()` helper

Centralise the schema in one place so every site gets it right:

```typescript
import { trace, SpanKind } from '@opentelemetry/api';

type AuditAction =
  | 'secret.read'
  | 'secret.write'
  | 'secret.delete'
  | 'policy.update'
  | 'user.create'
  | 'user.delete'
  | 'data.export'
  | 'session.assume';

interface AuditPayload {
  action: AuditAction;
  resource: { type: string; id: string };
  outcome: 'allow' | 'deny';
  reason?: string;
  actor?: { id: string; role?: string; sessionId?: string };
  policy?: { id: string };
  evidence?: { id: string };
}

const tracer = trace.getTracer('autotel-audit', '1.0.0');

export function audit(payload: AuditPayload): void {
  const span = tracer.startSpan(`audit.${payload.action}`, {
    kind: SpanKind.INTERNAL,
    attributes: {
      audit: true,
      'audit.action': payload.action,
      'audit.outcome': payload.outcome,
      'audit.resource.type': payload.resource.type,
      'audit.resource.id': payload.resource.id,
      ...(payload.reason && { 'audit.reason': payload.reason }),
      ...(payload.actor?.id && { 'enduser.id': payload.actor.id }),
      ...(payload.actor?.role && { 'enduser.role': payload.actor.role }),
      ...(payload.actor?.sessionId && {
        'audit.actor.session.id': payload.actor.sessionId,
      }),
      ...(payload.policy?.id && { 'audit.policy.id': payload.policy.id }),
      ...(payload.evidence?.id && { 'audit.evidence.id': payload.evidence.id }),
    },
  });
  span.end();
}
```

## Step 2: Always log denials

A frequent compliance failure is "we logged what users did but not what we **stopped** them doing." Wrap the authorization decision so both branches go through `audit()`:

```typescript
export async function withAuthz<T>(
  payload: Omit<AuditPayload, 'outcome'>,
  decide: () => Promise<{ allow: boolean; reason?: string }>,
  body: () => Promise<T>,
): Promise<T> {
  const decision = await decide();
  if (!decision.allow) {
    audit({ ...payload, outcome: 'deny', reason: decision.reason });
    throw createStructuredError({
      status: 403,
      code: 'FORBIDDEN',
      message: 'Not allowed',
      why: decision.reason ?? 'Insufficient permissions',
    });
  }
  audit({ ...payload, outcome: 'allow' });
  return body();
}
```

## Step 3: Separate the audit pipeline

Critical: route audit spans to a **different processor and backend** so:

- They are never dropped by head or tail sampling.
- They are not subject to development-mode debug exporters.
- They go to a write-once / append-only store (S3 Object Lock, immutable bucket, dedicated audit DB).

```typescript
import {
  composeSpanProcessors,
  composeSubscribers,
  defineConfig,
} from 'autotel-edge';
import { BatchSpanProcessor, FilteringSpanProcessor } from 'autotel/processors';

const auditExporter = new BatchSpanProcessor(
  new OTLPHttpJsonExporter({
    url: process.env.AUDIT_OTLP!,
    headers: { authorization: `Bearer ${process.env.AUDIT_TOKEN!}` },
  }),
);
const opsExporter = new BatchSpanProcessor(
  new OTLPHttpJsonExporter({ url: process.env.OPS_OTLP! }),
);

// Only audit spans reach the audit pipeline.
const auditOnly = new FilteringSpanProcessor({
  include: (span) => span.attributes['audit'] === true,
  next: auditExporter,
});

// Conversely, ops never sees audit spans (avoid leaking PII to dashboards).
const opsOnly = new FilteringSpanProcessor({
  exclude: (span) => span.attributes['audit'] === true,
  next: opsExporter,
});

export const otelConfig = defineConfig({
  service: { name: 'app' },
  spanProcessors: composeSpanProcessors([auditOnly, opsOnly]),
});
```

## Step 4: Tamper detection

For environments where audit storage is shared with the producing service (no append-only bucket), sign each span:

```typescript
import { createHmac, randomUUID } from 'node:crypto'

function signAuditAttributes(attrs: Record<string, unknown>): string {
  const key = process.env.AUDIT_HMAC_KEY!
  const payload = JSON.stringify(Object.fromEntries(Object.entries(attrs).sort()))
  return createHmac('sha256', key).update(payload).digest('hex')
}

export function audit(payload: AuditPayload): void {
  const id = randomUUID()
  const attributes = { /* … as before … */, 'audit.id': id }
  const signature = signAuditAttributes(attributes)
  attributes['audit.signature.alg'] = 'HMAC-SHA256'
  attributes['audit.signature.value'] = signature
  // … startSpan …
}
```

Verify on the read side: recompute the HMAC over the same sorted attribute set (excluding `audit.signature.value` itself); mismatched ⇒ tampered.

For multi-tenant or extra-strict (HIPAA), use Ed25519 with per-environment keys and rotate.

## Step 5: Redaction — what stays and what goes

| Field                    | In audit span? | Notes                                                                                 |
| ------------------------ | -------------- | ------------------------------------------------------------------------------------- |
| `enduser.id`             | ✅             | Internal user id; never the email                                                     |
| `audit.resource.id`      | ✅             | Required for forensics                                                                |
| `client.address`         | ✅             | Last-octet redaction acceptable for IPv4                                              |
| Free-form payload bodies | ❌             | Never inline raw input — link by id (`audit.evidence.id`)                             |
| Secret values            | ❌             | Use `audit.action=secret.read` + `audit.resource.id=sec_abc`, never the secret itself |
| Authorization headers    | ❌             | Token names ok (`bearer.*`), values never                                             |

`attributeRedactor` defaults are too aggressive for audit (you may need `enduser.id` literal, not masked). Disable redaction selectively:

```typescript
spanProcessors: composeSpanProcessors([
  // No redactor on the audit branch — keys are already conservative
  auditOnly,
  // Strict redactor on ops
  new AttributeRedactingProcessor(opsOnly, { redactor: 'strict' }),
]);
```

## Step 6: Retention

Audit retention is set by regulation, not engineering taste. Common minimums:

| Regulation           | Minimum retention                            |
| -------------------- | -------------------------------------------- |
| GDPR                 | 6 years (financial), 12 months (operational) |
| HIPAA                | 6 years                                      |
| PCI-DSS              | 1 year (online), 3 months hot                |
| SOX                  | 7 years                                      |
| GxP / 21 CFR Part 11 | Lifetime of product + 10 years               |

Express retention as a backend lifecycle policy (S3 Object Lock COMPLIANCE mode, BigQuery `--time_partitioning_expiration`), not application code.

## Step 7: Framework wiring

### Next.js

```typescript
// app/admin/users/[id]/route.ts
import { withAuthz, audit } from '@/lib/audit';

export async function DELETE(
  req: Request,
  { params }: { params: { id: string } },
) {
  return withAuthz(
    {
      action: 'user.delete',
      resource: { type: 'user', id: params.id },
      actor: { id: req.headers.get('x-user-id')!, role: 'admin' },
    },
    async () => ({ allow: await canDelete(req, params.id) }),
    async () => {
      await db.user.delete({ where: { id: params.id } });
      return Response.json({ ok: true });
    },
  );
}
```

### Hono

```typescript
import { audit, withAuthz } from './audit';
app.post('/secrets/:id/read', async (c) => {
  return withAuthz(
    {
      action: 'secret.read',
      resource: { type: 'secret', id: c.req.param('id') },
      actor: { id: c.var.user.id, role: c.var.user.role },
    },
    () => requireScope(c, 'secrets:read'),
    async () => c.json({ value: await secrets.read(c.req.param('id')) }),
  );
});
```

### Cloudflare Workers

`audit()` from inside `defineWorkerFetch` — `ctx.waitUntil` makes sure the audit span is exported before the response returns:

```typescript
export default defineWorkerFetch(
  { service: { name: 'admin-api' } },
  async (request, env, ctx, log) => {
    return withAuthz(
      {
        action: 'data.export',
        resource: { type: 'project', id: 'p_123' },
        actor: { id: 'usr_42' },
      },
      async () => ({ allow: true }),
      async () => Response.json({ ok: true }),
    );
  },
);
```

## Anti-patterns

| Anti-pattern                                   | Fix                                                            |
| ---------------------------------------------- | -------------------------------------------------------------- |
| Audit logs in `console.log` / unstructured     | Use `audit()` so every event has the same shape                |
| Same backend for audit and ops                 | Separate processors, separate retention                        |
| Audit subject to sampling                      | `FilteringSpanProcessor` with `include: span.attributes.audit` |
| Logging only successes                         | Always log denials too                                         |
| Putting secrets / payloads in audit attributes | Reference by id only (`audit.evidence.id`)                     |
| No tamper detection                            | HMAC signature on critical environments                        |
| Custom retention in code                       | Express via storage-layer lifecycle policy                     |
| Audit on every read of harmless data           | Audit _meaningful_ events; not every list call                 |
| Audit row tied to a specific framework         | The `audit()` function is framework-agnostic                   |
| `enduser.id` = email                           | Use the internal id; emails go in a separate identity table    |
