Skip to content

Commit 33d45ee

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. A size-limit entry measures `withSentry` of the new entry with the build settings of wrangler. An import from `@sentry/node` or the server `init` module exceeds its limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 92d9b8a commit 33d45ee

35 files changed

Lines changed: 1354 additions & 218 deletions

‎.size-limit.js‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,27 @@ module.exports = [
533533
return config;
534534
},
535535
},
536+
// Next.js on Cloudflare Workers (ESM) - minified to match `wrangler deploy --dry-run --minify` output. An import of
537+
// `@sentry/node` in this entry more than doubles its size.
538+
{
539+
name: '@sentry/nextjs/cloudflare (withSentry) - minified',
540+
path: 'packages/nextjs/build/esm/cloudflare/index.js',
541+
import: createImport('withSentry'),
542+
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
543+
gzip: false,
544+
brotli: false,
545+
limit: '222 KiB',
546+
disablePlugins: ['@size-limit/webpack'],
547+
webpack: false,
548+
modifyEsbuildConfig: function (config) {
549+
config.keepNames = true;
550+
// Match wrangler's build settings
551+
config.conditions = ['workerd', 'worker', 'browser'];
552+
config.platform = 'browser';
553+
config.format = 'esm';
554+
return config;
555+
},
556+
},
536557
];
537558

538559
function createImport(...args) {
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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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(spans.filter(span => getSpanOp(span) === 'db.query')).toEqual([d1Span]);
15+
expect(d1Span.name).toBe('SELECT');
16+
expect(d1Span.is_segment).toBe(false);
17+
expect(d1Span.status).toBe('ok');
18+
expect(d1Span.attributes['db.system.name']?.value).toBe('cloudflare-d1');
19+
expect(d1Span.attributes['db.query.text']?.value).toBe('SELECT ? AS one');
20+
expect(d1Span.attributes['sentry.origin']?.value).toBe('auto.db.cloudflare.d1');
21+
});

‎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: 34 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,56 @@
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(spans.filter(span => getSpanOp(span) === 'middleware')).toEqual([middlewareSpan]);
18+
expect(segmentSpan.attributes['url.path']?.value).toBe('/api/endpoint-behind-middleware');
19+
expect(segmentSpan.attributes['sentry.segment.name.source']?.value).toBe('route');
20+
expect(segmentSpan.attributes['isolation_scope.is_default']).toEqual({ value: false, type: 'boolean' });
21+
expect(middlewareSpan.is_segment).toBe(false);
22+
expect(middlewareSpan.parent_span_id).toBe(segmentSpan.span_id);
3023
expect(middlewareSpan.status).toBe('ok');
3124
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' });
3625
});
3726

38-
// TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime
39-
test.skip('Faulty middlewares', async ({ request }) => {
27+
test('Faulty middlewares', async ({ request }) => {
4028
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-
});
29+
// The middleware throws, so the request span of `withSentry` keeps the middleware name, like the middleware segment
30+
// of Node.js.
31+
const spansPromise = collectStreamedSpans(
32+
'nextjs-16-cf-workers',
33+
spans =>
34+
spans.some(span => span.is_segment && span.name === 'middleware GET') &&
35+
spans.some(span => !span.is_segment && span.name === 'middleware GET'),
36+
);
4437

4538
request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => {
4639
// Noop
4740
});
4841

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-
});
42+
const spans = await spansPromise;
43+
const segmentSpan = spans.find(span => span.is_segment)!;
44+
const middlewareSpan = spans.find(span => !span.is_segment && span.name === 'middleware GET')!;
45+
46+
expect(segmentSpan.status).toBe('error');
47+
expect(segmentSpan.attributes['sentry.segment.name.source']?.value).toBe('route');
48+
expect(middlewareSpan.parent_span_id).toBe(segmentSpan.span_id);
49+
expect(middlewareSpan.status).toBe('error');
50+
expect(getSpanOp(middlewareSpan)).toBe('middleware');
5551
});
5652

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

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

0 commit comments

Comments
 (0)