From 1f9ac0a7d4b30f679defed9fb062124b754a3d9a Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 29 Sep 2026 15:56:23 +0200 Subject: [PATCH] feat(nextjs): Add `@sentry/nextjs/cloudflare` `withSentry` from `@sentry/nextjs/cloudflare` wraps the Worker entry of a Next.js app on Cloudflare Workers, e.g. `.open-next/worker.js` of OpenNext or the fetch handler of vinext. It is `withSentry` of `@sentry/cloudflare` with the Next.js handling added: - It installs the OpenTelemetry async context strategy and context manager at module load, so the spans of Next.js nest and keep their OpenTelemetry context as on Node.js. - Its client gets the span hooks and `ignoreSpans` of the server `init`, the event processor for the control flow errors of React, the `use cache` integration, the Sentry propagator and the Next.js SDK metadata. - The propagator keeps the request span as parent when Next.js extracts an incoming trace that the root span already continued. Next.js does this when it misses its router server context, e.g. on Workers where `process.cwd()` is `/bundle`, and each continued request then had two segments. - The request spans of Next.js (`BaseServer.handleRequest`) are ignored, so the request span of `withSentry` is the only `http.server` span. The other Next.js spans become its children; it gets the route from their `next.route` and its status from the response. When the middleware answers the request or throws, the span is named `middleware GET`, like the middleware segment on Node.js. - Requests to the tunnel route are not sampled. - The server and edge `init` in `sentry.*.config.ts` create no client in the Worker. They hand over the build release of `withSentryConfig`, which only code that Next.js compiles can read. The `nextjs-16-cf-workers` e2e app now uses it. This runs the server tests that were skipped, and adds tests for D1 spans, the OpenTelemetry context and trace continuation. Its `compatibility_date` moves to 2026-02-19, the first date on which Next.js extracts the incoming trace again. Co-Authored-By: Claude Opus 5.5 --- .../nextjs-16-cf-workers/app/api/d1/route.ts | 12 + .../app/api/otel-context/route.ts | 22 + .../nextjs-16-cf-workers/package.json | 3 +- .../sentry.edge.config.ts | 15 +- .../sentry.server.config.ts | 12 +- .../tests/cloudflare-runtime.test.ts | 1 + .../tests/d1-binding.test.ts | 20 + .../tests/metrics.test.ts | 7 +- .../tests/middleware.test.ts | 72 ++- .../tests/nested-rsc-error.test.ts | 3 +- .../tests/otel-context.test.ts | 19 + .../tests/pageload-tracing.test.ts | 6 +- .../tests/route-handler.test.ts | 10 +- .../tests/server-action-redirect.test.ts | 2 +- .../tests/server-components.test.ts | 24 +- .../tests/streaming-rsc-error.test.ts | 5 +- .../tests/trace-continuation.test.ts | 38 ++ .../nextjs-16-cf-workers/worker.ts | 17 + .../nextjs-16-cf-workers/wrangler.jsonc | 13 +- packages/nextjs/package.json | 6 + packages/nextjs/rollup.npm.config.mjs | 1 + packages/nextjs/src/cloudflare/index.ts | 103 ++++ .../utils/dropMiddlewareTunnelRequests.ts | 2 +- .../utils/dropReactControlFlowErrors.ts | 43 ++ .../nextjs/src/common/utils/responseEnd.ts | 39 +- packages/nextjs/src/edge/index.ts | 4 +- .../server/enhanceHandleRequestRootSpan.ts | 8 +- .../nextjs/src/server/handleOnSpanStart.ts | 125 +++-- packages/nextjs/src/server/index.ts | 58 +- .../nextjs/test/cloudflare/withSentry.test.ts | 496 ++++++++++++++++++ .../utils/dropReactControlFlowErrors.test.ts | 40 ++ packages/nextjs/test/edgeSdk.test.ts | 7 +- .../test/server/handleOnSpanStart.test.ts | 143 +++++ packages/nextjs/test/serverSdk.test.ts | 41 ++ 34 files changed, 1214 insertions(+), 203 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/d1/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/otel-context/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/d1-binding.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/otel-context.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/trace-continuation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/worker.ts create mode 100644 packages/nextjs/src/cloudflare/index.ts create mode 100644 packages/nextjs/src/common/utils/dropReactControlFlowErrors.ts create mode 100644 packages/nextjs/test/cloudflare/withSentry.test.ts create mode 100644 packages/nextjs/test/common/utils/dropReactControlFlowErrors.test.ts create mode 100644 packages/nextjs/test/server/handleOnSpanStart.test.ts 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.