feat(core-audit): TraceIdEnrichingAuditLog decorator for OTel correlation

Decorator implementing IAuditLog that auto-populates AuditEntry.correlationId
from the active OTel span via currentTraceId(). Caller-supplied correlationId
always wins (explicit > implicit). eraseSubject passes through unmodified.
Adds @opentelemetry/{api,sdk-trace-base,context-async-hooks} as devDeps for
test infrastructure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 16:29:15 +02:00
parent 098fcd0d51
commit 07548c4620
3 changed files with 141 additions and 1 deletions

View File

@@ -25,9 +25,15 @@
"payload": "^3.0.0"
},
"peerDependenciesMeta": {
"payload": { "optional": true }
"payload": {
"optional": true
}
},
"devDependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.55.0",
"@opentelemetry/context-async-hooks": "^1.28.0",
"@opentelemetry/sdk-trace-base": "^1.27.0",
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { context, trace } from "@opentelemetry/api";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log";
import type { AuditEntry } from "@repo/core-shared/audit";
import type { IAuditLog } from "./audit-log.interface";
// Register async context manager so startActiveSpan propagates context.
const ctxManager = new AsyncLocalStorageContextManager();
ctxManager.enable();
context.setGlobalContextManager(ctxManager);
function setupProvider(): { exporter: InMemorySpanExporter; provider: BasicTracerProvider } {
const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
trace.setGlobalTracerProvider(provider);
return { exporter, provider };
}
const sample: AuditEntry = {
actorId: "user_1",
actorType: "user",
actorRoles: [],
action: "VIEW",
resource: { type: "articles" },
at: new Date(),
scope: { feature: "blog", environment: "test", tenant: "default" },
from: { ipTruncated: "10.0.0.0", userAgent: "test" },
containsPii: false,
outcome: "success",
};
function makeInner(): IAuditLog & { records: AuditEntry[] } {
const records: AuditEntry[] = [];
return {
records,
async record(e) {
records.push(e);
},
eraseSubject: vi.fn(),
};
}
describe("TraceIdEnrichingAuditLog", () => {
let exporter: InMemorySpanExporter;
let provider: BasicTracerProvider;
beforeEach(() => {
({ exporter, provider } = setupProvider());
});
afterEach(async () => {
await provider.shutdown();
trace.disable();
void exporter;
});
it("passes through when no active span", async () => {
const inner = makeInner();
const wrapper = new TraceIdEnrichingAuditLog(inner);
await wrapper.record(sample);
expect(inner.records[0]!.correlationId).toBeUndefined();
});
it("auto-populates correlationId from active span", async () => {
const inner = makeInner();
const wrapper = new TraceIdEnrichingAuditLog(inner);
const tracer = trace.getTracer("test");
await new Promise<void>((resolve) => {
tracer.startActiveSpan("test", async (span) => {
await wrapper.record(sample);
const expected = span.spanContext().traceId;
expect(inner.records[0]!.correlationId).toBe(expected);
span.end();
resolve();
});
});
});
it("explicit correlationId wins over auto-populated", async () => {
const inner = makeInner();
const wrapper = new TraceIdEnrichingAuditLog(inner);
const tracer = trace.getTracer("test");
await new Promise<void>((resolve) => {
tracer.startActiveSpan("test", async (span) => {
await wrapper.record({ ...sample, correlationId: "explicit-trace-id" });
expect(inner.records[0]!.correlationId).toBe("explicit-trace-id");
span.end();
resolve();
});
});
});
it("eraseSubject passes through unchanged", async () => {
const eraseSpy = vi.fn();
const inner: IAuditLog = { record: vi.fn(), eraseSubject: eraseSpy };
const wrapper = new TraceIdEnrichingAuditLog(inner);
await wrapper.eraseSubject("user_1", "delete");
expect(eraseSpy).toHaveBeenCalledWith("user_1", "delete");
});
});

View File

@@ -0,0 +1,30 @@
import type { AuditEntry } from "@repo/core-shared/audit";
import { currentTraceId } from "@repo/core-shared/instrumentation";
import type { IAuditLog } from "./audit-log.interface";
/**
* Decorates any IAuditLog by auto-populating AuditEntry.correlationId from
* the active OTel span (when present and the caller didn't supply a value).
* Caller-supplied correlationId always wins — explicit > implicit.
*
* Applied at bind time by bindAudit so all sinks see entries with
* correlationId already set. Single source of truth for the OTel-audit bridge.
*/
export class TraceIdEnrichingAuditLog implements IAuditLog {
constructor(readonly inner: IAuditLog) {}
async record(entry: AuditEntry): Promise<void> {
if (entry.correlationId) {
return this.inner.record(entry);
}
const traceId = currentTraceId();
if (!traceId) {
return this.inner.record(entry);
}
return this.inner.record({ ...entry, correlationId: traceId });
}
eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
return this.inner.eraseSubject(actorId, mode);
}
}