diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-16/.npmrc new file mode 100644 index 000000000000..a3160f4de175 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/.npmrc @@ -0,0 +1,4 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 +public-hoist-pattern[]=*import-in-the-middle* +public-hoist-pattern[]=*require-in-the-middle* diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/endpoint-behind-middleware/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/endpoint-behind-middleware/route.ts new file mode 100644 index 000000000000..2733cc918f44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/endpoint-behind-middleware/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ name: 'John Doe' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 46fdc5ecffa1..3d1df82b1748 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@sentry/nextjs": "latest || *", + "@sentry/core": "latest || *", "ai": "^3.0.0", "import-in-the-middle": "^1", "next": "16.0.0-beta.0", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts new file mode 100644 index 000000000000..60722f329fa0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts @@ -0,0 +1,24 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function proxy(request: NextRequest) { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + + if (request.headers.has('x-should-throw')) { + throw new Error('Middleware Error'); + } + + if (request.headers.has('x-should-make-request')) { + await fetch('http://localhost:3030/'); + } + + return NextResponse.next(); +} + +// See "Matching Paths" below to learn more +export const config = { + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts index 85bd765c9c44..2199afc46eaf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts @@ -6,4 +6,5 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, sendDefaultPii: true, + // debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts index 8da0a18497a0..08d5d580b314 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts @@ -6,5 +6,6 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, sendDefaultPii: true, + // debug: true, integrations: [Sentry.vercelAIIntegration()], }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts new file mode 100644 index 000000000000..a8096ab7bc69 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should create a transaction for middleware', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; + }); + + const response = await request.get('/api/endpoint-behind-middleware'); + expect(await response.json()).toStrictEqual({ name: 'John Doe' }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); + expect(middlewareTransaction.transaction_info?.source).toBe('url'); + + // Assert that isolation scope works properly + expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); + expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); +}); + +test('Faulty middlewares', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; + }); + + const errorEventPromise = waitForError('nextjs-16', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Middleware Error'; + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { + // Noop + }); + + await test.step('should record transactions', async () => { + const middlewareTransaction = await middlewareTransactionPromise; + expect(middlewareTransaction.contexts?.trace?.status).toBe('unknown_error'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); + expect(middlewareTransaction.transaction_info?.source).toBe('url'); + }); + + await test.step('should record exceptions', async () => { + const errorEvent = await errorEventPromise; + + // Assert that isolation scope works properly + expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + // this differs between webpack and turbopack + expect(['middleware GET', '/middleware']).toContain(errorEvent.transaction); + }); +}); + +test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return ( + transactionEvent?.transaction === 'middleware GET' && + !!transactionEvent.spans?.find(span => span.op === 'http.client') + ); + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-make-request': '1' } }).catch(() => { + // Noop + }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.spans).toEqual( + expect.arrayContaining([ + { + data: { + 'http.method': 'GET', + 'http.response.status_code': 200, + type: 'fetch', + url: 'http://localhost:3030/', + 'http.url': 'http://localhost:3030/', + 'server.address': 'localhost:3030', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.wintercg_fetch', + }, + description: 'GET http://localhost:3030/', + op: 'http.client', + origin: 'auto.http.wintercg_fetch', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ]), + ); + expect(middlewareTransaction.breadcrumbs).toEqual( + expect.arrayContaining([ + { + category: 'fetch', + data: { method: 'GET', status_code: 200, url: 'http://localhost:3030/' }, + timestamp: expect.any(Number), + type: 'http', + }, + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts index b9c0e7b4b602..45a89f683be4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts @@ -3,7 +3,7 @@ import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should create a transaction for middleware', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware'; + return transactionEvent?.transaction === 'middleware GET'; }); const response = await request.get('/api/endpoint-behind-middleware'); @@ -23,7 +23,7 @@ test('Should create a transaction for middleware', async ({ request }) => { test('Faulty middlewares', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-faulty-middleware'; + return transactionEvent?.transaction === 'middleware GET'; }); const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => { @@ -48,14 +48,14 @@ test('Faulty middlewares', async ({ request }) => { // Assert that isolation scope works properly expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - expect(errorEvent.transaction).toBe('middleware GET /api/endpoint-behind-faulty-middleware'); + expect(errorEvent.transaction).toBe('middleware GET'); }); }); test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { return ( - transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware' && + transactionEvent?.transaction === 'middleware GET' && !!transactionEvent.spans?.find(span => span.op === 'http.client') ); }); diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 07694d659e57..ba4f7a852d45 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -64,7 +64,7 @@ export function wrapMiddlewareWithSentry( isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(req), }); - spanName = `middleware ${req.method} ${new URL(req.url).pathname}`; + spanName = `middleware ${req.method}`; spanSource = 'url'; } else { spanName = 'middleware'; diff --git a/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts b/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts index 6d44af1275b5..236f4eff3999 100644 --- a/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts +++ b/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts @@ -15,6 +15,7 @@ type NextApiModule = // ESM export default?: EdgeRouteHandler; middleware?: EdgeRouteHandler; + proxy?: EdgeRouteHandler; } // CJS export | EdgeRouteHandler; @@ -29,6 +30,9 @@ let userProvidedDefaultHandler: EdgeRouteHandler | undefined = undefined; if ('middleware' in userApiModule && typeof userApiModule.middleware === 'function') { // Handle when user defines via named ESM export: `export { middleware };` userProvidedNamedHandler = userApiModule.middleware; +} else if ('proxy' in userApiModule && typeof userApiModule.proxy === 'function') { + // Handle when user defines via named ESM export (Next.js 16): `export { proxy };` + userProvidedNamedHandler = userApiModule.proxy; } else if ('default' in userApiModule && typeof userApiModule.default === 'function') { // Handle when user defines via ESM export: `export default myFunction;` userProvidedDefaultHandler = userApiModule.default; @@ -40,6 +44,7 @@ if ('middleware' in userApiModule && typeof userApiModule.middleware === 'functi export const middleware = userProvidedNamedHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedNamedHandler) : undefined; +export const proxy = userProvidedNamedHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedNamedHandler) : undefined; export default userProvidedDefaultHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedDefaultHandler) : undefined; // Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 6ba07cd09f8f..5d5fe326e771 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -183,8 +183,11 @@ export function constructWebpackConfigFunction({ ); }; - const possibleMiddlewareLocations = pageExtensions.map(middlewareFileEnding => { - return path.join(middlewareLocationFolder, `middleware.${middlewareFileEnding}`); + const possibleMiddlewareLocations = pageExtensions.flatMap(middlewareFileEnding => { + return [ + path.join(middlewareLocationFolder, `middleware.${middlewareFileEnding}`), + path.join(middlewareLocationFolder, `proxy.${middlewareFileEnding}`), + ]; }); const isMiddlewareResource = (resourcePath: string): boolean => { const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 6469e3c6a2c8..6ee523fe72dc 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,5 +1,8 @@ +import { context } from '@opentelemetry/api'; import { applySdkMetadata, + getCapturedScopesOnSpan, + getCurrentScope, getGlobalScope, getIsolationScope, getRootSpan, @@ -8,10 +11,12 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setCapturedScopesOnSpan, spanToJSON, stripUrlQueryAndFragment, vercelWaitUntil, } from '@sentry/core'; +import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; @@ -73,6 +78,19 @@ export function init(options: VercelEdgeOptions = {}): void { if (spanAttributes?.['next.span_type'] === 'Middleware.execute') { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server.middleware'); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); + + if (isRootSpan) { + // Fork isolation scope for middleware requests + const scopes = getCapturedScopesOnSpan(span); + const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); + const scope = scopes.scope || getCurrentScope(); + const currentScopesPointer = getScopesFromContext(context.active()); + if (currentScopesPointer) { + currentScopesPointer.isolationScope = isolationScope; + } + + setCapturedScopesOnSpan(span, scope, isolationScope); + } } if (isRootSpan) { @@ -93,7 +111,19 @@ export function init(options: VercelEdgeOptions = {}): void { event.contexts?.trace?.data?.['next.span_name'] ) { if (event.transaction) { - event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']); + // Older nextjs versions pass the full url appended to the middleware name, which results in high cardinality transaction names. + // We want to remove the url from the name here. + const spanName = event.contexts.trace.data['next.span_name']; + + if (typeof spanName === 'string') { + const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); + if (match) { + const normalizedName = `middleware ${match[1]}`; + event.transaction = normalizedName; + } else { + event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']); + } + } } } }); diff --git a/packages/nextjs/test/config/loaders.test.ts b/packages/nextjs/test/config/loaders.test.ts index 1b290796acb3..a2c1551ae4d1 100644 --- a/packages/nextjs/test/config/loaders.test.ts +++ b/packages/nextjs/test/config/loaders.test.ts @@ -129,6 +129,27 @@ describe('webpack loaders', () => { resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/middleware.tsx', expectedWrappingTargetKind: undefined, }, + // Next.js 16+ renamed middleware to proxy + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/proxy.js', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/proxy.ts', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: './src/proxy.ts', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/proxy.tsx', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/proxy.tsx', + expectedWrappingTargetKind: undefined, + }, { resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/api/testApiRoute.ts', expectedWrappingTargetKind: 'api-route',