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..f562907e08f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/d1-binding.test.ts @@ -0,0 +1,20 @@ +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(d1Span.is_segment).toBe(false); + expect(d1Span.attributes).toMatchObject({ + 'db.system.name': { value: 'cloudflare-d1', type: 'string' }, + 'db.query.text': { value: 'SELECT ? AS one', type: 'string' }, + 'sentry.origin': { value: 'auto.db.cloudflare.d1', type: 'string' }, + }); +}); 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..214e0d42b8cb 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,55 @@ 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(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(spans.some(span => span.span_id === middlewareSpan.parent_span_id)).toBe(true); 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..f4fee5dbc61e --- /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(innerSpan.trace_id).toBe(segmentSpan.trace_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..9d06ee350aa4 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 }) => { // Wait for the initial pageload span const pageLoadSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => { return span.name === '/redirect/origin' && span.is_segment; 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 2b2177b39a33..15d16c8973fd 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.2.0", "@sentry/bundler-plugins": "11.2.0", + "@sentry/cloudflare": "11.2.0", "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.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..c34bd3109465 --- /dev/null +++ b/packages/nextjs/src/cloudflare/index.ts @@ -0,0 +1,103 @@ +/* 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, + getAsyncContextStrategy, + getMainCarrier, + 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 { addNextjsServerSpanHooks, NEXTJS_SERVER_IGNORE_SPANS } from '../server/handleOnSpanStart'; +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(); + (getAsyncContextStrategy(getMainCarrier()) as { _sentryNextjsCloudflare?: boolean })._sentryNextjsCloudflare = true; + + 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 a2d2ab9ac0d2..aa4455d395e8 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 8fb70736989f..c2fac3a51e49 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 { + 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'; @@ -107,18 +116,34 @@ export function isCloudflareWaitUntilAvailable(): boolean { } /** - * 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()); + const strategy = getAsyncContextStrategy(getMainCarrier()) as ReturnType & { + _sentryNextjsCloudflare?: boolean; + }; // The AsyncLocalStorage strategy of `@sentry/cloudflare` has no `withActiveSpan`. const asyncLocalStorage = strategy.getTracingChannelBinding?.()?.asyncLocalStorage as | { getStore(): unknown } | undefined; return ( - !strategy.withActiveSpan && - asyncLocalStorage?.getStore() !== undefined && - (GLOBAL_OBJ as { navigator?: { userAgent?: string } }).navigator?.userAgent === 'Cloudflare-Workers' + !!strategy._sentryNextjsCloudflare || + (!strategy.withActiveSpan && + asyncLocalStorage?.getStore() !== undefined && + (GLOBAL_OBJ as { navigator?: { userAgent?: string } }).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 a0c034d67887..69c7a196b851 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -35,6 +35,7 @@ import { flushSafelyWithTimeout, isAsyncContextOwnedByCloudflare, isCloudflareWaitUntilAvailable, + setCloudflareWorkerRelease, waitUntil, } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; @@ -113,9 +114,10 @@ export function init(options: VercelEdgeOptions = {}): void { opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; 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..e0c828010ec5 100644 --- a/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts +++ b/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts @@ -8,7 +8,7 @@ import { URL_PATH, } from '@sentry/conventions/attributes'; import { HTTP_SERVER, MIDDLEWARE } from '@sentry/conventions/op'; -import { stripUrlQueryAndFragment } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, stripUrlQueryAndFragment } from '@sentry/core'; import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../common/span-attributes-with-logic-attached'; import { backfillHttpResponseStatusCode } from '../common/utils/backfillHttpResponseStatusCode'; @@ -34,7 +34,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[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] !== 'auto.http.cloudflare' + ) { return; } diff --git a/packages/nextjs/src/server/handleOnSpanStart.ts b/packages/nextjs/src/server/handleOnSpanStart.ts index 89277c42b53c..6b419590b350 100644 --- a/packages/nextjs/src/server/handleOnSpanStart.ts +++ b/packages/nextjs/src/server/handleOnSpanStart.ts @@ -6,12 +6,14 @@ import { HTTP_TARGET, URL_QUERY, } from '@sentry/conventions/attributes'; +import { MIDDLEWARE } from '@sentry/conventions/op'; import type { Client, Options, Span } from '@sentry/core'; import { getIsolationScope, getRootSpan, hasSpanStreamingEnabled, HTTP_SPAN_NAME_FALLBACK, + SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, } from '@sentry/core'; @@ -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(SEMANTIC_ATTRIBUTE_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(SEMANTIC_ATTRIBUTE_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(SEMANTIC_ATTRIBUTE_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/src/server/index.ts b/packages/nextjs/src/server/index.ts index 3734e9a174db..611d8d08e68c 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -1,15 +1,18 @@ // 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 } from '@sentry/core'; import { applySdkMetadata, debug, getClient, getGlobalScope, getVercelEnv, GLOBAL_OBJ } from '@sentry/core'; 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, + isCloudflareWaitUntilAvailable, + setCloudflareWorkerRelease, +} from '../common/utils/responseEnd'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; import { addNextjsServerSpanHooks, NEXTJS_SERVER_IGNORE_SPANS } from './handleOnSpanStart'; import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; @@ -165,49 +168,14 @@ export function init(options: NodeOptions): NodeClient | undefined { if (client) { addNextjsServerSpanHooks(client); + } else if (isOwnedByCloudflare) { + setCloudflareWorkerRelease(opts.release); } - getGlobalScope().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' }, - ), - ); + // The client of `withSentry` from `@sentry/nextjs/cloudflare` adds the processor itself. + if (!getClient()?.getIntegrationByName('Nextjs')) { + getGlobalScope().addEventProcessor(dropReactControlFlowErrorsEventProcessor); + } if (process.env.NODE_ENV === 'development') { getGlobalScope().addEventProcessor(devErrorSymbolicationEventProcessor); @@ -227,7 +195,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/test/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts new file mode 100644 index 000000000000..187a644ef729 --- /dev/null +++ b/packages/nextjs/test/cloudflare/withSentry.test.ts @@ -0,0 +1,496 @@ +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, + getMainCarrier, + getRootSpan, + GLOBAL_OBJ, + spanIsSampled, + spanToJSON, + startSpan, +} from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { withSentry } from '../../src/cloudflare'; +import { init as initEdge } from '../../src/edge'; + +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().map(processor => processor.id)).toContain('DropReactControlFlowErrors'); + expect(client?.getOptions()).toMatchObject({ + enableOpenTelemetrySetup: true, + _metadata: { + sdk: { + name: 'sentry.javascript.nextjs', + packages: [ + { name: 'npm:@sentry/nextjs', version: expect.any(String) }, + { name: 'npm:@sentry/cloudflare', version: expect.any(String) }, + ], + }, + }, + }); + expect(client?.getOptions().ignoreSpans).toContainEqual(/^NextServer\.getRequestHandler$/); + }); + + 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()).toMatchObject({ enableOpenTelemetrySetup: false, tracesSampleRate: 0.5 }); + expect(client?.getOptions().ignoreSpans).toContainEqual(userIgnoreSpan); + expect(client?.getOptions().ignoreSpans).toContainEqual(/^NextServer\.getRequestHandler$/); + }); + + 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: (request: Request) => { + if (request.method === 'HEAD') { + initEdge({ release: 'build' }); + } + client = getClient(); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/', { method: 'HEAD' }) as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + 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: (request: Request) => { + if (request.method === 'HEAD') { + initEdge({ release: 'build' }); + } + client = getClient(); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/', { method: 'HEAD' }) as never, { SENTRY_RELEASE: 'env' }, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + 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'); + }); + + // 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', async () => { + const contextKey = createContextKey('test.context.key'); + let observed: { value?: unknown; outerSpanId?: string; innerParentSpanId?: string } = {}; + + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: () => { + initEdge({ dsn: DSN }); + context.with(context.active().setValue(contextKey, 'test-value'), () => { + trace.getTracer('test').startActiveSpan('outer', outer => { + const inner = trace.getTracer('test').startSpan('inner', {}, context.active()); + observed = { + value: context.active().getValue(contextKey), + outerSpanId: outer.spanContext().spanId, + innerParentSpanId: spanToJSON(inner as never).parent_span_id, + }; + inner.end(); + outer.end(); + }); + }); + return new Response('ok'); + }, + }); + + 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(observed).toEqual({ + value: 'test-value', + outerSpanId: expect.any(String), + innerParentSpanId: observed.outerSpanId, + }); + expect(getAsyncContextStrategy(getMainCarrier()).withActiveSpan).toBeDefined(); + }); +}); 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..b1572bb129d2 --- /dev/null +++ b/packages/nextjs/test/common/utils/dropReactControlFlowErrors.test.ts @@ -0,0 +1,40 @@ +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('drops a Suspense exception', () => { + const event: Event = { + exception: { values: [{ type: 'Error', value: 'Suspense Exception: This is not a real error!' }] }, + }; + + expect(dropReactControlFlowErrorsEventProcessor(event, {})).toBeNull(); + }); + + it('keeps other errors and non-error events', () => { + const errorEvent: Event = { exception: { values: [{ type: 'Error', value: 'boom' }] } }; + const transactionEvent: Event = { type: 'transaction', transaction: 'GET /' }; + + expect(dropReactControlFlowErrorsEventProcessor(errorEvent, { originalException: new Error('boom') })).toBe( + errorEvent, + ); + expect(dropReactControlFlowErrorsEventProcessor(transactionEvent, {})).toBe(transactionEvent); + }); +}); diff --git a/packages/nextjs/test/edgeSdk.test.ts b/packages/nextjs/test/edgeSdk.test.ts index 8b95f3022fde..5b5e67f08756 100644 --- a/packages/nextjs/test/edgeSdk.test.ts +++ b/packages/nextjs/test/edgeSdk.test.ts @@ -25,6 +25,8 @@ describe('Edge init()', () => { vi.unstubAllGlobals(); getMainCarrier().__SENTRY__ = undefined; + + delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRelease?: string })._sentryRelease; }); it('inits the Vercel Edge SDK', () => { @@ -56,13 +58,14 @@ describe('Edge init()', () => { ); }); - it('skips init on Cloudflare Workers in a request of `withSentry` from `@sentry/cloudflare`', () => { + it('skips init and stores the release on Cloudflare Workers in a request of `withSentry` from `@sentry/cloudflare`', () => { vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); setAsyncLocalStorageAsyncContextStrategy(); - withIsolationScope(() => init({})); + withIsolationScope(() => init({ release: '1.2.3' })); expect(vercelEdgeInit).not.toHaveBeenCalled(); + expect((GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRelease?: string })._sentryRelease).toBe('1.2.3'); }); describe('integrations', () => { diff --git a/packages/nextjs/test/server/handleOnSpanStart.test.ts b/packages/nextjs/test/server/handleOnSpanStart.test.ts new file mode 100644 index 000000000000..58caa7aa8be0 --- /dev/null +++ b/packages/nextjs/test/server/handleOnSpanStart.test.ts @@ -0,0 +1,143 @@ +import { HTTP_ROUTE, SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import type { Client } from '@sentry/core'; +import { addChildSpanToSpan, SentrySpan, spanToStaticSpanJSON } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { handleOnSpanStart } from '../../src/server/handleOnSpanStart'; + +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[HTTP_ROUTE]).toBe('middleware GET'); + }); + + it('names a root span that another SDK started after the middleware and marks the middleware 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); + + const root = spanToStaticSpanJSON(rootSpan); + expect(root.description).toBe('middleware GET'); + expect(root.data[SENTRY_SEGMENT_NAME_SOURCE]).toBe('route'); + expect(root.data[HTTP_ROUTE]).toBeUndefined(); + 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 a84a456a63ee..6716e868db54 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', () => { @@ -98,6 +99,46 @@ 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(nodeInit).toHaveBeenCalledTimes(1); + expect(client!.getOptions().release).toBe('1.2.3'); + expect((GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRelease?: string })._sentryRelease).toBe('1.2.3'); + // With `cacheClient: false`, each request has a new client, so only the global scope reaches all of them. + expect( + getGlobalScope() + .getScopeData() + .eventProcessors.map(processor => processor.id), + ).toContain('DropReactControlFlowErrors'); + }); + + it('leaves the control flow error processor to the client of `withSentry` from `@sentry/nextjs/cloudflare`', () => { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + SentryNode.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + enableOpenTelemetrySetup: false, + integrations: [{ name: 'Nextjs' }], + }); + + withIsolationScope(() => init({})); + + expect(nodeInit).toHaveBeenCalledTimes(1); + expect( + getGlobalScope() + .getScopeData() + .eventProcessors.map(processor => processor.id), + ).not.toContain('DropReactControlFlowErrors'); + }); + // 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.