Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions dev-packages/cloudflare-integration-tests/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ type StartResult = {
completed(): Promise<void>;
/** Every non-ignored envelope received so far, matched or not, for count assertions. */
getReceivedEnvelopes(): Envelope[];
/** The port wrangler serves the worker on, once it is ready. */
getPort(): Promise<number>;
makeRequest<T>(
method: 'get' | 'post',
path: string,
Expand Down Expand Up @@ -558,6 +560,9 @@ export function createRunner(...paths: string[]) {
getReceivedEnvelopes: function (): Envelope[] {
return receivedEnvelopes;
},
getPort: function (): Promise<number> {
return workerPortPromise;
},
makeRequest: async function <T>(
method: 'get' | 'post',
path: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@ import { defineCloudflareOptions, serveTunnelIntegration } from '@sentry/cloudfl
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1,
// A meta-framework answers the tunnel requests, the Worker only drops their server spans.
integrations: [serveTunnelIntegration()],
}));
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@ function expectServerSpanAt(path: string) {
};
}

it('drops the server span of managed tunnel requests', async ({ signal }) => {
it('answers managed tunnel requests before the app, without a server span', async ({ signal }) => {
// Envelopes are expected in order, so a span of the tunnel request would arrive before the last one.
const runner = createRunner(__dirname)
.expect(expectServerSpanAt('/tunnel-path'))
.expect(expectServerSpanAt('/after-tunnel'))
.start(signal);

const tunnelPath = await runner.makeRequest<string>('get', '/tunnel-path');
expect(await runner.makeRequest<string>('post', tunnelPath!, { data: 'envelope' })).toBe('app');
// The tunnel rejects the body as an envelope. The app would have answered with a 200.
await runner.makeRequest('post', tunnelPath!, { data: 'envelope', expectError: true });
await runner.makeRequest('get', '/after-tunnel');

await runner.completed();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as Sentry from '@sentry/cloudflare';

interface Env {
SENTRY_DSN: string;
ALLOWED_DSNS: string;
TUNNEL_PATH: string;
}

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
integrations: [Sentry.serveTunnelIntegration({ allowedDsns: env.ALLOWED_DSNS.split(',') })],
}),
{
async fetch(request, env) {
const { pathname } = new URL(request.url);
if (pathname === '/health') {
return new Response('ok');
}
if (pathname === '/done') {
Sentry.captureMessage('done');
return new Response('done');
}
// The app's own handler on the tunnel path, which the runtime answers before it.
if (pathname === env.TUNNEL_PATH) {
return new Response('app tunnel handler', { status: 418 });
}

return new Response('not found', { status: 404 });
},
} satisfies ExportedHandler<Env>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { getTunnelPath } from '@sentry/core';
import type { FakeIngests } from '@sentry-internal/test-utils';
import { EXPECTED_TUNNEL_OUTCOMES, runTunnelHardeningCases, startFakeIngests } from '@sentry-internal/test-utils';
import { afterAll, beforeAll, expect, it } from 'vitest';
import { createRunner } from '../../runner';

// `wrangler dev` stalls the next request after any worker answers before reading a large body, Sentry or not.
const WRANGLER_DEV_STALLS = ['70KB header line', 'binary garbage', 'rejected tunnel with a big unread body, then app'];

let ingests: FakeIngests;

beforeAll(async () => {
ingests = await startFakeIngests();
});

afterAll(async () => {
await ingests.close();
});

it('keeps the worker serving whatever is thrown at the tunnel, and reports nothing of its own', async ({ signal }) => {
const runner = createRunner(__dirname)
.withWranglerArgs(
'--var',
`ALLOWED_DSNS:${ingests.allDsns().join(',')}`,
'--var',
`TUNNEL_PATH:${getTunnelPath(ingests.dsn('ok'))}`,
)
// The first event is the app's own, so the tunnel captured no error along the way.
.expect(envelope => {
expect(envelope[1][0]?.[1]).toMatchObject({ message: 'done' });
})
.start(signal);
await runner.makeRequest('get', '/health');

const results = await runTunnelHardeningCases({
port: await runner.getPort(),
ingests,
getTunnelPath,
skip: WRANGLER_DEV_STALLS,
});

expect(results.filter(result => result.health !== '200')).toEqual([]);
expect(Object.fromEntries(results.map(result => [result.name, result.outcome]))).toMatchObject(
Object.fromEntries(
Object.entries(EXPECTED_TUNNEL_OUTCOMES).filter(([name]) => !WRANGLER_DEV_STALLS.includes(name)),
),
);

await runner.makeRequest('get', '/done');
await runner.completed();
}, 180_000);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "worker-name",
"compatibility_date": "2025-06-17",
"main": "index.ts",
"compatibility_flags": ["nodejs_compat"],
}
6 changes: 5 additions & 1 deletion packages/astro/src/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,11 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
// Ref: https://developers.cloudflare.com/workers/runtime-apis/nodejs/
updateConfig({
vite: {
plugins: [sentryCloudflareNodeWarningPlugin()],
// A Pages build gets the same `withSentry` wrap as Workers, which answers managed tunnel requests.
plugins:
command !== 'dev'
? [sentryCloudflareNodeWarningPlugin(), sentryCloudflareVitePlugin()]
: [sentryCloudflareNodeWarningPlugin()],
ssr: {
// @sentry/node is required in case we have 2 different @sentry/node
// packages installed in the same project.
Expand Down
9 changes: 4 additions & 5 deletions packages/astro/test/integration/cloudflare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ describe('Cloudflare Pages vs Workers detection', () => {
});

expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith({
vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }),
vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin', 'sentryCloudflareVitePlugin'] }),
});
});

Expand Down Expand Up @@ -208,7 +208,7 @@ describe('Cloudflare Pages vs Workers detection', () => {
});

expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith({
vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }),
vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin', 'sentryCloudflareVitePlugin'] }),
});
});

