diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ccb43b5b6c4..3364374116bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts new file mode 100644 index 000000000000..1f3692d73837 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts @@ -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'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts new file mode 100644 index 000000000000..8aab877f22f8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts @@ -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!"]' }); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts new file mode 100644 index 000000000000..cee0e8c0b4b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts @@ -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 })], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc new file mode 100644 index 000000000000..4c475b2fe276 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "openai-options-worker", + "compatibility_date": "2026-04-26", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 8be218013d43..e1fb6e60cee3 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -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'; diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index dcbbb7089da4..d1be4d2d4599 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -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(() => { @@ -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); + }); }); diff --git a/packages/server-utils/src/orchestrion/instrumentation.ts b/packages/server-utils/src/orchestrion/instrumentation.ts index ded8aa30ac68..0dbddefc4da7 100644 --- a/packages/server-utils/src/orchestrion/instrumentation.ts +++ b/packages/server-utils/src/orchestrion/instrumentation.ts @@ -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'; @@ -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(); + /** * Run an integration's channel-subscription callback once one of its modules is * orchestrion-injected, and never more than once. @@ -113,6 +116,21 @@ export function invokeOrchestrionInstrumentation { diff --git a/packages/server-utils/test/orchestrion/instrumentation.test.ts b/packages/server-utils/test/orchestrion/instrumentation.test.ts index fce7ffc3bf26..07c61fd5bb7a 100644 --- a/packages/server-utils/test/orchestrion/instrumentation.test.ts +++ b/packages/server-utils/test/orchestrion/instrumentation.test.ts @@ -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'; @@ -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(); + }); });