diff --git a/packages/cloudflare/src/vite/flueRuntime.ts b/packages/cloudflare/src/vite/flueRuntime.ts new file mode 100644 index 000000000000..b69c9000fac0 --- /dev/null +++ b/packages/cloudflare/src/vite/flueRuntime.ts @@ -0,0 +1,71 @@ +import { createRequire } from 'node:module'; +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 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. */ +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 exposes the namespace on the global orchestrion marker. + * + * 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; + 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 { + // 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 { + 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 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' + + '(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/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/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..915c237aabe3 --- /dev/null +++ b/packages/server-utils/src/integrations/flue.ts @@ -0,0 +1,47 @@ +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) { + // 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); + } + } + }, + }; +}) satisfies IntegrationFn; + +export const flueIntegration = defineIntegration(_flueIntegration); 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..da6841a1ae9b --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/flue.ts @@ -0,0 +1,12 @@ +import type { InstrumentationConfig } from '../apmTypes'; +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 <3.0.0', filePath: 'dist/index.mjs' }), +] satisfies InstrumentationConfig[]; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 723c5857b8f1..fe9745acb79e 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'; @@ -67,6 +68,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...langchainConfig, ...langgraphConfig, ...lruMemoizerConfig, + ...flueConfig, ...mastraConfig, ...mistralConfig, ...mongodbConfig, 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..48379fc1c9c0 --- /dev/null +++ b/packages/server-utils/test/integrations/flue.test.ts @@ -0,0 +1,68 @@ +import { debug, 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('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 error; + }), + ); + + expect(() => flueIntegration().setup?.({} as never)).not.toThrow(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[Flue] auto-registration failed'), error); + }); +}); 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;