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