From 7f46250ffe02e7dfb0c302faadf69dbd18f730b4 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 13 Nov 2026 17:00:28 +0100 Subject: [PATCH] fix(nuxt): Answer managed tunnel requests on Cloudflare `sentryCloudflareNitroPlugin` matched the tunnel before `wrapRequestHandler` created the client. No client was current, so the match always failed, the tunnel request got no body, and the app answered it instead of the SDK. The plugin now matches the tunnel inside the `wrapRequestHandler` callback, where the client exists, and passes the body Nitro read only to the tunnel handler. Other requests still get no body, so their bodies stay off their events. The `nuxt-4-cloudflare` tunnel e2e test only checked for a 200, which the app returns too. It now waits for the forwarded envelope, and it fails without this fix. Closes #26364 Co-Authored-By: Claude Opus 5.5 --- .../test-applications/nuxt-4/package.json | 4 +- .../plugins/sentry-cloudflare.server.ts | 21 ++++++-- .../plugins/sentry-cloudflare.server.test.ts | 48 ++++++++++++++++--- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index f1c6d3f6ac46..acb0fb71101f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -87,9 +87,7 @@ "build-command": "pnpm test:build:cloudflare", "assert-command": "pnpm test:prod", "label": "nuxt-4 (cloudflare)" - } - ], - "optionalVariants": [ + }, { "runtime": "cloudflare", "build-command": "pnpm test:build:tunnel-basepath-cloudflare", diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 0f4aa12b2898..44583800ae99 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -9,7 +9,10 @@ import { getTraceData, stripUrlQueryAndFragment, } from '@sentry/core'; -import { _INTERNAL_isServedTunnelRequest as isServedTunnelRequest } from '@sentry/core/server'; +import { + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, +} from '@sentry/core/server'; import type { H3Event } from 'h3'; import type { NitroApp, NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; @@ -106,12 +109,11 @@ export const sentryCloudflareNitroPlugin = } else { // Usually, the protocol already includes ":" const url = `${event.protocol}${event.protocol.endsWith(':') ? '' : ':'}//${event.host}${pathname}`; + // Nitro read the body before calling `localFetch`. This request has no body, so other requests' bodies + // don't end up on events. Only the managed tunnel needs it, see below. const request = new Request(url, { method: event.method, headers: event.headers, - // Nitro read the body before calling `localFetch`. Only the managed tunnel answers from this request, so - // only its requests get the body, and other requests' bodies don't end up on events. - body: isServedTunnelRequest({ method: event.method, url: pathname }) ? event.body : undefined, // @ts-expect-error - 'cf' is a valid property in the RequestInit type for Cloudflare cf: getCfProperties(event), }); @@ -129,6 +131,17 @@ export const sentryCloudflareNitroPlugin = }; return wrapRequestHandler(requestHandlerOptions, () => { + // The served tunnel belongs to the client `wrapRequestHandler` creates, so it can only match from here. + // The wrapper records no span, session or body for a tunnel request. + if (event.body && isServedTunnelRequest(request)) { + const tunnelResponse = handleServedTunnelRequest( + new Request(url, { method: event.method, headers: event.headers, body: event.body }), + ); + if (tunnelResponse) { + return tunnelResponse; + } + } + setRequestTransactionName(event.method, pathname); const isolationScope = getIsolationScope(); diff --git a/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts b/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts index 33de134f47cc..003b69b227e1 100644 --- a/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts +++ b/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts @@ -1,7 +1,9 @@ import type { CloudflareOptions } from '@sentry/cloudflare'; +import { serveTunnelIntegration } from '@sentry/cloudflare'; import { wrapRequestHandler } from '@sentry/cloudflare/request'; +import { getTunnelPath } from '@sentry/core'; import type { NitroApp } from 'nitropack'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { sentryCloudflareNitroPlugin } from '../../../src/runtime/plugins/sentry-cloudflare.server'; vi.mock('@sentry/cloudflare/request', () => ({ @@ -10,18 +12,25 @@ vi.mock('@sentry/cloudflare/request', () => ({ const DSN = 'https://public@dsn.ingest.sentry.io/1337'; -async function handleRequest(options: CloudflareOptions): Promise { - const nitroApp = { localFetch: vi.fn(), hooks: { hook: vi.fn() } } as unknown as NitroApp; +async function handleRequest( + options: CloudflareOptions, + { path = '/api/db-mysql', method = 'GET', body }: { path?: string; method?: string; body?: string } = {}, +): Promise<{ response: unknown; localFetch: ReturnType }> { + const localFetch = vi.fn(() => new Response('app')); + const nitroApp = { localFetch, hooks: { hook: vi.fn() } } as unknown as NitroApp; sentryCloudflareNitroPlugin(options)(nitroApp); const event = { protocol: 'https:', host: 'example.com', - method: 'GET', - headers: {}, - context: { cf: {}, cloudflare: { context: {} } }, + method, + headers: body ? { 'content-length': String(body.length) } : {}, + body, + context: { cf: {}, cloudflare: { context: { waitUntil: vi.fn(), passThroughOnException: vi.fn() } } }, }; - await nitroApp.localFetch('/api/db-mysql', event as unknown as RequestInit); + const response = await nitroApp.localFetch(path, event as unknown as RequestInit); + + return { response, localFetch }; } describe('sentryCloudflareNitroPlugin', () => { @@ -29,6 +38,10 @@ describe('sentryCloudflareNitroPlugin', () => { vi.mocked(wrapRequestHandler).mockClear(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('passes a defaultIntegrations list that extends the SDK defaults', async () => { await handleRequest({ dsn: DSN }); @@ -53,4 +66,25 @@ describe('sentryCloudflareNitroPlugin', () => { expect.any(Function), ); }); + + it('answers a managed tunnel request with the body Nitro read', async () => { + const actual = await vi.importActual<{ wrapRequestHandler: typeof wrapRequestHandler }>( + '@sentry/cloudflare/request', + ); + vi.mocked(wrapRequestHandler).mockImplementationOnce(actual.wrapRequestHandler); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); + const envelope = `${JSON.stringify({ dsn: DSN })}\n${JSON.stringify({ type: 'event' })}\n{}`; + + const { response, localFetch } = await handleRequest( + { dsn: DSN, integrations: [serveTunnelIntegration()] }, + { path: getTunnelPath(DSN), method: 'POST', body: envelope }, + ); + + expect((response as Response).status).toBe(200); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining('dsn.ingest.sentry.io/api/1337/envelope/'), + expect.objectContaining({ method: 'POST' }), + ); + expect(localFetch).not.toHaveBeenCalled(); + }); });