Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions packages/cloudflare/src/vite/flueRuntime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createRequire } from 'node:module';

@isaacs isaacs Sep 18, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main issue/comment I'd make for this PR: this file is nearly identical to the packages/cloudflare/src/vite/mastraObservability.ts file, except for the module specifier, the identifier, the target regex, the tolerated resolve error, and getter versus assignment.

Suggestion: extract one factory, eg createProvidedModulePlugin({ name, moduleName, identifier, targetId, lazy }), and let both call sites shrink to a few lines. That also gives one place to fix any other concerns for both packages.

Also, I notice that Mastra's plain catch { return; } works today only because @mastra/observability still publishes a require condition. If it goes ESM-only, that provider silently stops injecting, with the same symptom this branch just fixed for Flue. A shared check removes that potential future bug, and lets us improve both in one place.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way, this can definitely be put off for a future PR, I just think we should probably get to it before there's a third one of these, and we start having a harder time deciding which drifting behavior is correct 😅

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<MagicString['generateMap']> } | 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;
}
}
Comment on lines +46 to +52

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on the comment above, it seems safer to detect module not found rather than "anything other than path not exported"?

Suggested change
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;
}
}
try {
createRequire(resolve(config.root, 'noop.js')).resolve(FLUE_MODULE);
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') {
return;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, also, it'd be a bigger refactor, but I think if we have a rollup context, we can do await this.resolve(FLUE_MODULE, resolve(root, 'noop.js')) on it to get a more definitive answer, regardless of export type. That would drop createRequire, node:path and the error-code special case entirely.

// 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`;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`{ configurable: true, get() { return ${PROVIDER_IDENTIFIER}; } });\n`;
`{ configurable: true, enumerable: true, get() { return ${PROVIDER_IDENTIFIER}; } });\n`;

},

transform(code: string, id: string): { code: string; map: ReturnType<MagicString['generateMap']> } | undefined {
if (!providerSnippet || !isFlueIntegrationModuleId(id)) return undefined;

const ms = new MagicString(code);
ms.prepend(providerSnippet);
return { code: ms.toString(), map: ms.generateMap({ hires: true }) };
},
Comment on lines +63 to +69

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not idempotent, because the ms.prepend unconditionally adds the snippet.

If transform ever sees the same module twice in one environment, the output carries two import * as __SENTRY_FLUE_RUNTIME__ statements, which is a duplicate binding and a syntax error. Vite's per-environment module graphs make it unlikely, but it's a potential future hazard.

(Note: same thing in the Mastra plugin, probably another reason to consider consolidating them.)

Suggested change
transform(code: string, id: string): { code: string; map: ReturnType<MagicString['generateMap']> } | undefined {
if (!providerSnippet || !isFlueIntegrationModuleId(id)) return undefined;
const ms = new MagicString(code);
ms.prepend(providerSnippet);
return { code: ms.toString(), map: ms.generateMap({ hires: true }) };
},
transform(code: string, id: string): { code: string; map: ReturnType<MagicString['generateMap']> } | undefined {
if (!providerSnippet || !isFlueIntegrationModuleId(id) || code.includes(PROVIDER_IDENTIFIER)) {
return undefined;
}
const ms = new MagicString(code);
ms.prepend(providerSnippet);
return { code: ms.toString(), map: ms.generateMap({ hires: true }) };
},

};
}
2 changes: 2 additions & 0 deletions packages/cloudflare/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -91,6 +92,7 @@ export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOp
dcModule: '@sentry/cloudflare/orchestrion-diagnostics-channel',
}),
sentryMastraObservabilityProviderPlugin(),
sentryFlueRuntimeProviderPlugin(),
...(options.autoInstrumentation !== false
? [sentryCloudflareAutoInstrumentPlugin({ wranglerConfigPath: options.wranglerConfigPath })]
: []),
Expand Down
133 changes: 133 additions & 0 deletions packages/cloudflare/test/vite/flueRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
4 changes: 4 additions & 0 deletions packages/server-utils/src/ai/flue/constants.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down
1 change: 1 addition & 0 deletions packages/server-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is exported here, should it be exported from cloudflare as well?

Same with the FlueOptions type.

export type { FlueOptions } from './ai/flue';
export { mastraIntegration } from './integrations/mastra';
export { SentryMastraExporter } from './ai/mastra';
Expand Down
47 changes: 47 additions & 0 deletions packages/server-utils/src/integrations/flue.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createFlueInstrumentation>) => 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);
}
Comment thread
RulaKhaled marked this conversation as resolved.
}
Comment thread
RulaKhaled marked this conversation as resolved.
Comment on lines +32 to +42

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In dev, a repeat instrument() under the same key doesn't throw, and instead disposes the previous registration. Sentry's dispose() (in packages/server-utils/src/ai/flue/index.ts) ends every tracked turn and tool span and clears all three maps.

Cloudflare calls Sentry.init() per request. With the default cacheClient: true the cached client short-circuits before setup() reruns, so this doesn't fire.

With cacheClient: false, or any path that bypasses the cache, setup() runs per request.

Under vite dev that means every request ends the in-flight turn and tool spans of every concurrent request, and the fresh registration starts with empty maps so those spans are then orphaned, and nothing throws or is logged.

Suggesgtion: guard the call with a module-scope flag, for example let registered = false; set after a successful instrument(). One registration per isolate is all the design wants, and the flag also avoids allocating two 1000-entry LRUMaps per request just to throw them away on the production path.

},
};
}) satisfies IntegrationFn;

export const flueIntegration = defineIntegration(_flueIntegration);
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
Expand Down
12 changes: 12 additions & 0 deletions packages/server-utils/src/orchestrion/config/flue.ts
Original file line number Diff line number Diff line change
@@ -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[];
2 changes: 2 additions & 0 deletions packages/server-utils/src/orchestrion/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +68,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [
...langchainConfig,
...langgraphConfig,
...lruMemoizerConfig,
...flueConfig,
...mastraConfig,
...mistralConfig,
...mongodbConfig,
Expand Down
68 changes: 68 additions & 0 deletions packages/server-utils/test/integrations/flue.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof GLOBAL_OBJ.__SENTRY_ORCHESTRION__>);
(marker as { providedModules?: Record<string, unknown> }).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);
});
});
Loading
Loading