Expand Down Expand Up @@ -270,7 +270,7 @@ directory = "./dist"
});
});

it('does not add Cloudflare Vite plugins for Pages production build', async () => {
it('wraps the Worker entry with `withSentry` for a Pages production build', async () => {
getWranglerConfig.mockReturnValue({
filename: 'wrangler.json',
content: JSON.stringify({
Expand All @@ -290,9 +290,8 @@ directory = "./dist"
command: 'build',
});

// Check that sentryCloudflareVitePlugin is NOT in any of the calls
expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith({
vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }),
vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin', 'sentryCloudflareVitePlugin'] }),
});
});

Expand Down
16 changes: 14 additions & 2 deletions packages/cloudflare/src/wrapRequestHandlerWithInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CfProperties, IncomingRequestCfProperties } from '@cloudflare/work
import { NETWORK_PROTOCOL_NAME, NETWORK_PROTOCOL_VERSION } from '@sentry/conventions/attributes';
import type { SpanAttributes } from '@sentry/core';
import { getIsolationScope, safeCallback } from '@sentry/core';
import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest } from '@sentry/core/server';
import { wrapServerRequestHandler } from '@sentry/server-utils/request-handler';
import { DEBUG_BUILD } from './debug-build';
import { getHttpServerRequestOptions } from './integrations/httpServer';
Expand All @@ -13,9 +14,13 @@ import { addCloudResourceContext, addCultureContext } from './scope-utils';
import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext';
import { withInvocationIsolationScope } from './utils/invocationScope';

