Summary
When @sentry/core is loaded in two module realms in the same process — once as CJS (require) and once as ESM (import) — the HTTP server instrumentation double-wraps server.emit and the Proxy chain grows on every request until it throws RangeError: Maximum call stack size exceeded on a live server.
This happens in any setup where a CJS host process also loads an ESM bundle that initializes Sentry — e.g. a NestJS/Express server that await import()s an ESM SSR bundle (React Router / Remix) which calls Sentry.init(). The CJS side (@sentry/nestjs → @sentry/node) and the ESM side (@sentry/react-router → @sentry/node) each pull their respective build of @sentry/core via the conditional exports map (require → build/cjs, import → build/esm), so two physical copies of the instrumentation module run in one process.
Environment
@sentry/core / @sentry/node / @sentry/node-core / @sentry/nestjs / @sentry/react-router: 10.59.0 (latest at time of writing)
- Node.js 24.17.0 (not version-specific)
- The captured production stack trace alternates between
@sentry/core/build/cjs/integrations/http/server-subscription.js and
@sentry/core/build/esm/integrations/http/server-subscription.js.
Root cause
integrations/http/server-subscription.js wraps server.emit in a Proxy and dedups re-wraps with a module-scoped WeakMap:
const lastSentryEmitMap = new WeakMap(); // module scope → one instance PER build (CJS, ESM)
function instrumentServer(options, server) {
const currentEmit = server.emit;
const instrumentedEmit = lastSentryEmitMap.get(server);
if (currentEmit === instrumentedEmit) return; // ← dedup guard fails ACROSS realms
const newEmit = new Proxy(currentEmit, {
apply(target, thisArg, args) { /* ... */ return target.apply(thisArg, args); },
});
lastSentryEmitMap.set(server, newEmit);
server.emit = newEmit;
}
httpServerIntegration (@sentry/node-core/.../integrations/http/httpServerIntegration.js) subscribes to the http.server.request.start diagnostics channel in each realm's setupOnce(). Because each build has its own lastSentryEmitMap, the CJS guard never recognizes the ESM realm's wrapper (and vice-versa). On every http.server.request.start, both listeners run, each sees server.emit !== ownMap.get(server), and re-wraps. The Proxy chain grows ~2 layers per request until emit('request') recurses past the stack limit.
Notably, the per-request mark already uses a cross-realm registry:
const kRequestMark = Symbol.for("sentry_http_server_instrumented");
…but the emit-dedup guard does not — even though @sentry/core already exposes a cross-realm singleton helper (getGlobalSingleton in carrier.js, anchored on globalThis.__SENTRY__[SDK_VERSION]).
Minimal reproduction
// repro.mjs — `npm i @sentry/node && node repro.mjs`
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import { EventEmitter } from 'node:events';
import dc from 'node:diagnostics_channel';
const require = createRequire(import.meta.url);
const opts = { dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', tracesSampleRate: 0, beforeSend: () => null };
// Realm A — CJS build (e.g. a NestJS/Express host)
const cjs = require('@sentry/node');
cjs.init(opts);
// Realm B — ESM build (e.g. a dynamically import()ed SSR bundle)
const pkg = require('@sentry/node/package.json');
const esmEntry = path.join(path.dirname(require.resolve('@sentry/node/package.json')), pkg.module);
const esm = await import(pathToFileURL(esmEntry).href);
esm.init(opts); // second init, in the ESM realm
const server = new EventEmitter();
const ch = dc.channel('http.server.request.start');
let prev = server.emit, rewraps = 0;
for (let i = 0; i < 9000; i++) { // simulate incoming requests
ch.publish({ server });
if (server.emit !== prev) { rewraps++; prev = server.emit; }
}
console.log('server.emit re-wraps:', rewraps); // → 9000 (grows every request)
server.emit('x'); // → RangeError: Maximum call stack size exceeded
Output:
server.emit re-wraps: 9000
<crash> RangeError: Maximum call stack size exceeded
With only a single realm initialized, rewraps === 1 (the guard works) and server.emit('x') is fine.
Suggested fix
Store the emit-dedup guard in the cross-realm carrier instead of a module-scoped WeakMap, so both builds consult one shared map (consistent with the already cross-realm Symbol.for(...) request mark):
import { getGlobalSingleton } from '../../carrier.js';
const lastSentryEmitMap = getGlobalSingleton('httpServerEmitMap', () => new WeakMap());
That makes the existing if (currentEmit === instrumentedEmit) return dedup effective across realms and prevents the unbounded re-wrap.
Workaround (for affected apps)
Ensure the node Http server instrumentation runs in only one realm per process — e.g. guard the secondary Sentry.init() with !getClient() so the embedded realm reuses the host's client, and keep all @sentry/* packages on a single version so the globalThis.__SENTRY__[SDK_VERSION] carrier (and thus getClient()) is shared across the CJS and ESM builds.
Summary
When
@sentry/coreis loaded in two module realms in the same process — once as CJS (require) and once as ESM (import) — the HTTP server instrumentation double-wrapsserver.emitand theProxychain grows on every request until it throwsRangeError: Maximum call stack size exceededon a live server.This happens in any setup where a CJS host process also loads an ESM bundle that initializes Sentry — e.g. a NestJS/Express server that
await import()s an ESM SSR bundle (React Router / Remix) which callsSentry.init(). The CJS side (@sentry/nestjs→@sentry/node) and the ESM side (@sentry/react-router→@sentry/node) each pull their respective build of@sentry/corevia the conditionalexportsmap (require→build/cjs,import→build/esm), so two physical copies of the instrumentation module run in one process.Environment
@sentry/core/@sentry/node/@sentry/node-core/@sentry/nestjs/@sentry/react-router: 10.59.0 (latest at time of writing)@sentry/core/build/cjs/integrations/http/server-subscription.jsand@sentry/core/build/esm/integrations/http/server-subscription.js.Root cause
integrations/http/server-subscription.jswrapsserver.emitin aProxyand dedups re-wraps with a module-scopedWeakMap:httpServerIntegration(@sentry/node-core/.../integrations/http/httpServerIntegration.js) subscribes to thehttp.server.request.startdiagnostics channel in each realm'ssetupOnce(). Because each build has its ownlastSentryEmitMap, the CJS guard never recognizes the ESM realm's wrapper (and vice-versa). On everyhttp.server.request.start, both listeners run, each seesserver.emit !== ownMap.get(server), and re-wraps. TheProxychain grows ~2 layers per request untilemit('request')recurses past the stack limit.Notably, the per-request mark already uses a cross-realm registry:
…but the emit-dedup guard does not — even though
@sentry/corealready exposes a cross-realm singleton helper (getGlobalSingletonincarrier.js, anchored onglobalThis.__SENTRY__[SDK_VERSION]).Minimal reproduction
Output:
With only a single realm initialized,
rewraps === 1(the guard works) andserver.emit('x')is fine.Suggested fix
Store the emit-dedup guard in the cross-realm carrier instead of a module-scoped
WeakMap, so both builds consult one shared map (consistent with the already cross-realmSymbol.for(...)request mark):That makes the existing
if (currentEmit === instrumentedEmit) returndedup effective across realms and prevents the unbounded re-wrap.Workaround (for affected apps)
Ensure the node
Httpserver instrumentation runs in only one realm per process — e.g. guard the secondarySentry.init()with!getClient()so the embedded realm reuses the host's client, and keep all@sentry/*packages on a single version so theglobalThis.__SENTRY__[SDK_VERSION]carrier (and thusgetClient()) is shared across the CJS and ESM builds.