Skip to content

Commit b565b86

Browse files
logaretmclaude
andcommitted
fix: use vendored tracingChannel and fix span ending in nitro tracing hooks
Replace otel-tracing-channel with @sentry/opentelemetry's vendored tracingChannel. Fix data.span -> data._sentrySpan references so tracing channel spans are actually ended. Export TracingChannelContextWithSpan type for consumers. Add e2e span nesting tests to verify context propagation through the full srvx -> h3 -> user code span tree. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4669dc5 commit b565b86

File tree

8 files changed

+183
-26
lines changed

8 files changed

+183
-26
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { startSpan } from '@sentry/nitro';
2+
import { defineHandler } from 'nitro/h3';
3+
4+
export default defineHandler(() => {
5+
startSpan({ name: 'db.select', op: 'db' }, () => {
6+
// simulate a select query
7+
});
8+
9+
startSpan({ name: 'db.insert', op: 'db' }, () => {
10+
startSpan({ name: 'db.serialize', op: 'serialize' }, () => {
11+
// simulate serializing data before insert
12+
});
13+
});
14+
15+
return { status: 'ok', nesting: true };
16+
});
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('Span nesting: all spans share the same trace_id', async ({ request }) => {
5+
const transactionEventPromise = waitForTransaction('nitro-3', event => {
6+
return event?.transaction === 'GET /api/test-nesting';
7+
});
8+
9+
await request.get('/api/test-nesting');
10+
11+
const event = await transactionEventPromise;
12+
const traceId = event.contexts?.trace?.trace_id;
13+
14+
expect(traceId).toMatch(/[a-f0-9]{32}/);
15+
16+
// Every child span must belong to the same trace
17+
for (const span of event.spans ?? []) {
18+
expect(span.trace_id).toBe(traceId);
19+
}
20+
});
21+
22+
test('Span nesting: h3 middleware spans are children of the srvx request span', async ({ request }) => {
23+
const transactionEventPromise = waitForTransaction('nitro-3', event => {
24+
return event?.transaction === 'GET /api/test-nesting';
25+
});
26+
27+
await request.get('/api/test-nesting');
28+
29+
const event = await transactionEventPromise;
30+
31+
// Find the srvx request span
32+
const srvxSpan = event.spans?.find(span => span.origin === 'auto.http.nitro.srvx' && span.op === 'http.server');
33+
expect(srvxSpan).toBeDefined();
34+
35+
// All h3 middleware spans should be children of the srvx span
36+
const h3Spans = event.spans?.filter(span => span.origin === 'auto.http.nitro.h3');
37+
expect(h3Spans?.length).toBeGreaterThanOrEqual(1);
38+
39+
for (const span of h3Spans ?? []) {
40+
expect(span.parent_span_id).toBe(srvxSpan!.span_id);
41+
}
42+
});
43+
44+
test('Span nesting: manual startSpan calls inside route handler are children of the srvx request span', async ({
45+
request,
46+
}) => {
47+
const transactionEventPromise = waitForTransaction('nitro-3', event => {
48+
return event?.transaction === 'GET /api/test-nesting';
49+
});
50+
51+
await request.get('/api/test-nesting');
52+
53+
const event = await transactionEventPromise;
54+
55+
// Find the srvx request span — this is the parent of all h3 and manual spans
56+
const srvxSpan = event.spans?.find(span => span.origin === 'auto.http.nitro.srvx' && span.op === 'http.server');
57+
expect(srvxSpan).toBeDefined();
58+
const srvxSpanId = srvxSpan!.span_id;
59+
60+
// Find the manually created db spans
61+
const dbSelectSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.select');
62+
const dbInsertSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.insert');
63+
expect(dbSelectSpan).toBeDefined();
64+
expect(dbInsertSpan).toBeDefined();
65+
66+
// FIXME: Once nitro's h3 tracing plugin emits a separate span for route handlers (type: "route"),
67+
// the db spans should be children of the h3 route handler span, not the srvx span directly.
68+
// Currently nitro bypasses h3's ~routes for file-based routing, so h3 only emits middleware spans.
69+
// Both db spans should be children of the srvx request span
70+
expect(dbSelectSpan!.parent_span_id).toBe(srvxSpanId);
71+
expect(dbInsertSpan!.parent_span_id).toBe(srvxSpanId);
72+
73+
// Both db spans should be siblings (same parent)
74+
expect(dbSelectSpan!.parent_span_id).toBe(dbInsertSpan!.parent_span_id);
75+
76+
// The serialize span should be nested inside the db.insert span
77+
const serializeSpan = event.spans?.find(span => span.op === 'serialize' && span.description === 'db.serialize');
78+
expect(serializeSpan).toBeDefined();
79+
expect(serializeSpan!.parent_span_id).toBe(dbInsertSpan!.span_id);
80+
});
81+
82+
// FIXME: Nitro's file-based routing bypasses h3's ~routes, so h3's tracing plugin never wraps
83+
// route handlers with type: "route". Once this is fixed upstream or we add our own wrapping,
84+
// uncomment these tests to verify the h3 route handler span exists and is the parent of manual spans.
85+
//
86+
// test('Span nesting: h3 route handler span is a child of the srvx request span', async ({ request }) => {
87+
// const transactionEventPromise = waitForTransaction('nitro-3', event => {
88+
// return event?.transaction === 'GET /api/test-nesting';
89+
// });
90+
//
91+
// await request.get('/api/test-nesting');
92+
//
93+
// const event = await transactionEventPromise;
94+
//
95+
// const srvxSpan = event.spans?.find(span => span.origin === 'auto.http.nitro.srvx' && span.op === 'http.server');
96+
// expect(srvxSpan).toBeDefined();
97+
//
98+
// const h3HandlerSpan = event.spans?.find(
99+
// span => span.origin === 'auto.http.nitro.h3' && span.op === 'http.server',
100+
// );
101+
// expect(h3HandlerSpan).toBeDefined();
102+
// expect(h3HandlerSpan!.parent_span_id).toBe(srvxSpan!.span_id);
103+
// });
104+
//
105+
// test('Span nesting: manual startSpan calls are children of the h3 route handler span', async ({ request }) => {
106+
// const transactionEventPromise = waitForTransaction('nitro-3', event => {
107+
// return event?.transaction === 'GET /api/test-nesting';
108+
// });
109+
//
110+
// await request.get('/api/test-nesting');
111+
//
112+
// const event = await transactionEventPromise;
113+
//
114+
// const h3HandlerSpan = event.spans?.find(
115+
// span => span.origin === 'auto.http.nitro.h3' && span.op === 'http.server',
116+
// );
117+
// expect(h3HandlerSpan).toBeDefined();
118+
//
119+
// const dbSelectSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.select');
120+
// const dbInsertSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.insert');
121+
// expect(dbSelectSpan!.parent_span_id).toBe(h3HandlerSpan!.span_id);
122+
// expect(dbInsertSpan!.parent_span_id).toBe(h3HandlerSpan!.span_id);
123+
// });
124+
125+
test('Span nesting: middleware spans start before manual spans in the span tree', async ({ request }) => {
126+
const transactionEventPromise = waitForTransaction('nitro-3', event => {
127+
return event?.transaction === 'GET /api/test-nesting';
128+
});
129+
130+
await request.get('/api/test-nesting');
131+
132+
const event = await transactionEventPromise;
133+
134+
// Middleware spans should start before the manual db spans
135+
const middlewareSpans = event.spans?.filter(span => span.op === 'middleware.nitro') ?? [];
136+
const dbSpans = event.spans?.filter(span => span.op === 'db') ?? [];
137+
138+
expect(middlewareSpans.length).toBeGreaterThanOrEqual(1);
139+
expect(dbSpans.length).toBeGreaterThanOrEqual(1);
140+
141+
const earliestMiddlewareStart = Math.min(...middlewareSpans.map(s => s.start_timestamp));
142+
const earliestDbStart = Math.min(...dbSpans.map(s => s.start_timestamp));
143+
144+
// Middleware should start before the db spans
145+
expect(earliestMiddlewareStart).toBeLessThanOrEqual(earliestDbStart);
146+
});