function getRequestErrorMechanismType(context: ExecutionContextCompat | undefined): string {
function isDurableObjectContext(context: ExecutionContextCompat | undefined): boolean {
// Durable Object fetch handlers use DO state as context (see instrumentDurableObjectWithSentry)
return context && 'storage' in context ? 'auto.faas.cloudflare.durable_object' : 'auto.http.cloudflare';
return !!context && 'storage' in context;
}

function getRequestErrorMechanismType(context: ExecutionContextCompat | undefined): string {
return isDurableObjectContext(context) ? 'auto.faas.cloudflare.durable_object' : 'auto.http.cloudflare';
}

export interface RequestHandlerWrapperOptions {
Expand Down Expand Up @@ -72,6 +77,13 @@ export function wrapRequestHandlerWithInit(
// request, where workerd allows publish, and init() above has registered subscribers.
flushDeferredChannelEvents();

// The tunnel answers these before the app, with no span. A Durable Object is only reached through the Worker,
// which answered them already.
const tunnelResponse = !isDurableObjectContext(context) && handleServedTunnelRequest(request);
if (tunnelResponse) {
return tunnelResponse.finally(() => waitUntil?.(flushAndDispose(client)));
}

addCloudResourceContext(isolationScope);
const attributes: SpanAttributes = {};
if (request.cf) {
Expand Down
32 changes: 32 additions & 0 deletions packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ExecutionContext } from '@cloudflare/workers-types';
import type { CloudflareOptions } from '@sentry/cloudflare';
import { getDefaultIntegrations, setAsyncLocalStorageAsyncContextStrategy } from '@sentry/cloudflare';
import { wrapRequestHandler } from '@sentry/cloudflare/request';
Expand Down Expand Up @@ -47,6 +48,34 @@ export const sentryCloudflareNitroPlugin =
(nitroApp: NitroApp): void => {
const traceDataMap = new WeakMap<object, ReturnType<typeof getTraceData>>();

// Nitro v3 (Nuxt 5) has no `localFetch`. It hands the Worker's own request to `fetch`, with `ctx` on it.
const nitroV3App = nitroApp as unknown as { localFetch?: unknown; fetch?: (request: Request) => Promise<Response> };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unguarded double-cast in Nitro v3

Low Severity

A new as unknown as double-cast widens nitroApp to a structural fetch shape. The nearby comment explains the Nitro v3 runtime, not why a safer type is impossible. SDK source requires that justification on each double-cast.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 02913e0. Configure here.

const appFetch = nitroV3App.fetch;
if (typeof nitroV3App.localFetch !== 'function' && typeof appFetch === 'function') {
nitroV3App.fetch = request => {
setAsyncLocalStorageAsyncContextStrategy();

const cloudflareOptions = typeof optionsOrFn === 'function' ? optionsOrFn(nitroApp) : optionsOrFn;
const runtime = (request as Request & { runtime?: { cloudflare?: { context?: ExecutionContext } } }).runtime;

return wrapRequestHandler(
{
options: {
defaultIntegrations: getDefaultIntegrations(cloudflareOptions),
_extendsDefaultIntegrations: cloudflareOptions.defaultIntegrations === undefined,
...cloudflareOptions,
},
request,
context: runtime?.cloudflare?.context,
},
() => appFetch(request),
);
};
nitroApp.hooks.hook('error', sentryCaptureErrorHook);

return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitro v3 path skips tracing tags

Medium Severity

The Nitro v3 branch wraps fetch and hooks error, then returns before registering render:html. Cloudflare setups rely on this plugin for that hook, so Nuxt 5 apps will not get Sentry tracing meta tags and distributed tracing from server to client will break.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 02913e0. Configure here.

}

nitroApp.localFetch = new Proxy(nitroApp.localFetch, {
async apply(handlerTarget, handlerThisArg, handlerArgs: [string, unknown]) {
setAsyncLocalStorageAsyncContextStrategy();
Expand All @@ -64,6 +93,9 @@ export const sentryCloudflareNitroPlugin =
const request = new Request(url, {
method: event.method,
headers: event.headers,
// Nitro read the body before calling `localFetch`. The managed tunnel answers from this request, so it
// needs the body; a `GET` or `HEAD` request can't have one.
body: event.method === 'GET' || event.method === 'HEAD' ? undefined : event.body,
// @ts-expect-error - 'cf' is a valid property in the RequestInit type for Cloudflare
cf: getCfProperties(event),
});
Expand Down
2 changes: 2 additions & 0 deletions packages/nuxt/src/runtime/utils/event-type-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ interface EventBase {
host: string;
method: string;
headers: Record<string, string>;
/** The request body Nitro already read, e.g. a `Buffer`. */
body?: BodyInit | null;
}

interface MinimalCloudflareProps {
Expand Down
Loading