Skip to content

Commit c8d4677

Browse files
authored
feat(nuxt): Support parametrized SSR routes in Nuxt 5 (#19977)
Nuxt 5 uses Nitro's `response` hook and changes the callback signature, while Nuxt 4 uses `beforeResponse`. This change keeps Sentry's server-side route naming working across both versions by separating the logic into two different plugins. Closes #19976
1 parent a1df56d commit c8d4677

File tree

7 files changed

+117
-8
lines changed

7 files changed

+117
-8
lines changed

dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,7 @@ test.describe('distributed tracing', () => {
6868
expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId);
6969
});
7070

71-
// TODO: Make test work with Nuxt 5
72-
test.skip('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => {
71+
test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => {
7372
const clientTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => {
7473
return txnEvent.transaction === '/test-param/user/:userId()';
7574
});

packages/nuxt/src/module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ export default defineNuxtModule<ModuleOptions>({
8383
if (serverConfigFile) {
8484
if (isNitroV3) {
8585
addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server'));
86+
addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name.server'));
8687
} else {
8788
addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server'));
89+
addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name-legacy.server'));
8890
}
8991

9092
addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server'));

packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
import { debug, getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core';
22
import type { H3Event } from 'h3';
33

4+
type MatchedRoute = { path?: string; route?: string };
5+
type EventWithMatchedRoute = Pick<H3Event, 'context'> & Partial<Pick<H3Event, 'path' | '_path'>>;
6+
7+
function getMatchedRoutePath(event: EventWithMatchedRoute): string | undefined {
8+
const matchedRoute = (event.context as { matchedRoute?: MatchedRoute }).matchedRoute;
9+
// Nuxt 4 with h3 v1 uses `path`, Nuxt 5 with h3 v2 uses `route`
10+
return matchedRoute?.path ?? matchedRoute?.route;
11+
}
12+
413
/**
514
* Update the root span (transaction) name for routes with parameters based on the matched route.
615
*/
7-
export function updateRouteBeforeResponse(event: H3Event): void {
16+
export function updateRouteBeforeResponse(event: EventWithMatchedRoute): void {
817
if (!event.context.matchedRoute) {
918
return;
1019
}
1120

12-
const matchedRoutePath = event.context.matchedRoute.path;
21+
const matchedRoutePath = getMatchedRoutePath(event);
22+
const requestPath = event.path ?? event._path;
1323

1424
// If the matched route path is defined and differs from the event's path, it indicates a parametrized route
1525
// Example: Matched route is "/users/:id" and the event's path is "/users/123",
16-
if (matchedRoutePath && matchedRoutePath !== event._path) {
26+
if (matchedRoutePath && matchedRoutePath !== requestPath) {
1727
if (matchedRoutePath === '/**') {
1828
// If page is server-side rendered, the whole path gets transformed to `/**` (Example : `/users/123` becomes `/**` instead of `/users/:id`).
1929
return; // Skip if the matched route is a catch-all route (handled in `route-detector.server.ts`)

packages/nuxt/src/runtime/plugins/sentry.server.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@ import type { H3Event } from 'h3';
33
import type { NitroAppPlugin } from 'nitropack';
44
import type { NuxtRenderHTMLContext } from 'nuxt/app';
55
import { sentryCaptureErrorHook } from '../hooks/captureErrorHook';
6-
import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse';
76
import { addSentryTracingMetaTags } from '../utils';
87

98
export default (nitroApp => {
10-
nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse);
11-
129
nitroApp.hooks.hook('error', sentryCaptureErrorHook);
1310

1411
// @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { NitroAppPlugin } from 'nitropack';
2+
import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse';
3+
4+
export default (nitroApp => {
5+
nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse);
6+
}) satisfies NitroAppPlugin;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { NitroAppPlugin } from 'nitro/types';
2+
import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse';
3+
import type { H3Event } from 'h3';
4+
5+
export default (nitroApp => {
6+
// @ts-expect-error Hook in Nuxt 5 (Nitro 3) is called 'response' https://nitro.build/docs/plugins#available-hooks
7+
nitroApp.hooks.hook('response', (_response, event: H3Event) => updateRouteBeforeResponse(event));
8+
}) satisfies NitroAppPlugin;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {
2+
debug,
3+
getActiveSpan,
4+
getRootSpan,
5+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
6+
type Span,
7+
type SpanAttributes,
8+
} from '@sentry/core';
9+
import { afterEach, describe, expect, it, type Mock, vi } from 'vitest';
10+
import { updateRouteBeforeResponse } from '../../../src/runtime/hooks/updateRouteBeforeResponse';
11+
12+
vi.mock(import('@sentry/core'), async importOriginal => {
13+
const mod = await importOriginal();
14+
15+
return {
16+
...mod,
17+
debug: {
18+
...mod.debug,
19+
log: vi.fn(),
20+
},
21+
getActiveSpan: vi.fn(),
22+
getRootSpan: vi.fn(),
23+
};
24+
});
25+
26+
describe('updateRouteBeforeResponse', () => {
27+
const mockRootSpan = {
28+
setAttributes: vi.fn(),
29+
} as unknown as Pick<Span, 'setAttributes'>;
30+
31+
afterEach(() => {
32+
vi.resetAllMocks();
33+
});
34+
35+
it('updates the transaction name for Nitro v2 matched routes', () => {
36+
(getActiveSpan as Mock).mockReturnValue({} as Span);
37+
(getRootSpan as Mock).mockReturnValue(mockRootSpan);
38+
39+
updateRouteBeforeResponse({
40+
_path: '/users/123',
41+
context: {
42+
matchedRoute: {
43+
path: '/users/:id',
44+
},
45+
params: {
46+
id: '123',
47+
},
48+
},
49+
} as never);
50+
51+
expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({
52+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
53+
'http.route': '/users/:id',
54+
} satisfies SpanAttributes);
55+
expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({
56+
'params.id': '123',
57+
'url.path.parameter.id': '123',
58+
} satisfies SpanAttributes);
59+
expect(debug.log).toHaveBeenCalledWith('Updated transaction name for parametrized route: /users/:id');
60+
});
61+
62+
it('updates the transaction name for Nitro v3 matched routes', () => {
63+
(getActiveSpan as Mock).mockReturnValue({} as Span);
64+
(getRootSpan as Mock).mockReturnValue(mockRootSpan);
65+
66+
updateRouteBeforeResponse({
67+
path: '/users/123',
68+
context: {
69+
matchedRoute: {
70+
route: '/users/:id',
71+
},
72+
params: {
73+
id: '123',
74+
},
75+
},
76+
} as never);
77+
78+
expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({
79+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
80+
'http.route': '/users/:id',
81+
} satisfies SpanAttributes);
82+
expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({
83+
'params.id': '123',
84+
'url.path.parameter.id': '123',
85+
} satisfies SpanAttributes);
86+
});
87+
});

0 commit comments

Comments
 (0)