Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

node:http client span never ends when the response is destroyed without an error (res.destroy())

オープン 初心者向け
#25,469 コメント 2 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@4ktLuffy がすでに取り組んでいます。

2026年10月19日 から。

  • #25503 @4ktLuffy による — オープン

評価

難易度
2/5
見積もり時間
1〜3時間
初心者へのやさしさ
78/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
node.js, typescript

調査の方向性

packages/core/src/integrations/http/client-subscriptions.tsを読み、特にfinishWithResponse周辺のレスポンスリスナーを確認して、既存のHTTPクライアントテストと比較してください。issueにある3つのレスポンスケースを再現し、各spanが終了すること、未完了のレスポンスにはerrorステータスが設定され、完了したレスポンスとエラーが発生したレスポンスはそれぞれ想定されたステータスを維持することを確認してください。

索引モデルが issue の本文から書いたものです。

説明

Bug Node.js
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.8.0 (latest at the time of writing)

Framework Version

Node v25.8.0, plain node:http, no framework

Link to Sentry event

n/a, local repro (the DSN points at a local sink on 127.0.0.1)

Reproduction Example/SDK Setup

Self-contained, only needs npm i @sentry/node. It starts a local server that sends headers and
then streams a body, and a local DSN sink that collects the envelopes.

// Repro: http.client span is never ended when the response is destroyed mid-body.
// Usage: npm i @sentry/node && node nodehttp-repro.mjs
// Everything is local (127.0.0.1); no real DSN is used.
import * as Sentry from '@sentry/node';
import http from 'node:http';

// Local DSN sink: collects envelopes so we can see which spans were actually sent.
const sentSpans = new Map(); // url.full -> status, for http.client spans that reached the sink
const collect = o => {
  if (!o || typeof o !== 'object') return;
  const a = o.attributes || {};
  const url = a['url.full']?.value ?? a['url.full'] ?? o.data?.['url.full'];
  const op = a['sentry.op']?.value ?? a['sentry.op'] ?? o.op;
  if (op === 'http.client' && url) sentSpans.set(url, o.status);
  for (const v of Object.values(o)) if (v && typeof v === 'object') collect(v);
};
const sink = http.createServer((req, res) => {
  let body = '';
  req.on('data', c => (body += c));
  req.on('end', () => {
    for (const line of body.split('\n')) { try { collect(JSON.parse(line)); } catch {} }
    res.end('{}');
  });
});
await new Promise(r => sink.listen(0, '127.0.0.1', r));

Sentry.init({
  dsn: `http://[email protected]:${sink.address().port}/1`,
  tracesSampleRate: 1,
  // do not trace the sink itself
  ignoreOutgoingRequests: url => url.includes(`:${sink.address().port}`),
});

const ended = new Map(); // url.full -> status
Sentry.getClient().on('spanEnd', span => {
  const j = Sentry.spanToJSON(span);
  const url = j.attributes?.['url.full'] ?? j.data?.['url.full'];
  const op = j.attributes?.['sentry.op'] ?? j.op;
  if (op === 'http.client' && url) ended.set(url, j.status);
});

// Server that streams a slow body (headers sent, body never finishes on its own).
const srv = http.createServer((req, res) => {
  res.writeHead(200);
  res.write('first chunk');
  if (req.url.startsWith('/full')) setTimeout(() => res.end('rest'), 50);
});
await new Promise(r => srv.listen(0, '127.0.0.1', r));
const port = srv.address().port;

const cases = {
  full: res => { res.on('data', () => {}); },
  'res.destroy()': res => { res.once('data', () => res.destroy()); },
  'res.destroy(new Error())': res => { res.once('data', () => res.destroy(new Error('boom'))); },
};

await Sentry.startSpan({ name: 'root' }, async () => {
  for (const [name, handle] of Object.entries(cases)) {
    await new Promise(resolve => {
      const path = `/${name === 'full' ? 'full' : 'slow'}?case=${encodeURIComponent(name)}`;
      const req = http.get({ host: '127.0.0.1', port, path }, res => {
        handle(res);
        res.on('close', () => setTimeout(resolve, 300));
        res.on('error', () => {});
      });
      req.on('error', () => {});
    });
  }
});

await Sentry.flush(2000);
for (const name of Object.keys(cases)) {
  const path = `/${name === 'full' ? 'full' : 'slow'}?case=${encodeURIComponent(name)}`;
  const url = `http://127.0.0.1:${port}${path}`;
  console.log(
    name.padEnd(26),
    '| spanEnd fired:', ended.has(url) ? `yes (status=${ended.get(url)})` : 'NO',
    '| span in envelope:', sentSpans.has(url) ? `yes (status=${sentSpans.get(url)})` : 'NO',
  );
}
srv.closeAllConnections();
srv.close();
sink.close();
setTimeout(() => process.exit(0), 100);
Steps to Reproduce
  1. npm i @sentry/[email protected]
  2. Save the script above as repro.mjs and run node repro.mjs.
  3. Look at the three http.client spans: one for a fully read response, one where the client calls
    res.destroy() after the first chunk, and one where it calls res.destroy(new Error('boom')).
Expected Result

All three http.client spans end. A response that was cut off before the end should end the span
with an error status (the code already does this for an aborted response, see response.aborted
in finishWithResponse).

Actual Result
full                       | spanEnd fired: yes (status=ok) | span in envelope: yes (status=ok)
res.destroy()              | spanEnd fired: NO | span in envelope: NO
res.destroy(new Error())   | spanEnd fired: yes (status=error) | span in envelope: yes (status=error)

With res.destroy() the span never ends, so it is never sent.

I also checked the other ways to cut a request short after the response headers arrived. These
all end the span correctly: req.destroy() (error), req.destroy(new Error()) (error),
AbortController.abort() on http.request after the headers (ok, per #25260), and abort before
the response (ok).

Additional Context

Cause, in packages/core/src/integrations/http/client-subscriptions.ts on develop:

  • L143: the request close fallback (requestOnClose) is what ends the span if there is no response.
  • L168: as soon as a response arrives, that fallback is removed (request.removeListener('close', requestOnClose)),
    because the span should now end with the response.
  • L197-198: the only things that can end it after that are response.on('end', ...) and
    response.on(errorMonitor, ...).

res.destroy() without an argument emits neither end nor error on the response, only close
(and aborted). So nothing is left that ends the span. With res.destroy(err) the response emits
error, which is why that case works. The aborted check at L187 is only reached through those
two listeners, so it never sees this case.

This is the same kind of gap that #25260 (http client, abort is not an error) and #25129 (fetch) fixed
for aborts by the caller, but those only change the status of spans that already end. I could not find
an existing issue or PR for the span not ending at all. I searched open and closed issues and PRs for
http client span, destroy, aborted and leak. The closest are #25183 and #25209, which are the same
symptom for the Express request-handler span.

A fix would be to also end the span on the response close event (a no-op if end or error already
fired), with an error status when the response is incomplete. I have a patch with tests and can open a PR
if that direction is fine.

主要言語
TypeScript
スター
8.8k
フォーク
1.9k
平均マージ
1日 11時間
マージ済み PR(30日)
613

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

getsentry/sentry-javascript のほかの issue

getsentry/sentry-javascript の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。