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
4 changes: 1 addition & 3 deletions dev-packages/e2e-tests/test-applications/nuxt-4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 17 additions & 4 deletions packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
});
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand All @@ -10,25 +12,36 @@ vi.mock('@sentry/cloudflare/request', () => ({

const DSN = 'https://public@dsn.ingest.sentry.io/1337';

async function handleRequest(options: CloudflareOptions): Promise<void> {
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<typeof vi.fn> }> {
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', () => {
beforeEach(() => {
vi.mocked(wrapRequestHandler).mockClear();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('passes a defaultIntegrations list that extends the SDK defaults', async () => {
await handleRequest({ dsn: DSN });

Expand All @@ -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();
});
});
Loading