node:http client span never ends when the response is destroyed without an error (res.destroy())
維護者通常 1 天內回覆
還沒有人認領這個 Issue。
評估
- 難度
- 2/5
- 預估耗時
- 1-3 小時
- 新手友好度
- 78/100
- Issue 類型
- 缺陷
- 描述清晰度
- 描述清楚
- 活躍度
- 活躍
- 技術堆疊
- node.js, typescript
- 領域
- backend, observability
研究方向
閱讀 packages/core/src/integrations/http/client-subscriptions.ts,特別檢視 finishWithResponse 周圍的回應監聽器,並與現有的 HTTP 用戶端測試比較。重現 issue 中的三個回應案例,接著檢查每個 span 是否結束,以及未完成的回應是否取得 error 狀態,同時已完成和發生錯誤的回應是否保留各自預期的狀態。
由索引模型根據 Issue 內容生成。
描述
Is there an existing issue for this?
- I have checked for existing issues https://github.com/getsentry/sentry-javascript/issues
- I have reviewed the documentation https://docs.sentry.io/
- I am using the latest SDK release https://github.com/getsentry/sentry-javascript/releases
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
npm i @sentry/[email protected]- Save the script above as
repro.mjsand runnode repro.mjs. - Look at the three
http.clientspans: one for a fully read response, one where the client calls
res.destroy()after the first chunk, and one where it callsres.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
closefallback (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 天 10 小時
- 30 天內合併 PR
- 608
環境準備
- 沒有 Dockerfile 或 Docker Compose 檔案
- 有 Pull Request 範本
- 閱讀貢獻指南
從這裡開始
- 先讀完整個 Issue,再讀專案的貢獻指南。
- 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
- Fork 儲存庫,在一個分支上完成修改。
- 送出 Pull Request,並在描述裡引用這個 Issue 編號。
getsentry/sentry-javascript 的其他 Issue
-
Core Feature: Typescript Meta: Breaking
難度 2/5 1-3 小時 新手友好度 65/100
getsentry/sentry-javascript#25286 · 2 則留言 ·
維護者通常 1 天內回覆
-
難度 2/5 1-3 小時 新手友好度 80/100
getsentry/sentry-javascript#24860 · 1 則留言 ·
維護者通常 1 天內回覆
-
javascript
難度 2/5 1-3 小時 新手友好度 75/100
getsentry/sentry-javascript#24200 · 2 則留言 ·
維護者通常 1 天內回覆
-
Cloudflare Workers javascript Tests
難度 2/5 1-3 小時 新手友好度 78/100
getsentry/sentry-javascript#24051 · 1 則留言 ·
維護者通常 1 天內回覆
-
Elysia events report as `sentry.javascript.bun`可能已有人在做 @Shubham-Padkonde 於 28 天前認領。 未關閉Bug Bun javascript
難度 2/5 1-3 小時 新手友好度 92/100
getsentry/sentry-javascript#24045 · 1 則留言 ·
維護者通常 1 天內回覆
查看 getsentry/sentry-javascript 的全部 Issue
相似的 Issue
-
難度 1/5 1 小時以內 新手友好度 83/100
-
難度 2/5 1-3 小時 新手友好度 72/100
ranxianglei/billion-context#3583 ·
維護者通常 1 天內回覆
-
bug:new
難度 2/5 1-3 小時 新手友好度 76/100
callstackincubator/simlock#673 ·
維護者通常 1 天內回覆
-
documentation v3
難度 2/5 1-3 小時 新手友好度 73/100
維護者通常 1 天內回覆
-
難度 2/5 1-3 小時 新手友好度 73/100
anomalyco/opencode#56380 · 1 則留言 ·
維護者通常 1 天內回覆