From 7b1080307c00cdbf8ab9e8076a19bb799e58a76e Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 8 Jul 2026 12:08:27 +0200 Subject: [PATCH] feat(server-utils): Allow integrations to be part of marker --- .../src/orchestrion/bundler/vite.ts | 98 +++++++++++++++- .../server-utils/src/orchestrion/detect.ts | 20 +++- .../server-utils/src/orchestrion/index.ts | 17 ++- .../src/orchestrion/runtime/register.ts | 5 +- .../orchestrion/register-integrations.test.ts | 76 ++++++++++++ .../vite-register-integrations.test.ts | 110 ++++++++++++++++++ 6 files changed, 319 insertions(+), 7 deletions(-) create mode 100644 packages/server-utils/test/orchestrion/register-integrations.test.ts create mode 100644 packages/server-utils/test/orchestrion/vite-register-integrations.test.ts diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 4cadc7b40925..279181a60e22 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -22,6 +22,30 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; // false positive. We don't need the runtime value for typing — `UnknownPlugin` // is sufficient — so we omit the import entirely. +export interface SentryOrchestrionPluginOptions { + /** + * Whether to register the SDK's channel-subscriber integrations at build time. + * + * When `true`, the plugin injects a generated (virtual) registration module + * into the app's server entry. That module puts the channel-subscriber + * integrations on the global orchestrion marker, where + * `getRegisteredChannelIntegrations()` picks them up. This is how the + * subscriber integrations — which SDKs deliberately do not import so bundlers + * can drop them — end up in the bundle exactly when this plugin injects the + * channels they subscribe to. + * + * The registration is SDK-agnostic: the virtual module imports from + * `@sentry/server-utils` (a transitive dependency of every SDK that uses this + * plugin), so any bundled SDK — Cloudflare today, Nuxt/Nitro, SvelteKit, Node + * SSR later — enables it the same way, with nothing to publish or wire up. + * + * Leave unset for SDKs that register integrations through the runtime + * `--import` hook instead (e.g. `@sentry/node`), which read them from a static + * import rather than the marker. + */ + registerIntegrations?: boolean; +} + /** * Vite plugin that runs the orchestrion code transform on the bundled output. * @@ -29,7 +53,7 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; * pipeline, SvelteKit). For unbundled Node processes use the runtime hook * instead (`node --import @sentry/node/orchestrion app.js`). * - * Returns two plugins: + * Returns the following plugins: * 1. `sentry-orchestrion-marker` — a `renderChunk` hook that prepends a * single-line banner to entry chunks. The banner sets * `globalThis.__SENTRY_ORCHESTRION__.bundler = true` at app boot, so the @@ -39,7 +63,11 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; * Also injects every instrumented package name into `ssr.noExternal` via * the `config` hook, since externalized deps are `require()`d at runtime * from `node_modules` and never pass through the transform. - * 2. The upstream `@apm-js-collab/code-transformer-bundler-plugins/vite` + * 2. `sentry-orchestrion-register-integrations` (only with + * `options.registerIntegrations`) — injects a generated registration + * module 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 @@ -49,12 +77,74 @@ import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '../config'; * export default { plugins: [sentryOrchestrionPlugin()] }; * ``` */ -export function sentryOrchestrionPlugin(): UnknownPlugin[] { +export function sentryOrchestrionPlugin(options: SentryOrchestrionPluginOptions = {}): UnknownPlugin[] { const codeTransformerPlugins = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }); const codeTransformerArray: UnknownPlugin[] = Array.isArray(codeTransformerPlugins) ? codeTransformerPlugins : [codeTransformerPlugins]; - return [bundlerMarkerPlugin(), ...codeTransformerArray]; + return [ + bundlerMarkerPlugin(), + ...(options.registerIntegrations ? [registerIntegrationsPlugin()] : []), + ...codeTransformerArray, + ]; +} + +// Id of the virtual registration module the plugin injects. The `\0` prefix on +// the resolved id is the Rollup/Vite convention that marks a module as +// synthetic, so no other plugin (or the file system) tries to resolve it. +const REGISTER_MODULE_ID = 'virtual:@sentry/orchestrion-register-integrations'; +const RESOLVED_REGISTER_MODULE_ID = `\0${REGISTER_MODULE_ID}`; + +function registerIntegrationsPlugin(): UnknownPlugin { + // 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 TransformContext { + environment?: { config?: { consumer?: string } }; + getModuleInfo?: (id: string) => { isEntry?: boolean } | null; + } + + return { + name: 'sentry-orchestrion-register-integrations', + 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; + // For now this registers every channel integration. `@sentry/server-utils` + // is `sideEffects: false`, so a future selective version only has to import + // the specific factories it needs here and the rest tree-shakes out of the + // bundle — without the app having to publish or wire up anything. + return { + code: [ + "import { registerChannelIntegrations } from '@sentry/server-utils/orchestrion';", + 'registerChannelIntegrations();', + '', + ].join('\n'), + // The injected import is a bare side-effect import; keep the module so + // the `registerChannelIntegrations()` call — the whole point — survives. + moduleSideEffects: true, + }; + }, + transform(this: TransformContext | undefined, code: string, id: string): { code: string; map: unknown } | null { + // Client bundles must never pull in a server SDK's integrations; without + // environment info (classic non-environment-API Vite) assume server. + if (this?.environment?.config?.consumer === 'client') return null; + // Inject into the app entry only — hoisting guarantees registration runs + // before the entry body (and thus before `Sentry.init()`), whether the + // entry inits directly or re-exports a module that does. Matching every + // module that imports the SDK instead would scatter redundant imports + // across the graph (e.g. any file using `Sentry.startSpan`). + if (!this?.getModuleInfo?.(id)?.isEntry) return null; + if (code.includes(REGISTER_MODULE_ID)) return null; + const ms = new MagicString(code); + // Appending keeps existing sourcemap lines intact; ESM hoisting makes the + // import's position irrelevant, and registration only has to happen by + // the time `init()` runs, not before the entry body evaluates. + ms.append(`\nimport ${JSON.stringify(REGISTER_MODULE_ID)};\n`); + return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; + }, + }; } function bundlerMarkerPlugin(): UnknownPlugin { diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index 9dfa88bf427d..4e111aa250d1 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -1,9 +1,12 @@ +import type { Integration } from '@sentry/core'; import { debug } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; declare global { // eslint-disable-next-line no-var - var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; + var __SENTRY_ORCHESTRION__: + | { runtime?: boolean; bundler?: boolean; integrations?: Array<() => Integration> } + | undefined; } /** @@ -20,6 +23,21 @@ export function isOrchestrionInjected(): boolean { return !!(marker?.runtime || marker?.bundler); } +/** + * Returns fresh instances of every channel-subscriber integration an injector + * registered on the global marker (e.g. the registration module that the + * `@sentry/cloudflare/vite` plugin injects into the worker bundle). + * + * SDKs that can't afford to ship the subscriber code unconditionally read the + * registry through this function instead of importing the integrations: no + * static import means bundlers drop the integration code entirely unless the + * injector put its registration module — and with it the integrations — into + * the bundle. + */ +export function getRegisteredChannelIntegrations(): Integration[] { + return (globalThis.__SENTRY_ORCHESTRION__?.integrations || []).map(factory => factory()); +} + /** * Verifies that the diagnostics channels have been injected either by the * runtime `--import` hook (or init-time registration), a bundler plugin, or diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 8ec742aff3f0..c01b4c6a12c2 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -9,7 +9,7 @@ import { postgresChannelIntegration } from '../integrations/tracing-channel/post import { postgresJsChannelIntegration } from '../integrations/tracing-channel/postgres-js'; import { vercelAiChannelIntegration } from '../integrations/tracing-channel/vercel-ai'; -export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; +export { detectOrchestrionSetup, getRegisteredChannelIntegrations, isOrchestrionInjected } from './detect'; export { anthropicChannelIntegration, googleGenAIChannelIntegration, @@ -49,3 +49,18 @@ export const channelIntegrations = { vercelAiIntegration: vercelAiChannelIntegration, hapiIntegration: hapiChannelIntegration, } as const; + +/** + * Puts the factories of all channel integrations onto the global orchestrion + * marker, where `getRegisteredChannelIntegrations()` picks them up. + * + * Only meant to be called from a bundler-injected registration module (e.g. + * `@sentry/cloudflare/orchestrion`, injected by the `@sentry/cloudflare/vite` + * plugin) — calling it statically from an SDK would defeat the whole point of + * the registry, which is keeping the integration code out of bundles that the + * injecting plugin never touched. + */ +export function registerChannelIntegrations(): void { + const marker = (globalThis.__SENTRY_ORCHESTRION__ = globalThis.__SENTRY_ORCHESTRION__ || {}); + marker.integrations = Object.values(channelIntegrations); +} diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 195464f7375f..7d51922a9dca 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -1,3 +1,4 @@ +import type { Integration } from '@sentry/core'; import { debug } from '@sentry/core'; import { createRequire } from 'node:module'; import * as Module from 'node:module'; @@ -7,7 +8,9 @@ import { SENTRY_INSTRUMENTATIONS } from '../config'; declare global { // eslint-disable-next-line no-var - var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; + var __SENTRY_ORCHESTRION__: + | { runtime?: boolean; bundler?: boolean; integrations?: Array<() => Integration> } + | undefined; } /** diff --git a/packages/server-utils/test/orchestrion/register-integrations.test.ts b/packages/server-utils/test/orchestrion/register-integrations.test.ts new file mode 100644 index 000000000000..5f4c0261c3c1 --- /dev/null +++ b/packages/server-utils/test/orchestrion/register-integrations.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getRegisteredChannelIntegrations } from '../../src/orchestrion/detect'; +import { channelIntegrations, registerChannelIntegrations } from '../../src/orchestrion/index'; + +describe('channel-integration registry', () => { + beforeEach(() => { + delete globalThis.__SENTRY_ORCHESTRION__; + }); + + afterEach(() => { + delete globalThis.__SENTRY_ORCHESTRION__; + }); + + describe('getRegisteredChannelIntegrations', () => { + it('returns an empty array when no marker exists', () => { + expect(getRegisteredChannelIntegrations()).toEqual([]); + }); + + it('returns an empty array when the marker has no integrations', () => { + globalThis.__SENTRY_ORCHESTRION__ = { bundler: true }; + expect(getRegisteredChannelIntegrations()).toEqual([]); + }); + + it('instantiates each registered factory', () => { + globalThis.__SENTRY_ORCHESTRION__ = { + integrations: [() => ({ name: 'FirstIntegration' }), () => ({ name: 'SecondIntegration' })], + }; + + expect(getRegisteredChannelIntegrations().map(i => i.name)).toEqual(['FirstIntegration', 'SecondIntegration']); + }); + + it('returns fresh instances on every call', () => { + globalThis.__SENTRY_ORCHESTRION__ = { integrations: [() => ({ name: 'FirstIntegration' })] }; + + const [first] = getRegisteredChannelIntegrations(); + const [second] = getRegisteredChannelIntegrations(); + + expect(first).not.toBe(second); + expect(first?.name).toBe(second?.name); + }); + }); + + describe('registerChannelIntegrations', () => { + it('registers a factory for every canonical channel integration', () => { + registerChannelIntegrations(); + + const registered = getRegisteredChannelIntegrations(); + expect(registered).toHaveLength(Object.keys(channelIntegrations).length); + expect(registered.every(i => typeof i.name === 'string' && i.name.length > 0)).toBe(true); + }); + + it('creates the marker when none exists', () => { + registerChannelIntegrations(); + + expect(globalThis.__SENTRY_ORCHESTRION__?.integrations).toBeDefined(); + }); + + it('preserves existing marker fields', () => { + globalThis.__SENTRY_ORCHESTRION__ = { bundler: true, runtime: true }; + + registerChannelIntegrations(); + + expect(globalThis.__SENTRY_ORCHESTRION__?.bundler).toBe(true); + expect(globalThis.__SENTRY_ORCHESTRION__?.runtime).toBe(true); + expect(getRegisteredChannelIntegrations().length).toBeGreaterThan(0); + }); + + it('registers factories, not eagerly-built instances', () => { + registerChannelIntegrations(); + + expect(globalThis.__SENTRY_ORCHESTRION__?.integrations?.every(factory => typeof factory === 'function')).toBe( + true, + ); + }); + }); +}); diff --git a/packages/server-utils/test/orchestrion/vite-register-integrations.test.ts b/packages/server-utils/test/orchestrion/vite-register-integrations.test.ts new file mode 100644 index 000000000000..6c277e5a5198 --- /dev/null +++ b/packages/server-utils/test/orchestrion/vite-register-integrations.test.ts @@ -0,0 +1,110 @@ +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 that reports `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()): { code: string; map: unknown } | null { + return plugin.transform.call(context, code, 'entry.js'); +} + +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 (\\0-prefixed) 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 module that registers the channel integrations, kept as a side effect', () => { + const result = plugin.load(RESOLVED_REGISTER_MODULE_ID); + + expect(result?.code).toContain("from '@sentry/server-utils/orchestrion'"); + expect(result?.code).toContain('registerChannelIntegrations()'); + expect(result?.moduleSideEffects).toBe(true); + }); + + it('does not reference any specific SDK package (SDK-agnostic)', () => { + expect(plugin.load(RESOLVED_REGISTER_MODULE_ID)?.code).not.toContain('@sentry/cloudflare'); + }); + + it('does not load unrelated ids', () => { + expect(plugin.load('some-other-module')).toBeNull(); + }); + }); + + describe('transform', () => { + 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 into a re-export entry that never names the SDK', () => { + const result = runTransform(plugin, `export { default } from './worker';\n`, ctx({ isEntry: true })); + + expect(result?.code).toContain(`import "${REGISTER_MODULE_ID}";`); + }); + + 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 the virtual module is already imported', () => { + const code = `export default {};\nimport "${REGISTER_MODULE_ID}";\n`; + expect(runTransform(plugin, code, ctx({ isEntry: true }))).toBeNull(); + }); + + 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(`import "${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(`import "${REGISTER_MODULE_ID}";`); + }); + }); +});