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(); + }); });