diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 73e5a1c6ee3c..511299fd3e3e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1037,6 +1037,7 @@ jobs: retention-days: 7 - name: Pre-process E2E Test Dumps + if: always() run: | node ./scripts/normalize-e2e-test-dump-transaction-events.js @@ -1193,6 +1194,7 @@ jobs: run: pnpm ${{ matrix.assert-command || 'test:assert' }} - name: Pre-process E2E Test Dumps + if: always() run: | node ./scripts/normalize-e2e-test-dump-transaction-events.js diff --git a/.size-limit.js b/.size-limit.js index bdfe8a4397e2..75545fd89194 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -224,7 +224,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '39 KB', + limit: '39.1 KB', }, // SvelteKit SDK (ESM) { diff --git a/dev-packages/e2e-tests/publish-packages.ts b/dev-packages/e2e-tests/publish-packages.ts index 408d046977a2..4f2cc4056826 100644 --- a/dev-packages/e2e-tests/publish-packages.ts +++ b/dev-packages/e2e-tests/publish-packages.ts @@ -12,6 +12,8 @@ const packageTarballPaths = glob.sync('packages/*/sentry-*.tgz', { // Publish built packages to the fake registry packageTarballPaths.forEach(tarballPath => { + // eslint-disable-next-line no-console + console.log(`Publishing tarball ${tarballPath} ...`); // `--userconfig` flag needs to be before `publish` childProcess.exec( `npm --userconfig ${__dirname}/test-registry.npmrc publish ${tarballPath}`, @@ -19,14 +21,10 @@ packageTarballPaths.forEach(tarballPath => { cwd: repositoryRoot, // Can't use __dirname here because npm would try to publish `@sentry-internal/e2e-tests` encoding: 'utf8', }, - (err, stdout, stderr) => { - // eslint-disable-next-line no-console - console.log(stdout); - // eslint-disable-next-line no-console - console.log(stderr); + err => { if (err) { // eslint-disable-next-line no-console - console.error(err); + console.error(`Error publishing tarball ${tarballPath}`, err); process.exit(1); } }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/package.json b/dev-packages/e2e-tests/test-applications/nextjs-13/package.json index 5be9ecbfc32c..3e7a0ac88266 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/package.json @@ -17,7 +17,7 @@ "@types/node": "18.11.17", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "13.2.0", + "next": "13.5.7", "react": "18.2.0", "react-dom": "18.2.0", "typescript": "4.9.5" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/customPageExtension.page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/customPageExtension.page.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/nextjs-13/pages/customPageExtension.page.tsx rename to dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/customPageExtension.page.tsx diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/error-getServerSideProps.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/error-getServerSideProps.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/nextjs-13/pages/error-getServerSideProps.tsx rename to dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/error-getServerSideProps.tsx diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/pages-dir-pageload.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/pages-dir-pageload.test.ts index 8c74b2c99427..af59b41c2908 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/pages-dir-pageload.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/pages-dir-pageload.test.ts @@ -46,3 +46,38 @@ test('should create a pageload transaction when the `pages` directory is used', type: 'transaction', }); }); + +test('should create a pageload transaction with correct name when an error occurs in getServerSideProps', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/[param]/error-getServerSideProps' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/something/error-getServerSideProps`, { waitUntil: 'networkidle' }); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.pages_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.pages_router_instrumentation', + }, + }, + transaction: '/[param]/error-getServerSideProps', + transaction_info: { source: 'route' }, + type: 'transaction', + }); + + // Ensure the transaction name is not '/_error' + expect(transaction.transaction).not.toBe('/_error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getInitialProps.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getInitialProps.test.ts index 22da2071d533..570b19b3271d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getInitialProps.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getInitialProps.test.ts @@ -11,7 +11,7 @@ test('should propagate serverside `getInitialProps` trace to client', async ({ p const serverTransactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { return ( - transactionEvent.transaction === '/[param]/withInitialProps' && + transactionEvent.transaction === 'GET /[param]/withInitialProps' && transactionEvent.contexts?.trace?.op === 'http.server' ); }); @@ -47,7 +47,7 @@ test('should propagate serverside `getInitialProps` trace to client', async ({ p status: 'ok', }, }, - transaction: '/[param]/withInitialProps', + transaction: 'GET /[param]/withInitialProps', transaction_info: { source: 'route', }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getServerSideProps.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getServerSideProps.test.ts index 20bbbc9437f6..765864dbf4a1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getServerSideProps.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getServerSideProps.test.ts @@ -11,7 +11,7 @@ test('Should record performance for getServerSideProps', async ({ page }) => { const serverTransactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { return ( - transactionEvent.transaction === '/[param]/withServerSideProps' && + transactionEvent.transaction === 'GET /[param]/withServerSideProps' && transactionEvent.contexts?.trace?.op === 'http.server' ); }); @@ -47,7 +47,7 @@ test('Should record performance for getServerSideProps', async ({ page }) => { status: 'ok', }, }, - transaction: '/[param]/withServerSideProps', + transaction: 'GET /[param]/withServerSideProps', transaction_info: { source: 'route', }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/excluded-api-endpoints.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/excluded-api-endpoints.test.ts index 63082fee6e07..2d3854e2a2a4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/excluded-api-endpoints.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/excluded-api-endpoints.test.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -test('should not automatically create transactions for routes that were excluded from auto wrapping (string)', async ({ +test('should not apply build-time instrumentation for routes that were excluded from auto wrapping (string)', async ({ request, }) => { const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { @@ -13,17 +13,13 @@ test('should not automatically create transactions for routes that were excluded expect(await (await request.get(`/api/endpoint-excluded-with-string`)).text()).toBe('{"success":true}'); - let transactionPromiseReceived = false; - transactionPromise.then(() => { - transactionPromiseReceived = true; - }); - - await new Promise(resolve => setTimeout(resolve, 5_000)); + const transaction = await transactionPromise; - expect(transactionPromiseReceived).toBe(false); + expect(transaction.contexts?.trace?.data?.['sentry.origin']).toBeDefined(); + expect(transaction.contexts?.trace?.data?.['sentry.origin']).not.toBe('auto.http.nextjs'); // This is the origin set by the build time instrumentation }); -test('should not automatically create transactions for routes that were excluded from auto wrapping (regex)', async ({ +test('should not apply build-time instrumentation for routes that were excluded from auto wrapping (regex)', async ({ request, }) => { const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { @@ -35,12 +31,8 @@ test('should not automatically create transactions for routes that were excluded expect(await (await request.get(`/api/endpoint-excluded-with-regex`)).text()).toBe('{"success":true}'); - let transactionPromiseReceived = false; - transactionPromise.then(() => { - transactionPromiseReceived = true; - }); - - await new Promise(resolve => setTimeout(resolve, 5_000)); + const transaction = await transactionPromise; - expect(transactionPromiseReceived).toBe(false); + expect(transaction.contexts?.trace?.data?.['sentry.origin']).toBeDefined(); + expect(transaction.contexts?.trace?.data?.['sentry.origin']).not.toBe('auto.http.nextjs'); // This is the origin set by the build time instrumentation }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/getServerSideProps.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/getServerSideProps.test.ts index 0c99ba302dfa..9ae79d7bd4b0 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/getServerSideProps.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/getServerSideProps.test.ts @@ -8,12 +8,12 @@ test('Should report an error event for errors thrown in getServerSideProps', asy const transactionEventPromise = waitForTransaction('nextjs-13', transactionEvent => { return ( - transactionEvent.transaction === '/error-getServerSideProps' && + transactionEvent.transaction === 'GET /[param]/error-getServerSideProps' && transactionEvent.contexts?.trace?.op === 'http.server' ); }); - await page.goto('/error-getServerSideProps'); + await page.goto('/dogsaregreat/error-getServerSideProps'); expect(await errorEventPromise).toMatchObject({ contexts: { @@ -40,7 +40,7 @@ test('Should report an error event for errors thrown in getServerSideProps', asy url: expect.stringMatching(/^http.*\/error-getServerSideProps/), }, timestamp: expect.any(Number), - transaction: 'getServerSideProps (/error-getServerSideProps)', + transaction: 'getServerSideProps (/[param]/error-getServerSideProps)', }); expect(await transactionEventPromise).toMatchObject({ @@ -60,11 +60,11 @@ test('Should report an error event for errors thrown in getServerSideProps', asy data: { 'http.response.status_code': 500, 'sentry.op': 'http.server', - 'sentry.origin': 'auto.function.nextjs', + 'sentry.origin': 'auto', 'sentry.source': 'route', }, op: 'http.server', - origin: 'auto.function.nextjs', + origin: 'auto', span_id: expect.any(String), status: 'internal_error', trace_id: expect.any(String), @@ -80,7 +80,7 @@ test('Should report an error event for errors thrown in getServerSideProps', asy }, start_timestamp: expect.any(Number), timestamp: expect.any(Number), - transaction: '/error-getServerSideProps', + transaction: 'GET /[param]/error-getServerSideProps', transaction_info: { source: 'route' }, type: 'transaction', }); @@ -95,11 +95,12 @@ test('Should report an error event for errors thrown in getServerSideProps in pa const transactionEventPromise = waitForTransaction('nextjs-13', transactionEvent => { return ( - transactionEvent.transaction === '/customPageExtension' && transactionEvent.contexts?.trace?.op === 'http.server' + transactionEvent.transaction === 'GET /[param]/customPageExtension' && + transactionEvent.contexts?.trace?.op === 'http.server' ); }); - await page.goto('/customPageExtension'); + await page.goto('/123/customPageExtension'); expect(await errorEventPromise).toMatchObject({ contexts: { @@ -126,7 +127,7 @@ test('Should report an error event for errors thrown in getServerSideProps in pa url: expect.stringMatching(/^http.*\/customPageExtension/), }, timestamp: expect.any(Number), - transaction: 'getServerSideProps (/customPageExtension)', + transaction: 'getServerSideProps (/[param]/customPageExtension)', }); expect(await transactionEventPromise).toMatchObject({ @@ -146,11 +147,11 @@ test('Should report an error event for errors thrown in getServerSideProps in pa data: { 'http.response.status_code': 500, 'sentry.op': 'http.server', - 'sentry.origin': 'auto.function.nextjs', + 'sentry.origin': 'auto', 'sentry.source': 'route', }, op: 'http.server', - origin: 'auto.function.nextjs', + origin: 'auto', span_id: expect.any(String), status: 'internal_error', trace_id: expect.any(String), @@ -166,7 +167,7 @@ test('Should report an error event for errors thrown in getServerSideProps in pa }, start_timestamp: expect.any(Number), timestamp: expect.any(Number), - transaction: '/customPageExtension', + transaction: 'GET /[param]/customPageExtension', transaction_info: { source: 'route' }, type: 'transaction', }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/layout.tsx index d2aae8c9cd8d..006a01fcfa76 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/layout.tsx @@ -9,31 +9,49 @@ export default function Layout({ children }: { children: React.ReactNode }) {

Layout (/)

{children} 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/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/client-app-routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts index 35984640bcf6..abfe9b323d0f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts @@ -42,9 +42,7 @@ test('Creates a navigation transaction for app router routes', async ({ page }) // It seems to differ between Next.js versions whether the route is parameterized or not (transactionEvent?.transaction === 'GET /server-component/parameter/foo/bar/baz' || transactionEvent?.transaction === 'GET /server-component/parameter/[...parameters]') && - transactionEvent.contexts?.trace?.data?.['http.target'].startsWith('/server-component/parameter/foo/bar/baz') && - (await clientNavigationTransactionPromise).contexts?.trace?.trace_id === - transactionEvent.contexts?.trace?.trace_id + transactionEvent.contexts?.trace?.data?.['http.target'].startsWith('/server-component/parameter/foo/bar/baz') ); }); @@ -52,6 +50,10 @@ test('Creates a navigation transaction for app router routes', async ({ page }) expect(await clientNavigationTransactionPromise).toBeDefined(); expect(await serverComponentTransactionPromise).toBeDefined(); + + expect((await serverComponentTransactionPromise).contexts?.trace?.trace_id).toBe( + (await clientNavigationTransactionPromise).contexts?.trace?.trace_id, + ); }); test('Creates a navigation transaction for `router.push()`', async ({ page }) => { 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 df7ce7afd19a..88460e3ab533 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 @@ -4,7 +4,8 @@ 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?.trace?.status === 'ok' + transactionEvent?.transaction === 'GET /api/edge-endpoint' && + transactionEvent.contexts?.runtime?.name === 'vercel-edge' ); }); @@ -19,47 +20,42 @@ test('Should create a transaction for edge routes', async ({ request }) => { expect(edgerouteTransaction.contexts?.trace?.status).toBe('ok'); expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server'); - expect(edgerouteTransaction.contexts?.runtime?.name).toBe('vercel-edge'); 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 === 'internal_error' + transactionEvent.contexts?.runtime?.name === 'vercel-edge' ); }); - request.get('/api/error-edge-endpoint').catch(() => { - // Noop - }); - - const edgerouteTransaction = await edgerouteTransactionPromise; - - expect(edgerouteTransaction.contexts?.trace?.status).toBe('internal_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(); -}); - -test('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'; + return ( + errorEvent?.exception?.values?.[0]?.value === 'Edge Route Error' && + errorEvent.contexts?.runtime?.name === 'vercel-edge' + ); }); request.get('/api/error-edge-endpoint').catch(() => { // 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(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 f5277dee6f66..934cfa2e472d 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 @@ -1,6 +1,8 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +const packageJson = require('../package.json'); + test('Should record exceptions for faulty edge server components', async ({ page }) => { const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Edge Server Component Error'; @@ -20,8 +22,14 @@ test('Should record exceptions for faulty edge server components', async ({ page }); test('Should record transaction for edge server components', async ({ page }) => { + const nextjsVersion = packageJson.dependencies.next; + const nextjsMajor = Number(nextjsVersion.split('.')[0]); + const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return transactionEvent?.transaction === 'Page Server Component (/edge-server-components)'; + return ( + transactionEvent?.transaction === 'GET /edge-server-components' && + transactionEvent.contexts?.runtime?.name === 'vercel-edge' + ); }); await page.goto('/edge-server-components'); @@ -29,9 +37,14 @@ test('Should record transaction for edge server components', async ({ page }) => const serverComponentTransaction = await serverComponentTransactionPromise; expect(serverComponentTransaction).toBeDefined(); - expect(serverComponentTransaction.request?.headers).toBeDefined(); + expect(serverComponentTransaction.contexts?.trace?.op).toBe('http.server'); - // Assert that isolation scope works properly - expect(serverComponentTransaction.tags?.['my-isolated-tag']).toBe(true); - expect(serverComponentTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + // For some reason headers aren't picked up on Next.js 13 - also causing scope isolation to be broken + if (nextjsMajor >= 14) { + expect(serverComponentTransaction.request?.headers).toBeDefined(); + + // Assert that isolation scope works properly + expect(serverComponentTransaction.tags?.['my-isolated-tag']).toBe(true); + expect(serverComponentTransaction.tags?.['my-global-scope-isolated-tag']).not.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 11a5f48799bd..a00a29672ed6 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,53 +12,50 @@ 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'); + 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('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 === 'internal_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('internal_error'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware.nextjs'); - expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); -}); - -test('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'); + expect(middlewareTransaction.transaction_info?.source).toBe('url'); + }); - // 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/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts index a67e4328ba1c..10a4cd77f111 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts @@ -8,7 +8,7 @@ test('Will capture error for SSR rendering error with a connected trace (Class C const serverComponentTransaction = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( - transactionEvent?.transaction === '/pages-router/ssr-error-class' && + transactionEvent?.transaction === 'GET /pages-router/ssr-error-class' && (await errorEventPromise).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id ); }); @@ -26,7 +26,7 @@ test('Will capture error for SSR rendering error with a connected trace (Functio const ssrTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( - transactionEvent?.transaction === '/pages-router/ssr-error-fc' && + transactionEvent?.transaction === 'GET /pages-router/ssr-error-fc' && (await errorEventPromise).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id ); }); 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 afa02e60884a..7e6dc5fbe300 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 @@ -54,9 +54,9 @@ test('Should record exceptions and transactions for faulty route handlers', asyn expect(routehandlerError.tags?.['my-isolated-tag']).toBe(true); expect(routehandlerError.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - expect(routehandlerTransaction.contexts?.trace?.status).toBe('unknown_error'); + expect(routehandlerTransaction.contexts?.trace?.status).toBe('internal_error'); expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); - expect(routehandlerTransaction.contexts?.trace?.origin).toBe('auto.function.nextjs'); + expect(routehandlerTransaction.contexts?.trace?.origin).toContain('auto'); expect(routehandlerError.exception?.values?.[0].value).toBe('route-handler-error'); @@ -66,7 +66,10 @@ test('Should record exceptions and transactions for faulty route handlers', asyn test.describe('Edge runtime', () => { test('should create a transaction for route handlers', async ({ request }) => { const routehandlerTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return transactionEvent?.transaction === 'PATCH /route-handlers/[param]/edge'; + return ( + transactionEvent?.transaction === 'PATCH /route-handlers/[param]/edge' && + transactionEvent.contexts?.runtime?.name === 'vercel-edge' + ); }); const response = await request.patch('/route-handlers/bar/edge'); @@ -80,11 +83,17 @@ test.describe('Edge runtime', () => { test('should record exceptions and transactions for faulty route handlers', async ({ request }) => { const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { - return errorEvent?.exception?.values?.[0]?.value === 'route-handler-edge-error'; + return ( + errorEvent?.exception?.values?.[0]?.value === 'route-handler-edge-error' && + errorEvent.contexts?.runtime?.name === 'vercel-edge' + ); }); const routehandlerTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return transactionEvent?.transaction === 'DELETE /route-handlers/[param]/edge'; + return ( + transactionEvent?.transaction === 'DELETE /route-handlers/[param]/edge' && + transactionEvent.contexts?.runtime?.name === 'vercel-edge' + ); }); await request.delete('/route-handlers/baz/edge').catch(() => { @@ -100,12 +109,10 @@ test.describe('Edge runtime', () => { expect(routehandlerError.tags?.['my-isolated-tag']).toBe(true); expect(routehandlerError.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - expect(routehandlerTransaction.contexts?.trace?.status).toBe('internal_error'); + expect(routehandlerTransaction.contexts?.trace?.status).toBe('unknown_error'); expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); - expect(routehandlerTransaction.contexts?.runtime?.name).toBe('vercel-edge'); expect(routehandlerError.exception?.values?.[0].value).toBe('route-handler-edge-error'); - expect(routehandlerError.contexts?.runtime?.name).toBe('vercel-edge'); expect(routehandlerError.transaction).toBe('DELETE /route-handlers/[param]/edge'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts index 49afe791328f..75f30075a47f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts @@ -16,7 +16,7 @@ test('Sends a transaction for a request to app router', async ({ page }) => { expect(transactionEvent.contexts?.trace).toEqual({ data: expect.objectContaining({ 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto', 'sentry.sample_rate': 1, 'sentry.source': 'route', 'http.method': 'GET', @@ -27,7 +27,7 @@ test('Sends a transaction for a request to app router', async ({ page }) => { 'otel.kind': 'SERVER', }), op: 'http.server', - origin: 'auto.http.otel.http', + origin: 'auto', span_id: expect.any(String), status: 'ok', trace_id: expect.any(String), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts index 8d2489bab34d..278b6b1074eb 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts @@ -76,6 +76,34 @@ test('Should send a transaction for instrumented server actions', async ({ page expect(Object.keys(transactionEvent.request?.headers || {}).length).toBeGreaterThan(0); }); +test('Should send a wrapped server action as a child of a nextjs transaction', async ({ page }) => { + const nextjsVersion = packageJson.dependencies.next; + const nextjsMajor = Number(nextjsVersion.split('.')[0]); + test.skip(!isNaN(nextjsMajor) && nextjsMajor < 14, 'only applies to nextjs apps >= version 14'); + test.skip(process.env.TEST_ENV === 'development', 'this magically only works in production'); + + const nextjsPostTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'POST /server-action' && transactionEvent.contexts?.trace?.origin === 'auto' + ); + }); + + const serverActionTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { + return transactionEvent?.transaction === 'serverAction/myServerAction'; + }); + + await page.goto('/server-action'); + await page.getByText('Run Action').click(); + + const nextjsTransaction = await nextjsPostTransactionPromise; + const serverActionTransaction = await serverActionTransactionPromise; + + expect(nextjsTransaction).toBeDefined(); + expect(serverActionTransaction).toBeDefined(); + + expect(nextjsTransaction.contexts?.trace?.span_id).toBe(serverActionTransaction.contexts?.trace?.parent_span_id); +}); + test('Should set not_found status for server actions calling notFound()', async ({ page }) => { const nextjsVersion = packageJson.dependencies.next; const nextjsMajor = Number(nextjsVersion.split('.')[0]); diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index 1a855e5674b7..4e6483364ee4 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -36,6 +36,7 @@ export function makeBaseNPMConfig(options = {}) { packageSpecificConfig = {}, addPolyfills = true, sucrase = {}, + bundledBuiltins = [], } = options; const nodeResolvePlugin = makeNodeResolvePlugin(); @@ -113,7 +114,7 @@ export function makeBaseNPMConfig(options = {}) { // don't include imported modules from outside the package in the final output external: [ - ...builtinModules, + ...builtinModules.filter(m => !bundledBuiltins.includes(m)), ...Object.keys(packageDotJSON.dependencies || {}), ...Object.keys(packageDotJSON.peerDependencies || {}), ...Object.keys(packageDotJSON.optionalDependencies || {}), diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 8dd13919442a..23047338e280 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -76,6 +76,7 @@ "access": "public" }, "dependencies": { + "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation-http": "0.53.0", "@opentelemetry/semantic-conventions": "^1.27.0", "@rollup/plugin-commonjs": "26.0.1", diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index c66f50a293f2..c50bbce37305 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -13,7 +13,7 @@ import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; -export { captureUnderscoreErrorException } from '../common/_error'; +export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error'; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { __rewriteFramesAssetPrefixPath__: string; diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts index f6906a566050..2380b743cced 100644 --- a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts @@ -6,7 +6,7 @@ import { } from '@sentry/core'; import { WINDOW, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan } from '@sentry/react'; import type { Client, TransactionSource } from '@sentry/types'; -import { browserPerformanceTimeOrigin, logger, stripUrlQueryAndFragment } from '@sentry/utils'; +import { browserPerformanceTimeOrigin, logger, parseBaggageHeader, stripUrlQueryAndFragment } from '@sentry/utils'; import type { NEXT_DATA } from 'next/dist/shared/lib/utils'; import RouterImport from 'next/router'; @@ -106,7 +106,15 @@ function extractNextDataTagInformation(): NextDataTagInfo { */ export function pagesRouterInstrumentPageLoad(client: Client): void { const { route, params, sentryTrace, baggage } = extractNextDataTagInformation(); - const name = route || globalObject.location.pathname; + const parsedBaggage = parseBaggageHeader(baggage); + let name = route || globalObject.location.pathname; + + // /_error is the fallback page for all errors. If there is a transaction name for /_error, use that instead + if (parsedBaggage && parsedBaggage['sentry-transaction'] && name === '/_error') { + name = parsedBaggage['sentry-transaction']; + // Strip any HTTP method from the span name + name = name.replace(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|TRACE|CONNECT)\s+/i, ''); + } startBrowserTracingPageLoadSpan( client, diff --git a/packages/nextjs/src/common/index.ts b/packages/nextjs/src/common/index.ts index 354113637a30..7740c35c016c 100644 --- a/packages/nextjs/src/common/index.ts +++ b/packages/nextjs/src/common/index.ts @@ -1,14 +1,14 @@ -export { wrapGetStaticPropsWithSentry } from './wrapGetStaticPropsWithSentry'; -export { wrapGetInitialPropsWithSentry } from './wrapGetInitialPropsWithSentry'; -export { wrapAppGetInitialPropsWithSentry } from './wrapAppGetInitialPropsWithSentry'; -export { wrapDocumentGetInitialPropsWithSentry } from './wrapDocumentGetInitialPropsWithSentry'; -export { wrapErrorGetInitialPropsWithSentry } from './wrapErrorGetInitialPropsWithSentry'; -export { wrapGetServerSidePropsWithSentry } from './wrapGetServerSidePropsWithSentry'; +export { wrapGetStaticPropsWithSentry } from './pages-router-instrumentation/wrapGetStaticPropsWithSentry'; +export { wrapGetInitialPropsWithSentry } from './pages-router-instrumentation/wrapGetInitialPropsWithSentry'; +export { wrapAppGetInitialPropsWithSentry } from './pages-router-instrumentation/wrapAppGetInitialPropsWithSentry'; +export { wrapDocumentGetInitialPropsWithSentry } from './pages-router-instrumentation/wrapDocumentGetInitialPropsWithSentry'; +export { wrapErrorGetInitialPropsWithSentry } from './pages-router-instrumentation/wrapErrorGetInitialPropsWithSentry'; +export { wrapGetServerSidePropsWithSentry } from './pages-router-instrumentation/wrapGetServerSidePropsWithSentry'; export { wrapServerComponentWithSentry } from './wrapServerComponentWithSentry'; export { wrapRouteHandlerWithSentry } from './wrapRouteHandlerWithSentry'; -export { wrapApiHandlerWithSentryVercelCrons } from './wrapApiHandlerWithSentryVercelCrons'; +export { wrapApiHandlerWithSentryVercelCrons } from './pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons'; export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry'; -export { wrapPageComponentWithSentry } from './wrapPageComponentWithSentry'; +export { wrapPageComponentWithSentry } from './pages-router-instrumentation/wrapPageComponentWithSentry'; export { wrapGenerationFunctionWithSentry } from './wrapGenerationFunctionWithSentry'; export { withServerActionInstrumentation } from './withServerActionInstrumentation'; // eslint-disable-next-line deprecation/deprecation diff --git a/packages/nextjs/src/common/_error.ts b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts similarity index 97% rename from packages/nextjs/src/common/_error.ts rename to packages/nextjs/src/common/pages-router-instrumentation/_error.ts index 385df8244a17..3450aad8ef5e 100644 --- a/packages/nextjs/src/common/_error.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts @@ -1,7 +1,7 @@ import { captureException, withScope } from '@sentry/core'; import { vercelWaitUntil } from '@sentry/utils'; import type { NextPageContext } from 'next'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; +import { flushSafelyWithTimeout } from '../utils/responseEnd'; type ContextOrProps = { req?: NextPageContext['req']; diff --git a/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts similarity index 93% rename from packages/nextjs/src/common/wrapApiHandlerWithSentry.ts rename to packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts index a6463b0a7791..30fce67e482e 100644 --- a/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts @@ -1,4 +1,5 @@ import { + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, continueTrace, @@ -6,12 +7,13 @@ import { startSpanManual, withIsolationScope, } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; -import { isString, logger, objectify, vercelWaitUntil } from '@sentry/utils'; +import { isString, logger, objectify } from '@sentry/utils'; + +import { vercelWaitUntil } from '@sentry/utils'; import type { NextApiRequest } from 'next'; -import type { AugmentedNextApiResponse, NextApiHandler } from './types'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; -import { escapeNextjsTracing } from './utils/tracingUtils'; +import type { AugmentedNextApiResponse, NextApiHandler } from '../types'; +import { flushSafelyWithTimeout } from '../utils/responseEnd'; +import { dropNextjsRootContext, escapeNextjsTracing } from '../utils/tracingUtils'; export type AugmentedNextApiRequest = NextApiRequest & { __withSentry_applied__?: boolean; @@ -32,6 +34,7 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz thisArg, args: [AugmentedNextApiRequest | undefined, AugmentedNextApiResponse | undefined], ) => { + dropNextjsRootContext(); return escapeNextjsTracing(() => { const [req, res] = args; diff --git a/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts similarity index 96% rename from packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts rename to packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts index 4974cd827e9a..5ca29b338cda 100644 --- a/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts @@ -1,7 +1,7 @@ import { captureCheckIn } from '@sentry/core'; import type { NextApiRequest } from 'next'; -import type { VercelCronsConfig } from './types'; +import type { VercelCronsConfig } from '../types'; type EdgeRequest = { nextUrl: URL; @@ -9,7 +9,7 @@ type EdgeRequest = { }; /** - * Wraps a function with Sentry crons instrumentation by automaticaly sending check-ins for the given Vercel crons config. + * Wraps a function with Sentry crons instrumentation by automatically sending check-ins for the given Vercel crons config. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function wrapApiHandlerWithSentryVercelCrons any>( diff --git a/packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapAppGetInitialPropsWithSentry.ts similarity index 97% rename from packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts rename to packages/nextjs/src/common/pages-router-instrumentation/wrapAppGetInitialPropsWithSentry.ts index 2c7b0adc7d7b..10f783b9e9e6 100644 --- a/packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapAppGetInitialPropsWithSentry.ts @@ -1,7 +1,7 @@ import type App from 'next/app'; -import { isBuild } from './utils/isBuild'; -import { withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; +import { isBuild } from '../utils/isBuild'; +import { withErrorInstrumentation, withTracedServerSideDataFetcher } from '../utils/wrapperUtils'; type AppGetInitialProps = (typeof App)['getInitialProps']; diff --git a/packages/nextjs/src/common/wrapDocumentGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapDocumentGetInitialPropsWithSentry.ts similarity index 96% rename from packages/nextjs/src/common/wrapDocumentGetInitialPropsWithSentry.ts rename to packages/nextjs/src/common/pages-router-instrumentation/wrapDocumentGetInitialPropsWithSentry.ts index 192e70f093b1..d7f69c621132 100644 --- a/packages/nextjs/src/common/wrapDocumentGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapDocumentGetInitialPropsWithSentry.ts @@ -1,7 +1,7 @@ import type Document from 'next/document'; -import { isBuild } from './utils/isBuild'; -import { withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; +import { isBuild } from '../utils/isBuild'; +import { withErrorInstrumentation, withTracedServerSideDataFetcher } from '../utils/wrapperUtils'; type DocumentGetInitialProps = typeof Document.getInitialProps; diff --git a/packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapErrorGetInitialPropsWithSentry.ts similarity index 97% rename from packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts rename to packages/nextjs/src/common/pages-router-instrumentation/wrapErrorGetInitialPropsWithSentry.ts index a2bd559342a4..731d3fe1e24a 100644 --- a/packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapErrorGetInitialPropsWithSentry.ts @@ -1,8 +1,8 @@ import type { NextPageContext } from 'next'; import type { ErrorProps } from 'next/error'; -import { isBuild } from './utils/isBuild'; -import { withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; +import { isBuild } from '../utils/isBuild'; +import { withErrorInstrumentation, withTracedServerSideDataFetcher } from '../utils/wrapperUtils'; type ErrorGetInitialProps = (context: NextPageContext) => Promise; diff --git a/packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapGetInitialPropsWithSentry.ts similarity index 96% rename from packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts rename to packages/nextjs/src/common/pages-router-instrumentation/wrapGetInitialPropsWithSentry.ts index 2624aefb4d24..97246ec9d122 100644 --- a/packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapGetInitialPropsWithSentry.ts @@ -1,7 +1,7 @@ import type { NextPage } from 'next'; -import { isBuild } from './utils/isBuild'; -import { withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; +import { isBuild } from '../utils/isBuild'; +import { withErrorInstrumentation, withTracedServerSideDataFetcher } from '../utils/wrapperUtils'; type GetInitialProps = Required['getInitialProps']; diff --git a/packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapGetServerSidePropsWithSentry.ts similarity index 96% rename from packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts rename to packages/nextjs/src/common/pages-router-instrumentation/wrapGetServerSidePropsWithSentry.ts index 0037bad36300..7c4b4101d80e 100644 --- a/packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapGetServerSidePropsWithSentry.ts @@ -1,7 +1,7 @@ import type { GetServerSideProps } from 'next'; -import { isBuild } from './utils/isBuild'; -import { withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; +import { isBuild } from '../utils/isBuild'; +import { withErrorInstrumentation, withTracedServerSideDataFetcher } from '../utils/wrapperUtils'; /** * Create a wrapped version of the user's exported `getServerSideProps` function diff --git a/packages/nextjs/src/common/wrapGetStaticPropsWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapGetStaticPropsWithSentry.ts similarity index 82% rename from packages/nextjs/src/common/wrapGetStaticPropsWithSentry.ts rename to packages/nextjs/src/common/pages-router-instrumentation/wrapGetStaticPropsWithSentry.ts index aebbf42ac684..5d083eb97ca8 100644 --- a/packages/nextjs/src/common/wrapGetStaticPropsWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapGetStaticPropsWithSentry.ts @@ -1,7 +1,7 @@ import type { GetStaticProps } from 'next'; -import { isBuild } from './utils/isBuild'; -import { callDataFetcherTraced, withErrorInstrumentation } from './utils/wrapperUtils'; +import { isBuild } from '../utils/isBuild'; +import { callDataFetcherTraced, withErrorInstrumentation } from '../utils/wrapperUtils'; type Props = { [key: string]: unknown }; @@ -14,7 +14,7 @@ type Props = { [key: string]: unknown }; */ export function wrapGetStaticPropsWithSentry( origGetStaticPropsa: GetStaticProps, - parameterizedRoute: string, + _parameterizedRoute: string, ): GetStaticProps { return new Proxy(origGetStaticPropsa, { apply: async (wrappingTarget, thisArg, args: Parameters>) => { @@ -23,10 +23,7 @@ export function wrapGetStaticPropsWithSentry( } const errorWrappedGetStaticProps = withErrorInstrumentation(wrappingTarget); - return callDataFetcherTraced(errorWrappedGetStaticProps, args, { - parameterizedRoute, - dataFetchingMethodName: 'getStaticProps', - }); + return callDataFetcherTraced(errorWrappedGetStaticProps, args); }, }); } diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapPageComponentWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapPageComponentWithSentry.ts new file mode 100644 index 000000000000..8b6a45faa63b --- /dev/null +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapPageComponentWithSentry.ts @@ -0,0 +1,91 @@ +import { captureException, getCurrentScope, withIsolationScope } from '@sentry/core'; +import { extractTraceparentData } from '@sentry/utils'; + +interface FunctionComponent { + (...args: unknown[]): unknown; +} + +interface ClassComponent { + new (...args: unknown[]): { + props?: unknown; + render(...args: unknown[]): unknown; + }; +} + +function isReactClassComponent(target: unknown): target is ClassComponent { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return typeof target === 'function' && target?.prototype?.isReactComponent; +} + +/** + * Wraps a page component with Sentry error instrumentation. + */ +export function wrapPageComponentWithSentry(pageComponent: FunctionComponent | ClassComponent): unknown { + if (isReactClassComponent(pageComponent)) { + return class SentryWrappedPageComponent extends pageComponent { + public render(...args: unknown[]): unknown { + return withIsolationScope(() => { + const scope = getCurrentScope(); + // We extract the sentry trace data that is put in the component props by datafetcher wrappers + const sentryTraceData = + typeof this.props === 'object' && + this.props !== null && + '_sentryTraceData' in this.props && + typeof this.props._sentryTraceData === 'string' + ? this.props._sentryTraceData + : undefined; + + if (sentryTraceData) { + const traceparentData = extractTraceparentData(sentryTraceData); + scope.setContext('trace', { + span_id: traceparentData?.parentSpanId, + trace_id: traceparentData?.traceId, + }); + } + + try { + return super.render(...args); + } catch (e) { + captureException(e, { + mechanism: { + handled: false, + }, + }); + throw e; + } + }); + } + }; + } else if (typeof pageComponent === 'function') { + return new Proxy(pageComponent, { + apply(target, thisArg, argArray: [{ _sentryTraceData?: string } | undefined]) { + return withIsolationScope(() => { + const scope = getCurrentScope(); + // We extract the sentry trace data that is put in the component props by datafetcher wrappers + const sentryTraceData = argArray?.[0]?._sentryTraceData; + + if (sentryTraceData) { + const traceparentData = extractTraceparentData(sentryTraceData); + scope.setContext('trace', { + span_id: traceparentData?.parentSpanId, + trace_id: traceparentData?.traceId, + }); + } + + try { + return target.apply(thisArg, argArray); + } catch (e) { + captureException(e, { + mechanism: { + handled: false, + }, + }); + throw e; + } + }); + }, + }); + } else { + return pageComponent; + } +} diff --git a/packages/nextjs/src/common/span-attributes-with-logic-attached.ts b/packages/nextjs/src/common/span-attributes-with-logic-attached.ts new file mode 100644 index 000000000000..a272ef525dff --- /dev/null +++ b/packages/nextjs/src/common/span-attributes-with-logic-attached.ts @@ -0,0 +1,8 @@ +/** + * If this attribute is attached to a transaction, the Next.js SDK will drop that transaction. + */ +export const TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION = 'sentry.drop_transaction'; + +export const TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL = 'sentry.sentry_trace_backfill'; + +export const TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL = 'sentry.route_backfill'; 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/utils/tracingUtils.ts b/packages/nextjs/src/common/utils/tracingUtils.ts index b996b6af1877..ff57fcae3acc 100644 --- a/packages/nextjs/src/common/utils/tracingUtils.ts +++ b/packages/nextjs/src/common/utils/tracingUtils.ts @@ -1,7 +1,8 @@ -import { Scope, startNewTrace } from '@sentry/core'; +import { Scope, getActiveSpan, getRootSpan, spanToJSON, startNewTrace } from '@sentry/core'; import type { PropagationContext } from '@sentry/types'; import { GLOBAL_OBJ, logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; const commonPropagationContextMap = new WeakMap(); @@ -92,3 +93,19 @@ export function escapeNextjsTracing(cb: () => T): T { }); } } + +/** + * Ideally this function never lands in the develop branch. + * + * Drops the entire span tree this function was called in, if it was a span tree created by Next.js. + */ +export function dropNextjsRootContext(): void { + const nextJsOwnedSpan = getActiveSpan(); + if (nextJsOwnedSpan) { + const rootSpan = getRootSpan(nextJsOwnedSpan); + const rootSpanAttributes = spanToJSON(rootSpan).data; + if (rootSpanAttributes?.['next.span_type']) { + getRootSpan(nextJsOwnedSpan)?.setAttribute(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + } + } +} diff --git a/packages/nextjs/src/common/utils/wrapperUtils.ts b/packages/nextjs/src/common/utils/wrapperUtils.ts index ff04aebbd3ed..159ee669ec09 100644 --- a/packages/nextjs/src/common/utils/wrapperUtils.ts +++ b/packages/nextjs/src/common/utils/wrapperUtils.ts @@ -1,44 +1,13 @@ import type { IncomingMessage, ServerResponse } from 'http'; import { - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SPAN_STATUS_ERROR, - SPAN_STATUS_OK, captureException, - continueTrace, + getActiveSpan, + getCurrentScope, + getIsolationScope, + getRootSpan, getTraceData, - startInactiveSpan, - startSpan, - startSpanManual, - withActiveSpan, - withIsolationScope, } from '@sentry/core'; -import type { Span } from '@sentry/types'; -import { isString, vercelWaitUntil } from '@sentry/utils'; - -import { autoEndSpanOnResponseEnd, flushSafelyWithTimeout } from './responseEnd'; -import { commonObjectToIsolationScope, escapeNextjsTracing } from './tracingUtils'; - -declare module 'http' { - interface IncomingMessage { - _sentrySpan?: Span; - } -} - -/** - * Grabs a span off a Next.js datafetcher request object, if it was previously put there via - * `setSpanOnRequest`. - * - * @param req The Next.js datafetcher request object - * @returns the span on the request object if there is one, or `undefined` if the request object didn't have one. - */ -export function getSpanFromRequest(req: IncomingMessage): Span | undefined { - return req._sentrySpan; -} - -function setSpanOnRequest(span: Span, req: IncomingMessage): void { - req._sentrySpan = span; -} +import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../span-attributes-with-logic-attached'; /** * Wraps a function that potentially throws. If it does, the error is passed to `captureException` and rethrown. @@ -55,7 +24,6 @@ export function withErrorInstrumentation any>( } catch (e) { // TODO: Extract error logic from `withSentry` in here or create a new wrapper with said logic or something like that. captureException(e, { mechanism: { handled: false } }); - throw e; } }; @@ -93,78 +61,27 @@ export function withTracedServerSideDataFetcher Pr this: unknown, ...args: Parameters ): Promise<{ data: ReturnType; sentryTrace?: string; baggage?: string }> { - return escapeNextjsTracing(() => { - const isolationScope = commonObjectToIsolationScope(req); - return withIsolationScope(isolationScope, () => { - isolationScope.setTransactionName(`${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`); - isolationScope.setSDKProcessingMetadata({ - request: req, - }); - - const sentryTrace = - req.headers && isString(req.headers['sentry-trace']) ? req.headers['sentry-trace'] : undefined; - const baggage = req.headers?.baggage; - - return continueTrace({ sentryTrace, baggage }, () => { - const requestSpan = getOrStartRequestSpan(req, res, options.requestedRouteName); - return withActiveSpan(requestSpan, () => { - return startSpanManual( - { - op: 'function.nextjs', - name: `${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }, - }, - async dataFetcherSpan => { - dataFetcherSpan.setStatus({ code: SPAN_STATUS_OK }); - const { 'sentry-trace': sentryTrace, baggage } = getTraceData(); - try { - return { - sentryTrace: sentryTrace, - baggage: baggage, - data: await origDataFetcher.apply(this, args), - }; - } catch (e) { - dataFetcherSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - requestSpan?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - throw e; - } finally { - dataFetcherSpan.end(); - } - }, - ); - }); - }); - }); - }).finally(() => { - vercelWaitUntil(flushSafelyWithTimeout()); + getCurrentScope().setTransactionName(`${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`); + getIsolationScope().setSDKProcessingMetadata({ + request: req, }); - }; -} -function getOrStartRequestSpan(req: IncomingMessage, res: ServerResponse, name: string): Span { - const existingSpan = getSpanFromRequest(req); - if (existingSpan) { - return existingSpan; - } + const span = getActiveSpan(); - const requestSpan = startInactiveSpan({ - name, - forceTransaction: true, - op: 'http.server', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }, - }); + // Only set the route backfill if the span is not for /_error + if (span && options.requestedRouteName !== '/_error') { + const root = getRootSpan(span); + root.setAttribute(TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL, options.requestedRouteName); + } - requestSpan.setStatus({ code: SPAN_STATUS_OK }); - setSpanOnRequest(requestSpan, req); - autoEndSpanOnResponseEnd(requestSpan, res); + const { 'sentry-trace': sentryTrace, baggage } = getTraceData(); - return requestSpan; + return { + sentryTrace: sentryTrace, + baggage: baggage, + data: await origDataFetcher.apply(this, args), + }; + }; } /** @@ -177,37 +94,11 @@ function getOrStartRequestSpan(req: IncomingMessage, res: ServerResponse, name: export async function callDataFetcherTraced Promise | any>( origFunction: F, origFunctionArgs: Parameters, - options: { - parameterizedRoute: string; - dataFetchingMethodName: string; - }, ): Promise> { - const { parameterizedRoute, dataFetchingMethodName } = options; - - return startSpan( - { - op: 'function.nextjs', - name: `${dataFetchingMethodName} (${parameterizedRoute})`, - onlyIfParent: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }, - }, - async dataFetcherSpan => { - dataFetcherSpan.setStatus({ code: SPAN_STATUS_OK }); - - try { - return await origFunction(...origFunctionArgs); - } catch (e) { - dataFetcherSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(e, { mechanism: { handled: false } }); - throw e; - } finally { - dataFetcherSpan.end(); - } - }, - ).finally(() => { - vercelWaitUntil(flushSafelyWithTimeout()); - }); + try { + return await origFunction(...origFunctionArgs); + } catch (e) { + captureException(e, { mechanism: { handled: false } }); + throw e; + } } diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts index 0b8d3b6d7c60..b13d3ebef3dd 100644 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ b/packages/nextjs/src/common/withServerActionInstrumentation.ts @@ -1,16 +1,19 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, SPAN_STATUS_ERROR, + captureException, + continueTrace, + getClient, getIsolationScope, + handleCallbackErrors, + startSpan, withIsolationScope, } from '@sentry/core'; -import { captureException, continueTrace, getClient, handleCallbackErrors, startSpan } from '@sentry/core'; import { logger, vercelWaitUntil } from '@sentry/utils'; import { DEBUG_BUILD } from './debug-build'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import { flushSafelyWithTimeout } from './utils/responseEnd'; -import { escapeNextjsTracing } from './utils/tracingUtils'; interface Options { formData?: FormData; @@ -64,88 +67,86 @@ async function withServerActionInstrumentationImplementation> { - return escapeNextjsTracing(() => { - return withIsolationScope(async isolationScope => { - const sendDefaultPii = getClient()?.getOptions().sendDefaultPii; + return withIsolationScope(async isolationScope => { + const sendDefaultPii = getClient()?.getOptions().sendDefaultPii; - let sentryTraceHeader; - let baggageHeader; - const fullHeadersObject: Record = {}; - try { - const awaitedHeaders: Headers = await options.headers; - sentryTraceHeader = awaitedHeaders?.get('sentry-trace') ?? undefined; - baggageHeader = awaitedHeaders?.get('baggage'); - awaitedHeaders?.forEach((value, key) => { - fullHeadersObject[key] = value; - }); - } catch (e) { - DEBUG_BUILD && - logger.warn( - "Sentry wasn't able to extract the tracing headers for a server action. Will not trace this request.", - ); - } - - isolationScope.setTransactionName(`serverAction/${serverActionName}`); - isolationScope.setSDKProcessingMetadata({ - request: { - headers: fullHeadersObject, - }, + let sentryTraceHeader; + let baggageHeader; + const fullHeadersObject: Record = {}; + try { + const awaitedHeaders: Headers = await options.headers; + sentryTraceHeader = awaitedHeaders?.get('sentry-trace') ?? undefined; + baggageHeader = awaitedHeaders?.get('baggage'); + awaitedHeaders?.forEach((value, key) => { + fullHeadersObject[key] = value; }); + } catch (e) { + DEBUG_BUILD && + logger.warn( + "Sentry wasn't able to extract the tracing headers for a server action. Will not trace this request.", + ); + } - return continueTrace( - { - sentryTrace: sentryTraceHeader, - baggage: baggageHeader, - }, - async () => { - try { - return await startSpan( - { - op: 'function.server_action', - name: `serverAction/${serverActionName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }, - }, - async span => { - const result = await handleCallbackErrors(callback, error => { - if (isNotFoundNavigationError(error)) { - // We don't want to report "not-found"s - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - } else if (isRedirectNavigationError(error)) { - // Don't do anything for redirects - } else { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - }, - }); - } - }); - - if (options.recordResponse !== undefined ? options.recordResponse : sendDefaultPii) { - getIsolationScope().setExtra('server_action_result', result); - } + isolationScope.setTransactionName(`serverAction/${serverActionName}`); + isolationScope.setSDKProcessingMetadata({ + request: { + headers: fullHeadersObject, + }, + }); - if (options.formData) { - options.formData.forEach((value, key) => { - getIsolationScope().setExtra( - `server_action_form_data.${key}`, - typeof value === 'string' ? value : '[non-string value]', - ); + return continueTrace( + { + sentryTrace: sentryTraceHeader, + baggage: baggageHeader, + }, + async () => { + try { + return await startSpan( + { + op: 'function.server_action', + name: `serverAction/${serverActionName}`, + forceTransaction: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }, + }, + async span => { + const result = await handleCallbackErrors(callback, error => { + if (isNotFoundNavigationError(error)) { + // We don't want to report "not-found"s + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + } else if (isRedirectNavigationError(error)) { + // Don't do anything for redirects + } else { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + }, }); } + }); - return result; - }, - ); - } finally { - vercelWaitUntil(flushSafelyWithTimeout()); - } - }, - ); - }); + if (options.recordResponse !== undefined ? options.recordResponse : sendDefaultPii) { + getIsolationScope().setExtra('server_action_result', result); + } + + if (options.formData) { + options.formData.forEach((value, key) => { + getIsolationScope().setExtra( + `server_action_form_data.${key}`, + typeof value === 'string' ? value : '[non-string value]', + ); + }); + } + + return result; + }, + ); + } finally { + vercelWaitUntil(flushSafelyWithTimeout()); + } + }, + ); }); } diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 5944b520f6ea..5c9b2506ecee 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -20,6 +20,7 @@ import { propagationContextFromHeaders, uuid4, winterCGHeadersToDict } from '@se import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import type { GenerationFunctionContext } from '../common/types'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; +import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; /** @@ -49,9 +50,6 @@ export function wrapGenerationFunctionWithSentry a const rootSpan = getRootSpan(activeSpan); const { scope } = getCapturedScopesOnSpan(rootSpan); setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope); - - // We mark the root span as an app router span so we can allow-list it in our span processor that would normally filter out all Next.js transactions/spans - rootSpan.setAttribute('sentry.rsc', true); } let data: Record | undefined = undefined; @@ -75,6 +73,15 @@ export function wrapGenerationFunctionWithSentry a }, }); + const activeSpan = getActiveSpan(); + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + const sentryTrace = headersDict?.['sentry-trace']; + if (sentryTrace) { + rootSpan.setAttribute(TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL, sentryTrace); + } + } + const propagationContext = commonObjectToPropagationContext( headers, headersDict?.['sentry-trace'] diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 66cbbb046300..e8b57c7d2b8b 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 spanSource: TransactionSource; + + if (req instanceof Request) { + isolationScope.setSDKProcessingMetadata({ + request: winterCGRequestToRequestData(req), + }); + spanName = `middleware ${req.method} ${new URL(req.url).pathname}`; + spanSource = 'url'; + } else { + spanName = 'middleware'; + spanSource = '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'; + spanSource = 'component'; + + const rootSpan = getRootSpan(activeSpan); + if (rootSpan) { + setCapturedScopesOnSpan(rootSpan, currentScope, isolationScope); + } + } + + return startSpan( + { + name: spanName, + op: 'http.server.middleware', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: spanSource, + [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/common/wrapPageComponentWithSentry.ts b/packages/nextjs/src/common/wrapPageComponentWithSentry.ts deleted file mode 100644 index 8cd4a250ac14..000000000000 --- a/packages/nextjs/src/common/wrapPageComponentWithSentry.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { captureException, getCurrentScope, withIsolationScope } from '@sentry/core'; -import { extractTraceparentData } from '@sentry/utils'; -import { escapeNextjsTracing } from './utils/tracingUtils'; - -interface FunctionComponent { - (...args: unknown[]): unknown; -} - -interface ClassComponent { - new (...args: unknown[]): { - props?: unknown; - render(...args: unknown[]): unknown; - }; -} - -function isReactClassComponent(target: unknown): target is ClassComponent { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return typeof target === 'function' && target?.prototype?.isReactComponent; -} - -/** - * Wraps a page component with Sentry error instrumentation. - */ -export function wrapPageComponentWithSentry(pageComponent: FunctionComponent | ClassComponent): unknown { - if (isReactClassComponent(pageComponent)) { - return class SentryWrappedPageComponent extends pageComponent { - public render(...args: unknown[]): unknown { - return escapeNextjsTracing(() => { - return withIsolationScope(() => { - const scope = getCurrentScope(); - // We extract the sentry trace data that is put in the component props by datafetcher wrappers - const sentryTraceData = - typeof this.props === 'object' && - this.props !== null && - '_sentryTraceData' in this.props && - typeof this.props._sentryTraceData === 'string' - ? this.props._sentryTraceData - : undefined; - - if (sentryTraceData) { - const traceparentData = extractTraceparentData(sentryTraceData); - scope.setContext('trace', { - span_id: traceparentData?.parentSpanId, - trace_id: traceparentData?.traceId, - }); - } - - try { - return super.render(...args); - } catch (e) { - captureException(e, { - mechanism: { - handled: false, - }, - }); - throw e; - } - }); - }); - } - }; - } else if (typeof pageComponent === 'function') { - return new Proxy(pageComponent, { - apply(target, thisArg, argArray: [{ _sentryTraceData?: string } | undefined]) { - return escapeNextjsTracing(() => { - return withIsolationScope(() => { - const scope = getCurrentScope(); - // We extract the sentry trace data that is put in the component props by datafetcher wrappers - const sentryTraceData = argArray?.[0]?._sentryTraceData; - - if (sentryTraceData) { - const traceparentData = extractTraceparentData(sentryTraceData); - scope.setContext('trace', { - span_id: traceparentData?.parentSpanId, - trace_id: traceparentData?.traceId, - }); - } - - try { - return target.apply(thisArg, argArray); - } catch (e) { - captureException(e, { - mechanism: { - handled: false, - }, - }); - throw e; - } - }); - }); - }, - }); - } else { - return pageComponent; - } -} diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index 71061e913dac..6ff4e314b17b 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -1,24 +1,24 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SPAN_STATUS_ERROR, + Scope, captureException, + getActiveSpan, + getCapturedScopesOnSpan, + getRootSpan, handleCallbackErrors, + setCapturedScopesOnSpan, setHttpStatus, - startSpan, withIsolationScope, withScope, } from '@sentry/core'; -import { propagationContextFromHeaders, vercelWaitUntil, winterCGHeadersToDict } from '@sentry/utils'; -import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; + import type { RouteHandlerContext } from './types'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; -import { - commonObjectToIsolationScope, - commonObjectToPropagationContext, - escapeNextjsTracing, -} from './utils/tracingUtils'; + +import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; + +import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; +import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; /** * Wraps a Next.js App Router Route handler with Sentry error and performance instrumentation. @@ -33,74 +33,79 @@ export function wrapRouteHandlerWithSentry any>( const { method, parameterizedRoute, headers } = context; return new Proxy(routeHandler, { - apply: (originalFunction, thisArg, args) => { - return escapeNextjsTracing(() => { - const isolationScope = commonObjectToIsolationScope(headers); + apply: async (originalFunction, thisArg, args) => { + const isolationScope = commonObjectToIsolationScope(headers); - const completeHeadersDict: Record = headers ? winterCGHeadersToDict(headers) : {}; + const completeHeadersDict: Record = headers ? winterCGHeadersToDict(headers) : {}; - isolationScope.setSDKProcessingMetadata({ - request: { - headers: completeHeadersDict, - }, - }); + isolationScope.setSDKProcessingMetadata({ + request: { + headers: completeHeadersDict, + }, + }); - const incomingPropagationContext = propagationContextFromHeaders( - completeHeadersDict['sentry-trace'], - completeHeadersDict['baggage'], - ); + const incomingPropagationContext = propagationContextFromHeaders( + completeHeadersDict['sentry-trace'], + completeHeadersDict['baggage'], + ); - const propagationContext = commonObjectToPropagationContext(headers, incomingPropagationContext); + const propagationContext = commonObjectToPropagationContext(headers, incomingPropagationContext); - return withIsolationScope(isolationScope, () => { - return withScope(async scope => { - scope.setTransactionName(`${method} ${parameterizedRoute}`); - scope.setPropagationContext(propagationContext); - try { - return startSpan( - { - name: `${method} ${parameterizedRoute}`, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - }, - forceTransaction: true, - }, - async span => { - const response: Response = await handleCallbackErrors( - () => originalFunction.apply(thisArg, args), - error => { - // Next.js throws errors when calling `redirect()`. We don't wanna report these. - if (isRedirectNavigationError(error)) { - // Don't do anything - } else if (isNotFoundNavigationError(error) && span) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - } else { - captureException(error, { - mechanism: { - handled: false, - }, - }); - } - }, - ); + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + if (rootSpan) { + const { scope } = getCapturedScopesOnSpan(rootSpan); + setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope); + + if (process.env.NEXT_RUNTIME === 'edge') { + rootSpan.updateName(`${method} ${parameterizedRoute}`); + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server'); + } + } - try { - if (span && response.status) { - setHttpStatus(span, response.status); - } - } catch { - // best effort - response may be undefined? - } + return withIsolationScope(isolationScope, () => { + return withScope(async scope => { + scope.setTransactionName(`${method} ${parameterizedRoute}`); + scope.setPropagationContext(propagationContext); - return response; - }, - ); - } finally { - vercelWaitUntil(flushSafelyWithTimeout()); + const response: Response = await handleCallbackErrors( + () => originalFunction.apply(thisArg, args), + error => { + // Next.js throws errors when calling `redirect()`. We don't wanna report these. + if (isRedirectNavigationError(error)) { + // Don't do anything + } else if (isNotFoundNavigationError(error)) { + if (activeSpan) { + setHttpStatus(activeSpan, 404); + } + if (rootSpan) { + setHttpStatus(rootSpan, 404); + } + } else { + captureException(error, { + mechanism: { + handled: false, + }, + }); + } + }, + ); + + try { + if (response.status) { + if (activeSpan) { + setHttpStatus(activeSpan, response.status); + } + if (rootSpan) { + setHttpStatus(rootSpan, response.status); + } } - }); + } catch { + // best effort - response may be undefined? + } + + return response; }); }); }, diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index 079722dad76d..c4bbde29eb53 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -18,6 +18,7 @@ import { propagationContextFromHeaders, uuid4, vercelWaitUntil, winterCGHeadersT import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import type { ServerComponentContext } from '../common/types'; +import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; import { flushSafelyWithTimeout } from './utils/responseEnd'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; @@ -43,9 +44,6 @@ export function wrapServerComponentWithSentry any> const rootSpan = getRootSpan(activeSpan); const { scope } = getCapturedScopesOnSpan(rootSpan); setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope); - - // We mark the root span as an app router span so we can allow-list it in our span processor that would normally filter out all Next.js transactions/spans - rootSpan.setAttribute('sentry.rsc', true); } const headersDict = context.headers ? winterCGHeadersToDict(context.headers) : undefined; @@ -74,6 +72,15 @@ export function wrapServerComponentWithSentry any> scope.setPropagationContext(propagationContext); } + const activeSpan = getActiveSpan(); + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + const sentryTrace = headersDict?.['sentry-trace']; + if (sentryTrace) { + rootSpan.setAttribute(TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL, sentryTrace); + } + } + return startSpanManual( { op: 'function.nextjs', diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index d2e78d87f4ae..63f678cdbc66 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -409,12 +409,15 @@ export type SentryBuildOptions = { autoInstrumentAppDirectory?: boolean; /** - * Exclude certain serverside API routes or pages from being instrumented with Sentry. This option takes an array of - * strings or regular expressions. This options also affects pages in the `app` directory. + * Exclude certain serverside API routes or pages from being instrumented with Sentry during build-time. This option + * takes an array of strings or regular expressions. This options also affects pages in the `app` directory. * * NOTE: Pages should be specified as routes (`/animals` or `/api/animals/[animalType]/habitat`), not filepaths * (`pages/animals/index.js` or `.\src\pages\api\animals\[animalType]\habitat.tsx`), and strings must be be a full, * exact match. + * + * Notice: If you build Next.js with turbopack, the Sentry SDK will no longer apply build-time instrumentation and + * purely rely on Next.js telemetry features, meaning that this option will effectively no-op. */ excludeServerRoutes?: Array; diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 7034873f665e..fff4236bf3be 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,11 +1,23 @@ -import { applySdkMetadata, registerSpanErrorInstrumentation } from '@sentry/core'; -import { GLOBAL_OBJ } from '@sentry/utils'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + applySdkMetadata, + getRootSpan, + registerSpanErrorInstrumentation, + spanToJSON, +} from '@sentry/core'; + +import { GLOBAL_OBJ, stripUrlQueryAndFragment, vercelWaitUntil } from '@sentry/utils'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { isBuild } from '../common/utils/isBuild'; +import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; +export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error'; + export type EdgeOptions = VercelEdgeOptions; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { @@ -37,7 +49,44 @@ export function init(options: VercelEdgeOptions = {}): void { applySdkMetadata(opts, 'nextjs'); - vercelEdgeInit(opts); + const client = vercelEdgeInit(opts); + + client?.on('spanStart', span => { + const spanAttributes = spanToJSON(span).data; + + // Mark all spans generated by Next.js as 'auto' + if (spanAttributes?.['next.span_type'] !== undefined) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto'); + } + + // Make sure middleware spans get the right op + if (spanAttributes?.['next.span_type'] === 'Middleware.execute') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server.middleware'); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); + } + }); + + // Use the preprocessEvent hook instead of an event processor, so that the users event processors receive the most + // up-to-date value, but also so that the logic that detects changes to the transaction names to set the source to + // "custom", doesn't trigger. + client?.on('preprocessEvent', event => { + // The otel auto inference will clobber the transaction name because the span has an http.target + if ( + event.type === 'transaction' && + event.contexts?.trace?.data?.['next.span_type'] === 'Middleware.execute' && + event.contexts?.trace?.data?.['next.span_name'] + ) { + if (event.transaction) { + event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']); + } + } + }); + + 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/src/server/index.ts b/packages/nextjs/src/server/index.ts index 6249fcd8bad4..7aade8cbd5c3 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -1,16 +1,22 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, applySdkMetadata, + getCapturedScopesOnSpan, getClient, + getCurrentScope, getGlobalScope, + getIsolationScope, getRootSpan, + setCapturedScopesOnSpan, spanToJSON, } from '@sentry/core'; -import { getDefaultIntegrations, init as nodeInit } from '@sentry/node'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { GLOBAL_OBJ, logger } from '@sentry/utils'; +import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; +import { GLOBAL_OBJ, extractTraceparentData, logger, stripUrlQueryAndFragment } from '@sentry/utils'; +import { context } from '@opentelemetry/api'; import { ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_ROUTE, @@ -18,38 +24,28 @@ import { SEMATTRS_HTTP_METHOD, SEMATTRS_HTTP_TARGET, } from '@opentelemetry/semantic-conventions'; +import { getScopesFromContext } from '@sentry/opentelemetry'; import type { EventProcessor } from '@sentry/types'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; +import { + TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL, + TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL, + TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, +} from '../common/span-attributes-with-logic-attached'; import { isBuild } from '../common/utils/isBuild'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; export * from '@sentry/node'; -export { captureUnderscoreErrorException } from '../common/_error'; +export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error'; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { __rewriteFramesDistDir__?: string; __sentryRewritesTunnelPath__?: string; }; -// https://github.com/lforst/nextjs-fork/blob/9051bc44d969a6e0ab65a955a2fc0af522a83911/packages/next/src/server/lib/trace/constants.ts#L11 -const NEXTJS_SPAN_NAME_PREFIXES = [ - 'BaseServer.', - 'LoadComponents.', - 'NextServer.', - 'createServer.', - 'startServer.', - 'NextNodeServer.', - 'Render.', - 'AppRender.', - 'Router.', - 'Node.', - 'AppRouteRouteHandlers.', - 'ResolveMetadata.', -]; - /** * A passthrough error boundary for the server that doesn't depend on any react. Error boundaries don't catch SSR errors * so they should simply be a passthrough. @@ -98,7 +94,14 @@ export function init(options: NodeOptions): NodeClient | undefined { return; } - const customDefaultIntegrations = getDefaultIntegrations(options); + const customDefaultIntegrations = getDefaultIntegrations(options) + .filter(integration => integration.name !== 'Http') + .concat( + // We are using the HTTP integration without instrumenting incoming HTTP requests because Next.js does that by itself. + httpIntegration({ + disableIncomingRequestSpans: true, + }), + ); // Turn off Next.js' own fetch instrumentation // https://github.com/lforst/nextjs-fork/blob/1994fd186defda77ad971c36dc3163db263c993f/packages/next/src/server/lib/patch-fetch.ts#L245 @@ -133,25 +136,7 @@ export function init(options: NodeOptions): NodeClient | undefined { applySdkMetadata(opts, 'nextjs', ['nextjs', 'node']); const client = nodeInit(opts); - client?.on('beforeSampling', ({ spanAttributes, spanName, parentSampled, parentContext }, samplingDecision) => { - // We allowlist the "BaseServer.handleRequest" span, since that one is responsible for App Router requests, which are actually useful for us. - // HOWEVER, that span is not only responsible for App Router requests, which is why we additionally filter for certain transactions in an - // event processor further below. - if (spanAttributes['next.span_type'] === 'BaseServer.handleRequest') { - return; - } - - // If we encounter a span emitted by Next.js, we do not want to sample it - // The reason for this is that the data quality of the spans varies, it is different per version of Next, - // and we need to keep our manual instrumentation around for the edge runtime anyhow. - // BUT we only do this if we don't have a parent span with a sampling decision yet (or if the parent is remote) - if ( - (spanAttributes['next.span_type'] || NEXTJS_SPAN_NAME_PREFIXES.some(prefix => spanName.startsWith(prefix))) && - (parentSampled === undefined || parentContext?.isRemote) - ) { - samplingDecision.decision = false; - } - + client?.on('beforeSampling', ({ spanAttributes }, samplingDecision) => { // There are situations where the Next.js Node.js server forwards requests for the Edge Runtime server (e.g. in // middleware) and this causes spans for Sentry ingest requests to be created. These are not exempt from our tracing // because we didn't get the chance to do `suppressTracing`, since this happens outside of userland. @@ -176,7 +161,7 @@ export function init(options: NodeOptions): NodeClient | undefined { // What we do in this glorious piece of code, is hoist any information about parameterized routes from spans emitted // by Next.js via the `next.route` attribute, up to the transaction by setting the http.route attribute. - if (spanAttributes?.['next.route']) { + if (typeof spanAttributes?.['next.route'] === 'string') { const rootSpan = getRootSpan(span); const rootSpanAttributes = spanToJSON(rootSpan).data; @@ -186,21 +171,31 @@ export function init(options: NodeOptions): NodeClient | undefined { (rootSpanAttributes?.[ATTR_HTTP_REQUEST_METHOD] || rootSpanAttributes?.[SEMATTRS_HTTP_METHOD]) && !rootSpanAttributes?.[ATTR_HTTP_ROUTE] ) { - rootSpan.setAttribute(ATTR_HTTP_ROUTE, spanAttributes['next.route']); + const route = spanAttributes['next.route'].replace(/\/route$/, ''); + rootSpan.updateName(route); + rootSpan.setAttribute(ATTR_HTTP_ROUTE, route); } } // We want to skip span data inference for any spans generated by Next.js. Reason being that Next.js emits spans // with patterns (e.g. http.server spans) that will produce confusing data. if (spanAttributes?.['next.span_type'] !== undefined) { - span.setAttribute('sentry.skip_span_data_inference', true); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto'); } - // We want to rename these spans because they look like "GET /path/to/route" and we already emit spans that look - // like this with our own http instrumentation. - if (spanAttributes?.['next.span_type'] === 'BaseServer.handleRequest') { - span.updateName('next server handler'); // This is all lowercase because the spans that Next.js emits by itself generally look like this. + // We want to fork the isolation scope for incoming requests + if (spanAttributes?.['next.span_type'] === 'BaseServer.handleRequest' && span === getRootSpan(span)) { + 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); } }); @@ -215,15 +210,6 @@ export function init(options: NodeOptions): NodeClient | undefined { return null; } - // We only want to use our HTTP integration/instrumentation for app router requests, which are marked with the `sentry.rsc` attribute. - if ( - (event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.http.otel.http' || - event.contexts?.trace?.data?.['next.span_type'] === 'BaseServer.handleRequest') && - event.contexts?.trace?.data?.['sentry.rsc'] !== true - ) { - return null; - } - // Filter out transactions for requests to the tunnel route if ( globalWithInjectedValues.__sentryRewritesTunnelPath__ && @@ -247,6 +233,27 @@ export function init(options: NodeOptions): NodeClient | undefined { return null; } + // Filter transactions that we explicitly want to drop. + if (event.contexts?.trace?.data?.[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]) { + return null; + } + + // Next.js 13 sometimes names the root transactions like this containing useless tracing. + if (event.transaction === 'NextServer.getRequestHandler') { + return null; + } + + // Next.js 13 is not correctly picking up tracing data for trace propagation so we use a back-fill strategy + if (typeof event.contexts?.trace?.data?.[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL] === 'string') { + const traceparentData = extractTraceparentData( + event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL], + ); + + if (traceparentData?.parentSampled === false) { + return null; + } + } + return event; } else { return event; @@ -291,27 +298,62 @@ export function init(options: NodeOptions): NodeClient | undefined { ), ); - getGlobalScope().addEventProcessor( - Object.assign( - (event => { - // Sometimes, the HTTP integration will not work, causing us not to properly set an op for spans generated by - // Next.js that are actually more or less correct server HTTP spans, so we are backfilling the op here. - if ( - event.type === 'transaction' && - event.transaction?.match(/^(RSC )?GET /) && - event.contexts?.trace?.data?.['sentry.rsc'] === true && - !event.contexts.trace.op - ) { - event.contexts.trace.data = event.contexts.trace.data || {}; - event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; - event.contexts.trace.op = 'http.server'; - } + // Use the preprocessEvent hook instead of an event processor, so that the users event processors receive the most + // up-to-date value, but also so that the logic that detects changes to the transaction names to set the source to + // "custom", doesn't trigger. + client?.on('preprocessEvent', event => { + // Enhance route handler transactions + if ( + event.type === 'transaction' && + event.contexts?.trace?.data?.['next.span_type'] === 'BaseServer.handleRequest' + ) { + event.contexts.trace.data = event.contexts.trace.data || {}; + event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; + event.contexts.trace.op = 'http.server'; - return event; - }) satisfies EventProcessor, - { id: 'NextjsTransactionEnhancer' }, - ), - ); + if (event.transaction) { + event.transaction = stripUrlQueryAndFragment(event.transaction); + } + + // eslint-disable-next-line deprecation/deprecation + const method = event.contexts.trace.data[SEMATTRS_HTTP_METHOD]; + // eslint-disable-next-line deprecation/deprecation + const target = event.contexts?.trace?.data?.[SEMATTRS_HTTP_TARGET]; + const route = event.contexts.trace.data[ATTR_HTTP_ROUTE]; + + if (typeof method === 'string' && typeof route === 'string') { + event.transaction = `${method} ${route.replace(/\/route$/, '')}`; + event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; + } + + // backfill transaction name for pages that would otherwise contain unparameterized routes + if (event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL] && event.transaction !== 'GET /_app') { + event.transaction = `${method} ${event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]}`; + } + + // Next.js overrides transaction names for page loads that throw an error + // but we want to keep the original target name + if (event.transaction === 'GET /_error' && target) { + event.transaction = `${method ? `${method} ` : ''}${target}`; + } + } + + // Next.js 13 is not correctly picking up tracing data for trace propagation so we use a back-fill strategy + if ( + event.type === 'transaction' && + typeof event.contexts?.trace?.data?.[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL] === 'string' + ) { + const traceparentData = extractTraceparentData(event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL]); + + if (traceparentData?.traceId) { + event.contexts.trace.trace_id = traceparentData.traceId; + } + + if (traceparentData?.parentSpanId) { + event.contexts.trace.parent_span_id = traceparentData.parentSpanId; + } + } + }); if (process.env.NODE_ENV === 'development') { getGlobalScope().addEventProcessor(devErrorSymbolicationEventProcessor); @@ -328,4 +370,4 @@ function sdkAlreadyInitialized(): boolean { export * from '../common'; -export { wrapApiHandlerWithSentry } from '../common/wrapApiHandlerWithSentry'; +export { wrapApiHandlerWithSentry } from '../common/pages-router-instrumentation/wrapApiHandlerWithSentry'; diff --git a/packages/nextjs/test/config/mocks.ts b/packages/nextjs/test/config/mocks.ts index 5c27c743c9f9..7d2cc1a0f4ac 100644 --- a/packages/nextjs/test/config/mocks.ts +++ b/packages/nextjs/test/config/mocks.ts @@ -58,15 +58,11 @@ afterEach(() => { mkdtempSyncSpy.mockClear(); }); -// TODO (v8): This shouldn't be necessary once `hideSourceMaps` gets a default value, even for the updated error message // eslint-disable-next-line @typescript-eslint/unbound-method const realConsoleWarn = global.console.warn; global.console.warn = (...args: unknown[]) => { - // Suppress the warning message about the `hideSourceMaps` option. This is better than forcing a value for - // `hideSourceMaps` because that would mean we couldn't test it easily and would muddy the waters of other tests. Note - // that doing this here, as a side effect, only works because the tests which trigger this warning are the same tests - // which need other mocks from this file. - if (typeof args[0] === 'string' && args[0]?.includes('your original code may be visible in browser devtools')) { + // Suppress the v7 -> v8 migration warning which would get spammed for the unit tests otherwise + if (typeof args[0] === 'string' && args[0]?.includes('Learn more about setting up an instrumentation hook')) { return; } diff --git a/packages/nextjs/test/config/wrappers.test.ts b/packages/nextjs/test/config/wrappers.test.ts index 284737f4335d..e2928d59016e 100644 --- a/packages/nextjs/test/config/wrappers.test.ts +++ b/packages/nextjs/test/config/wrappers.test.ts @@ -1,13 +1,12 @@ import type { IncomingMessage, ServerResponse } from 'http'; import * as SentryCore from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; import type { Client } from '@sentry/types'; import { wrapGetInitialPropsWithSentry, wrapGetServerSidePropsWithSentry } from '../../src/common'; const startSpanManualSpy = jest.spyOn(SentryCore, 'startSpanManual'); -describe('data-fetching function wrappers should create spans', () => { +describe('data-fetching function wrappers should not create manual spans', () => { const route = '/tricks/[trickName]'; let req: IncomingMessage; let res: ServerResponse; @@ -35,17 +34,7 @@ describe('data-fetching function wrappers should create spans', () => { const wrappedOriginal = wrapGetServerSidePropsWithSentry(origFunction, route); await wrappedOriginal({ req, res } as any); - expect(startSpanManualSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'getServerSideProps (/tricks/[trickName])', - op: 'function.nextjs', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }, - }), - expect.any(Function), - ); + expect(startSpanManualSpy).not.toHaveBeenCalled(); }); test('wrapGetInitialPropsWithSentry', async () => { @@ -54,16 +43,44 @@ describe('data-fetching function wrappers should create spans', () => { const wrappedOriginal = wrapGetInitialPropsWithSentry(origFunction); await wrappedOriginal({ req, res, pathname: route } as any); - expect(startSpanManualSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'getInitialProps (/tricks/[trickName])', - op: 'function.nextjs', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }, - }), - expect.any(Function), - ); + expect(startSpanManualSpy).not.toHaveBeenCalled(); + }); + + test('wrapped function sets route backfill attribute when called within an active span', async () => { + const mockSetAttribute = jest.fn(); + const mockGetActiveSpan = jest.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({ + setAttribute: mockSetAttribute, + } as any); + const mockGetRootSpan = jest.spyOn(SentryCore, 'getRootSpan').mockReturnValue({ + setAttribute: mockSetAttribute, + } as any); + + const origFunction = jest.fn(async () => ({ props: {} })); + const wrappedOriginal = wrapGetServerSidePropsWithSentry(origFunction, route); + + await wrappedOriginal({ req, res } as any); + + expect(mockGetActiveSpan).toHaveBeenCalled(); + expect(mockGetRootSpan).toHaveBeenCalled(); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.route_backfill', '/tricks/[trickName]'); + }); + + test('wrapped function does not set route backfill attribute for /_error route', async () => { + const mockSetAttribute = jest.fn(); + const mockGetActiveSpan = jest.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({ + setAttribute: mockSetAttribute, + } as any); + const mockGetRootSpan = jest.spyOn(SentryCore, 'getRootSpan').mockReturnValue({ + setAttribute: mockSetAttribute, + } as any); + + const origFunction = jest.fn(async () => ({ props: {} })); + const wrappedOriginal = wrapGetServerSidePropsWithSentry(origFunction, '/_error'); + + await wrappedOriginal({ req, res } as any); + + expect(mockGetActiveSpan).toHaveBeenCalled(); + expect(mockGetRootSpan).not.toHaveBeenCalled(); + expect(mockSetAttribute).not.toHaveBeenCalled(); }); }); 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), - ); }); }); diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index 18c935863b75..d00319ec2c98 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -345,7 +345,6 @@ function removeSentryAttributes(data: Record): Record { source: 'route', }, ], - [ - "should not do any data parsing when the 'sentry.skip_span_data_inference' attribute is set", - { - 'sentry.skip_span_data_inference': true, - - // All of these should be ignored - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_DB_SYSTEM]: 'mysql', - [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', - }, - 'test name', - undefined, - { - op: undefined, - description: 'test name', - source: 'custom', - data: { - 'sentry.skip_span_data_inference': undefined, - }, - }, - ], ])('%s', (_, attributes, name, kind, expected) => { const actual = parseSpanDescription({ attributes, kind, name } as unknown as Span); expect(actual).toEqual(expected); diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index 448ae6250f4a..c2e5bc6e1259 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -39,11 +39,17 @@ "access": "public" }, "dependencies": { + "@opentelemetry/api": "^1.9.0", "@sentry/core": "8.35.0", "@sentry/types": "8.35.0", "@sentry/utils": "8.35.0" }, "devDependencies": { + "@opentelemetry/semantic-conventions": "^1.27.0", + "@opentelemetry/core": "^1.25.1", + "@opentelemetry/resources": "^1.26.0", + "@opentelemetry/sdk-trace-base": "^1.26.0", + "@sentry/opentelemetry": "8.35.0", "@edge-runtime/types": "3.0.1" }, "scripts": { diff --git a/packages/vercel-edge/rollup.npm.config.mjs b/packages/vercel-edge/rollup.npm.config.mjs index 84a06f2fb64a..3cfd779d57f6 100644 --- a/packages/vercel-edge/rollup.npm.config.mjs +++ b/packages/vercel-edge/rollup.npm.config.mjs @@ -1,3 +1,60 @@ -import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; +import replace from '@rollup/plugin-replace'; +import { makeBaseNPMConfig, makeNPMConfigVariants, plugins } from '@sentry-internal/rollup-utils'; -export default makeNPMConfigVariants(makeBaseNPMConfig()); +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts'], + bundledBuiltins: ['perf_hooks'], + packageSpecificConfig: { + context: 'globalThis', + output: { + preserveModules: false, + }, + plugins: [ + plugins.makeCommonJSPlugin({ transformMixedEsModules: true }), // Needed because various modules in the OTEL toolchain use CJS (require-in-the-middle, shimmer, etc..) + plugins.makeJsonPlugin(), // Needed because `require-in-the-middle` imports json via require + replace({ + preventAssignment: true, + values: { + 'process.argv0': JSON.stringify(''), // needed because otel relies on process.argv0 for the default service name, but that api is not available in the edge runtime. + }, + }), + { + // This plugin is needed because otel imports `performance` from `perf_hooks` and also uses it via the `performance` global. + // Both of these APIs are not available in the edge runtime so we need to define a polyfill. + // Vercel does something similar in the `@vercel/otel` package: https://github.com/vercel/otel/blob/087601ae585cb116bb2b46c211d014520de76c71/packages/otel/build.ts#L62 + name: 'perf-hooks-performance-polyfill', + banner: ` + { + if (globalThis.performance === undefined) { + globalThis.performance = { + timeOrigin: 0, + now: () => Date.now() + }; + } + } + `, + resolveId: source => { + if (source === 'perf_hooks') { + return '\0perf_hooks_sentry_shim'; + } else { + return null; + } + }, + load: id => { + if (id === '\0perf_hooks_sentry_shim') { + return ` + export const performance = { + timeOrigin: 0, + now: () => Date.now() + } + `; + } else { + return null; + } + }, + }, + ], + }, + }), +); diff --git a/packages/vercel-edge/src/async.ts b/packages/vercel-edge/src/async.ts deleted file mode 100644 index dd7432c8e959..000000000000 --- a/packages/vercel-edge/src/async.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core'; -import type { Scope } from '@sentry/types'; -import { GLOBAL_OBJ, logger } from '@sentry/utils'; - -import { DEBUG_BUILD } from './debug-build'; - -interface AsyncLocalStorage { - getStore(): T | undefined; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - run(store: T, callback: (...args: TArgs) => R, ...args: TArgs): R; -} - -let asyncStorage: AsyncLocalStorage<{ scope: Scope; isolationScope: Scope }>; - -/** - * Sets the async context strategy to use AsyncLocalStorage which should be available in the edge runtime. - */ -export function setAsyncLocalStorageAsyncContextStrategy(): void { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - const MaybeGlobalAsyncLocalStorage = (GLOBAL_OBJ as any).AsyncLocalStorage; - - if (!MaybeGlobalAsyncLocalStorage) { - DEBUG_BUILD && - logger.warn( - "Tried to register AsyncLocalStorage async context strategy in a runtime that doesn't support AsyncLocalStorage.", - ); - return; - } - - if (!asyncStorage) { - asyncStorage = new MaybeGlobalAsyncLocalStorage(); - } - - function getScopes(): { scope: Scope; isolationScope: Scope } { - const scopes = asyncStorage.getStore(); - - if (scopes) { - return scopes; - } - - // fallback behavior: - // if, for whatever reason, we can't find scopes on the context here, we have to fix this somehow - return { - scope: getDefaultCurrentScope(), - isolationScope: getDefaultIsolationScope(), - }; - } - - function withScope(callback: (scope: Scope) => T): T { - const scope = getScopes().scope.clone(); - const isolationScope = getScopes().isolationScope; - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(scope); - }); - } - - function withSetScope(scope: Scope, callback: (scope: Scope) => T): T { - const isolationScope = getScopes().isolationScope.clone(); - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(scope); - }); - } - - function withIsolationScope(callback: (isolationScope: Scope) => T): T { - const scope = getScopes().scope; - const isolationScope = getScopes().isolationScope.clone(); - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(isolationScope); - }); - } - - function withSetIsolationScope(isolationScope: Scope, callback: (isolationScope: Scope) => T): T { - const scope = getScopes().scope; - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(isolationScope); - }); - } - - setAsyncContextStrategy({ - withScope, - withSetScope, - withIsolationScope, - withSetIsolationScope, - getCurrentScope: () => getScopes().scope, - getIsolationScope: () => getScopes().isolationScope, - }); -} diff --git a/packages/vercel-edge/src/client.ts b/packages/vercel-edge/src/client.ts index b2c7416130bc..09987eacd030 100644 --- a/packages/vercel-edge/src/client.ts +++ b/packages/vercel-edge/src/client.ts @@ -2,6 +2,7 @@ import type { ServerRuntimeClientOptions } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import { ServerRuntimeClient } from '@sentry/core'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import type { VercelEdgeClientOptions } from './types'; declare const process: { @@ -15,6 +16,8 @@ declare const process: { * @see ServerRuntimeClient for usage documentation. */ export class VercelEdgeClient extends ServerRuntimeClient { + public traceProvider: BasicTracerProvider | undefined; + /** * Creates a new Vercel Edge Runtime SDK instance. * @param options Configuration options for this SDK. @@ -33,4 +36,21 @@ export class VercelEdgeClient extends ServerRuntimeClient { + const provider = this.traceProvider; + const spanProcessor = provider?.activeSpanProcessor; + + if (spanProcessor) { + await spanProcessor.forceFlush(); + } + + if (this.getOptions().sendClientReports) { + this._flushOutcomes(); + } + + return super.flush(timeout); + } } diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index 4e1bed208c34..4fa8415b2184 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -1,21 +1,48 @@ import { dedupeIntegration, functionToStringIntegration, + getCurrentScope, getIntegrationsToSetup, + hasTracingEnabled, inboundFiltersIntegration, - initAndBind, linkedErrorsIntegration, requestDataIntegration, } from '@sentry/core'; import type { Client, Integration, Options } from '@sentry/types'; -import { GLOBAL_OBJ, createStackParser, nodeStackLineParser, stackParserFromStackParserOptions } from '@sentry/utils'; +import { + GLOBAL_OBJ, + SDK_VERSION, + createStackParser, + logger, + nodeStackLineParser, + stackParserFromStackParserOptions, +} from '@sentry/utils'; -import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import { DiagLogLevel, diag } from '@opentelemetry/api'; +import { Resource } from '@opentelemetry/resources'; +import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, + SEMRESATTRS_SERVICE_NAMESPACE, +} from '@opentelemetry/semantic-conventions'; +import { + SentryPropagator, + SentrySampler, + SentrySpanProcessor, + enhanceDscWithOpenTelemetryRootSpanName, + openTelemetrySetupCheck, + setOpenTelemetryContextAsyncContextStrategy, + setupEventContextTrace, + wrapContextManagerClass, +} from '@sentry/opentelemetry'; import { VercelEdgeClient } from './client'; +import { DEBUG_BUILD } from './debug-build'; import { winterCGFetchIntegration } from './integrations/wintercg-fetch'; import { makeEdgeTransport } from './transports'; -import type { VercelEdgeClientOptions, VercelEdgeOptions } from './types'; +import type { VercelEdgeOptions } from './types'; import { getVercelEnv } from './utils/vercel'; +import { AsyncLocalStorageContextManager } from './vendored/async-local-storage-context-manager'; declare const process: { env: Record; @@ -37,7 +64,10 @@ export function getDefaultIntegrations(options: Options): Integration[] { /** Inits the Sentry NextJS SDK on the Edge Runtime. */ export function init(options: VercelEdgeOptions = {}): Client | undefined { - setAsyncLocalStorageAsyncContextStrategy(); + setOpenTelemetryContextAsyncContextStrategy(); + + const scope = getCurrentScope(); + scope.update(options.initialScope); if (options.defaultIntegrations === undefined) { options.defaultIntegrations = getDefaultIntegrations(options); @@ -71,14 +101,108 @@ export function init(options: VercelEdgeOptions = {}): Client | undefined { options.autoSessionTracking = true; } - const clientOptions: VercelEdgeClientOptions = { + const client = new VercelEdgeClient({ ...options, stackParser: stackParserFromStackParserOptions(options.stackParser || nodeStackParser), integrations: getIntegrationsToSetup(options), transport: options.transport || makeEdgeTransport, - }; + }); + // The client is on the current scope, from where it generally is inherited + getCurrentScope().setClient(client); + + client.init(); + + // If users opt-out of this, they _have_ to set up OpenTelemetry themselves + // There is no way to use this SDK without OpenTelemetry! + if (!options.skipOpenTelemetrySetup) { + setupOtel(client); + validateOpenTelemetrySetup(); + } + + enhanceDscWithOpenTelemetryRootSpanName(client); + setupEventContextTrace(client); + + return client; +} + +function validateOpenTelemetrySetup(): void { + if (!DEBUG_BUILD) { + return; + } + + const setup = openTelemetrySetupCheck(); + + const required: ReturnType = ['SentryContextManager', 'SentryPropagator']; + + if (hasTracingEnabled()) { + required.push('SentrySpanProcessor'); + } + + for (const k of required) { + if (!setup.includes(k)) { + logger.error( + `You have to set up the ${k}. Without this, the OpenTelemetry & Sentry integration will not work properly.`, + ); + } + } + + if (!setup.includes('SentrySampler')) { + logger.warn( + 'You have to set up the SentrySampler. Without this, the OpenTelemetry & Sentry integration may still work, but sample rates set for the Sentry SDK will not be respected. If you use a custom sampler, make sure to use `wrapSamplingDecision`.', + ); + } +} + +// exported for tests +// eslint-disable-next-line jsdoc/require-jsdoc +export function setupOtel(client: VercelEdgeClient): void { + if (client.getOptions().debug) { + setupOpenTelemetryLogger(); + } + + // Create and configure NodeTracerProvider + const provider = new BasicTracerProvider({ + sampler: new SentrySampler(client), + resource: new Resource({ + [ATTR_SERVICE_NAME]: 'edge', + // eslint-disable-next-line deprecation/deprecation + [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', + [ATTR_SERVICE_VERSION]: SDK_VERSION, + }), + forceFlushTimeoutMillis: 500, + }); + + provider.addSpanProcessor( + new SentrySpanProcessor({ + timeout: client.getOptions().maxSpanWaitDuration, + }), + ); + + const SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); + + // Initialize the provider + provider.register({ + propagator: new SentryPropagator(), + contextManager: new SentryContextManager(), + }); + + client.traceProvider = provider; +} + +/** + * Setup the OTEL logger to use our own logger. + */ +function setupOpenTelemetryLogger(): void { + const otelLogger = new Proxy(logger as typeof logger & { verbose: (typeof logger)['debug'] }, { + get(target, prop, receiver) { + const actualProp = prop === 'verbose' ? 'debug' : prop; + return Reflect.get(target, actualProp, receiver); + }, + }); - return initAndBind(VercelEdgeClient, clientOptions); + // Disable diag, to ensure this works even if called multiple times + diag.disable(); + diag.setLogger(otelLogger, DiagLogLevel.DEBUG); } /** diff --git a/packages/vercel-edge/src/types.ts b/packages/vercel-edge/src/types.ts index 7544820c75a3..26bc1b911875 100644 --- a/packages/vercel-edge/src/types.ts +++ b/packages/vercel-edge/src/types.ts @@ -33,6 +33,27 @@ export interface BaseVercelEdgeOptions { * */ clientClass?: typeof VercelEdgeClient; + /** + * If this is set to true, the SDK will not set up OpenTelemetry automatically. + * In this case, you _have_ to ensure to set it up correctly yourself, including: + * * The `SentrySpanProcessor` + * * The `SentryPropagator` + * * The `SentryContextManager` + * * The `SentrySampler` + */ + skipOpenTelemetrySetup?: boolean; + + /** + * The max. duration in seconds that the SDK will wait for parent spans to be finished before discarding a span. + * The SDK will automatically clean up spans that have no finished parent after this duration. + * This is necessary to prevent memory leaks in case of parent spans that are never finished or otherwise dropped/missing. + * However, if you have very long-running spans in your application, a shorter duration might cause spans to be discarded too early. + * In this case, you can increase this duration to a value that fits your expected data. + * + * Defaults to 300 seconds (5 minutes). + */ + maxSpanWaitDuration?: number; + /** Callback that is executed when a fatal global error occurs. */ onFatalError?(this: void, error: Error): void; } diff --git a/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts b/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts new file mode 100644 index 000000000000..883e9e43cf54 --- /dev/null +++ b/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts @@ -0,0 +1,234 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Code vendored from: https://github.com/open-telemetry/opentelemetry-js/blob/6515ed8098333646a63a74a8c0150cc2daf520db/packages/opentelemetry-context-async-hooks/src/AbstractAsyncHooksContextManager.ts + * - Modifications: + * - Added lint rules + * - Modified bind() method not to rely on Node.js specific APIs + */ + +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +/* eslint-disable @typescript-eslint/member-ordering */ +/* eslint-disable jsdoc/require-jsdoc */ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable prefer-rest-params */ +/* eslint-disable @typescript-eslint/no-dynamic-delete */ +/* eslint-disable @typescript-eslint/unbound-method */ +/* eslint-disable @typescript-eslint/no-this-alias */ + +import type { EventEmitter } from 'events'; +import type { Context, ContextManager } from '@opentelemetry/api'; + +type Func = (...args: unknown[]) => T; + +/** + * Store a map for each event of all original listeners and their "patched" + * version. So when a listener is removed by the user, the corresponding + * patched function will be also removed. + */ +interface PatchMap { + [name: string]: WeakMap, Func>; +} + +const ADD_LISTENER_METHODS = [ + 'addListener' as const, + 'on' as const, + 'once' as const, + 'prependListener' as const, + 'prependOnceListener' as const, +]; + +export abstract class AbstractAsyncHooksContextManager implements ContextManager { + abstract active(): Context; + + abstract with ReturnType>( + context: Context, + fn: F, + thisArg?: ThisParameterType, + ...args: A + ): ReturnType; + + abstract enable(): this; + + abstract disable(): this; + + /** + * Binds a the certain context or the active one to the target function and then returns the target + * @param context A context (span) to be bind to target + * @param target a function or event emitter. When target or one of its callbacks is called, + * the provided context will be used as the active context for the duration of the call. + */ + bind(context: Context, target: T): T { + if (typeof target === 'object' && target !== null && 'on' in target) { + return this._bindEventEmitter(context, target as unknown as EventEmitter) as T; + } + + if (typeof target === 'function') { + return this._bindFunction(context, target); + } + return target; + } + + private _bindFunction(context: Context, target: T): T { + const manager = this; + const contextWrapper = function (this: never, ...args: unknown[]) { + return manager.with(context, () => target.apply(this, args)); + }; + Object.defineProperty(contextWrapper, 'length', { + enumerable: false, + configurable: true, + writable: false, + value: target.length, + }); + /** + * It isn't possible to tell Typescript that contextWrapper is the same as T + * so we forced to cast as any here. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return contextWrapper as any; + } + + /** + * By default, EventEmitter call their callback with their context, which we do + * not want, instead we will bind a specific context to all callbacks that + * go through it. + * @param context the context we want to bind + * @param ee EventEmitter an instance of EventEmitter to patch + */ + private _bindEventEmitter(context: Context, ee: T): T { + const map = this._getPatchMap(ee); + if (map !== undefined) return ee; + this._createPatchMap(ee); + + // patch methods that add a listener to propagate context + ADD_LISTENER_METHODS.forEach(methodName => { + if (ee[methodName] === undefined) return; + ee[methodName] = this._patchAddListener(ee, ee[methodName], context); + }); + // patch methods that remove a listener + if (typeof ee.removeListener === 'function') { + ee.removeListener = this._patchRemoveListener(ee, ee.removeListener); + } + if (typeof ee.off === 'function') { + ee.off = this._patchRemoveListener(ee, ee.off); + } + // patch method that remove all listeners + if (typeof ee.removeAllListeners === 'function') { + ee.removeAllListeners = this._patchRemoveAllListeners(ee, ee.removeAllListeners); + } + return ee; + } + + /** + * Patch methods that remove a given listener so that we match the "patched" + * version of that listener (the one that propagate context). + * @param ee EventEmitter instance + * @param original reference to the patched method + */ + private _patchRemoveListener(ee: EventEmitter, original: Function) { + const contextManager = this; + return function (this: never, event: string, listener: Func) { + const events = contextManager._getPatchMap(ee)?.[event]; + if (events === undefined) { + return original.call(this, event, listener); + } + const patchedListener = events.get(listener); + return original.call(this, event, patchedListener || listener); + }; + } + + /** + * Patch methods that remove all listeners so we remove our + * internal references for a given event. + * @param ee EventEmitter instance + * @param original reference to the patched method + */ + private _patchRemoveAllListeners(ee: EventEmitter, original: Function) { + const contextManager = this; + return function (this: never, event: string) { + const map = contextManager._getPatchMap(ee); + if (map !== undefined) { + if (arguments.length === 0) { + contextManager._createPatchMap(ee); + } else if (map[event] !== undefined) { + delete map[event]; + } + } + return original.apply(this, arguments); + }; + } + + /** + * Patch methods on an event emitter instance that can add listeners so we + * can force them to propagate a given context. + * @param ee EventEmitter instance + * @param original reference to the patched method + * @param [context] context to propagate when calling listeners + */ + private _patchAddListener(ee: EventEmitter, original: Function, context: Context) { + const contextManager = this; + return function (this: never, event: string, listener: Func) { + /** + * This check is required to prevent double-wrapping the listener. + * The implementation for ee.once wraps the listener and calls ee.on. + * Without this check, we would wrap that wrapped listener. + * This causes an issue because ee.removeListener depends on the onceWrapper + * to properly remove the listener. If we wrap their wrapper, we break + * that detection. + */ + if (contextManager._wrapped) { + return original.call(this, event, listener); + } + let map = contextManager._getPatchMap(ee); + if (map === undefined) { + map = contextManager._createPatchMap(ee); + } + let listeners = map[event]; + if (listeners === undefined) { + listeners = new WeakMap(); + map[event] = listeners; + } + const patchedListener = contextManager.bind(context, listener); + // store a weak reference of the user listener to ours + listeners.set(listener, patchedListener); + + /** + * See comment at the start of this function for the explanation of this property. + */ + contextManager._wrapped = true; + try { + return original.call(this, event, patchedListener); + } finally { + contextManager._wrapped = false; + } + }; + } + + private _createPatchMap(ee: EventEmitter): PatchMap { + const map = Object.create(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ee as any)[this._kOtListeners] = map; + return map; + } + private _getPatchMap(ee: EventEmitter): PatchMap | undefined { + return (ee as never)[this._kOtListeners]; + } + + private readonly _kOtListeners = Symbol('OtListeners'); + private _wrapped = false; +} diff --git a/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts b/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts new file mode 100644 index 000000000000..99520a3c0362 --- /dev/null +++ b/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts @@ -0,0 +1,89 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Code vendored from: https://github.com/open-telemetry/opentelemetry-js/blob/6515ed8098333646a63a74a8c0150cc2daf520db/packages/opentelemetry-context-async-hooks/src/AbstractAsyncHooksContextManager.ts + * - Modifications: + * - Added lint rules + * - Modified import path to AbstractAsyncHooksContextManager + * - Added Sentry logging + * - Modified constructor to access AsyncLocalStorage class from global object instead of the Node.js API + */ + +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +/* eslint-disable jsdoc/require-jsdoc */ + +import type { Context } from '@opentelemetry/api'; +import { ROOT_CONTEXT } from '@opentelemetry/api'; + +import { GLOBAL_OBJ, logger } from '@sentry/utils'; +import type { AsyncLocalStorage } from 'async_hooks'; +import { DEBUG_BUILD } from '../debug-build'; +import { AbstractAsyncHooksContextManager } from './abstract-async-hooks-context-manager'; + +export class AsyncLocalStorageContextManager extends AbstractAsyncHooksContextManager { + private _asyncLocalStorage: AsyncLocalStorage; + + constructor() { + super(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + const MaybeGlobalAsyncLocalStorageConstructor = (GLOBAL_OBJ as any).AsyncLocalStorage; + + if (!MaybeGlobalAsyncLocalStorageConstructor) { + DEBUG_BUILD && + logger.warn( + "Tried to register AsyncLocalStorage async context strategy in a runtime that doesn't support AsyncLocalStorage.", + ); + + // @ts-expect-error Vendored type shenanigans + this._asyncLocalStorage = { + getStore() { + return undefined; + }, + run(_store, callback, ...args) { + return callback.apply(this, args); + }, + disable() { + // noop + }, + }; + } else { + this._asyncLocalStorage = new MaybeGlobalAsyncLocalStorageConstructor(); + } + } + + active(): Context { + return this._asyncLocalStorage.getStore() ?? ROOT_CONTEXT; + } + + with ReturnType>( + context: Context, + fn: F, + thisArg?: ThisParameterType, + ...args: A + ): ReturnType { + const cb = thisArg == null ? fn : fn.bind(thisArg); + return this._asyncLocalStorage.run(context, cb as never, ...args); + } + + enable(): this { + return this; + } + + disable(): this { + this._asyncLocalStorage.disable(); + return this; + } +} diff --git a/packages/vercel-edge/test/async.test.ts b/packages/vercel-edge/test/async.test.ts index a4423e0ca434..75c7d56803cd 100644 --- a/packages/vercel-edge/test/async.test.ts +++ b/packages/vercel-edge/test/async.test.ts @@ -1,19 +1,33 @@ import { Scope, getCurrentScope, getGlobalScope, getIsolationScope, withIsolationScope, withScope } from '@sentry/core'; +import { setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; import { GLOBAL_OBJ } from '@sentry/utils'; import { AsyncLocalStorage } from 'async_hooks'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { VercelEdgeClient } from '../src'; +import { setupOtel } from '../src/sdk'; +import { makeEdgeTransport } from '../src/transports'; + +beforeAll(() => { + (GLOBAL_OBJ as any).AsyncLocalStorage = AsyncLocalStorage; + + const client = new VercelEdgeClient({ + stackParser: () => [], + integrations: [], + transport: makeEdgeTransport, + }); -describe('withScope()', () => { - beforeEach(() => { - getIsolationScope().clear(); - getCurrentScope().clear(); - getGlobalScope().clear(); + setupOtel(client); - (GLOBAL_OBJ as any).AsyncLocalStorage = AsyncLocalStorage; - setAsyncLocalStorageAsyncContextStrategy(); - }); + setOpenTelemetryContextAsyncContextStrategy(); +}); + +beforeEach(() => { + getIsolationScope().clear(); + getCurrentScope().clear(); + getGlobalScope().clear(); +}); +describe('withScope()', () => { it('will make the passed scope the active scope within the callback', () => new Promise(done => { withScope(scope => { @@ -84,15 +98,6 @@ describe('withScope()', () => { }); describe('withIsolationScope()', () => { - beforeEach(() => { - getIsolationScope().clear(); - getCurrentScope().clear(); - getGlobalScope().clear(); - (GLOBAL_OBJ as any).AsyncLocalStorage = AsyncLocalStorage; - - setAsyncLocalStorageAsyncContextStrategy(); - }); - it('will make the passed isolation scope the active isolation scope within the callback', () => new Promise(done => { withIsolationScope(scope => { diff --git a/packages/vercel-edge/test/sdk.test.ts b/packages/vercel-edge/test/sdk.test.ts deleted file mode 100644 index b1367716c73a..000000000000 --- a/packages/vercel-edge/test/sdk.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import * as SentryCore from '@sentry/core'; -import { init } from '../src/sdk'; - -describe('init', () => { - it('initializes and returns client', () => { - const initSpy = vi.spyOn(SentryCore, 'initAndBind'); - - expect(init({})).not.toBeUndefined(); - expect(initSpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/vercel-edge/vite.config.ts b/packages/vercel-edge/vite.config.mts similarity index 100% rename from packages/vercel-edge/vite.config.ts rename to packages/vercel-edge/vite.config.mts