packages/nitro/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"dependencies": {
4141
"@sentry/core": "10.48.0",
4242
"@sentry/node": "10.48.0",
43-
"otel-tracing-channel": "^0.2.0"
43+
"@sentry/opentelemetry": "10.48.0"
4444
},
4545
"devDependencies": {
4646
"h3": "^2.0.1-rc.13",

packages/nitro/rollup.npm.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export default [
55
makeBaseNPMConfig({
66
entrypoints: ['src/index.ts', 'src/runtime/plugins/server.ts'],
77
packageSpecificConfig: {
8-
external: [/^nitro/, 'otel-tracing-channel', /^h3/, /^srvx/],
8+
external: [/^nitro/, /^h3/, /^srvx/],
99
},
1010
}),
1111
),

packages/nitro/src/runtime/hooks/captureTracingEvents.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import {
1616
startSpanManual,
1717
updateSpanName,
1818
} from '@sentry/core';
19+
import { tracingChannel, type TracingChannelContextWithSpan } from '@sentry/opentelemetry';
1920
import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing';
20-
import { tracingChannel } from 'otel-tracing-channel';
2121
import type { RequestEvent as SrvxRequestEvent } from 'srvx/tracing';
2222
import { setServerTimingHeaders } from './setServerTimingHeaders';
2323

@@ -58,19 +58,19 @@ function getResponseStatusCode(result: unknown): number | undefined {
5858
return undefined;
5959
}
6060

61-
function onTraceEnd(data: { span?: Span; result?: unknown }): void {
61+
function onTraceEnd(data: TracingChannelContextWithSpan<{ result?: unknown }>): void {
6262
const statusCode = getResponseStatusCode(data.result);
63-
if (data.span && statusCode !== undefined) {
64-
setHttpStatus(data.span, statusCode);
63+
if (data._sentrySpan && statusCode !== undefined) {
64+
setHttpStatus(data._sentrySpan, statusCode);
6565
}
6666

67-
data.span?.end();
67+
data._sentrySpan?.end();
6868
}
6969

70-
function onTraceError(data: { span?: Span; error: unknown }): void {
70+
function onTraceError(data: TracingChannelContextWithSpan<{ error: unknown }>): void {
7171
captureException(data.error, { mechanism: { type: 'auto.http.nitro.onTraceError', handled: false } });
72-
data.span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
73-
data.span?.end();
72+
data._sentrySpan?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
73+
data._sentrySpan?.end();
7474
}
7575

7676
/**
@@ -128,19 +128,19 @@ function setupH3TracingChannels(): void {
128128
},
129129
asyncStart: NOOP,
130130
end: NOOP,
131-
asyncEnd: (data: H3TracingRequestEvent & { span?: Span; result?: unknown }) => {
131+
asyncEnd: (data: TracingChannelContextWithSpan<H3TracingRequestEvent>) => {
132132
onTraceEnd(data);
133133

134-
if (!data.span) {
134+
if (!data._sentrySpan) {
135135
return;
136136
}
137137

138138
// Update the root span (srvx transaction) with the parameterized route name.
139139
// The srvx span is created before h3 resolves the route, so it initially has the raw URL.
140140
// Note: data.type is always 'middleware' in asyncEnd regardless of handler type,
141141
// so we rely on getParameterizedRoute() to filter out catch-all routes instead.
142-
const rootSpan = getRootSpan(data.span);
143-
if (rootSpan && rootSpan !== data.span) {
142+
const rootSpan = getRootSpan(data._sentrySpan);
143+
if (rootSpan && rootSpan !== data._sentrySpan) {
144144
const routePattern = getParameterizedRoute(data.event);
145145
if (routePattern) {
146146
const method = data.event.req.method || 'GET';

packages/opentelemetry/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export { openTelemetrySetupCheck } from './utils/setupCheck';
5252
export { getSentryResource } from './resource';
5353

5454
export { tracingChannel } from './tracingChannel';
55-
export type { OtelTracingChannel, OtelTracingChannelTransform } from './tracingChannel';
55+
export type { OtelTracingChannel, OtelTracingChannelTransform, TracingChannelContextWithSpan } from './tracingChannel';
5656

5757
export { withStreamedSpan } from '@sentry/core';
5858

packages/opentelemetry/src/tracingChannel.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ import { DEBUG_BUILD } from './debug-build';
1818
*/
1919
export type OtelTracingChannelTransform<TData = object> = (data: TData) => Span;
2020

21-
type WithSpan<TData = object> = TData & { _sentrySpan?: Span };
21+
export type TracingChannelContextWithSpan<TContext extends object = object> = TContext & { _sentrySpan?: Span };
2222

2323
/**
2424
* A TracingChannel whose `subscribe` / `unsubscribe` accept partial subscriber
2525
* objects — you only need to provide handlers for the events you care about.
2626
*/
2727
export interface OtelTracingChannel<
2828
TData extends object = object,
29-
TDataWithSpan extends object = WithSpan<TData>,
29+
TDataWithSpan extends object = TracingChannelContextWithSpan<TData>,
3030
> extends Omit<TracingChannel<TData, TDataWithSpan>, 'subscribe' | 'unsubscribe'> {
3131
subscribe(subscribers: Partial<TracingChannelSubscribers<TDataWithSpan>>): void;
3232
unsubscribe(subscribers: Partial<TracingChannelSubscribers<TDataWithSpan>>): void;
@@ -52,10 +52,10 @@ interface ContextApi {
5252
export function tracingChannel<TData extends object = object>(
5353
channelNameOrInstance: string,
5454
transformStart: OtelTracingChannelTransform<TData>,
55-
): OtelTracingChannel<TData, WithSpan<TData>> {
56-
const channel = nativeTracingChannel<WithSpan<TData>, WithSpan<TData>>(
55+
): OtelTracingChannel<TData, TracingChannelContextWithSpan<TData>> {
56+
const channel = nativeTracingChannel<TracingChannelContextWithSpan<TData>, TracingChannelContextWithSpan<TData>>(
5757
channelNameOrInstance,
58-
) as unknown as OtelTracingChannel<TData, WithSpan<TData>>;
58+
) as unknown as OtelTracingChannel<TData, TracingChannelContextWithSpan<TData>>;
5959

6060
let lookup: AsyncLocalStorageLookup | undefined;
6161
try {
@@ -78,7 +78,7 @@ export function tracingChannel<TData extends object = object>(
7878
// Bind the start channel so that each trace invocation runs the transform
7979
// and stores the resulting context (with span) in AsyncLocalStorage.
8080
// @ts-expect-error bindStore types don't account for AsyncLocalStorage of a different generic type
81-
channel.start.bindStore(otelStorage, (data: WithSpan<TData>) => {
81+
channel.start.bindStore(otelStorage, (data: TracingChannelContextWithSpan<TData>) => {
8282
const span = transformStart(data);
8383

8484
// Store the span on data so downstream event handlers (asyncEnd, error, etc.) can access it.

yarn.lock

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23650,11 +23650,6 @@ osenv@^0.1.3:
2365023650
os-homedir "^1.0.0"
2365123651
os-tmpdir "^1.0.0"
2365223652

23653-
otel-tracing-channel@^0.2.0:
23654-
version "0.2.0"
23655-
resolved "https://registry.yarnpkg.com/otel-tracing-channel/-/otel-tracing-channel-0.2.0.tgz#55c8dafa55dafaa9daf64dd501a4b5d8e58c3f29"
23656-
integrity sha512-m+JtCKi05Ou2MpSsAHFqSCBjc2QDlnmXtOasZXvDnU56uBr4UeClXWKvBK8MsGwNCbGUBqwOOPDbjS7+D9A8lw==
23657-
2365823653
own-keys@^1.0.1:
2365923654
version "1.0.1"
2366023655
resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358"

0 commit comments

Comments
 (0)