Skip to content

feat(nuxt): Parametrize SSR routes #16843

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jul 10, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,5 @@ test('does not send transactions for build asset folder "_nuxt"', async ({ page

expect(buildAssetFolderOccurred).toBe(false);

// todo: url not yet parametrized
expect(transactionEvent.transaction).toBe('GET /test-param/1234');
expect(transactionEvent.transaction).toBe('GET /test-param/:param()');
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ test.describe('distributed tracing', () => {

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

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

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

expect(serverTxnEvent).toMatchObject({
transaction: `GET /test-param/${PARAM}`, // todo: parametrize
transaction_info: { source: 'url' },
transaction: `GET /test-param/:param()`, // parametrized
transaction_info: { source: 'route' },
type: 'transaction',
contexts: {
trace: {
Expand Down Expand Up @@ -121,8 +122,8 @@ test.describe('distributed tracing', () => {
expect(ssrTxnEvent).toEqual(
expect.objectContaining({
type: 'transaction',
transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro)
transaction_info: { source: 'url' },
transaction: `GET /test-param/user/:userId()`, // parametrized route
transaction_info: { source: 'route' },
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'http.server',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ test('does not send transactions for build asset folder "_nuxt"', async ({ page

expect(buildAssetFolderOccurred).toBe(false);

// todo: url not yet parametrized
// Parametrization does not work in Nuxt 3.7 yet (only in newer versions)
expect(transactionEvent.transaction).toBe('GET /test-param/1234');
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ test.describe('distributed tracing', () => {

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

expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`);
// Parametrization does not work in Nuxt 3.7 yet (only in newer versions)
expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F${PARAM}`); // URL-encoded for 'GET /test-param/s0me-param'
expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`);
expect(baggageMetaTagContent).toContain('sentry-sampled=true');
expect(baggageMetaTagContent).toContain('sentry-sample_rate=1');

Expand All @@ -47,7 +48,7 @@ test.describe('distributed tracing', () => {
});

expect(serverTxnEvent).toMatchObject({
transaction: `GET /test-param/${PARAM}`, // todo: parametrize
transaction: `GET /test-param/${PARAM}`, // Parametrization does not work in Nuxt 3.7 yet (only in newer versions)
transaction_info: { source: 'url' },
type: 'transaction',
contexts: {
Expand Down Expand Up @@ -121,7 +122,7 @@ test.describe('distributed tracing', () => {
expect(ssrTxnEvent).toEqual(
expect.objectContaining({
type: 'transaction',
transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro)
transaction: `GET /test-param/user/${PARAM}`, // Parametrization does not work in Nuxt 3.7 yet (only in newer versions)
transaction_info: { source: 'url' },
contexts: expect.objectContaining({
trace: expect.objectContaining({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,5 @@ test('does not send transactions for build asset folder "_nuxt"', async ({ page

expect(buildAssetFolderOccurred).toBe(false);

// todo: url not yet parametrized
expect(transactionEvent.transaction).toBe('GET /test-param/1234');
expect(transactionEvent.transaction).toBe('GET /test-param/:param()');
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ test.describe('distributed tracing', () => {

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

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

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

expect(serverTxnEvent).toMatchObject({
transaction: `GET /test-param/${PARAM}`, // todo: parametrize
transaction_info: { source: 'url' },
transaction: `GET /test-param/:param()`, // parametrized
transaction_info: { source: 'route' },
type: 'transaction',
contexts: {
trace: {
Expand Down Expand Up @@ -121,8 +122,8 @@ test.describe('distributed tracing', () => {
expect(ssrTxnEvent).toEqual(
expect.objectContaining({
type: 'transaction',
transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro)
transaction_info: { source: 'url' },
transaction: `GET /test-param/user/:userId()`, // parametrized route
transaction_info: { source: 'route' },
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'http.server',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,5 @@ test('does not send transactions for build asset folder "_nuxt"', async ({ page

expect(buildAssetFolderOccurred).toBe(false);

// todo: url not yet parametrized
expect(transactionEvent.transaction).toBe('GET /test-param/1234');
expect(transactionEvent.transaction).toBe('GET /test-param/:param()');
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ test.describe('distributed tracing', () => {

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

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

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

expect(serverTxnEvent).toMatchObject({
transaction: `GET /test-param/${PARAM}`, // todo: parametrize
transaction_info: { source: 'url' },
transaction: `GET /test-param/:param()`, // parametrized
transaction_info: { source: 'route' },
type: 'transaction',
contexts: {
trace: {
Expand Down Expand Up @@ -121,8 +122,8 @@ test.describe('distributed tracing', () => {
expect(ssrTxnEvent).toEqual(
expect.objectContaining({
type: 'transaction',
transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro)
transaction_info: { source: 'url' },
transaction: `GET /test-param/user/:userId()`, // parametrized route
transaction_info: { source: 'route' },
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'http.server',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,5 @@ test('does not send transactions for build asset folder "_nuxt"', async ({ page

expect(buildAssetFolderOccurred).toBe(false);

// todo: url not yet parametrized
expect(transactionEvent.transaction).toBe('GET /test-param/1234');
expect(transactionEvent.transaction).toBe('GET /test-param/:param()');
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ test.describe('distributed tracing', () => {

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

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

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

expect(serverTxnEvent).toMatchObject({
transaction: `GET /test-param/${PARAM}`, // todo: parametrize
transaction_info: { source: 'url' },
transaction: `GET /test-param/:param()`, // parametrized route
transaction_info: { source: 'route' },
type: 'transaction',
contexts: {
trace: {
Expand Down Expand Up @@ -121,8 +122,8 @@ test.describe('distributed tracing', () => {
expect(ssrTxnEvent).toEqual(
expect.objectContaining({
type: 'transaction',
transaction: `GET /test-param/user/${PARAM}`, // fixme: parametrize (nitro)
transaction_info: { source: 'url' },
transaction: `GET /test-param/user/:userId()`, // parametrized route
transaction_info: { source: 'route' },
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'http.server',
Expand Down
34 changes: 33 additions & 1 deletion packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { addPlugin, addPluginTemplate, addServerPlugin, createResolver, defineNuxtModule } from '@nuxt/kit';
import {
addPlugin,
addPluginTemplate,
addServerPlugin,
addTemplate,
createResolver,
defineNuxtModule,
} from '@nuxt/kit';
import { consoleSandbox } from '@sentry/core';
import * as path from 'path';
import type { SentryNuxtModuleOptions } from './common/types';
Expand Down Expand Up @@ -70,6 +77,11 @@ export default defineNuxtModule<ModuleOptions>({

if (serverConfigFile) {
addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server'));

addPlugin({
src: moduleDirResolver.resolve('./runtime/plugins/route-detector.server'),
mode: 'server',
});
}

if (clientConfigFile || serverConfigFile) {
Expand All @@ -78,6 +90,26 @@ export default defineNuxtModule<ModuleOptions>({

addOTelCommonJSImportAlias(nuxt);

const pagesDataTemplate = addTemplate({
filename: 'sentry--nuxt-pages-data.mjs',
// Initial empty array (later filled in pages:extend hook)
// Template needs to be created in the root-level of the module to work
getContents: () => 'export default [];',
});

nuxt.hooks.hook('pages:extend', pages => {
pagesDataTemplate.getContents = () => {
const pagesSubset = pages
.map(page => ({ file: page.file, path: page.path }))
.filter(page => {
// Check for dynamic parameter (e.g., :userId or [userId])
return page.path.includes(':') || page?.file?.includes('[');
});

return `export default ${JSON.stringify(pagesSubset, null, 2)};`;
};
});

nuxt.hooks.hook('nitro:init', nitro => {
if (serverConfigFile?.includes('.server.config')) {
if (nitro.options.dev) {
Expand Down
12 changes: 3 additions & 9 deletions packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getActiveSpan, getCurrentScope, getRootSpan, logger, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core';
import { getActiveSpan, getRootSpan, logger, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core';
import type { H3Event } from 'h3';

/**
Expand All @@ -15,16 +15,10 @@ export function updateRouteBeforeResponse(event: H3Event): void {
// Example: Matched route is "/users/:id" and the event's path is "/users/123",
if (matchedRoutePath && matchedRoutePath !== event._path) {
if (matchedRoutePath === '/**') {
// todo: support parametrized SSR pageload spans
// If page is server-side rendered, the whole path gets transformed to `/**` (Example : `/users/123` becomes `/**` instead of `/users/:id`).
return; // Skip if the matched route is a catch-all route.
return; // Skip if the matched route is a catch-all route (handled in `route-detector.server.ts`)
}

const method = event._method || 'GET';

const parametrizedTransactionName = `${method.toUpperCase()} ${matchedRoutePath}`;
getCurrentScope().setTransactionName(parametrizedTransactionName);

const activeSpan = getActiveSpan(); // In development mode, getActiveSpan() is always undefined
if (!activeSpan) {
return;
Expand Down Expand Up @@ -52,6 +46,6 @@ export function updateRouteBeforeResponse(event: H3Event): void {
});
}

logger.log(`Updated transaction name for parametrized route: ${parametrizedTransactionName}`);
logger.log(`Updated transaction name for parametrized route: ${matchedRoutePath}`);
}
}
49 changes: 49 additions & 0 deletions packages/nuxt/src/runtime/plugins/route-detector.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { getActiveSpan, getRootSpan, logger, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core';
import { defineNuxtPlugin } from 'nuxt/app';
import type { NuxtPageSubset } from '../utils/route-extraction';
import { extractParametrizedRouteFromContext } from '../utils/route-extraction';

export default defineNuxtPlugin(nuxtApp => {
nuxtApp.hooks.hook('app:rendered', async renderContext => {
let buildTimePagesData: NuxtPageSubset[];
try {
// 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
// @ts-expect-error This import is dynamically resolved at build time (`addTemplate` in module.ts)
const { default: importedPagesData } = await import('#build/sentry--nuxt-pages-data.mjs');
buildTimePagesData = importedPagesData || [];
logger.log('Imported build-time pages data:', buildTimePagesData);
} catch (error) {
buildTimePagesData = [];
logger.warn('Failed to import build-time pages data:', error);
}

const ssrContext = renderContext.ssrContext;

const routeInfo = extractParametrizedRouteFromContext(
ssrContext?.modules,
ssrContext?.url || ssrContext?.event._path,
buildTimePagesData,
);

if (routeInfo === null) {
return;
}

const activeSpan = getActiveSpan(); // In development mode, getActiveSpan() is always undefined

if (activeSpan && routeInfo.parametrizedRoute) {
const rootSpan = getRootSpan(activeSpan);

if (!rootSpan) {
return;
}

logger.log('Matched parametrized server route:', routeInfo.parametrizedRoute);

rootSpan.setAttributes({
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
'http.route': routeInfo.parametrizedRoute,
});
}
});
});
Loading
Loading