Skip to content

Commit 0e82bbf

Browse files
mydeaclaude
andauthored
fix(node): Bootstrap Mastra observability under ESM (#24177)
The Mastra integration failed to auto-bootstrap `@mastra/observability` under ESM when the app configured no observability of its own. `loadMastraObservability()` anchored module resolution on `process.cwd()` and the CJS `require.cache`, but an ESM-loaded `@mastra/core` never lands in that cache, leaving cwd as the only anchor. When the app's dependencies don't live under cwd — a serverless/monorepo layout, or concretely the per-suite temp install the integration tests use — resolution failed silently: no exporter was attached and no `gen_ai` spans were produced, so the ESM `scenario-auto` test timed out. The runtime injection hook already receives the resolved `file:` URL of every module it instruments and was throwing it away. We now record it on `__SENTRY_ORCHESTRION__.runtimeFiles` and let the Mastra integration anchor `@mastra/observability` resolution on the app's actual `@mastra/core` file. This works under both ESM and CJS and no longer depends on cwd; the CJS-cache and cwd lookups remain as fallbacks. Decisions: - Anchor on `@mastra/core`, not `@mastra/observability`. The latter is never loaded in the failing case — that is precisely why the SDK has to bootstrap it — so there is nothing to hook there. `@mastra/core` is always loaded when the integration runs. - Fix it in SDK code rather than by changing the scenario child's cwd in the test runner. The cwd approach would have papered over a real production gap (ESM apps whose cwd is not the app directory, e.g. serverless) that the code's own comments already acknowledged. This also moves `@mastra/core` and `@mastra/observability` out of the `node-integration-tests` workspace `devDependencies` into the runner's per-suite `additionalDependencies`. `@mastra/core` declares `engines.node >= 22.13`, so a workspace `devDependency` broke `yarn install` on the Node 20.19 CI matrix even though the suite is gated behind `conditionalTest({ min: 22 })`. Installing them into the test's temp dir at run time means Node 20 never resolves them, while Node 22+ runs get them exactly as before. _Root cause_: cwd/`require.cache`-based resolution has no valid anchor for an optional peer dependency of an ESM-loaded module when the app's `node_modules` is not under `process.cwd()`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9596f42 commit 0e82bbf

7 files changed

Lines changed: 314 additions & 507 deletions

File tree

‎dev-packages/node-integration-tests/package.json‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,6 @@
4444
"@langchain/core": "^0.3.80",
4545
"@langchain/langgraph": "^0.2.32",
4646
"@langchain/openai": "^0.5.0",
47-
"@mastra/core": "1.63.2",
48-
"@mastra/observability": "1.17.4",
4947
"@modelcontextprotocol/client": "^2.0.0",
5048
"@modelcontextprotocol/server": "^2.0.0",
5149
"@nestjs/common": "^11",

‎dev-packages/node-integration-tests/suites/tracing/mastra/test.ts‎

Lines changed: 228 additions & 177 deletions
Large diffs are not rendered by default.

‎packages/core/src/utils/worldwide.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ export type InternalGlobal = {
6161
__SENTRY_ORCHESTRION__?: {
6262
/** Empty array signifies runtime hooked */
6363
runtime?: string[];
64+
/**
65+
* The resolved `file:` URL of a transformed file for each runtime-injected
66+
* module, keyed by module name (e.g. `@mastra/core`). Lets an integration
67+
* anchor `createRequire` on the app's actual copy of a dependency without
68+
* relying on `process.cwd()` or the CJS `require.cache` — the latter is
69+
* always empty for ESM-loaded modules. Only the runtime `--import`/hook path
70+
* populates this; the bundler path inlines modules and records none.
71+
*/
72+
runtimeFiles?: Record<string, string>;
6473
/**
6574
* Module names recorded as each bundler-transformed module loads (the
6675
* injected snippet calls `orchestrionModuleInjected`). The bundler plugin's

‎packages/server-runtime-injection/src/register.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export function registerDiagnosticsChannelInjection(): void {
9797
// we build against, hence the cast.
9898
const mod = Module as NodeModule;
9999

100-
setDiagnosticsHook(({ moduleName, error }): void => {
100+
setDiagnosticsHook(({ url, moduleName, error }): void => {
101101
if (error) {
102102
// A stripped transformer surfaces as a `TypeError` (`parse`/`generate` are `undefined`) and
103103
// costs the user this module's instrumentation, so it is worth an always-on warning. Every
@@ -110,6 +110,9 @@ export function registerDiagnosticsChannelInjection(): void {
110110
GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {};
111111
GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || [];
112112
GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(moduleName);
113+
// Record the module's resolved file so integrations can anchor dependency resolution on the
114+
// app's actual copy, even under ESM (where the CJS `require.cache` never sees the module).
115+
(GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtimeFiles ??= {})[moduleName] = url;
113116
// Tell channel integrations their module just loaded, so they subscribe
114117
// now. They hold off at `init()` to avoid claiming channel slots for
115118
// modules that never load, because Node caps channels in use at 1024.

‎packages/server-utils/src/integrations/mastra.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import * as diagnosticsChannel from 'node:diagnostics_channel';
22
import { createRequire } from 'node:module';
33
import { join } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
45
import type { IntegrationFn } from '@sentry/core';
5-
import { consoleSandbox, debug, defineIntegration } from '@sentry/core';
6+
import { consoleSandbox, debug, defineIntegration, GLOBAL_OBJ } from '@sentry/core';
67
import {
78
COMMUNITY_MASTRA_SENTRY_EXPORTER_NAME,
89
MASTRA_EXPORTER_BRAND,
@@ -169,9 +170,28 @@ function appRequire(): ReturnType<typeof createRequire> {
169170
return createRequire(cwdRequireParent());
170171
}
171172

173+
/**
174+
* The runtime injection hook records the resolved file of each instrumented module as it loads.
175+
* Unlike the CJS `require.cache`, this is populated for ESM-loaded modules too, so it is the
176+
* reliable anchor for finding the app's `@mastra/observability` next to its `@mastra/core`.
177+
*/
178+
function findInjectedMastraCoreFilename(): string | undefined {
179+
const url = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtimeFiles?.['@mastra/core'];
180+
if (!url) {
181+
return undefined;
182+
}
183+
184+
try {
185+
return url.startsWith('file:') ? fileURLToPath(url) : url;
186+
} catch {
187+
return undefined;
188+
}
189+
}
190+
172191
/**
173192
* `@mastra/core` is already evaluated (we are in its constructor). Prefer that file so a
174193
* serverless/test cwd that is not the app still finds the app's `@mastra/observability`.
194+
* Only sees CJS-loaded modules; ESM apps rely on {@link findInjectedMastraCoreFilename}.
175195
*/
176196
function findLoadedMastraCoreFilename(): string | undefined {
177197
const cache = appRequire().cache;
@@ -197,11 +217,16 @@ function tryRequireObservability(parent: string): Record<string, unknown> | unde
197217
}
198218

199219
/**
200-
* Prefer the already-loaded `@mastra/core` file, then cwd-resolved core, then cwd itself.
201-
* A cache hit can still fail under pnpm if that copy cannot see `@mastra/observability`.
220+
* Prefer the runtime-injected `@mastra/core` file (works under ESM and CJS), then the CJS-cached
221+
* copy, then cwd-resolved core, then cwd itself. A hit can still fail under pnpm if that copy
222+
* cannot see `@mastra/observability`, hence the fallbacks.
202223
*/
203224
function loadMastraObservability(): Record<string, unknown> {
204225
const parents = new Set<string>();
226+
const injectedCore = findInjectedMastraCoreFilename();
227+
if (injectedCore) {
228+
parents.add(injectedCore);
229+
}
205230
const loadedCore = findLoadedMastraCoreFilename();
206231
if (loadedCore) {
207232
parents.add(loadedCore);

‎packages/server-utils/test/integrations/mastra/bootstrap-resolve.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { CHANNELS } from '../../../src/orchestrion/channels';
88
const observabilityFrom = vi.hoisted(() => ({
99
parent: undefined as string | undefined,
1010
cache: {} as NodeJS.Dict<NodeModule>,
11+
injectedCoreUrl: 'file:///app/node_modules/@mastra/core/dist/mastra-abc123.js',
12+
injectedCore: '/app/node_modules/@mastra/core/dist/mastra-abc123.js',
1113
loadedCore: '/app/node_modules/@mastra/core/dist/index.js',
1214
cwdCore: '/cwd/node_modules/@mastra/core/index.js',
1315
cwd: `${process.cwd()}/noop.js`,
@@ -59,6 +61,39 @@ describe('mastraIntegration observability resolve', () => {
5961
delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__;
6062
});
6163

64+
it('loads @mastra/observability from the runtime-injected @mastra/core file (ESM path)', () => {
65+
// No CJS cache entry — mirrors an ESM app, where the loaded module never reaches `require.cache`.
66+
GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = {
67+
runtime: ['@mastra/core'],
68+
runtimeFiles: { '@mastra/core': observabilityFrom.injectedCoreUrl },
69+
};
70+
const registerExporter = vi.fn();
71+
72+
tracingChannel(CHANNELS.MASTRA_CONSTRUCTOR).end.publish({
73+
self: { registerExporter },
74+
arguments: [],
75+
});
76+
77+
expect(observabilityFrom.parent).toBe(observabilityFrom.injectedCore);
78+
expect(registerExporter).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything());
79+
});
80+
81+
it('prefers the runtime-injected file over the CJS-cached copy', () => {
82+
observabilityFrom.cache[observabilityFrom.loadedCore] = {} as NodeModule;
83+
GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = {
84+
runtime: ['@mastra/core'],
85+
runtimeFiles: { '@mastra/core': observabilityFrom.injectedCoreUrl },
86+
};
87+
const registerExporter = vi.fn();
88+
89+
tracingChannel(CHANNELS.MASTRA_CONSTRUCTOR).end.publish({
90+
self: { registerExporter },
91+
arguments: [],
92+
});
93+
94+
expect(observabilityFrom.parent).toBe(observabilityFrom.injectedCore);
95+
});
96+
6297
it('loads @mastra/observability from a loaded @mastra/core file, not cwd', () => {
6398
observabilityFrom.cache[observabilityFrom.loadedCore] = {} as NodeModule;
6499
const registerExporter = vi.fn();

0 commit comments

Comments
 (0)