From a83bbc0981b650a08a1bfd51af2f07cd73d230a0 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Thu, 17 Sep 2026 16:53:12 +0300 Subject: [PATCH 1/3] feat(cloudflare): Auto-register Flue instrumentation in bundled workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flue is registered, not patched — `instrument()` writes into module-scope state — so instrumenting it needs a reference to that module's own binding, and no channel payload carries one. On Node the user supplies it by calling `instrument()` themselves, which stays the only route there. In a bundled worker there is no `node_modules` to resolve one from, so it is supplied at build time instead. Two halves, mirroring how Mastra reaches a worker: - `flueIntegration()` registers the instrumentation when the `@flue/runtime` namespace is on the orchestrion marker, and no-ops when it is not. A `registrationOnly` orchestrion entry is what installs it on a bundler-only SDK: evaluating `@flue/runtime` registers the factory on the marker. That also keeps the integration reachable under `sideEffects: false`, which would otherwise let the bundler drop the module and the registration with it. - `@sentry/cloudflare/vite` splices a static `@flue/runtime` import into Sentry's own Flue integration module and exposes the namespace on `providedModules`. Two things the Mastra provider does not have to handle. `@flue/runtime` is ESM-only, so `createRequire().resolve()` throws `ERR_PACKAGE_PATH_NOT_EXPORTED` on it and the existence check goes through the ESM resolver. And the namespace is exposed through a getter rather than assigned: the snippet is prepended to Sentry's module, which the bundler may evaluate before `@flue/runtime` is initialized, so assigning it stores `undefined` — the key lands on `providedModules` with nothing behind it. An app that also calls `instrument()` itself is unaffected: its own registration wins and the integration swallows the resulting `InstrumentationAlreadyInstalledError`. Node is unchanged. `moduleInjectedTransforms` is wired into the bundler paths only, and Sentry stays external in a Flue node build, so neither half applies there and `flueIntegration()` installs as a no-op. Co-Authored-By: Claude Opus 5 --- packages/cloudflare/src/vite/flueRuntime.ts | 81 +++++++++++++++++++ packages/cloudflare/src/vite/index.ts | 2 + .../server-utils/src/ai/flue/constants.ts | 4 + packages/server-utils/src/index.ts | 1 + .../server-utils/src/integrations/flue.ts | 44 ++++++++++ .../server-utils/src/integrations/index.ts | 2 + .../config/channel-integration-definitions.ts | 1 + .../src/orchestrion/config/flue.ts | 15 ++++ .../src/orchestrion/config/index.ts | 2 + 9 files changed, 152 insertions(+) create mode 100644 packages/cloudflare/src/vite/flueRuntime.ts create mode 100644 packages/server-utils/src/integrations/flue.ts create mode 100644 packages/server-utils/src/orchestrion/config/flue.ts diff --git a/packages/cloudflare/src/vite/flueRuntime.ts b/packages/cloudflare/src/vite/flueRuntime.ts new file mode 100644 index 000000000000..cb31db5f1382 --- /dev/null +++ b/packages/cloudflare/src/vite/flueRuntime.ts @@ -0,0 +1,81 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; +import MagicString from 'magic-string'; + +// Namespace binding the injected provider import uses; read back by the integration +// off the global marker. +const PROVIDER_IDENTIFIER = '__SENTRY_FLUE_RUNTIME__'; + +const FLUE_MODULE = '@flue/runtime'; + +// The bundled `@sentry/server-utils` Flue integration module (ESM build — the only one a +// worker loads). It reads `@flue/runtime` off the global marker this provider populates, +// because `instrument()` registers into module-scope state that cannot be reached from a +// diagnostics channel. +const FLUE_INTEGRATION_ID = /@sentry\/server-utils\/build\/esm\/integrations\/flue\.js$/; + +/** Whether `id` is the Sentry Flue integration module the provider injects into. */ +export function isFlueIntegrationModuleId(id: string): boolean { + const normalizedId = id.replace(/\\/g, '/').replace(/[?#].*$/, ''); + return FLUE_INTEGRATION_ID.test(normalizedId); +} + +/** + * Splices a static `import * as … from '@flue/runtime'` into Sentry's own Flue integration + * module and stashes the namespace on the global orchestrion marker. + * + * Flue is registered rather than patched: `instrument()` writes into module-scope state, so + * instrumenting it needs a reference to that module's own binding, and no channel payload + * carries one. On Node the user supplies it by calling `instrument()` themselves; in a + * bundled worker this provider supplies it at build time instead, so the integration can + * register on its own. The import is static (statically analyzable, no lazy `import()`), + * lands in Sentry's module rather than the user's code, and is only emitted when the package + * actually resolves; if it is absent the marker stays empty and the integration no-ops. + */ +export function sentryFlueRuntimeProviderPlugin(): { + name: string; + configResolved(config: { root: string }): void; + transform(code: string, id: string): { code: string; map: ReturnType } | undefined; +} { + let providerSnippet: string | undefined; + + return { + name: 'sentry-cloudflare-flue-runtime-provider', + + configResolved(config: { root: string }): void { + // Resolved at build time (Node), so none of this ships to the worker. `@flue/runtime` is + // ESM-only with no `require` condition, so `createRequire().resolve()` throws + // `ERR_PACKAGE_PATH_NOT_EXPORTED` on it — resolve through the ESM resolver instead, and only + // fall back to CJS for hosts where `import.meta.resolve` is unavailable. + const from = pathToFileURL(resolve(config.root, 'noop.js')); + try { + import.meta.resolve(FLUE_MODULE, from.href); + } catch { + try { + createRequire(from).resolve(FLUE_MODULE); + } catch { + return; + } + } + // A getter, not a snapshot: the snippet is prepended to Sentry's module, which the bundler + // may evaluate before `@flue/runtime`'s namespace is initialized. Assigning the namespace + // there stores `undefined` — the key appears on `providedModules` with nothing behind it. + // Reading it through a getter defers that to first access, by which point it is populated. + providerSnippet = + `import * as ${PROVIDER_IDENTIFIER} from '${FLUE_MODULE}';\n` + + '(globalThis.__SENTRY_ORCHESTRION__ = globalThis.__SENTRY_ORCHESTRION__ || {});\n' + + '(globalThis.__SENTRY_ORCHESTRION__.providedModules = globalThis.__SENTRY_ORCHESTRION__.providedModules || {});\n' + + `Object.defineProperty(globalThis.__SENTRY_ORCHESTRION__.providedModules, '${FLUE_MODULE}', ` + + `{ configurable: true, get() { return ${PROVIDER_IDENTIFIER}; } });\n`; + }, + + transform(code: string, id: string): { code: string; map: ReturnType } | undefined { + if (!providerSnippet || !isFlueIntegrationModuleId(id)) return undefined; + + const ms = new MagicString(code); + ms.prepend(providerSnippet); + return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; + }, + }; +} diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts index be679c4823f5..d6005250d9ee 100644 --- a/packages/cloudflare/src/vite/index.ts +++ b/packages/cloudflare/src/vite/index.ts @@ -5,6 +5,7 @@ // expose it — same setup as `@sentry/server-utils/orchestrion/vite` itself. import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import { sentryCloudflareAutoInstrumentPlugin } from './autoInstrument'; +import { sentryFlueRuntimeProviderPlugin } from './flueRuntime'; import { sentryMastraObservabilityProviderPlugin } from './mastraObservability'; /** @@ -91,6 +92,7 @@ export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOp dcModule: '@sentry/cloudflare/orchestrion-diagnostics-channel', }), sentryMastraObservabilityProviderPlugin(), + sentryFlueRuntimeProviderPlugin(), ...(options.autoInstrumentation !== false ? [sentryCloudflareAutoInstrumentPlugin({ wranglerConfigPath: options.wranglerConfigPath })] : []), diff --git a/packages/server-utils/src/ai/flue/constants.ts b/packages/server-utils/src/ai/flue/constants.ts index ae568d24312a..6a3bbaaefaa9 100644 --- a/packages/server-utils/src/ai/flue/constants.ts +++ b/packages/server-utils/src/ai/flue/constants.ts @@ -1,3 +1,7 @@ +export const FLUE_INTEGRATION_NAME = 'Flue' as const; + +export const FLUE_MODULE_NAME = '@flue/runtime'; + export const FLUE_ORIGIN = 'auto.ai.flue'; /** diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 57fa3f913cda..c49e6220835f 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -43,6 +43,7 @@ export { knexIntegration } from './integrations/knex'; export { langChainIntegration } from './integrations/langchain'; export { langGraphIntegration } from './integrations/langgraph'; export { createFlueInstrumentation } from './ai/flue'; +export { flueIntegration } from './integrations/flue'; export type { FlueOptions } from './ai/flue'; export { mastraIntegration } from './integrations/mastra'; export { SentryMastraExporter } from './ai/mastra'; diff --git a/packages/server-utils/src/integrations/flue.ts b/packages/server-utils/src/integrations/flue.ts new file mode 100644 index 000000000000..2225c86302be --- /dev/null +++ b/packages/server-utils/src/integrations/flue.ts @@ -0,0 +1,44 @@ +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, GLOBAL_OBJ } from '@sentry/core'; +import type { FlueOptions } from '../ai/flue'; +import { createFlueInstrumentation } from '../ai/flue'; +import { FLUE_INTEGRATION_NAME, FLUE_MODULE_NAME } from '../ai/flue/constants'; +import { DEBUG_BUILD } from '../debug-build'; + +type FlueInstrumentFn = (instrumentation: ReturnType) => unknown; + +/** + * Register the instrumentation with Flue on the user's behalf, when the runtime binding is available. + * + * Flue is registered rather than patched — `instrument()` writes into module-scope state — so this + * needs a reference to that module's own binding. In a bundled worker there is no `node_modules` to + * resolve one from, so `@sentry/cloudflare/vite` splices a static `@flue/runtime` import into this + * module at build time and stashes the namespace on the global marker. Outside that setup the marker + * is empty and this no-ops, leaving the user's own `instrument(Sentry.createFlueInstrumentation())` + * as the way in. + */ +const _flueIntegration = ((options: FlueOptions = {}) => { + return { + name: FLUE_INTEGRATION_NAME, + setup() { + const provided = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.providedModules?.[FLUE_MODULE_NAME]; + const instrument = provided?.instrument as FlueInstrumentFn | undefined; + + if (typeof instrument !== 'function') { + DEBUG_BUILD && debug.log('[Flue] no provided `@flue/runtime` binding; skipping auto-registration'); + return; + } + + try { + instrument(createFlueInstrumentation(options)); + } catch (error) { + // A repeated `instrument()` throws `InstrumentationAlreadyInstalledError`, which is what an + // app that also registers manually will hit. Its own registration is already in place, so + // there is nothing to recover. + DEBUG_BUILD && debug.log('[Flue] auto-registration skipped:', error); + } + }, + }; +}) satisfies IntegrationFn; + +export const flueIntegration = defineIntegration(_flueIntegration); diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts index 6e319bfc5e2b..c2d6690c490e 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -1,5 +1,6 @@ import { amqplibIntegration } from './amqplib'; import { dataloaderIntegration } from './dataloader'; +import { flueIntegration } from './flue'; import { knexIntegration } from './knex'; import { mongoIntegration } from './mongodb'; import { graphqlIntegration } from './graphql'; @@ -54,6 +55,7 @@ export function getTracingIntegrations(): Integration[] { langChainIntegration(), langGraphIntegration(), mastraIntegration(), + flueIntegration(), vercelAIIntegration(), openAIIntegration(), anthropicAIIntegration(), diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index 73d7c164cfe7..3935735ac3b9 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -43,6 +43,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ }, { exportName: 'langGraphIntegration', modules: ['@langchain/langgraph'] }, { exportName: 'mastraIntegration', modules: ['@mastra/core'] }, + { exportName: 'flueIntegration', modules: ['@flue/runtime'] }, { exportName: 'awsIntegration', modules: ['@aws-sdk/smithy-client', '@smithy/core', '@smithy/smithy-client'] }, { exportName: 'firebaseIntegration', modules: ['@firebase/firestore', 'firebase-functions'] }, { exportName: 'amqplibIntegration', modules: ['amqplib'] }, diff --git a/packages/server-utils/src/orchestrion/config/flue.ts b/packages/server-utils/src/orchestrion/config/flue.ts new file mode 100644 index 000000000000..5b0fc60ca138 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/flue.ts @@ -0,0 +1,15 @@ +import type { InstrumentationConfig } from '../apmTypes'; +import { getModuleNames } from './module-names'; +import { registrationOnly } from './registration-only'; + +/** + * Flue publishes no diagnostics channels and needs none: it is instrumented by registering with + * `instrument()`, not by patching call sites. Transforming the entry is only how the module's + * integration gets registered at evaluation time, which is what installs it on a bundler-only SDK + * like `@sentry/cloudflare`. + */ +export const flueConfig = [ + registrationOnly({ name: '@flue/runtime', versionRange: '>=2.0.0', filePath: 'dist/index.mjs' }), +] satisfies InstrumentationConfig[]; + +export const flueModuleNames = getModuleNames(flueConfig); diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 723c5857b8f1..e3a911e9ecc7 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -19,6 +19,7 @@ import { koaConfig } from './koa'; import { langchainConfig } from './langchain'; import { langgraphConfig } from './langgraph'; import { lruMemoizerConfig } from './lru-memoizer'; +import { flueConfig } from './flue'; import { mastraConfig } from './mastra'; import { mistralConfig } from './mistral'; import { mongodbConfig } from './mongodb'; @@ -68,6 +69,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...langgraphConfig, ...lruMemoizerConfig, ...mastraConfig, + ...flueConfig, ...mistralConfig, ...mongodbConfig, ...mongooseConfig, From 97b7ed796c84783647e31e0f634c19ebf0b6f077 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Fri, 18 Sep 2026 10:45:02 +0300 Subject: [PATCH 2/3] fix(cloudflare): Resolve Flue from the app root and scope the registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build-time presence check used `import.meta.resolve(spec, parentURL)`. The `parentURL` argument is ignored without `--experimental-import-meta-resolve`, so the check resolved from Sentry's own install rather than the app's, and it compiles to `undefined(...)` in this package's CJS build, where it threw and fell through to a `createRequire` fallback that always fails for an ESM-only package. Injection was therefore skipped outright on the CJS path and wherever Sentry is not installed beneath the app. It now resolves with `createRequire` from the Vite root and counts `ERR_PACKAGE_PATH_NOT_EXPORTED` as a hit: `@flue/runtime` publishes no `require` condition on any subpath, so that error means the package is present, while a missing one reports `MODULE_NOT_FOUND`. Also narrows the registration catch to `InstrumentationAlreadyInstalledError` so a changed `instrument()` contract surfaces instead of becoming a debug log, bounds the supported range at `<3.0.0`, drops the unused `flueModuleNames` export, and removes `flueIntegration()` from the default integrations — it has no binding to read on Node, where registering stays a manual `instrument(Sentry.createFlueInstrumentation())` call. Co-Authored-By: Claude Opus 5 --- packages/cloudflare/src/vite/flueRuntime.ts | 42 +++--- .../cloudflare/test/vite/flueRuntime.test.ts | 133 ++++++++++++++++++ .../server-utils/src/integrations/flue.ts | 9 +- .../server-utils/src/integrations/index.ts | 2 - .../src/orchestrion/config/flue.ts | 5 +- .../src/orchestrion/config/index.ts | 2 +- .../test/integrations/flue.test.ts | 63 +++++++++ .../test/orchestrion/config.test.ts | 15 ++ 8 files changed, 234 insertions(+), 37 deletions(-) create mode 100644 packages/cloudflare/test/vite/flueRuntime.test.ts create mode 100644 packages/server-utils/test/integrations/flue.test.ts diff --git a/packages/cloudflare/src/vite/flueRuntime.ts b/packages/cloudflare/src/vite/flueRuntime.ts index cb31db5f1382..b69c9000fac0 100644 --- a/packages/cloudflare/src/vite/flueRuntime.ts +++ b/packages/cloudflare/src/vite/flueRuntime.ts @@ -1,5 +1,4 @@ import { createRequire } from 'node:module'; -import { pathToFileURL } from 'node:url'; import { resolve } from 'node:path'; import MagicString from 'magic-string'; @@ -11,8 +10,7 @@ const FLUE_MODULE = '@flue/runtime'; // The bundled `@sentry/server-utils` Flue integration module (ESM build — the only one a // worker loads). It reads `@flue/runtime` off the global marker this provider populates, -// because `instrument()` registers into module-scope state that cannot be reached from a -// diagnostics channel. +// because `instrument()` registers into module-scope state no channel payload can carry. const FLUE_INTEGRATION_ID = /@sentry\/server-utils\/build\/esm\/integrations\/flue\.js$/; /** Whether `id` is the Sentry Flue integration module the provider injects into. */ @@ -22,16 +20,13 @@ export function isFlueIntegrationModuleId(id: string): boolean { } /** - * Splices a static `import * as … from '@flue/runtime'` into Sentry's own Flue integration - * module and stashes the namespace on the global orchestrion marker. + * Splices a static `import * as … from '@flue/runtime'` into Sentry's own Flue integration module + * and exposes the namespace on the global orchestrion marker. * - * Flue is registered rather than patched: `instrument()` writes into module-scope state, so - * instrumenting it needs a reference to that module's own binding, and no channel payload - * carries one. On Node the user supplies it by calling `instrument()` themselves; in a - * bundled worker this provider supplies it at build time instead, so the integration can - * register on its own. The import is static (statically analyzable, no lazy `import()`), - * lands in Sentry's module rather than the user's code, and is only emitted when the package - * actually resolves; if it is absent the marker stays empty and the integration no-ops. + * Flue is registered rather than patched — `instrument()` writes into module-scope state — so + * instrumenting it needs that module's own binding, and no channel payload carries one. On Node the + * user passes it by calling `instrument()` themselves; a bundled worker has no `node_modules` to + * resolve from, so it is supplied at build time instead. */ export function sentryFlueRuntimeProviderPlugin(): { name: string; @@ -44,24 +39,19 @@ export function sentryFlueRuntimeProviderPlugin(): { name: 'sentry-cloudflare-flue-runtime-provider', configResolved(config: { root: string }): void { - // Resolved at build time (Node), so none of this ships to the worker. `@flue/runtime` is - // ESM-only with no `require` condition, so `createRequire().resolve()` throws - // `ERR_PACKAGE_PATH_NOT_EXPORTED` on it — resolve through the ESM resolver instead, and only - // fall back to CJS for hosts where `import.meta.resolve` is unavailable. - const from = pathToFileURL(resolve(config.root, 'noop.js')); + // Build-time only; never ships to the worker. `@flue/runtime` is ESM-only, so an installed + // copy throws `ERR_PACKAGE_PATH_NOT_EXPORTED` and only a missing one throws `MODULE_NOT_FOUND`. + // Not `import.meta.resolve`: `parentURL` is ignored without a flag, and it is absent from the + // CJS build. try { - import.meta.resolve(FLUE_MODULE, from.href); - } catch { - try { - createRequire(from).resolve(FLUE_MODULE); - } catch { + createRequire(resolve(config.root, 'noop.js')).resolve(FLUE_MODULE); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') { return; } } - // A getter, not a snapshot: the snippet is prepended to Sentry's module, which the bundler - // may evaluate before `@flue/runtime`'s namespace is initialized. Assigning the namespace - // there stores `undefined` — the key appears on `providedModules` with nothing behind it. - // Reading it through a getter defers that to first access, by which point it is populated. + // A getter where Mastra assigns: the bundler may evaluate Sentry's module before + // `@flue/runtime` is initialized, and assigning there would store `undefined`. providerSnippet = `import * as ${PROVIDER_IDENTIFIER} from '${FLUE_MODULE}';\n` + '(globalThis.__SENTRY_ORCHESTRION__ = globalThis.__SENTRY_ORCHESTRION__ || {});\n' + diff --git a/packages/cloudflare/test/vite/flueRuntime.test.ts b/packages/cloudflare/test/vite/flueRuntime.test.ts new file mode 100644 index 000000000000..7d617fd030d7 --- /dev/null +++ b/packages/cloudflare/test/vite/flueRuntime.test.ts @@ -0,0 +1,133 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { sentryCloudflareVitePlugin } from '../../src/vite/index'; +import { isFlueIntegrationModuleId, sentryFlueRuntimeProviderPlugin } from '../../src/vite/flueRuntime'; + +const PROVIDER_PLUGIN = 'sentry-cloudflare-flue-runtime-provider'; +const FLUE_INTEGRATION_MODULE = '/app/node_modules/@sentry/server-utils/build/esm/integrations/flue.js'; + +/** An app root whose `node_modules` holds an ESM-only `@flue/runtime`, as published. */ +function createRootWithFlue(): string { + const root = mkdtempSync(join(tmpdir(), 'sentry-flue-root-')); + const pkgDir = join(root, 'node_modules', '@flue', 'runtime'); + mkdirSync(join(pkgDir, 'dist'), { recursive: true }); + writeFileSync( + join(pkgDir, 'package.json'), + // No `require` condition — the reason `resolve()` reports ERR_PACKAGE_PATH_NOT_EXPORTED. + JSON.stringify({ + name: '@flue/runtime', + version: '2.0.8', + type: 'module', + exports: { '.': { import: './dist/index.mjs' } }, + }), + ); + writeFileSync(join(pkgDir, 'dist', 'index.mjs'), 'export const instrument = () => {};\n'); + return root; +} + +function createEmptyRoot(): string { + return mkdtempSync(join(tmpdir(), 'sentry-flue-empty-')); +} + +describe('isFlueIntegrationModuleId', () => { + it('matches the ESM Flue integration module', () => { + expect(isFlueIntegrationModuleId(FLUE_INTEGRATION_MODULE)).toBe(true); + }); + + it('ignores a trailing query/hash Vite may append', () => { + expect(isFlueIntegrationModuleId(`${FLUE_INTEGRATION_MODULE}?v=abc`)).toBe(true); + }); + + it('normalizes Windows separators', () => { + expect( + isFlueIntegrationModuleId('C:\\app\\node_modules\\@sentry\\server-utils\\build\\esm\\integrations\\flue.js'), + ).toBe(true); + }); + + it('does not match the CJS build (workers load ESM)', () => { + expect(isFlueIntegrationModuleId('/app/node_modules/@sentry/server-utils/build/cjs/integrations/flue.js')).toBe( + false, + ); + }); + + it('does not match another integration module', () => { + expect(isFlueIntegrationModuleId('/app/node_modules/@sentry/server-utils/build/esm/integrations/mastra.js')).toBe( + false, + ); + }); + + it('does not match Flue itself', () => { + expect(isFlueIntegrationModuleId('/app/node_modules/@flue/runtime/dist/index.mjs')).toBe(false); + }); +}); + +describe('sentryFlueRuntimeProviderPlugin', () => { + describe('when the app has @flue/runtime installed', () => { + let root: string; + + beforeAll(() => { + root = createRootWithFlue(); + }); + + it('injects the provider even though the package is ESM-only', () => { + // Regression guard: treating that error as "absent" silently disabled auto-instrumentation. + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root }); + + const result = plugin.transform('export const x = 1;', FLUE_INTEGRATION_MODULE); + + expect(result?.code).toContain("import * as __SENTRY_FLUE_RUNTIME__ from '@flue/runtime';"); + expect(result?.code).toContain('__SENTRY_ORCHESTRION__.providedModules'); + expect(result?.code).toContain('export const x = 1;'); + }); + + it('exposes the namespace through a getter rather than a snapshot', () => { + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root }); + + expect(plugin.transform('', FLUE_INTEGRATION_MODULE)?.code).toContain( + 'get() { return __SENTRY_FLUE_RUNTIME__; }', + ); + }); + + it('leaves every other module untouched', () => { + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root }); + + expect(plugin.transform('export const x = 1;', '/app/src/index.ts')).toBeUndefined(); + }); + }); + + describe('when the app does not have @flue/runtime installed', () => { + it('injects nothing', () => { + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root: createEmptyRoot() }); + + expect(plugin.transform('export const x = 1;', FLUE_INTEGRATION_MODULE)).toBeUndefined(); + }); + + it("resolves from the app root, not from Sentry's own install", () => { + // This repo has no `@flue/runtime`, so only an app root that does can pass the check. + const withFlue = sentryFlueRuntimeProviderPlugin(); + withFlue.configResolved({ root: createRootWithFlue() }); + + const withoutFlue = sentryFlueRuntimeProviderPlugin(); + withoutFlue.configResolved({ root: createEmptyRoot() }); + + expect(withFlue.transform('', FLUE_INTEGRATION_MODULE)).toBeDefined(); + expect(withoutFlue.transform('', FLUE_INTEGRATION_MODULE)).toBeUndefined(); + }); + }); +}); + +describe('sentryCloudflareVitePlugin', () => { + it('always includes the Flue runtime provider plugin', () => { + expect(sentryCloudflareVitePlugin().map(plugin => plugin.name)).toContain(PROVIDER_PLUGIN); + // Not gated by auto-instrumentation: it injects into Sentry's own module, not the entry. + expect(sentryCloudflareVitePlugin({ autoInstrumentation: false }).map(plugin => plugin.name)).toContain( + PROVIDER_PLUGIN, + ); + }); +}); diff --git a/packages/server-utils/src/integrations/flue.ts b/packages/server-utils/src/integrations/flue.ts index 2225c86302be..5e71e71a9162 100644 --- a/packages/server-utils/src/integrations/flue.ts +++ b/packages/server-utils/src/integrations/flue.ts @@ -32,10 +32,11 @@ const _flueIntegration = ((options: FlueOptions = {}) => { try { instrument(createFlueInstrumentation(options)); } catch (error) { - // A repeated `instrument()` throws `InstrumentationAlreadyInstalledError`, which is what an - // app that also registers manually will hit. Its own registration is already in place, so - // there is nothing to recover. - DEBUG_BUILD && debug.log('[Flue] auto-registration skipped:', error); + // Expected when the app registers manually too. Anything else is a real failure. + if ((error as Error | undefined)?.name !== 'InstrumentationAlreadyInstalledError') { + throw error; + } + DEBUG_BUILD && debug.log('[Flue] already instrumented by the app; skipping auto-registration'); } }, }; diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts index c2d6690c490e..6e319bfc5e2b 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -1,6 +1,5 @@ import { amqplibIntegration } from './amqplib'; import { dataloaderIntegration } from './dataloader'; -import { flueIntegration } from './flue'; import { knexIntegration } from './knex'; import { mongoIntegration } from './mongodb'; import { graphqlIntegration } from './graphql'; @@ -55,7 +54,6 @@ export function getTracingIntegrations(): Integration[] { langChainIntegration(), langGraphIntegration(), mastraIntegration(), - flueIntegration(), vercelAIIntegration(), openAIIntegration(), anthropicAIIntegration(), diff --git a/packages/server-utils/src/orchestrion/config/flue.ts b/packages/server-utils/src/orchestrion/config/flue.ts index 5b0fc60ca138..da6841a1ae9b 100644 --- a/packages/server-utils/src/orchestrion/config/flue.ts +++ b/packages/server-utils/src/orchestrion/config/flue.ts @@ -1,5 +1,4 @@ import type { InstrumentationConfig } from '../apmTypes'; -import { getModuleNames } from './module-names'; import { registrationOnly } from './registration-only'; /** @@ -9,7 +8,5 @@ import { registrationOnly } from './registration-only'; * like `@sentry/cloudflare`. */ export const flueConfig = [ - registrationOnly({ name: '@flue/runtime', versionRange: '>=2.0.0', filePath: 'dist/index.mjs' }), + registrationOnly({ name: '@flue/runtime', versionRange: '>=2.0.0 <3.0.0', filePath: 'dist/index.mjs' }), ] satisfies InstrumentationConfig[]; - -export const flueModuleNames = getModuleNames(flueConfig); diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index e3a911e9ecc7..fe9745acb79e 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -68,8 +68,8 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...langchainConfig, ...langgraphConfig, ...lruMemoizerConfig, - ...mastraConfig, ...flueConfig, + ...mastraConfig, ...mistralConfig, ...mongodbConfig, ...mongooseConfig, diff --git a/packages/server-utils/test/integrations/flue.test.ts b/packages/server-utils/test/integrations/flue.test.ts new file mode 100644 index 000000000000..99ec40e50846 --- /dev/null +++ b/packages/server-utils/test/integrations/flue.test.ts @@ -0,0 +1,63 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { flueIntegration } from '../../src/integrations/flue'; + +function setProvidedFlue(instrument: unknown): void { + const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {} as NonNullable); + (marker as { providedModules?: Record }).providedModules = { + '@flue/runtime': { instrument }, + }; +} + +function clearMarker(): void { + delete (GLOBAL_OBJ as { __SENTRY_ORCHESTRION__?: unknown }).__SENTRY_ORCHESTRION__; +} + +/** Flue's own error for a duplicate `instrument()`, which sets `name` on the instance. */ +function alreadyInstalledError(): Error { + const error = new Error('An instrumentation is already installed for this key'); + error.name = 'InstrumentationAlreadyInstalledError'; + return error; +} + +describe('flueIntegration', () => { + afterEach(() => { + clearMarker(); + vi.restoreAllMocks(); + }); + + it('registers the instrumentation when a Flue binding is provided', () => { + const instrument = vi.fn(); + setProvidedFlue(instrument); + + flueIntegration().setup?.({} as never); + + expect(instrument).toHaveBeenCalledTimes(1); + }); + + it('does nothing when no Flue binding is on the marker', () => { + clearMarker(); + + expect(() => flueIntegration().setup?.({} as never)).not.toThrow(); + }); + + it('swallows a duplicate registration from an app that also calls instrument()', () => { + setProvidedFlue( + vi.fn(() => { + throw alreadyInstalledError(); + }), + ); + + expect(() => flueIntegration().setup?.({} as never)).not.toThrow(); + }); + + it('rethrows anything that is not a duplicate registration', () => { + setProvidedFlue( + vi.fn(() => { + throw new TypeError('instrument is not a function'); + }), + ); + + expect(() => flueIntegration().setup?.({} as never)).toThrow(TypeError); + }); +}); diff --git a/packages/server-utils/test/orchestrion/config.test.ts b/packages/server-utils/test/orchestrion/config.test.ts index 78d489dc2175..3d9a76415b9c 100644 --- a/packages/server-utils/test/orchestrion/config.test.ts +++ b/packages/server-utils/test/orchestrion/config.test.ts @@ -64,6 +64,21 @@ describe('orchestrion config — channel-subscriber coverage', () => { }); }); +describe('orchestrion config — Flue', () => { + it('transforms @flue/runtime', () => { + expect(SENTRY_INSTRUMENTATIONS.map(i => i.module.name)).toContain('@flue/runtime'); + }); + + it('is force-bundled as a side effect of being instrumented', () => { + expect(INSTRUMENTED_MODULE_NAMES).toContain('@flue/runtime'); + }); + + // Registration-only configs carry a custom transform the runtime loader cannot apply. + it('excludes @flue/runtime from the runtime loader', () => { + expect(SENTRY_RUNTIME_INSTRUMENTATIONS.map(i => i.module.name)).not.toContain('@flue/runtime'); + }); +}); + describe('orchestrion config — custom instrumentations', () => { const customInstrumentation = { module: { name: 'my-lib' } } as InstrumentationConfig; From 143d7f530a866e5feb9e126d1e09959efd8ec97a Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Fri, 18 Sep 2026 11:55:50 +0300 Subject: [PATCH 3/3] fix(server-utils): Never let Flue auto-registration throw out of `Sentry.init()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core calls `integration.setup()` unguarded, and Cloudflare runs `Sentry.init()` inside the request wrapper, so rethrowing an unexpected `instrument()` failure would take down the handler — and every later request, since the client is never cached. A duplicate registration stays a debug log; anything else now warns that Flue spans will not be recorded, which keeps the failure visible without making it fatal. Co-Authored-By: Claude Opus 5 --- packages/server-utils/src/integrations/flue.ts | 10 ++++++---- .../server-utils/test/integrations/flue.test.ts | 13 +++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/server-utils/src/integrations/flue.ts b/packages/server-utils/src/integrations/flue.ts index 5e71e71a9162..915c237aabe3 100644 --- a/packages/server-utils/src/integrations/flue.ts +++ b/packages/server-utils/src/integrations/flue.ts @@ -32,11 +32,13 @@ const _flueIntegration = ((options: FlueOptions = {}) => { try { instrument(createFlueInstrumentation(options)); } catch (error) { - // Expected when the app registers manually too. Anything else is a real failure. - if ((error as Error | undefined)?.name !== 'InstrumentationAlreadyInstalledError') { - throw error; + // Never rethrow: `setup()` runs inside `Sentry.init()`, which core calls unguarded and + // Cloudflare calls per request, so throwing here would take down the request handler. + if ((error as Error | undefined)?.name === 'InstrumentationAlreadyInstalledError') { + DEBUG_BUILD && debug.log('[Flue] already instrumented by the app; skipping auto-registration'); + } else { + debug.warn('[Flue] auto-registration failed; Flue spans will not be recorded:', error); } - DEBUG_BUILD && debug.log('[Flue] already instrumented by the app; skipping auto-registration'); } }, }; diff --git a/packages/server-utils/test/integrations/flue.test.ts b/packages/server-utils/test/integrations/flue.test.ts index 99ec40e50846..48379fc1c9c0 100644 --- a/packages/server-utils/test/integrations/flue.test.ts +++ b/packages/server-utils/test/integrations/flue.test.ts @@ -1,4 +1,4 @@ -import { GLOBAL_OBJ } from '@sentry/core'; +import { debug, GLOBAL_OBJ } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { flueIntegration } from '../../src/integrations/flue'; @@ -51,13 +51,18 @@ describe('flueIntegration', () => { expect(() => flueIntegration().setup?.({} as never)).not.toThrow(); }); - it('rethrows anything that is not a duplicate registration', () => { + it('warns but never throws when registration fails for any other reason', () => { + // `setup()` runs inside `Sentry.init()`, which core calls unguarded — throwing would take + // down the Cloudflare request handler. + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + const error = new TypeError('instrument is not a function'); setProvidedFlue( vi.fn(() => { - throw new TypeError('instrument is not a function'); + throw error; }), ); - expect(() => flueIntegration().setup?.({} as never)).toThrow(TypeError); + expect(() => flueIntegration().setup?.({} as never)).not.toThrow(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[Flue] auto-registration failed'), error); }); });