Skip to content

Commit 3b8dc3d

Browse files
committed
feat: added server timing headers
1 parent 4126831 commit 3b8dc3d

4 files changed

Lines changed: 218 additions & 177 deletions

File tree

dev-packages/e2e-tests/test-applications/nitro-3/tests/transactions.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,12 @@ test('Sends a transaction event for a parameterized route', async ({ request })
5959
}),
6060
);
6161
});
62+
63+
test('Sets Server-Timing response headers for trace propagation', async ({ request }) => {
64+
const response = await request.get('/test-transaction');
65+
const headers = response.headers();
66+
67+
expect(headers['server-timing']).toBeDefined();
68+
expect(headers['server-timing']).toContain('sentry-trace;desc="');
69+
expect(headers['server-timing']).toContain('baggage;desc="');
70+
});
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import {
2+
captureException,
3+
getActiveSpan,
4+
getClient,
5+
getHttpSpanDetailsFromUrlObject,
6+
GLOBAL_OBJ,
7+
httpHeadersToSpanAttributes,
8+
parseStringToURLObject,
9+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
10+
setHttpStatus,
11+
type Span,
12+
SPAN_STATUS_ERROR,
13+
startSpanManual,
14+
} from '@sentry/core';
15+
import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing';
16+
import { tracingChannel } from 'otel-tracing-channel';
17+
import type { RequestEvent as SrvxRequestEvent } from 'srvx/tracing';
18+
19+
/**
20+
* Global object with the trace channels
21+
*/
22+
const globalWithTraceChannels = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
23+
__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__: boolean;
24+
};
25+
26+
/**
27+
* Captures tracing events emitted by Nitro tracing channels.
28+
*/
29+
export function captureTracingEvents(): void {
30+
if (globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__) {
31+
return;
32+
}
33+
34+
setupH3TracingChannels();
35+
setupSrvxTracingChannels();
36+
globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__ = true;
37+
}
38+
39+
/**
40+
* No-op function to satisfy the tracing channel subscribe callbacks
41+
*/
42+
const NOOP = (): void => {};
43+
44+
/**
45+
* Extracts the HTTP status code from a tracing channel result.
46+
* The result is the return value of the traced handler, which is a Response for srvx
47+
* and may or may not be a Response for h3.
48+
*/
49+
function getResponseStatusCode(result: unknown): number | undefined {
50+
if (result && typeof result === 'object' && 'status' in result && typeof result.status === 'number') {
51+
return result.status;
52+
}
53+
return undefined;
54+
}
55+
56+
function onTraceEnd(data: { span?: Span; result?: unknown }): void {
57+
const statusCode = getResponseStatusCode(data.result);
58+
if (data.span && statusCode !== undefined) {
59+
setHttpStatus(data.span, statusCode);
60+
data.span.end();
61+
}
62+
}
63+
64+
function onTraceError(data: { span?: Span; error: unknown }): void {
65+
captureException(data.error);
66+
data.span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
67+
data.span?.end();
68+
}
69+
70+
function setupH3TracingChannels(): void {
71+
const h3Channel = tracingChannel<H3TracingRequestEvent>('h3.fetch', data => {
72+
const parsedUrl = parseStringToURLObject(data.event.url.href);
73+
const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.h3', {
74+
method: data.event.req.method,
75+
});
76+
77+
return startSpanManual(
78+
{
79+
name: spanName,
80+
attributes: {
81+
...urlAttributes,
82+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: data?.type === 'middleware' ? 'middleware.nitro' : 'http.server',
83+
},
84+
},
85+
s => s,
86+
);
87+
});
88+
89+
h3Channel.subscribe({
90+
start: NOOP,
91+
asyncStart: NOOP,
92+
end: NOOP,
93+
asyncEnd: onTraceEnd,
94+
error: onTraceError,
95+
});
96+
}
97+
98+
function setupSrvxTracingChannels(): void {
99+
// Store the parent span for all middleware and fetch to share
100+
// This ensures they all appear as siblings in the trace
101+
let requestParentSpan: Span | null = null;
102+
103+
const fetchChannel = tracingChannel<SrvxRequestEvent>('srvx.fetch', data => {
104+
const parsedUrl = data.request._url ? parseStringToURLObject(data.request._url.href) : undefined;
105+
const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.srvx', {
106+
method: data.request.method,
107+
});
108+
109+
const sendDefaultPii = getClient()?.getOptions().sendDefaultPii ?? false;
110+
const headerAttributes = httpHeadersToSpanAttributes(
111+
Object.fromEntries(data.request.headers.entries()),
112+
sendDefaultPii,
113+
);
114+
115+
return startSpanManual(
116+
{
117+
name: spanName,
118+
attributes: {
119+
...urlAttributes,
120+
...headerAttributes,
121+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: data.middleware ? 'middleware.nitro' : 'http.server',
122+
'server.port': data.server.options.port,
123+
},
124+
// Use the same parent span as middleware to make them siblings
125+
parentSpan: requestParentSpan || undefined,
126+
},
127+
span => span,
128+
);
129+
});
130+
131+
// Subscribe to events (span already created in bindStore)
132+
fetchChannel.subscribe({
133+
start: () => {},
134+
asyncStart: () => {},
135+
end: () => {},
136+
asyncEnd: data => {
137+
onTraceEnd(data);
138+
139+
// Reset parent span reference after the fetch handler completes
140+
// This ensures each request gets a fresh parent span capture
141+
requestParentSpan = null;
142+
},
143+
error: data => {
144+
onTraceError(data);
145+
// Reset parent span reference on error too
146+
requestParentSpan = null;
147+
},
148+
});
149+
150+
const middlewareChannel = tracingChannel<SrvxRequestEvent>('srvx.middleware', data => {
151+
// For the first middleware, capture the current parent span
152+
if (data.middleware?.index === 0) {
153+
requestParentSpan = getActiveSpan() || null;
154+
}
155+
156+
const parsedUrl = data.request._url ? parseStringToURLObject(data.request._url.href) : undefined;
157+
const [, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.srvx.middleware', {
158+
method: data.request.method,
159+
});
160+
161+
// Create span as a child of the original parent, not the previous middleware
162+
return startSpanManual(
163+
{
164+
name: `${data.middleware?.handler.name ?? 'unknown'} - ${data.request.method} ${data.request._url?.pathname}`,
165+
attributes: {
166+
...urlAttributes,
167+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nitro',
168+
},
169+
parentSpan: requestParentSpan || undefined,
170+
},
171+
span => span,
172+
);
173+
});
174+
175+
// Subscribe to events (span already created in bindStore)
176+
middlewareChannel.subscribe({
177+
start: () => {},
178+
asyncStart: () => {},
179+
end: () => {},
180+
asyncEnd: onTraceEnd,
181+
error: onTraceError,
182+
});
183+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { getTraceData } from '@sentry/core';
2+
3+
/**
4+
* Sets Server-Timing response headers for trace propagation to the client.
5+
* The browser SDK reads these via the Performance API to connect pageload traces.
6+
*/
7+
export function setServerTimingHeaders(response: unknown, _event: unknown): void {
8+
if (response && typeof response === 'object' && 'headers' in response) {
9+
const responseObj = response as Response;
10+
const traceData = getTraceData();
11+
12+
if (traceData['sentry-trace']) {
13+
responseObj.headers.append('Server-Timing', `sentry-trace;desc="${traceData['sentry-trace']}"`);
14+
}
15+
if (traceData.baggage) {
16+
responseObj.headers.append('Server-Timing', `baggage;desc="${traceData.baggage}"`);
17+
}
18+
}
19+
}

0 commit comments

Comments
 (0)