Repository navigation
feat(cloudflare): Answer managed tunnel requests in withSentry
#25999
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"], | ||
| } |
| 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'; | ||
|
|
@@ -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> }; | ||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitro v3 path skips tracing tagsMedium Severity The Nitro v3 branch wraps Reviewed by Cursor Bugbot for commit 02913e0. Configure here. |
||
| } | ||
|
|
||
| nitroApp.localFetch = new Proxy(nitroApp.localFetch, { | ||
| async apply(handlerTarget, handlerThisArg, handlerArgs: [string, unknown]) { | ||
| setAsyncLocalStorageAsyncContextStrategy(); | ||
|
|
@@ -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), | ||
| }); | ||
|
|
||


There was a problem hiding this comment.
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 asdouble-cast widensnitroAppto 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.Triggered by project rule: PR Review Guidelines for Cursor Bot
Reviewed by Cursor Bugbot for commit 02913e0. Configure here.