diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts index e136d5635a29..9c456d50d85a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts @@ -68,8 +68,7 @@ test.describe('distributed tracing', () => { expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); }); - // TODO: Make test work with Nuxt 5 - test.skip('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { + test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { const clientTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { return txnEvent.transaction === '/test-param/user/:userId()'; }); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 0c1e43031742..077bda66745b 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -83,8 +83,10 @@ export default defineNuxtModule({ if (serverConfigFile) { if (isNitroV3) { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name.server')); } else { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name-legacy.server')); } addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); diff --git a/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts index 9b0f8f4d05fe..becb367e178b 100644 --- a/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts +++ b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts @@ -1,19 +1,29 @@ import { debug, getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; import type { H3Event } from 'h3'; +type MatchedRoute = { path?: string; route?: string }; +type EventWithMatchedRoute = Pick & Partial>; + +function getMatchedRoutePath(event: EventWithMatchedRoute): string | undefined { + const matchedRoute = (event.context as { matchedRoute?: MatchedRoute }).matchedRoute; + // Nuxt 4 with h3 v1 uses `path`, Nuxt 5 with h3 v2 uses `route` + return matchedRoute?.path ?? matchedRoute?.route; +} + /** * Update the root span (transaction) name for routes with parameters based on the matched route. */ -export function updateRouteBeforeResponse(event: H3Event): void { +export function updateRouteBeforeResponse(event: EventWithMatchedRoute): void { if (!event.context.matchedRoute) { return; } - const matchedRoutePath = event.context.matchedRoute.path; + const matchedRoutePath = getMatchedRoutePath(event); + const requestPath = event.path ?? event._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 && matchedRoutePath !== requestPath) { if (matchedRoutePath === '/**') { // 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 (handled in `route-detector.server.ts`) diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index 7ea91e36cf25..63b4f6c107be 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -3,12 +3,9 @@ import type { H3Event } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; -import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags } from '../utils'; export default (nitroApp => { - nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse); - nitroApp.hooks.hook('error', sentryCaptureErrorHook); // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context diff --git a/packages/nuxt/src/runtime/plugins/update-route-name-legacy.server.ts b/packages/nuxt/src/runtime/plugins/update-route-name-legacy.server.ts new file mode 100644 index 000000000000..417c83062cb8 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/update-route-name-legacy.server.ts @@ -0,0 +1,6 @@ +import type { NitroAppPlugin } from 'nitropack'; +import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; + +export default (nitroApp => { + nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/update-route-name.server.ts b/packages/nuxt/src/runtime/plugins/update-route-name.server.ts new file mode 100644 index 000000000000..72e3d9452e7e --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/update-route-name.server.ts @@ -0,0 +1,8 @@ +import type { NitroAppPlugin } from 'nitro/types'; +import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; +import type { H3Event } from 'h3'; + +export default (nitroApp => { + // @ts-expect-error Hook in Nuxt 5 (Nitro 3) is called 'response' https://nitro.build/docs/plugins#available-hooks + nitroApp.hooks.hook('response', (_response, event: H3Event) => updateRouteBeforeResponse(event)); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/test/runtime/hooks/updateRouteBeforeResponse.test.ts b/packages/nuxt/test/runtime/hooks/updateRouteBeforeResponse.test.ts new file mode 100644 index 000000000000..d311ee253121 --- /dev/null +++ b/packages/nuxt/test/runtime/hooks/updateRouteBeforeResponse.test.ts @@ -0,0 +1,87 @@ +import { + debug, + getActiveSpan, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + type Span, + type SpanAttributes, +} from '@sentry/core'; +import { afterEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { updateRouteBeforeResponse } from '../../../src/runtime/hooks/updateRouteBeforeResponse'; + +vi.mock(import('@sentry/core'), async importOriginal => { + const mod = await importOriginal(); + + return { + ...mod, + debug: { + ...mod.debug, + log: vi.fn(), + }, + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + }; +}); + +describe('updateRouteBeforeResponse', () => { + const mockRootSpan = { + setAttributes: vi.fn(), + } as unknown as Pick; + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('updates the transaction name for Nitro v2 matched routes', () => { + (getActiveSpan as Mock).mockReturnValue({} as Span); + (getRootSpan as Mock).mockReturnValue(mockRootSpan); + + updateRouteBeforeResponse({ + _path: '/users/123', + context: { + matchedRoute: { + path: '/users/:id', + }, + params: { + id: '123', + }, + }, + } as never); + + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': '/users/:id', + } satisfies SpanAttributes); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + 'params.id': '123', + 'url.path.parameter.id': '123', + } satisfies SpanAttributes); + expect(debug.log).toHaveBeenCalledWith('Updated transaction name for parametrized route: /users/:id'); + }); + + it('updates the transaction name for Nitro v3 matched routes', () => { + (getActiveSpan as Mock).mockReturnValue({} as Span); + (getRootSpan as Mock).mockReturnValue(mockRootSpan); + + updateRouteBeforeResponse({ + path: '/users/123', + context: { + matchedRoute: { + route: '/users/:id', + }, + params: { + id: '123', + }, + }, + } as never); + + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': '/users/:id', + } satisfies SpanAttributes); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + 'params.id': '123', + 'url.path.parameter.id': '123', + } satisfies SpanAttributes); + }); +});