From 0d561816f820620c495e86ca4207ca5081d74f18 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 14 Jul 2026 15:23:35 +0200 Subject: [PATCH 1/4] feat(server-utils): Register orchestrion channel integrations via the global marker Let SDKs enable orchestrion diagnostics-channel integrations without statically importing (and therefore shipping) their subscriber code. A bundler plugin puts the integration factories on `globalThis.__SENTRY_ORCHESTRION__`, and `getRegisteredChannelIntegrations()` reads them at `init()`, activating only the integrations whose module was actually transformed into the bundle. - Vite plugin (`@sentry/server-utils/orchestrion/vite`) instruments both `vite build` and `vite dev`. In dev, dependencies are pre-bundled by the optimizer before the `transform` hook runs, so the code transform is wired into the optimizer instead (esbuild on classic Vite, Rolldown on Vite 8), and the registration import is injected structurally into the entry since `renderChunk`/`getModuleInfo().isEntry` are unavailable there. - Warn when a module's transform fails (at build time, and once per isolate in debug runtime): the package ships without its diagnostics channels, so its integration is skipped and its spans would otherwise silently go missing. - Guard the CJS-only `pathToFileURL` import so the ESM build carries no unused external import. Co-Authored-By: Claude Opus 4.8 feat: Remove failedModules usage fix: Call the banner via virtual module test: for..of -> it.each (insert drake meme) chore: Fix a wrong rebase chore: Fix lint --- packages/core/src/utils/worldwide.ts | 3 + packages/server-utils/package.json | 3 +- .../src/orchestrion/bundler/vite.ts | 358 +++++++++++++++++- .../server-utils/src/orchestrion/detect.ts | 19 +- .../server-utils/src/orchestrion/index.ts | 23 +- .../orchestrion/register-integrations.test.ts | 79 ++++ .../test/orchestrion/vite-plugin.test.ts | 118 ++++++ .../vite-register-integrations.test.ts | 198 ++++++++++ 8 files changed, 789 insertions(+), 12 deletions(-) create mode 100644 packages/server-utils/test/orchestrion/register-integrations.test.ts create mode 100644 packages/server-utils/test/orchestrion/vite-plugin.test.ts create mode 100644 packages/server-utils/test/orchestrion/vite-register-integrations.test.ts diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 42a7ffdfaec4..f7c247c88691 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 '@sentry/core'; 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..043f2f421d4b 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -1,14 +1,91 @@ +// 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. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type UnknownPlugin = any; + +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 { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; import { orchestrionTransformOptions } from './options'; +// `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 +94,207 @@ 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 { - ...codeTransformer(orchestrionTransformOptions(options)), + ...plugin, + applyToEnvironment(this: unknown, environment: { config?: { consumer?: string } }): unknown { + 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 { + const require = createRequire(import.meta.url); + const packageRoot = dirname(require.resolve('@sentry/server-utils/package.json')); + // This avoids any backslash-escaping concerns on Windows + const resolvedRegisterModule = resolve(packageRoot, 'build/esm/orchestrion/index.js').replace(/\\/g, '/'); + + // The slices of Vite's environment-API / Rollup plugin context we read; typed + // structurally since we don't import `vite`/`rollup` types here (see note at + // the top of the file). + interface PluginContext { + environment?: { name?: string; config?: { consumer?: string } }; + getModuleInfo?: (id: string) => { isEntry?: boolean } | null; + } + + // `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): { code: string; map: unknown } | null { + 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 '${resolvedRegisterModule}';`, + 'registerChannelIntegrations();', + '', + ].join('\n'), + moduleSideEffects: true, + }; + }, + transform(this: PluginContext | undefined, code: string, id: string): { code: string; map: unknown } | null { + // Client bundles must never pull in a server SDK's integrations; without + // environment info (classic non-environment-API Vite) assume server. + if (this?.environment?.config?.consumer === 'client') return null; + + if (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 { + 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 +303,78 @@ 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: { meta?: { rolldownVersion?: string } } | undefined, name: string): unknown { + 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. + if (this?.meta?.rolldownVersion) { + return { + optimizeDeps: { + rolldownOptions: { + plugins: [codeTransformerRollup(transformOptions)], + }, + }, + }; + } + + return { + optimizeDeps: { + esbuildOptions: { + plugins: [codeTransformerEsbuild(transformOptions)], + }, + }, + }; + }, + transform( + this: { environment?: { name?: string; config?: { consumer?: string } } } | undefined, + code: string, + id: string, + ): { code: string; map: unknown } | null { + 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: { environment?: { config?: { consumer?: string } } } | undefined, + code: string, + chunk: { isEntry: boolean }, + ): { code: string; map: unknown } | null { + 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..65a6232c9581 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 = globalThis.__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..ce6453b1b343 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -20,7 +20,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 +58,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 +92,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 = (globalThis.__SENTRY_ORCHESTRION__ = globalThis.__SENTRY_ORCHESTRION__ || {}); + marker.bundler = true; + 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..e86ac8bbf3a6 --- /dev/null +++ b/packages/server-utils/test/orchestrion/register-integrations.test.ts @@ -0,0 +1,79 @@ +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__?.bundler).toBe(true); + 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..5bf697733a54 --- /dev/null +++ b/packages/server-utils/test/orchestrion/vite-register-integrations.test.ts @@ -0,0 +1,198 @@ +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('/server-utils/build/esm/orchestrion/index.js'); + expect(normalizedCode).not.toContain('/build/cjs/'); + 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(); + }); + }); +}); From b59ebba192761d135176b0bfce7d13ec6800b374 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 16 Jul 2026 09:18:02 +0200 Subject: [PATCH 2/4] fixup! feat(server-utils): Register orchestrion channel integrations via the global marker --- packages/core/src/utils/worldwide.ts | 2 +- .../src/orchestrion/bundler/vite.ts | 45 +++++-------------- .../server-utils/src/orchestrion/detect.ts | 2 +- .../server-utils/src/orchestrion/index.ts | 5 ++- .../orchestrion/register-integrations.test.ts | 1 - .../vite-register-integrations.test.ts | 3 +- 6 files changed, 18 insertions(+), 40 deletions(-) diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index f7c247c88691..61fd650f18bf 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -12,7 +12,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { Integration } from '@sentry/core'; +import type { Integration } from '../shared-exports'; import type { Carrier } from '../carrier'; import type { SdkSource } from './env'; diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 043f2f421d4b..593637b411a2 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -10,20 +10,17 @@ // ESM entry, so attempts to `require('@sentry/server-utils/orchestrion/vite')` will // fail at resolution time rather than producing a half-broken plugin. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type UnknownPlugin = any; - 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 { createRequire } from 'node:module'; -import { dirname, resolve } from 'node:path'; 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` @@ -120,7 +117,7 @@ function serverEnvironmentOnly(plugin: UnknownPlugin): UnknownPlugin { return { ...plugin, - applyToEnvironment(this: unknown, environment: { config?: { consumer?: string } }): unknown { + applyToEnvironment(this: unknown, environment) { if (environment.config?.consumer === 'client') return false; return applyToEnvironment?.call(this, environment) ?? true; }, @@ -175,27 +172,15 @@ const RESOLVED_REGISTER_MODULE_ID = `\0${REGISTER_MODULE_ID}`; * eligible source module transformed in each server environment. */ function registerIntegrationsPlugin(): UnknownPlugin { - const require = createRequire(import.meta.url); - const packageRoot = dirname(require.resolve('@sentry/server-utils/package.json')); - // This avoids any backslash-escaping concerns on Windows - const resolvedRegisterModule = resolve(packageRoot, 'build/esm/orchestrion/index.js').replace(/\\/g, '/'); - - // The slices of Vite's environment-API / Rollup plugin context we read; typed - // structurally since we don't import `vite`/`rollup` types here (see note at - // the top of the file). - interface PluginContext { - environment?: { name?: string; config?: { consumer?: string } }; - getModuleInfo?: (id: string) => { isEntry?: boolean } | null; - } - // `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): { code: string; map: unknown } | null { + function injectRegisterImport(code: string) { if (code.includes(REGISTER_MODULE_ID)) return null; const ms = new MagicString(code); @@ -220,14 +205,14 @@ function registerIntegrationsPlugin(): UnknownPlugin { // imports here and let Rollup tree-shake the rest of the ESM module. return { code: [ - `import { registerChannelIntegrations } from '${resolvedRegisterModule}';`, + `import { registerChannelIntegrations } from '@sentry/server-utils/orchestrion';`, 'registerChannelIntegrations();', '', ].join('\n'), moduleSideEffects: true, }; }, - transform(this: PluginContext | undefined, code: string, id: string): { code: string; map: unknown } | null { + 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; @@ -305,7 +290,7 @@ function bundlerMarkerPlugin({ // their additions. return { ssr: { noExternal: instrumentedModuleNames(instrumentations) } }; }, - configEnvironment(this: { meta?: { rolldownVersion?: string } } | undefined, name: string): unknown { + configEnvironment(this, name) { if (name === 'client') return undefined; // In dev, environments that pre-bundle their dependencies (e.g. @@ -321,6 +306,8 @@ function bundlerMarkerPlugin({ // 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: { @@ -339,11 +326,7 @@ function bundlerMarkerPlugin({ }, }; }, - transform( - this: { environment?: { name?: string; config?: { consumer?: string } } } | undefined, - code: string, - id: string, - ): { code: string; map: unknown } | null { + transform(this, code, id) { if (hasRegistrationPlugin || command !== 'serve' || this?.environment?.config?.consumer === 'client') return null; const environment = this?.environment?.name ?? ''; @@ -363,11 +346,7 @@ function bundlerMarkerPlugin({ return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; }, - renderChunk( - this: { environment?: { config?: { consumer?: string } } } | undefined, - code: string, - chunk: { isEntry: boolean }, - ): { code: string; map: unknown } | null { + 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 diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index 65a6232c9581..19971b3f00c9 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -24,7 +24,7 @@ export function isOrchestrionInjected(): boolean { * the bundle. */ export function getRegisteredChannelIntegrations(): Integration[] { - const marker = globalThis.__SENTRY_ORCHESTRION__; + const marker = GLOBAL_OBJ.__SENTRY_ORCHESTRION__; const registered = marker?.integrations || []; return registered.map(factory => factory()); diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index ce6453b1b343..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'; @@ -103,7 +104,7 @@ export const channelIntegrations = { * integration code in bundles that the plugin never instruments. */ export function registerChannelIntegrations(): void { - const marker = (globalThis.__SENTRY_ORCHESTRION__ = globalThis.__SENTRY_ORCHESTRION__ || {}); - marker.bundler = true; + 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 index e86ac8bbf3a6..54289840e270 100644 --- a/packages/server-utils/test/orchestrion/register-integrations.test.ts +++ b/packages/server-utils/test/orchestrion/register-integrations.test.ts @@ -54,7 +54,6 @@ describe('channel-integration registry', () => { it('creates the marker when none exists', () => { registerChannelIntegrations(); - expect(globalThis.__SENTRY_ORCHESTRION__?.bundler).toBe(true); expect(globalThis.__SENTRY_ORCHESTRION__?.integrations).toBeDefined(); }); diff --git a/packages/server-utils/test/orchestrion/vite-register-integrations.test.ts b/packages/server-utils/test/orchestrion/vite-register-integrations.test.ts index 5bf697733a54..523440b69906 100644 --- a/packages/server-utils/test/orchestrion/vite-register-integrations.test.ts +++ b/packages/server-utils/test/orchestrion/vite-register-integrations.test.ts @@ -69,8 +69,7 @@ describe('sentryOrchestrionPlugin — registerIntegrations', () => { const result = plugin.load(RESOLVED_REGISTER_MODULE_ID); const normalizedCode = result?.code.replaceAll('\\', '/'); - expect(normalizedCode).toContain('/server-utils/build/esm/orchestrion/index.js'); - expect(normalizedCode).not.toContain('/build/cjs/'); + expect(normalizedCode).toContain('@sentry/server-utils/orchestrion'); expect(result?.code).toContain('registerChannelIntegrations();'); expect(result?.moduleSideEffects).toBe(true); }); From 7e8f420e72e5b6d3a1a96aa3fead3bd124a4eb97 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 14 Jul 2026 15:31:34 +0200 Subject: [PATCH 3/4] feat(cloudflare): Instrument bundled packages via orchestrion diagnostics channels Integrate the server-utils orchestrion channel-integration mechanism into the Cloudflare SDK, so bundled npm packages (e.g. `mysql`) are traced without monkey-patching, which workerd doesn't support anyway. - Add the `@sentry/cloudflare/vite` plugin, which runs the orchestrion transform (injecting `diagnostics_channel.tracingChannel` calls) and injects a registration module that puts the channel-subscriber integrations on the global marker. Workers built without the plugin don't ship that code. - `getDefaultIntegrations()` reads the registered integrations from the marker at `init()`, activating only those whose module was actually transformed, and warns (debug only, once per isolate) about modules whose transform failed. - Add a Cloudflare + MySQL e2e test app exercising real `db` spans in workerd. Co-Authored-By: Claude Opus 4.8 --- .../cloudflare-orchestrion-mysql/.gitignore | 2 + .../docker-compose.yml | 18 +++++ .../global-setup.mjs | 14 ++++ .../global-teardown.mjs | 12 +++ .../cloudflare-orchestrion-mysql/package.json | 34 +++++++++ .../playwright.config.ts | 22 ++++++ .../cloudflare-orchestrion-mysql/src/env.d.ts | 3 + .../cloudflare-orchestrion-mysql/src/index.ts | 73 +++++++++++++++++++ .../start-event-proxy.mjs | 6 ++ .../tests/mysql.test.ts | 57 +++++++++++++++ .../tsconfig.json | 16 ++++ .../vite.config.ts | 14 ++++ .../wrangler.jsonc | 7 ++ packages/cloudflare/package.json | 4 + packages/cloudflare/rollup.npm.config.mjs | 2 +- packages/cloudflare/src/sdk.ts | 19 +++++ packages/cloudflare/src/vite/index.ts | 70 ++++++++++++++++++ packages/cloudflare/test/sdk.test.ts | 42 ++++++++++- 18 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/docker-compose.yml create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-teardown.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tests/mysql.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/wrangler.jsonc create mode 100644 packages/cloudflare/src/vite/index.ts 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..813c457ef8a5 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..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/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'); + }); +}); From b5454ef0fcd46b2712873914e947e7ecf0f414b6 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 14 Jul 2026 16:31:08 +0200 Subject: [PATCH 4/4] feat(cloudflare): Read wrangler config and resolve the Sentry options module Add the building blocks the auto-instrument Vite plugin will use, with no wiring into a plugin yet: - `wranglerConfig`: locate and parse `wrangler.{json,jsonc,toml}`, returning the worker entry (`main`) and the configured Durable Object class names. - `instrumentFile`: find a conventional `instrument.server.{ts,js,mjs,cjs}` next to the entry and build the options import, falling back to an env-based callback when absent. - `defineCloudflareOptions`: identity helper that gives the options callback its type in that module. Adds `jsonc-parser`, `smol-toml`, and `magic-string` as dependencies. Co-Authored-By: Claude Opus 4.8 --- packages/cloudflare/package.json | 5 +- .../cloudflare/src/defineCloudflareOptions.ts | 46 ++++ packages/cloudflare/src/index.ts | 1 + .../cloudflare/src/vite/instrumentFile.ts | 53 ++++ .../cloudflare/src/vite/wranglerConfig.ts | 108 ++++++++ .../test/defineCloudflareOptions.test.ts | 28 ++ .../test/vite/wranglerConfig.test.ts | 244 ++++++++++++++++++ yarn.lock | 14 +- 8 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 packages/cloudflare/src/defineCloudflareOptions.ts create mode 100644 packages/cloudflare/src/vite/instrumentFile.ts create mode 100644 packages/cloudflare/src/vite/wranglerConfig.ts create mode 100644 packages/cloudflare/test/defineCloudflareOptions.test.ts create mode 100644 packages/cloudflare/test/vite/wranglerConfig.test.ts diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 813c457ef8a5..69954354cbab 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -66,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/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/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/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/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"