diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts index 17c2704279b3..d3b7415e7678 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts @@ -10,7 +10,7 @@ test.describe('distributed tracing', () => { }); const serverTxnEventPromise = waitForTransaction('nuxt-3-dynamic-import', txnEvent => { - return txnEvent.transaction.includes('GET /test-param/'); + return txnEvent.transaction?.includes('GET /test-param/') || false; }); const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ @@ -47,7 +47,7 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) + transaction: `GET /test-param/${PARAM}`, // todo: parametrize transaction_info: { source: 'url' }, type: 'transaction', contexts: { @@ -135,8 +135,8 @@ test.describe('distributed tracing', () => { expect(serverReqTxnEvent).toEqual( expect.objectContaining({ type: 'transaction', - transaction: `GET /api/user/${PARAM}`, - transaction_info: { source: 'url' }, + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, contexts: expect.objectContaining({ trace: expect.objectContaining({ op: 'http.server', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts index 0daa9a856236..a23b1bb8f35a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts @@ -47,7 +47,7 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) + transaction: `GET /test-param/${PARAM}`, // todo: parametrize transaction_info: { source: 'url' }, type: 'transaction', contexts: { @@ -135,8 +135,8 @@ test.describe('distributed tracing', () => { expect(serverReqTxnEvent).toEqual( expect.objectContaining({ type: 'transaction', - transaction: `GET /api/user/${PARAM}`, - transaction_info: { source: 'url' }, + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, contexts: expect.objectContaining({ trace: expect.objectContaining({ op: 'http.server', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts index 5248926e30fb..62f8f9ab51e0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts @@ -47,7 +47,7 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) + transaction: `GET /test-param/${PARAM}`, // todo: parametrize transaction_info: { source: 'url' }, type: 'transaction', contexts: { @@ -135,8 +135,8 @@ test.describe('distributed tracing', () => { expect(serverReqTxnEvent).toEqual( expect.objectContaining({ type: 'transaction', - transaction: `GET /api/user/${PARAM}`, - transaction_info: { source: 'url' }, + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, contexts: expect.objectContaining({ trace: expect.objectContaining({ op: 'http.server', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts index 58eb03eccb88..de19d6d739f9 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts @@ -47,7 +47,7 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) + transaction: `GET /test-param/${PARAM}`, // todo: parametrize transaction_info: { source: 'url' }, type: 'transaction', contexts: { @@ -135,8 +135,8 @@ test.describe('distributed tracing', () => { expect(serverReqTxnEvent).toEqual( expect.objectContaining({ type: 'transaction', - transaction: `GET /api/user/${PARAM}`, - transaction_info: { source: 'url' }, + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, contexts: expect.objectContaining({ trace: expect.objectContaining({ op: 'http.server', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts index bb824f60f7d1..3448851dd299 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts @@ -47,7 +47,7 @@ test.describe('distributed tracing', () => { }); expect(serverTxnEvent).toMatchObject({ - transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) + transaction: `GET /test-param/${PARAM}`, // todo: parametrize transaction_info: { source: 'url' }, type: 'transaction', contexts: { @@ -135,8 +135,8 @@ test.describe('distributed tracing', () => { expect(serverReqTxnEvent).toEqual( expect.objectContaining({ type: 'transaction', - transaction: `GET /api/user/${PARAM}`, - transaction_info: { source: 'url' }, + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, contexts: expect.objectContaining({ trace: expect.objectContaining({ op: 'http.server', diff --git a/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts new file mode 100644 index 000000000000..28854a320bc0 --- /dev/null +++ b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts @@ -0,0 +1,57 @@ +import { getActiveSpan, getCurrentScope, getRootSpan, logger, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import type { H3Event } from 'h3'; + +/** + * Update the root span (transaction) name for routes with parameters based on the matched route. + */ +export function updateRouteBeforeResponse(event: H3Event): void { + if (!event.context.matchedRoute) { + return; + } + + const matchedRoutePath = event.context.matchedRoute.path; + + // If the matched route path is defined and differs from the event's path, it indicates a parametrized route + // 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. + } + + 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; + } + + const rootSpan = getRootSpan(activeSpan); + if (!rootSpan) { + return; + } + + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': matchedRoutePath, + }); + + const params = event.context?.params; + + if (params && typeof params === 'object') { + Object.entries(params).forEach(([key, value]) => { + // Based on this convention: https://getsentry.github.io/sentry-conventions/generated/attributes/url.html#urlpathparameterkey + rootSpan.setAttributes({ + [`url.path.parameter.${key}`]: String(value), + [`params.${key}`]: String(value), + }); + }); + } + + logger.log(`Updated transaction name for parametrized route: ${parametrizedTransactionName}`); + } +} diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 9d10e9bd86d0..96fa59a4c643 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -6,6 +6,7 @@ import type { H3Event } from 'h3'; import type { NitroApp, NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; +import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags } from '../utils'; interface CfEventType { @@ -139,6 +140,8 @@ export const sentryCloudflareNitroPlugin = }, }); + nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse); + // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { const storedTraceData = event?.context?.cf ? traceDataMap.get(event.context.cf) : undefined; diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index baf9f2029051..0f13fbec0fd3 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -5,11 +5,14 @@ import { type EventHandler } from 'h3'; import { defineNitroPlugin } from 'nitropack/runtime'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; +import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags, flushIfServerless } from '../utils'; export default defineNitroPlugin(nitroApp => { nitroApp.h3App.handler = patchEventHandler(nitroApp.h3App.handler); + nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse); + nitroApp.hooks.hook('error', sentryCaptureErrorHook); // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context