diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue index 3547773a1af9..2768eb370f94 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue @@ -7,17 +7,19 @@ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/test-param/[param].vue index 019404aaf460..fde203b1575c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/test-param/[param].vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/test-param/[param].vue @@ -3,9 +3,10 @@ import { useRoute, useFetch } from '#imports'; const route = useRoute(); const param = route.params.param; +const apiPrefix = route.query.apiPrefix ?? '/api'; const fetchError = async () => { - await useFetch(`/api/param-error/${param}`); + await useFetch(`${apiPrefix}/param-error/${param}`); }; const fetchData = async () => { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/test-param/user/[userId].vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/test-param/user/[userId].vue index 41daf0460b05..39d629b25ad6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/test-param/user/[userId].vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/test-param/user/[userId].vue @@ -3,8 +3,9 @@ import { useFetch, useRoute } from '#imports'; const route = useRoute(); const userId = route.params.userId as string; +const apiPrefix = route.query.apiPrefix ?? '/api'; -const { data } = await useFetch(`/api/user/${userId}`, { +const { data } = await useFetch(`${apiPrefix}/user/${userId}`, { server: false, // Don't fetch during SSR, only client-side }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index ac1ea1939999..5f84fb085b6d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -25,7 +25,7 @@ "ioredis": "5.10.1", "mongoose": "^9.10.2", "mysql": "^2.18.1", - "nuxt": "^4.1.2" + "nuxt": "^4.6.0" }, "devDependencies": { "@playwright/test": "~1.63.0", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/middleware-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/middleware-test.ts new file mode 100644 index 000000000000..ab0b283df34b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/middleware-test.ts @@ -0,0 +1,9 @@ +import { defineEventHandler } from 'nuxt/server'; + +export default defineEventHandler(event => { + return { + message: 'Server middleware test endpoint', + path: event.url.pathname, + method: event.req.method, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/nitro-fetch.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/nitro-fetch.ts new file mode 100644 index 000000000000..8f8f4c4ae4fb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/nitro-fetch.ts @@ -0,0 +1,7 @@ +import { defineEventHandler } from 'nuxt/server'; + +// `$fetch` is a Nitro global, not a `nuxt/server` export, so the portable copy uses `fetch`. +export default defineEventHandler(async () => { + const response = await fetch('https://example.com'); + return response.text(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/param-error/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/param-error/[param].ts new file mode 100644 index 000000000000..6ad843e18b9f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/param-error/[param].ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from 'nuxt/server'; + +export default defineEventHandler(() => { + throw new Error('Nuxt 4 Param Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/server-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/server-error.ts new file mode 100644 index 000000000000..106f0ea9110c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/server-error.ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from 'nuxt/server'; + +export default defineEventHandler(() => { + throw new Error('Nuxt 4 Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/third-party-http-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/third-party-http-error.ts new file mode 100644 index 000000000000..7b70b0af0586 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/third-party-http-error.ts @@ -0,0 +1,16 @@ +import { defineEventHandler } from 'nuxt/server'; + +// Mimics ky's and got's `HTTPError`: it shares its `name` with h3's error class, but keeps the +// status on `response` instead of on the error itself. +class ThirdPartyHTTPError extends Error { + public readonly response = { status: 404 }; + + public constructor(message: string) { + super(message); + this.name = 'HTTPError'; + } +} + +export default defineEventHandler(() => { + throw new ThirdPartyHTTPError('Nuxt 4 third-party HTTPError'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/user/[userId].ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/user/[userId].ts new file mode 100644 index 000000000000..6fff4f8f91b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nuxt-server/user/[userId].ts @@ -0,0 +1,7 @@ +import { defineEventHandler, getRouterParam } from 'nuxt/server'; + +export default defineEventHandler(event => { + const userId = getRouterParam(event, 'userId'); + + return `UserId Param: ${userId}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/nuxt-server/06.nuxt-server-first.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/nuxt-server/06.nuxt-server-first.ts new file mode 100644 index 000000000000..58d68a494905 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/nuxt-server/06.nuxt-server-first.ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from 'nuxt/server'; + +export default defineEventHandler(event => { + event.res.headers.set('x-nuxt-server-first-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/nuxt-server/07.nuxt-server-auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/nuxt-server/07.nuxt-server-auth.ts new file mode 100644 index 000000000000..62b19ad9c1ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/nuxt-server/07.nuxt-server-auth.ts @@ -0,0 +1,9 @@ +import { defineEventHandler, getQuery } from 'nuxt/server'; + +export default defineEventHandler(event => { + if (getQuery(event).throwNuxtServerError === 'true') { + throw new Error('nuxt/server auth middleware error'); + } + + event.res.headers.set('x-nuxt-server-auth-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts index ea9c78b2d60e..418c09f16c57 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts @@ -1,94 +1,97 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; - -test.describe('server-side errors', async () => { - test('captures api fetch error (fetched on click)', async ({ page }) => { - const errorPromise = waitForError('nuxt-4', async errorEvent => { - return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; - }); - - await page.goto(`/fetch-server-routes`); - await page.getByText('Fetch Server API Error', { exact: true }).click(); - - const error = await errorPromise; - - expect(error.transaction).toEqual('GET /api/server-error'); - - const exception0 = error.exception.values[0]; - const exception1 = error.exception.values[1]; - - expect(exception0.type).toEqual('Error'); - expect(exception0.value).toEqual('Nuxt 4 Server error'); - expect(exception0.mechanism).toEqual({ - handled: true, - type: 'chained', - exception_id: 1, - parent_id: 0, - source: 'cause', - }); - - expect(exception1.type).toEqual('Error'); - expect(exception1.value).toEqual('Nuxt 4 Server error'); - expect(exception1.mechanism).toEqual({ - handled: false, - type: 'auto.function.nuxt.nitro', - exception_id: 0, - }); - }); - - test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { - const errorPromise = waitForError('nuxt-4', async errorEvent => { - return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Param Server error'; +import { IMPORT_SURFACES } from './importSurfaces'; + +IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { + test.describe(`server-side errors (${name})`, () => { + test('captures api fetch error (fetched on click)', async ({ page }) => { + const errorPromise = waitForError('nuxt-4', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; + }); + + await page.goto(`/fetch-server-routes?apiPrefix=${apiPrefix}`); + await page.getByText('Fetch Server API Error', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual(`GET ${apiPrefix}/server-error`); + + const exception0 = error.exception.values[0]; + const exception1 = error.exception.values[1]; + + expect(exception0.type).toEqual('Error'); + expect(exception0.value).toEqual('Nuxt 4 Server error'); + expect(exception0.mechanism).toEqual({ + handled: true, + type: 'chained', + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(exception1.type).toEqual('Error'); + expect(exception1.value).toEqual('Nuxt 4 Server error'); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); }); - await page.goto(`/test-param/1234`); - await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); - - const error = await errorPromise; - - expect(error.transaction).toEqual('GET /api/param-error/1234'); - - const exception0 = error.exception.values[0]; - const exception1 = error.exception.values[1]; - - expect(exception0.type).toEqual('Error'); - expect(exception0.value).toEqual('Nuxt 4 Param Server error'); - expect(exception0.mechanism).toEqual({ - handled: true, - type: 'chained', - exception_id: 1, - parent_id: 0, - source: 'cause', + test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { + const errorPromise = waitForError('nuxt-4', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Param Server error'; + }); + + await page.goto(`/test-param/1234?apiPrefix=${apiPrefix}`); + await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual(`GET ${apiPrefix}/param-error/1234`); + + const exception0 = error.exception.values[0]; + const exception1 = error.exception.values[1]; + + expect(exception0.type).toEqual('Error'); + expect(exception0.value).toEqual('Nuxt 4 Param Server error'); + expect(exception0.mechanism).toEqual({ + handled: true, + type: 'chained', + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(exception1.type).toEqual('Error'); + expect(exception1.value).toEqual('Nuxt 4 Param Server error'); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); }); - expect(exception1.type).toEqual('Error'); - expect(exception1.value).toEqual('Nuxt 4 Param Server error'); - expect(exception1.mechanism).toEqual({ - handled: false, - type: 'auto.function.nuxt.nitro', - exception_id: 0, + // ky and got name their errors `HTTPError` too. h3 wraps a thrown one in its own error before the + // hook sees it, so this checks it still gets reported. The hook's handling of an unwrapped lookalike + // is covered by the unit tests. + test('captures a thrown third-party `HTTPError`', async ({ page }) => { + const errorPromise = waitForError('nuxt-4', async errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'Nuxt 4 third-party HTTPError'); + }); + + await page.goto(`/fetch-server-routes?apiPrefix=${apiPrefix}`); + await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual(`GET ${apiPrefix}/third-party-http-error`); + expect(error.exception.values).toContainEqual( + expect.objectContaining({ + value: 'Nuxt 4 third-party HTTPError', + mechanism: expect.objectContaining({ handled: false, type: 'auto.function.nuxt.nitro' }), + }), + ); }); }); - - // ky and got name their errors `HTTPError` too. h3 wraps a thrown one in its own error before the - // hook sees it, so this checks it still gets reported. The hook's handling of an unwrapped lookalike - // is covered by the unit tests. - test('captures a thrown third-party `HTTPError`', async ({ page }) => { - const errorPromise = waitForError('nuxt-4', async errorEvent => { - return !!errorEvent?.exception?.values?.some(value => value.value === 'Nuxt 4 third-party HTTPError'); - }); - - await page.goto(`/fetch-server-routes`); - await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click(); - - const error = await errorPromise; - - expect(error.transaction).toEqual('GET /api/third-party-http-error'); - expect(error.exception.values).toContainEqual( - expect.objectContaining({ - value: 'Nuxt 4 third-party HTTPError', - mechanism: expect.objectContaining({ handled: false, type: 'auto.function.nuxt.nitro' }), - }), - ); - }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/importSurfaces.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/importSurfaces.ts new file mode 100644 index 000000000000..d0d4fd9a22d3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/importSurfaces.ts @@ -0,0 +1,6 @@ +// The server test routes under `apiPrefix` are copies of each other that differ only in where they import from. +// Reference: https://github.com/nuxt/nuxt/pull/36275 +export const IMPORT_SURFACES = [ + { name: '#imports', apiPrefix: '/api' }, + { name: 'nuxt/server', apiPrefix: '/api/nuxt-server' }, +] as const; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts index fe44b684fe14..cc1dd5b14d2d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts @@ -1,139 +1,145 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp, waitForError } from '@sentry-internal/test-utils'; - -async function collectRequestSpans() { - const spans = await collectStreamedSpansUntilSegment( - 'nuxt-4', - span => span.attributes['url.path']?.value === '/api/middleware-test', - ); - const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/middleware-test'); +import { IMPORT_SURFACES } from './importSurfaces'; + +// Nitro runs every file in `server/middleware/` on every request, so each surface tells its spans apart by +// middleware name. `nuxt/server` has no object syntax (`onRequest`, `onBeforeResponse`) and no `eventHandler` +// alias, so only the simple middleware has a copy. +const MIDDLEWARE = { + '#imports': { + names: ['01.first', '02.second', '03.auth', '04.hooks', '05.array-hooks'], + simpleNames: ['01.first', '02.second', '03.auth'], + // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse) + spanCount: 11, + authName: '03.auth', + authErrorQuery: 'throwError=true', + authErrorMessage: 'Auth middleware error', + }, + 'nuxt/server': { + names: ['06.nuxt-server-first', '07.nuxt-server-auth'], + simpleNames: ['06.nuxt-server-first', '07.nuxt-server-auth'], + spanCount: 2, + authName: '07.nuxt-server-auth', + authErrorQuery: 'throwNuxtServerError=true', + authErrorMessage: 'nuxt/server auth middleware error', + }, +} as const; + +async function collectRequestSpans(path = '/api/middleware-test') { + const spans = await collectStreamedSpansUntilSegment('nuxt-4', span => span.attributes['url.path']?.value === path); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === path); return spans.filter(span => span.trace_id === rootSpan?.trace_id); } -test.describe('Server Middleware Instrumentation', () => { - test('should create separate spans for each server middleware', async ({ request }) => { - const spansPromise = collectRequestSpans(); +IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { + const middleware = MIDDLEWARE[name]; + const path = `${apiPrefix}/middleware-test`; + const isSurfaceMiddlewareSpan = (span: { attributes: Record }) => + (middleware.names as readonly unknown[]).includes(span.attributes['nuxt.middleware.name']?.value); - // Make request to the API endpoint that will trigger all server middleware - const response = await request.get('/api/middleware-test'); - expect(response.status()).toBe(200); + test.describe(`Server Middleware Instrumentation (${name})`, () => { + test('should create separate spans for each server middleware', async ({ request }) => { + const spansPromise = collectRequestSpans(path); - const responseData = await response.json(); - expect(responseData.message).toBe('Server middleware test endpoint'); + const response = await request.get(path); + expect(response.status()).toBe(200); - const spans = await spansPromise; + const responseData = await response.json(); + expect(responseData.message).toBe('Server middleware test endpoint'); - // Verify that we have spans for each middleware - const middlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware'); + const spans = await spansPromise; - // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse) - expect(middlewareSpans).toHaveLength(11); - - // Check for specific middleware spans - const findSpanByName = (name: string) => - middlewareSpans.find(span => span.attributes['nuxt.middleware.name']?.value === name); - - const firstMiddlewareSpan = findSpanByName('01.first'); - const secondMiddlewareSpan = findSpanByName('02.second'); - const authMiddlewareSpan = findSpanByName('03.auth'); - const hooksOnRequestSpan = findSpanByName('04.hooks'); - const arrayHooksHandlerSpan = findSpanByName('05.array-hooks'); - - expect(firstMiddlewareSpan).toBeDefined(); - expect(secondMiddlewareSpan).toBeDefined(); - expect(authMiddlewareSpan).toBeDefined(); - expect(hooksOnRequestSpan).toBeDefined(); - expect(arrayHooksHandlerSpan).toBeDefined(); - - // Verify each span has the correct attributes - [firstMiddlewareSpan, secondMiddlewareSpan, authMiddlewareSpan].forEach(span => { - expect(span).toEqual( - expect.objectContaining({ - is_segment: false, - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'middleware' }, - 'sentry.origin': { type: 'string', value: 'auto.middleware.nuxt' }, - 'http.request.method': { type: 'string', value: 'GET' }, - 'http.route': { type: 'string', value: '/api/middleware-test' }, - }), - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }), - ); - }); + const middlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware' && isSurfaceMiddlewareSpan(span)); - // Verify spans have different span IDs (each middleware gets its own span) - const spanIds = middlewareSpans.map(span => span.span_id); - const uniqueSpanIds = new Set(spanIds); - // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse) - expect(uniqueSpanIds.size).toBe(11); + expect(middlewareSpans).toHaveLength(middleware.spanCount); - // Verify spans share the same trace ID - const traceIds = middlewareSpans.map(span => span.trace_id); - const uniqueTraceIds = new Set(traceIds); - expect(uniqueTraceIds.size).toBe(1); - }); + const findSpanByName = (middlewareName: string) => + middlewareSpans.find(span => span.attributes['nuxt.middleware.name']?.value === middlewareName); - test('middleware spans should have proper parent-child relationship', async ({ request }) => { - const spansPromise = collectRequestSpans(); + middleware.names.forEach(middlewareName => { + expect(findSpanByName(middlewareName)).toBeDefined(); + }); - await request.get('/api/middleware-test'); - const spans = await spansPromise; + middleware.simpleNames.forEach(middlewareName => { + expect(findSpanByName(middlewareName)).toEqual( + expect.objectContaining({ + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nuxt' }, + 'http.request.method': { type: 'string', value: 'GET' }, + 'http.route': { type: 'string', value: path }, + }), + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }), + ); + }); - const segmentSpan = spans.find( - span => span.is_segment && span.attributes['url.path']?.value === '/api/middleware-test', - ); - const middlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware'); + // Verify spans have different span IDs (each middleware gets its own span) + const uniqueSpanIds = new Set(middlewareSpans.map(span => span.span_id)); + expect(uniqueSpanIds.size).toBe(middleware.spanCount); - // All middleware spans should be children of the request's segment span - middlewareSpans.forEach(span => { - expect(span.parent_span_id).toBe(segmentSpan?.span_id); + const uniqueTraceIds = new Set(middlewareSpans.map(span => span.trace_id)); + expect(uniqueTraceIds.size).toBe(1); }); - }); - test('should capture errors thrown in middleware and associate them with the span', async ({ request }) => { - const spansPromise = collectRequestSpans(); + test('middleware spans should have proper parent-child relationship', async ({ request }) => { + const spansPromise = collectRequestSpans(path); - const errorEventPromise = waitForError('nuxt-4', errorEvent => { - return errorEvent?.exception?.values?.[0]?.value === 'Auth middleware error'; + await request.get(path); + const spans = await spansPromise; + + const segmentSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === path); + const middlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware' && isSurfaceMiddlewareSpan(span)); + + expect(middlewareSpans).toHaveLength(middleware.spanCount); + middlewareSpans.forEach(span => { + expect(span.parent_span_id).toBe(segmentSpan?.span_id); + }); }); - // Make request with query param to trigger error in auth middleware - const response = await request.get('/api/middleware-test?throwError=true'); + test('should capture errors thrown in middleware and associate them with the span', async ({ request }) => { + const spansPromise = collectRequestSpans(path); - // The request should fail due to the middleware error - expect(response.status()).toBe(500); + const errorEventPromise = waitForError('nuxt-4', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === middleware.authErrorMessage; + }); - const [spans, errorEvent] = await Promise.all([spansPromise, errorEventPromise]); + const response = await request.get(`${path}?${middleware.authErrorQuery}`); - // Find the auth middleware span - const authMiddlewareSpan = spans.find( - span => getSpanOp(span) === 'middleware' && span.attributes['nuxt.middleware.name']?.value === '03.auth', - ); + expect(response.status()).toBe(500); - expect(authMiddlewareSpan).toBeDefined(); + const [spans, errorEvent] = await Promise.all([spansPromise, errorEventPromise]); - // Verify the span has error status - expect(authMiddlewareSpan?.status).toBe('error'); + const authMiddlewareSpan = spans.find( + span => + getSpanOp(span) === 'middleware' && span.attributes['nuxt.middleware.name']?.value === middleware.authName, + ); + + expect(authMiddlewareSpan).toBeDefined(); + expect(authMiddlewareSpan?.status).toBe('error'); - // Verify the error event is associated with the correct request - expect(errorEvent.transaction).toContain('GET /api/middleware-test'); + expect(errorEvent.transaction).toContain(`GET ${path}`); - // Verify the error has the correct mechanism - expect(errorEvent.exception?.values?.[0]).toEqual( - expect.objectContaining({ - value: 'Auth middleware error', - type: 'Error', - mechanism: expect.objectContaining({ - handled: false, - type: 'auto.middleware.nuxt', + expect(errorEvent.exception?.values?.[0]).toEqual( + expect.objectContaining({ + value: middleware.authErrorMessage, + type: 'Error', + mechanism: expect.objectContaining({ + handled: false, + type: 'auto.middleware.nuxt', + }), }), - }), - ); + ); + }); }); +}); +// `nuxt/server` has no object syntax for middleware hooks, so these only run on the classic surface. +test.describe('Server Middleware Instrumentation (#imports hooks)', () => { test('should create spans for onRequest and onBeforeResponse hooks', async ({ request }) => { const spansPromise = collectRequestSpans(); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts index 16097e9d1dd2..4ef6d7c039e0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { IMPORT_SURFACES } from './importSurfaces'; test('sends a server root span on pageload', async ({ page }) => { const serverSpanPromise = waitForStreamedSpan('nuxt-4', span => { @@ -38,34 +39,36 @@ test('does not send spans for build asset folder "_nuxt"', async ({ page }) => { expect(serverSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('captures server API calls made with Nitro $fetch', async ({ page }) => { - // The server-side http.client span can flush before its segment. Accumulate until both arrived. - const spansPromise = collectStreamedSpans('nuxt-4', spans => { - return ( - spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/nitro-fetch') && - spans.some( - span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes('example.com'), - ) +IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { + test(`captures server API calls made with Nitro $fetch (${name})`, async ({ page }) => { + // The server-side http.client span can flush before its segment. Accumulate until both arrived. + const spansPromise = collectStreamedSpans('nuxt-4', spans => { + return ( + spans.some(span => span.is_segment && span.attributes['url.path']?.value === `${apiPrefix}/nitro-fetch`) && + spans.some( + span => getSpanOp(span) === 'http.client' && span.attributes['url.full']?.value === 'https://example.com/', + ) + ); + }); + + await page.goto(`/fetch-server-routes?apiPrefix=${apiPrefix}`); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const spans = await spansPromise; + + const serverSegmentSpan = spans.find( + span => span.is_segment && span.attributes['url.path']?.value === `${apiPrefix}/nitro-fetch`, + ); + const httpClientSpan = spans.find( + span => getSpanOp(span) === 'http.client' && span.attributes['url.full']?.value === 'https://example.com/', ); - }); - - await page.goto(`/fetch-server-routes`); - await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); - - const spans = await spansPromise; - - const serverSegmentSpan = spans.find( - span => span.is_segment && span.attributes['url.path']?.value === '/api/nitro-fetch', - ); - const httpClientSpan = spans.find( - span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes('example.com'), - ); - expect(serverSegmentSpan).toBeDefined(); - expect(getSpanOp(serverSegmentSpan!)).toBe('http.server'); + expect(serverSegmentSpan).toBeDefined(); + expect(getSpanOp(serverSegmentSpan!)).toBe('http.server'); - expect(httpClientSpan).toBeDefined(); - expect(httpClientSpan?.name).toBe('GET example.com'); - expect(httpClientSpan?.trace_id).toBe(serverSegmentSpan?.trace_id); - expect(httpClientSpan?.parent_span_id).toBe(serverSegmentSpan?.span_id); + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan?.name).toBe('GET example.com'); + expect(httpClientSpan?.trace_id).toBe(serverSegmentSpan?.trace_id); + expect(httpClientSpan?.parent_span_id).toBe(serverSegmentSpan?.span_id); + }); }); 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 70419d18389e..7ee91e3eeb9c 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 @@ -1,9 +1,9 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { IMPORT_SURFACES } from './importSurfaces'; test.describe('distributed tracing', () => { const PARAM = 's0me-param'; - const API_PATH = `/api/user/${PARAM}`; test('capture a distributed pageload trace', async ({ page }) => { const clientSpanPromise = waitForStreamedSpan('nuxt-4', span => { @@ -62,87 +62,93 @@ test.describe('distributed tracing', () => { expect(serverSpan.trace_id).toBe(metaTraceId); }); - test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { - // The `http.client` span ends after the pageload segment, so it can be flushed in a later - // envelope. Accumulate until both spans have arrived. - const clientSpansPromise = collectStreamedSpans('nuxt-4', spans => { - return ( - spans.some(span => span.name === '/test-param/user/:userId()' && span.is_segment) && - spans.some( - span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes(API_PATH), - ) + IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { + test(`capture a distributed trace from a client-side API request with parametrized routes (${name})`, async ({ + page, + }) => { + const API_PATH = `${apiPrefix}/user/${PARAM}`; + + // The `http.client` span ends after the pageload segment, so it can be flushed in a later + // envelope. Accumulate until both spans have arrived. + const clientSpansPromise = collectStreamedSpans('nuxt-4', spans => { + return ( + spans.some(span => span.name === '/test-param/user/:userId()' && span.is_segment) && + spans.some( + span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes(API_PATH), + ) + ); + }); + const ssrSpanPromise = waitForStreamedSpan('nuxt-4', span => { + return span.is_segment && span.name.includes('GET /test-param/user'); + }); + const serverReqSpanPromise = waitForStreamedSpan('nuxt-4', span => { + return span.is_segment && span.name.includes(`GET ${apiPrefix}/user/`); + }); + + // Navigate to the page which will trigger an API call from the client-side + await page.goto(`/test-param/user/${PARAM}?apiPrefix=${apiPrefix}`); + + const [clientSpans, ssrSpan, serverReqSpan] = await Promise.all([ + clientSpansPromise, + ssrSpanPromise, + serverReqSpanPromise, + ]); + + const pageloadSpan = clientSpans.find(span => span.name === '/test-param/user/:userId()' && span.is_segment); + const httpClientSpan = clientSpans.find( + span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes(API_PATH), ); - }); - const ssrSpanPromise = waitForStreamedSpan('nuxt-4', span => { - return span.is_segment && span.name.includes('GET /test-param/user'); - }); - const serverReqSpanPromise = waitForStreamedSpan('nuxt-4', span => { - return span.is_segment && span.name.includes('GET /api/user/'); - }); - // Navigate to the page which will trigger an API call from the client-side - await page.goto(`/test-param/user/${PARAM}`); - - const [clientSpans, ssrSpan, serverReqSpan] = await Promise.all([ - clientSpansPromise, - ssrSpanPromise, - serverReqSpanPromise, - ]); - - const pageloadSpan = clientSpans.find(span => span.name === '/test-param/user/:userId()' && span.is_segment); - const httpClientSpan = clientSpans.find( - span => getSpanOp(span) === 'http.client' && `${span.attributes['url.full']?.value}`.includes(API_PATH), - ); - - expect(pageloadSpan).toMatchObject({ - name: '/test-param/user/:userId()', - is_segment: true, - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'pageload' }, - 'sentry.origin': { type: 'string', value: 'auto.pageload.vue' }, - 'sentry.segment.name.source': { type: 'string', value: 'route' }, - }), - }); - - expect(httpClientSpan).toBeDefined(); - expect(httpClientSpan).toMatchObject({ - // A relative fetch has no domain of its own, so it resolves against the page origin. - name: 'GET localhost', - parent_span_id: pageloadSpan?.span_id, // pageload span is parent - attributes: expect.objectContaining({ - type: { type: 'string', value: 'fetch' }, - 'sentry.op': { type: 'string', value: 'http.client' }, - 'sentry.origin': { type: 'string', value: 'auto.http.browser' }, - 'http.request.method': { type: 'string', value: 'GET' }, - 'url.full': { type: 'string', value: expect.stringContaining(API_PATH) }, - 'url.domain': { type: 'string', value: 'localhost' }, - }), + expect(pageloadSpan).toMatchObject({ + name: '/test-param/user/:userId()', + is_segment: true, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'pageload' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.vue' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + }), + }); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan).toMatchObject({ + // A relative fetch has no domain of its own, so it resolves against the page origin. + name: 'GET localhost', + parent_span_id: pageloadSpan?.span_id, // pageload span is parent + attributes: expect.objectContaining({ + type: { type: 'string', value: 'fetch' }, + 'sentry.op': { type: 'string', value: 'http.client' }, + 'sentry.origin': { type: 'string', value: 'auto.http.browser' }, + 'http.request.method': { type: 'string', value: 'GET' }, + 'url.full': { type: 'string', value: expect.stringContaining(API_PATH) }, + 'url.domain': { type: 'string', value: 'localhost' }, + }), + }); + + expect(ssrSpan).toMatchObject({ + name: 'GET /test-param/user/:userId()', // parametrized route + is_segment: true, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + }), + }); + + expect(serverReqSpan).toMatchObject({ + name: `GET ${apiPrefix}/user/:userId`, // parametrized route + is_segment: true, + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + }), + }); + + // `collectStreamedSpans` already guarantees the pageload and http.client spans share a trace, + // so only the independently awaited server spans need the check. + expect(pageloadSpan?.trace_id).toBeDefined(); + expect(pageloadSpan?.trace_id).toBe(ssrSpan.trace_id); + expect(pageloadSpan?.trace_id).toBe(serverReqSpan.trace_id); }); - - expect(ssrSpan).toMatchObject({ - name: 'GET /test-param/user/:userId()', // parametrized route - is_segment: true, - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'http.server' }, - 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, - 'sentry.segment.name.source': { type: 'string', value: 'route' }, - }), - }); - - expect(serverReqSpan).toMatchObject({ - name: 'GET /api/user/:userId', // parametrized route - is_segment: true, - parent_span_id: httpClientSpan?.span_id, // http.client span is parent - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'http.server' }, - 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, - }), - }); - - // `collectStreamedSpans` already guarantees the pageload and http.client spans share a trace, - // so only the independently awaited server spans need the check. - expect(pageloadSpan?.trace_id).toBeDefined(); - expect(pageloadSpan?.trace_id).toBe(ssrSpan.trace_id); - expect(pageloadSpan?.trace_id).toBe(serverReqSpan.trace_id); }); });