From 71d34c312445b8c52382ada5b639b27ab3c771d9 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 19 Aug 2026 10:51:19 -0700 Subject: [PATCH] ref(server-utils): make __SENTRY_ORCHESTRION__.bundler a Set --- .../suites/orchestrion-mysql/scenario.ts | 7 +++-- .../suites/orchestrion-postgres/scenario.ts | 7 +++-- .../tests/performance/build-injection.test.ts | 2 +- .../scenario-bundler.mjs | 2 +- packages/bun/src/plugin.ts | 6 ++-- packages/cloudflare/test/sdk.test.ts | 6 ++-- packages/core/src/utils/worldwide.ts | 6 ++-- .../bundler/moduleInjectedTransform.ts | 8 ++--- .../server-utils/src/orchestrion/detect.ts | 16 +++++----- .../src/orchestrion/moduleInjected.ts | 9 ++---- .../test/orchestrion/bundler.test.ts | 18 +++++++++--- .../test/orchestrion/detect.test.ts | 29 +++++++++++++++++-- .../test/orchestrion/instrumentation.test.ts | 6 ++-- .../test/orchestrion/moduleInjected.test.ts | 20 +++++++++---- 14 files changed, 95 insertions(+), 47 deletions(-) diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/scenario.ts b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/scenario.ts index cfefc47d9001..3d4f10730def 100644 --- a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/scenario.ts +++ b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/scenario.ts @@ -56,11 +56,14 @@ try { // ignore } -const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: string[]; bundler?: string[] } }) +const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: string[]; bundler?: Set } }) .__SENTRY_ORCHESTRION__; +// `bundler` is a `Set`, which `JSON.stringify` renders as `{}`. Spread it into +// an array so the test can read the recorded module names. +const reportedMarker = marker ? { ...marker, bundler: marker.bundler ? [...marker.bundler] : undefined } : null; setTimeout(() => { // eslint-disable-next-line no-console - console.log(`SCENARIO events=${events.join(',')} statement=${statement} marker=${JSON.stringify(marker ?? null)}`); + console.log(`SCENARIO events=${events.join(',')} statement=${statement} marker=${JSON.stringify(reportedMarker)}`); process.exit(0); }, 200); diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/scenario.ts b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/scenario.ts index 58068545c98c..2f6194112aba 100644 --- a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/scenario.ts +++ b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/scenario.ts @@ -51,11 +51,14 @@ try { // `start` has already published synchronously by this point. } -const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: string[]; bundler?: string[] } }) +const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: string[]; bundler?: Set } }) .__SENTRY_ORCHESTRION__; +// `bundler` is a `Set`, which `JSON.stringify` renders as `{}`. Spread it into +// an array so the test can read the recorded module names. +const reportedMarker = marker ? { ...marker, bundler: marker.bundler ? [...marker.bundler] : undefined } : null; setTimeout(() => { // eslint-disable-next-line no-console - console.log(`SCENARIO events=${events.join(',')} statement=${statement} marker=${JSON.stringify(marker ?? null)}`); + console.log(`SCENARIO events=${events.join(',')} statement=${statement} marker=${JSON.stringify(reportedMarker)}`); process.exit(0); }, 200); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts index 00404f077bbf..5aa7e2fa3054 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts @@ -30,7 +30,7 @@ test.describe('Orchestrion build-time injection', () => { }); test('does not inject diagnostics-channel publishers into the client build', () => { - // The client build may carry the inert `__SENTRY_ORCHESTRION__.bundler = []` detection marker + // The client build may carry the inert `__SENTRY_ORCHESTRION__.bundler = new Set()` detection marker // (it's environment-agnostic and harmless in a browser), but the actual `tracingChannel` publishers // and their channel names must never leak — `applyToEnvironment` keeps the transform server-only, so // a browser never hits a `diagnostics_channel` call that would throw. diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario-bundler.mjs b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario-bundler.mjs index 5eca4672935a..89556adfd647 100644 --- a/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario-bundler.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario-bundler.mjs @@ -25,7 +25,7 @@ assert.equal( // real build this runs when the bundled module is first evaluated. orchestrionModuleInjected('generic-pool'); -assert.ok(marker.bundler?.includes('generic-pool'), 'expected the module to be recorded as bundler-injected'); +assert.ok(marker.bundler?.has('generic-pool'), 'expected the module to be recorded as bundler-injected'); // The helper emitted `orchestrion.module-injected`, so the GenericPool // integration must have subscribed, even though generic-pool was never loaded diff --git a/packages/bun/src/plugin.ts b/packages/bun/src/plugin.ts index a3ed11f4f72d..a246ba1dc632 100644 --- a/packages/bun/src/plugin.ts +++ b/packages/bun/src/plugin.ts @@ -57,9 +57,9 @@ interface BunPluginBuilder { * `diagnostics_channel.tracingChannel` calls into the instrumented libraries as * `bun build` bundles them — plus, via the module-injected transform, the * snippet that records each module on `globalThis.__SENTRY_ORCHESTRION__` when - * it is evaluated — and injects the marker banner so `bundler` is set (to `[]`) - * from boot, which is what gates the SDK's channel-integration setup at - * `init()`. + * it is evaluated — and injects the marker banner so `bundler` is set (to an + * empty `Set`) from boot, which is what gates the SDK's channel-integration + * setup at `init()`. * * Pass the result to `Bun.build({ plugins: [...] })`. * diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index edac98354fc0..92515335146c 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -99,7 +99,7 @@ describe('getDefaultIntegrations', () => { test('does not add orchestrion channel integrations when only the bundler marker is set', () => { // The plugin's entry banner ran, but no instrumented module has loaded yet. - globalThis.__SENTRY_ORCHESTRION__ = { bundler: [] }; + globalThis.__SENTRY_ORCHESTRION__ = { bundler: new Set() }; const names = getDefaultIntegrations({}).map(i => i.name); @@ -115,7 +115,7 @@ describe('getDefaultIntegrations', () => { const { mysqlIntegration, postgresIntegration, lruMemoizerIntegration } = await import('@sentry/server-utils/orchestrion'); globalThis.__SENTRY_ORCHESTRION__ = { - bundler: ['mysql', 'pg', 'lru-memoizer'], + bundler: new Set(['mysql', 'pg', 'lru-memoizer']), integrations: new Map([ ['mysql', mysqlIntegration], ['pg', postgresIntegration], @@ -138,7 +138,7 @@ describe('getDefaultIntegrations', () => { // Mirror `orchestrionModuleInjected` for a driver that first evaluates // after init: store the factory on the marker, then emit the event. globalThis.__SENTRY_ORCHESTRION__ = { - bundler: ['mysql'], + bundler: new Set(['mysql']), integrations: new Map([['mysql', mysqlIntegration]]), }; client?.emit('orchestrion.module-injected', 'mysql'); diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index e3cb23d5b4a0..7ca27dbe5a99 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -64,10 +64,10 @@ export type InternalGlobal = { /** * Module names recorded as each bundler-transformed module loads (the * injected snippet calls `orchestrionModuleInjected`). The bundler plugin's - * entry banner ensures `[]` at boot, so a defined array — even empty — - * signifies the plugin ran. + * entry banner ensures an empty `Set` at boot, so a defined set — even + * empty — signifies the plugin ran. */ - bundler?: string[]; + bundler?: Set; /** * Channel-subscriber integration factories stored by the snippet the * bundler transform splices into each instrumented module, keyed by module diff --git a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts index 781ba5fd8a0c..f8a31fe44bba 100644 --- a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts +++ b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts @@ -16,14 +16,14 @@ const DEFAULT_IMPORT_SPECIFIER = '@sentry/server-utils/orchestrion'; /** * Entry-chunk banner that marks "the bundler plugin ran" for - * `detectOrchestrionSetup()`. Merge-only (`g.bundler = g.bundler || []`) so it - * can never clobber module names already recorded by an injected snippet that - * happened to run first; the names themselves arrive per module, when each + * `detectOrchestrionSetup()`. Merge-only (`g.bundler = g.bundler || new Set()`) + * so it can never clobber module names already recorded by an injected snippet + * that happened to run first; the names themselves arrive per module, when each * transformed module is evaluated and its snippet calls * `orchestrionModuleInjected`. */ export const ORCHESTRION_BUNDLER_MARKER_BANNER = - ';(function(){var g=globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{};g.bundler=g.bundler||[];})();'; + ';(function(){var g=globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{};g.bundler=g.bundler||new Set();})();'; /** * Snippet injected into each instrumented module. It imports the diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index 4545cbc56ed3..8348d1f85e43 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -21,22 +21,22 @@ export function isOrchestrionInjected(): boolean { * Channel-based integrations use it to decide whether to subscribe now (their * module is already loaded) or wait for the module-injected event. * - * The `Array.isArray` guard is runtime safety, not typing: a banner from - * another SDK copy or version may have written a non-array flag here. + * The `instanceof Set` guard is runtime safety, not typing: a banner from + * another SDK copy or version may have written a non-Set flag here. */ export function getOrchestrionInjectedModules(): string[] { const { runtime, bundler } = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ?? {}; - return [...(runtime ?? []), ...(Array.isArray(bundler) ? bundler : [])]; + return [...(runtime ?? []), ...(bundler instanceof Set ? bundler : [])]; } /** * Verifies that the diagnostics channels have been injected either by the * runtime `--import` hook (or init-time registration), a bundler plugin, or * both, and warns if not. When at least one injector is active, logs for each - * mechanism whether it hooked (a defined array, even empty, means it did) and + * mechanism whether it hooked (a defined list, even empty, means it did) and * which libraries it injected. For the bundler path, the entry banner ensures - * `[]` at boot; module names arrive as each transformed module is evaluated, - * so an empty list can also just mean none has loaded yet. + * an empty `Set` at boot; module names arrive as each transformed module is + * evaluated, so an empty set can also just mean none has loaded yet. * * Both injectors being active at once is fine: they operate on disjoint module * sets (a module is either loaded through Node's loader and transformed by the @@ -64,7 +64,9 @@ export function detectOrchestrionSetup(): void { ); debug.log( bundler - ? `[Sentry] Bundler plugin ran, injected libraries=${JSON.stringify(bundler)}` + ? `[Sentry] Bundler plugin ran, injected libraries=${JSON.stringify( + bundler instanceof Set ? [...bundler] : bundler, + )}` : '[Sentry] Bundler plugin did not run', ); } diff --git a/packages/server-utils/src/orchestrion/moduleInjected.ts b/packages/server-utils/src/orchestrion/moduleInjected.ts index 0e78afe91e06..1e7fe98e4692 100644 --- a/packages/server-utils/src/orchestrion/moduleInjected.ts +++ b/packages/server-utils/src/orchestrion/moduleInjected.ts @@ -27,12 +27,9 @@ export function orchestrionModuleInjected(moduleName: string, integrationFn?: () const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {}); // Runtime guard, not just type narrowing: a banner from another SDK copy or - // version may have written a non-array flag here; leave that untouched. - if (marker.bundler === undefined || Array.isArray(marker.bundler)) { - const bundler = (marker.bundler ??= []); - if (!bundler.includes(moduleName)) { - bundler.push(moduleName); - } + // version may have written a non-Set flag here; leave that untouched. + if (marker.bundler === undefined || marker.bundler instanceof Set) { + (marker.bundler ??= new Set()).add(moduleName); } if (integrationFn) { diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index 83f7e49db37c..2e718a92a270 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -347,20 +347,30 @@ describe('orchestrionTransformOptions', () => { new Function('globalThis', banner as string)(global); } - it('marks the plugin as ran with an empty module list', () => { + it('marks the plugin as ran with an empty module set', () => { const global: Record = {}; runBanner(global); - expect((global.__SENTRY_ORCHESTRION__ as { bundler?: string[] }).bundler).toEqual([]); + expect((global.__SENTRY_ORCHESTRION__ as { bundler?: Set }).bundler).toEqual(new Set()); }); it('never clobbers module names an injected snippet already recorded', () => { - const global: Record = { __SENTRY_ORCHESTRION__: { bundler: ['mysql'] } }; + const global: Record = { __SENTRY_ORCHESTRION__: { bundler: new Set(['mysql']) } }; runBanner(global); - expect((global.__SENTRY_ORCHESTRION__ as { bundler?: string[] }).bundler).toEqual(['mysql']); + expect((global.__SENTRY_ORCHESTRION__ as { bundler?: Set }).bundler).toEqual(new Set(['mysql'])); + }); + + it('runs twice without resetting the recorded modules', () => { + const global: Record = {}; + + runBanner(global); + (global.__SENTRY_ORCHESTRION__ as { bundler: Set }).bundler.add('mysql'); + runBanner(global); + + expect((global.__SENTRY_ORCHESTRION__ as { bundler?: Set }).bundler).toEqual(new Set(['mysql'])); }); }); }); diff --git a/packages/server-utils/test/orchestrion/detect.test.ts b/packages/server-utils/test/orchestrion/detect.test.ts index 972cc7b0af37..a337268e5f91 100644 --- a/packages/server-utils/test/orchestrion/detect.test.ts +++ b/packages/server-utils/test/orchestrion/detect.test.ts @@ -1,6 +1,6 @@ import { GLOBAL_OBJ } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { isOrchestrionInjected } from '../../src/orchestrion/detect'; +import { getOrchestrionInjectedModules, isOrchestrionInjected } from '../../src/orchestrion/detect'; describe('isOrchestrionInjected', () => { beforeEach(() => { @@ -17,7 +17,8 @@ describe('isOrchestrionInjected', () => { it.each([ ['runtime', { runtime: [] }], - ['bundler array', { bundler: ['mysql'] }], + ['bundler set', { bundler: new Set(['mysql']) }], + ['bundler empty set', { bundler: new Set() }], ['bundler true', { bundler: true }], ['integrations', { integrations: new Map() }], ] as const)('is true when %s injection is present', (_label, marker) => { @@ -27,3 +28,27 @@ describe('isOrchestrionInjected', () => { expect(isOrchestrionInjected()).toBe(true); }); }); + +describe('getOrchestrionInjectedModules', () => { + beforeEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + }); + + afterEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + }); + + it('is empty when no marker exists', () => { + expect(getOrchestrionInjectedModules()).toEqual([]); + }); + + it('merges the runtime list and the bundler set', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['pg'], bundler: new Set(['mysql']) }; + expect(getOrchestrionInjectedModules()).toEqual(['pg', 'mysql']); + }); + + it('ignores a foreign non-Set bundler flag', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['pg'], bundler: true as unknown as Set }; + expect(getOrchestrionInjectedModules()).toEqual(['pg']); + }); +}); diff --git a/packages/server-utils/test/orchestrion/instrumentation.test.ts b/packages/server-utils/test/orchestrion/instrumentation.test.ts index 2815c607ee76..fce7ffc3bf26 100644 --- a/packages/server-utils/test/orchestrion/instrumentation.test.ts +++ b/packages/server-utils/test/orchestrion/instrumentation.test.ts @@ -170,7 +170,7 @@ describe('invokeOrchestrionInstrumentation', () => { }); it('treats bundler-recorded modules as injected', () => { - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: ['mysql'] }; + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: new Set(['mysql']) }; const client = makeClient(); const callback = vi.fn(); @@ -179,8 +179,8 @@ describe('invokeOrchestrionInstrumentation', () => { expect(callback).toHaveBeenCalledTimes(1); }); - it('ignores a non-array bundler flag (Bun sets `true`)', () => { - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: true as unknown as string[] }; + it('ignores a non-Set bundler flag written by a foreign SDK copy', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: true as unknown as Set }; const client = makeClient(); const callback = vi.fn(); diff --git a/packages/server-utils/test/orchestrion/moduleInjected.test.ts b/packages/server-utils/test/orchestrion/moduleInjected.test.ts index d64fda3d60a5..081e6a0f10bb 100644 --- a/packages/server-utils/test/orchestrion/moduleInjected.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjected.test.ts @@ -18,13 +18,21 @@ describe('orchestrionModuleInjected', () => { it('records the module name as bundler-injected', () => { orchestrionModuleInjected('mysql'); - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(['mysql']); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(new Set(['mysql'])); }); it('deduplicates the recorded module across repeated calls', () => { orchestrionModuleInjected('mysql'); orchestrionModuleInjected('mysql'); - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(['mysql']); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(new Set(['mysql'])); + }); + + it('adds to the set the banner already created', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: new Set(['pg']) }; + + orchestrionModuleInjected('mysql'); + + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(new Set(['pg', 'mysql'])); }); it('stores the factory on the global marker keyed by module name', () => { @@ -41,7 +49,7 @@ describe('orchestrionModuleInjected', () => { it('emits the module-injected event on the current client, after recording', () => { const emit = vi.fn(() => { // Listeners react by reading the marker, so it must be recorded by now. - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(['mysql']); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(new Set(['mysql'])); expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.has('mysql')).toBe(true); }); getCurrentScope().setClient({ emit } as unknown as Client); @@ -54,7 +62,7 @@ describe('orchestrionModuleInjected', () => { it('does not throw when no client is set yet', () => { expect(() => orchestrionModuleInjected('mysql', factory('Mysql'))).not.toThrow(); // still recorded for the next init() to pick up - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(['mysql']); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(new Set(['mysql'])); expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.has('mysql')).toBe(true); }); @@ -69,8 +77,8 @@ describe('orchestrionModuleInjected', () => { expect(emit).toHaveBeenCalledWith('orchestrion.module-injected', 'mysql'); }); - it('leaves a foreign non-array bundler flag untouched but still stores and emits', () => { - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: true as unknown as string[] }; + it('leaves a foreign non-Set bundler flag untouched but still stores and emits', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: true as unknown as Set }; const emit = vi.fn(); getCurrentScope().setClient({ emit } as unknown as Client);