Skip to content

Commit 1f9ac0a

Browse files
JPeer264claude
andcommitted
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 <noreply@anthropic.com>
1 parent 81f62d0 commit 1f9ac0a

34 files changed

Lines changed: 1214 additions & 203 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { getCloudflareContext } from '@opennextjs/cloudflare';
2+
import { NextResponse } from 'next/server';
3+
4+
export const dynamic = 'force-dynamic';
5+
6+
type Env = { DB: { prepare(query: string): { all(): Promise<{ results: unknown[] }> } } };
7+
8+
export async function GET() {
9+
const { env } = await getCloudflareContext({ async: true });
10+
const { results } = await (env as unknown as Env).DB.prepare('SELECT 1 AS one').all();
11+
return NextResponse.json(results);
12+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { context, createContextKey, trace } from '@opentelemetry/api';
2+
import { NextResponse } from 'next/server';
3+
4+
export const dynamic = 'force-dynamic';
5+
6+
const E2E_CONTEXT_KEY = createContextKey('e2e.context.key');
7+
8+
export async function GET() {
9+
const tracer = trace.getTracer('e2e');
10+
11+
context.with(context.active().setValue(E2E_CONTEXT_KEY, 'e2e-value'), () => {
12+
tracer.startActiveSpan('otel-context-outer', outer => {
13+
// An explicit context, as OpenTelemetry instrumentations pass it.
14+
const inner = tracer.startSpan('otel-context-inner', {}, context.active());
15+
inner.setAttribute('e2e.context.value', String(context.active().getValue(E2E_CONTEXT_KEY)));
16+
inner.end();
17+
outer.end();
18+
});
19+
});
20+
21+
return NextResponse.json({ ok: true });
22+
}

‎dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"scripts": {
66
"dev": "next dev",
77
"cf:build": "opennextjs-cloudflare build",
8-
"cf:preview": "opennextjs-cloudflare preview",
8+
"cf:preview": "opennextjs-cloudflare preview --var \"E2E_TEST_DSN:$E2E_TEST_DSN\"",
99
"build": "next build",
1010
"clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs",
1111
"start": "pnpm cf:preview",
@@ -17,6 +17,7 @@
1717
"test:assert": "pnpm test:prod"
1818
},
1919
"dependencies": {
20+
"@opentelemetry/api": "^1.9.1",
2021
"@opennextjs/cloudflare": "^1.14.9",
2122
"@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
2223
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,7 @@
11
import * as Sentry from '@sentry/nextjs';
22

3+
// Only hands over the build release: `withSentry` from `@sentry/nextjs/cloudflare` in worker.ts creates the client and
4+
// holds the options.
35
Sentry.init({
4-
environment: 'qa', // dynamic sampling bias to keep transactions
56
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
6-
tunnel: `http://localhost:3031/`, // proxy server
7-
tracesSampler: samplingContext => {
8-
if (samplingContext.attributes?.['next.span_type'] === 'Middleware.execute') {
9-
// Only keep the middleware transaction when `normalizedRequest` was available at sampling time.
10-
// Test times out and fails when transaction is dropped
11-
const { normalizedRequest } = samplingContext;
12-
return Boolean(normalizedRequest?.url && normalizedRequest?.method);
13-
}
14-
15-
return 1.0;
16-
},
17-
// debug: true,
187
});
Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,7 @@
11
import * as Sentry from '@sentry/nextjs';
2-
import { Log } from '@sentry/nextjs';
32

3+
// Only hands over the build release and adds the global Next.js event processors and the turbopack tag: `withSentry`
4+
// from `@sentry/nextjs/cloudflare` in worker.ts creates the client and holds the options.
45
Sentry.init({
5-
environment: 'qa', // dynamic sampling bias to keep transactions
66
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
7-
tunnel: `http://localhost:3031/`, // proxy server
8-
tracesSampleRate: 1.0,
9-
// debug: true,
10-
integrations: [Sentry.vercelAIIntegration()],
11-
// Verify Log type is available
12-
beforeSendLog(log: Log) {
13-
return log;
14-
},
157
});

‎dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ test.describe('Cloudflare Runtime', () => {
1818
expect(errorEvent.contexts?.runtime).toEqual({
1919
name: 'cloudflare',
2020
});
21+
expect(errorEvent.tags?.turbopack).toBe(true);
2122

2223
// The SDK info should include cloudflare in the packages
2324
expect(errorEvent.sdk?.packages).toEqual(
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { expect, test } from '@playwright/test';
2+
import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils';
3+
4+
// `withSentry` passes an instrumented `env` to the OpenNext handler, which `getCloudflareContext()` returns.
5+
test('Should create a span for a D1 query through the `env` of `getCloudflareContext`', async ({ request }) => {
6+
const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-cf-workers', 'GET /api/d1');
7+
8+
const response = await request.get('/api/d1');
9+
expect(await response.json()).toStrictEqual([{ one: 1 }]);
10+
11+
const spans = await spansPromise;
12+
const d1Span = spans.find(span => getSpanOp(span) === 'db.query')!;
13+
14+
expect(d1Span.is_segment).toBe(false);
15+
expect(d1Span.attributes).toMatchObject({
16+
'db.system.name': { value: 'cloudflare-d1', type: 'string' },
17+
'db.query.text': { value: 'SELECT ? AS one', type: 'string' },
18+
'sentry.origin': { value: 'auto.db.cloudflare.d1', type: 'string' },
19+
});
20+
});

‎dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/metrics.test.ts‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { expect, test } from '@playwright/test';
22
import { waitForMetric } from '@sentry-internal/test-utils';
33

4-
// Metrics are not currently supported on Cloudflare Workers
5-
// TODO: Investigate and enable when metrics support is added for CF Workers
6-
test.skip('Should emit metrics from server and client', async ({ request, page }) => {
4+
test('Should emit metrics from server and client', async ({ request, page }) => {
75
const clientCountPromise = waitForMetric('nextjs-16-cf-workers', async metric => {
86
return metric.name === 'test.page.count';
97
});
@@ -92,7 +90,6 @@ test.skip('Should emit metrics from server and client', async ({ request, page }
9290
type: 'counter',
9391
value: 1,
9492
attributes: {
95-
'server.address': { value: expect.any(String), type: 'string' },
9693
'random.attribute': { value: 'Potatoes', type: 'string' },
9794
endpoint: { value: '/metrics/route-handler', type: 'string' },
9895
'sentry.environment': { value: 'qa', type: 'string' },
@@ -108,7 +105,6 @@ test.skip('Should emit metrics from server and client', async ({ request, page }
108105
type: 'distribution',
109106
value: 100,
110107
attributes: {
111-
'server.address': { value: expect.any(String), type: 'string' },
112108
'random.attribute': { value: 'Patatas', type: 'string' },
113109
endpoint: { value: '/metrics/route-handler', type: 'string' },
114110
'sentry.environment': { value: 'qa', type: 'string' },
@@ -124,7 +120,6 @@ test.skip('Should emit metrics from server and client', async ({ request, page }
124120
type: 'gauge',
125121
value: 200,
126122
attributes: {
127-
'server.address': { value: expect.any(String), type: 'string' },
128123
'random.attribute': { value: 'Patate', type: 'string' },
129124
endpoint: { value: '/metrics/route-handler', type: 'string' },
130125
'sentry.environment': { value: 'qa', type: 'string' },

‎dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts‎

Lines changed: 33 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,55 @@
11
import { expect, test } from '@playwright/test';
2-
import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
2+
import { collectStreamedSpans, collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils';
33
import { isDevMode } from './isDevMode';
44

5-
// TODO: Skipped until the Cloudflare Workers edge middleware setup emits middleware spans reliably.
6-
test.skip('tracesSampler receives normalizedRequest for edge middleware', async ({ request }) => {
7-
const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
8-
return span.name === 'middleware GET' && span.is_segment;
9-
});
10-
11-
await request.get('/api/endpoint-behind-middleware');
12-
13-
const middlewareSpan = await middlewareSpanPromise;
14-
15-
expect(String(middlewareSpan.attributes['http.target']?.value)).toContain('/api/endpoint-behind-middleware');
16-
expect(middlewareSpan.attributes['http.request.method']?.value).toBe('GET');
17-
});
18-
19-
// TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime
20-
test.skip('Should create a span for middleware', async ({ request }) => {
21-
const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
22-
return span.name === 'middleware GET' && span.is_segment;
23-
});
5+
// `withSentry` from `@sentry/nextjs/cloudflare` wraps the Worker entry, so middleware runs inside the request's
6+
// `http.server` segment and its span is a child of it rather than a segment of its own.
7+
test('Should create a span for middleware inside the request segment', async ({ request }) => {
8+
const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-cf-workers', 'GET /api/endpoint-behind-middleware');
249

2510
const response = await request.get('/api/endpoint-behind-middleware');
2611
expect(await response.json()).toStrictEqual({ name: 'John Doe' });
2712

28-
const middlewareSpan = await middlewareSpanPromise;
29-
13+
const spans = await spansPromise;
14+
const segmentSpan = spans.find(span => span.is_segment)!;
15+
const middlewareSpan = spans.find(span => span.name === 'middleware GET')!;
16+
17+
expect(segmentSpan.attributes['url.path']?.value).toBe('/api/endpoint-behind-middleware');
18+
expect(segmentSpan.attributes['sentry.segment.name.source']?.value).toBe('route');
19+
expect(segmentSpan.attributes['isolation_scope.is_default']).toEqual({ value: false, type: 'boolean' });
20+
expect(middlewareSpan.is_segment).toBe(false);
21+
expect(spans.some(span => span.span_id === middlewareSpan.parent_span_id)).toBe(true);
3022
expect(middlewareSpan.status).toBe('ok');
3123
expect(getSpanOp(middlewareSpan)).toBe('middleware');
32-
expect(middlewareSpan.attributes['sentry.segment.name.source']?.value).toBe('route');
33-
34-
// Assert that isolation scope works properly
35-
// expect(middlewareSpan.attributes['isolation_scope.is_default']).toEqual({ value: false, type: 'boolean' });
3624
});
3725

38-
// TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime
39-
test.skip('Faulty middlewares', async ({ request }) => {
26+
test('Faulty middlewares', async ({ request }) => {
4027
test.skip(isDevMode, 'Throwing crashes the dev server atm'); // https://github.com/vercel/next.js/issues/85261
41-
const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
42-
return span.name === 'middleware GET' && span.is_segment;
43-
});
28+
// The middleware throws, so the request span of `withSentry` keeps the middleware name, like the middleware segment
29+
// of Node.js.
30+
const spansPromise = collectStreamedSpans(
31+
'nextjs-16-cf-workers',
32+
spans =>
33+
spans.some(span => span.is_segment && span.name === 'middleware GET') &&
34+
spans.some(span => !span.is_segment && span.name === 'middleware GET'),
35+
);
4436

4537
request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => {
4638
// Noop
4739
});
4840

49-
await test.step('should record spans', async () => {
50-
const middlewareSpan = await middlewareSpanPromise;
51-
expect(middlewareSpan.status).toBe('error');
52-
expect(getSpanOp(middlewareSpan)).toBe('middleware');
53-
expect(middlewareSpan.attributes['sentry.segment.name.source']?.value).toBe('route');
54-
});
41+
const spans = await spansPromise;
42+
const segmentSpan = spans.find(span => span.is_segment)!;
43+
const middlewareSpan = spans.find(span => !span.is_segment && span.name === 'middleware GET')!;
44+
45+
expect(segmentSpan.status).toBe('error');
46+
expect(segmentSpan.attributes['sentry.segment.name.source']?.value).toBe('route');
47+
expect(middlewareSpan.parent_span_id).toBe(segmentSpan.span_id);
48+
expect(middlewareSpan.status).toBe('error');
49+
expect(getSpanOp(middlewareSpan)).toBe('middleware');
5550
});
5651

57-
// TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime
58-
test.skip('Should trace outgoing fetch requests inside middleware', async ({ request }) => {
52+
test('Should trace outgoing fetch requests inside middleware', async ({ request }) => {
5953
test.skip(isDevMode, 'The fetch requests ends up in a separate tx in dev atm');
6054

6155
// `http.client` span names are low cardinality under span streaming, hence `GET localhost` rather

‎dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/nested-rsc-error.test.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { expect, test } from '@playwright/test';
22
import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
33

4-
// TODO: Flakey on CI
5-
test.skip('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({
4+
test('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({
65
page,
76
}) => {
87
const errorEventPromise = waitForError('nextjs-16-cf-workers', errorEvent => {

0 commit comments

Comments
 (0)