fix(profiling): abort profile upload on socket timeout#9217
Conversation
The agent profile exporter sets `options.timeout` on the upload request but never handled the request's 'timeout' event. Per the Node http docs the timeout event is informational only — it does not abort the socket — so a stalled upload (e.g. a connection that hangs on a loaded/shared host's network stack) never emitted 'error', the retry/backoff in AgentExporter.export never ran, and the export promise never settled. The worker process then stays alive on its shutdown flush until the container is force-killed, and the profile is lost. Handle 'timeout' by destroying the request, which surfaces as an 'error' and drives the existing retry/backoff — matching the trace exporter in exporters/common/request.js, which already aborts on timeout. Also guard the request callback so it runs at most once: the timeout can fire after a response was already received (e.g. on a lingering keep-alive socket once it goes idle), which would otherwise invoke the callback twice and corrupt the retry accounting. Adds a regression test asserting the export rejects (after retrying) instead of hanging when the agent never responds. Also bumps the uploadTimeout in the existing backoff test so its (now enforced) per-attempt socket timeout doesn't race the in-process test agent's response. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Overall package sizeSelf size: 6.53 MB Dependency sizes| name | version | self size | total size | |------|---------|-----------|------------| | import-in-the-middle | 3.3.0 | 117.14 kB | 432.47 kB | | opentracing | 0.14.7 | 194.81 kB | 194.81 kB | | dc-polyfill | 0.1.11 | 25.74 kB | 25.74 kB |🤖 This report was automatically generated by heaviest-objects-in-the-universe |
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #9217 +/- ##
==========================================
- Coverage 93.65% 93.65% -0.01%
==========================================
Files 899 899
Lines 52473 52480 +7
Branches 12352 12353 +1
==========================================
+ Hits 49145 49151 +6
- Misses 3328 3329 +1 Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
BenchmarksBenchmark execution time: 2026-07-06 12:32:59 Comparing candidate commit 47037f5 in PR branch Found 0 performance improvements and 0 performance regressions! Performance is the same for 2252 metrics, 34 unstable metrics.
|
The agent profile exporter sets `options.timeout` on the upload request but never handled the request's 'timeout' event. Per the Node http docs the timeout event is informational only — it does not abort the socket — so a stalled upload (e.g. a connection that hangs on a loaded/shared host's network stack) never emitted 'error', the retry/backoff in AgentExporter.export never ran, and the export promise never settled. The worker process then stays alive on its shutdown flush until the container is force-killed, and the profile is lost. Handle 'timeout' by destroying the request, which surfaces as an 'error' and drives the existing retry/backoff — matching the trace exporter in exporters/common/request.js, which already aborts on timeout. Also guard the request callback so it runs at most once: the timeout can fire after a response was already received (e.g. on a lingering keep-alive socket once it goes idle), which would otherwise invoke the callback twice and corrupt the retry accounting. Adds a regression test asserting the export rejects (after retrying) instead of hanging when the agent never responds. Also bumps the uploadTimeout in the existing backoff test so its (now enforced) per-attempt socket timeout doesn't race the in-process test agent's response.
The agent profile exporter sets `options.timeout` on the upload request but never handled the request's 'timeout' event. Per the Node http docs the timeout event is informational only — it does not abort the socket — so a stalled upload (e.g. a connection that hangs on a loaded/shared host's network stack) never emitted 'error', the retry/backoff in AgentExporter.export never ran, and the export promise never settled. The worker process then stays alive on its shutdown flush until the container is force-killed, and the profile is lost. Handle 'timeout' by destroying the request, which surfaces as an 'error' and drives the existing retry/backoff — matching the trace exporter in exporters/common/request.js, which already aborts on timeout. Also guard the request callback so it runs at most once: the timeout can fire after a response was already received (e.g. on a lingering keep-alive socket once it goes idle), which would otherwise invoke the callback twice and corrupt the retry accounting. Adds a regression test asserting the export rejects (after retrying) instead of hanging when the agent never responds. Also bumps the uploadTimeout in the existing backoff test so its (now enforced) per-attempt socket timeout doesn't race the in-process test agent's response.
What does this PR do?
Makes the profiling agent exporter enforce its upload timeout by destroying the request on the socket
'timeout'event, and guards the request callback so it runs at most once.Motivation
The exporter sets
options.timeouton the upload request (this.#backoffTime * 2 ** attempt) but never registered a'timeout'handler. Per the Node http docs the'timeout'event is informational only — it does not abort the socket. So a stalled upload (a connection that hangs on a loaded/shared host's network stack, even to a local agent) never emitted'error': the retry/backoff inAgentExporter.exportnever ran and the returned promise never settled.In practice this surfaced as a flaky/hung profile upload on shutdown: a worker's final
beforeExitflush would block indefinitely on the stalled request, keeping the process alive until it was force-killed and losing that profile. The trace exporter (exporters/common/request.js) already aborts on timeout; only the profiling exporter was missing the equivalent.This essentially brings the behavior in line with how the trace exporter behaves.
NB: Node.js profiler is the only profiler that bothers with retries; other language profilers nonchalantly drop the profile if the first communication attempt with the agent fails. We could rethink if we want to go in that direction, but for now fixing the bug in existing retrying behavior feels like a good incremental fix.
Changes
req.on('timeout', () => req.destroy(...))insendRequest— the destroy surfaces as an'error', which drives the existing retry/backoff (and eventual rejection), instead of hanging forever.'timeout'can fire after a response was already received (e.g. on a lingering keep-alive socket once it goes idle); without the guard that invokes the callback a second time and corrupts the retry accounting.uploadTimeoutwas unrealistically small (50ms first attempt), which — now that the timeout is actually enforced — raced the in-process test agent's response. Bumped it so the HTTP-error retries it means to exercise aren't pre-empted by a socket timeout.Testing
packages/dd-trace/test/profiling/exporters/agent.spec.js: 8 passing, ESLint clean; the new timeout test and the backoff test are stable across repeated runs.Jira: PROF-15326