diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/edge/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/edge/route.ts index 8879a85c488a..7d2ea2341a99 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/edge/route.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/edge/route.ts @@ -5,6 +5,8 @@ import { NextResponse } from 'next/server'; export const runtime = 'edge'; export async function PATCH() { + // Test that actual fetch requests are captured + await fetch('https://github.com'); return NextResponse.json({ name: 'John Doe' }, { status: 401 }); } 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 deleted file mode 100644 index 88460e3ab533..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; - -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?.runtime?.name === 'vercel-edge' - ); - }); - - const response = await request.get('/api/edge-endpoint', { - headers: { - 'x-yeet': 'test-value', - }, - }); - expect(await response.json()).toStrictEqual({ name: 'Jim Halpert' }); - - const edgerouteTransaction = await edgerouteTransactionPromise; - - expect(edgerouteTransaction.contexts?.trace?.status).toBe('ok'); - expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server'); - expect(edgerouteTransaction.request?.headers?.['x-yeet']).toBe('test-value'); -}); - -test('Faulty edge routes', async ({ request }) => { - const edgerouteTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return ( - transactionEvent?.transaction === 'GET /api/error-edge-endpoint' && - transactionEvent.contexts?.runtime?.name === 'vercel-edge' - ); - }); - - const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { - return ( - errorEvent?.exception?.values?.[0]?.value === 'Edge Route Error' && - errorEvent.contexts?.runtime?.name === 'vercel-edge' - ); - }); - - request.get('/api/error-edge-endpoint').catch(() => { - // Noop - }); - - const [edgerouteTransaction, errorEvent] = await Promise.all([ - test.step('should create a transaction', () => edgerouteTransactionPromise), - test.step('should create an error event', () => errorEventPromise), - ]); - - 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'); - }); - - 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/pages-router-edge-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-router-edge-route.test.ts new file mode 100644 index 000000000000..9cccb1d9a6a6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-router-edge-route.test.ts @@ -0,0 +1,130 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +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?.runtime?.name === 'vercel-edge' + ); + }); + + const response = await request.get('/api/edge-endpoint', { + headers: { + 'x-yeet': 'test-value', + }, + }); + expect(await response.json()).toStrictEqual({ name: 'Jim Halpert' }); + + const edgerouteTransaction = await edgerouteTransactionPromise; + + expect(edgerouteTransaction.contexts?.trace?.status).toBe('ok'); + expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server'); + expect(edgerouteTransaction.request?.headers?.['x-yeet']).toBe('test-value'); +}); + +test('Faulty edge routes', async ({ request }) => { + const edgerouteTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /api/error-edge-endpoint' && + transactionEvent.contexts?.runtime?.name === 'vercel-edge' + ); + }); + + const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { + return ( + errorEvent?.exception?.values?.[0]?.value === 'Edge Route Error' && + errorEvent.contexts?.runtime?.name === 'vercel-edge' + ); + }); + + request.get('/api/error-edge-endpoint').catch(() => { + // Noop + }); + + const [edgerouteTransaction, errorEvent] = await Promise.all([ + test.step('should create a transaction', () => edgerouteTransactionPromise), + test.step('should create an error event', () => errorEventPromise), + ]); + + 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'); + }); + + 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(); + }); +}); + +test('Should not create spans for outgoing Sentry requests on edge routes', async ({ request }) => { + // Ensure no http.client transaction is created for any orphaned request + waitForTransaction('nextjs-app-dir', async transactionEvent => { + if (transactionEvent.contexts?.trace?.op === 'http.client') { + throw new Error(`Should not receive http.client transaction, but got: ${transactionEvent.transaction}`); + } + return false; + }); + + // We hit the endpoint three times and check that nowhere a http.client span for Sentry is to be found + // this way, we ensure that nothing is sent to Sentry in a follow up span + const edgerouteTransactionPromise1 = waitForTransaction('nextjs-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /api/edge-endpoint' && + transactionEvent.contexts?.runtime?.name === 'vercel-edge' + ); + }); + + await request.get('/api/edge-endpoint', { + headers: { + 'x-yeet': 'test-value', + }, + }); + + const edgerouteTransactionPromise2 = waitForTransaction('nextjs-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /api/edge-endpoint' && + transactionEvent.contexts?.runtime?.name === 'vercel-edge' + ); + }); + + await request.get('/api/edge-endpoint', { + headers: { + 'x-yeet': 'test-value-2', + }, + }); + + const edgerouteTransactionPromise3 = waitForTransaction('nextjs-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /api/edge-endpoint' && + transactionEvent.contexts?.runtime?.name === 'vercel-edge' + ); + }); + + await request.get('/api/edge-endpoint', { + headers: { + 'x-yeet': 'test-value-3', + }, + }); + + const [edgerouteTransaction1, edgerouteTransaction2, edgerouteTransaction3] = await Promise.all([ + edgerouteTransactionPromise1, + edgerouteTransactionPromise2, + edgerouteTransactionPromise3, + ]); + + expect(edgerouteTransaction1.contexts?.trace?.op).toBe('http.server'); + expect(edgerouteTransaction2.contexts?.trace?.op).toBe('http.server'); + expect(edgerouteTransaction3.contexts?.trace?.op).toBe('http.server'); + + expect(edgerouteTransaction1.spans?.length).toBe(1); + expect(edgerouteTransaction2.spans?.length).toBe(1); + expect(edgerouteTransaction3.spans?.length).toBe(1); + + expect(edgerouteTransaction1.spans?.[0].description).toBe('handler (/api/edge-endpoint)'); + expect(edgerouteTransaction2.spans?.[0].description).toBe('handler (/api/edge-endpoint)'); + expect(edgerouteTransaction3.spans?.[0].description).toBe('handler (/api/edge-endpoint)'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts index 02f2b6dc4f24..4d0de1d0d169 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts @@ -119,6 +119,87 @@ test.describe('Edge runtime', () => { expect(routehandlerError.transaction).toBe('DELETE /route-handlers/[param]/edge'); }); + + test('should not create spans for outgoing Sentry requests on edge routes', async ({ request }) => { + // Ensure no http.client transaction is created for any orphaned request + waitForTransaction('nextjs-app-dir', async transactionEvent => { + if (transactionEvent.contexts?.trace?.op === 'http.client') { + throw new Error(`Should not receive http.client transaction, but got: ${transactionEvent.transaction}`); + } + return false; + }); + + // We hit the endpoint three times and check that nowhere a http.client span for Sentry is to be found + // this way, we ensure that nothing is sent to Sentry in a follow up span + const edgerouteTransactionPromise1 = waitForTransaction('nextjs-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'PATCH /route-handlers/[param]/edge' && + transactionEvent.contexts?.runtime?.name === 'vercel-edge' + ); + }); + + await request.patch('/route-handlers/bar/edge'); + + const edgerouteTransactionPromise2 = waitForTransaction('nextjs-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'PATCH /route-handlers/[param]/edge' && + transactionEvent.contexts?.runtime?.name === 'vercel-edge' + ); + }); + + await request.patch('/route-handlers/bar/edge'); + + const edgerouteTransactionPromise3 = waitForTransaction('nextjs-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'PATCH /route-handlers/[param]/edge' && + transactionEvent.contexts?.runtime?.name === 'vercel-edge' + ); + }); + + await request.patch('/route-handlers/bar/edge'); + + const [edgerouteTransaction1, edgerouteTransaction2, edgerouteTransaction3] = await Promise.all([ + edgerouteTransactionPromise1, + edgerouteTransactionPromise2, + edgerouteTransactionPromise3, + ]); + + expect(edgerouteTransaction1.contexts?.trace?.op).toBe('http.server'); + expect(edgerouteTransaction2.contexts?.trace?.op).toBe('http.server'); + expect(edgerouteTransaction3.contexts?.trace?.op).toBe('http.server'); + + expect(edgerouteTransaction1.spans).toContainEqual( + expect.objectContaining({ + description: 'GET https://github.com/', + }), + ); + expect(edgerouteTransaction2.spans).toContainEqual( + expect.objectContaining({ + description: 'GET https://github.com/', + }), + ); + expect(edgerouteTransaction3.spans).toContainEqual( + expect.objectContaining({ + description: 'GET https://github.com/', + }), + ); + // Does not contain span that is sent to the event proxy server + expect(edgerouteTransaction1.spans).not.toContainEqual( + expect.objectContaining({ + description: expect.stringContaining('https://localhost:3031'), + }), + ); + expect(edgerouteTransaction2.spans).not.toContainEqual( + expect.objectContaining({ + description: expect.stringContaining('https://localhost:3031'), + }), + ); + expect(edgerouteTransaction3.spans).not.toContainEqual( + expect.objectContaining({ + description: expect.stringContaining('https://localhost:3031'), + }), + ); + }); }); test('should not crash route handlers that are configured with `export const dynamic = "error"`', async ({ diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index a6594e7fae1e..f871fa2ee11d 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -17,6 +17,7 @@ import { getIsolationScope, getRootSpan, GLOBAL_OBJ, + isSentryRequestUrl, logger, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -203,7 +204,7 @@ export function init(options: NodeOptions): NodeClient | undefined { } }); - getGlobalScope().addEventProcessor( + client?.addEventProcessor( Object.assign( (event => { if (event.type === 'transaction') { @@ -260,6 +261,16 @@ export function init(options: NodeOptions): NodeClient | undefined { } } + // On Edge Runtime, fetch requests can leak to the Node runtime fetch instrumentation + // and end up being sent as transactions to Sentry. + // We filter them here based on URL + if (event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'http.client') { + const url = event.contexts?.trace?.data?.['url']; + if (url && isSentryRequestUrl(url, client)) { + return null; + } + } + return event; } else { return event; @@ -269,7 +280,7 @@ export function init(options: NodeOptions): NodeClient | undefined { ), ); - getGlobalScope().addEventProcessor( + client?.addEventProcessor( Object.assign( ((event, hint) => { if (event.type !== undefined) { @@ -364,7 +375,7 @@ export function init(options: NodeOptions): NodeClient | undefined { }); if (process.env.NODE_ENV === 'development') { - getGlobalScope().addEventProcessor(devErrorSymbolicationEventProcessor); + client?.addEventProcessor(devErrorSymbolicationEventProcessor); } try { diff --git a/yarn.lock b/yarn.lock index ac3970cfb153..a44f353e348e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29253,7 +29253,7 @@ vite@^5.0.0, vite@^5.4.11, vite@^5.4.5: optionalDependencies: fsevents "~2.3.3" -vitefu@^0.2.2, vitefu@^0.2.4, vitefu@^0.2.5: +vitefu@^0.2.2, vitefu@^0.2.4: version "0.2.5" resolved "https://registry.yarnpkg.com/vitefu/-/vitefu-0.2.5.tgz#c1b93c377fbdd3e5ddd69840ea3aa70b40d90969" integrity sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==