Skip to content

Commit c3bb682

Browse files
d-csclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 31999af commit c3bb682

5 files changed

Lines changed: 185 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
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.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { TraceFlags, trace } from "@opentelemetry/api";
2+
import type { Event, EventHint } from "@sentry/remix";
3+
4+
export function getActiveTraceIds():
5+
| { traceId: string; spanId: string; sampled: boolean }
6+
| undefined {
7+
try {
8+
const span = trace.getActiveSpan();
9+
if (!span) return undefined;
10+
const ctx = span.spanContext();
11+
return {
12+
traceId: ctx.traceId,
13+
spanId: ctx.spanId,
14+
sampled: (ctx.traceFlags & TraceFlags.SAMPLED) !== 0,
15+
};
16+
} catch {
17+
return undefined;
18+
}
19+
}
20+
21+
export function addOtelTraceContextToEvent(event: Event, _hint: EventHint): Event {
22+
const ids = getActiveTraceIds();
23+
if (!ids) return event;
24+
// We intentionally overwrite Sentry's own trace_id/span_id on contexts.trace.
25+
// With skipOpenTelemetrySetup: true, Sentry generates an internal trace_id
26+
// unrelated to OTel; replacing it with the active OTel ids is the whole
27+
// point of this processor — it makes Sentry issues navigable to the
28+
// corresponding OTel trace in any backend.
29+
return {
30+
...event,
31+
contexts: {
32+
...event.contexts,
33+
trace: {
34+
...event.contexts?.trace,
35+
trace_id: ids.traceId,
36+
span_id: ids.spanId,
37+
},
38+
},
39+
tags: {
40+
...event.tags,
41+
otel_sampled: ids.sampled ? "true" : "false",
42+
},
43+
};
44+
}

apps/webapp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"build": "run-s build:** && pnpm run upload:sourcemaps",
88
"build:remix": "remix build --sourcemap",
99
"build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --sourcemap",
10-
"build:sentry": "esbuild --platform=node --format=cjs ./sentry.server.ts --outdir=build --sourcemap",
10+
"build:sentry": "esbuild --platform=node --format=cjs --outbase=. ./sentry.server.ts ./app/utils/sentryTraceContext.server.ts --outdir=build --sourcemap",
1111
"dev": "cross-env PORT=3030 remix dev -c \"node ./build/server.js\"",
1212
"dev:worker": "cross-env NODE_PATH=../../node_modules/.pnpm/node_modules node ./build/server.js",
1313
"format": "prettier --write .",

apps/webapp/sentry.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as Sentry from "@sentry/remix";
2+
import { addOtelTraceContextToEvent } from "./app/utils/sentryTraceContext.server";
23

