Skip to content

feat(cloudflare): Auto-register Flue instrumentation in bundled workers - #24476

Open
RulaKhaled wants to merge 3 commits into
developfrom
feat/flue-cloudflare-autoinstrument
Open

RulaKhaled wants to merge 3 commits into
developfrom
feat/flue-cloudflare-autoinstrument

Conversation

@RulaKhaled

@RulaKhaled RulaKhaled commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

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. A bundled worker has no node_modules to resolve one from, so this supplies it at build time.

@sentry/cloudflare/vite splices a static @flue/runtime import into Sentry's own Flue integration module and exposes the namespace on providedModules; flueIntegration() reads it there and registers. A registrationOnly orchestrion entry installs the integration on a bundler-only SDK and keeps it reachable under sideEffects: false.

@flue/runtime is ESM-only, so the presence check resolves with createRequire from the Vite root and counts ERR_PACKAGE_PATH_NOT_EXPORTED as a hit — the package publishes no require condition on any subpath, while a genuinely missing one reports MODULE_NOT_FOUND. The namespace is exposed through a getter rather than assigned, because the bundler may evaluate Sentry's module before @flue/runtime is initialized.

An app that also calls instrument() itself is unaffected: its own registration wins, and only the resulting InstrumentationAlreadyInstalledError is swallowed. On Node registering stays a manual instrument(Sentry.createFlueInstrumentation()) call — flueIntegration() is not among the default integrations there.

Verified end to end in #24477.

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 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 29.09 kB - -
@sentry/browser - with treeshaking flags 27.35 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 27.26 kB - -
@sentry/browser (incl. Tracing) 50.6 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 50.62 kB - -
@sentry/browser (incl. Tracing, Profiling) 53.61 kB - -
@sentry/browser (incl. Tracing, Replay) 90.15 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 79.25 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 94.85 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 107.83 kB - -
@sentry/browser (incl. Feedback) 46.62 kB - -
@sentry/browser (incl. sendFeedback) 34.15 kB - -
@sentry/browser (incl. FeedbackAsync) 39.26 kB - -
@sentry/browser (incl. Metrics) 30.1 kB - -
@sentry/browser (incl. Logs) 30.35 kB - -
@sentry/browser (incl. Metrics & Logs) 31.02 kB - -
@sentry/react 30.84 kB - -
@sentry/react (incl. Tracing) 52.94 kB - -
@sentry/vue 36.34 kB - -
@sentry/vue (incl. Tracing) 52.91 kB - -
@sentry/svelte 29.11 kB - -
CDN Bundle 30.8 kB - -
CDN Bundle (incl. Tracing) 51.15 kB - -
CDN Bundle (incl. Logs, Metrics) 33.06 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 53.14 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 73.75 kB - -
CDN Bundle (incl. Tracing, Replay) 88.69 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 90.63 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 94.73 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 96.78 kB - -
CDN Bundle - uncompressed 91.16 kB - -
CDN Bundle (incl. Tracing) - uncompressed 152.66 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 97.73 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 158.61 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 227.14 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 272.23 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 278.17 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 285.93 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 291.86 kB - -
@sentry/nextjs (client) 55.27 kB - -
@sentry/sveltekit (client) 51.05 kB - -
@sentry/core/server 39.63 kB - -
@sentry/core/browser 13.66 kB - -
@sentry/node 133.33 kB +0.05% +60 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 82.14 kB +0.04% +27 B 🔺
@sentry/node - without tracing 89.92 kB +0.06% +52 B 🔺
@sentry/node - without channel injection 112.15 kB +0.02% +15 B 🔺
@sentry/aws-serverless 98.19 kB +0.06% +55 B 🔺
@sentry/cloudflare (withSentry) - minified 204.69 kB - -
@sentry/cloudflare (withSentry) 509.25 kB - -

View base workflow run

@RulaKhaled
RulaKhaled added this pull request to stack #24478 September 17, 2026 14:18
…ation

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 <noreply@anthropic.com>
@RulaKhaled RulaKhaled changed the title feat(cloudflare): Auto-register Flue instrumentation in bundled workers (WIP) feat(cloudflare): Auto-register Flue instrumentation in bundled workers Sep 18, 2026
@RulaKhaled

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 97b7ed7. Configure here.

Comment thread packages/server-utils/src/integrations/flue.ts
Comment thread packages/server-utils/src/integrations/flue.ts
…try.init()`

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 <noreply@anthropic.com>
@RulaKhaled
RulaKhaled marked this pull request as ready for review September 18, 2026 09:14
@RulaKhaled
RulaKhaled requested review from a team as code owners September 18, 2026 09:14
@RulaKhaled
RulaKhaled requested review from isaacs and mydea and removed request for a team September 18, 2026 09:14

@isaacs isaacs left a comment

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 good :)

Breaking the import cycle with a getter is a good approach. Build-time presence detection avoids forcing @flue/runtime into every worker.

The only concern (which can be put off to a follow-up easily enough) is the overlap with Mastra, which is already drifting in a few spots (albeit pretty minor), so would probably be good to consolidate.

@@ -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 😅

Comment on lines +46 to +52
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;
}
}

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.

Comment on lines +32 to +42
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);
}
}

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.

Comment on lines +63 to +69
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 }) };
},

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 }) };
},

'(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`;

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants