Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/sentry-trace-id-context.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions apps/webapp/app/utils/sentryTraceContext.server.ts
Original file line number Diff line number Diff line change
@@ -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",
},
};
}
2 changes: 1 addition & 1 deletion apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
3 changes: 3 additions & 0 deletions apps/webapp/sentry.server.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -29,4 +30,6 @@ if (process.env.SENTRY_DSN) {
ignoreErrors: ["queryRoute() call aborted", /^ServiceValidationError(?::|$)/],
includeLocalVariables: false,
});

Sentry.addEventProcessor(addOtelTraceContextToEvent);
}
127 changes: 127 additions & 0 deletions apps/webapp/test/sentryTraceContext.server.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment thread
d-cs marked this conversation as resolved.

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,
});
});
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
});
});
Loading