Skip to content

Commit 8492c82

Browse files
committed
feat(nuxt): Parametrize SSR routes
1 parent 3b4a57c commit 8492c82

File tree

5 files changed

+454
-6
lines changed

5 files changed

+454
-6
lines changed

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ test.describe('distributed tracing', () => {
2222

2323
const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content');
2424

25+
// URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param`
26+
expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`);
2527
expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`);
26-
expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F${PARAM}`); // URL-encoded for 'GET /test-param/s0me-param'
2728
expect(baggageMetaTagContent).toContain('sentry-sampled=true');
2829
expect(baggageMetaTagContent).toContain('sentry-sample_rate=1');
2930

@@ -47,8 +48,8 @@ test.describe('distributed tracing', () => {
4748
});
4849

4950
expect(serverTxnEvent).toMatchObject({
50-
transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro)
51-
transaction_info: { source: 'url' },
51+
transaction: `GET /test-param/:param()`, // parametrized route
52+
transaction_info: { source: 'route' },
5253
type: 'transaction',
5354
contexts: {
5455
trace: {
@@ -121,8 +122,8 @@ test.describe('distributed tracing', () => {
121122
expect(ssrTxnEvent).toEqual(
122123
expect.objectContaining({
123124
type: 'transaction',
124-
transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro)
125-
transaction_info: { source: 'url' },
125+
transaction: `GET /test-param/user/:userId()`, // parametrized route
126+
transaction_info: { source: 'route' },
126127
contexts: expect.objectContaining({
127128
trace: expect.objectContaining({
128129
op: 'http.server',

packages/nuxt/src/module.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { addPlugin, addPluginTemplate, addServerPlugin, createResolver, defineNuxtModule } from '@nuxt/kit';
1+
import {
2+
addPlugin,
3+
addPluginTemplate,
4+
addServerPlugin,
5+
addTemplate,
6+
createResolver,
7+
defineNuxtModule,
8+
} from '@nuxt/kit';
29
import { consoleSandbox } from '@sentry/core';
310
import * as path from 'path';
411
import type { SentryNuxtModuleOptions } from './common/types';
@@ -70,6 +77,11 @@ export default defineNuxtModule<ModuleOptions>({
7077

7178
if (serverConfigFile) {
7279
addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server'));
80+
81+
addPlugin({
82+
src: moduleDirResolver.resolve('./runtime/plugins/route-detector.server'),
83+
mode: 'server',
84+
});
7385
}
7486

7587
if (clientConfigFile || serverConfigFile) {
@@ -78,6 +90,17 @@ export default defineNuxtModule<ModuleOptions>({
7890

7991
addOTelCommonJSImportAlias(nuxt);
8092

93+
const pagesDataTemplate = addTemplate({
94+
filename: 'sentry--nuxt-pages-data.mjs',
95+
// Initial empty array (later filled in pages:extend hook)
96+
// Template needs to be created in the root-level of the module to work
97+
getContents: () => 'export default [];',
98+
});
99+
100+
nuxt.hooks.hook('pages:extend', pages => {
101+
pagesDataTemplate.getContents = () => `export default ${JSON.stringify(pages, null, 2)};`;
102+
});
103+
81104
nuxt.hooks.hook('nitro:init', nitro => {
82105
if (serverConfigFile?.includes('.server.config')) {
83106
if (nitro.options.dev) {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { getActiveSpan, getRootSpan, logger, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, updateSpanName } from '@sentry/core';
2+
import { defineNuxtPlugin } from 'nuxt/app';
3+
import type { NuxtPage } from 'nuxt/schema';
4+
import { extractParametrizedRouteFromContext } from '../utils/route-extraction';
5+
6+
export default defineNuxtPlugin(nuxtApp => {
7+
nuxtApp.hooks.hook('app:rendered', async renderContext => {
8+
let buildTimePagesData: NuxtPage[] = [];
9+
try {
10+
// This is a common Nuxt pattern to import build-time generated data: https://nuxt.com/docs/4.x/api/kit/templates#creating-a-virtual-file-for-runtime-plugin
11+
// @ts-expect-error This import is dynamically resolved at build time (`addTemplate` in module.ts)
12+
const { default: importedPagesData } = await import('#build/sentry--nuxt-pages-data.mjs');
13+
buildTimePagesData = importedPagesData || [];
14+
} catch (error) {
15+
buildTimePagesData = [];
16+
}
17+
18+
const ssrContext = renderContext.ssrContext;
19+
20+
const routeInfo = extractParametrizedRouteFromContext(
21+
ssrContext?.modules,
22+
ssrContext?.url || ssrContext?.event._path,
23+
buildTimePagesData,
24+
);
25+
26+
if (routeInfo === null) {
27+
return;
28+
}
29+
30+
const activeSpan = getActiveSpan(); // In development mode, getActiveSpan() is always undefined
31+
32+
if (activeSpan && routeInfo.parametrizedRoute) {
33+
const rootSpan = getRootSpan(activeSpan);
34+
35+
if (rootSpan) {
36+
const method = ssrContext?.event?._method || 'GET';
37+
const parametrizedTransactionName = `${method.toUpperCase()} ${routeInfo.parametrizedRoute}`;
38+
39+
logger.log('Updating root span name to:', parametrizedTransactionName);
40+
updateSpanName(rootSpan, parametrizedTransactionName);
41+
42+
rootSpan.setAttributes({
43+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
44+
'http.route': routeInfo.parametrizedRoute,
45+
});
46+
}
47+
}
48+
});
49+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { logger } from '@sentry/core';
2+
import type { NuxtSSRContext } from 'nuxt/app';
3+
import type { NuxtPage } from 'nuxt/schema';
4+
5+
/**
6+
* Extracts route information from the SSR context modules and URL.
7+
*
8+
* The function matches the requested URL against the build-time pages data. The build-time pages data
9+
* contains the routes that were generated during the build process, which allows us to set the parametrized route.
10+
*
11+
* @param ssrContextModules - The modules from the SSR context.
12+
* This is a Set of module paths that were used when loading one specific page.
13+
* Example: `Set(['app.vue', 'components/Button.vue', 'pages/user/[userId].vue'])`
14+
*
15+
* @param currentUrl - The requested URL string
16+
* Example: `/user/123`
17+
*
18+
* @param buildTimePagesData
19+
* An array of NuxtPage objects representing the build-time pages data.
20+
* Example: [{ name: 'some-path', path: '/some/path' }, { name: 'user-userId', path: '/user/:userId()' }]
21+
*/
22+
export function extractParametrizedRouteFromContext(
23+
ssrContextModules?: NuxtSSRContext['modules'],
24+
currentUrl?: NuxtSSRContext['url'],
25+
buildTimePagesData: NuxtPage[] = [],
26+
): null | { parametrizedRoute: string } {
27+
if (!ssrContextModules || !currentUrl) {
28+
logger.warn('SSR context modules or URL is not available.');
29+
return null;
30+
}
31+
32+
if (buildTimePagesData.length === 0) {
33+
return null;
34+
}
35+
36+
const modulesArray = Array.from(ssrContextModules);
37+
38+
// Find the route data that corresponds to a module in ssrContext.modules
39+
const foundRouteData = buildTimePagesData.find(routeData => {
40+
if (!routeData.file) return false;
41+
42+
return modulesArray.some(module => {
43+
// Extract the folder name and relative path from the page file
44+
// e.g., 'pages/test-param/[param].vue' -> folder: 'pages', path: 'test-param/[param].vue'
45+
const filePathParts = module.split('/');
46+
47+
// Exclude root-level files (e.g., 'app.vue')
48+
if (filePathParts.length < 2) return false;
49+
50+
// Normalize path separators to handle both Unix and Windows paths
51+
const normalizedRouteFile = routeData.file?.replace(/\\/g, '/');
52+
53+
const pagesFolder = filePathParts[0];
54+
const pageRelativePath = filePathParts.slice(1).join('/');
55+
56+
// Check if any module in ssrContext.modules ends with the same folder/relative path structure
57+
return normalizedRouteFile?.endsWith(`/${pagesFolder}/${pageRelativePath}`);
58+
});
59+
});
60+
61+
const parametrizedRoute = foundRouteData?.path ?? null;
62+
63+
return parametrizedRoute === null ? null : { parametrizedRoute };
64+
}

0 commit comments

Comments
 (0)