Skip to content
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 (nitro)
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 (nitro)
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 (nitro)
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 (nitro)
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 (nitro)
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
25 changes: 24 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,17 @@ 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 = () => `export default ${JSON.stringify(pages, null, 2)};`;
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: So stringifying all of the pages can get pretty big from what I can see in the NuxtPage type.

Perhaps we should just save a subset? We really only need the file and the path from looking at extractParametrizedRouteFromContext.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I also filtered for only dynamic pages.


nuxt.hooks.hook('nitro:init', nitro => {
if (serverConfigFile?.includes('.server.config')) {
if (nitro.options.dev) {
Expand Down
50 changes: 50 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,50 @@
import { getActiveSpan, getRootSpan, logger, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core';
import { defineNuxtPlugin } from 'nuxt/app';
import type { NuxtPage } from 'nuxt/schema';
import { extractParametrizedRouteFromContext } from '../utils/route-extraction';

export default defineNuxtPlugin(nuxtApp => {
nuxtApp.hooks.hook('app:rendered', async renderContext => {
let buildTimePagesData: NuxtPage[] = [];
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 || [];
} catch (error) {
buildTimePagesData = [];
}

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;
}

const method = ssrContext?.event?._method || 'GET';
const parametrizedTransactionName = `${method.toUpperCase()} ${routeInfo.parametrizedRoute}`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: We create the parametrizedTransactionName but don't update the span name?

Copy link
Member Author

@s1gr1d s1gr1d Jul 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh that's left from before the last commit 😅
was using updateSpanName before, but I only need to set 'http.route': routeInfo.parametrizedRoute to update the name.


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

rootSpan.setAttributes({
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
'http.route': routeInfo.parametrizedRoute,
});
}
});
});
64 changes: 64 additions & 0 deletions packages/nuxt/src/runtime/utils/route-extraction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { logger } from '@sentry/core';
import type { NuxtSSRContext } from 'nuxt/app';
import type { NuxtPage } from 'nuxt/schema';

/**
* Extracts route information from the SSR context modules and URL.
*
* The function matches the requested URL against the build-time pages data. The build-time pages data
* contains the routes that were generated during the build process, which allows us to set the parametrized route.
*
* @param ssrContextModules - The modules from the SSR context.
* This is a Set of module paths that were used when loading one specific page.
* Example: `Set(['app.vue', 'components/Button.vue', 'pages/user/[userId].vue'])`
*
* @param currentUrl - The requested URL string
* Example: `/user/123`
*
* @param buildTimePagesData
* An array of NuxtPage objects representing the build-time pages data.
* Example: [{ name: 'some-path', path: '/some/path' }, { name: 'user-userId', path: '/user/:userId()' }]
*/
export function extractParametrizedRouteFromContext(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: should we memoize this function?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the function is quite expensive as the lookup is hard :/

I added some general, small optimizations for the function.

Using the currentUrl is not possible as it includes the parameters and that would blow up.
And the only way to memoize the result is using the ssrContextModules as key (which is quite complex as this can be a large Set) and this Set has a different reference, every time a request is sent. So the key has to be created on every request, as I cannot use the Set itself as a key.

However, as I think the key generation regardless its overhead still makes sense, I added it now.

ssrContextModules?: NuxtSSRContext['modules'],
currentUrl?: NuxtSSRContext['url'],
buildTimePagesData: NuxtPage[] = [],
): null | { parametrizedRoute: string } {
if (!ssrContextModules || !currentUrl) {
logger.warn('SSR context modules or URL is not available.');
return null;
}

if (buildTimePagesData.length === 0) {
return null;
}

const modulesArray = Array.from(ssrContextModules);

// Find the route data that corresponds to a module in ssrContext.modules
const foundRouteData = buildTimePagesData.find(routeData => {
if (!routeData.file) return false;

return modulesArray.some(module => {
// Extract the folder name and relative path from the page file
// e.g., 'pages/test-param/[param].vue' -> folder: 'pages', path: 'test-param/[param].vue'
const filePathParts = module.split('/');

// Exclude root-level files (e.g., 'app.vue')
if (filePathParts.length < 2) return false;

// Normalize path separators to handle both Unix and Windows paths
const normalizedRouteFile = routeData.file?.replace(/\\/g, '/');

const pagesFolder = filePathParts[0];
const pageRelativePath = filePathParts.slice(1).join('/');

// Check if any module in ssrContext.modules ends with the same folder/relative path structure
return normalizedRouteFile?.endsWith(`/${pagesFolder}/${pageRelativePath}`);
});
});

const parametrizedRoute = foundRouteData?.path ?? null;

return parametrizedRoute === null ? null : { parametrizedRoute };
}
Loading
Loading