From 7ef196aaee049df641618df7beec6ff2699460ff Mon Sep 17 00:00:00 2001 From: Dan Sutton Date: Wed, 6 May 2026 16:06:31 +0100 Subject: [PATCH] feat(webapp): link Sentry events to OTel traces via trace_id Stamps the active OpenTelemetry trace_id and span_id onto every Sentry event captured from the webapp, plus an otel_sampled tag indicating whether the corresponding trace was head-sampled. Engineers can now copy the trace_id from any Sentry issue and search their tracing backend by it directly. Implemented as a single global Sentry event processor registered after Sentry.init in apps/webapp/sentry.server.ts. The processor reads the active OTel context via @opentelemetry/api and writes Sentry's native contexts.trace fields. No tracer config or sampling changes; no client-side Sentry init exists in this codebase. Co-Authored-By: Claude Opus 4.7 (1M context) --- .server-changes/sentry-trace-id-context.md | 6 + .../app/utils/sentryTraceContext.server.ts | 52 +++++++ apps/webapp/package.json | 2 +- apps/webapp/sentry.server.ts | 3 + .../test/sentryTraceContext.server.test.ts | 127 ++++++++++++++++++ 5 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 .server-changes/sentry-trace-id-context.md create mode 100644 apps/webapp/app/utils/sentryTraceContext.server.ts create mode 100644 apps/webapp/test/sentryTraceContext.server.test.ts diff --git a/.server-changes/sentry-trace-id-context.md b/.server-changes/sentry-trace-id-context.md new file mode 100644 index 00000000000..eaf2c333aa3 --- /dev/null +++ b/.server-changes/sentry-trace-id-context.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Stamp the active OpenTelemetry trace_id and span_id onto every Sentry event so issues can be cross-referenced with traces in any OTel backend. diff --git a/apps/webapp/app/utils/sentryTraceContext.server.ts b/apps/webapp/app/utils/sentryTraceContext.server.ts new file mode 100644 index 00000000000..bad678c34d8 --- /dev/null +++ b/apps/webapp/app/utils/sentryTraceContext.server.ts @@ -0,0 +1,52 @@ +import { type Span, TraceFlags, trace } from "@opentelemetry/api"; +import type { Event, EventHint } from "@sentry/remix"; + +export type GetActiveSpan = () => Span | undefined; + +const defaultGetActiveSpan: GetActiveSpan = () => trace.getActiveSpan(); + +export function getActiveTraceIds( + getActiveSpan: GetActiveSpan = defaultGetActiveSpan +): { traceId: string; spanId: string; sampled: boolean } | undefined { + try { + const span = getActiveSpan(); + if (!span) return undefined; + const ctx = span.spanContext(); + return { + traceId: ctx.traceId, + spanId: ctx.spanId, + sampled: (ctx.traceFlags & TraceFlags.SAMPLED) !== 0, + }; + } catch { + return undefined; + } +} + +export function addOtelTraceContextToEvent( + event: Event, + _hint: EventHint, + getActiveSpan: GetActiveSpan = defaultGetActiveSpan +): Event { + const ids = getActiveTraceIds(getActiveSpan); + if (!ids) return event; + // We intentionally overwrite Sentry's own trace_id/span_id on contexts.trace. + // With skipOpenTelemetrySetup: true, Sentry generates an internal trace_id + // unrelated to OTel; replacing it with the active OTel ids is the whole + // point of this processor — it makes Sentry issues navigable to the + // corresponding OTel trace in any backend. + return { + ...event, + contexts: { + ...event.contexts, + trace: { + ...event.contexts?.trace, + trace_id: ids.traceId, + span_id: ids.spanId, + }, + }, + tags: { + ...event.tags, + otel_sampled: ids.sampled ? "true" : "false", + }, + }; +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 0880eb71037..5b2725288dd 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -7,7 +7,7 @@ "build": "run-s build:** && pnpm run upload:sourcemaps", "build:remix": "remix build --sourcemap", "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --sourcemap", - "build:sentry": "esbuild --platform=node --format=cjs ./sentry.server.ts --outdir=build --sourcemap", + "build:sentry": "esbuild --platform=node --format=cjs --outbase=. ./sentry.server.ts ./app/utils/sentryTraceContext.server.ts --outdir=build --sourcemap", "dev": "cross-env PORT=3030 remix dev -c \"node ./build/server.js\"", "dev:worker": "cross-env NODE_PATH=../../node_modules/.pnpm/node_modules node ./build/server.js", "format": "prettier --write .", diff --git a/apps/webapp/sentry.server.ts b/apps/webapp/sentry.server.ts index d34e676dca5..ee84c1d0e32 100644 --- a/apps/webapp/sentry.server.ts +++ b/apps/webapp/sentry.server.ts @@ -1,4 +1,5 @@ import * as Sentry from "@sentry/remix"; +import { addOtelTraceContextToEvent } from "./app/utils/sentryTraceContext.server"; if (process.env.SENTRY_DSN) { console.log("🔭 Initializing Sentry"); @@ -29,4 +30,6 @@ if (process.env.SENTRY_DSN) { ignoreErrors: ["queryRoute() call aborted", /^ServiceValidationError(?::|$)/], includeLocalVariables: false, }); + + Sentry.addEventProcessor(addOtelTraceContextToEvent); } diff --git a/apps/webapp/test/sentryTraceContext.server.test.ts b/apps/webapp/test/sentryTraceContext.server.test.ts new file mode 100644 index 00000000000..231d9387e10 --- /dev/null +++ b/apps/webapp/test/sentryTraceContext.server.test.ts @@ -0,0 +1,127 @@ +import { ROOT_CONTEXT, TraceFlags, context, trace } from "@opentelemetry/api"; +import { describe, expect, it } from "vitest"; +import { + addOtelTraceContextToEvent, + getActiveTraceIds, +} from "../app/utils/sentryTraceContext.server"; +import { createInMemoryTracing } from "./utils/tracing"; + +describe("getActiveTraceIds", () => { + it("returns undefined when no OTel span is active", () => { + expect(getActiveTraceIds()).toBeUndefined(); + }); + + it("returns the trace_id, span_id, and sampled=true for an active recording span", () => { + const { tracer } = createInMemoryTracing(); + + tracer.startActiveSpan("test-span", (span) => { + const ids = getActiveTraceIds(); + expect(ids).toEqual({ + traceId: span.spanContext().traceId, + spanId: span.spanContext().spanId, + sampled: true, + }); + span.end(); + }); + }); + + it("returns sampled=false when the active span is non-recording", () => { + // Initialise the global context manager (createInMemoryTracing does this + // as a side effect of NodeTracerProvider.register()). + createInMemoryTracing(); + + const nonSampledSpan = trace.wrapSpanContext({ + traceId: "0123456789abcdef0123456789abcdef", + spanId: "0123456789abcdef", + traceFlags: TraceFlags.NONE, + }); + + context.with(trace.setSpan(ROOT_CONTEXT, nonSampledSpan), () => { + expect(getActiveTraceIds()).toEqual({ + traceId: "0123456789abcdef0123456789abcdef", + spanId: "0123456789abcdef", + sampled: false, + }); + }); + }); +}); + +describe("addOtelTraceContextToEvent", () => { + it("returns the event unchanged when no OTel span is active", () => { + const event = { message: "boom" }; + const result = addOtelTraceContextToEvent(event, {}); + expect(result).toBe(event); + expect(result).toEqual({ message: "boom" }); + }); + + it("stamps trace_id and span_id from the active span onto event.contexts.trace", () => { + const { tracer } = createInMemoryTracing(); + + tracer.startActiveSpan("test-span", (span) => { + const event = { message: "boom" }; + const result = addOtelTraceContextToEvent(event, {}); + expect(result.contexts?.trace?.trace_id).toBe(span.spanContext().traceId); + expect(result.contexts?.trace?.span_id).toBe(span.spanContext().spanId); + span.end(); + }); + }); + + it("tags the event with otel_sampled=true when the active span is recording", () => { + const { tracer } = createInMemoryTracing(); + + tracer.startActiveSpan("test-span", (span) => { + const event = { message: "boom" }; + const result = addOtelTraceContextToEvent(event, {}); + expect(result.tags?.otel_sampled).toBe("true"); + span.end(); + }); + }); + + it("tags the event with otel_sampled=false when the active span is non-recording", () => { + createInMemoryTracing(); + + const nonSampledSpan = trace.wrapSpanContext({ + traceId: "0123456789abcdef0123456789abcdef", + spanId: "0123456789abcdef", + traceFlags: TraceFlags.NONE, + }); + + context.with(trace.setSpan(ROOT_CONTEXT, nonSampledSpan), () => { + const event = { message: "boom" }; + const result = addOtelTraceContextToEvent(event, {}); + expect(result.tags?.otel_sampled).toBe("false"); + }); + }); + + it("preserves existing event.contexts.trace fields", () => { + const { tracer } = createInMemoryTracing(); + + tracer.startActiveSpan("test-span", (span) => { + const event = { + message: "boom", + contexts: { + trace: { op: "http.server", description: "GET /things" }, + runtime: { name: "node" }, + }, + }; + const result = addOtelTraceContextToEvent(event, {}); + expect(result.contexts?.trace).toMatchObject({ + op: "http.server", + description: "GET /things", + trace_id: span.spanContext().traceId, + span_id: span.spanContext().spanId, + }); + expect(result.contexts?.runtime).toEqual({ name: "node" }); + span.end(); + }); + }); + + it("returns the event unchanged if reading the OTel context throws", () => { + const throwingAccessor = () => { + throw new Error("otel api blew up"); + }; + const event = { message: "boom" }; + const result = addOtelTraceContextToEvent(event, {}, throwingAccessor); + expect(result).toBe(event); + }); +});