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..541d36ac0a61 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/vite.config.ts @@ -0,0 +1,14 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + useDiagnosticsChannelInjection: true, + }, + }), + ], +}); 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 5e965bf529f1..33a0228e9157 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -46,6 +46,10 @@ "types": "./build/types/nodejs_compat/index.d.ts", "default": "./build/cjs/nodejs_compat/index.js" } + }, + "./vite": { + "types": "./build/types/vite/index.d.ts", + "import": "./build/esm/vite/index.js" } }, "typesVersions": { diff --git a/packages/cloudflare/rollup.npm.config.mjs b/packages/cloudflare/rollup.npm.config.mjs index 63407d8629dd..9b674514ca4f 100644 --- a/packages/cloudflare/rollup.npm.config.mjs +++ b/packages/cloudflare/rollup.npm.config.mjs @@ -2,6 +2,6 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu export default makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/nodejs_compat/index.ts'], + entrypoints: ['src/index.ts', 'src/nodejs_compat/index.ts', 'src/vite/index.ts'], }), ); diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index d74ab861bb74..2bbd704e6004 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -5,6 +5,7 @@ import { dedupeIntegration, functionToStringIntegration, getIntegrationsToSetup, + GLOBAL_OBJ, inboundFiltersIntegration, initAndBind, linkedErrorsIntegration, @@ -21,6 +22,23 @@ import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; import { makeCloudflareTransport } from './transport'; import { defaultStackParser } from './vendor/stacktrace'; +/** + * Instantiate the channel-subscriber factories the `@sentry/cloudflare/vite` + * plugin registered on the global marker. The plugin splices a small snippet + * into each instrumented module that `.set`s its factory here (keyed by export + * name), so the marker holds one factory per package actually bundled. + * + * The marker is read directly instead of importing the factories, so a worker + * built without the plugin — where the channels never fire — ships none of this + * code. + * TODO(v11): Use `@sentry/server-utils/orchestrion` once we move to `nodejs_compat` by default. + */ +function getRegisteredChannelIntegrations(): Integration[] { + const registered = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations; + + return registered ? [...registered.values()].map(factory => factory()) : []; +} + /** Get the default integrations for the Cloudflare SDK. */ export function getDefaultIntegrations(options: CloudflareOptions): Integration[] { // TODO(v11): Drop this transitional gating and let `requestDataIntegration` rely on the resolved @@ -44,6 +62,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, + // next to each, a snippet that registers the matching subscriber factory 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..113d18193257 --- /dev/null +++ b/packages/cloudflare/src/vite/index.ts @@ -0,0 +1,71 @@ +// 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'; + +/** + * Options for {@link sentryCloudflareVitePlugin}. + */ +export interface SentryCloudflareVitePluginOptions { + /** + * Experimental options that may change or be removed without notice. + */ + _experimental?: { + /** + * Enables build-time automatic instrumentation of supported dependencies + * (e.g. database clients like `mysql`) so the Sentry Cloudflare SDK can + * trace them without monkey-patching, which wouldn't work in workerd anyway. + * + * When enabled, the plugin injects `diagnostics_channel.tracingChannel` + * calls into the bundled packages and, next to each, a snippet that + * registers the matching Sentry channel-subscriber factory on the global + * marker, which the SDK picks up in `Sentry.withSentry()`. Both `vite build` + * and `vite dev` are instrumented. + * + * @default false + * @experimental May change or be removed in any release. + */ + useDiagnosticsChannelInjection?: boolean; + }; +} + +/** + * Sentry Vite plugin for Cloudflare Workers. + * + * Add this plugin to your Vite configuration to enable additional Sentry + * instrumentation for Cloudflare Workers built with Vite. Configure the Sentry + * SDK in your Worker as usual with `Sentry.withSentry()`. + * + * Currently, the only functionality is the experimental + * `_experimental.useDiagnosticsChannelInjection` option, which traces supported + * dependencies (such as database clients) without changing your application + * code. Without it, the plugin is a no-op. + * + * @example + * ```ts + * // vite.config.ts + * import { cloudflare } from '@cloudflare/vite-plugin'; + * import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; + * import { defineConfig } from 'vite'; + * + * export default defineConfig({ + * plugins: [ + * cloudflare(), + * sentryCloudflareVitePlugin({ + * _experimental: { + * useDiagnosticsChannelInjection: true, + * }, + * }), + * ], + * }); + * ``` + */ +export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOptions = {}) { + if (!options._experimental?.useDiagnosticsChannelInjection) { + return []; + } + + return sentryOrchestrionPlugin({ injectChannelSubscribers: true }); +} diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 54b8ee609cda..09efdd96f3d6 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,51 @@ 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 on the marker by injected modules', async () => { + // Mirror what the snippet the vite plugin injects into each instrumented + // module does at runtime: import its factory and `.set` it on the marker map, + // keyed by export name (so a package split across files registers once). + const { mysqlChannelIntegration, postgresChannelIntegration, lruMemoizerChannelIntegration } = + await import('@sentry/server-utils/orchestrion'); + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: true, + integrations: new Map([ + ['mysqlChannelIntegration', mysqlChannelIntegration], + ['postgresChannelIntegration', postgresChannelIntegration], + ['lruMemoizerChannelIntegration', lruMemoizerChannelIntegration], + ]), + }; + + const names = getDefaultIntegrations({}).map(i => i.name); + + expect(names).toContain('Mysql'); + expect(names).toContain('Postgres'); + expect(names).toContain('LruMemoizer'); + }); +});