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
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
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}`);
}
}
48 changes: 48 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,48 @@
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 || [];
} 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