From 098fcd0d51523633aba28fe6bc0580f79e63b6a4 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 16:27:36 +0200 Subject: [PATCH] feat(core-shared): currentTraceId helper for OTel-audit correlation bridge Reads the active OTel span context via trace.getActiveSpan(); returns the 32-char hex traceId or undefined when no span is active or traceId is the all-zeros invalid value. Re-exported from both instrumentation/otel/index and instrumentation/index barrels. Co-Authored-By: Claude Sonnet 4.6 --- .../core-shared/src/instrumentation/index.ts | 1 + .../otel/current-trace-id.test.ts | 79 +++++++++++++++++++ .../instrumentation/otel/current-trace-id.ts | 20 +++++ .../src/instrumentation/otel/index.ts | 1 + 4 files changed, 101 insertions(+) create mode 100644 packages/core-shared/src/instrumentation/otel/current-trace-id.test.ts create mode 100644 packages/core-shared/src/instrumentation/otel/current-trace-id.ts diff --git a/packages/core-shared/src/instrumentation/index.ts b/packages/core-shared/src/instrumentation/index.ts index 3e01e58..1951bc8 100644 --- a/packages/core-shared/src/instrumentation/index.ts +++ b/packages/core-shared/src/instrumentation/index.ts @@ -28,3 +28,4 @@ export { bindOtelInstrumentation as bindSentryInstrumentation } from "./di/bind- export type { BindOtelOpts as BindSentryOpts } from "./di/bind-otel-instrumentation"; export { initSentryClientReact } from "./sentry/init-client-react"; +export { currentTraceId } from "./otel/current-trace-id"; diff --git a/packages/core-shared/src/instrumentation/otel/current-trace-id.test.ts b/packages/core-shared/src/instrumentation/otel/current-trace-id.test.ts new file mode 100644 index 0000000..2c7c75b --- /dev/null +++ b/packages/core-shared/src/instrumentation/otel/current-trace-id.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach } 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 { currentTraceId } from "./current-trace-id"; + +// 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 }; +} + +describe("currentTraceId", () => { + let exporter: InMemorySpanExporter; + let provider: BasicTracerProvider; + + beforeEach(() => { + ({ exporter, provider } = setupProvider()); + }); + + afterEach(async () => { + await provider.shutdown(); + trace.disable(); + }); + + it("returns undefined when no active span", () => { + expect(currentTraceId()).toBeUndefined(); + }); + + it("returns the active span's traceId when inside startActiveSpan", async () => { + const tracer = trace.getTracer("test"); + await new Promise((resolve) => { + tracer.startActiveSpan("test-span", (span) => { + const id = currentTraceId(); + expect(id).toBeDefined(); + expect(id).toMatch(/^[a-f0-9]{32}$/); + span.end(); + resolve(); + }); + }); + }); + + it("filters all-zeros invalid traceId", () => { + // The INVALID_SPAN (no-op) has traceId "00000000000000000000000000000000" + // which is what getActiveSpan() returns when there is no real span. + // currentTraceId() must treat this as absent. + expect(currentTraceId()).toBeUndefined(); + }); + + // Suppress unused variable warning — exporter used via closure + it("returns distinct traceIds for independent spans", async () => { + const tracer = trace.getTracer("test"); + const ids: string[] = []; + for (let i = 0; i < 2; i++) { + await new Promise((resolve) => { + tracer.startActiveSpan(`span-${i}`, (span) => { + const id = currentTraceId(); + if (id) ids.push(id); + span.end(); + resolve(); + }); + }); + } + // Both spans are in fresh traces — IDs should be valid hex strings + expect(ids).toHaveLength(2); + for (const id of ids) { + expect(id).toMatch(/^[a-f0-9]{32}$/); + } + void exporter; // suppress lint + }); +}); diff --git a/packages/core-shared/src/instrumentation/otel/current-trace-id.ts b/packages/core-shared/src/instrumentation/otel/current-trace-id.ts new file mode 100644 index 0000000..46bb891 --- /dev/null +++ b/packages/core-shared/src/instrumentation/otel/current-trace-id.ts @@ -0,0 +1,20 @@ +import { trace } from "@opentelemetry/api"; + +/** + * Returns the trace ID of the currently active OTel span, or undefined if + * there is no active span (e.g., outside any request context, in unit tests + * without an OTel SDK). + * + * Used by core-audit's TraceIdEnrichingAuditLog decorator to auto-populate + * AuditEntry.correlationId so callers don't have to thread it explicitly. + * + * Returns undefined for the all-zeros invalid trace ID — OTel emits this + * when context propagation hasn't kicked in. + */ +export function currentTraceId(): string | undefined { + const span = trace.getActiveSpan(); + if (!span) return undefined; + const ctx = span.spanContext(); + if (!ctx.traceId || /^0+$/.test(ctx.traceId)) return undefined; + return ctx.traceId; +} diff --git a/packages/core-shared/src/instrumentation/otel/index.ts b/packages/core-shared/src/instrumentation/otel/index.ts index 365a5b9..a35eba0 100644 --- a/packages/core-shared/src/instrumentation/otel/index.ts +++ b/packages/core-shared/src/instrumentation/otel/index.ts @@ -1,2 +1,3 @@ export { initOtelServerNode, type InitOtelServerNodeOpts } from "./init-server-node"; export { buildResource, type BuildResourceOpts } from "./resource"; +export { currentTraceId } from "./current-trace-id";