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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph
- Bun and Cloudflare now export the `Log` and `LogSeverityLevel` types.
- Deno now exports `fastifyIntegration`, which was already in its default integrations.

- **feat(cloudflare): Export the channel integrations**

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:

```ts
Sentry.withSentry(
env => ({
dsn: env.SENTRY_DSN,
integrations: [Sentry.openAIIntegration({ recordInputs: false, recordOutputs: false })],
}),
handler,
);
```

These integrations need the vite plugin. Without it, they record nothing, and with `debug: true` the SDK logs a warning.

## 11.8.0

### Important Changes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as Sentry from '@sentry/cloudflare';
import OpenAI from 'openai';

interface Env {
SENTRY_DSN: string;
}

// Return a canned response so the `openai` SDK runs on workerd without hitting the network.
const mockFetch: typeof fetch = async () =>
new Response(
JSON.stringify({
id: 'chatcmpl-mock123',
object: 'chat.completion',
created: 1677652288,
model: 'gpt-3.5-turbo',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Hello from OpenAI!' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1,
// `dataCollection` records inputs by default. The integration options must win over it.
integrations: [Sentry.openAIIntegration({ recordInputs: false })],
}),
{
async fetch(_request, _env, _ctx) {
// Not wrapped with `instrumentOpenAiClient`: the vite plugin instruments the `openai` package.
const client = new OpenAI({ apiKey: 'mock-api-key', fetch: mockFetch });
await client.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: 'What is the capital of France?' }],
});
return new Response('ok');
},
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { GEN_AI_INPUT_MESSAGES, GEN_AI_RESPONSE_TEXT, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import { GEN_AI_CHAT } from '@sentry/conventions/op';
import { expect, it } from 'vitest';
import { createRunner } from '../../../../runner';

it('applies the options of an openAIIntegration the user configured', async ({ signal }) => {
const runner = createRunner(__dirname).ignore('event').start(signal);

// The segment span and the gen_ai child. The segment can arrive before the child's envelope.
const spansPromise = runner.collectStreamedSpans(spansOfTrace => spansOfTrace.length === 2);

await runner.makeRequest('get', '/');

const genAiSpans = (await spansPromise).filter(span => !span.is_segment);
expect(genAiSpans).toHaveLength(1);
const attributes = genAiSpans[0]?.attributes;

// The vite plugin instrumented `openai`, so the channel integration created the span.
expect(attributes?.[SENTRY_OP]).toEqual({ type: 'string', value: GEN_AI_CHAT });
expect(attributes?.[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ai.openai' });
// `recordInputs: false` from the configured integration wins over `dataCollection`, which records
// inputs by default. Outputs keep the default.
expect(attributes?.[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
expect(attributes?.[GEN_AI_RESPONSE_TEXT]).toEqual({ type: 'string', value: '["Hello from OpenAI!"]' });
});
Comment thread
cursor[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [cloudflare(), sentryCloudflareVitePlugin({ autoInstrumentation: false })],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "openai-options-worker",
"compatibility_date": "2026-04-26",
"main": "index.ts",
"compatibility_flags": ["nodejs_compat"],
}
42 changes: 42 additions & 0 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,48 @@ export {
getInstrumentedModuleNames,
} from '@sentry/server-utils';

// The orchestrion channel integrations. When the `@sentry/cloudflare/vite`
// plugin bundles an instrumented package, the SDK adds the matching
// integration with its default options. Pass one in `integrations` to set
// options. Without the plugin, these integrations record nothing.
export {
amqplibIntegration,
anthropicAIIntegration,
awsIntegration,
dataloaderIntegration,
expressIntegration,
firebaseIntegration,
genericPoolIntegration,
googleGenAIIntegration,
graphqlIntegration,
groqIntegration,
hapiIntegration,
kafkaIntegration,
knexIntegration,
koaIntegration,
langChainIntegration,
langGraphIntegration,
lruMemoizerIntegration,
mastraIntegration,
mcpServerIntegration,
mistralAIIntegration,
mongoIntegration,
mongooseIntegration,
mysql2Integration,
mysqlIntegration,
neonIntegration,
nitroIntegration,
openAIIntegration,
piDurableIntegration,
postgresIntegration,
postgresJsIntegration,
redisIntegration,
SentryMastraExporter,
tediousIntegration,
togetherAIIntegration,
typesafeIntegration,
} from '@sentry/server-utils';

export { instrumentWorkflowWithSentry } from './workflows';

export { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
31 changes: 30 additions & 1 deletion packages/cloudflare/test/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { CloudflareOptions } from '../src/client';
import { CloudflareClient } from '../src/client';
import { getDefaultIntegrations, init } from '../src/sdk';
import { resetSdk } from './testUtils';
import { spanStreamingIntegration } from '../src/';
import { mysqlIntegration as exportedMysqlIntegration, spanStreamingIntegration } from '../src/';

describe('init', () => {
beforeEach(() => {
Expand Down Expand Up @@ -443,4 +443,33 @@ describe('getDefaultIntegrations', () => {

expect(client?.getIntegrationByName('Mysql')).toBeDefined();
});

test('keeps a channel integration the user configured instead of the registered default', async () => {
resetSdk();
const { mysqlIntegration } = await import('@sentry/server-utils');
globalThis.__SENTRY_ORCHESTRION__ = {
bundler: new Set(['mysql']),
integrations: new Map([['mysql', mysqlIntegration]]),
};
const userInstance = exportedMysqlIntegration();

const client = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [userInstance] });

expect(client?.getIntegrationByName('Mysql')).toBe(userInstance);
});

test('keeps a channel integration the user configured when its module loads after init', async () => {
resetSdk();
const { mysqlIntegration } = await import('@sentry/server-utils');
const userInstance = exportedMysqlIntegration();
const client = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [userInstance] });

globalThis.__SENTRY_ORCHESTRION__ = {
bundler: new Set(['mysql']),
integrations: new Map([['mysql', mysqlIntegration]]),
};
client?.emit('orchestrion.module-injected', 'mysql');

expect(client?.getIntegrationByName('Mysql')).toBe(userInstance);
});
Comment thread
cursor[bot] marked this conversation as resolved.
});
22 changes: 20 additions & 2 deletions packages/server-utils/src/orchestrion/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as diagnosticsChannel from '../utils/diagnosticsChannel';
import type { Client } from '@sentry/core';
import { addNonEnumerableProperty, debug, waitForTracingChannelBinding } from '@sentry/core';
import { addNonEnumerableProperty, debug, GLOBAL_OBJ, waitForTracingChannelBinding } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { getOrchestrionInjectedModules } from './detect';

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

const globalAny = globalThis as { Bun?: unknown; Deno?: unknown };
const globalAny = globalThis as { Bun?: unknown; Deno?: unknown; navigator?: { userAgent?: string } };
const isBun = typeof globalAny.Bun !== 'undefined';
const isDeno = typeof globalAny.Deno !== 'undefined';

// Labels already warned about, so a client per request does not repeat the warning.
const warnedMissingCloudflarePlugin = new Set<string>();

/**
* Run an integration's channel-subscription callback once one of its modules is
* orchestrion-injected, and never more than once.
Expand Down Expand Up @@ -113,6 +116,21 @@ export function invokeOrchestrionInstrumentation<Callback extends Instrumentatio
return;
}

// On Cloudflare, only the `@sentry/cloudflare/vite` plugin injects the channels. An entry banner
// from the plugin defines `bundler` at boot, so without it the channels never fire.
if (
DEBUG_BUILD &&
globalAny.navigator?.userAgent === 'Cloudflare-Workers' &&
!GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler &&
!warnedMissingCloudflarePlugin.has(label)
) {
warnedMissingCloudflarePlugin.add(label);
debug.warn(
`[instrumentation:${label}] The integration for "${label}" records nothing, because the ` +
'`@sentry/cloudflare/vite` plugin did not run. Add `sentryCloudflareVitePlugin()` to your Vite config.',
);
}

DEBUG_BUILD && debug.log(`[instrumentation:${label}] not injected yet, waiting for the module to load`);

const cleanup = client.on('orchestrion.module-injected', (moduleName: string) => {
Expand Down
41 changes: 40 additions & 1 deletion packages/server-utils/test/orchestrion/instrumentation.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AsyncContextStrategy } from '@sentry/core';
import { GLOBAL_OBJ, setAsyncContextStrategy } from '@sentry/core';
import { debug, GLOBAL_OBJ, setAsyncContextStrategy } from '@sentry/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { invokeOrchestrionInstrumentation } from '../../src/orchestrion/instrumentation';

Expand Down Expand Up @@ -190,4 +190,43 @@ describe('invokeOrchestrionInstrumentation', () => {
expect(callback).not.toHaveBeenCalled();
expect(client.listenerCount()).toBe(1);
});

describe('on Cloudflare', () => {
beforeEach(() => {
vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' });
});

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

it('warns once when the vite plugin did not run', () => {
const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined);

invokeOrchestrionInstrumentation(makeClient() as never, ['openai'], vi.fn(), []);
invokeOrchestrionInstrumentation(makeClient() as never, ['openai'], vi.fn(), []);

expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('`@sentry/cloudflare/vite` plugin did not run'));
});

it('does not warn when the vite plugin ran, even before any module loaded', () => {
GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: new Set() };
const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined);

invokeOrchestrionInstrumentation(makeClient() as never, ['pg'], vi.fn(), []);

expect(warn).not.toHaveBeenCalled();
});
});

it('does not warn about the Cloudflare plugin on other runtimes', () => {
const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined);

invokeOrchestrionInstrumentation(makeClient() as never, ['redis'], vi.fn(), []);

expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
});
Loading