34
if (process.env.SENTRY_DSN) {
45
console.log("🔭 Initializing Sentry");
@@ -29,4 +30,6 @@ if (process.env.SENTRY_DSN) {
2930
ignoreErrors: ["queryRoute() call aborted", /^ServiceValidationError(?::|$)/],
3031
includeLocalVariables: false,
3132
});
33+
34+
Sentry.addEventProcessor(addOtelTraceContextToEvent);
3235
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { ROOT_CONTEXT, TraceFlags, context, trace } from "@opentelemetry/api";
2+
import { describe, expect, it, vi } from "vitest";
3+
import {
4+
addOtelTraceContextToEvent,
5+
getActiveTraceIds,
6+
} from "../app/utils/sentryTraceContext.server";
7+
import { createInMemoryTracing } from "./utils/tracing";
8+
9+
describe("getActiveTraceIds", () => {
10+
it("returns undefined when no OTel span is active", () => {
11+
expect(getActiveTraceIds()).toBeUndefined();
12+
});
13+
14+
it("returns the trace_id, span_id, and sampled=true for an active recording span", () => {
15+
const { tracer } = createInMemoryTracing();
16+
17+
tracer.startActiveSpan("test-span", (span) => {
18+
const ids = getActiveTraceIds();
19+
expect(ids).toEqual({
20+
traceId: span.spanContext().traceId,
21+
spanId: span.spanContext().spanId,
22+
sampled: true,
23+
});
24+
span.end();
25+
});
26+
});
27+
28+
it("returns sampled=false when the active span is non-recording", () => {
29+
// Initialise the global context manager (createInMemoryTracing does this
30+
// as a side effect of NodeTracerProvider.register()).
31+
createInMemoryTracing();
32+
33+
const nonSampledSpan = trace.wrapSpanContext({
34+
traceId: "0123456789abcdef0123456789abcdef",
35+
spanId: "0123456789abcdef",
36+
traceFlags: TraceFlags.NONE,
37+
});
38+
39+
context.with(trace.setSpan(ROOT_CONTEXT, nonSampledSpan), () => {
40+
expect(getActiveTraceIds()).toEqual({
41+
traceId: "0123456789abcdef0123456789abcdef",
42+
spanId: "0123456789abcdef",
43+
sampled: false,
44+
});
45+
});
46+
});
47+
});
48+
49+
describe("addOtelTraceContextToEvent", () => {
50+
it("returns the event unchanged when no OTel span is active", () => {
51+
const event = { message: "boom" };
52+
const result = addOtelTraceContextToEvent(event, {});
53+
expect(result).toBe(event);
54+
expect(result).toEqual({ message: "boom" });
55+
});
56+
57+
it("stamps trace_id and span_id from the active span onto event.contexts.trace", () => {
58+
const { tracer } = createInMemoryTracing();
59+
60+
tracer.startActiveSpan("test-span", (span) => {
61+
const event = { message: "boom" };
62+
const result = addOtelTraceContextToEvent(event, {});
63+
expect(result.contexts?.trace?.trace_id).toBe(span.spanContext().traceId);
64+
expect(result.contexts?.trace?.span_id).toBe(span.spanContext().spanId);
65+
span.end();
66+
});
67+
});
68+
69+
it("tags the event with otel_sampled=true when the active span is recording", () => {
70+
const { tracer } = createInMemoryTracing();
71+
72+
tracer.startActiveSpan("test-span", (span) => {
73+
const event = { message: "boom" };
74+
const result = addOtelTraceContextToEvent(event, {});
75+
expect(result.tags?.otel_sampled).toBe("true");
76+
span.end();
77+
});
78+
});
79+
80+
it("tags the event with otel_sampled=false when the active span is non-recording", () => {
81+
createInMemoryTracing();
82+
83+
const nonSampledSpan = trace.wrapSpanContext({
84+
traceId: "0123456789abcdef0123456789abcdef",
85+
spanId: "0123456789abcdef",
86+
traceFlags: TraceFlags.NONE,
87+
});
88+
89+
context.with(trace.setSpan(ROOT_CONTEXT, nonSampledSpan), () => {
90+
const event = { message: "boom" };
91+
const result = addOtelTraceContextToEvent(event, {});
92+
expect(result.tags?.otel_sampled).toBe("false");
93+
});
94+
});
95+
96+
it("preserves existing event.contexts.trace fields", () => {
97+
const { tracer } = createInMemoryTracing();
98+
99+
tracer.startActiveSpan("test-span", (span) => {
100+
const event = {
101+
message: "boom",
102+
contexts: {
103+
trace: { op: "http.server", description: "GET /things" },
104+
runtime: { name: "node" },
105+
},
106+
};
107+
const result = addOtelTraceContextToEvent(event, {});
108+
expect(result.contexts?.trace).toMatchObject({
109+
op: "http.server",
110+
description: "GET /things",
111+
trace_id: span.spanContext().traceId,
112+
span_id: span.spanContext().spanId,
113+
});
114+
expect(result.contexts?.runtime).toEqual({ name: "node" });
115+
span.end();
116+
});
117+
});
118+
119+
it("returns the event unchanged if reading the OTel context throws", () => {
120+
const spy = vi.spyOn(trace, "getActiveSpan").mockImplementation(() => {
121+
throw new Error("otel api blew up");
122+
});
123+
try {
124+
const event = { message: "boom" };
125+
const result = addOtelTraceContextToEvent(event, {});
126+
expect(result).toBe(event);
127+
} finally {
128+
spy.mockRestore();
129+
}
130+
});
131+
});

0 commit comments

Comments
 (0)