Skip to content

Commit 693f829

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 693f829

3 files changed

Lines changed: 180 additions & 0 deletions

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.

apps/webapp/sentry.server.ts

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

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

0 commit comments

Comments
 (0)