Skip to content

Commit 97c6e76

Browse files
RulaKhaledclaude
andauthored
ref(cloudflare): Extract one factory for the Vite provider plugins (#24535)
Follow-up to #24476, where @isaacs pointed out that `flueRuntime.ts` and `mastraObservability.ts` are near-identical and drifting. Both are now a few lines over a shared `createProvidedModulePlugin`, so the next provider is a config object rather than a third copy. The probe moves from `createRequire().resolve()` to the Rollup context's `this.resolve()`, which uses the same resolver and conditions as the injected import. That drops `createRequire`, `node:path` and the error-code special case, and fixes a latent Mastra bug: its bare `catch { return; }` only works because `@mastra/observability` still publishes a `require` condition, so an ESM-only release would have turned injection off silently, the same failure #24476 fixed for Flue. `this.resolve()` needs a plugin context, so the probe moves to `buildStart` and `configResolved` stays only to capture the app root. Resolution runs per environment against a shared instance, so it stops once the package is found and retries otherwise, since the worker environment may not run first. Mastra also picks up the guards Flue gained in #24476: `transform` is idempotent, a resolver error injects rather than skipping, and the namespace goes behind the same lazy getter. Assigning reads the binding at injection time and stores `undefined` if the bundler evaluates Sentry's module first, which is not Flue-specific, so there is one shape for both and no `lazy` option. `cloudflare-mastra` is the real check here: on workerd the injected binding is the only path `loadMastraObservability` can take, so a broken injection means no agent spans and a failing suite. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ca47d81 commit 97c6e76

6 files changed

Lines changed: 378 additions & 290 deletions

File tree

Lines changed: 8 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,5 @@
1-
import { createRequire } from 'node:module';
2-
import { resolve } from 'node:path';
3-
import MagicString from 'magic-string';
4-
5-
// Namespace binding the injected provider import uses; read back by the integration
6-
// off the global marker.
7-
const PROVIDER_IDENTIFIER = '__SENTRY_FLUE_RUNTIME__';
8-
9-
const FLUE_MODULE = '@flue/runtime';
10-
11-
// The bundled `@sentry/server-utils` Flue integration module (ESM build — the only one a
12-
// worker loads). It reads `@flue/runtime` off the global marker this provider populates,
13-
// because `instrument()` registers into module-scope state no channel payload can carry.
14-
const FLUE_INTEGRATION_ID = /@sentry\/server-utils\/build\/esm\/integrations\/flue\.js$/;
15-
16-
/** Whether `id` is the Sentry Flue integration module the provider injects into. */
17-
export function isFlueIntegrationModuleId(id: string): boolean {
18-
const normalizedId = id.replace(/\\/g, '/').replace(/[?#].*$/, '');
19-
return FLUE_INTEGRATION_ID.test(normalizedId);
20-
}
1+
import type { ProvidedModulePlugin } from './providedModulePlugin';
2+
import { createProvidedModulePlugin } from './providedModulePlugin';
213

224
/**
235
* Splices a static `import * as … from '@flue/runtime'` into Sentry's own Flue integration module
@@ -28,48 +10,11 @@ export function isFlueIntegrationModuleId(id: string): boolean {
2810
* user passes it by calling `instrument()` themselves; a bundled worker has no `node_modules` to
2911
* resolve from, so it is supplied at build time instead.
3012
*/
31-
export function sentryFlueRuntimeProviderPlugin(): {
32-
name: string;
33-
configResolved(config: { root: string }): void;
34-
transform(code: string, id: string): { code: string; map: ReturnType<MagicString['generateMap']> } | undefined;
35-
} {
36-
let providerSnippet: string | undefined;
37-
38-
return {
13+
export function sentryFlueRuntimeProviderPlugin(): ProvidedModulePlugin {
14+
return createProvidedModulePlugin({
3915
name: 'sentry-cloudflare-flue-runtime-provider',
40-
41-
configResolved(config: { root: string }): void {
42-
// Build-time only; never ships to the worker. Probed with CJS resolution, which an ESM-only
43-
// `@flue/runtime` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED` — so only a module-not-found
44-
// counts as absent, and any other failure still injects and lets Vite report it. Not
45-
// `import.meta.resolve`: `parentURL` is ignored without a flag, and it is absent from the
46-
// CJS build.
47-
try {
48-
createRequire(resolve(config.root, 'noop.js')).resolve(FLUE_MODULE);
49-
} catch (error) {
50-
const code = (error as NodeJS.ErrnoException | undefined)?.code;
51-
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') {
52-
return;
53-
}
54-
}
55-
// A getter where Mastra assigns: the bundler may evaluate Sentry's module before
56-
// `@flue/runtime` is initialized, and assigning there would store `undefined`.
57-
providerSnippet =
58-
`import * as ${PROVIDER_IDENTIFIER} from '${FLUE_MODULE}';\n` +
59-
'(globalThis.__SENTRY_ORCHESTRION__ = globalThis.__SENTRY_ORCHESTRION__ || {});\n' +
60-
'(globalThis.__SENTRY_ORCHESTRION__.providedModules = globalThis.__SENTRY_ORCHESTRION__.providedModules || {});\n' +
61-
`Object.defineProperty(globalThis.__SENTRY_ORCHESTRION__.providedModules, '${FLUE_MODULE}', ` +
62-
`{ configurable: true, enumerable: true, get() { return ${PROVIDER_IDENTIFIER}; } });\n`;
63-
},
64-
65-
transform(code: string, id: string): { code: string; map: ReturnType<MagicString['generateMap']> } | undefined {
66-
// `code.includes` keeps this idempotent: a second pass over already-injected output would
67-
// otherwise emit a duplicate `import * as` binding, which is a syntax error.
68-
if (!providerSnippet || !isFlueIntegrationModuleId(id) || code.includes(PROVIDER_IDENTIFIER)) return undefined;
69-
70-
const ms = new MagicString(code);
71-
ms.prepend(providerSnippet);
72-
return { code: ms.toString(), map: ms.generateMap({ hires: true }) };
73-
},
74-
};
16+
moduleName: '@flue/runtime',
17+
identifier: '__SENTRY_FLUE_RUNTIME__',
18+
integrationModule: 'flue',
19+
});
7520
}
Lines changed: 14 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,20 @@
1-
import { createRequire } from 'node:module';
2-
import { resolve } from 'node:path';
3-
import MagicString from 'magic-string';
4-
5-
// Namespace binding the injected provider import uses; read back by the integration
6-
// off the global marker.
7-
const PROVIDER_IDENTIFIER = '__SENTRY_MASTRA_OBSERVABILITY__';
8-
9-
// The bundled `@sentry/server-utils` Mastra integration module (ESM build — the only
10-
// one a worker loads). Its `loadMastraObservability` reads `@mastra/observability` off
11-
// the global marker this provider populates, instead of `createRequire`, which cannot
12-
// resolve a package inside a bundled worker.
13-
const MASTRA_INTEGRATION_ID = /@sentry\/server-utils\/build\/esm\/integrations\/mastra\.js$/;
14-
15-
/** Whether `id` is the Sentry Mastra integration module the provider injects into. */
16-
export function isMastraIntegrationModuleId(id: string): boolean {
17-
const normalizedId = id.replace(/\\/g, '/').replace(/[?#].*$/, '');
18-
return MASTRA_INTEGRATION_ID.test(normalizedId);
19-
}
1+
import type { ProvidedModulePlugin } from './providedModulePlugin';
2+
import { createProvidedModulePlugin } from './providedModulePlugin';
203

214
/**
22-
* Splices a static `import * as … from '@mastra/observability'` into Sentry's own
23-
* Mastra integration module and stashes the namespace on the global orchestrion
24-
* marker.
5+
* Splices a static `import * as … from '@mastra/observability'` into Sentry's own Mastra
6+
* integration module and stashes the namespace on the global orchestrion marker.
257
*
26-
* On Cloudflare the integration cannot `createRequire('@mastra/observability')` to
27-
* bootstrap Mastra's observability pipeline — there is no on-disk `node_modules` in
28-
* workerd — so without this the user has to construct and wire up an `Observability`
29-
* themselves. The import is static (statically analyzable, no lazy `import()`), lands
30-
* in Sentry's module rather than the user's code, and is only emitted when the package
31-
* actually resolves; if it is absent, the integration keeps its Node `createRequire`
32-
* fallback and the marker stays empty.
8+
* On Cloudflare the integration cannot `createRequire('@mastra/observability')` to bootstrap
9+
* Mastra's observability pipeline — there is no on-disk `node_modules` in workerd — so without
10+
* this the user has to construct and wire up an `Observability` themselves. If the package is
11+
* absent, the integration keeps its Node `createRequire` fallback and the marker stays empty.
3312
*/
34-
export function sentryMastraObservabilityProviderPlugin(): {
35-
name: string;
36-
configResolved(config: { root: string }): void;
37-
transform(code: string, id: string): { code: string; map: ReturnType<MagicString['generateMap']> } | undefined;
38-
} {
39-
let providerSnippet: string | undefined;
40-
41-
return {
13+
export function sentryMastraObservabilityProviderPlugin(): ProvidedModulePlugin {
14+
return createProvidedModulePlugin({
4215
name: 'sentry-cloudflare-mastra-observability-provider',
43-
44-
configResolved(config: { root: string }): void {
45-
// Resolved at build time (Node), so this `createRequire` never ships to the worker.
46-
try {
47-
createRequire(resolve(config.root, 'noop.js')).resolve('@mastra/observability');
48-
} catch {
49-
return;
50-
}
51-
providerSnippet =
52-
`import * as ${PROVIDER_IDENTIFIER} from '@mastra/observability';\n` +
53-
'(globalThis.__SENTRY_ORCHESTRION__ = globalThis.__SENTRY_ORCHESTRION__ || {});\n' +
54-
'(globalThis.__SENTRY_ORCHESTRION__.providedModules = globalThis.__SENTRY_ORCHESTRION__.providedModules || {})' +
55-
`['@mastra/observability'] = ${PROVIDER_IDENTIFIER};\n`;
56-
},
57-
58-
transform(code: string, id: string): { code: string; map: ReturnType<MagicString['generateMap']> } | undefined {
59-
if (!providerSnippet || !isMastraIntegrationModuleId(id)) return undefined;
60-
61-
const ms = new MagicString(code);
62-
ms.prepend(providerSnippet);
63-
return { code: ms.toString(), map: ms.generateMap({ hires: true }) };
64-
},
65-
};
16+
moduleName: '@mastra/observability',
17+
identifier: '__SENTRY_MASTRA_OBSERVABILITY__',
18+
integrationModule: 'mastra',
19+
});
6620
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { resolve } from 'node:path';
2+
import MagicString from 'magic-string';
3+
4+
/**
5+
* The slice of the Rollup plugin context the probe needs. Declared here rather than imported so
6+
* this file carries no Rollup or Vite type dependency.
7+
*/
8+
interface ResolveContext {
9+
resolve(
10+
source: string,
11+
importer?: string,
12+
options?: { skipSelf?: boolean },
13+
): Promise<{ id: string; external?: boolean | string } | null>;
14+
warn(message: string): void;
15+
}
16+
17+
/** The plugin shape `sentryCloudflareVitePlugin` composes. */
18+
export interface ProvidedModulePlugin {
19+
name: string;
20+
applyToEnvironment(environment: { config: { consumer: string } }): boolean;
21+
configResolved(config: { root: string }): void;
22+
buildStart(this: ResolveContext): Promise<void>;
23+
transform(code: string, id: string): { code: string; map: ReturnType<MagicString['generateMap']> } | undefined;
24+
}
25+
26+
export interface ProvidedModulePluginOptions {
27+
/** Vite plugin name, e.g. `sentry-cloudflare-flue-runtime-provider`. */
28+
name: string;
29+
/** Bare specifier of the package to provide, e.g. `@flue/runtime`. */
30+
moduleName: string;
31+
/** Namespace binding the injected import uses, e.g. `__SENTRY_FLUE_RUNTIME__`. */
32+
identifier: string;
33+
/** Basename of the `@sentry/server-utils` integration module to inject into, e.g. `flue`. */
34+
integrationModule: string;
35+
}
36+
37+
/**
38+
* Build the matcher for one `@sentry/server-utils` integration module.
39+
*
40+
* Plain `endsWith`, not a `RegExp`: nothing here needs pattern matching, and building one from
41+
* a caller-supplied string would need escaping, which is the only reason this file would have to
42+
* import from `@sentry/core`. A build-time plugin should not drag the SDK into the build.
43+
*/
44+
export function createIntegrationModuleMatcher(integrationModule: string): (id: string) => boolean {
45+
// The ESM build only: a worker never loads the CJS one.
46+
const suffix = `@sentry/server-utils/build/esm/integrations/${integrationModule}.js`;
47+
48+
return (id: string): boolean =>
49+
id
50+
.replace(/\\/g, '/')
51+
.replace(/[?#].*$/, '')
52+
.endsWith(suffix);
53+
}
54+
55+
function buildProviderSnippet({ moduleName, identifier }: ProvidedModulePluginOptions): string {
56+
const marker = 'globalThis.__SENTRY_ORCHESTRION__';
57+
58+
// A getter, not an assignment: assigning reads the binding at injection time, so it stores
59+
// `undefined` whenever the bundler evaluates Sentry's module before the provided package
60+
// finished initializing. Enumerable so the entry shows up in `Object.keys` and a spread.
61+
return (
62+
`import * as ${identifier} from '${moduleName}';\n` +
63+
`(${marker} = ${marker} || {});\n` +
64+
`(${marker}.providedModules = ${marker}.providedModules || {});\n` +
65+
`Object.defineProperty(${marker}.providedModules, '${moduleName}', ` +
66+
`{ configurable: true, enumerable: true, get() { return ${identifier}; } });\n`
67+
);
68+
}
69+
70+
/**
71+
* Build a Vite plugin that splices a static `import * as … from '<moduleName>'` into one of
72+
* Sentry's own integration modules and exposes the namespace on the global orchestrion marker.
73+
*
74+
* Some packages are instrumented by registration rather than by patching, so instrumenting them
75+
* needs a reference to that module's own binding and no channel payload carries one. On Node the
76+
* integration resolves it itself; a bundled worker has no `node_modules` to resolve from, so the
77+
* binding is supplied at build time instead. The import is static, lands in Sentry's module rather
78+
* than the user's code, and is only emitted when the package actually resolves.
79+
*/
80+
export function createProvidedModulePlugin(options: ProvidedModulePluginOptions): ProvidedModulePlugin {
81+
const isIntegrationModuleId = createIntegrationModuleMatcher(options.integrationModule);
82+
83+
let root = process.cwd();
84+
let providerSnippet: string | undefined;
85+
86+
return {
87+
name: options.name,
88+
89+
applyToEnvironment(environment: { config: { consumer: string } }): boolean {
90+
// Server environments only. `buildStart` runs per environment against one shared plugin
91+
// instance, so without this a `client` build resolves first, under browser conditions, and
92+
// answers on the worker's behalf. That defeats the point of probing with `this.resolve`.
93+
// Same gate the orchestrion plugin uses.
94+
return environment.config.consumer === 'server';
95+
},
96+
97+
configResolved(config: { root: string }): void {
98+
root = config.root;
99+
},
100+
101+
async buildStart(this: ResolveContext): Promise<void> {
102+
// Already answered by an earlier server environment. A build with several worker
103+
// environments shares the answer: they resolve under the same conditions.
104+
if (providerSnippet) return;
105+
106+
try {
107+
// The environment's own resolver, so the probe uses the conditions the injected import
108+
// will. That is what a `require.resolve` probe cannot do: an ESM-only package has no
109+
// `require` condition and reads as missing. Resolved from the app root, not from Sentry's
110+
// own install.
111+
const resolved = await this.resolve(options.moduleName, resolve(root, 'noop.js'));
112+
if (!resolved) return;
113+
} catch (error) {
114+
// Present but unresolvable for some other reason. Inject anyway so the build fails loudly
115+
// rather than silently shipping a worker with no instrumentation, and surface the original
116+
// cause: the import error Vite raises next says nothing about why resolution broke.
117+
this.warn(
118+
`[Sentry] could not resolve ${options.moduleName} while probing for it; injecting the provider anyway. ${
119+
(error as Error | undefined)?.message ?? error
120+
}`,
121+
);
122+
}
123+
124+
providerSnippet = buildProviderSnippet(options);
125+
},
126+
127+
transform(code: string, id: string): { code: string; map: ReturnType<MagicString['generateMap']> } | undefined {
128+
// `code.includes` keeps this idempotent: a second pass over already-injected output would
129+
// otherwise emit a duplicate `import * as` binding, which is a syntax error.
130+
if (!providerSnippet || !isIntegrationModuleId(id) || code.includes(options.identifier)) return undefined;
131+
132+
const ms = new MagicString(code);
133+
ms.prepend(providerSnippet);
134+
return { code: ms.toString(), map: ms.generateMap({ hires: true }) };
135+
},
136+
};
137+
}

0 commit comments

Comments
 (0)