Skip to content

Commit 5dfe525

Browse files
isaacsclaude
andcommitted
feat(cloudflare): Export the channel integrations
When the `@sentry/cloudflare/vite` plugin bundles an instrumented package, such as `pg` or `openai`, the SDK adds the matching channel integration. It calls each factory with no arguments, so a Cloudflare user always got the default options. They could not, for example, turn off prompt and response capture with `openAIIntegration({ recordInputs: false })`, as Node, Bun and Deno users can. `@sentry/cloudflare` now exports the channel integrations that Node exports. These are the integrations that the vite plugin can register, so their code already runs on workerd. A user instance passed in `integrations` replaces the registered default with the same name, at `init()` and also when the module loads after `init()`. Both packages set `"sideEffects": false`, so a worker bundles only the integrations it imports. The `withSentry` bundle does not change in size. These integrations get data only when the vite plugin injected the channels. Without the plugin, a configured integration records nothing. On Cloudflare, `invokeOrchestrionInstrumentation` now logs a debug warning, once for each integration, when the plugin did not run. Fixes #25575 Fixes JS-3973 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 47558e0 commit 5dfe525

9 files changed

Lines changed: 232 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph
7878
- Bun and Cloudflare now export the `Log` and `LogSeverityLevel` types.
7979
- Deno now exports `fastifyIntegration`, which was already in its default integrations.
8080

81+
- **feat(cloudflare): Export the channel integrations**
82+
83+
When the `@sentry/cloudflare/vite` plugin bundles an instrumented package (for example `pg` or `openai`), the SDK adds the matching integration with its default options. `@sentry/cloudflare` now exports these integrations, so you can set their options, for example:
84+
85+
```ts
86+
Sentry.withSentry(
87+
env => ({
88+
dsn: env.SENTRY_DSN,
89+
integrations: [Sentry.openAIIntegration({ recordInputs: false, recordOutputs: false })],
90+
}),
91+
handler,
92+
);
93+
```
94+
95+
These integrations need the vite plugin. Without it, they record nothing, and with `debug: true` the SDK logs a warning.
96+
8197
## 11.8.0
8298

