Skip to content

@sentry/core loaded as both CJS and ESM double-instruments server.emit → RangeError: Maximum call stack size exceeded #21696

Description

@ak125

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 (requirebuild/cjs, importbuild/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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugNode.jsjavascriptPull requests that update javascript code
    No fields configured for issues without a type.

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions