diff --git a/.size-limit.js b/.size-limit.js index 220fa9c349e8..112bde727e27 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -533,6 +533,27 @@ module.exports = [ return config; }, }, + // Next.js on Cloudflare Workers (ESM) - minified to match `wrangler deploy --dry-run --minify` output. An import of + // `@sentry/node` in this entry more than doubles its size. + { + name: '@sentry/nextjs/cloudflare (withSentry) - minified', + path: 'packages/nextjs/build/esm/cloudflare/index.js', + import: createImport('withSentry'), + ignore: [...builtinModules, ...nodePrefixedBuiltinModules], + gzip: false, + brotli: false, + limit: '222 KiB', + disablePlugins: ['@size-limit/webpack'], + webpack: false, + modifyEsbuildConfig: function (config) { + config.keepNames = true; + // Match wrangler's build settings + config.conditions = ['workerd', 'worker', 'browser']; + config.platform = 'browser'; + config.format = 'esm'; + return config; + }, + }, ]; function createImport(...args) { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/d1/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/d1/route.ts new file mode 100644 index 000000000000..244a4d21942b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/d1/route.ts @@ -0,0 +1,12 @@ +import { getCloudflareContext } from '@opennextjs/cloudflare'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +type Env = { DB: { prepare(query: string): { all(): Promise<{ results: unknown[] }> } } }; + +export async function GET() { + const { env } = await getCloudflareContext({ async: true }); + const { results } = await (env as unknown as Env).DB.prepare('SELECT 1 AS one').all(); + return NextResponse.json(results); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/otel-context/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/otel-context/route.ts new file mode 100644 index 000000000000..1ca661b60b63 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/otel-context/route.ts @@ -0,0 +1,22 @@ +import { context, createContextKey, trace } from '@opentelemetry/api'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const E2E_CONTEXT_KEY = createContextKey('e2e.context.key'); + +export async function GET() { + const tracer = trace.getTracer('e2e'); + + context.with(context.active().setValue(E2E_CONTEXT_KEY, 'e2e-value'), () => { + tracer.startActiveSpan('otel-context-outer', outer => { + // An explicit context, as OpenTelemetry instrumentations pass it. + const inner = tracer.startSpan('otel-context-inner', {}, context.active()); + inner.setAttribute('e2e.context.value', String(context.active().getValue(E2E_CONTEXT_KEY))); + inner.end(); + outer.end(); + }); + }); + + return NextResponse.json({ ok: true }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json index 6de585c71e76..0a5cbe178693 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev", "cf:build": "opennextjs-cloudflare build", - "cf:preview": "opennextjs-cloudflare preview", + "cf:preview": "opennextjs-cloudflare preview --var \"E2E_TEST_DSN:$E2E_TEST_DSN\"", "build": "next build", "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", "start": "pnpm cf:preview", @@ -17,6 +17,7 @@ "test:assert": "pnpm test:prod" }, "dependencies": { + "@opentelemetry/api": "^1.9.1", "@opennextjs/cloudflare": "^1.14.9", "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.edge.config.ts index d6aeeac195c8..eceebb29d45d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.edge.config.ts @@ -1,18 +1,7 @@ import * as Sentry from '@sentry/nextjs'; +// Only hands over the build release: `withSentry` from `@sentry/nextjs/cloudflare` in worker.ts creates the client and +// holds the options. Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server - tracesSampler: samplingContext => { - if (samplingContext.attributes?.['next.span_type'] === 'Middleware.execute') { - // Only keep the middleware transaction when `normalizedRequest` was available at sampling time. - // Test times out and fails when transaction is dropped - const { normalizedRequest } = samplingContext; - return Boolean(normalizedRequest?.url && normalizedRequest?.method); - } - - return 1.0; - }, - // debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.server.config.ts index f55707dea51a..1009ee9e1f39 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.server.config.ts @@ -1,15 +1,7 @@ import * as Sentry from '@sentry/nextjs'; -import { Log } from '@sentry/nextjs'; +// Only hands over the build release and adds the global Next.js event processors and the turbopack tag: `withSentry` +// from `@sentry/nextjs/cloudflare` in worker.ts creates the client and holds the options. Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server - tracesSampleRate: 1.0, - // debug: true, - integrations: [Sentry.vercelAIIntegration()], - // Verify Log type is available - beforeSendLog(log: Log) { - return log; - }, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts index cba53fa1970d..d088edd32dcb 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts @@ -18,6 +18,7 @@ test.describe('Cloudflare Runtime', () => { expect(errorEvent.contexts?.runtime).toEqual({ name: 'cloudflare', }); + expect(errorEvent.tags?.turbopack).toBe(true); // The SDK info should include cloudflare in the packages expect(errorEvent.sdk?.packages).toEqual( diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/d1-binding.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/d1-binding.test.ts new file mode 100644 index 000000000000..43c31b94084d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/d1-binding.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; + +// `withSentry` passes an instrumented `env` to the OpenNext handler, which `getCloudflareContext()` returns. +test('Should create a span for a D1 query through the `env` of `getCloudflareContext`', async ({ request }) => { + const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-cf-workers', 'GET /api/d1'); + + const response = await request.get('/api/d1'); + expect(await response.json()).toStrictEqual([{ one: 1 }]); + + const spans = await spansPromise; + const d1Span = spans.find(span => getSpanOp(span) === 'db.query')!; + + expect(spans.filter(span => getSpanOp(span) === 'db.query')).toEqual([d1Span]); + expect(d1Span.name).toBe('SELECT'); + expect(d1Span.is_segment).toBe(false); + expect(d1Span.status).toBe('ok'); + expect(d1Span.attributes['db.system.name']?.value).toBe('cloudflare-d1'); + expect(d1Span.attributes['db.query.text']?.value).toBe('SELECT ? AS one'); + expect(d1Span.attributes['sentry.origin']?.value).toBe('auto.db.cloudflare.d1'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/metrics.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/metrics.test.ts index 6569c3d21890..1ccbe00953de 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/metrics.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/metrics.test.ts @@ -1,9 +1,7 @@ import { expect, test } from '@playwright/test'; import { waitForMetric } from '@sentry-internal/test-utils'; -// Metrics are not currently supported on Cloudflare Workers -// TODO: Investigate and enable when metrics support is added for CF Workers -test.skip('Should emit metrics from server and client', async ({ request, page }) => { +test('Should emit metrics from server and client', async ({ request, page }) => { const clientCountPromise = waitForMetric('nextjs-16-cf-workers', async metric => { return metric.name === 'test.page.count'; }); @@ -92,7 +90,6 @@ test.skip('Should emit metrics from server and client', async ({ request, page } type: 'counter', value: 1, attributes: { - 'server.address': { value: expect.any(String), type: 'string' }, 'random.attribute': { value: 'Potatoes', type: 'string' }, endpoint: { value: '/metrics/route-handler', type: 'string' }, 'sentry.environment': { value: 'qa', type: 'string' }, @@ -108,7 +105,6 @@ test.skip('Should emit metrics from server and client', async ({ request, page } type: 'distribution', value: 100, attributes: { - 'server.address': { value: expect.any(String), type: 'string' }, 'random.attribute': { value: 'Patatas', type: 'string' }, endpoint: { value: '/metrics/route-handler', type: 'string' }, 'sentry.environment': { value: 'qa', type: 'string' }, @@ -124,7 +120,6 @@ test.skip('Should emit metrics from server and client', async ({ request, page } type: 'gauge', value: 200, attributes: { - 'server.address': { value: expect.any(String), type: 'string' }, 'random.attribute': { value: 'Patate', type: 'string' }, endpoint: { value: '/metrics/route-handler', type: 'string' }, 'sentry.environment': { value: 'qa', type: 'string' }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts index 78b27c8427d9..32d797f6d836 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts @@ -1,61 +1,56 @@ import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; -// TODO: Skipped until the Cloudflare Workers edge middleware setup emits middleware spans reliably. -test.skip('tracesSampler receives normalizedRequest for edge middleware', async ({ request }) => { - const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => { - return span.name === 'middleware GET' && span.is_segment; - }); - - await request.get('/api/endpoint-behind-middleware'); - - const middlewareSpan = await middlewareSpanPromise; - - expect(String(middlewareSpan.attributes['http.target']?.value)).toContain('/api/endpoint-behind-middleware'); - expect(middlewareSpan.attributes['http.request.method']?.value).toBe('GET'); -}); - -// TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime -test.skip('Should create a span for middleware', async ({ request }) => { - const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => { - return span.name === 'middleware GET' && span.is_segment; - }); +// `withSentry` from `@sentry/nextjs/cloudflare` wraps the Worker entry, so middleware runs inside the request's +// `http.server` segment and its span is a child of it rather than a segment of its own. +test('Should create a span for middleware inside the request segment', async ({ request }) => { + const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-cf-workers', 'GET /api/endpoint-behind-middleware'); const response = await request.get('/api/endpoint-behind-middleware'); expect(await response.json()).toStrictEqual({ name: 'John Doe' }); - const middlewareSpan = await middlewareSpanPromise; - + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; + const middlewareSpan = spans.find(span => span.name === 'middleware GET')!; + + expect(spans.filter(span => getSpanOp(span) === 'middleware')).toEqual([middlewareSpan]); + expect(segmentSpan.attributes['url.path']?.value).toBe('/api/endpoint-behind-middleware'); + expect(segmentSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(segmentSpan.attributes['isolation_scope.is_default']).toEqual({ value: false, type: 'boolean' }); + expect(middlewareSpan.is_segment).toBe(false); + expect(middlewareSpan.parent_span_id).toBe(segmentSpan.span_id); expect(middlewareSpan.status).toBe('ok'); expect(getSpanOp(middlewareSpan)).toBe('middleware'); - expect(middlewareSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); - - // Assert that isolation scope works properly - // expect(middlewareSpan.attributes['isolation_scope.is_default']).toEqual({ value: false, type: 'boolean' }); }); -// TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime -test.skip('Faulty middlewares', async ({ request }) => { +test('Faulty middlewares', async ({ request }) => { test.skip(isDevMode, 'Throwing crashes the dev server atm'); // https://github.com/vercel/next.js/issues/85261 - const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => { - return span.name === 'middleware GET' && span.is_segment; - }); + // The middleware throws, so the request span of `withSentry` keeps the middleware name, like the middleware segment + // of Node.js. + const spansPromise = collectStreamedSpans( + 'nextjs-16-cf-workers', + spans => + spans.some(span => span.is_segment && span.name === 'middleware GET') && + spans.some(span => !span.is_segment && span.name === 'middleware GET'), + ); request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { // Noop }); - await test.step('should record spans', async () => { - const middlewareSpan = await middlewareSpanPromise; - expect(middlewareSpan.status).toBe('error'); - expect(getSpanOp(middlewareSpan)).toBe('middleware'); - expect(middlewareSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); - }); + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; + const middlewareSpan = spans.find(span => !span.is_segment && span.name === 'middleware GET')!; + + expect(segmentSpan.status).toBe('error'); + expect(segmentSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(middlewareSpan.parent_span_id).toBe(segmentSpan.span_id); + expect(middlewareSpan.status).toBe('error'); + expect(getSpanOp(middlewareSpan)).toBe('middleware'); }); -// TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime -test.skip('Should trace outgoing fetch requests inside middleware', async ({ request }) => { +test('Should trace outgoing fetch requests inside middleware', async ({ request }) => { test.skip(isDevMode, 'The fetch requests ends up in a separate tx in dev atm'); // `http.client` span names are low cardinality under span streaming, hence `GET localhost` rather diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/nested-rsc-error.test.ts index 509dc49cf364..03f7d224e466 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/nested-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/nested-rsc-error.test.ts @@ -1,8 +1,7 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; -// TODO: Flakey on CI -test.skip('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ +test('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ page, }) => { const errorEventPromise = waitForError('nextjs-16-cf-workers', errorEvent => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/otel-context.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/otel-context.test.ts new file mode 100644 index 000000000000..5303c62a984f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/otel-context.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; + +test('Keeps OpenTelemetry context values and parents', async ({ request }) => { + const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-cf-workers', 'GET /api/otel-context'); + + const response = await request.get('/api/otel-context'); + expect(await response.json()).toStrictEqual({ ok: true }); + + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; + const outerSpan = spans.find(span => span.name === 'otel-context-outer')!; + const innerSpan = spans.find(span => span.name === 'otel-context-inner')!; + + expect(spans.some(span => span.span_id === outerSpan.parent_span_id)).toBe(true); + expect(innerSpan.parent_span_id).toBe(outerSpan.span_id); + expect(outerSpan.attributes['sentry.segment.id']?.value).toBe(segmentSpan.span_id); + expect(innerSpan.attributes['e2e.context.value']?.value).toBe('e2e-value'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/pageload-tracing.test.ts index 19879c4e6853..d6165244752e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/pageload-tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/pageload-tracing.test.ts @@ -1,8 +1,7 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -// TODO: Flakey on CI -test.skip('App router spans should be attached to the pageload request span', async ({ page }) => { +test('App router spans should be attached to the pageload request span', async ({ page }) => { const serverSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => { return span.name === 'GET /pageload-tracing' && span.is_segment; }); @@ -19,8 +18,7 @@ test.skip('App router spans should be attached to the pageload request span', as expect(serverSpan.trace_id).toBe(pageloadSpan.trace_id); }); -// TODO: HTTP request headers are not extracted as span attributes on Cloudflare Workers -test.skip('extracts HTTP request headers as span attributes', async ({ baseURL }) => { +test('extracts HTTP request headers as span attributes', async ({ baseURL }) => { const serverSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => { const requestId = span.attributes['http.request.header.x-request-id']; return ( diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/route-handler.test.ts index 7623d1c29e31..f8a8420f3694 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/route-handler.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/route-handler.test.ts @@ -1,7 +1,7 @@ import test, { expect } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test.skip('Should create a span for node route handlers', async ({ request }) => { +test('Should create a span for node route handlers', async ({ request }) => { const routehandlerSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => { return span.name === 'GET /route-handler/[xoxo]/node' && span.is_segment; }); @@ -13,15 +13,11 @@ test.skip('Should create a span for node route handlers', async ({ request }) => expect(routehandlerSpan.status).toBe('ok'); expect(getSpanOp(routehandlerSpan)).toBe('http.server'); - - // Custom headers are not captured on Cloudflare Workers - // This assertion is skipped for CF Workers environment + expect(routehandlerSpan.attributes['http.request.header.x-charly']?.value).toEqual(['gomez']); }); test('Should create a span for edge route handlers', async ({ request }) => { - // This test only works for webpack builds on non-async param extraction - // todo: check if we can set request headers for edge on sdkProcessingMetadata - test.skip(); + test.skip(true, 'OpenNext does not support the Edge runtime'); const routehandlerSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => { return span.name === 'GET /route-handler/[xoxo]/edge' && span.is_segment; }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-action-redirect.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-action-redirect.test.ts index 7d7f75e5eb7b..a5961ae41274 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-action-redirect.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-action-redirect.test.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test.skip('Should handle server action redirect without capturing errors', async ({ page }) => { +test('Should handle server action redirect without capturing errors', async ({ page, request }) => { // Wait for the initial pageload span const pageLoadSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => { return span.name === '/redirect/origin' && span.is_segment; @@ -18,10 +18,9 @@ test.skip('Should handle server action redirect without capturing errors', async return span.name === 'GET /redirect/destination' && span.is_segment; }); - // No error should be captured - const redirectErrorPromise = waitForError('nextjs-16-cf-workers', async errorEvent => { - return !!errorEvent; - }); + // The error of `/api/test-error`, requested after the redirect, is the sentinel: an error of the redirect would + // arrive before it. + const firstErrorPromise = waitForError('nextjs-16-cf-workers', () => true); // Click the redirect button await page.click('button[type="submit"]'); @@ -31,17 +30,8 @@ test.skip('Should handle server action redirect without capturing errors', async // Verify we got redirected to the destination page await expect(page).toHaveURL('/redirect/destination'); - // Wait for potential errors with a 2 second timeout - const errorTimeout = new Promise((_, reject) => - setTimeout(() => reject(new Error('No error captured (timeout)')), 2000), - ); - - // We expect this to timeout since no error should be captured during the redirect - try { - await Promise.race([redirectErrorPromise, errorTimeout]); - throw new Error('Expected no error to be captured, but an error was found'); - } catch (e) { - // If we get a timeout error (as expected), no error was captured - expect((e as Error).message).toBe('No error captured (timeout)'); - } + await request.get('/api/test-error'); + + const firstError = await firstErrorPromise; + expect(firstError.exception?.values?.[0]?.value).toBe('This is a test error from an API route'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts index 7a34986062dc..344a62127543 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts @@ -5,13 +5,14 @@ import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/te // them, so asserting `undefined` pins that they stay untouched. const nextjsSpan = { op: undefined, description: undefined, codeFunctionName: undefined }; -// TODO: Server component tests need SDK adjustments for Cloudflare Workers -test.skip('Sends a span for a request to app router with URL', async ({ page }) => { +// `withSentry` from `@sentry/nextjs/cloudflare` wraps the Worker entry, so the segment is its `http.server` +// span, named after the route Next.js resolved. +test('Sends a span for a request to app router with URL', async ({ page }) => { const spansPromise = collectStreamedSpansUntilSegment( 'nextjs-16-cf-workers', span => span.name === 'GET /parameterized/[one]/beep/[two]' && - String(span.attributes['http.target']?.value).startsWith('/parameterized/1337/beep/42'), + span.attributes['url.path']?.value === '/parameterized/1337/beep/42', ); await page.goto('/parameterized/1337/beep/42'); @@ -21,7 +22,7 @@ test.skip('Sends a span for a request to app router with URL', async ({ page }) span => span.name === 'GET /parameterized/[one]/beep/[two]' && span.is_segment && - String(span.attributes['http.target']?.value).startsWith('/parameterized/1337/beep/42'), + span.attributes['url.path']?.value === '/parameterized/1337/beep/42', )!; expect(segmentSpan.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); @@ -29,24 +30,22 @@ test.skip('Sends a span for a request to app router with URL', async ({ page }) expect(segmentSpan.status).toBe('ok'); expect(segmentSpan.attributes).toMatchObject({ 'sentry.op': { value: 'http.server', type: 'string' }, - 'sentry.origin': { value: 'auto', type: 'string' }, + 'sentry.origin': { value: 'auto.http.cloudflare', type: 'string' }, 'sentry.sample_rate': { value: 1, type: 'integer' }, 'sentry.segment.name.source': { value: 'route', type: 'string' }, - 'http.method': { value: 'GET', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, 'http.response.status_code': { value: 200, type: 'integer' }, 'http.route': { value: '/parameterized/[one]/beep/[two]', type: 'string' }, - 'http.status_code': { value: 200, type: 'integer' }, - 'http.target': { value: '/parameterized/1337/beep/42', type: 'string' }, - 'sentry.kind': { value: 'server', type: 'string' }, + 'url.path': { value: '/parameterized/1337/beep/42', type: 'string' }, 'next.route': { value: '/parameterized/[one]/beep/[two]', type: 'string' }, }); // No child span should share the segment span's name expect(spans.filter(span => !span.is_segment && span.name === segmentSpan.name)).toHaveLength(0); + expect(spans.filter(span => getSpanOp(span) === 'http.server')).toEqual([segmentSpan]); }); -// TODO: Server component span tests need SDK adjustments for Cloudflare Workers -test.skip('Will create spans for every server component and metadata generation functions when visiting a page', async ({ +test('Will create spans for every server component and metadata generation functions when visiting a page', async ({ page, }) => { const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-cf-workers', 'GET /nested-layout'); @@ -93,8 +92,7 @@ test.skip('Will create spans for every server component and metadata generation expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' }); }); -// TODO: Server component span tests need SDK adjustments for Cloudflare Workers -test.skip('Will create spans for every server component and metadata generation functions when visiting a dynamic page', async ({ +test('Will create spans for every server component and metadata generation functions when visiting a dynamic page', async ({ page, }) => { const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-cf-workers', 'GET /nested-layout/[dynamic]'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts index 296d485e8644..1b991f1a9fe4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts @@ -1,10 +1,7 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; -// TODO: Under span streaming the Workers runtime emits no server segment span for a request -// interrupted by a streaming RSC error, so the correlation below cannot be satisfied. -// See https://github.com/getsentry/sentry-javascript/issues/23932 -test.skip('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ +test('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ page, }) => { const errorEventPromise = waitForError('nextjs-16-cf-workers', errorEvent => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/trace-continuation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/trace-continuation.test.ts new file mode 100644 index 000000000000..447aa7df0cb6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/trace-continuation.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; + +test('Continues an incoming trace in the segment of the Worker request', async ({ request }) => { + const traceId = crypto.randomUUID().replace(/-/g, ''); + const parentSpanId = traceId.slice(0, 16); + + const spansPromise = collectStreamedSpans( + 'nextjs-16-cf-workers', + spans => + spans.some( + span => + span.trace_id === traceId && + span.is_segment && + span.attributes['sentry.origin']?.value === 'auto.http.cloudflare', + ) && + spans.some( + span => span.trace_id === traceId && span.name === 'render route (app) /parameterized/[one]/beep/[two]', + ), + ); + + await request.get('/parameterized/1337/beep/42', { + headers: { + 'sentry-trace': `${traceId}-${parentSpanId}-1`, + baggage: `sentry-trace_id=${traceId},sentry-sample_rate=1,sentry-sampled=true`, + }, + }); + + const spans = await spansPromise; + const segmentSpan = spans.find( + span => span.is_segment && span.attributes['sentry.origin']?.value === 'auto.http.cloudflare', + )!; + const renderSpan = spans.find(span => span.name === 'render route (app) /parameterized/[one]/beep/[two]')!; + + expect(segmentSpan.parent_span_id).toBe(parentSpanId); + expect(segmentSpan.name).toBe('GET /parameterized/[one]/beep/[two]'); + expect(renderSpan.attributes['sentry.segment.id']?.value).toBe(segmentSpan.span_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/worker.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/worker.ts new file mode 100644 index 000000000000..6718e015a32c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/worker.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/nextjs/cloudflare'; +// @ts-expect-error generated by `opennextjs-cloudflare build` +import handler from './.open-next/worker.js'; + +// @ts-expect-error generated by `opennextjs-cloudflare build` +export { DOQueueHandler, DOShardedTagCache, BucketCachePurge } from './.open-next/worker.js'; + +export default Sentry.withSentry( + (env: { E2E_TEST_DSN: string }) => ({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + integrations: [Sentry.vercelAIIntegration()], + }), + handler, +); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/wrangler.jsonc index 4bf131c387e9..16b0271383b1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/wrangler.jsonc +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/wrangler.jsonc @@ -9,8 +9,10 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "next-cf", - "main": ".open-next/worker.js", - "compatibility_date": "2025-12-01", + "main": "./worker.ts", + // From 2026-02-19 on, wrangler uses the native `process` of workerd, whose `cwd()` is `/bundle` during a request. + // Next.js then misses its router server context and extracts the incoming trace again, see trace-continuation.test.ts. + "compatibility_date": "2026-02-19", "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], "assets": { "binding": "ASSETS", @@ -29,6 +31,13 @@ "service": "next-cf", }, ], + "d1_databases": [ + { + "binding": "DB", + "database_name": "e2e", + "database_id": "00000000-0000-0000-0000-000000000000", + }, + ], "observability": { "enabled": true, }, diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 1f2d91d9a76f..e99bbf4b6dfb 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -46,6 +46,11 @@ "node": "./build/cjs/index.server.js", "import": "./build/esm/index.server.js" }, + "./cloudflare": { + "types": "./build/types/cloudflare/index.d.ts", + "import": "./build/esm/cloudflare/index.js", + "require": "./build/cjs/cloudflare/index.js" + }, "./config": { "types": "./build/types/config/index.d.ts", "default": "./build/cjs/config/index.js" @@ -76,6 +81,7 @@ "@rollup/plugin-commonjs": "28.0.1", "@sentry/browser-utils": "11.4.0", "@sentry/bundler-plugins": "11.4.0", + "@sentry/cloudflare": "11.4.0", "@sentry/conventions": "^0.26.0", "@sentry/core": "11.4.0", "@sentry/node": "11.4.0", diff --git a/packages/nextjs/rollup.npm.config.mjs b/packages/nextjs/rollup.npm.config.mjs index 0b5edc3fa0e3..bef1bd0f92d3 100644 --- a/packages/nextjs/rollup.npm.config.mjs +++ b/packages/nextjs/rollup.npm.config.mjs @@ -11,6 +11,7 @@ export default [ 'src/client/index.ts', 'src/server/index.ts', 'src/edge/index.ts', + 'src/cloudflare/index.ts', 'src/config/index.ts', ], diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts new file mode 100644 index 000000000000..c97b7298a143 --- /dev/null +++ b/packages/nextjs/src/cloudflare/index.ts @@ -0,0 +1,96 @@ +/* eslint-disable import/export */ +import type { Context, TextMapGetter } from '@opentelemetry/api'; +import { context, propagation, trace } from '@opentelemetry/api'; +import type { CloudflareOptions } from '@sentry/cloudflare'; +import { withSentry as withSentryCloudflare } from '@sentry/cloudflare'; +import type { Integration } from '@sentry/core'; +import { applySdkMetadata, extractTraceparentData, getRootSpan, GLOBAL_OBJ, spanToJSON } from '@sentry/core'; +import { + registerPrepareSpanScope, + SentryPropagator, + setOpenTelemetryContextAsyncContextStrategy, +} from '@sentry/opentelemetry'; +import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; +import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'; +import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; +import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd'; +import { addNextjsServerSpanHooks, NEXTJS_SERVER_IGNORE_SPANS } from '../server/serverSpanHooks'; +import { nextjsUseCacheIntegration } from '../server/useCacheInstrumentation'; + +export * from '@sentry/cloudflare'; + +class NextjsCloudflarePropagator extends SentryPropagator { + /** @inheritDoc */ + public extract(ctx: Context, carrier: unknown, getter: TextMapGetter): Context { + // Next.js extracts the request headers again from the root context when it misses its router server context, e.g. + // on Workers where `process.cwd()` is `/bundle`. When the root span of `withSentry` already continued that trace, + // this would start the Next.js request span as a second segment under the upstream span. + const activeSpan = trace.getSpan(context.active()); + const sentryTrace = getter.get(carrier, 'sentry-trace'); + const incoming = extractTraceparentData(Array.isArray(sentryTrace) ? sentryTrace[0] : sentryTrace); + if ( + activeSpan && + incoming?.parentSpanId && + incoming.parentSpanId === spanToJSON(getRootSpan(activeSpan)).parent_span_id + ) { + return ctx; + } + return super.extract(ctx, carrier, getter); + } +} + +const nextjsIntegration = (): Integration => ({ + name: 'Nextjs', + setup: client => { + if ((client.getOptions() as CloudflareOptions).enableOpenTelemetrySetup) { + propagation.setGlobalPropagator(new NextjsCloudflarePropagator()); + } + client.getOptions().release ??= (GLOBAL_OBJ as { _sentryRelease?: string })._sentryRelease; + addNextjsServerSpanHooks(client); + client.addEventProcessor(dropReactControlFlowErrorsEventProcessor); + registerPrepareSpanScope(client); + // `dropMiddlewareTunnelRequests` only marks spans of Next.js, which are children of the root span of `withSentry`. + client.on('beforeSampling', samplingData => { + if (isTunnelRouteSpan(samplingData.spanAttributes)) { + samplingData.parentSampled = false; + } + }); + }, +}); + +/** + * `withSentry` of `@sentry/cloudflare` for the Worker entry of a Next.js app, e.g. `.open-next/worker.js`. It adds the + * span handling of `@sentry/nextjs` and the OpenTelemetry context of Next.js. Call it at module scope of the Worker entry. + */ +export const withSentry: typeof withSentryCloudflare = (optionsCallback, handler) => { + setOpenTelemetryContextAsyncContextStrategy(); + markAsyncContextOwnedByNextjsCloudflare(); + + return withSentryCloudflare(env => { + const options: CloudflareOptions = { enableOpenTelemetrySetup: true, ...optionsCallback(env) }; + options.ignoreSpans = [ + ...(options.ignoreSpans || []), + ...NEXTJS_SERVER_IGNORE_SPANS, + // The request spans of Next.js repeat the request span of `withSentry`. An ignored span does not become active, + // so the other Next.js spans become children of the request span of `withSentry`, which then gets the route + // from their `next.route` and its status from the response. + { attributes: { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' } }, + ]; + const { tracesSampler } = options; + if (tracesSampler) { + // A `tracesSampler` can ignore the `parentSampled: false` that the Next.js integration sets for tunnel requests. + options.tracesSampler = samplingContext => + !isTunnelRouteSpan(samplingContext.attributes || {}) && tracesSampler(samplingContext); + } + const userIntegrations = options.integrations; + // A function, so the integrations are only created with a new client and not on every request. + options.integrations = defaults => { + const integrations = [...defaults, nextjsIntegration(), nextjsUseCacheIntegration()]; + return typeof userIntegrations === 'function' + ? userIntegrations(integrations) + : [...integrations, ...(userIntegrations || [])]; + }; + applySdkMetadata(options, 'nextjs', ['nextjs', 'cloudflare']); + return options as ReturnType; + }, handler); +}; diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index 6cb11040038f..31064377e4ba 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -62,7 +62,7 @@ function isSentryRequestSpan(attrs: RawAttributes>): boo /** * Checks if a span's HTTP target matches the tunnel route. */ -function isTunnelRouteSpan(spanAttributes: Record): boolean { +export function isTunnelRouteSpan(spanAttributes: Record): boolean { const tunnelPath = globalWithInjectedValues._sentryRewritesTunnelPath || process.env._sentryRewritesTunnelPath; if (!tunnelPath) { return false; diff --git a/packages/nextjs/src/common/utils/dropReactControlFlowErrors.ts b/packages/nextjs/src/common/utils/dropReactControlFlowErrors.ts new file mode 100644 index 000000000000..032ae1714bd2 --- /dev/null +++ b/packages/nextjs/src/common/utils/dropReactControlFlowErrors.ts @@ -0,0 +1,43 @@ +import type { EventProcessor } from '@sentry/core'; +import { isPrerenderControlFlowError } from '../nextNavigationErrorUtils'; + +/** Drops the errors that React and Next.js throw for control flow: postpones, prerender aborts and Suspense exceptions. */ +export const dropReactControlFlowErrorsEventProcessor: EventProcessor = Object.assign( + ((event, hint) => { + if (event.type !== undefined) { + return event; + } + + const originalException = hint.originalException; + + const isPostponeError = + typeof originalException === 'object' && + originalException !== null && + '$$typeof' in originalException && + originalException.$$typeof === Symbol.for('react.postpone'); + + if (isPostponeError) { + // Postpone errors are used for partial-pre-rendering (PPR) + return null; + } + + if (isPrerenderControlFlowError(originalException)) { + // Next.js aborts prerenders by rejecting the promises it handed out (e.g. `fetch()` under Cache + // Components) and throws to bail out of static rendering. These never reach the user, so drop them + // here as well - the wrappers cannot cover every path they escape through. + return null; + } + + // We don't want to capture suspense errors as they are simply used by React/Next.js for control flow + const exceptionMessage = event.exception?.values?.[0]?.value; + if ( + exceptionMessage?.includes('Suspense Exception: This is not a real error!') || + exceptionMessage?.includes('Suspense Exception: This is not a real error, and should not leak') + ) { + return null; + } + + return event; + }) satisfies EventProcessor, + { id: 'DropReactControlFlowErrors' }, +); diff --git a/packages/nextjs/src/common/utils/responseEnd.ts b/packages/nextjs/src/common/utils/responseEnd.ts index 4ec79d9cabdf..52d07f46cae6 100644 --- a/packages/nextjs/src/common/utils/responseEnd.ts +++ b/packages/nextjs/src/common/utils/responseEnd.ts @@ -1,5 +1,14 @@ -import type { Span } from '@sentry/core'; -import { debug, fill, flush, getAsyncContextStrategy, getMainCarrier, GLOBAL_OBJ, setHttpStatus } from '@sentry/core'; +import type { AsyncContextStrategy, Span } from '@sentry/core'; +import { + debug, + fill, + flush, + getAsyncContextStrategy, + getClient, + getMainCarrier, + GLOBAL_OBJ, + setHttpStatus, +} from '@sentry/core'; import { vercelWaitUntil } from '@sentry/core/server'; import type { ServerResponse } from 'http'; import { DEBUG_BUILD } from '../debug-build'; @@ -106,9 +115,29 @@ export function isCloudflareWaitUntilAvailable(): boolean { return typeof _getOpenNextCloudflareContext()?.waitUntil === 'function'; } +type MarkedAsyncContextStrategy = AsyncContextStrategy & { _sentryNextjsCloudflare?: boolean }; + +/** + * Marks the async context strategy that `withSentry` of `@sentry/nextjs/cloudflare` installed. The mark is on the + * strategy in the global carrier, because the Worker entry and the server build of Next.js each bundle their own copy + * of `@sentry/nextjs`. + */ +export function markAsyncContextOwnedByNextjsCloudflare(): void { + (getAsyncContextStrategy(getMainCarrier()) as MarkedAsyncContextStrategy)._sentryNextjsCloudflare = true; +} + +/** + * Whether `withSentry` of `@sentry/nextjs/cloudflare` set up the async context of this Worker, also outside a request. + */ +export function isAsyncContextOwnedByNextjsCloudflare(): boolean { + const strategy: MarkedAsyncContextStrategy = getAsyncContextStrategy(getMainCarrier()); + return !!strategy._sentryNextjsCloudflare; +} + /** - * Whether a request of `withSentry` from `@sentry/cloudflare` runs. A client of `init` would then replace its async - * context strategy while the request runs (#24603). + * Whether `withSentry` of `@sentry/nextjs/cloudflare` set up the async context of this Worker, or a request of + * `withSentry` from `@sentry/cloudflare` runs. A client of `init` would then replace its async context strategy while + * a request runs (#24603). */ export function isAsyncContextOwnedByCloudflare(): boolean { const strategy = getAsyncContextStrategy(getMainCarrier()); @@ -117,8 +146,21 @@ export function isAsyncContextOwnedByCloudflare(): boolean { | { getStore(): unknown } | undefined; return ( - !strategy.withActiveSpan && - asyncLocalStorage?.getStore() !== undefined && - GLOBAL_OBJ.navigator?.userAgent === 'Cloudflare-Workers' + isAsyncContextOwnedByNextjsCloudflare() || + (!strategy.withActiveSpan && + asyncLocalStorage?.getStore() !== undefined && + GLOBAL_OBJ.navigator?.userAgent === 'Cloudflare-Workers') ); } + +/** + * Sets the build release of `withSentryConfig` on the current client if it has none, and stores it for the clients + * that `withSentry` of `@sentry/nextjs/cloudflare` creates later. Only code that Next.js compiles can read it. + */ +export function setCloudflareWorkerRelease(release: string | undefined): void { + (GLOBAL_OBJ as { _sentryRelease?: string })._sentryRelease ??= release; + const options = getClient()?.getOptions(); + if (options && !options.release) { + options.release = release; + } +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index e582927fd6e8..5e8fd6d3826b 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -33,6 +33,7 @@ import { flushSafelyWithTimeout, isAsyncContextOwnedByCloudflare, isCloudflareWaitUntilAvailable, + setCloudflareWorkerRelease, waitUntil, } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; @@ -124,9 +125,10 @@ export function init(options: VercelEdgeOptions = {}): void { } if (isAsyncContextOwnedByCloudflare()) { + setCloudflareWorkerRelease(opts.release); DEBUG_BUILD && debug.log( - 'The client of `withSentry` handles this Worker, so `init` creates no client. Set the options in `withSentry`.', + 'The client of `withSentry` handles this Worker, so `init` creates no client and only applies `release`. Set the other options in `withSentry`.', ); return; } diff --git a/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts b/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts index 49551290316c..85f134515e82 100644 --- a/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts +++ b/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts @@ -4,6 +4,7 @@ import { HTTP_ROUTE, HTTP_TARGET, SENTRY_OP, + SENTRY_ORIGIN, SENTRY_SEGMENT_NAME_SOURCE, URL_PATH, } from '@sentry/conventions/attributes'; @@ -34,7 +35,11 @@ export interface MutableRootSpan { export function enhanceHandleRequestRootSpan(span: MutableRootSpan): void { const { attributes } = span; - if (attributes[ATTR_NEXT_SPAN_TYPE] !== 'BaseServer.handleRequest') { + // On Cloudflare Workers, the `http.server` span of `withSentry` from `@sentry/cloudflare` is the request root span. + if ( + attributes[ATTR_NEXT_SPAN_TYPE] !== 'BaseServer.handleRequest' && + attributes[SENTRY_ORIGIN] !== 'auto.http.cloudflare' + ) { return; } diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 3b5ac2d0bd72..856be94c7363 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -1,7 +1,7 @@ // import/export got a false positive, and affects most of our index barrel files // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ -import type { EventProcessor, Scope } from '@sentry/core'; +import type { Scope } from '@sentry/core'; import { _INTERNAL_getActiveClient, applySdkMetadata, @@ -14,9 +14,14 @@ import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; -import { isPrerenderControlFlowError } from '../common/nextNavigationErrorUtils'; +import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { isBuild } from '../common/utils/isBuild'; -import { isAsyncContextOwnedByCloudflare, isCloudflareWaitUntilAvailable } from '../common/utils/responseEnd'; +import { + isAsyncContextOwnedByCloudflare, + isAsyncContextOwnedByNextjsCloudflare, + isCloudflareWaitUntilAvailable, + setCloudflareWorkerRelease, +} from '../common/utils/responseEnd'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; import { addNextjsServerSpanHooks, NEXTJS_SERVER_IGNORE_SPANS } from './serverSpanHooks'; import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; @@ -173,6 +178,8 @@ export function init(options: NodeOptions): NodeClient | undefined { if (client) { addNextjsServerSpanHooks(client); + } else if (isOwnedByCloudflare) { + setCloudflareWorkerRelease(opts.release); } // On the client, not the global scope, so a later `init()` after @@ -187,47 +194,10 @@ export function init(options: NodeOptions): NodeClient | undefined { .eventProcessors.some(processor => processor.id === 'DropReactControlFlowErrors'); eventProcessorTarget = isAdded ? undefined : globalScope; } - eventProcessorTarget?.addEventProcessor( - Object.assign( - ((event, hint) => { - if (event.type !== undefined) { - return event; - } - - const originalException = hint.originalException; - - const isPostponeError = - typeof originalException === 'object' && - originalException !== null && - '$$typeof' in originalException && - originalException.$$typeof === Symbol.for('react.postpone'); - - if (isPostponeError) { - // Postpone errors are used for partial-pre-rendering (PPR) - return null; - } - - if (isPrerenderControlFlowError(originalException)) { - // Next.js aborts prerenders by rejecting the promises it handed out (e.g. `fetch()` under Cache - // Components) and throws to bail out of static rendering. These never reach the user, so drop them - // here as well - the wrappers cannot cover every path they escape through. - return null; - } - - // We don't want to capture suspense errors as they are simply used by React/Next.js for control flow - const exceptionMessage = event.exception?.values?.[0]?.value; - if ( - exceptionMessage?.includes('Suspense Exception: This is not a real error!') || - exceptionMessage?.includes('Suspense Exception: This is not a real error, and should not leak') - ) { - return null; - } - - return event; - }) satisfies EventProcessor, - { id: 'DropReactControlFlowErrors' }, - ), - ); + // Each client of `withSentry` from `@sentry/nextjs/cloudflare` gets the processor from its `Nextjs` integration. + if (!isAsyncContextOwnedByNextjsCloudflare()) { + eventProcessorTarget?.addEventProcessor(dropReactControlFlowErrorsEventProcessor); + } if (process.env.NODE_ENV === 'development') { eventProcessorTarget?.addEventProcessor(devErrorSymbolicationEventProcessor); @@ -247,7 +217,7 @@ export function init(options: NodeOptions): NodeClient | undefined { if (isOwnedByCloudflare) { DEBUG_BUILD && debug.log( - 'The client of `withSentry` handles this Worker, so `init` creates no client. Set the options in `withSentry`.', + 'The client of `withSentry` handles this Worker, so `init` creates no client and only applies `release`. Set the other options in `withSentry`.', ); } else { DEBUG_BUILD && debug.log('SDK successfully initialized'); diff --git a/packages/nextjs/src/server/serverSpanHooks.ts b/packages/nextjs/src/server/serverSpanHooks.ts index fd6eb7fb2154..3d21bb094eae 100644 --- a/packages/nextjs/src/server/serverSpanHooks.ts +++ b/packages/nextjs/src/server/serverSpanHooks.ts @@ -4,9 +4,11 @@ import { HTTP_REQUEST_METHOD, HTTP_ROUTE, HTTP_TARGET, + SENTRY_OP, SENTRY_ORIGIN, URL_QUERY, } from '@sentry/conventions/attributes'; +import { MIDDLEWARE } from '@sentry/conventions/op'; import type { Client, Options, Span } from '@sentry/core'; import { getIsolationScope, @@ -33,6 +35,8 @@ import { maybeEnrichQueueProducerSpan, } from './vercelQueuesMonitoring'; +const NEXTJS_ERROR_PAGE_ROUTES = ['/500', '/_error']; + export const NEXTJS_SERVER_IGNORE_SPANS: NonNullable = [ // Static assets (matches `_next/static` anywhere in the name to handle custom basePath) /^GET (\/.*)?\/_next\/static\//, @@ -97,6 +101,82 @@ export function addNextjsServerSpanHooks(client: Client): void { }); } +type SpanAttributes = ReturnType['attributes']; + +/** + * Hoists the parameterized route that a Next.js span carries in `next.route` up to the root span, as its name and + * `http.route`. + */ +function maybeHoistRouteToRootSpan( + spanAttributes: SpanAttributes, + rootSpan: Span, + rootSpanAttributes: SpanAttributes, +): void { + if (typeof spanAttributes?.[ATTR_NEXT_ROUTE] !== 'string') { + return; + } + + const route = spanAttributes[ATTR_NEXT_ROUTE].replace(/\/route$/, ''); + // When middleware throws, Next.js renders its error page. A root span that the middleware named keeps that name, as + // on Node.js. A 404 page still names the root span. + const isErrorPageBehindMiddleware = + NEXTJS_ERROR_PAGE_ROUTES.includes(route) && rootSpanAttributes?.[SENTRY_SEGMENT_NAME_SOURCE] === 'route'; + // eslint-disable-next-line typescript/no-deprecated + const method = rootSpanAttributes?.[HTTP_REQUEST_METHOD] || rootSpanAttributes?.[HTTP_METHOD]; + + // Only hoist the http.route attribute if the transaction doesn't already have it + if (!method || rootSpanAttributes?.[HTTP_ROUTE] || isErrorPageBehindMiddleware) { + return; + } + + const name = typeof method === 'string' ? `${method} ${route}` : route; + rootSpan.updateName(name); + rootSpan.setAttributes({ + [HTTP_ROUTE]: route, + // Preserving the original attribute despite internally not depending on it + [ATTR_NEXT_ROUTE]: route, + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + }); + + // Update the isolation scope's transaction name so that non-transaction events + // (e.g. captureMessage, captureException) also get the parameterized route. + getIsolationScope().setTransactionName(name); + + // Check if this is a Vercel cron request and start a check-in + maybeStartCronCheckIn(rootSpan, route); + + // Enrich queue consumer spans (Vercel Queue push delivery via CloudEvent) + maybeEnrichQueueConsumerSpan(rootSpan); +} + +/** Names the root span after the middleware. When Next.js did not start the root span, also sets the op of the middleware span. */ +function handleMiddlewareSpanStart( + span: Span, + spanAttributes: SpanAttributes, + rootSpan: Span, + rootSpanAttributes: SpanAttributes, +): void { + const middlewareName = spanAttributes?.[ATTR_NEXT_SPAN_NAME]; + if (rootSpanAttributes?.[ATTR_NEXT_SPAN_TYPE] === undefined) { + // The middleware runs inside a request span that Next.js did not start, e.g. the one of `withSentry` from + // `@sentry/cloudflare`. That span gets the middleware name until a route span hoists its route, so a request + // that the middleware answers is named like the middleware segment on Node.js. + span.setAttribute(SENTRY_OP, MIDDLEWARE); + if (typeof middlewareName === 'string' && !rootSpanAttributes?.[HTTP_ROUTE]) { + rootSpan.updateName(middlewareName); + rootSpan.setAttribute(SENTRY_SEGMENT_NAME_SOURCE, 'route'); + } + } else if (typeof middlewareName === 'string') { + rootSpan.updateName(middlewareName); + rootSpan.setAttributes({ + [HTTP_ROUTE]: middlewareName, + [ATTR_NEXT_SPAN_NAME]: middlewareName, + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + }); + } + span.setAttribute(SENTRY_ORIGIN, 'auto'); +} + /** * Handles the on span start event for Next.js spans. * This function is used to enhance the span with additional information such as the route, the method, the headers, etc. @@ -127,51 +207,10 @@ export function handleOnSpanStart(span: Span, client: Client): void { ); } - // 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 (typeof spanAttributes?.[ATTR_NEXT_ROUTE] === 'string') { - // Only hoist the http.route attribute if the transaction doesn't already have it - if ( - // eslint-disable-next-line typescript/no-deprecated - (rootSpanAttributes?.[HTTP_REQUEST_METHOD] || rootSpanAttributes?.[HTTP_METHOD]) && - !rootSpanAttributes?.[HTTP_ROUTE] - ) { - const route = spanAttributes[ATTR_NEXT_ROUTE].replace(/\/route$/, ''); - // eslint-disable-next-line typescript/no-deprecated - const method = rootSpanAttributes?.[HTTP_REQUEST_METHOD] || rootSpanAttributes?.[HTTP_METHOD]; - - const name = typeof method === 'string' ? `${method} ${route}` : route; - rootSpan.updateName(name); - rootSpan.setAttributes({ - [HTTP_ROUTE]: route, - // Preserving the original attribute despite internally not depending on it - [ATTR_NEXT_ROUTE]: route, - [SENTRY_SEGMENT_NAME_SOURCE]: 'route', - }); - - // Update the isolation scope's transaction name so that non-transaction events - // (e.g. captureMessage, captureException) also get the parameterized route. - getIsolationScope().setTransactionName(name); - - // Check if this is a Vercel cron request and start a check-in - maybeStartCronCheckIn(rootSpan, route); - - // Enrich queue consumer spans (Vercel Queue push delivery via CloudEvent) - maybeEnrichQueueConsumerSpan(rootSpan); - } - } + maybeHoistRouteToRootSpan(spanAttributes, rootSpan, rootSpanAttributes); if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute') { - const middlewareName = spanAttributes[ATTR_NEXT_SPAN_NAME]; - if (typeof middlewareName === 'string') { - rootSpan.updateName(middlewareName); - rootSpan.setAttributes({ - [HTTP_ROUTE]: middlewareName, - [ATTR_NEXT_SPAN_NAME]: middlewareName, - [SENTRY_SEGMENT_NAME_SOURCE]: 'route', - }); - } - span.setAttribute(SENTRY_ORIGIN, 'auto'); + handleMiddlewareSpanStart(span, spanAttributes, rootSpan, rootSpanAttributes); } // We want to skip span data inference for any spans generated by Next.js. Reason being that Next.js emits spans diff --git a/packages/nextjs/test/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts new file mode 100644 index 000000000000..ff3395f62b4e --- /dev/null +++ b/packages/nextjs/test/cloudflare/withSentry.test.ts @@ -0,0 +1,567 @@ +import { context, createContextKey, propagation, ROOT_CONTEXT, trace } from '@opentelemetry/api'; +import { HTTP_ROUTE, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import type { Client, Span } from '@sentry/core'; +import { + getActiveSpan, + getAsyncContextStrategy, + getClient, + getGlobalScope, + getMainCarrier, + getRootSpan, + GLOBAL_OBJ, + SDK_VERSION, + spanIsSampled, + spanToJSON, + startSpan, +} from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { withSentry } from '../../src/cloudflare'; +import { ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; +import { init as initEdge } from '../../src/edge'; +import { init as initServer } from '../../src/server'; +import { NEXTJS_SERVER_IGNORE_SPANS } from '../../src/server/serverSpanHooks'; + +const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + +describe('withSentry', () => { + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { __SENTRY_CLOUDFLARE_CLIENT__?: unknown }).__SENTRY_CLOUDFLARE_CLIENT__ = + undefined; + delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath; + delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRelease?: string })._sentryRelease; + vi.restoreAllMocks(); + trace.disable(); + context.disable(); + propagation.disable(); + }); + + it('creates the Cloudflare client with the Next.js integration, SDK metadata and span filters', async () => { + let client: Client | undefined; + const handler = withSentry(() => ({ dsn: DSN }), { + fetch: () => { + client = getClient(); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(client?.getIntegrationByName('Nextjs')).toBeDefined(); + expect( + client?.getEventProcessors().filter(processor => processor.id === 'DropReactControlFlowErrors'), + ).toHaveLength(1); + expect(client?.getOptions()).toHaveProperty('enableOpenTelemetrySetup', true); + expect(client?.getOptions()._metadata).toEqual({ + sdk: { + name: 'sentry.javascript.nextjs', + version: SDK_VERSION, + packages: [ + { name: 'npm:@sentry/nextjs', version: SDK_VERSION }, + { name: 'npm:@sentry/cloudflare', version: SDK_VERSION }, + ], + }, + }); + expect(client?.getOptions().ignoreSpans).toEqual([ + ...NEXTJS_SERVER_IGNORE_SPANS, + { attributes: { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' } }, + ]); + }); + + it('adds the Next.js defaults when the callback returns no options and the env holds the DSN', async () => { + let client: Client | undefined; + const handler = withSentry(() => undefined, { + fetch: () => { + client = getClient(); + return new Response('ok'); + }, + }); + + const response = await handler.fetch?.(new Request('https://example.com/') as never, { SENTRY_DSN: DSN }, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + await response?.text(); + + expect(client?.getOptions().dsn).toBe(DSN); + expect(client?.getIntegrationByName('Nextjs')).toBeDefined(); + expect(client?.getOptions()._metadata?.sdk?.name).toBe('sentry.javascript.nextjs'); + }); + + it('keeps the options the callback returns', async () => { + const userIgnoreSpan = /^GET \/health$/; + let client: Client | undefined; + const handler = withSentry( + () => ({ dsn: DSN, enableOpenTelemetrySetup: false, tracesSampleRate: 0.5, ignoreSpans: [userIgnoreSpan] }), + { + fetch: () => { + client = getClient(); + return new Response('ok'); + }, + }, + ); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(client?.getOptions()).toHaveProperty('enableOpenTelemetrySetup', false); + expect(client?.getOptions().tracesSampleRate).toBe(0.5); + expect(client?.getOptions().ignoreSpans).toEqual([ + userIgnoreSpan, + ...NEXTJS_SERVER_IGNORE_SPANS, + { attributes: { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' } }, + ]); + }); + + it('adds the integrations the callback returns to the Next.js integrations', async () => { + const userIntegration = { name: 'UserIntegration' }; + let client: Client | undefined; + const handler = withSentry(() => ({ dsn: DSN, integrations: [userIntegration] }), { + fetch: () => { + client = getClient(); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(client?.getIntegrationByName('UserIntegration')).toBe(userIntegration); + expect(client?.getIntegrationByName('Nextjs')).toBeDefined(); + }); + + it('passes the Next.js integrations to an `integrations` function of the callback', async () => { + let client: Client | undefined; + const handler = withSentry( + () => ({ + dsn: DSN, + integrations: integrations => integrations.filter(integration => integration.name !== 'NextjsUseCache'), + }), + { + fetch: () => { + client = getClient(); + return new Response('ok'); + }, + }, + ); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(client?.getIntegrationByName('Nextjs')).toBeDefined(); + expect(client?.getIntegrationByName('NextjsUseCache')).toBeUndefined(); + }); + + it('adds the Next.js integrations when the callback sets `defaultIntegrations`', async () => { + let client: Client | undefined; + const handler = withSentry(() => ({ dsn: DSN, defaultIntegrations: false }), { + fetch: () => { + client = getClient(); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(client?.getIntegrationByName('Nextjs')).toBeDefined(); + expect(client?.getIntegrationByName('NextjsUseCache')).toBeDefined(); + }); + + it('does not sample the request span of a request to the tunnel route', async () => { + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath = + '/monitoring'; + const sampledByPath: Record = {}; + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: (request: Request) => { + const activeSpan = getActiveSpan(); + sampledByPath[new URL(request.url).pathname] = !!activeSpan && spanIsSampled(activeSpan); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/monitoring?o=1&p=2') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + await handler.fetch?.(new Request('https://example.com/page') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(sampledByPath).toEqual({ '/monitoring': false, '/page': true }); + }); + + it('does not sample the request span of a request to the tunnel route when a `tracesSampler` samples every span', async () => { + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath = + '/monitoring'; + const sampledByPath: Record = {}; + const handler = withSentry(() => ({ dsn: DSN, tracesSampler: () => 1 }), { + fetch: (request: Request) => { + const activeSpan = getActiveSpan(); + sampledByPath[new URL(request.url).pathname] = !!activeSpan && spanIsSampled(activeSpan); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/monitoring?o=1&p=2') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + await handler.fetch?.(new Request('https://example.com/page') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(sampledByPath).toEqual({ '/monitoring': false, '/page': true }); + }); + + it('injects the trace data of the active span with the global propagator', async () => { + const carrier: Record = {}; + let activeSpan: Span | undefined; + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: () => { + activeSpan = getActiveSpan(); + propagation.inject(context.active(), carrier); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(carrier['sentry-trace']).toBe(`${activeSpan?.spanContext().traceId}-${activeSpan?.spanContext().spanId}-1`); + }); + + it('does not set the global propagator when the callback turns off the OpenTelemetry setup', async () => { + const setGlobalPropagator = vi.spyOn(propagation, 'setGlobalPropagator'); + const handler = withSentry(() => ({ dsn: DSN, enableOpenTelemetrySetup: false }), { + fetch: () => new Response('ok'), + }); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(setGlobalPropagator).not.toHaveBeenCalled(); + }); + + it('starts a span under an extracted remote parent as a root span that continues its trace', async () => { + const traceId = '12312012123120121231201212312012'; + const parentSpanId = '1121201211212012'; + let consumerSpan: Span | undefined; + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: () => { + context.with(propagation.extract(ROOT_CONTEXT, { 'sentry-trace': `${traceId}-${parentSpanId}-1` }), () => { + trace.getTracer('test').startActiveSpan('consumer', span => { + consumerSpan = span as Span; + span.end(); + }); + }); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(getRootSpan(consumerSpan!)).toBe(consumerSpan); + expect(spanToJSON(consumerSpan!)).toMatchObject({ trace_id: traceId, parent_span_id: parentSpanId }); + }); + + it('keeps the request span as parent when Next.js extracts the incoming trace it continued again', async () => { + const traceId = '12312012123120121231201212312012'; + const parentSpanId = '1121201211212012'; + let rootSpan: Span | undefined; + let pageSpan: Span | undefined; + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: (request: Request) => { + rootSpan = getRootSpan(getActiveSpan()!); + const headers = Object.fromEntries(request.headers); + // `withPropagatedContext` of Next.js with `force`, which it uses when it misses its router server context. + const remoteContext = propagation.extract(ROOT_CONTEXT, headers); + const pageContext = trace.getSpanContext(remoteContext) + ? remoteContext + : propagation.extract(context.active(), headers); + context.with(pageContext, () => { + trace.getTracer('test').startActiveSpan('GET /about/page', span => { + pageSpan = span as Span; + span.end(); + }); + }); + return new Response('ok'); + }, + }); + + const response = await handler.fetch?.( + new Request('https://example.com/about', { + headers: { 'sentry-trace': `${traceId}-${parentSpanId}-1` }, + }) as never, + {}, + { waitUntil: vi.fn(), passThroughOnException: vi.fn(), props: {} } as never, + ); + await response?.text(); + + expect(spanToJSON(rootSpan!)).toMatchObject({ trace_id: traceId, parent_span_id: parentSpanId }); + expect(getRootSpan(pageSpan!)).toBe(rootSpan); + }); + + it('attaches the children of the Next.js request span to the request span of `withSentry`', async () => { + let rootSpan: Span | undefined; + let activeSpanInRequestSpan: Span | undefined; + let renderSpan: Span | undefined; + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: () => { + rootSpan = getRootSpan(getActiveSpan()!); + trace + .getTracer('test') + .startActiveSpan('GET /about', { attributes: { 'next.span_type': 'BaseServer.handleRequest' } }, span => { + activeSpanInRequestSpan = getActiveSpan(); + trace.getTracer('test').startActiveSpan('render route (app) /about', childSpan => { + renderSpan = childSpan as Span; + childSpan.end(); + }); + span.end(); + }); + return new Response('ok'); + }, + }); + + const response = await handler.fetch?.(new Request('https://example.com/about') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + await response?.text(); + + expect(activeSpanInRequestSpan).toBe(rootSpan); + expect(spanToJSON(renderSpan!).parent_span_id).toBe(rootSpan!.spanContext().spanId); + }); + + it('starts a new trace for a span under a context extracted without trace headers', async () => { + let requestTraceId: string | undefined; + let spanTraceId: string | undefined; + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: () => { + requestTraceId = getActiveSpan()?.spanContext().traceId; + context.with(propagation.extract(ROOT_CONTEXT, {}), () => { + trace.getTracer('test').startActiveSpan('consumer', span => { + spanTraceId = span.spanContext().traceId; + span.end(); + }); + }); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(spanTraceId).toEqual(expect.stringMatching(/^[a-f0-9]{32}$/)); + expect(spanTraceId).not.toBe(requestTraceId); + }); + + it('names the request span after the `http.route` Next.js sets on it', async () => { + let rootSpan: Span | undefined; + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: () => { + rootSpan = getRootSpan(getActiveSpan()!); + rootSpan.setAttribute(HTTP_ROUTE, '/api/users/[id]'); + // With a length, the request span ends with the response, in the request. Node.js 26 ends the span of a + // streamed body in the async context of its reader, which has no client to run the `spanEnd` hooks. + return new Response('ok', { headers: { 'content-length': '2' } }); + }, + }); + + await handler.fetch?.(new Request('https://example.com/api/users/1') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(spanToJSON(rootSpan!).name).toBe('GET /api/users/[id]'); + expect(spanToJSON(rootSpan!).attributes).toMatchObject({ + [HTTP_ROUTE]: '/api/users/[id]', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + }); + }); + + it('names the request span after the route a Pages Router API route wrapper backfills', async () => { + let rootSpan: Span | undefined; + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: () => { + rootSpan = getRootSpan(getActiveSpan()!); + rootSpan.setAttribute('sentry.route_backfill', '/api/users/[id]'); + return new Response('ok', { headers: { 'content-length': '2' } }); + }, + }); + + await handler.fetch?.(new Request('https://example.com/api/users/1', { method: 'POST' }) as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(spanToJSON(rootSpan!).name).toBe('POST /api/users/[id]'); + expect(spanToJSON(rootSpan!).attributes).toMatchObject({ + [HTTP_ROUTE]: '/api/users/[id]', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + }); + }); + + it('sets the release of `Sentry.init` on a client created after it ran', async () => { + let client: Client | undefined; + const handler = withSentry(() => ({ dsn: DSN }), { + fetch: () => { + client = getClient(); + return new Response('ok'); + }, + }); + initEdge({ release: 'build' }); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(client?.getOptions().release).toBe('build'); + }); + + it('keeps the release from `SENTRY_RELEASE` over the release of `Sentry.init`', async () => { + let client: Client | undefined; + const handler = withSentry(() => ({ dsn: DSN }), { + fetch: () => { + client = getClient(); + return new Response('ok'); + }, + }); + initEdge({ release: 'build' }); + + await handler.fetch?.(new Request('https://example.com/') as never, { SENTRY_RELEASE: 'env' }, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(client?.getOptions().release).toBe('env'); + }); + + it('leaves the control flow error processor to its client when `Sentry.init` of the server build runs during a request', async () => { + const handler = withSentry(() => ({ dsn: DSN }), { + fetch: () => { + initServer({}); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect( + getGlobalScope() + .getScopeData() + .eventProcessors.filter(processor => processor.id === 'DropReactControlFlowErrors'), + ).toHaveLength(0); + }); + + it('leaves the control flow error processor to its clients when `Sentry.init` of the server build runs outside a request', () => { + withSentry(() => ({ dsn: DSN }), { fetch: () => new Response('ok') }); + + initServer({}); + + expect( + getGlobalScope() + .getScopeData() + .eventProcessors.filter(processor => processor.id === 'DropReactControlFlowErrors'), + ).toHaveLength(0); + }); + + // https://github.com/getsentry/sentry-javascript/issues/24603 + it('keeps its async context strategy when `Sentry.init` of the edge build runs during a request', async () => { + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: () => { + initEdge({ dsn: DSN }); + startSpan({ name: 'after-init' }, () => undefined); + return new Response('ok'); + }, + }); + const strategy = getAsyncContextStrategy(getMainCarrier()); + + const response = await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(response?.status).toBe(200); + expect(getAsyncContextStrategy(getMainCarrier())).toBe(strategy); + }); + + it('keeps OpenTelemetry context values and parents when `Sentry.init` of the edge build runs during a request', async () => { + const contextKey = createContextKey('sentry.test.tenant'); + let contextValue: unknown; + let outerSpanId: string | undefined; + let innerParentSpanId: string | undefined; + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: () => { + initEdge({ dsn: DSN }); + context.with(context.active().setValue(contextKey, 'tenant-42'), () => { + trace.getTracer('test').startActiveSpan('outer', outer => { + const inner = trace.getTracer('test').startSpan('inner', {}, context.active()); + contextValue = context.active().getValue(contextKey); + outerSpanId = outer.spanContext().spanId; + innerParentSpanId = spanToJSON(inner as never).parent_span_id; + inner.end(); + outer.end(); + }); + }); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(contextValue).toBe('tenant-42'); + expect(innerParentSpanId).toBe(outerSpanId); + }); +}); diff --git a/packages/nextjs/test/common/utils/dropReactControlFlowErrors.test.ts b/packages/nextjs/test/common/utils/dropReactControlFlowErrors.test.ts new file mode 100644 index 000000000000..976dab9cf5b8 --- /dev/null +++ b/packages/nextjs/test/common/utils/dropReactControlFlowErrors.test.ts @@ -0,0 +1,48 @@ +import type { Event } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { dropReactControlFlowErrorsEventProcessor } from '../../../src/common/utils/dropReactControlFlowErrors'; + +describe('dropReactControlFlowErrorsEventProcessor', () => { + it('drops a React postpone error', () => { + const event: Event = { exception: { values: [{ type: 'Error', value: 'postponed' }] } }; + + expect( + dropReactControlFlowErrorsEventProcessor(event, { + originalException: { $$typeof: Symbol.for('react.postpone') }, + }), + ).toBeNull(); + }); + + it('drops an error that Next.js throws to abort a prerender', () => { + const event: Event = { exception: { values: [{ type: 'Error', value: 'prerender aborted' }] } }; + const error = Object.assign(new Error('prerender aborted'), { digest: 'HANGING_PROMISE_REJECTION' }); + + expect(dropReactControlFlowErrorsEventProcessor(event, { originalException: error })).toBeNull(); + }); + + it.each([ + "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render.", + "Suspense Exception: This is not a real error, and should not leak into userspace. If you're seeing this, it's likely a bug in React.", + ])('drops a Suspense exception: %s', value => { + const event: Event = { exception: { values: [{ type: 'Error', value }] } }; + + expect(dropReactControlFlowErrorsEventProcessor(event, {})).toBeNull(); + }); + + it('keeps an error that is not thrown for control flow', () => { + const error = new TypeError("Cannot read properties of undefined (reading 'id')"); + const event: Event = { exception: { values: [{ type: 'TypeError', value: error.message }] } }; + + expect(dropReactControlFlowErrorsEventProcessor(event, { originalException: error })).toBe(event); + }); + + it('keeps a transaction event whose hint holds a React postpone', () => { + const event: Event = { type: 'transaction', transaction: 'GET /dashboard' }; + + expect( + dropReactControlFlowErrorsEventProcessor(event, { + originalException: { $$typeof: Symbol.for('react.postpone') }, + }), + ).toBe(event); + }); +}); diff --git a/packages/nextjs/test/edgeSdk.test.ts b/packages/nextjs/test/edgeSdk.test.ts index 2cbd0ef0070b..a3fb9dacc7b8 100644 --- a/packages/nextjs/test/edgeSdk.test.ts +++ b/packages/nextjs/test/edgeSdk.test.ts @@ -28,6 +28,8 @@ describe('Edge init()', () => { getMainCarrier().__SENTRY__ = undefined; delete (process as { turbopack?: boolean }).turbopack; + + delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRelease?: string })._sentryRelease; }); it('inits the Vercel Edge SDK', () => { @@ -68,6 +70,15 @@ describe('Edge init()', () => { expect(vercelEdgeInit).not.toHaveBeenCalled(); }); + it('stores the release on Cloudflare Workers in a request of `withSentry` from `@sentry/cloudflare`', () => { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + setAsyncLocalStorageAsyncContextStrategy(); + + withIsolationScope(() => init({ release: '1.2.3' })); + + expect((GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRelease?: string })._sentryRelease).toBe('1.2.3'); + }); + it('sets the `turbopack` tag on Cloudflare Workers in a request of `withSentry` from `@sentry/cloudflare`', () => { vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); setAsyncLocalStorageAsyncContextStrategy(); diff --git a/packages/nextjs/test/server/serverSpanHooks.test.ts b/packages/nextjs/test/server/serverSpanHooks.test.ts new file mode 100644 index 000000000000..aef1a76dd6d2 --- /dev/null +++ b/packages/nextjs/test/server/serverSpanHooks.test.ts @@ -0,0 +1,173 @@ +import { + HTTP_REQUEST_METHOD, + HTTP_ROUTE, + SENTRY_OP, + SENTRY_ORIGIN, + SENTRY_SEGMENT_NAME_SOURCE, +} from '@sentry/conventions/attributes'; +import { MIDDLEWARE } from '@sentry/conventions/op'; +import type { Client } from '@sentry/core'; +import { addChildSpanToSpan, SentrySpan, spanToStaticSpanJSON } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; +import { handleOnSpanStart } from '../../src/server/serverSpanHooks'; + +const client = { getOptions: () => ({}) } as unknown as Client; + +describe('handleOnSpanStart', () => { + it('names the root span after the middleware when Next.js started the root span', () => { + const middlewareSpan = new SentrySpan({ + sampled: true, + name: 'middleware GET', + attributes: { 'next.span_type': 'Middleware.execute', 'next.span_name': 'middleware GET' }, + }); + + handleOnSpanStart(middlewareSpan, client); + + const { description, data } = spanToStaticSpanJSON(middlewareSpan); + expect(description).toBe('middleware GET'); + expect(data).toEqual({ + [SENTRY_ORIGIN]: 'auto', + [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', + [ATTR_NEXT_SPAN_NAME]: 'middleware GET', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [HTTP_ROUTE]: 'middleware GET', + }); + }); + + it('names a root span that another SDK started after the middleware', () => { + const rootSpan = new SentrySpan({ sampled: true, name: 'GET', attributes: { 'http.request.method': 'GET' } }); + const middlewareSpan = new SentrySpan({ + sampled: true, + name: 'middleware GET', + attributes: { 'next.span_type': 'Middleware.execute', 'next.span_name': 'middleware GET' }, + }); + addChildSpanToSpan(rootSpan, middlewareSpan); + + handleOnSpanStart(middlewareSpan, client); + + const root = spanToStaticSpanJSON(rootSpan); + expect(root.description).toBe('middleware GET'); + expect(root.data).toEqual({ + [SENTRY_ORIGIN]: 'manual', + [HTTP_REQUEST_METHOD]: 'GET', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + }); + }); + + it('sets the middleware op on the middleware span when another SDK started the root span', () => { + const rootSpan = new SentrySpan({ sampled: true, name: 'GET', attributes: { 'http.request.method': 'GET' } }); + const middlewareSpan = new SentrySpan({ + sampled: true, + name: 'middleware GET', + attributes: { 'next.span_type': 'Middleware.execute', 'next.span_name': 'middleware GET' }, + }); + addChildSpanToSpan(rootSpan, middlewareSpan); + + handleOnSpanStart(middlewareSpan, client); + + expect(spanToStaticSpanJSON(middlewareSpan).data[SENTRY_OP]).toBe(MIDDLEWARE); + }); + + it('names a root span that another SDK started after the route once a route span starts behind the middleware', () => { + const rootSpan = new SentrySpan({ sampled: true, name: 'GET', attributes: { 'http.request.method': 'GET' } }); + const middlewareSpan = new SentrySpan({ + sampled: true, + name: 'middleware GET', + attributes: { 'next.span_type': 'Middleware.execute', 'next.span_name': 'middleware GET' }, + }); + const routeSpan = new SentrySpan({ + sampled: true, + name: 'executing api route (app) /api/endpoint/route', + attributes: { 'next.span_type': 'AppRouteRouteHandlers.runHandler', 'next.route': '/api/endpoint/route' }, + }); + addChildSpanToSpan(rootSpan, middlewareSpan); + addChildSpanToSpan(rootSpan, routeSpan); + + handleOnSpanStart(middlewareSpan, client); + handleOnSpanStart(routeSpan, client); + + const root = spanToStaticSpanJSON(rootSpan); + expect(root.description).toBe('GET /api/endpoint'); + expect(root.data[HTTP_ROUTE]).toBe('/api/endpoint'); + expect(root.data[SENTRY_SEGMENT_NAME_SOURCE]).toBe('route'); + }); + + it('keeps the middleware name of a root span that another SDK started when Next.js renders an error page', () => { + const rootSpan = new SentrySpan({ sampled: true, name: 'GET', attributes: { 'http.request.method': 'GET' } }); + const middlewareSpan = new SentrySpan({ + sampled: true, + name: 'middleware GET', + attributes: { 'next.span_type': 'Middleware.execute', 'next.span_name': 'middleware GET' }, + }); + const errorPageSpan = new SentrySpan({ + sampled: true, + name: 'resolve page components', + attributes: { 'next.span_type': 'NextNodeServer.findPageComponents', 'next.route': '/500' }, + }); + addChildSpanToSpan(rootSpan, middlewareSpan); + addChildSpanToSpan(rootSpan, errorPageSpan); + + handleOnSpanStart(middlewareSpan, client); + handleOnSpanStart(errorPageSpan, client); + + const root = spanToStaticSpanJSON(rootSpan); + expect(root.description).toBe('middleware GET'); + expect(root.data[HTTP_ROUTE]).toBeUndefined(); + }); + + it('names a root span that another SDK started after an error page when no middleware ran', () => { + const rootSpan = new SentrySpan({ sampled: true, name: 'GET', attributes: { 'http.request.method': 'GET' } }); + const errorPageSpan = new SentrySpan({ + sampled: true, + name: 'resolve page components', + attributes: { 'next.span_type': 'NextNodeServer.findPageComponents', 'next.route': '/500' }, + }); + addChildSpanToSpan(rootSpan, errorPageSpan); + + handleOnSpanStart(errorPageSpan, client); + + expect(spanToStaticSpanJSON(rootSpan).description).toBe('GET /500'); + }); + + it('names a root span that another SDK started after the 404 page behind the middleware', () => { + const rootSpan = new SentrySpan({ sampled: true, name: 'GET', attributes: { 'http.request.method': 'GET' } }); + const middlewareSpan = new SentrySpan({ + sampled: true, + name: 'middleware GET', + attributes: { 'next.span_type': 'Middleware.execute', 'next.span_name': 'middleware GET' }, + }); + const notFoundPageSpan = new SentrySpan({ + sampled: true, + name: 'resolve page components', + attributes: { 'next.span_type': 'NextNodeServer.findPageComponents', 'next.route': '/_not-found' }, + }); + addChildSpanToSpan(rootSpan, middlewareSpan); + addChildSpanToSpan(rootSpan, notFoundPageSpan); + + handleOnSpanStart(middlewareSpan, client); + handleOnSpanStart(notFoundPageSpan, client); + + const root = spanToStaticSpanJSON(rootSpan); + expect(root.description).toBe('GET /_not-found'); + expect(root.data[HTTP_ROUTE]).toBe('/_not-found'); + }); + + it('keeps the route of a root span that another SDK started when the middleware starts', () => { + const rootSpan = new SentrySpan({ + sampled: true, + name: 'GET /api/endpoint', + attributes: { 'http.request.method': 'GET', [HTTP_ROUTE]: '/api/endpoint' }, + }); + const middlewareSpan = new SentrySpan({ + sampled: true, + name: 'middleware GET', + attributes: { 'next.span_type': 'Middleware.execute', 'next.span_name': 'middleware GET' }, + }); + addChildSpanToSpan(rootSpan, middlewareSpan); + + handleOnSpanStart(middlewareSpan, client); + + expect(spanToStaticSpanJSON(rootSpan).description).toBe('GET /api/endpoint'); + }); +}); diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 3ac99be0037f..61d15736a3d0 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -25,6 +25,7 @@ describe('Server init()', () => { delete process.env.VERCEL; delete (process as { turbopack?: boolean }).turbopack; + delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRelease?: string })._sentryRelease; }); it('inits the Node SDK', () => { @@ -146,6 +147,48 @@ describe('Server init()', () => { expect(nodeInit).toHaveBeenCalledTimes(1); }); + it('sets the release on the client of `@sentry/cloudflare` when it has none', () => { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + const client = SentryNode.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + enableOpenTelemetrySetup: false, + }); + // `init` of `@sentry/node` takes a release from CI env vars like `GITHUB_SHA`. + client!.getOptions().release = undefined; + + withIsolationScope(() => init({ release: '1.2.3' })); + + expect(client!.getOptions().release).toBe('1.2.3'); + expect((GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRelease?: string })._sentryRelease).toBe('1.2.3'); + }); + + it('keeps the release of the client of `@sentry/cloudflare`', () => { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + const client = SentryNode.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + enableOpenTelemetrySetup: false, + release: 'worker@2.0.0', + }); + + withIsolationScope(() => init({ release: '1.2.3' })); + + expect(client!.getOptions().release).toBe('worker@2.0.0'); + }); + + it('adds its event processors to the global scope when the client of `@sentry/cloudflare` exists', () => { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + SentryNode.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', enableOpenTelemetrySetup: false }); + + withIsolationScope(() => init({})); + + // With `cacheClient: false`, each request has a new client, so only the global scope reaches all of them. + expect( + getGlobalScope() + .getScopeData() + .eventProcessors.filter(processor => processor.id === 'DropReactControlFlowErrors'), + ).toHaveLength(1); + }); + // TODO: test `vercel` tag when running on Vercel // Can't just add the test and set env variables, since the value in `index.server.ts` // is resolved when importing.