Skip to content

Commit 8777dbf

Browse files
committed
feat: route parameterization
1 parent e6c5bdc commit 8777dbf

File tree

3 files changed

+66
-7
lines changed

3 files changed

+66
-7
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
33

44
test('Isolation scope prevents tag leaking between requests', async ({ request }) => {
55
const transactionEventPromise = waitForTransaction('nitro-3', event => {
6-
return event?.transaction === 'GET /api/test-isolation/1';
6+
return event?.transaction === 'GET /api/test-isolation/:id';
77
});
88

99
const errorPromise = waitForError('nitro-3', event => {

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ test('Sets correct HTTP status code on transaction', async ({ request }) => {
4444
expect(transactionEvent.contexts?.trace?.status).toBe('ok');
4545
});
4646

47-
test('Sends a transaction event for a parameterized route', async ({ request }) => {
47+
test('Uses parameterized route for transaction name', async ({ request }) => {
4848
const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => {
49-
return transactionEvent?.transaction === 'GET /api/test-param/123';
49+
return transactionEvent?.transaction === 'GET /api/test-param/:id';
5050
});
5151

5252
await request.get('/api/test-param/123');
@@ -55,9 +55,17 @@ test('Sends a transaction event for a parameterized route', async ({ request })
5555

5656
expect(transactionEvent).toEqual(
5757
expect.objectContaining({
58+
transaction: 'GET /api/test-param/:id',
59+
transaction_info: expect.objectContaining({ source: 'route' }),
5860
type: 'transaction',
5961
}),
6062
);
63+
64+
expect(transactionEvent.contexts?.trace?.data).toEqual(
65+
expect.objectContaining({
66+
'http.route': '/api/test-param/:id',
67+
}),
68+
);
6169
});
6270

6371
test('Sets Server-Timing response headers for trace propagation', async ({ request }) => {

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

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ import {
33
getActiveSpan,
44
getClient,
55
getHttpSpanDetailsFromUrlObject,
6+
getRootSpan,
67
GLOBAL_OBJ,
78
httpHeadersToSpanAttributes,
89
parseStringToURLObject,
910
SEMANTIC_ATTRIBUTE_SENTRY_OP,
1011
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
12+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
1113
setHttpStatus,
1214
type Span,
1315
SPAN_STATUS_ERROR,
1416
startSpanManual,
17+
updateSpanName,
1518
} from '@sentry/core';
1619
import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing';
1720
import { tracingChannel } from 'otel-tracing-channel';
@@ -69,12 +72,37 @@ function onTraceError(data: { span?: Span; error: unknown }): void {
6972
data.span?.end();
7073
}
7174

75+
/**
76+
* Extracts the parameterized route pattern from the h3 event context.
77+
*/
78+
function getParameterizedRoute(event: H3TracingRequestEvent['event']): string | undefined {
79+
const matchedRoute = event.context?.matchedRoute;
80+
if (!matchedRoute) {
81+
return undefined;
82+
}
83+
84+
const routePath = matchedRoute.route;
85+
86+
// Skip catch-all routes as they're not useful for transaction grouping
87+
if (!routePath || routePath === '/**') {
88+
return undefined;
89+
}
90+
91+
return routePath;
92+
}
93+
7294
function setupH3TracingChannels(): void {
7395
const h3Channel = tracingChannel<H3TracingRequestEvent>('h3.request', data => {
7496
const parsedUrl = parseStringToURLObject(data.event.url.href);
75-
const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.h3', {
76-
method: data.event.req.method,
77-
});
97+
const routePattern = getParameterizedRoute(data.event);
98+
99+
const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(
100+
parsedUrl,
101+
'server',
102+
'auto.http.nitro.h3',
103+
{ method: data.event.req.method },
104+
routePattern,
105+
);
78106

79107
return startSpanManual(
80108
{
@@ -93,7 +121,30 @@ function setupH3TracingChannels(): void {
93121
start: NOOP,
94122
asyncStart: NOOP,
95123
end: NOOP,
96-
asyncEnd: onTraceEnd,
124+
asyncEnd: (data: H3TracingRequestEvent & { span?: Span; result?: unknown }) => {
125+
onTraceEnd(data);
126+
127+
if (!data.span) {
128+
return;
129+
}
130+
131+
// Update the root span (srvx transaction) with the parameterized route name.
132+
// The srvx span is created before h3 resolves the route, so it initially has the raw URL.
133+
// Note: data.type is always 'middleware' in asyncEnd regardless of handler type,
134+
// so we rely on getParameterizedRoute() to filter out catch-all routes instead.
135+
const rootSpan = getRootSpan(data.span);
136+
if (rootSpan && rootSpan !== data.span) {
137+
const routePattern = getParameterizedRoute(data.event);
138+
if (routePattern) {
139+
const method = data.event.req.method || 'GET';
140+
updateSpanName(rootSpan, `${method} ${routePattern}`);
141+
rootSpan.setAttributes({
142+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
143+
'http.route': routePattern,
144+
});
145+
}
146+
}
147+
},
97148
error: onTraceError,
98149
});
99150
}

0 commit comments

Comments
 (0)