8399
### Important Changes
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import OpenAI from 'openai';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
}
7+
8+
// Return a canned response so the `openai` SDK runs on workerd without hitting the network.
9+
const mockFetch: typeof fetch = async () =>
10+
new Response(
11+
JSON.stringify({
12+
id: 'chatcmpl-mock123',
13+
object: 'chat.completion',
14+
created: 1677652288,
15+
model: 'gpt-3.5-turbo',
16+
choices: [
17+
{
18+
index: 0,
19+
message: { role: 'assistant', content: 'Hello from OpenAI!' },
20+
finish_reason: 'stop',
21+
},
22+
],
23+
usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 },
24+
}),
25+
{ status: 200, headers: { 'content-type': 'application/json' } },
26+
);
27+
28+
export default Sentry.withSentry(
29+
(env: Env) => ({
30+
dsn: env.SENTRY_DSN,
31+
tracesSampleRate: 1,
32+
// `dataCollection` records inputs by default. The integration options must win over it.
33+
integrations: [Sentry.openAIIntegration({ recordInputs: false })],
34+
}),
35+
{
36+
async fetch(_request, _env, _ctx) {
37+
// Not wrapped with `instrumentOpenAiClient`: the vite plugin instruments the `openai` package.
38+
const client = new OpenAI({ apiKey: 'mock-api-key', fetch: mockFetch });
39+
await client.chat.completions.create({
40+
model: 'gpt-3.5-turbo',
41+
messages: [{ role: 'user', content: 'What is the capital of France?' }],
42+
});
43+
return new Response('ok');
44+
},
45+
},
46+
);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { GEN_AI_INPUT_MESSAGES, GEN_AI_RESPONSE_TEXT, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
2+
import { GEN_AI_CHAT } from '@sentry/conventions/op';
3+
import { expect, it } from 'vitest';
4+
import { createRunner } from '../../../../runner';
5+
6+
it('applies the options of an openAIIntegration the user configured', async ({ signal }) => {
7+
const runner = createRunner(__dirname).ignore('event').start(signal);
8+
9+
// The segment span and the gen_ai child. The segment can arrive before the child's envelope.
10+
const spansPromise = runner.collectStreamedSpans(spansOfTrace => spansOfTrace.length === 2);
11+
12+
await runner.makeRequest('get', '/');
13+
14+
const genAiSpans = (await spansPromise).filter(span => !span.is_segment);
15+
expect(genAiSpans).toHaveLength(1);
16+
const attributes = genAiSpans[0]?.attributes;
17+
18+
// The vite plugin instrumented `openai`, so the channel integration created the span.
19+
expect(attributes?.[SENTRY_OP]).toEqual({ type: 'string', value: GEN_AI_CHAT });
20+
expect(attributes?.[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ai.openai' });
21+
// `recordInputs: false` from the configured integration wins over `dataCollection`, which records
22+
// inputs by default. Outputs keep the default.
23+
expect(attributes?.[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
24+
expect(attributes?.[GEN_AI_RESPONSE_TEXT]).toEqual({ type: 'string', value: '["Hello from OpenAI!"]' });
25+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { cloudflare } from '@cloudflare/vite-plugin';
2+
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
3+
import { defineConfig } from 'vite';
4+
5+
export default defineConfig({
6+
plugins: [cloudflare(), sentryCloudflareVitePlugin({ autoInstrumentation: false })],
7+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "openai-options-worker",
3+
"compatibility_date": "2026-04-26",
4+
"main": "index.ts",
5+
"compatibility_flags": ["nodejs_compat"],
6+
}

‎packages/cloudflare/src/index.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,48 @@ export {
152152
getInstrumentedModuleNames,
153153
} from '@sentry/server-utils';
154154

155+
// The orchestrion channel integrations. When the `@sentry/cloudflare/vite`
156+
// plugin bundles an instrumented package, the SDK adds the matching
157+
// integration with its default options. Pass one in `integrations` to set
158+
// options. Without the plugin, these integrations record nothing.
159+
export {
160+
amqplibIntegration,
161+
anthropicAIIntegration,
162+
awsIntegration,
163+
dataloaderIntegration,
164+
expressIntegration,
165+
firebaseIntegration,
166+
genericPoolIntegration,
167+
googleGenAIIntegration,
168+
graphqlIntegration,
169+
groqIntegration,
170+
hapiIntegration,
171+
kafkaIntegration,
172+
knexIntegration,
173+
koaIntegration,
174+
langChainIntegration,
175+
langGraphIntegration,
176+
lruMemoizerIntegration,
177+
mastraIntegration,
178+
mcpServerIntegration,
179+
mistralAIIntegration,
180+
mongoIntegration,
181+
mongooseIntegration,
182+
mysql2Integration,
183+
mysqlIntegration,
184+
neonIntegration,
185+
nitroIntegration,
186+
openAIIntegration,
187+
piDurableIntegration,
188+
postgresIntegration,
189+
postgresJsIntegration,
190+
redisIntegration,
191+
SentryMastraExporter,
192+
tediousIntegration,
193+
togetherAIIntegration,
194+
typesafeIntegration,
195+
} from '@sentry/server-utils';
196+
155197
export { instrumentWorkflowWithSentry } from './workflows';
156198

157199
export { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { CloudflareOptions } from '../src/client';
66
import { CloudflareClient } from '../src/client';
77
import { getDefaultIntegrations, init } from '../src/sdk';
88
import { resetSdk } from './testUtils';
9-
import { spanStreamingIntegration } from '../src/';
9+
import { mysqlIntegration as exportedMysqlIntegration, spanStreamingIntegration } from '../src/';
1010

1111
describe('init', () => {
1212
beforeEach(() => {
@@ -443,4 +443,33 @@ describe('getDefaultIntegrations', () => {
443443

444444
expect(client?.getIntegrationByName('Mysql')).toBeDefined();
445445
});
446+
447+
test('keeps a channel integration the user configured instead of the registered default', async () => {
448+
resetSdk();
449+
const { mysqlIntegration } = await import('@sentry/server-utils');
450+
globalThis.__SENTRY_ORCHESTRION__ = {
451+
bundler: new Set(['mysql']),
452+
integrations: new Map([['mysql', mysqlIntegration]]),
453+
};
454+
const userInstance = exportedMysqlIntegration();
455+
456+
const client = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [userInstance] });
457+
458+
expect(client?.getIntegrationByName('Mysql')).toBe(userInstance);
459+
});
460+
461+
test('keeps a channel integration the user configured when its module loads after init', async () => {
462+
resetSdk();
463+
const { mysqlIntegration } = await import('@sentry/server-utils');
464+
const userInstance = exportedMysqlIntegration();
465+
const client = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [userInstance] });
466+
467+
globalThis.__SENTRY_ORCHESTRION__ = {
468+
bundler: new Set(['mysql']),
469+
integrations: new Map([['mysql', mysqlIntegration]]),
470+
};
471+
client?.emit('orchestrion.module-injected', 'mysql');
472+
473+
expect(client?.getIntegrationByName('Mysql')).toBe(userInstance);
474+
});
446475
});

‎packages/server-utils/src/orchestrion/instrumentation.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as diagnosticsChannel from '../utils/diagnosticsChannel';
22
import type { Client } from '@sentry/core';
3-
import { addNonEnumerableProperty, debug, waitForTracingChannelBinding } from '@sentry/core';
3+
import { addNonEnumerableProperty, debug, GLOBAL_OBJ, waitForTracingChannelBinding } from '@sentry/core';
44
import { DEBUG_BUILD } from '../debug-build';
55
import { getOrchestrionInjectedModules } from './detect';
66

@@ -13,10 +13,13 @@ const INSTRUMENTED = Symbol.for('SentryOrchestrionInstrumented');
1313
// oxlint-disable-next-line typescript/no-explicit-any
1414
type InstrumentationFn = ((...args: any[]) => void) & { [INSTRUMENTED]?: boolean };
1515

16-
const globalAny = globalThis as { Bun?: unknown; Deno?: unknown };
16+
const globalAny = globalThis as { Bun?: unknown; Deno?: unknown; navigator?: { userAgent?: string } };
1717
const isBun = typeof globalAny.Bun !== 'undefined';
1818
const isDeno = typeof globalAny.Deno !== 'undefined';
1919

20+
// Labels already warned about, so a client per request does not repeat the warning.
21+
const warnedMissingCloudflarePlugin = new Set<string>();
22+
2023
/**
2124
* Run an integration's channel-subscription callback once one of its modules is
2225
* orchestrion-injected, and never more than once.
@@ -113,6 +116,21 @@ export function invokeOrchestrionInstrumentation<Callback extends Instrumentatio
113116
return;
114117
}
115118

119+
// On Cloudflare, only the `@sentry/cloudflare/vite` plugin injects the channels. An entry banner
120+
// from the plugin defines `bundler` at boot, so without it the channels never fire.
121+
if (
122+
DEBUG_BUILD &&
123+
globalAny.navigator?.userAgent === 'Cloudflare-Workers' &&
124+
!GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler &&
125+
!warnedMissingCloudflarePlugin.has(label)
126+
) {
127+
warnedMissingCloudflarePlugin.add(label);
128+
debug.warn(
129+
`[instrumentation:${label}] The integration for "${label}" records nothing, because the ` +
130+
'`@sentry/cloudflare/vite` plugin did not run. Add `sentryCloudflareVitePlugin()` to your Vite config.',
131+
);
132+
}
133+
116134
DEBUG_BUILD && debug.log(`[instrumentation:${label}] not injected yet, waiting for the module to load`);
117135

118136
const cleanup = client.on('orchestrion.module-injected', (moduleName: string) => {

‎packages/server-utils/test/orchestrion/instrumentation.test.ts‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { AsyncContextStrategy } from '@sentry/core';
2-
import { GLOBAL_OBJ, setAsyncContextStrategy } from '@sentry/core';
2+
import { debug, GLOBAL_OBJ, setAsyncContextStrategy } from '@sentry/core';
33
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
44
import { invokeOrchestrionInstrumentation } from '../../src/orchestrion/instrumentation';
55

@@ -190,4 +190,43 @@ describe('invokeOrchestrionInstrumentation', () => {
190190
expect(callback).not.toHaveBeenCalled();
191191
expect(client.listenerCount()).toBe(1);
192192
});
193+
194+
describe('on Cloudflare', () => {
195+
beforeEach(() => {
196+
vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' });
197+
});
198+
199+
afterEach(() => {
200+
vi.unstubAllGlobals();
201+
vi.restoreAllMocks();
202+
});
203+
204+
it('warns once when the vite plugin did not run', () => {
205+
const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined);
206+
207+
invokeOrchestrionInstrumentation(makeClient() as never, ['openai'], vi.fn(), []);
208+
invokeOrchestrionInstrumentation(makeClient() as never, ['openai'], vi.fn(), []);
209+
210+
expect(warn).toHaveBeenCalledTimes(1);
211+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('`@sentry/cloudflare/vite` plugin did not run'));
212+
});
213+
214+
it('does not warn when the vite plugin ran, even before any module loaded', () => {
215+
GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: new Set() };
216+
const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined);
217+
218+
invokeOrchestrionInstrumentation(makeClient() as never, ['pg'], vi.fn(), []);
219+
220+
expect(warn).not.toHaveBeenCalled();
221+
});
222+
});
223+
224+
it('does not warn about the Cloudflare plugin on other runtimes', () => {
225+
const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined);
226+
227+
invokeOrchestrionInstrumentation(makeClient() as never, ['redis'], vi.fn(), []);
228+
229+
expect(warn).not.toHaveBeenCalled();
230+
warn.mockRestore();
231+
});
193232
});

0 commit comments

Comments
 (0)