Skip to content

Commit 96ccc02

Browse files
isaacsclaude
andauthored
fix(cloudflare): Add late channel integrations for SDKs that extend the defaults
The Nuxt and SvelteKit Cloudflare SDKs build `defaultIntegrations` from the Cloudflare defaults and pass the list to the SDK. Since the SDK treats any `defaultIntegrations` list as an opt-out, it did not add the channel integration of a module that loads after `init()`, such as the `mysql` driver in a route chunk. The `nuxt-4-cloudflare` and `sveltekit-cloudflare-pages` E2E tests then got no db spans and timed out. The new internal option `extendsDefaultIntegrations` tells the SDK that the list extends the defaults, so it adds late integrations again. The user's `integrations` function still filters them. Nuxt and SvelteKit set the option only when they build the list. A list that the user passes still turns off late integrations. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 3734b6f commit 96ccc02

7 files changed

Lines changed: 123 additions & 5 deletions

File tree

‎packages/cloudflare/src/baseSdk.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,10 @@ export function initWithDefaultIntegrations(
107107
return cached;
108108
}
109109

110-
// `false` or a custom list turns off the SDK defaults, late-loaded ones included.
111-
const usesSdkDefaults = options.defaultIntegrations === undefined;
112-
if (usesSdkDefaults) {
110+
// `false` or a custom list turns off the SDK defaults, late-loaded ones
111+
// included, unless an SDK built the list from the defaults.
112+
const usesSdkDefaults = options.defaultIntegrations === undefined || !!options.extendsDefaultIntegrations;
113+
if (options.defaultIntegrations === undefined) {
113114
options.defaultIntegrations = getDefaultIntegrationsImpl(options);
114115
}
115116

‎packages/cloudflare/src/client.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,16 @@ interface BaseCloudflareOptions {
351351
*/
352352
enableDedupe?: boolean;
353353

354+
/**
355+
* @ignore Used internally by SDKs that build `defaultIntegrations` from the Cloudflare defaults.
356+
* @hidden Used internally by SDKs that build `defaultIntegrations` from the Cloudflare defaults.
357+
*
358+
* Marks the `defaultIntegrations` list as an extension of the SDK defaults, so the SDK also adds
359+
* the integrations of instrumented modules that load after `init()`.
360+
* @default false
361+
*/
362+
extendsDefaultIntegrations?: boolean;
363+
354364
/**
355365
* The Cloudflare SDK is not OpenTelemetry native. By default (`false`) it does not set up a tracer
356366
* provider; spans are emitted via the SDK's own instrumentation and scopes are isolated with

‎packages/cloudflare/test/sdk.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,29 @@ describe('getDefaultIntegrations', () => {
524524
expect(client?.getIntegrationByName('Mysql')).toBeUndefined();
525525
});
526526

527+
test('adds it when a custom defaultIntegrations list extends the SDK defaults', async () => {
528+
resetSdk();
529+
const client = init({ dsn: DSN, defaultIntegrations: [], extendsDefaultIntegrations: true });
530+
531+
await loadMysqlLate(client);
532+
533+
expect(client?.getIntegrationByName('Mysql')).toBeDefined();
534+
});
535+
536+
test('does not add it when the integrations function filtered it out of a list that extends the SDK defaults', async () => {
537+
resetSdk();
538+
const client = init({
539+
dsn: DSN,
540+
defaultIntegrations: [],
541+
extendsDefaultIntegrations: true,
542+
integrations: defaults => defaults.filter(integration => integration.name !== 'Mysql'),
543+
});
544+
545+
await loadMysqlLate(client);
546+
547+
expect(client?.getIntegrationByName('Mysql')).toBeUndefined();
548+
});
549+
527550
test.each([
528551
['false', false as const],
529552
['a custom list', [] as Integration[]],

‎packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ export const sentryCloudflareNitroPlugin =
7171
const requestHandlerOptions = {
7272
// `wrapRequestHandler` only sets up the integrations that work without `nodejs_compat`.
7373
// Nitro's Cloudflare presets require the flag anyway, so opt into the full set.
74-
options: { defaultIntegrations: getDefaultIntegrations(cloudflareOptions), ...cloudflareOptions },
74+
options: {
75+
defaultIntegrations: getDefaultIntegrations(cloudflareOptions),
76+
extendsDefaultIntegrations: cloudflareOptions.defaultIntegrations === undefined,
77+
...cloudflareOptions,
78+
},
7579
request,
7680
context: getCloudflareProperties(event).context,
7781
};
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { CloudflareOptions } from '@sentry/cloudflare';
2+
import { wrapRequestHandler } from '@sentry/cloudflare/request';
3+
import type { NitroApp } from 'nitropack';
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { sentryCloudflareNitroPlugin } from '../../../src/runtime/plugins/sentry-cloudflare.server';
6+
7+
vi.mock('@sentry/cloudflare/request', () => ({
8+
wrapRequestHandler: vi.fn((_options: unknown, callback: () => unknown) => callback()),
9+
}));
10+
11+
const DSN = 'https://public@dsn.ingest.sentry.io/1337';
12+
13+
async function handleRequest(options: CloudflareOptions): Promise<void> {
14+
const nitroApp = { localFetch: vi.fn(), hooks: { hook: vi.fn() } } as unknown as NitroApp;
15+
sentryCloudflareNitroPlugin(options)(nitroApp);
16+
17+
const event = {
18+
protocol: 'https:',
19+
host: 'example.com',
20+
method: 'GET',
21+
headers: {},
22+
context: { cf: {}, cloudflare: { context: {} } },
23+
};
24+
await nitroApp.localFetch('/api/db-mysql', event as unknown as RequestInit);
25+
}
26+
27+
describe('sentryCloudflareNitroPlugin', () => {
28+
beforeEach(() => {
29+
vi.mocked(wrapRequestHandler).mockClear();
30+
});
31+
32+
it('passes a defaultIntegrations list that extends the SDK defaults', async () => {
33+
await handleRequest({ dsn: DSN });
34+
35+
expect(wrapRequestHandler).toHaveBeenCalledWith(
36+
expect.objectContaining({
37+
options: expect.objectContaining({
38+
defaultIntegrations: expect.any(Array),
39+
extendsDefaultIntegrations: true,
40+
}),
41+
}),
42+
expect.any(Function),
43+
);
44+
});
45+
46+
it('does not mark a defaultIntegrations list the user passed as extending the SDK defaults', async () => {
47+
await handleRequest({ dsn: DSN, defaultIntegrations: [] });
48+
49+
expect(wrapRequestHandler).toHaveBeenCalledWith(
50+
expect.objectContaining({
51+
options: expect.objectContaining({ defaultIntegrations: [], extendsDefaultIntegrations: false }),
52+
}),
53+
expect.any(Function),
54+
);
55+
});
56+
});

‎packages/sveltekit/src/worker/cloudflare.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle {
2323
rewriteFramesIntegration(),
2424
svelteKitSpansIntegration(),
2525
],
26+
extendsDefaultIntegrations: options.defaultIntegrations === undefined,
2627
// SvelteKit emits its own OpenTelemetry spans (Kit tracing), so, like the Node SvelteKit SDK, it
2728
// defaults to registering the tracer provider instead of inheriting Cloudflare's no-provider default.
2829
// Only the `init`-backed wrapper from the main entry point honors this; `@sentry/cloudflare/request`

‎packages/sveltekit/test/worker/cloudflare.test.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ describe('initCloudflareSentryHandle', () => {
6363
{
6464
// SvelteKit emits its own OpenTelemetry spans, so it opts into the tracer provider rather than
6565
// inheriting Cloudflare's no-provider default.
66-
options: expect.objectContaining({ dsn: options.dsn, enableOpenTelemetrySetup: true }),
66+
options: expect.objectContaining({
67+
dsn: options.dsn,
68+
enableOpenTelemetrySetup: true,
69+
extendsDefaultIntegrations: true,
70+
}),
6771
request,
6872
context,
6973
captureErrors: false,
@@ -74,6 +78,25 @@ describe('initCloudflareSentryHandle', () => {
7478
expect(resolve).toHaveBeenCalledTimes(1);
7579
});
7680

81+
it('does not mark a defaultIntegrations list the user passed as extending the SDK defaults', async () => {
82+
const { options, event, resolve } = getHandlerInput();
83+
84+
// @ts-expect-error - resolving an empty object is enough for this test
85+
vi.mocked(wrapRequestHandler).mockImplementationOnce((_, cb) => cb());
86+
87+
const handle = initCloudflareSentryHandle({ ...options, defaultIntegrations: [] });
88+
89+
// @ts-expect-error - only passing a partial event object
90+
await handle({ event, resolve });
91+
92+
expect(wrapRequestHandler).toHaveBeenCalledWith(
93+
expect.objectContaining({
94+
options: expect.objectContaining({ defaultIntegrations: [], extendsDefaultIntegrations: false }),
95+
}),
96+
expect.any(Function),
97+
);
98+
});
99+
77100
it('adds flag to skip request isolation in subsequent sentry handler', async () => {
78101
const { options, event, resolve } = getHandlerInput();
79102
const locals = {};

0 commit comments

Comments
 (0)