diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/.gitignore new file mode 100644 index 000000000000..37cbd6339404 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/.gitignore @@ -0,0 +1,2 @@ +dist +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/docker-compose.yml b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/docker-compose.yml new file mode 100644 index 000000000000..e07e3e50ccd6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/docker-compose.yml @@ -0,0 +1,18 @@ +services: + db: + image: mysql:8.0 + restart: always + container_name: e2e-tests-cloudflare-orchestrion-mysql + # The `mysql` 2.x driver doesn't speak MySQL 8's default + # `caching_sha2_password` auth, so force the legacy plugin. + command: ['--default-authentication-plugin=mysql_native_password'] + ports: + - '3306:3306' + environment: + MYSQL_ROOT_PASSWORD: password + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -ppassword'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-setup.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-setup.mjs new file mode 100644 index 000000000000..9ba25cd71638 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start MySQL via Docker Compose. `--wait` blocks until the healthcheck in + // docker-compose.yml passes, so the worker can connect on the first request. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json new file mode 100644 index 000000000000..7b8f571c423d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json @@ -0,0 +1,34 @@ +{ + "name": "cloudflare-orchestrion-mysql", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "test": "playwright test", + "typecheck": "tsc --noEmit", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test" + }, + "dependencies": { + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "mysql": "2.18.1" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.35.0", + "@playwright/test": "~1.56.0", + "@cloudflare/workers-types": "^4.20260629.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^24.12.4", + "typescript": "^5.5.2", + "vite": "7.3.2", + "wrangler": "^4.61.0", + "ws": "^8.18.3" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/playwright.config.ts new file mode 100644 index 000000000000..d6e6fa435f6c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/playwright.config.ts @@ -0,0 +1,22 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +// `vite build` (where the Sentry plugin's orchestrion transform runs) produces +// the worker; `pnpm preview` (`wrangler dev`, following the vite plugin's +// `.wrangler/deploy` redirect to the built output) serves it. `globalSetup` +// spins up the MySQL container the worker connects to. +const config = getPlaywrightConfig( + { + startCommand: 'pnpm preview', + port: 8787, + }, + { + workers: '100%', + retries: 0, + }, +); + +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/env.d.ts new file mode 100644 index 000000000000..eb80bafb4834 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/env.d.ts @@ -0,0 +1,3 @@ +interface Env { + E2E_TEST_DSN: string; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/index.ts new file mode 100644 index 000000000000..2cccb3c26c64 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/index.ts @@ -0,0 +1,73 @@ +import * as Sentry from '@sentry/cloudflare'; +// @ts-ignore -- `mysql` ships no type declarations; only needed at runtime. +import mysql from 'mysql'; + +// The `@sentry/cloudflare/vite` plugin's orchestrion transform injects the +// `orchestrion:mysql:query` diagnostics channel into the bundled `mysql` +// package at build time. The SDK detects the injection and subscribes to the +// channel, so the queries below produce `db` spans with no OTel require-hook — +// which wouldn't work in workerd anyway. + +interface Connection { + query(sql: string, cb: (err: unknown, results?: unknown) => void): void; + end(cb?: (err: unknown) => void): void; + on(event: string, cb: (err: unknown) => void): void; +} + +interface MysqlModule { + createConnection(opts: { host: string; port: number; user: string; password: string }): Connection; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, + transportOptions: { + bufferSize: 1000, + }, + }), + { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + // Runs two queries, the second NESTED inside the first's callback. mysql + // dispatches that callback from its socket data handler (a fresh async + // context), so the nested query's span only lands on this request's + // http.server transaction if the channel subscriber restored the parent + // span across that async boundary. + if (url.pathname === '/test-mysql') { + // The connection is created inside the handler: workerd forbids I/O in + // global scope, and mysql opens its socket lazily on the first query. + const connection = (mysql as MysqlModule).createConnection({ + host: '127.0.0.1', + port: 3306, + user: 'root', + password: 'password', + }); + + // Swallow connection-level errors so a socket hiccup doesn't become an + // uncaught exception that fails the request unrelated to the spans. + connection.on('error', () => { + // no-op + }); + + await new Promise((resolve, reject) => { + connection.query('SELECT 1 + 1 AS solution', (err: unknown) => { + if (err) return reject(err); + connection.query('SELECT NOW()', (err2: unknown) => { + connection.end(); + if (err2) return reject(err2); + resolve(); + }); + }); + }); + + return Response.json({ status: 'ok' }); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/start-event-proxy.mjs new file mode 100644 index 000000000000..ebb560fb9f3c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-orchestrion-mysql', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tests/mysql.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tests/mysql.test.ts new file mode 100644 index 000000000000..e59c3ab11ae2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tests/mysql.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ baseURL }) => { + // Each incoming request gets a Sentry http.server transaction; the mysql + // queries run inside it, so their db spans attach to it. The + // `orchestrion:mysql:query` channel was injected into the bundled `mysql` + // package at build time by `@sentry/cloudflare/vite`, and the Cloudflare SDK + // subscribes to it once it detects the injection. + const transactionPromise = waitForTransaction('cloudflare-orchestrion-mysql', event => { + return ( + event?.contexts?.trace?.op === 'http.server' && + (event.request?.url ?? '').includes('/test-mysql') && + (event.spans?.some(span => span.op === 'db') ?? false) + ); + }); + + const res = await fetch(`${baseURL}/test-mysql`); + expect(res.status).toBe(200); + await res.json(); + + const transaction = await transactionPromise; + const dbSpans = transaction.spans!.filter(span => span.op === 'db'); + + const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution'); + expect(firstQuery).toBeDefined(); + expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.orchestrion.mysql'); + expect(firstQuery!.data?.['db.system']).toBe('mysql'); + expect(firstQuery!.data?.['db.statement']).toBe('SELECT 1 + 1 AS solution'); + expect(firstQuery!.data?.['net.peer.name']).toBe('127.0.0.1'); + expect(firstQuery!.data?.['net.peer.port']).toBe(3306); + expect(firstQuery!.data?.['db.user']).toBe('root'); +}); + +test('a nested query lands on the same transaction (async context restored)', async ({ baseURL }) => { + // The second query runs inside the first query's callback — i.e. across + // mysql's async socket-callback dispatch. Both spans appearing on the SAME + // http.server transaction proves the channel subscriber restored the parent + // span across that async boundary (otherwise the nested query would start its + // own trace and never join this transaction). + const transactionPromise = waitForTransaction('cloudflare-orchestrion-mysql', event => { + return ( + event?.contexts?.trace?.op === 'http.server' && + (event.request?.url ?? '').includes('/test-mysql') && + (event.spans?.filter(span => span.op === 'db').length ?? 0) >= 2 + ); + }); + + const res = await fetch(`${baseURL}/test-mysql`); + expect(res.status).toBe(200); + await res.json(); + + const transaction = await transactionPromise; + const descriptions = transaction.spans!.filter(span => span.op === 'db').map(span => span.description); + expect(descriptions).toContain('SELECT 1 + 1 AS solution'); + expect(descriptions).toContain('SELECT NOW()'); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tsconfig.json new file mode 100644 index 000000000000..0bd378d7c8f8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "module": "es2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "node"], + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true + }, + "include": ["src/**/*"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/vite.config.ts new file mode 100644 index 000000000000..b1847a2ee380 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/vite.config.ts @@ -0,0 +1,10 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + // The Sentry plugin runs first so its orchestrion transform injects the + // `orchestrion:mysql:query` diagnostics channel into the bundled `mysql` + // package before `@cloudflare/vite-plugin` finalizes the worker bundle. + plugins: [sentryCloudflareVitePlugin(), cloudflare()], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/wrangler.jsonc new file mode 100644 index 000000000000..dd811d6c177c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-orchestrion-mysql", + "main": "src/index.ts", + "compatibility_date": "2026-06-29", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 00c6202eacea..70e8a6898667 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -36,6 +36,20 @@ "types": "./build/types/request.d.ts", "default": "./build/cjs/request.js" } + }, + "./vite": { + "types": "./build/types/vite/index.d.ts", + "import": "./build/esm/vite/index.js" + }, + "./orchestrion": { + "import": { + "types": "./build/types/orchestrion.d.ts", + "default": "./build/esm/orchestrion.js" + }, + "require": { + "types": "./build/types/orchestrion.d.ts", + "default": "./build/cjs/orchestrion.js" + } } }, "typesVersions": { @@ -50,7 +64,8 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/core": "10.63.0" + "@sentry/core": "10.63.0", + "@sentry/server-utils": "10.63.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x" @@ -89,5 +104,8 @@ "volta": { "extends": "../../package.json" }, - "sideEffects": false + "sideEffects": [ + "./build/esm/orchestrion.js", + "./build/cjs/orchestrion.js" + ] } diff --git a/packages/cloudflare/rollup.npm.config.mjs b/packages/cloudflare/rollup.npm.config.mjs index 84a06f2fb64a..9073c29b222b 100644 --- a/packages/cloudflare/rollup.npm.config.mjs +++ b/packages/cloudflare/rollup.npm.config.mjs @@ -1,3 +1,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; -export default makeNPMConfigVariants(makeBaseNPMConfig()); +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts', 'src/vite/index.ts', 'src/orchestrion.ts'], + }), +); diff --git a/packages/cloudflare/src/orchestrion.ts b/packages/cloudflare/src/orchestrion.ts new file mode 100644 index 000000000000..48e574d62a84 --- /dev/null +++ b/packages/cloudflare/src/orchestrion.ts @@ -0,0 +1,12 @@ +// Side-effect registration module for the orchestrion channel-subscriber +// integrations (mysql, pg, …). NOT imported by the SDK itself — the +// `@sentry/cloudflare/vite` plugin injects an import of this module into the +// worker bundle alongside the diagnostics-channel injection it performs. +// `getDefaultIntegrations` then picks the factories up from the global marker, +// so builds without the plugin never contain the integration code (and have no +// channels to subscribe to anyway). +// +// Listed in `package.json#sideEffects` so bundlers keep the bare import. +import { registerChannelIntegrations } from '@sentry/server-utils/orchestrion'; + +registerChannelIntegrations(); diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index d74ab861bb74..4d08063c7aff 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -14,6 +14,7 @@ import { import type { CloudflareClientOptions, CloudflareOptions } from './client'; import { CloudflareClient } from './client'; import { makeFlushLock } from './flush'; +import { getRegisteredChannelIntegrations } from '@sentry/server-utils/orchestrion'; import { httpServerIntegration } from './integrations/httpServer'; import { fetchIntegration } from './integrations/fetch'; import { honoIntegration } from './integrations/hono'; @@ -44,6 +45,13 @@ export function getDefaultIntegrations(options: CloudflareOptions): Integration[ httpServerIntegration(), requestDataIntegration(cookiesEnabled ? undefined : { include: { cookies: false } }), consoleIntegration(), + // The orchestrion diagnostics-channel subscribers (mysql, pg, …). The + // `@sentry/cloudflare/vite` plugin injects the channels at build time and + // adds the `@sentry/cloudflare/orchestrion` registration module to the + // bundle, which puts the subscriber factories on the global marker. Read + // from there instead of importing them so bundles built without the + // plugin — where the channels would never fire — don't ship the code. + ...getRegisteredChannelIntegrations(), ]; } diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts new file mode 100644 index 000000000000..41812040d72e --- /dev/null +++ b/packages/cloudflare/src/vite/index.ts @@ -0,0 +1,52 @@ +// Published ESM-only via the `@sentry/cloudflare/vite` subpath export: +// `@sentry/server-utils/orchestrion/vite` exposes no `require` condition, so a +// CJS entry here would fail at resolution time (ERR_PACKAGE_PATH_NOT_EXPORTED). +// The CJS rollup variant still emits this file, but `package.json` doesn't +// expose it — same setup as `@sentry/server-utils/orchestrion/vite` itself. +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; + +/** + * Sentry Vite plugin for Cloudflare Workers. + * + * Injects `diagnostics_channel.tracingChannel` calls into bundled npm packages + * (e.g. `mysql`) at build time via orchestrion, so the SDK can trace them + * without monkey-patching, which wouldn't work in workerd anyway. + * + * It also injects the `@sentry/cloudflare/orchestrion` registration module + * into the bundle, which registers the matching channel-subscriber + * integrations for `Sentry.init` to pick up. The SDK itself doesn't import + * them, so workers built without this plugin don't ship that code; the worker + * only needs the usual `Sentry.withSentry` wrapping. + * + * @example + * ```ts + * // vite.config.ts + * import { cloudflare } from '@cloudflare/vite-plugin'; + * import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; + * + * export default { + * plugins: [ + * sentryCloudflareVitePlugin(), + * cloudflare(), + * ], + * }; + * + * // src/index.ts (worker entry) + * import * as Sentry from '@sentry/cloudflare'; + * + * export default Sentry.withSentry( + * env => ({ + * dsn: env.SENTRY_DSN, + * tracesSampleRate: 1.0, + * }), + * { + * async fetch(request, env, ctx) { + * // ... + * }, + * } satisfies ExportedHandler, + * ); + * ``` + */ +export function sentryCloudflareVitePlugin() { + return sentryOrchestrionPlugin({ registrationModule: '@sentry/cloudflare/orchestrion' }); +} diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 54b8ee609cda..eab78f53c722 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -1,9 +1,9 @@ import * as SentryCore from '@sentry/core'; import type { Integration } from '@sentry/core'; import { getClient } from '@sentry/core'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { CloudflareClient } from '../src/client'; -import { init } from '../src/sdk'; +import { getDefaultIntegrations, init } from '../src/sdk'; import { resetSdk } from './testUtils'; import { spanStreamingIntegration } from '../src/'; @@ -60,3 +60,40 @@ describe('init', () => { expect((integrations?.[0] as MarkedIntegration)?._custom).toBe(true); }); }); + +describe('getDefaultIntegrations', () => { + afterEach(() => { + delete globalThis.__SENTRY_ORCHESTRION__; + }); + + test('does not add orchestrion channel integrations when none were registered', () => { + delete globalThis.__SENTRY_ORCHESTRION__; + + const names = getDefaultIntegrations({}).map(i => i.name); + + expect(names).not.toContain('Mysql'); + expect(names).not.toContain('Postgres'); + expect(names).not.toContain('LruMemoizer'); + }); + + test('does not add orchestrion channel integrations when only the bundler marker is set', () => { + globalThis.__SENTRY_ORCHESTRION__ = { bundler: true }; + + const names = getDefaultIntegrations({}).map(i => i.name); + + expect(names).not.toContain('Mysql'); + expect(names).not.toContain('Postgres'); + expect(names).not.toContain('LruMemoizer'); + }); + + test('adds orchestrion channel integrations registered by the injected registration module', async () => { + // Import the actual registration module the vite plugin injects into bundles. + await import('../src/orchestrion'); + + const names = getDefaultIntegrations({}).map(i => i.name); + + expect(names).toContain('Mysql'); + expect(names).toContain('Postgres'); + expect(names).toContain('LruMemoizer'); + }); +}); diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 4cadc7b40925..35363f82008b 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -22,6 +22,24 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; // false positive. We don't need the runtime value for typing — `UnknownPlugin` // is sufficient — so we omit the import entirely. +export interface SentryOrchestrionPluginOptions { + /** + * Bare specifier of a side-effect module that registers the SDK's + * channel-subscriber integrations on the global orchestrion marker (e.g. + * `@sentry/cloudflare/orchestrion`). + * + * When set, the plugin appends an import of this module to every server + * module that statically imports the module's package (`@sentry/cloudflare` + * in the example). This is how the subscriber integrations — which the SDK + * deliberately does not import so bundlers can drop them — end up in the + * bundle exactly when this plugin injects the channels they subscribe to. + * + * The specifier must be resolvable from the app being bundled, so it should + * live in the SDK package the user directly depends on. + */ + registrationModule?: string; +} + /** * Vite plugin that runs the orchestrion code transform on the bundled output. * @@ -29,7 +47,7 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; * pipeline, SvelteKit). For unbundled Node processes use the runtime hook * instead (`node --import @sentry/node/orchestrion app.js`). * - * Returns two plugins: + * Returns the following plugins: * 1. `sentry-orchestrion-marker` — a `renderChunk` hook that prepends a * single-line banner to entry chunks. The banner sets * `globalThis.__SENTRY_ORCHESTRION__.bundler = true` at app boot, so the @@ -39,7 +57,11 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; * Also injects every instrumented package name into `ssr.noExternal` via * the `config` hook, since externalized deps are `require()`d at runtime * from `node_modules` and never pass through the transform. - * 2. The upstream `@apm-js-collab/code-transformer-bundler-plugins/vite` + * 2. `sentry-orchestrion-register-integrations` (only with + * `options.registrationModule`) — injects the given registration module + * into server modules that import the SDK, see + * {@link SentryOrchestrionPluginOptions.registrationModule}. + * 3. The upstream `@apm-js-collab/code-transformer-bundler-plugins/vite` * plugin, fed our central `SENTRY_INSTRUMENTATIONS` config. * * @example @@ -49,12 +71,52 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; * export default { plugins: [sentryOrchestrionPlugin()] }; * ``` */ -export function sentryOrchestrionPlugin(): UnknownPlugin[] { +export function sentryOrchestrionPlugin(options: SentryOrchestrionPluginOptions = {}): UnknownPlugin[] { const codeTransformerPlugins = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }); const codeTransformerArray: UnknownPlugin[] = Array.isArray(codeTransformerPlugins) ? codeTransformerPlugins : [codeTransformerPlugins]; - return [bundlerMarkerPlugin(), ...codeTransformerArray]; + return [ + bundlerMarkerPlugin(), + ...(options.registrationModule ? [registerIntegrationsPlugin(options.registrationModule)] : []), + ...codeTransformerArray, + ]; +} + +/** Extracts `@scope/name` (or `name`) from a bare specifier with an optional subpath. */ +function getPackageName(specifier: string): string { + const segments = specifier.split('/'); + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : (segments[0] as string); +} + +function registerIntegrationsPlugin(registrationModule: string): UnknownPlugin { + const sdkPackage = getPackageName(registrationModule); + // Only match static ESM imports: the registration module itself is ESM-only + // in spirit (it must run at module-evaluation time), and injecting an + // `import` statement into a CJS module would break it. + const sdkImport = new RegExp(`from\\s*(['"])${sdkPackage.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\1`); + + // The slice of Vite's environment-API plugin context we read; typed structurally + // since we don't import `vite` types here (see note at the top of the file). + interface TransformContext { + environment?: { config?: { consumer?: string } }; + } + + return { + name: 'sentry-orchestrion-register-integrations', + transform(this: TransformContext | undefined, code: string, _id: string): { code: string; map: unknown } | null { + // Client bundles must never pull in a server SDK's integrations; without + // environment info (classic non-environment-API Vite) assume server. + if (this?.environment?.config?.consumer === 'client') return null; + if (!sdkImport.test(code) || code.includes(registrationModule)) return null; + const ms = new MagicString(code); + // Appending keeps existing sourcemap lines intact; ESM hoisting makes the + // import's position irrelevant, and registration only has to happen by + // the time `init()` runs, not before the SDK module evaluates. + ms.append(`\nimport ${JSON.stringify(registrationModule)};\n`); + return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; + }, + }; } function bundlerMarkerPlugin(): UnknownPlugin { diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index 9dfa88bf427d..4e111aa250d1 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -1,9 +1,12 @@ +import type { Integration } from '@sentry/core'; import { debug } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; declare global { // eslint-disable-next-line no-var - var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; + var __SENTRY_ORCHESTRION__: + | { runtime?: boolean; bundler?: boolean; integrations?: Array<() => Integration> } + | undefined; } /** @@ -20,6 +23,21 @@ export function isOrchestrionInjected(): boolean { return !!(marker?.runtime || marker?.bundler); } +/** + * Returns fresh instances of every channel-subscriber integration an injector + * registered on the global marker (e.g. the registration module that the + * `@sentry/cloudflare/vite` plugin injects into the worker bundle). + * + * SDKs that can't afford to ship the subscriber code unconditionally read the + * registry through this function instead of importing the integrations: no + * static import means bundlers drop the integration code entirely unless the + * injector put its registration module — and with it the integrations — into + * the bundle. + */ +export function getRegisteredChannelIntegrations(): Integration[] { + return (globalThis.__SENTRY_ORCHESTRION__?.integrations || []).map(factory => factory()); +} + /** * Verifies that the diagnostics channels have been injected either by the * runtime `--import` hook (or init-time registration), a bundler plugin, or diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 3682ca752232..4592b619b670 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -6,7 +6,7 @@ import { openaiChannelIntegration } from '../integrations/tracing-channel/openai import { postgresChannelIntegration } from '../integrations/tracing-channel/postgres'; import { vercelAiChannelIntegration } from '../integrations/tracing-channel/vercel-ai'; -export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; +export { detectOrchestrionSetup, getRegisteredChannelIntegrations, isOrchestrionInjected } from './detect'; export { anthropicChannelIntegration, ioredisChannelIntegration, @@ -39,3 +39,18 @@ export const channelIntegrations = { anthropicIntegration: anthropicChannelIntegration, vercelAiIntegration: vercelAiChannelIntegration, } as const; + +/** + * Puts the factories of all channel integrations onto the global orchestrion + * marker, where `getRegisteredChannelIntegrations()` picks them up. + * + * Only meant to be called from a bundler-injected registration module (e.g. + * `@sentry/cloudflare/orchestrion`, injected by the `@sentry/cloudflare/vite` + * plugin) — calling it statically from an SDK would defeat the whole point of + * the registry, which is keeping the integration code out of bundles that the + * injecting plugin never touched. + */ +export function registerChannelIntegrations(): void { + const marker = (globalThis.__SENTRY_ORCHESTRION__ = globalThis.__SENTRY_ORCHESTRION__ || {}); + marker.integrations = Object.values(channelIntegrations); +} diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 48a63a732a5d..34a4ec1c06f8 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -1,3 +1,4 @@ +import type { Integration } from '@sentry/core'; import { debug } from '@sentry/core'; import { createRequire } from 'node:module'; import * as Module from 'node:module'; @@ -7,7 +8,9 @@ import { SENTRY_INSTRUMENTATIONS } from '../config'; declare global { // eslint-disable-next-line no-var - var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; + var __SENTRY_ORCHESTRION__: + | { runtime?: boolean; bundler?: boolean; integrations?: Array<() => Integration> } + | undefined; } /** diff --git a/packages/server-utils/test/orchestrion/vite-plugin.test.ts b/packages/server-utils/test/orchestrion/vite-plugin.test.ts new file mode 100644 index 000000000000..1a422aff306f --- /dev/null +++ b/packages/server-utils/test/orchestrion/vite-plugin.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { sentryOrchestrionPlugin } from '../../src/orchestrion/bundler/vite'; +import { INSTRUMENTED_MODULE_NAMES } from '../../src/orchestrion/config'; + +function getMarkerPlugin() { + const plugins = sentryOrchestrionPlugin(); + const marker = plugins.find(p => p.name === 'sentry-orchestrion-marker'); + expect(marker).toBeDefined(); + return marker; +} + +describe('sentryOrchestrionPlugin', () => { + it('returns the marker plugin and the code transformer', () => { + const plugins = sentryOrchestrionPlugin(); + expect(plugins.map(p => p.name)).toContain('sentry-orchestrion-marker'); + expect(plugins.map(p => p.name)).toContain('code-transformer'); + }); + + it('force-bundles instrumented packages via ssr.noExternal', () => { + const marker = getMarkerPlugin(); + expect(marker.config()).toEqual({ ssr: { noExternal: INSTRUMENTED_MODULE_NAMES } }); + }); + + it('prepends the bundler marker banner to entry chunks', () => { + const marker = getMarkerPlugin(); + const result = marker.renderChunk('console.log("app");', { isEntry: true }); + expect(result.code).toContain('globalThis.__SENTRY_ORCHESTRION__.bundler = true;'); + expect(result.map).toBeDefined(); + expect(marker.renderChunk('console.log("chunk");', { isEntry: false })).toBeNull(); + }); + + describe('registrationModule option', () => { + const REGISTRATION_MODULE = '@sentry/cloudflare/orchestrion'; + + function getRegisterPlugin(): AnyPlugin { + const plugins = sentryOrchestrionPlugin({ registrationModule: REGISTRATION_MODULE }); + const plugin = plugins.find((p: AnyPlugin) => p.name === 'sentry-orchestrion-register-integrations'); + expect(plugin).toBeDefined(); + return plugin; + } + + it('is not added without the option', () => { + const names = sentryOrchestrionPlugin().map((p: AnyPlugin) => p.name); + expect(names).not.toContain('sentry-orchestrion-register-integrations'); + }); + + it('appends the registration import to modules importing the SDK package', () => { + const plugin = getRegisterPlugin(); + const code = "import * as Sentry from '@sentry/cloudflare';\nexport default Sentry.withSentry(() => ({}), {});"; + const result = plugin.transform.call({}, code, '/app/src/index.ts'); + expect(result.code).toContain(`import "${REGISTRATION_MODULE}";`); + // Original code stays at the top so existing sourcemap lines keep their positions. + expect(result.code.startsWith(code)).toBe(true); + expect(result.map).toBeDefined(); + }); + + it('matches double-quoted imports', () => { + const plugin = getRegisterPlugin(); + const result = plugin.transform.call({}, 'import { withSentry } from "@sentry/cloudflare";', '/app/entry.ts'); + expect(result.code).toContain(`import "${REGISTRATION_MODULE}";`); + }); + + it('ignores modules that do not import the SDK package', () => { + const plugin = getRegisterPlugin(); + expect(plugin.transform.call({}, "import mysql from 'mysql';", '/app/db.ts')).toBeNull(); + // A specifier that merely starts with the package name is a different package. + expect(plugin.transform.call({}, "import { x } from '@sentry/cloudflare-foo';", '/app/other.ts')).toBeNull(); + }); + + it('does not inject twice', () => { + const plugin = getRegisterPlugin(); + const code = `import * as Sentry from '@sentry/cloudflare';\nimport "${REGISTRATION_MODULE}";`; + expect(plugin.transform.call({}, code, '/app/src/index.ts')).toBeNull(); + }); + + it('skips client environments', () => { + const plugin = getRegisterPlugin(); + const clientContext = { environment: { config: { consumer: 'client' } } }; + const code = "import * as Sentry from '@sentry/cloudflare';"; + expect(plugin.transform.call(clientContext, code, '/app/src/index.ts')).toBeNull(); + + const serverContext = { environment: { config: { consumer: 'server' } } }; + expect(plugin.transform.call(serverContext, code, '/app/src/index.ts')).not.toBeNull(); + }); + }); +});