Is there an existing issue for this?
How do you use Sentry?
Sentry Saas (sentry.io)
Which SDK are you using?
@sentry/node
SDK Version
11.1.0 (also 11.0.0; not present on 10.75.0)
Framework Version
Express 5.2.1 (router 2.x) and Express 4.22.3, Node 24.18.1
Link to Sentry event
N/A. The symptom is a Node process warning plus span timings, not a captured event.
Reproduction Example/SDK Setup
instrument.mjs
import * as Sentry from '@sentry/node';
export const spans = [];
Sentry.init({
dsn: 'https://public@o0.ingest.sentry.io/0',
tracesSampleRate: 1.0,
transport: () => ({ send: async () => ({}), flush: async () => true }),
beforeSendSpan(span) {
spans.push(span);
return span;
},
});
app.mjs
import http from 'node:http';
import express from 'express';
import { spans } from './instrument.mjs';
process.on('warning', warning => console.log(`${warning.name}: ${warning.message}`));
const app = express();
// Ten ordinary synchronous middleware (request id, helmet, cors, body parsers, loggers, ...)
for (let i = 0; i < 10; i++) {
app.use(function syncMiddleware(_req, _res, next) {
next();
});
}
app.get('/test', (_req, res) => {
console.log(`'finish' listeners on the response inside the handler: ${res.listenerCount('finish')}`);
const until = Date.now() + 50; // 50ms of synchronous work in the handler
while (Date.now() < until);
res.json({ ok: true });
});
const server = app.listen(0, () => {
http.get(`http://127.0.0.1:${server.address().port}/test`, res => {
res.resume().on('end', () => {
setTimeout(() => {
const first = spans.find(span => span.attributes?.['express.type'] === 'middleware');
const ms = (first.end_timestamp - first.start_timestamp) * 1000;
console.log(`duration of the first (no-op) middleware span: ${ms.toFixed(1)}ms`);
server.close();
}, 100);
});
});
});
Steps to Reproduce
npm i @sentry/node@11.1.0 express@5.2.1
node --import ./instrument.mjs app.mjs
Expected Result
Each layer's span ends, and its finish listener is removed, when the layer calls next(). That is how v10's OpenTelemetry-based Express instrumentation behaved. The number of finish listeners stays constant no matter how many layers a request passes through, and a middleware span measures only that middleware.
Actual Result
'finish' listeners on the response inside the handler: 12
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 finish listeners added to [ServerResponse]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
duration of the first (no-op) middleware span: 50.7ms
- Every request whose synchronous middleware/router chain is about 9 or more layers deep logs one
MaxListenersExceededWarning. In our production Express 5 API that is one warning every few seconds, which our platform surfaces as error-level logs.
- It also happens for unsampled requests:
tracesSampleRate: 0 produces the same warning.
- Express 4.22.3 is affected the same way (14 listeners in the same repro).
- The listeners are released when the response finishes, so this is not a real memory leak. The problems are the warning noise and inflated span durations.
Additional Context
Root cause
getSpanForLayer adds res.once('finish', onFinish) for every traced layer (instrumentation.ts L332-L338). The comment there expects the helper to end the span, and drop the listener, "when next is called".
bindTracingChannelToSpan actually ends the span on asyncEnd (tracing-channel.ts L197-L203). For the Callback kind, asyncEnd is published only after the callback returns.
- In Express,
next() runs the rest of the chain synchronously. It only returns after every downstream layer has run up to its first async hop. So every layer on that synchronous chain holds a live span and a finish listener at the same time: one per middleware and router, plus the route handler. That passes Node's default limit of 10.
- The same cause makes each middleware span include the synchronous work of every layer after it. In the repro, a no-op middleware reports 50.7ms because the handler does 50ms of synchronous work.
- The rest of the integration already treats
asyncStart as the moment next is called, before the downstream layer runs (instrumentation.ts L97-L108). The orchestrion config also says the transform "ends the traced operation when next is invoked" (config/express.ts L11-L13). Ending on asyncEnd therefore looks unintended.
Suggested fix
End the layer span in the existing asyncStart subscriber:
channel.subscribe({
start: NOOP,
asyncEnd: NOOP,
end: NOOP,
error: data => captureLayerError(data, options.shouldHandleError),
- asyncStart: popLayerPathForLayer,
+ asyncStart: data => {
+ popLayerPathForLayer(data);
+ endLayerSpan(data);
+ },
});
/** End a layer's span when it hands off via `next`, before the downstream layer runs. */
function endLayerSpan(data: HandleChannelContext): void {
const span = data._sentrySpan;
if (!span) {
return;
}
data._sentryCleanup?.();
span.end();
}
The helper's later asyncEnd then does nothing: removeListener on an already-removed listener is a no-op, and span.end() is idempotent. next(err) still marks the span as errored, because the channel publishes error before asyncStart.
I applied this change to the published 11.1.0 build and re-ran the repro:
'finish' listeners on the response inside the handler: 2
duration of the first (no-op) middleware span: 0.1ms
Other behaviour I checked with the patch, compared against the unpatched build:
- async middleware (
setTimeout(next, 20))
- fall-through routers mounted on the same prefix
next(err): the span status stays error
- transaction and route names (
GET /api/items/:id)
All are unchanged, apart from span durations and end order now being correct.
Workarounds until a fix ships:
expressIntegration({ ignoreLayersType: ['middleware', 'router'] })
- leaving
tracesSampleRate unset
PR
I'm happy to open a PR with this change and a node-integration-test.
Is there an existing issue for this?
How do you use Sentry?
Sentry Saas (sentry.io)
Which SDK are you using?
@sentry/node
SDK Version
11.1.0 (also 11.0.0; not present on 10.75.0)
Framework Version
Express 5.2.1 (
router2.x) and Express 4.22.3, Node 24.18.1Link to Sentry event
N/A. The symptom is a Node process warning plus span timings, not a captured event.
Reproduction Example/SDK Setup
instrument.mjsapp.mjsSteps to Reproduce
npm i @sentry/node@11.1.0 express@5.2.1node --import ./instrument.mjs app.mjsExpected Result
Each layer's span ends, and its
finishlistener is removed, when the layer callsnext(). That is how v10's OpenTelemetry-based Express instrumentation behaved. The number offinishlisteners stays constant no matter how many layers a request passes through, and a middleware span measures only that middleware.Actual Result
MaxListenersExceededWarning. In our production Express 5 API that is one warning every few seconds, which our platform surfaces as error-level logs.tracesSampleRate: 0produces the same warning.Additional Context
Root cause
getSpanForLayeraddsres.once('finish', onFinish)for every traced layer (instrumentation.ts L332-L338). The comment there expects the helper to end the span, and drop the listener, "whennextis called".bindTracingChannelToSpanactually ends the span onasyncEnd(tracing-channel.ts L197-L203). For theCallbackkind,asyncEndis published only after the callback returns.next()runs the rest of the chain synchronously. It only returns after every downstream layer has run up to its first async hop. So every layer on that synchronous chain holds a live span and afinishlistener at the same time: one per middleware and router, plus the route handler. That passes Node's default limit of 10.asyncStartas the momentnextis called, before the downstream layer runs (instrumentation.ts L97-L108). The orchestrion config also says the transform "ends the traced operation whennextis invoked" (config/express.ts L11-L13). Ending onasyncEndtherefore looks unintended.Suggested fix
End the layer span in the existing
asyncStartsubscriber:channel.subscribe({ start: NOOP, asyncEnd: NOOP, end: NOOP, error: data => captureLayerError(data, options.shouldHandleError), - asyncStart: popLayerPathForLayer, + asyncStart: data => { + popLayerPathForLayer(data); + endLayerSpan(data); + }, });The helper's later
asyncEndthen does nothing:removeListeneron an already-removed listener is a no-op, andspan.end()is idempotent.next(err)still marks the span as errored, because the channel publisheserrorbeforeasyncStart.I applied this change to the published 11.1.0 build and re-ran the repro:
Other behaviour I checked with the patch, compared against the unpatched build:
setTimeout(next, 20))next(err): the span status stayserrorGET /api/items/:id)All are unchanged, apart from span durations and end order now being correct.
Workarounds until a fix ships:
expressIntegration({ ignoreLayersType: ['middleware', 'router'] })tracesSampleRateunsetPR
I'm happy to open a PR with this change and a node-integration-test.