diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/vercelai/v6/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/index.ts similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/vercelai/v6/index.ts rename to dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/index.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/vercelai/v6/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts similarity index 98% rename from dev-packages/cloudflare-integration-tests/suites/tracing/vercelai/v6/test.ts rename to dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts index 65fb16e9ab1e..556f5babbe80 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/vercelai/v6/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts @@ -31,7 +31,7 @@ it('captures a transaction with Vercel AI v6 spans via @sentry/cloudflare vercel is_segment: false, attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'gen_ai.invoke_agent' }, - 'sentry.origin': { type: 'string', value: 'auto.vercelai.otel' }, + 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'invoke_agent' }, [GEN_AI_USAGE_INPUT_TOKENS]: { type: 'integer', value: 10 }, [GEN_AI_USAGE_OUTPUT_TOKENS]: { type: 'integer', value: 20 }, @@ -49,7 +49,7 @@ it('captures a transaction with Vercel AI v6 spans via @sentry/cloudflare vercel is_segment: false, attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'gen_ai.generate_content' }, - 'sentry.origin': { type: 'string', value: 'auto.vercelai.otel' }, + 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'generate_content' }, }), }, diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/vite.config.mts new file mode 100644 index 000000000000..cee0e8c0b4b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/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/tracing/vercelai/v6/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/vercelai/v6/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/wrangler.jsonc diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/package.json index a5d574038322..f557f00e891e 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/package.json @@ -3,9 +3,9 @@ "version": "0.0.0", "private": true, "scripts": { - "deploy": "wrangler deploy", - "dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", - "build": "wrangler deploy --dry-run", + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview --port 38787", "typecheck": "tsc --noEmit", "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:prod", @@ -17,11 +17,13 @@ "ai": "^7.0.0" }, "devDependencies": { + "@cloudflare/vite-plugin": "1.52.0", "@playwright/test": "~1.56.0", "@cloudflare/workers-types": "^4.20260426.0", "@sentry-internal/test-utils": "link:../../../test-utils", "typescript": "^5.5.2", - "wrangler": "4.86.0", + "vite": "7.3.2", + "wrangler": "^4.86.0", "ws": "^8.18.3" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/playwright.config.ts index 59a70bd39838..3761c194df43 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/playwright.config.ts @@ -9,7 +9,7 @@ const APP_PORT = 38787; const config = getPlaywrightConfig( { - startCommand: `pnpm dev --port ${APP_PORT}`, + startCommand: `pnpm preview`, port: APP_PORT, }, { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/vite.config.mts b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/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()], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/wrangler.toml b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/wrangler.toml index 9e33c22a28ed..b416a58dd9ab 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/wrangler.toml +++ b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/wrangler.toml @@ -3,3 +3,8 @@ name = "cloudflare-vercelai-v7-compat" main = "src/index.ts" compatibility_date = "2026-04-26" compatibility_flags = ["nodejs_compat"] + +# `vite preview` takes no `--var` flag, so the DSN is declared here and read from +# the process environment the Playwright web server inherits. +[secrets] +required = ["E2E_TEST_DSN"] diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index f96cbdc0626f..c07a5ec019a0 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -1,5 +1,4 @@ import type { Integration } from '@sentry/core'; -import { vercelAIIntegration } from './integrations/tracing/vercelai'; import { getBaseDefaultIntegrations, initWithDefaultIntegrations } from './baseSdk'; import type { CloudflareClient, CloudflareOptions } from './client'; @@ -11,11 +10,7 @@ import type { CloudflareClient, CloudflareOptions } from './client'; * `getBaseDefaultIntegrations`. */ export function getDefaultIntegrations(options: CloudflareOptions): Integration[] { - return [ - ...getBaseDefaultIntegrations(options), - // Subscribes to the `ai` SDK's native `node:diagnostics_channel` telemetry channel. - vercelAIIntegration(), - ]; + return getBaseDefaultIntegrations(options); } /** diff --git a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts index 781ba5fd8a0c..d6acd04f8559 100644 --- a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts +++ b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts @@ -1,6 +1,7 @@ import type { CustomTransform } from '../apmTypes'; import { parse } from 'meriyah'; import { subscriberExportForModule } from '../config/channel-integration-definitions'; +import { MODULE_REGISTRATION_TRANSFORM } from '../config/registration-only'; // Tracks Program nodes we already injected into, so a package with several // instrumented files (or several configs pointing at one file) is injected only @@ -75,6 +76,10 @@ function moduleInjectedSnippet( * per file. Requires `@apm-js-collab/code-transformer` >= 0.18.1, where * built-ins dispatch through the override map and expose the originals on * `state.transforms.defaults`. + * + * Also carries the registration-only operator, which splices the same snippet + * without any channel injection — for library versions whose tracing channels + * are native. */ export function moduleInjectedTransforms( // A function is read per injected file — the webpack/Turbopack loader uses it @@ -83,15 +88,12 @@ export function moduleInjectedTransforms( // importing file's location). importSpecifier?: string | (() => string | undefined), ): Record { - const injectModuleInjected: CustomTransform = (state, program, parent, ancestry) => { - const { moduleType, module, transforms } = state as { + const spliceModuleInjected = (state: unknown, program: unknown): void => { + const { moduleType, module } = state as { moduleType?: string; module?: { name?: string }; - transforms: { defaults: { tracingChannelImport: CustomTransform } }; }; - transforms.defaults.tracingChannelImport(state, program, parent, ancestry); - const node = program as ProgramNode; if (injectedPrograms.has(node)) { return; @@ -116,5 +118,25 @@ export function moduleInjectedTransforms( node.body.splice(directiveIndex + 1, 0, ...statements); }; - return { tracingChannelImport: injectModuleInjected }; + const injectModuleInjected: CustomTransform = (state, program, parent, ancestry) => { + const { transforms } = state as { + transforms: { defaults: { tracingChannelImport: CustomTransform } }; + }; + + transforms.defaults.tracingChannelImport(state, program, parent, ancestry); + spliceModuleInjected(state, program); + }; + + // Operator for registration-only configs (`transform` field, see + // `config/registration-only.ts`): dispatched INSTEAD of `traceSync`, so no + // channel is declared and no function is wrapped. Their `astQuery: 'Program'` + // matches only the file root, so `node` is the Program itself. + const injectRegistrationOnly: CustomTransform = (state, node) => { + spliceModuleInjected(state, node); + }; + + return { + tracingChannelImport: injectModuleInjected, + [MODULE_REGISTRATION_TRANSFORM]: injectRegistrationOnly, + }; } diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index df7400104578..446ce552a434 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -1,8 +1,8 @@ /** - * Build-time metadata mapping each instrumented package (orchestrion - * `module.name`) to the channel-subscriber integration that consumes the - * channels injected into it — by the `exportName` it is published under from - * `@sentry/server-utils/orchestrion`. + * Build-time metadata mapping each channel-subscriber integration — by the + * `exportName` it is published under from `@sentry/server-utils/orchestrion` — + * to the instrumented packages (orchestrion `module.name`) whose channels it + * consumes. * * Kept in a separate, factory-free module on purpose: the module-injected * transform (reachable from every orchestrion bundler plugin) reads this to @@ -20,6 +20,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'postgresJsIntegration', modules: ['postgres'] }, { exportName: 'mysqlIntegration', modules: ['mysql'] }, { exportName: 'mysql2Integration', modules: ['mysql2'] }, + { exportName: 'mongooseIntegration', modules: ['mongoose'] }, { exportName: 'genericPoolIntegration', modules: ['generic-pool'] }, { exportName: 'lruMemoizerIntegration', modules: ['lru-memoizer'] }, { exportName: 'openAIIntegration', modules: ['openai'] }, diff --git a/packages/server-utils/src/orchestrion/config/ioredis.ts b/packages/server-utils/src/orchestrion/config/ioredis.ts index edaa7f525985..d64eb8536441 100644 --- a/packages/server-utils/src/orchestrion/config/ioredis.ts +++ b/packages/server-utils/src/orchestrion/config/ioredis.ts @@ -1,7 +1,9 @@ import type { InstrumentationConfig } from '..'; import { getModuleNames } from './module-names'; +import { registrationOnly } from './registration-only'; export const ioredisConfig = [ + registrationOnly({ name: 'ioredis', versionRange: '>=5.11.0', filePath: 'built/Redis.js' }), // ioredis `<5.11.0` (>=5.11.0 publishes its own `ioredis:*` diagnostics_channel) ...['lib/redis.js', 'built/redis.js', 'built/redis/index.js'].flatMap((filePath): InstrumentationConfig[] => [ { diff --git a/packages/server-utils/src/orchestrion/config/mongoose.ts b/packages/server-utils/src/orchestrion/config/mongoose.ts index 2a9c8c1ee4eb..b2533d01855f 100644 --- a/packages/server-utils/src/orchestrion/config/mongoose.ts +++ b/packages/server-utils/src/orchestrion/config/mongoose.ts @@ -1,5 +1,6 @@ import type { InstrumentationConfig } from '..'; import { getModuleNames } from './module-names'; +import { registrationOnly } from './registration-only'; // mongoose >= 9.7.0 publishes via its own `node:diagnostics_channel` tracing channels (handled by // `subscribeMongooseDiagnosticChannels`), so this transform is gated to `< 9.7.0` to avoid emitting @@ -37,6 +38,7 @@ const CONTEXT_CAPTURE_QUERY_METHODS = [ ] as const; export const mongooseConfig = [ + registrationOnly({ name: 'mongoose', versionRange: '>=9.7.0', filePath: 'lib/query.js' }), // Query execution // the span for most read/write operations. `op`, collection and model are // read off the `Query` at exec time. diff --git a/packages/server-utils/src/orchestrion/config/mysql2.ts b/packages/server-utils/src/orchestrion/config/mysql2.ts index 0dcb5e7067e4..edbbb3aa2984 100644 --- a/packages/server-utils/src/orchestrion/config/mysql2.ts +++ b/packages/server-utils/src/orchestrion/config/mysql2.ts @@ -1,5 +1,6 @@ import type { InstrumentationConfig } from '..'; import { getModuleNames } from './module-names'; +import { registrationOnly } from './registration-only'; // Ports `@opentelemetry/instrumentation-mysql2` (which patches `query`/`execute` on the connection // prototype) to orchestrion channel injection. @@ -21,6 +22,7 @@ import { getModuleNames } from './module-names'; // guard) — so `Auto` would crash streamed queries. `Callback` leaves that shape untouched (a rare, // row-streaming use that consumes the emitter's events), the tradeoff being it isn't traced. export const mysql2Config = [ + registrationOnly({ name: 'mysql2', versionRange: '>=3.20.0', filePath: 'lib/base/connection.js' }), { channelName: 'query', module: { name: 'mysql2', versionRange: '>=1.4.2 <3.11.5', filePath: 'lib/connection.js' }, diff --git a/packages/server-utils/src/orchestrion/config/redis.ts b/packages/server-utils/src/orchestrion/config/redis.ts index 7d5cc56839e4..4fc95fba6ef5 100644 --- a/packages/server-utils/src/orchestrion/config/redis.ts +++ b/packages/server-utils/src/orchestrion/config/redis.ts @@ -1,7 +1,9 @@ import type { InstrumentationConfig } from '..'; import { getModuleNames } from './module-names'; +import { registrationOnly } from './registration-only'; export const redisConfig = [ + registrationOnly({ name: '@redis/client', versionRange: '>=5.12.0', filePath: 'dist/lib/client/index.js' }), // redis `>=2.6.0 <4` (standalone `redis`). `internal_send_command` is an // anonymous prototype assignment (`expressionName`); it settles via the nested // `command_obj.callback`, so `kind: 'Sync'` and the subscriber wraps that callback. diff --git a/packages/server-utils/src/orchestrion/config/registration-only.ts b/packages/server-utils/src/orchestrion/config/registration-only.ts new file mode 100644 index 000000000000..336b44b9e62f --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/registration-only.ts @@ -0,0 +1,30 @@ +import type { InstrumentationConfig } from '..'; + +/** + * Name of the custom transform (see `bundler/moduleInjectedTransform.ts`) that + * splices only the module-injected registration snippet — no channel machinery. + * Prefixed so it can never shadow a built-in operator (`traceSync`, + * `tracingChannelImport`, ...), which the custom-transforms map overrides by + * name. + */ +export const MODULE_REGISTRATION_TRANSFORM = 'sentryModuleRegistration'; + +/** + * Build a registration-only config for one file of a library whose tracing + * channels are native (published by the library itself): no channels are + * injected, but transforming the file registers the module's channel-subscriber + * integration at evaluation time. `astQuery: 'Program'` matches the file root + * unconditionally, so no anchor function inside the library needs to exist. + */ +export function registrationOnly(module: { + name: string; + versionRange: string; + filePath: string; +}): InstrumentationConfig { + return { + channelName: 'module-registration', + module, + astQuery: 'Program', + transform: MODULE_REGISTRATION_TRANSFORM, + }; +} diff --git a/packages/server-utils/src/orchestrion/config/vercel-ai.ts b/packages/server-utils/src/orchestrion/config/vercel-ai.ts index 6aa86beba558..bac7f35cae00 100644 --- a/packages/server-utils/src/orchestrion/config/vercel-ai.ts +++ b/packages/server-utils/src/orchestrion/config/vercel-ai.ts @@ -1,7 +1,11 @@ import type { InstrumentationConfig } from '..'; import { getModuleNames } from './module-names'; +import { registrationOnly } from './registration-only'; export const vercelAiConfig = [ + ...['dist/index.js', 'dist/index.mjs'].map(filePath => + registrationOnly({ name: 'ai', versionRange: '>=7.0.0', filePath }), + ), // Vercel AI v6: mirror the v7 native `ai:telemetry` channel by injecting // channels into the top-level entry points. `resolveLanguageModel` is wrapped // not to span it, but so the subscriber can monkey-patch `doGenerate`/ diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index 57817b569a08..b45e5761dbe9 100644 --- a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts @@ -12,9 +12,9 @@ import { orchestrionTransformOptions } from '../../src/orchestrion/bundler/optio // The code transformer reads the instrumented package's version from its // on-disk `package.json`, so each test package needs a real directory. -function makePackage(root: string, name: string, version: string, type?: 'module' | 'commonjs'): void { +function makePackage(root: string, name: string, version: string, type?: 'module' | 'commonjs', subdir = 'lib'): void { const dir = join(root, 'node_modules', name); - mkdirSync(join(dir, 'lib'), { recursive: true }); + mkdirSync(join(dir, subdir), { recursive: true }); writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version, ...(type ? { type } : {}) })); } @@ -44,6 +44,8 @@ describe('module-injected transform', () => { makePackage(root, 'mysql', '2.18.1', 'commonjs'); makePackage(root, 'pg', '8.11.0', 'module'); makePackage(root, 'my-lib', '1.0.0', 'commonjs'); + makePackage(root, 'ioredis', '5.11.0', 'commonjs', 'built'); + makePackage(root, 'ai', '7.0.0', 'module', 'dist'); }); afterAll(() => { @@ -124,6 +126,38 @@ describe('module-injected transform', () => { expect(calls).toHaveLength(1); }); + it('injects only the registration snippet for a CJS version with native channels', () => { + const t = createCodeTransformer(orchestrionTransformOptions({})); + // No anchor function present — registration must not depend on any library + // internals existing (`astQuery: 'Program'` matches the file root). + const code = "'use strict';\nclass Redis {}\nmodule.exports = Redis;\n"; + const result = t.transform(code, join(root, 'node_modules/ioredis/built/Redis.js')); + + expect(result).not.toBeNull(); + expect(result!.code).toMatch( + /const\s*\{\s*orchestrionModuleInjected,\s*redisIntegration\s*\}\s*=\s*require\(["']@sentry\/server-utils\/orchestrion["']\)/, + ); + expect(result!.code).toContain('orchestrionModuleInjected("ioredis", redisIntegration)'); + // The library publishes its own channels, so nothing else is injected: no + // diagnostics_channel import, no channel declaration, no function wrapper. + expect(result!.code).not.toContain('diagnostics_channel'); + expect(result!.code).not.toContain('tr_ch_apm'); + expect(result!.code).toContain('class Redis'); + }); + + it('injects only the registration snippet for an ESM version with native channels', () => { + const t = createCodeTransformer(orchestrionTransformOptions({})); + const result = t.transform('export const embed = () => {};\n', join(root, 'node_modules/ai/dist/index.mjs')); + + expect(result).not.toBeNull(); + expect(result!.code).toMatch( + /import\s*\{\s*orchestrionModuleInjected,\s*vercelAIIntegration\s*\}\s*from\s*["']@sentry\/server-utils\/orchestrion["']/, + ); + expect(result!.code).toContain('orchestrionModuleInjected("ai", vercelAIIntegration)'); + expect(result!.code).not.toContain('diagnostics_channel'); + expect(result!.code).not.toContain('tr_ch_apm'); + }); + it('honors a custom import specifier (Turbopack passes an absolute path)', () => { const t = createCodeTransformer({ ...orchestrionTransformOptions({}),