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 6134c44bb1f5..69954354cbab 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": { @@ -62,7 +66,10 @@ "@opentelemetry/api": "^1.9.1", "@sentry/core": "10.65.0", "@sentry/node": "10.65.0", - "@sentry/server-utils": "10.65.0" + "@sentry/server-utils": "10.65.0", + "jsonc-parser": "^3.3.1", + "magic-string": "~0.30.21", + "smol-toml": "^1.7.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x || ^5.x" 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/defineCloudflareOptions.ts b/packages/cloudflare/src/defineCloudflareOptions.ts new file mode 100644 index 000000000000..6e905dad7fa1 --- /dev/null +++ b/packages/cloudflare/src/defineCloudflareOptions.ts @@ -0,0 +1,46 @@ +import type { env as cloudflareEnv } from 'cloudflare:workers'; +import type { CloudflareOptions } from './client'; + +/** + * Define the Sentry options for a Cloudflare Worker in a dedicated module. + * + * This is the recommended way to configure the SDK when using the Vite plugin's + * auto-instrumentation: place an `instrument.server.{ts,js,mjs}` file next to + * the worker entry whose **default export** is the result of this function. The + * plugin picks it up automatically and hands it to `withSentry`. + * + * Unlike Node's `Sentry.init(...)`, the options cannot be applied at module + * load time on Cloudflare: the DSN and other settings typically come from the + * per-request `env`, which only exists inside the handler. Pass a callback to + * read from `env`, or a static object when no `env` access is needed — either + * way you get full type-checking and autocomplete on {@link CloudflareOptions}. + * + * At runtime this is a thin pass-through; it only normalizes a static object + * into a callback so the plugin always imports a `(env) => options` function. + * + * @example + * ```ts + * // src/instrument.server.ts + * import { defineCloudflareOptions } from '@sentry/cloudflare'; + * + * export default defineCloudflareOptions((env) => ({ + * dsn: env.SENTRY_DSN, + * tracesSampleRate: 1.0, + * })); + * ``` + * + * @example + * ```ts + * // Static options — no `env` access needed + * export default defineCloudflareOptions({ tracesSampleRate: 1.0 }); + * ``` + */ +export function defineCloudflareOptions( + optionsOrCallback: CloudflareOptions | ((env: Env) => CloudflareOptions | undefined), +): (env: Env) => CloudflareOptions | undefined { + if (typeof optionsOrCallback === 'function') { + return optionsOrCallback as (env: Env) => CloudflareOptions | undefined; + } + + return () => optionsOrCallback; +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 20a537c5b307..b93d9626bde9 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -117,6 +117,7 @@ export { } from '@sentry/core'; export { withSentry } from './withSentry'; +export { defineCloudflareOptions } from './defineCloudflareOptions'; export { instrumentDurableObjectWithSentry } from './durableobject'; export { sentryPagesPlugin } from './pages-plugin'; diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index d74ab861bb74..7f752e736808 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -21,6 +21,18 @@ import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; import { makeCloudflareTransport } from './transport'; import { defaultStackParser } from './vendor/stacktrace'; +/** + * Exact copy of the function from `@sentry/server-utils/orchestrion`. + * This is to avoid importing from server-utils directly into the Cloudflare SDK. + * TODO(v11): Use `@sentry/server-utils/orchestrion` once we move to `nodejs_compat` by default + */ +function getRegisteredChannelIntegrations(): Integration[] { + const marker = globalThis.__SENTRY_ORCHESTRION__; + const registered = marker?.integrations || []; + + return registered.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 +56,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 a generated 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..ee92dec763cf --- /dev/null +++ b/packages/cloudflare/src/vite/index.ts @@ -0,0 +1,70 @@ +// 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 adds a generated registration module + * to the bundle, 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({ registerIntegrations: true }); +} diff --git a/packages/cloudflare/src/vite/instrumentFile.ts b/packages/cloudflare/src/vite/instrumentFile.ts new file mode 100644 index 000000000000..ffd79ca0104c --- /dev/null +++ b/packages/cloudflare/src/vite/instrumentFile.ts @@ -0,0 +1,53 @@ +import { existsSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; + +// Fallback options callback used when no instrument file is present. Returning +// `undefined` makes the SDK read all configuration (DSN, release, environment, +// sample rate, …) from the worker's `env` at runtime. +export const ENV_FALLBACK_OPTIONS_FN = '() => undefined'; + +// Identifier the generated import binds the user's options module to. +const OPTIONS_IMPORT_IDENTIFIER = '__SENTRY_OPTIONS_CALLBACK__'; + +// Conventional, non-configurable name of the Sentry options module. It is +// looked up next to the worker entry file; its default export is the options +// callback `(env) => CloudflareOptions`. +const INSTRUMENT_FILE_BASENAME = 'instrument.server'; +const INSTRUMENT_FILE_EXTENSIONS = ['ts', 'mts', 'js', 'mjs', 'cjs']; + +/** + * Locate the conventional `instrument.server.*` module sitting next to the + * worker entry file. Returns its absolute path, or `undefined` when absent. + */ +export function resolveInstrumentFile(entryFilePath: string): string | undefined { + const dir = dirname(entryFilePath); + for (const ext of INSTRUMENT_FILE_EXTENSIONS) { + const candidate = resolve(dir, `${INSTRUMENT_FILE_BASENAME}.${ext}`); + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +/** + * Build the `optionsFn` reference and `import` statement for the instrument + * module whose **default export** is the options callback + * `(env) => CloudflareOptions`. + * + * The import is emitted relative to `entryFilePath` because it is injected into + * the entry file's source. The file extension is kept: extensionless specifiers + * only resolve for extensions in Vite's default `resolve.extensions` (which + * excludes `.cjs`), and keeping it makes our probe order authoritative when + * several `instrument.server.*` files coexist. + */ +export function buildOptionsImport( + entryFilePath: string, + instrumentFilePath: string, +): { optionsFn: string; importStmt: string } { + let relativePath = relative(dirname(entryFilePath), instrumentFilePath).replace(/\\/g, '/'); + if (!relativePath.startsWith('.')) relativePath = `./${relativePath}`; + + return { + optionsFn: OPTIONS_IMPORT_IDENTIFIER, + importStmt: `import ${OPTIONS_IMPORT_IDENTIFIER} from '${relativePath}';\n`, + }; +} diff --git a/packages/cloudflare/src/vite/wranglerConfig.ts b/packages/cloudflare/src/vite/wranglerConfig.ts new file mode 100644 index 000000000000..66eee78be6b8 --- /dev/null +++ b/packages/cloudflare/src/vite/wranglerConfig.ts @@ -0,0 +1,108 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import * as jsoncParser from 'jsonc-parser'; +import TOML from 'smol-toml'; + +/** + * The slice of the wrangler configuration the auto-instrument plugin cares + * about, normalized into a single shape regardless of the source format. + */ +export interface WranglerConfig { + main?: string; + durableObjects: Array<{ name: string; className: string }>; +} + +/** + * The raw wrangler config as parsed from disk. Both TOML and JSONC decode to + * this shape (snake_case keys, `durable_objects.bindings`), matching wrangler's + * own schema; {@link normalizeWranglerConfig} maps it to {@link WranglerConfig}. + * Named environments (`[env.]` / `"env"`) repeat the same shape. + */ +interface RawWranglerEnvironment { + main?: string; + durable_objects?: { bindings?: Array<{ name: string; class_name: string; script_name?: string }> }; +} + +interface RawWranglerConfig extends RawWranglerEnvironment { + env?: Record; +} + +/** + * Locate and parse a wrangler configuration file. + * + * When `explicitPath` is provided it is resolved against `root` and used + * directly. Otherwise the function probes for `wrangler.json`, + * `wrangler.jsonc` and `wrangler.toml` inside `root` — the same precedence + * wrangler itself applies, so we read the file wrangler would actually use + * when more than one exists. + * + * Returns `undefined` when no config file exists or the file cannot be parsed + * (the caller warns and disables auto-instrumentation rather than failing the + * whole build on a malformed config). + */ +export function resolveWranglerConfig( + root: string, + explicitPath?: string, +): { config: WranglerConfig; configDir: string } | undefined { + if (explicitPath) { + const filePath = resolve(root, explicitPath); + if (!existsSync(filePath)) return undefined; + const config = parseWranglerFile(filePath); + return config && { config, configDir: dirname(filePath) }; + } + + for (const filename of ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml']) { + const filePath = resolve(root, filename); + if (existsSync(filePath)) { + const config = parseWranglerFile(filePath); + return config && { config, configDir: root }; + } + } + + return undefined; +} + +/** + * Parse a wrangler config file into the normalized {@link WranglerConfig}. + * + * TOML is parsed with `smol-toml` and JSON/JSONC with `jsonc-parser` — the same + * libraries wrangler itself uses — so comments, trailing commas and the full + * TOML grammar are handled correctly rather than approximated. Returns + * `undefined` for empty or unparseable files. + */ +function parseWranglerFile(filePath: string): WranglerConfig | undefined { + let raw: unknown; + try { + const content = readFileSync(filePath, 'utf-8'); + raw = filePath.endsWith('.toml') ? TOML.parse(content) : jsoncParser.parse(content); + } catch { + return undefined; + } + if (typeof raw !== 'object' || raw === null) return undefined; + return normalizeWranglerConfig(raw as RawWranglerConfig); +} + +function normalizeWranglerConfig(raw: RawWranglerConfig): WranglerConfig { + // `main` follows the active wrangler environment (selected via CLOUDFLARE_ENV, + // as `@cloudflare/vite-plugin` does). Durable Object class names are unioned + // across ALL environments instead: wrapping a class that is only bound in + // another environment is harmless, while missing one loses instrumentation. + const activeEnvName = process.env.CLOUDFLARE_ENV; + const activeEnv = activeEnvName ? raw.env?.[activeEnvName] : undefined; + + const durableObjects: WranglerConfig['durableObjects'] = []; + const seenClassNames = new Set(); + for (const environment of [raw, ...Object.values(raw.env ?? {})]) { + for (const binding of environment.durable_objects?.bindings ?? []) { + // `script_name` bindings reference a class exported by a *different* + // worker — there is nothing to wrap in this worker's entry file. + if (typeof binding?.class_name !== 'string' || binding.script_name || seenClassNames.has(binding.class_name)) { + continue; + } + seenClassNames.add(binding.class_name); + durableObjects.push({ name: binding.name, className: binding.class_name }); + } + } + + return { main: activeEnv?.main ?? raw.main, durableObjects }; +} diff --git a/packages/cloudflare/test/defineCloudflareOptions.test.ts b/packages/cloudflare/test/defineCloudflareOptions.test.ts new file mode 100644 index 000000000000..08a6a8233024 --- /dev/null +++ b/packages/cloudflare/test/defineCloudflareOptions.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { defineCloudflareOptions } from '../src/defineCloudflareOptions'; + +describe('defineCloudflareOptions', () => { + it('returns the callback unchanged', () => { + const callback = (env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN }); + expect(defineCloudflareOptions(callback)).toBe(callback); + }); + + it('passes env through to the callback', () => { + const callback = defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + })); + + expect(callback({ SENTRY_DSN: 'https://example' })).toEqual({ + dsn: 'https://example', + tracesSampleRate: 1.0, + }); + }); + + it('normalizes a static options object into a callback', () => { + const callback = defineCloudflareOptions({ tracesSampleRate: 0.5 }); + + expect(typeof callback).toBe('function'); + expect(callback({} as never)).toEqual({ tracesSampleRate: 0.5 }); + }); +}); diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 54b8ee609cda..f7f2228e5e1a 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,41 @@ 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 () => { + // Mirror what the module the vite plugin injects into bundles does at runtime. + const { registerChannelIntegrations } = await import('@sentry/server-utils/orchestrion'); + registerChannelIntegrations(); + + const names = getDefaultIntegrations({}).map(i => i.name); + + expect(names).toContain('Mysql'); + expect(names).toContain('Postgres'); + expect(names).toContain('LruMemoizer'); + }); +}); diff --git a/packages/cloudflare/test/vite/wranglerConfig.test.ts b/packages/cloudflare/test/vite/wranglerConfig.test.ts new file mode 100644 index 000000000000..55e336fb3af3 --- /dev/null +++ b/packages/cloudflare/test/vite/wranglerConfig.test.ts @@ -0,0 +1,244 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { resolveWranglerConfig } from '../../src/vite/wranglerConfig'; + +function writeTempDir(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-')); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(dir, name), content); + } + return dir; +} + +describe('resolveWranglerConfig', () => { + it('parses wrangler.toml', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '[[durable_objects.bindings]]', + 'name = "MY_DO"', + 'class_name = "MyDurableObject"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result).toBeDefined(); + expect(result!.config.main).toBe('src/index.ts'); + expect(result!.config.durableObjects).toEqual([{ name: 'MY_DO', className: 'MyDurableObject' }]); + }); + + it('parses wrangler.json', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/worker.ts', + durable_objects: { + bindings: [{ name: 'DO_A', class_name: 'A' }], + }, + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result).toBeDefined(); + expect(result!.config.main).toBe('src/worker.ts'); + expect(result!.config.durableObjects).toEqual([{ name: 'DO_A', className: 'A' }]); + }); + + it('parses wrangler.jsonc (strips comments)', () => { + const dir = writeTempDir({ + 'wrangler.jsonc': [ + '{', + ' // Entry point', + ' "main": "src/index.ts",', + ' /* DO bindings */', + ' "durable_objects": {', + ' "bindings": [', + ' { "name": "DO", "class_name": "MyDO" }', + ' ]', + ' }', + '}', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result).toBeDefined(); + expect(result!.config.main).toBe('src/index.ts'); + expect(result!.config.durableObjects).toEqual([{ name: 'DO', className: 'MyDO' }]); + }); + + it('parses JSONC with trailing commas', () => { + const dir = writeTempDir({ + 'wrangler.jsonc': [ + '{', + ' "main": "src/index.ts",', + ' "durable_objects": {', + ' "bindings": [', + ' { "name": "DO", "class_name": "MyDO" },', + ' ],', + ' },', + '}', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe('src/index.ts'); + expect(result!.config.durableObjects).toEqual([{ name: 'DO', className: 'MyDO' }]); + }); + + it('parses TOML single-quoted (literal) strings', () => { + const dir = writeTempDir({ 'wrangler.toml': "main = 'src/index.ts'" }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe('src/index.ts'); + }); + + it('prefers wrangler.json over wrangler.toml (matching wrangler itself)', () => { + const dir = writeTempDir({ + 'wrangler.toml': 'main = "from-toml.ts"', + 'wrangler.json': '{ "main": "from-json.ts" }', + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe('from-json.ts'); + }); + + it('prefers wrangler.jsonc over wrangler.toml (matching wrangler itself)', () => { + const dir = writeTempDir({ + 'wrangler.toml': 'main = "from-toml.ts"', + 'wrangler.jsonc': '{ "main": "from-jsonc.ts" }', + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe('from-jsonc.ts'); + }); + + it('handles TOML with commented-out bindings', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '# [[durable_objects.bindings]]', + '# name = "IGNORED"', + '# class_name = "IgnoredDO"', + '', + '[[durable_objects.bindings]]', + 'name = "REAL"', + 'class_name = "RealDO"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toEqual([{ name: 'REAL', className: 'RealDO' }]); + }); + + it('handles multiple DO bindings', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '[[durable_objects.bindings]]', + 'name = "DO_A"', + 'class_name = "A"', + '', + '[[durable_objects.bindings]]', + 'name = "DO_B"', + 'class_name = "B"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toHaveLength(2); + expect(result!.config.durableObjects[0]).toEqual({ name: 'DO_A', className: 'A' }); + expect(result!.config.durableObjects[1]).toEqual({ name: 'DO_B', className: 'B' }); + }); + + it('returns undefined when no config exists', () => { + const dir = writeTempDir({}); + expect(resolveWranglerConfig(dir)).toBeUndefined(); + }); + + it('returns undefined for explicit non-existent path', () => { + expect(resolveWranglerConfig('/tmp', '/tmp/nonexistent.toml')).toBeUndefined(); + }); + + it('resolves a relative explicit path against the root', () => { + const dir = writeTempDir({ 'custom.toml': 'main = "src/index.ts"' }); + + const result = resolveWranglerConfig(dir, 'custom.toml'); + expect(result).toBeDefined(); + expect(result!.config.main).toBe('src/index.ts'); + expect(result!.configDir).toBe(dir); + }); + + it('returns undefined for an empty config file instead of crashing', () => { + const dir = writeTempDir({ 'wrangler.json': '' }); + expect(resolveWranglerConfig(dir)).toBeUndefined(); + }); + + it('returns undefined for invalid TOML instead of crashing', () => { + const dir = writeTempDir({ 'wrangler.toml': 'main = [' }); + expect(resolveWranglerConfig(dir)).toBeUndefined(); + }); + + it('skips DO bindings with a script_name (class lives in another worker)', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + durable_objects: { + bindings: [ + { name: 'LOCAL', class_name: 'LocalDO' }, + { name: 'EXTERNAL', class_name: 'ExternalDO', script_name: 'other-worker' }, + ], + }, + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toEqual([{ name: 'LOCAL', className: 'LocalDO' }]); + }); + + it('unions DO bindings across named environments', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + durable_objects: { bindings: [{ name: 'TOP', class_name: 'TopDO' }] }, + env: { + production: { + durable_objects: { + bindings: [ + { name: 'TOP', class_name: 'TopDO' }, + { name: 'PROD_ONLY', class_name: 'ProdDO' }, + ], + }, + }, + }, + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toEqual([ + { name: 'TOP', className: 'TopDO' }, + { name: 'PROD_ONLY', className: 'ProdDO' }, + ]); + }); + + it('honors a CLOUDFLARE_ENV main override', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + env: { staging: { main: 'src/staging.ts' } }, + }), + }); + + const previous = process.env.CLOUDFLARE_ENV; + process.env.CLOUDFLARE_ENV = 'staging'; + try { + expect(resolveWranglerConfig(dir)!.config.main).toBe('src/staging.ts'); + } finally { + if (previous === undefined) delete process.env.CLOUDFLARE_ENV; + else process.env.CLOUDFLARE_ENV = previous; + } + }); +}); diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 42a7ffdfaec4..61fd650f18bf 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -12,6 +12,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Integration } from '../shared-exports'; import type { Carrier } from '../carrier'; import type { SdkSource } from './env'; @@ -63,6 +64,8 @@ export type InternalGlobal = { runtime?: string[]; /** Empty array signifies bundler plugin ran */ bundler?: string[]; + /** Integrations registered by the orchestrion injector */ + integrations?: Array<() => Integration>; }; } & Carrier; diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index a905ad45b5f8..06c929e33c6b 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -120,7 +120,8 @@ "@apm-js-collab/code-transformer": "^0.18.0", "@apm-js-collab/tracing-hooks": "^0.13.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.65.0" + "@sentry/core": "10.65.0", + "magic-string": "~0.30.0" }, "devDependencies": { "@types/node": "^18.19.1", diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 75ad1ab49855..593637b411a2 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -1,14 +1,88 @@ +// EXPERIMENTAL — Vite plugin that injects +// `diagnostics_channel.tracingChannel` calls into the libraries listed in +// `SENTRY_INSTRUMENTATIONS`, during builds or dev dependency optimization. +// +// This file is published ESM-only via the `@sentry/server-utils/orchestrion/vite` +// subpath export. `@apm-js-collab/code-transformer-bundler-plugins` is +// `"type": "module"`, so consuming it from a CJS build is intentionally +// unsupported — vite.config.ts is almost always ESM in practice. The CJS +// rollup variant still emits this file, but `package.json` only exposes the +// ESM entry, so attempts to `require('@sentry/server-utils/orchestrion/vite')` will +// fail at resolution time rather than producing a half-broken plugin. + +import codeTransformerEsbuild from '@apm-js-collab/code-transformer-bundler-plugins/esbuild'; +import codeTransformerRollup from '@apm-js-collab/code-transformer-bundler-plugins/rollup'; import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; +import MagicString from 'magic-string'; +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; import { orchestrionTransformOptions } from './options'; +type UnknownPlugin = ReturnType; + +// `vite` types live in the package's ESM-only subpath; under Node16 module +// resolution with TS treating @sentry/server-utils as CJS, importing them produces a +// false positive. We don't need the runtime value for typing — `UnknownPlugin` +// is sufficient — so we omit the import entirely. + +export interface SentryOrchestrionPluginOptions extends PluginOptions { + /** + * Whether to register the SDK's channel-subscriber integrations. + * + * When enabled, the plugin injects a virtual module into the server entry + * which registers the integration factories on the global orchestrion marker. + * SDKs read them through `getRegisteredChannelIntegrations()`. Every registered + * integration is instantiated regardless of which packages the app bundles; + * the ones whose channels never fire sit idle. + * + * Leave unset for SDKs that wire up the integrations through a static import + * instead (e.g. `@sentry/node`'s `experimentalUseDiagnosticsChannelInjection()`), + * which never read the marker. + */ + registerIntegrations?: boolean; +} + /** - * Vite plugin that runs the orchestrion code transform on the bundled output. + * Vite plugin that runs the orchestrion code transform in builds and dev. * * Use when bundling a Node app with Vite (e.g. Vite SSR builds, Nuxt's Nitro - * pipeline, SvelteKit). For unbundled Node processes use the runtime hook - * instead (`node --import @sentry/node/orchestrion app.js`). + * pipeline, SvelteKit). For unbundled Node processes use the runtime hooks + * instead (`@sentry/node`'s `experimentalUseDiagnosticsChannelInjection()`, or + * `node --import @sentry/server-utils/orchestrion/import-hook app.js`). + * + * Both `vite build` and `vite dev` instrument deps, but by different paths + * because the dev server never bundles: + * - Build: Rollup runs the code transform; the marker plugin prepends + * `bundler = true` on entry chunks via `renderChunk`. + * - Dev: deps are pre-bundled by the optimizer before the Vite `transform` + * hook sees them, so the marker plugin wires the transform into the + * optimizer (esbuild on classic Vite, Rolldown on Vite 8). + * + * With `registerIntegrations`, a registration import is also injected into the + * server entry (see {@link registerIntegrationsPlugin}); build uses + * `ModuleInfo.isEntry`, dev uses the first transformed server source module + * because `isEntry` throws there. Every registered channel integration is + * instantiated at runtime regardless of which packages the app actually + * bundles; the ones whose channels never fire sit idle. + * + * Returns the following plugins: + * 1. `sentry-orchestrion-marker` — a `renderChunk` hook that prepends a + * banner to entry chunks. The banner sets + * `globalThis.__SENTRY_ORCHESTRION__.bundler = true` at app boot, so the + * runtime can detect that the bundler path ran. + * 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. And it hooks + * the dep optimizer of non-client environments via `configEnvironment`, so + * dev-mode dependency pre-bundling (which bypasses the `transform` hook) + * also injects the channels. + * 2. `sentry-orchestrion-register-integrations` (only with + * `options.registerIntegrations`) — injects the channel-integration + * registration import into the app's server entry, see + * {@link SentryOrchestrionPluginOptions.registerIntegrations}. + * 3. The upstream `@apm-js-collab/code-transformer-bundler-plugins/vite` + * plugin, fed our central `SENTRY_INSTRUMENTATIONS` config. * * @example * ```ts @@ -17,9 +91,195 @@ import { orchestrionTransformOptions } from './options'; * export default { plugins: [sentryOrchestrionPlugin()] }; * ``` */ -export function sentryOrchestrionPlugin(options: PluginOptions = {}): ReturnType { +export function sentryOrchestrionPlugin(options: SentryOrchestrionPluginOptions = {}): UnknownPlugin[] { + const transformOptions = orchestrionTransformOptions(options); + const codeTransformerPlugins = codeTransformer(transformOptions); + const codeTransformerArray: UnknownPlugin[] = Array.isArray(codeTransformerPlugins) + ? codeTransformerPlugins + : [codeTransformerPlugins]; + const serverCodeTransformerArray = codeTransformerArray.map(plugin => serverEnvironmentOnly(plugin)); + + return [ + bundlerMarkerPlugin({ + hasRegistrationPlugin: !!options.registerIntegrations, + instrumentations: options.instrumentations, + transformOptions, + }), + ...(options.registerIntegrations ? [registerIntegrationsPlugin()] : []), + ...serverCodeTransformerArray, + ]; +} + +/** Keeps environment-aware Vite builds from transforming client bundles. */ +function serverEnvironmentOnly(plugin: UnknownPlugin): UnknownPlugin { + const applyToEnvironment = (plugin as { applyToEnvironment?: (this: unknown, environment: unknown) => unknown }) + .applyToEnvironment; + + return { + ...plugin, + applyToEnvironment(this: unknown, environment) { + if (environment.config?.consumer === 'client') return false; + return applyToEnvironment?.call(this, environment) ?? true; + }, + }; +} + +/** + * Shared dev-mode "inject once into the first eligible source module per + * environment" logic used by both the marker and registration plugins. + * + * Returns the cleaned module id when the module is eligible for injection, + * `null` otherwise. Caller is responsible for recording the injection. + */ +function eligibleDevEntry(injectedServeModules: Map, id: string, environment: string): string | null { + const cleanId = id.split('?')[0] ?? id; + const injectedModule = injectedServeModules.get(environment); + + if (injectedModule && injectedModule !== cleanId) return null; + + if ( + id.startsWith('\0') || + cleanId.includes('/node_modules/') || + cleanId.includes('/.vite/') || + !/\.[cm]?[jt]sx?$/.test(cleanId) + ) { + return null; + } + + return cleanId; +} + +// The virtual registration module the plugin injects also acts as the sentinel +// which prevents duplicate injection. +const REGISTER_MODULE_ID = 'virtual:@sentry/orchestrion-register-integrations'; +const RESOLVED_REGISTER_MODULE_ID = `\0${REGISTER_MODULE_ID}`; + +/** + * Injects a virtual registration module into the app's server entry. + * + * The import is added during `transform`, while Rollup can still include it in + * the module graph. The virtual module imports an absolute ESM path because the + * entry may itself be virtual, with no directory from which to resolve a bare + * specifier. This also avoids bundling a second, CommonJS copy of + * `@sentry/core`. + * + * All factories are registered and every one is instantiated at runtime; there + * is no narrowing to the packages the app actually bundled (and no tree-shaking + * of unused subscriber code — that needs a module-graph-phase hook upstream). + * + * Builds identify entries through `ModuleInfo.isEntry`. Vite dev does not + * support that property, so registration is injected once into the first + * eligible source module transformed in each server environment. + */ +function registerIntegrationsPlugin(): UnknownPlugin { + // `serve` (vite dev) vs `build`; drives entry detection in `transform`. + let command = 'build'; + + // Dev only: records which source module receives registration in each + // environment. Re-transforming that module must inject again because HMR can + // start a fresh isolate from the newly transformed output. + const injectedServeModules = new Map(); + + function injectRegisterImport(code: string) { + if (code.includes(REGISTER_MODULE_ID)) return null; + + const ms = new MagicString(code); + const injection = `import '${REGISTER_MODULE_ID}';\n`; + ms.prepend(injection); + + return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; + } + + return { + name: 'sentry-orchestrion-register-integrations', + configResolved(config: { command: string }): void { + command = config.command; + }, + resolveId(id: string): string | null { + return id === REGISTER_MODULE_ID ? RESOLVED_REGISTER_MODULE_ID : null; + }, + load(id: string): { code: string; moduleSideEffects: boolean } | null { + if (id !== RESOLVED_REGISTER_MODULE_ID) return null; + // Keep this generated rather than moving the side effect into a published + // entry point: a future allow-list can emit only the requested factory + // imports here and let Rollup tree-shake the rest of the ESM module. + return { + code: [ + `import { registerChannelIntegrations } from '@sentry/server-utils/orchestrion';`, + 'registerChannelIntegrations();', + '', + ].join('\n'), + moduleSideEffects: true, + }; + }, + transform(this, code, id) { + // 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 (command === 'build') { + // Inject into the app entry only. It must be the first module request so + // registration runs before an entry body or a re-exported worker module + // can initialize Sentry. + if (!this?.getModuleInfo?.(id)?.isEntry) return null; + + return injectRegisterImport(code); + } + + const environment = this?.environment?.name ?? ''; + const cleanId = eligibleDevEntry(injectedServeModules, id, environment); + if (!cleanId) return null; + + const result = injectRegisterImport(code); + if (result) injectedServeModules.set(environment, cleanId); + + return result; + }, + }; +} + +// The virtual marker module the plugin injects in dev also acts as the +// sentinel which prevents duplicate injection. +const MARKER_MODULE_ID = 'virtual:@sentry/orchestrion-marker'; +const RESOLVED_MARKER_MODULE_ID = `\0${MARKER_MODULE_ID}`; + +function bundlerMarkerPlugin({ + hasRegistrationPlugin, + instrumentations, + transformOptions, +}: { + hasRegistrationPlugin: boolean; + instrumentations?: InstrumentationConfig[]; + transformOptions: ReturnType; +}): UnknownPlugin { + const banner = [ + 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', + 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', + '', + ].join('\n'); + + // Dev-mode transform state — only needed when there is no registration + // plugin, because `registerChannelIntegrations()` already sets + // `bundler = true`. + let command = 'build'; + const injectedServeModules = new Map(); + return { - ...codeTransformer(orchestrionTransformOptions(options)), + name: 'sentry-orchestrion-marker', + enforce: 'pre', + applyToEnvironment(environment: { config?: { consumer?: string } }): boolean { + return environment.config?.consumer !== 'client'; + }, + configResolved(config: { command: string }): void { + command = config.command; + }, + resolveId(id: string): string | null { + return id === MARKER_MODULE_ID ? RESOLVED_MARKER_MODULE_ID : null; + }, + load(id: string): { code: string; moduleSideEffects: boolean } | null { + if (id !== RESOLVED_MARKER_MODULE_ID) return null; + return { code: banner, moduleSideEffects: true }; + }, config(): { ssr: { noExternal: string[] } } { // Force-bundle every instrumented package so the code transform actually // sees its source. Vite externalizes dependencies in SSR builds by @@ -28,7 +288,72 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): ReturnType // diagnostics_channel calls never get injected. Vite merges array // `noExternal` entries with the user's config, so we don't overwrite // their additions. - return { ssr: { noExternal: instrumentedModuleNames(options.instrumentations) } }; + return { ssr: { noExternal: instrumentedModuleNames(instrumentations) } }; + }, + configEnvironment(this, name) { + if (name === 'client') return undefined; + + // In dev, environments that pre-bundle their dependencies (e.g. + // `@cloudflare/vite-plugin` worker environments set + // `optimizeDeps.noDiscovery: false`) load instrumented packages through + // the dep optimizer, which bypasses the normal Vite transform pipeline — + // channels would silently never be injected in `vite dev`. Register the + // code transformer with the optimizer too; it returns null for everything + // but the instrumented files, so it composes with Vite's loader. + // Environments without dep optimization ignore this. + // + // Vite 8 pre-bundles deps with Rolldown (which takes Rollup-style plugins + // via `optimizeDeps.rolldownOptions` and deprecates `esbuildOptions`); + // earlier Vite uses esbuild. Detect via the Rolldown-only + // `meta.rolldownVersion` and feed the matching transformer flavor. + // @ts-expect-error - rolldownVersion is not typed + // eslint-disable-next-line no-unsafe-member-access + if (this?.meta?.rolldownVersion) { + return { + optimizeDeps: { + rolldownOptions: { + plugins: [codeTransformerRollup(transformOptions)], + }, + }, + }; + } + + return { + optimizeDeps: { + esbuildOptions: { + plugins: [codeTransformerEsbuild(transformOptions)], + }, + }, + }; + }, + transform(this, code, id) { + if (hasRegistrationPlugin || command !== 'serve' || this?.environment?.config?.consumer === 'client') return null; + + const environment = this?.environment?.name ?? ''; + const cleanId = eligibleDevEntry(injectedServeModules, id, environment); + if (!cleanId || code.includes(MARKER_MODULE_ID)) return null; + + // Inject an `import` rather than prepending the banner as plain + // statements: `vite dev` serves unbundled ESM, and ES module evaluation + // runs a module's imports before its body. Plain statements would run + // after an imported instrument file's `Sentry.init()`, so + // `isOrchestrionInjected()` would still read false. A prepended import is + // hoisted and evaluated in source order — before the instrument import — + // so the marker is set first. + injectedServeModules.set(environment, cleanId); + const ms = new MagicString(code); + ms.prepend(`import '${MARKER_MODULE_ID}';\n`); + + return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; + }, + renderChunk(this, code, chunk) { + if (!chunk.isEntry || this?.environment?.config?.consumer === 'client') return null; + // Prepend via magic-string so the entry chunk's sourcemap stays aligned — + // returning `map: null` here would shift every mapping by the banner's + // line count and misattribute server stack traces. + const ms = new MagicString(code); + ms.prepend(banner); + return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; }, }; } diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index f6b7cb1c9e73..19971b3f00c9 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -1,4 +1,4 @@ -import { debug, GLOBAL_OBJ } from '@sentry/core'; +import { debug, GLOBAL_OBJ, type Integration } from '@sentry/core'; /** * Whether orchestrion has injected the diagnostics channels into this process, @@ -13,6 +13,23 @@ export function isOrchestrionInjected(): boolean { return !!GLOBAL_OBJ.__SENTRY_ORCHESTRION__; } +/** + * Returns fresh instances of the channel-subscriber integrations an injector + * registered on the global marker. + * + * 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[] { + const marker = GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + const registered = marker?.integrations || []; + + return registered.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 cf0302556d6a..fb3cca7f4890 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,3 +1,4 @@ +import { GLOBAL_OBJ } from '@sentry/core'; import { amqplibChannelIntegration } from '../integrations/tracing-channel/amqplib'; import { anthropicChannelIntegration } from '../integrations/tracing-channel/anthropic'; import { dataloaderChannelIntegration } from '../integrations/tracing-channel/dataloader'; @@ -20,7 +21,7 @@ import { postgresJsChannelIntegration } from '../integrations/tracing-channel/po import { vercelAiChannelIntegration } from '../integrations/tracing-channel/vercel-ai'; import { expressChannelIntegration } from '../integrations/tracing-channel/express'; -export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; +export { detectOrchestrionSetup, getRegisteredChannelIntegrations, isOrchestrionInjected } from './detect'; // The `@nestjs/*` channel names live here alongside their transform config; the // listener that subscribes to them lives in `@sentry/nestjs`, which imports this. export { nestjsChannels } from './config/nestjs'; @@ -58,9 +59,9 @@ export type * from '../integrations/tracing-channel/graphql/graphql-types'; * (OTel-parity) factory name. * * Single source of truth: add a new channel integration here and every consumer — the `@sentry/node` - * opt-in helper (`experimentalUseDiagnosticsChannelInjection`) and its public - * `diagnosticsChannelInjectionIntegrations()` map — picks it up automatically, so there's no separate - * list to keep in sync. + * opt-in helper (`experimentalUseDiagnosticsChannelInjection`), its public + * `diagnosticsChannelInjectionIntegrations()` map, and the marker-based `registerChannelIntegrations()` + * below — picks it up automatically. * * NOTE: `ioredisChannelIntegration` and `redisChannelIntegration` are intentionally NOT here. They * only partially replace the composite OTel `Redis` integration and need the node SDK's redis cache @@ -92,3 +93,18 @@ export const channelIntegrations = { graphqlIntegration: graphqlDiagnosticsChannelIntegration, kafkajsIntegration: kafkajsChannelIntegration, } as const; + +/** + * Puts the factories of all channel integrations onto the global orchestrion + * marker and marks the bundler injection as active, so + * `getRegisteredChannelIntegrations()` picks them up. + * + * Only meant to be called from the registration import that a bundler plugin + * injects into the app entry. Calling it statically from an SDK would keep the + * integration code in bundles that the plugin never instruments. + */ +export function registerChannelIntegrations(): void { + const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}); + + marker.integrations = Object.values(channelIntegrations); +} diff --git a/packages/server-utils/test/orchestrion/register-integrations.test.ts b/packages/server-utils/test/orchestrion/register-integrations.test.ts new file mode 100644 index 000000000000..54289840e270 --- /dev/null +++ b/packages/server-utils/test/orchestrion/register-integrations.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getRegisteredChannelIntegrations } from '../../src/orchestrion/detect'; +import { channelIntegrations, registerChannelIntegrations } from '../../src/orchestrion/index'; + +describe('channel-integration registry', () => { + beforeEach(() => { + delete globalThis.__SENTRY_ORCHESTRION__; + }); + + afterEach(() => { + delete globalThis.__SENTRY_ORCHESTRION__; + }); + + describe('getRegisteredChannelIntegrations', () => { + it('returns an empty array when no marker exists', () => { + expect(getRegisteredChannelIntegrations()).toEqual([]); + }); + + it('returns an empty array when the marker has no integrations', () => { + globalThis.__SENTRY_ORCHESTRION__ = { bundler: true }; + expect(getRegisteredChannelIntegrations()).toEqual([]); + }); + + it('instantiates each registered factory', () => { + globalThis.__SENTRY_ORCHESTRION__ = { + integrations: [() => ({ name: 'FirstIntegration' }), () => ({ name: 'SecondIntegration' })], + }; + + expect(getRegisteredChannelIntegrations().map(i => i.name)).toEqual(['FirstIntegration', 'SecondIntegration']); + }); + + it('returns fresh instances on every call', () => { + globalThis.__SENTRY_ORCHESTRION__ = { + integrations: [() => ({ name: 'FirstIntegration' })], + }; + + const [first] = getRegisteredChannelIntegrations(); + const [second] = getRegisteredChannelIntegrations(); + + expect(first).not.toBe(second); + expect(first?.name).toBe(second?.name); + }); + }); + + describe('registerChannelIntegrations', () => { + it('registers a factory for every canonical channel integration', () => { + registerChannelIntegrations(); + + const registered = getRegisteredChannelIntegrations(); + expect(registered).toHaveLength(Object.keys(channelIntegrations).length); + expect(registered.every(i => typeof i.name === 'string' && i.name.length > 0)).toBe(true); + }); + + it('creates the marker when none exists', () => { + registerChannelIntegrations(); + + expect(globalThis.__SENTRY_ORCHESTRION__?.integrations).toBeDefined(); + }); + + it('preserves existing marker fields', () => { + globalThis.__SENTRY_ORCHESTRION__ = { bundler: true, runtime: true }; + + registerChannelIntegrations(); + + expect(globalThis.__SENTRY_ORCHESTRION__?.bundler).toBe(true); + expect(globalThis.__SENTRY_ORCHESTRION__?.runtime).toBe(true); + expect(getRegisteredChannelIntegrations().length).toBeGreaterThan(0); + }); + + it('registers factories, not eagerly-built instances', () => { + registerChannelIntegrations(); + + expect(globalThis.__SENTRY_ORCHESTRION__?.integrations?.every(factory => typeof factory === 'function')).toBe( + true, + ); + }); + }); +}); 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..cc0c779a6b24 --- /dev/null +++ b/packages/server-utils/test/orchestrion/vite-plugin.test.ts @@ -0,0 +1,118 @@ +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; +} + +// The optimizer names the transformer plugin `code-transformer`, both flavors. +function optimizerPluginNames(meta?: { rolldownVersion?: string }): string[] { + const marker = getMarkerPlugin(); + const result = marker.configEnvironment.call({ meta }, 'ssr'); + const opts = result?.optimizeDeps ?? {}; + const plugins = opts.rolldownOptions?.plugins ?? opts.esbuildOptions?.plugins ?? []; + return plugins.map((p: { name: string }) => p.name); +} + +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(); + }); + + it('does not prepend the bundler marker to client chunks', () => { + const marker = getMarkerPlugin(); + const clientContext = { environment: { config: { consumer: 'client' } } }; + + expect(marker.renderChunk.call(clientContext, 'console.log("app");', { isEntry: true })).toBeNull(); + }); + + it('applies the marker and code transformer only to server environments', () => { + const plugins = sentryOrchestrionPlugin({ registerIntegrations: true }); + const environmentPlugins = plugins.filter( + plugin => plugin.name === 'sentry-orchestrion-marker' || plugin.name === 'code-transformer', + ); + + expect(environmentPlugins.every(plugin => plugin.applyToEnvironment({ config: { consumer: 'server' } }))).toBe( + true, + ); + expect(environmentPlugins.every(plugin => !plugin.applyToEnvironment({ config: { consumer: 'client' } }))).toBe( + true, + ); + }); + + it('injects the marker as a hoisted import during dev and reinjects it after entry HMR', () => { + const marker = getMarkerPlugin(); + marker.configResolved({ command: 'serve' }); + const context = { environment: { name: 'worker', config: { consumer: 'server' } } }; + + const initial = marker.transform.call(context, `import './instrument';\n`, '/app/src/index.ts'); + const updated = marker.transform.call( + context, + `import './instrument';\nexport const updated = true;\n`, + '/app/src/index.ts?t=123', + ); + + // A prepended import is hoisted before the instrument import, so it runs + // before `Sentry.init()` — plain banner statements would not. + expect(initial?.code).toContain(`import 'virtual:@sentry/orchestrion-marker';`); + expect(initial?.code.indexOf(`virtual:@sentry/orchestrion-marker`)).toBeLessThan( + initial?.code.indexOf(`import './instrument'`), + ); + expect(updated?.code).toContain(`import 'virtual:@sentry/orchestrion-marker';`); + }); + + it('resolves and loads the virtual marker module as a side-effect banner', () => { + const marker = getMarkerPlugin(); + + expect(marker.resolveId('virtual:@sentry/orchestrion-marker')).toBe('\0virtual:@sentry/orchestrion-marker'); + expect(marker.resolveId('some-other-module')).toBeNull(); + + const loaded = marker.load('\0virtual:@sentry/orchestrion-marker'); + expect(loaded?.code).toContain('globalThis.__SENTRY_ORCHESTRION__.bundler = true;'); + expect(loaded?.moduleSideEffects).toBe(true); + expect(marker.load('some-other-module')).toBeNull(); + }); + + describe('configEnvironment (dev dep-optimizer instrumentation)', () => { + it('adds the esbuild transformer for server environments on classic Vite', () => { + // No `meta.rolldownVersion` → esbuild-based optimizer. + expect(optimizerPluginNames()).toContain('code-transformer'); + const result = getMarkerPlugin().configEnvironment.call({ meta: {} }, 'ssr'); + expect(result.optimizeDeps.esbuildOptions).toBeDefined(); + expect(result.optimizeDeps.rolldownOptions).toBeUndefined(); + }); + + it('adds the rollup transformer via rolldownOptions on Vite 8 / Rolldown', () => { + const marker = getMarkerPlugin(); + const result = marker.configEnvironment.call({ meta: { rolldownVersion: '1.1.5' } }, 'ssr'); + expect(result.optimizeDeps.rolldownOptions).toBeDefined(); + expect(result.optimizeDeps.esbuildOptions).toBeUndefined(); + expect(result.optimizeDeps.rolldownOptions.plugins.map((p: { name: string }) => p.name)).toContain( + 'code-transformer', + ); + }); + + it('does not instrument the client environment', () => { + expect(getMarkerPlugin().configEnvironment.call({ meta: {} }, 'client')).toBeUndefined(); + }); + }); +}); diff --git a/packages/server-utils/test/orchestrion/vite-register-integrations.test.ts b/packages/server-utils/test/orchestrion/vite-register-integrations.test.ts new file mode 100644 index 000000000000..523440b69906 --- /dev/null +++ b/packages/server-utils/test/orchestrion/vite-register-integrations.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; +import { sentryOrchestrionPlugin } from '../../src/orchestrion/bundler/vite'; + +const REGISTER_MODULE_ID = 'virtual:@sentry/orchestrion-register-integrations'; +const RESOLVED_REGISTER_MODULE_ID = `\0${REGISTER_MODULE_ID}`; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getRegisterPlugin(plugins: any[]): any { + return plugins.find(p => p.name === 'sentry-orchestrion-register-integrations'); +} + +// A Vite/Rollup plugin `this` context reporting `id` as an entry (or not), +// optionally in a given environment consumer ('client' | 'server'). +function ctx({ isEntry = true, consumer }: { isEntry?: boolean; consumer?: string } = {}): unknown { + return { + getModuleInfo: () => ({ isEntry }), + ...(consumer ? { environment: { config: { consumer } } } : {}), + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function runTransform( + plugin: any, + code: string, + context: unknown = ctx(), + id = 'entry.js', +): { code: string; map: unknown } | null { + return plugin.transform.call(context, code, id); +} + +// A dev-server `this` context: no `getModuleInfo` — reading `.isEntry` throws in +// the real dev server, so the serve path must never call it. +function serveCtx({ name = 'ssr', consumer }: { name?: string; consumer?: string } = {}): unknown { + return { environment: { name, ...(consumer ? { config: { consumer } } : {}) } }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeServePlugin(): any { + const plugin = getRegisterPlugin(sentryOrchestrionPlugin({ registerIntegrations: true })); + plugin.configResolved({ command: 'serve' }); + return plugin; +} + +describe('sentryOrchestrionPlugin — registerIntegrations', () => { + it('omits the register plugin by default', () => { + expect(getRegisterPlugin(sentryOrchestrionPlugin())).toBeUndefined(); + }); + + it('omits the register plugin when registerIntegrations is false', () => { + expect(getRegisterPlugin(sentryOrchestrionPlugin({ registerIntegrations: false }))).toBeUndefined(); + }); + + it('includes the register plugin when registerIntegrations is true', () => { + expect(getRegisterPlugin(sentryOrchestrionPlugin({ registerIntegrations: true }))).toBeDefined(); + }); + + describe('virtual registration module', () => { + const plugin = getRegisterPlugin(sentryOrchestrionPlugin({ registerIntegrations: true })); + + it('resolves the virtual id to the synthetic id', () => { + expect(plugin.resolveId(REGISTER_MODULE_ID)).toBe(RESOLVED_REGISTER_MODULE_ID); + }); + + it('does not resolve unrelated ids', () => { + expect(plugin.resolveId('some-other-module')).toBeNull(); + }); + + it('loads a side-effect module which registers integrations from the absolute ESM build', () => { + const result = plugin.load(RESOLVED_REGISTER_MODULE_ID); + const normalizedCode = result?.code.replaceAll('\\', '/'); + + expect(normalizedCode).toContain('@sentry/server-utils/orchestrion'); + expect(result?.code).toContain('registerChannelIntegrations();'); + expect(result?.moduleSideEffects).toBe(true); + }); + + it('does not load unrelated ids', () => { + expect(plugin.load('some-other-module')).toBeNull(); + }); + }); + + describe('transform (build)', () => { + const plugin = getRegisterPlugin(sentryOrchestrionPlugin({ registerIntegrations: true })); + + it('injects the virtual registration import into the entry module', () => { + const result = runTransform(plugin, 'export default {};\n', ctx({ isEntry: true })); + + expect(result?.code).toContain(`import '${REGISTER_MODULE_ID}';`); + expect(result?.map).toBeTruthy(); + }); + + it('injects before a re-exported worker can initialize the SDK', () => { + const result = runTransform(plugin, `export { default } from './worker';\n`, ctx({ isEntry: true })); + + expect(result?.code.indexOf(REGISTER_MODULE_ID)).toBeLessThan( + result?.code.indexOf("export { default } from './worker'"), + ); + }); + + it('does not inject into non-entry modules', () => { + const code = `import * as Sentry from '@sentry/cloudflare';\nSentry.startSpan({}, () => {});\n`; + expect(runTransform(plugin, code, ctx({ isEntry: false }))).toBeNull(); + }); + + it('does not double-inject when registration is already present', () => { + const code = `import "${REGISTER_MODULE_ID}";\nexport default {};\n`; + expect(runTransform(plugin, code, ctx({ isEntry: true }))).toBeNull(); + }); + + it('does not confuse a mention of the registration function with the injection sentinel', () => { + const code = `// registerChannelIntegrations is injected by the plugin\nexport default {};\n`; + expect(runTransform(plugin, code, ctx({ isEntry: true }))?.code).toContain(REGISTER_MODULE_ID); + }); + + it('does not inject into client-environment entries', () => { + expect(runTransform(plugin, 'export default {};\n', ctx({ isEntry: true, consumer: 'client' }))).toBeNull(); + }); + + it('injects into server-environment entries', () => { + const result = runTransform(plugin, 'export default {};\n', ctx({ isEntry: true, consumer: 'server' })); + + expect(result?.code).toContain(REGISTER_MODULE_ID); + }); + + it('assumes server when no environment info is available (classic Vite)', () => { + const noEnvCtx = { getModuleInfo: () => ({ isEntry: true }) }; + const result = runTransform(plugin, 'export default {};\n', noEnvCtx); + + expect(result?.code).toContain(REGISTER_MODULE_ID); + }); + }); + + describe('transform (dev / vite serve)', () => { + // `serveCtx` intentionally has no `getModuleInfo` — reading `.isEntry` + // throws in the real dev server, so the serve path must never call it. + it('injects into the first server source module (the entry) without reading isEntry', () => { + const plugin = makeServePlugin(); + const result = runTransform(plugin, 'export default {};\n', serveCtx(), '/app/src/index.ts'); + + expect(result?.code).toContain(`import '${REGISTER_MODULE_ID}';`); + }); + + it('injects only once per environment', () => { + const plugin = makeServePlugin(); + const first = runTransform(plugin, 'export default {};\n', serveCtx({ name: 'worker' }), '/app/src/index.ts'); + const second = runTransform(plugin, 'export const x = 1;\n', serveCtx({ name: 'worker' }), '/app/src/util.ts'); + + expect(first?.code).toContain(REGISTER_MODULE_ID); + expect(second).toBeNull(); + }); + + it('reinjects when HMR transforms the registered entry again', () => { + const plugin = makeServePlugin(); + runTransform(plugin, 'export default {};\n', serveCtx({ name: 'worker' }), '/app/src/index.ts'); + + const updated = runTransform( + plugin, + 'export default { updated: true };\n', + serveCtx({ name: 'worker' }), + '/app/src/index.ts?t=123', + ); + + expect(updated?.code).toContain(REGISTER_MODULE_ID); + }); + + it('injects once per distinct environment', () => { + const plugin = makeServePlugin(); + const worker = runTransform(plugin, 'export default {};\n', serveCtx({ name: 'worker' }), '/app/src/index.ts'); + const ssr = runTransform(plugin, 'export default {};\n', serveCtx({ name: 'ssr' }), '/app/src/index.ts'); + + expect(worker?.code).toContain(REGISTER_MODULE_ID); + expect(ssr?.code).toContain(REGISTER_MODULE_ID); + }); + + it.each([ + { label: 'pre-bundled deps', id: '/app/node_modules/.vite/deps_worker/mysql.js' }, + { label: 'node_modules source', id: '/app/node_modules/mysql/index.js' }, + { label: 'virtual modules', id: '\0virtual:some-module' }, + ])('skips $label', ({ id }) => { + const plugin = makeServePlugin(); + + expect(runTransform(plugin, 'export default {};\n', serveCtx({ name: 'worker' }), id)).toBeNull(); + }); + + it('does not inject into client environments', () => { + const plugin = makeServePlugin(); + const result = runTransform( + plugin, + 'export default {};\n', + serveCtx({ name: 'client', consumer: 'client' }), + '/app/src/index.ts', + ); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 7b70dded22ba..d4c3ae2068ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19859,11 +19859,16 @@ jsonc-parser@3.1.0: resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.1.0.tgz#73b8f0e5c940b83d03476bc2e51a20ef0932615d" integrity sha512-DRf0QjnNeCUds3xTjKlQQ3DpJD51GvDjJfnxUVWg6PZTo2otSm+slzNAxU/35hF8/oJIKoG9slq30JYOsF2azg== -jsonc-parser@3.2.0, jsonc-parser@^3.0.0: +jsonc-parser@3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.0.tgz#31ff3f4c2b9793f89c67212627c51c6394f88e76" integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w== +jsonc-parser@^3.0.0, jsonc-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + jsondiffpatch@0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz#daa6a25bedf0830974c81545568d5f671c82551f" @@ -20690,7 +20695,7 @@ magic-string@^0.26.0, magic-string@^0.26.7: dependencies: sourcemap-codec "^1.4.8" -magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.8: +magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.21, magic-string@~0.30.8: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== @@ -27184,6 +27189,11 @@ smol-toml@1.6.1: resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.6.1.tgz#4fceb5f7c4b86c2544024ef686e12ff0983465be" integrity sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg== +smol-toml@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.7.0.tgz#ed1b259ce7e05907df1abe758971bd0a0ef2c0dd" + integrity sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ== + snake-case@^3.0.3: version "3.0.4" resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c"