Skip to content

expressIntegration: layer spans end when next() returns instead of when it is called, piling up finish listeners (MaxListenersExceededWarning) and inflating middleware span durations #24853

Description

@diobriggs

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

  1. npm i @sentry/node@11.1.0 express@5.2.1
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions