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:
@@ -25,9 +25,15 @@
|
|||||||
"payload": "^3.0.0"
|
"payload": "^3.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"payload": { "optional": true }
|
"payload": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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-eslint": "workspace:*",
|
||||||
"@repo/core-testing": "workspace:*",
|
"@repo/core-testing": "workspace:*",
|
||||||
"@repo/core-typescript": "workspace:*",
|
"@repo/core-typescript": "workspace:*",
|
||||||
|
|||||||
104
packages/core-audit/src/trace-id-enriching-audit-log.test.ts
Normal file
104
packages/core-audit/src/trace-id-enriching-audit-log.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
30
packages/core-audit/src/trace-id-enriching-audit-log.ts
Normal file
30
packages/core-audit/src/trace-id-enriching-audit-log.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user