diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts index 6096fcfb1493..abc565f438b4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts @@ -20,5 +20,5 @@ export async function middleware(request: NextRequest) { // See "Matching Paths" below to learn more export const config = { - matcher: ['/api/endpoint-behind-middleware'], + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], }; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/next-env.d.ts index fd36f9494e2c..725dd6f24515 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/next-env.d.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/next-env.d.ts @@ -3,4 +3,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/async-context-edge-endpoint.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/async-context-edge-endpoint.ts index 6dc023fdf1ed..d6a129f9e056 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/async-context-edge-endpoint.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/async-context-edge-endpoint.ts @@ -7,21 +7,22 @@ export const config = { export default async function handler() { // Without a working async context strategy the two spans created by `Sentry.startSpan()` would be nested. - const outerSpanPromise = Sentry.withIsolationScope(() => { - return Sentry.startSpan({ name: 'outer-span' }, () => { - return new Promise(resolve => setTimeout(resolve, 300)); - }); + const outerSpanPromise = Sentry.startSpan({ name: 'outer-span' }, () => { + return new Promise(resolve => setTimeout(resolve, 300)); }); - setTimeout(() => { - Sentry.withIsolationScope(() => { - return Sentry.startSpan({ name: 'inner-span' }, () => { + const innerSpanPromise = new Promise(resolve => { + setTimeout(() => { + Sentry.startSpan({ name: 'inner-span' }, () => { return new Promise(resolve => setTimeout(resolve, 100)); + }).then(() => { + resolve(); }); - }); - }, 100); + }, 100); + }); await outerSpanPromise; + await innerSpanPromise; return new Response('ok', { status: 200 }); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-faulty-middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-faulty-middleware.ts new file mode 100644 index 000000000000..2ca75a33ba7e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-faulty-middleware.ts @@ -0,0 +1,9 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +type Data = { + name: string; +}; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + res.status(200).json({ name: 'John Doe' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/async-context-edge.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/async-context-edge.test.ts index ecce719f0656..cb92cb2bab49 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/async-context-edge.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/async-context-edge.test.ts @@ -3,7 +3,10 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; test('Should allow for async context isolation in the edge SDK', async ({ request }) => { const edgerouteTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return transactionEvent?.transaction === 'GET /api/async-context-edge-endpoint'; + return ( + transactionEvent?.transaction === 'GET /api/async-context-edge-endpoint' && + transactionEvent.contexts?.runtime?.name === 'vercel-edge' + ); }); await request.get('/api/async-context-edge-endpoint'); @@ -13,8 +16,5 @@ test('Should allow for async context isolation in the edge SDK', async ({ reques const outerSpan = asyncContextEdgerouteTransaction.spans?.find(span => span.description === 'outer-span'); const innerSpan = asyncContextEdgerouteTransaction.spans?.find(span => span.description === 'inner-span'); - // @ts-expect-error parent_span_id exists - expect(outerSpan?.parent_span_id).toStrictEqual(asyncContextEdgerouteTransaction.contexts?.trace?.span_id); - // @ts-expect-error parent_span_id exists - expect(innerSpan?.parent_span_id).toStrictEqual(asyncContextEdgerouteTransaction.contexts?.trace?.span_id); + expect(outerSpan?.parent_span_id).toStrictEqual(innerSpan?.parent_span_id); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts index 810e76eaa690..6233688317e4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts @@ -5,7 +5,6 @@ test('Should create a transaction for edge routes', async ({ request }) => { const edgerouteTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'GET /api/edge-endpoint' && - transactionEvent?.contexts?.trace?.status === 'ok' && transactionEvent.contexts?.runtime?.name === 'vercel-edge' ); }); @@ -24,31 +23,11 @@ test('Should create a transaction for edge routes', async ({ request }) => { expect(edgerouteTransaction.request?.headers?.['x-yeet']).toBe('test-value'); }); -test('Should create a transaction with error status for faulty edge routes', async ({ request }) => { +test('Faulty edge routes', async ({ request }) => { const edgerouteTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return ( - transactionEvent?.transaction === 'GET /api/error-edge-endpoint' && - transactionEvent?.contexts?.trace?.status === 'unknown_error' - ); + return transactionEvent?.transaction === 'GET /api/error-edge-endpoint'; }); - request.get('/api/error-edge-endpoint').catch(() => { - // Noop - }); - - const edgerouteTransaction = await edgerouteTransactionPromise; - - expect(edgerouteTransaction.contexts?.trace?.status).toBe('unknown_error'); - expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server'); - expect(edgerouteTransaction.contexts?.runtime?.name).toBe('vercel-edge'); - - // Assert that isolation scope works properly - expect(edgerouteTransaction.tags?.['my-isolated-tag']).toBe(true); - expect(edgerouteTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); -}); - -// TODO(lforst): This cannot make it into production - Make sure to fix this test -test.skip('Should record exceptions for faulty edge routes', async ({ request }) => { const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Edge Route Error'; }); @@ -57,11 +36,21 @@ test.skip('Should record exceptions for faulty edge routes', async ({ request }) // Noop }); - const errorEvent = await errorEventPromise; + const [edgerouteTransaction, errorEvent] = await Promise.all([ + test.step('should create a transaction', () => edgerouteTransactionPromise), + test.step('should create an error event', () => 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(); + test.step('should create transactions with the right fields', () => { + expect(edgerouteTransaction.contexts?.trace?.status).toBe('unknown_error'); + expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server'); + expect(edgerouteTransaction.contexts?.runtime?.name).toBe('vercel-edge'); + }); - expect(errorEvent.transaction).toBe('GET /api/error-edge-endpoint'); + test.step('should have scope isolation', () => { + expect(edgerouteTransaction.tags?.['my-isolated-tag']).toBe(true); + expect(edgerouteTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts index de4e2f45ed37..a34d415ee4bf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts @@ -19,17 +19,19 @@ test('Should record exceptions for faulty edge server components', async ({ page expect(errorEvent.transaction).toBe(`Page Server Component (/edge-server-components/error)`); }); -// TODO(lforst): This test skip cannot make it into production - make sure to fix this test before merging into develop branch +// TODO(lforst): Fix this test test.skip('Should record transaction for edge server components', async ({ page }) => { const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return transactionEvent?.transaction === 'GET /edge-server-components'; + return ( + transactionEvent?.transaction === 'GET /edge-server-components' && + transactionEvent.contexts?.runtime?.name === 'vercel-edge' + ); }); await page.goto('/edge-server-components'); const serverComponentTransaction = await serverComponentTransactionPromise; - expect(serverComponentTransaction).toBe(1); expect(serverComponentTransaction).toBeDefined(); expect(serverComponentTransaction.request?.headers).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts index 2fb31bba13a7..5501f9a13b33 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-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-app-dir', async transactionEvent => { - return transactionEvent?.transaction === 'middleware' && transactionEvent?.contexts?.trace?.status === 'ok'; + return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware'; }); const response = await request.get('/api/endpoint-behind-middleware'); @@ -12,7 +12,7 @@ test('Should create a transaction for middleware', async ({ request }) => { const middlewareTransaction = await middlewareTransactionPromise; expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware.nextjs'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); // Assert that isolation scope works properly @@ -20,46 +20,40 @@ test('Should create a transaction for middleware', async ({ request }) => { expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); -test('Should create a transaction with error status for faulty middleware', async ({ request }) => { +test('Faulty middlewares', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return ( - transactionEvent?.transaction === 'middleware' && transactionEvent?.contexts?.trace?.status === 'unknown_error' - ); + return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-faulty-middleware'; }); - request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { - // Noop - }); - - const middlewareTransaction = await middlewareTransactionPromise; - - expect(middlewareTransaction.contexts?.trace?.status).toBe('unknown_error'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware.nextjs'); - expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); -}); - -// TODO(lforst): This cannot make it into production - Make sure to fix this test -test.skip('Records exceptions happening in middleware', async ({ request }) => { const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Middleware Error'; }); - request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { + request.get('/api/endpoint-behind-faulty-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { // Noop }); - const errorEvent = await errorEventPromise; + 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'); + }); - // 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'); + 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(); + expect(errorEvent.transaction).toBe('middleware GET /api/endpoint-behind-faulty-middleware'); + }); }); test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( - transactionEvent?.transaction === 'middleware' && + transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware' && !!transactionEvent.spans?.find(span => span.op === 'http.client') ); }); diff --git a/packages/nextjs/src/common/utils/edgeWrapperUtils.ts b/packages/nextjs/src/common/utils/edgeWrapperUtils.ts deleted file mode 100644 index 5eed59aca0a3..000000000000 --- a/packages/nextjs/src/common/utils/edgeWrapperUtils.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SPAN_STATUS_OK, - captureException, - continueTrace, - handleCallbackErrors, - setHttpStatus, - startSpan, - withIsolationScope, -} from '@sentry/core'; -import { vercelWaitUntil, winterCGRequestToRequestData } from '@sentry/utils'; - -import type { EdgeRouteHandler } from '../../edge/types'; -import { flushSafelyWithTimeout } from './responseEnd'; -import { commonObjectToIsolationScope, escapeNextjsTracing } from './tracingUtils'; - -/** - * Wraps a function on the edge runtime with error and performance monitoring. - */ -export function withEdgeWrapping( - handler: H, - options: { spanDescription: string; spanOp: string; mechanismFunctionName: string }, -): (...params: Parameters) => Promise> { - return async function (this: unknown, ...args) { - return escapeNextjsTracing(() => { - const req: unknown = args[0]; - return withIsolationScope(commonObjectToIsolationScope(req), isolationScope => { - let sentryTrace; - let baggage; - - if (req instanceof Request) { - sentryTrace = req.headers.get('sentry-trace') || ''; - baggage = req.headers.get('baggage'); - - isolationScope.setSDKProcessingMetadata({ - request: winterCGRequestToRequestData(req), - }); - } - - isolationScope.setTransactionName(options.spanDescription); - - return continueTrace( - { - sentryTrace, - baggage, - }, - () => { - return startSpan( - { - name: options.spanDescription, - op: options.spanOp, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.withEdgeWrapping', - }, - }, - async span => { - const handlerResult = await handleCallbackErrors( - () => handler.apply(this, args), - error => { - captureException(error, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: options.mechanismFunctionName, - }, - }, - }); - }, - ); - - if (handlerResult instanceof Response) { - setHttpStatus(span, handlerResult.status); - } else { - span.setStatus({ code: SPAN_STATUS_OK }); - } - - return handlerResult; - }, - ); - }, - ).finally(() => { - vercelWaitUntil(flushSafelyWithTimeout()); - }); - }); - }); - }; -} diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 66cbbb046300..9f0903e86984 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -1,5 +1,19 @@ +import { + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + captureException, + getActiveSpan, + getCurrentScope, + getRootSpan, + handleCallbackErrors, + setCapturedScopesOnSpan, + startSpan, + withIsolationScope, +} from '@sentry/core'; +import type { TransactionSource } from '@sentry/types'; +import { vercelWaitUntil, winterCGRequestToRequestData } from '@sentry/utils'; import type { EdgeRouteHandler } from '../edge/types'; -import { withEdgeWrapping } from './utils/edgeWrapperUtils'; +import { flushSafelyWithTimeout } from './utils/responseEnd'; /** * Wraps Next.js middleware with Sentry error and performance instrumentation. @@ -11,12 +25,69 @@ export function wrapMiddlewareWithSentry( middleware: H, ): (...params: Parameters) => Promise> { return new Proxy(middleware, { - apply: (wrappingTarget, thisArg, args: Parameters) => { - return withEdgeWrapping(wrappingTarget, { - spanDescription: 'middleware', - spanOp: 'middleware.nextjs', - mechanismFunctionName: 'withSentryMiddleware', - }).apply(thisArg, args); + apply: async (wrappingTarget, thisArg, args: Parameters) => { + // TODO: We still should add central isolation scope creation for when our build-time instrumentation does not work anymore with turbopack. + return withIsolationScope(isolationScope => { + const req: unknown = args[0]; + const currentScope = getCurrentScope(); + + let spanName: string; + let spanOrigin: TransactionSource; + + if (req instanceof Request) { + isolationScope.setSDKProcessingMetadata({ + request: winterCGRequestToRequestData(req), + }); + spanName = `middleware ${req.method} ${new URL(req.url).pathname}`; + spanOrigin = 'url'; + } else { + spanName = 'middleware'; + spanOrigin = 'component'; + } + + currentScope.setTransactionName(spanName); + + const activeSpan = getActiveSpan(); + + if (activeSpan) { + // If there is an active span, it likely means that the automatic Next.js OTEL instrumentation worked and we can + // rely on that for parameterization. + spanName = 'middleware'; + spanOrigin = 'component'; + + const rootSpan = getRootSpan(activeSpan); + if (rootSpan) { + setCapturedScopesOnSpan(rootSpan, currentScope, isolationScope); + } + } + + return startSpan( + { + name: spanName, + op: 'http.server.middleware', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: spanOrigin, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.wrapMiddlewareWithSentry', + }, + }, + () => { + return handleCallbackErrors( + () => wrappingTarget.apply(thisArg, args), + error => { + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + }, + }); + }, + () => { + vercelWaitUntil(flushSafelyWithTimeout()); + }, + ); + }, + ); + }); }, }); } diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index cf0e571e67cb..0a63118ada46 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,20 +1,17 @@ -import { context } from '@opentelemetry/api'; import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, applySdkMetadata, - getCapturedScopesOnSpan, - getCurrentScope, - getIsolationScope, getRootSpan, registerSpanErrorInstrumentation, - setCapturedScopesOnSpan, + spanToJSON, } from '@sentry/core'; -import { GLOBAL_OBJ } from '@sentry/utils'; +import { GLOBAL_OBJ, vercelWaitUntil } from '@sentry/utils'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; -import { getScopesFromContext } from '@sentry/opentelemetry'; import { isBuild } from '../common/utils/isBuild'; +import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error'; @@ -52,20 +49,18 @@ export function init(options: VercelEdgeOptions = {}): void { const client = vercelEdgeInit(opts); - // Create/fork an isolation whenever we create root spans. This is ok because in Next.js we only create root spans on the edge for incoming requests. client?.on('spanStart', span => { - if (span === getRootSpan(span)) { - const scopes = getCapturedScopesOnSpan(span); - - const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); - const scope = scopes.scope || getCurrentScope(); + const spanAttributes = spanToJSON(span).data; - const currentScopesPointer = getScopesFromContext(context.active()); - if (currentScopesPointer) { - currentScopesPointer.isolationScope = isolationScope; - } + // Make sure middleware spans get the right op + if (spanAttributes?.['next.span_type'] === 'Middleware.execute') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server.middleware'); + } + }); - setCapturedScopesOnSpan(span, scope, isolationScope); + client?.on('spanEnd', span => { + if (span === getRootSpan(span)) { + vercelWaitUntil(flushSafelyWithTimeout()); } }); } diff --git a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts index e5191ea27dbe..5c8ce043ecb8 100644 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts @@ -1,4 +1,18 @@ -import { withEdgeWrapping } from '../common/utils/edgeWrapperUtils'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + captureException, + getActiveSpan, + getCurrentScope, + getRootSpan, + handleCallbackErrors, + setCapturedScopesOnSpan, + startSpan, + withIsolationScope, +} from '@sentry/core'; +import { vercelWaitUntil, winterCGRequestToRequestData } from '@sentry/utils'; +import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import type { EdgeRouteHandler } from './types'; /** @@ -9,18 +23,76 @@ export function wrapApiHandlerWithSentry( parameterizedRoute: string, ): (...params: Parameters) => Promise> { return new Proxy(handler, { - apply: (wrappingTarget, thisArg, args: Parameters) => { - const req = args[0]; + apply: async (wrappingTarget, thisArg, args: Parameters) => { + // TODO: We still should add central isolation scope creation for when our build-time instrumentation does not work anymore with turbopack. - const wrappedHandler = withEdgeWrapping(wrappingTarget, { - spanDescription: !(req instanceof Request) - ? `handler (${parameterizedRoute})` - : `${req.method} ${parameterizedRoute}`, - spanOp: 'http.server', - mechanismFunctionName: 'wrapApiHandlerWithSentry', - }); + return withIsolationScope(isolationScope => { + const req: unknown = args[0]; + const currentScope = getCurrentScope(); + + if (req instanceof Request) { + isolationScope.setSDKProcessingMetadata({ + request: winterCGRequestToRequestData(req), + }); + currentScope.setTransactionName(`${req.method} ${parameterizedRoute}`); + } else { + currentScope.setTransactionName(`handler (${parameterizedRoute})`); + } + + let spanName: string; + let op: string | undefined = 'http.server'; - return wrappedHandler.apply(thisArg, args); + // If there is an active span, it likely means that the automatic Next.js OTEL instrumentation worked and we can + // rely on that for parameterization. + const activeSpan = getActiveSpan(); + if (activeSpan) { + spanName = `handler (${parameterizedRoute})`; + op = undefined; + + const rootSpan = getRootSpan(activeSpan); + if (rootSpan) { + rootSpan.updateName( + req instanceof Request ? `${req.method} ${parameterizedRoute}` : `handler ${parameterizedRoute}`, + ); + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }); + setCapturedScopesOnSpan(rootSpan, currentScope, isolationScope); + } + } else if (req instanceof Request) { + spanName = `${req.method} ${parameterizedRoute}`; + } else { + spanName = `handler ${parameterizedRoute}`; + } + + return startSpan( + { + name: spanName, + op: op, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.wrapApiHandlerWithSentry', + }, + }, + () => { + return handleCallbackErrors( + () => wrappingTarget.apply(thisArg, args), + error => { + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + }, + }); + }, + () => { + vercelWaitUntil(flushSafelyWithTimeout()); + }, + ); + }, + ); + }); }, }); } diff --git a/packages/nextjs/test/edge/edgeWrapperUtils.test.ts b/packages/nextjs/test/edge/edgeWrapperUtils.test.ts deleted file mode 100644 index 029ee9d97fce..000000000000 --- a/packages/nextjs/test/edge/edgeWrapperUtils.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import * as coreSdk from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; - -import { withEdgeWrapping } from '../../src/common/utils/edgeWrapperUtils'; - -const origRequest = global.Request; -const origResponse = global.Response; - -// @ts-expect-error Request does not exist on type Global -global.Request = class Request { - headers = { - get() { - return null; - }, - }; -}; - -// @ts-expect-error Response does not exist on type Global -global.Response = class Request {}; - -afterAll(() => { - global.Request = origRequest; - global.Response = origResponse; -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('withEdgeWrapping', () => { - it('should return a function that calls the passed function', async () => { - const origFunctionReturnValue = new Response(); - const origFunction = jest.fn(_req => origFunctionReturnValue); - - const wrappedFunction = withEdgeWrapping(origFunction, { - spanDescription: 'some label', - mechanismFunctionName: 'some name', - spanOp: 'some op', - }); - - const returnValue = await wrappedFunction(new Request('https://sentry.io/')); - - expect(returnValue).toBe(origFunctionReturnValue); - expect(origFunction).toHaveBeenCalledTimes(1); - }); - - it('should return a function that calls captureException on error', async () => { - const captureExceptionSpy = jest.spyOn(coreSdk, 'captureException'); - const error = new Error(); - const origFunction = jest.fn(_req => { - throw error; - }); - - const wrappedFunction = withEdgeWrapping(origFunction, { - spanDescription: 'some label', - mechanismFunctionName: 'some name', - spanOp: 'some op', - }); - - await expect(wrappedFunction(new Request('https://sentry.io/'))).rejects.toBe(error); - expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - }); - - it('should return a function that calls trace', async () => { - const startSpanSpy = jest.spyOn(coreSdk, 'startSpan'); - - const request = new Request('https://sentry.io/'); - const origFunction = jest.fn(_req => new Response()); - - const wrappedFunction = withEdgeWrapping(origFunction, { - spanDescription: 'some label', - mechanismFunctionName: 'some name', - spanOp: 'some op', - }); - - await wrappedFunction(request); - - expect(startSpanSpy).toHaveBeenCalledTimes(1); - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [coreSdk.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.withEdgeWrapping', - }, - name: 'some label', - op: 'some op', - }), - expect.any(Function), - ); - - expect(coreSdk.getIsolationScope().getScopeData().sdkProcessingMetadata).toEqual({ - request: { headers: {} }, - }); - }); - - it("should return a function that doesn't crash when req isn't passed", async () => { - const origFunctionReturnValue = new Response(); - const origFunction = jest.fn(() => origFunctionReturnValue); - - const wrappedFunction = withEdgeWrapping(origFunction, { - spanDescription: 'some label', - mechanismFunctionName: 'some name', - spanOp: 'some op', - }); - - await expect(wrappedFunction()).resolves.toBe(origFunctionReturnValue); - expect(origFunction).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/nextjs/test/edge/withSentryAPI.test.ts b/packages/nextjs/test/edge/withSentryAPI.test.ts index 6e24eca21bfe..11449da0e1ef 100644 --- a/packages/nextjs/test/edge/withSentryAPI.test.ts +++ b/packages/nextjs/test/edge/withSentryAPI.test.ts @@ -1,6 +1,3 @@ -import * as coreSdk from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; - import { wrapApiHandlerWithSentry } from '../../src/edge'; const origRequest = global.Request; @@ -31,53 +28,16 @@ afterAll(() => { global.Response = origResponse; }); -const startSpanSpy = jest.spyOn(coreSdk, 'startSpan'); - afterEach(() => { jest.clearAllMocks(); }); describe('wrapApiHandlerWithSentry', () => { - it('should return a function that calls trace', async () => { - const request = new Request('https://sentry.io/'); - const origFunction = jest.fn(_req => new Response()); - - const wrappedFunction = wrapApiHandlerWithSentry(origFunction, '/user/[userId]/post/[postId]'); - - await wrappedFunction(request); - - expect(startSpanSpy).toHaveBeenCalledTimes(1); - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.withEdgeWrapping', - }, - name: 'POST /user/[userId]/post/[postId]', - op: 'http.server', - }), - expect.any(Function), - ); - }); - - it('should return a function that calls trace without throwing when no request is passed', async () => { + it('should return a function that does not throw when no request is passed', async () => { const origFunction = jest.fn(() => new Response()); const wrappedFunction = wrapApiHandlerWithSentry(origFunction, '/user/[userId]/post/[postId]'); await wrappedFunction(); - - expect(startSpanSpy).toHaveBeenCalledTimes(1); - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [coreSdk.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.withEdgeWrapping', - }, - name: 'handler (/user/[userId]/post/[postId])', - op: 'http.server', - }), - expect.any(Function), - ); }); });