Skip to content

fix(ddtrace/tracer): reduce UDS connection resets under agent backpressure#4484

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 7 commits into
mainfrom
kakkoyun/apms-18682
Mar 3, 2026
Merged

fix(ddtrace/tracer): reduce UDS connection resets under agent backpressure#4484
gh-worker-dd-mergequeue-cf854d[bot] merged 7 commits into
mainfrom
kakkoyun/apms-18682

Conversation

@kakkoyun

@kakkoyun kakkoyun commented Mar 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Set MaxIdleConnsPerHost to match MaxIdleConns (100) in the UDS HTTP
    transport, eliminating connection churn when multiple flush goroutines
    are active.
  • Add retry logic to stats payload sending (flushAndSend), matching the
    existing retry pattern used by trace sending. Stats payloads were
    previously dropped permanently on a single transport error.

Motivation

Customers using UDS transport under high concurrency see connection reset by peer and broken pipe errors. The root cause is agent-side decoder
backpressure (429 responses), but two tracer-side issues amplify the impact:
the connection pool was effectively limited to 2 idle connections despite
100 concurrent flush goroutines, and stats payloads had no retry path.

Test plan

  • go test -race ./internal/ -run TestUDS — verifies transport config
  • go test -race ./ddtrace/tracer/ -run TestStatsFlushRetries — verifies retry behavior (9 cases)
  • go test -race ./ddtrace/tracer/ -run TestConcentrator — existing stats tests still pass

kakkoyun added 2 commits March 2, 2026 15:32
…lient

Go's http.Transport defaults MaxIdleConnsPerHost to 2. Since all UDS
requests target a single synthetic hostname, this cap was the binding
limit — connections beyond 2 were closed immediately after use. Under
concurrent flush goroutines (up to 100), this caused constant connection
churn and increased exposure to stale-connection resets after agent
restarts.

Set MaxIdleConnsPerHost to 100 to match MaxIdleConns, allowing the full
connection pool to be reused.
Stats payloads sent via flushAndSend() had no retry mechanism — a single
transport error permanently dropped the payload. Trace sending already
supports configurable retries via sendRetries/RetryInterval, but the
stats path was missing this.

Add the same retry loop used by agentTraceWriter.flush() so stats
sending respects the existing WithSendRetries()/WithRetryInterval()
configuration.
@kakkoyun kakkoyun added the AI Assisted AI/LLM assistance used in this PR (partially or fully) label Mar 2, 2026
@pr-commenter

pr-commenter Bot commented Mar 2, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-03-03 15:38:09

Comparing candidate commit c3b95ce in PR branch kakkoyun/apms-18682 with baseline commit e53622b in branch main.

Found 1 performance improvements and 1 performance regressions! Performance is the same for 154 metrics, 8 unstable metrics.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

scenario:BenchmarkParallelMetrics/count/handle-reused-25

  • 🟩 execution_time [-10.509ns; -2.167ns] or [-12.582%; -2.594%]

scenario:BenchmarkSetTagMetric-25

  • 🟥 execution_time [+1.712ns; +2.306ns] or [+2.887%; +3.888%]

kakkoyun added 2 commits March 2, 2026 16:01
Add behavioral tests that exercise the UDS connection pool under
concurrent load, verifying that connections are reused rather than
churned and that the client recovers from server-side connection closes.

These tests validate the MaxIdleConnsPerHost fix under realistic
conditions rather than just asserting config values.
Signed-off-by: Kemal Akkoyun <[email protected]>
@kakkoyun
kakkoyun requested a review from darccio March 2, 2026 15:07
@codecov

codecov Bot commented Mar 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 55.94%. Comparing base (87126fd) to head (d5348b0).

Additional details and impacted files
Files with missing lines Coverage Δ
ddtrace/tracer/span.go 85.36% <100.00%> (-3.26%) ⬇️
ddtrace/tracer/stats.go 98.49% <100.00%> (-0.72%) ⬇️
internal/uds.go 100.00% <ø> (+71.42%) ⬆️

... and 196 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@darccio

darccio commented Mar 2, 2026

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@darccio
darccio marked this pull request as ready for review March 2, 2026 16:04
@darccio
darccio requested review from a team as code owners March 2, 2026 16:04
Comment thread ddtrace/tracer/span.go
@darccio

darccio commented Mar 2, 2026

Copy link
Copy Markdown
Member

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Mar 2, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-03-02 17:05:01 UTC ℹ️ Start processing command /merge


2026-03-02 17:05:10 UTC ℹ️ MergeQueue: waiting for PR to be ready

This pull request is not mergeable according to GitHub. Common reasons include pending required checks, missing approvals, or merge conflicts — but it could also be blocked by other repository rules or settings.
It will be added to the queue as soon as checks pass and/or get approvals. View in MergeQueue UI.
Note: if you pushed new commits since the last approval, you may need additional approval.
You can remove it from the waiting list with /remove command.


2026-03-02 17:09:20 UTC ℹ️ MergeQueue: merge request added to the queue

The expected merge time in main is approximately 29m (p90).


2026-03-02 17:23:26 UTCMergeQueue: The checks failed on this merge request

Tests failed on this commit c13425e:

What to do next?

  • Investigate the failures and when ready, re-add your pull request to the queue!
  • If your PR checks are green, try to rebase/merge. It might be because the CI run is a bit old.
  • Any question, go check the FAQ.

@darccio

darccio commented Mar 2, 2026

Copy link
Copy Markdown
Member

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Mar 2, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-03-02 20:45:07 UTC ℹ️ Start processing command /merge


2026-03-02 20:45:12 UTC ℹ️ MergeQueue: pull request added to the queue

The expected merge time in main is approximately 29m (p90).


2026-03-02 20:59:17 UTCMergeQueue: The checks failed on this merge request

Tests failed on this commit d4d0e3e:

What to do next?

  • Investigate the failures and when ready, re-add your pull request to the queue!
  • If your PR checks are green, try to rebase/merge. It might be because the CI run is a bit old.
  • Any question, go check the FAQ.

@darccio

darccio commented Mar 3, 2026

Copy link
Copy Markdown
Member

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Mar 3, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-03-03 10:37:01 UTC ℹ️ Start processing command /merge


2026-03-03 10:37:09 UTC ℹ️ MergeQueue: waiting for PR to be ready

This pull request is not mergeable according to GitHub. Common reasons include pending required checks, missing approvals, or merge conflicts — but it could also be blocked by other repository rules or settings.
It will be added to the queue as soon as checks pass and/or get approvals. View in MergeQueue UI.
Note: if you pushed new commits since the last approval, you may need additional approval.
You can remove it from the waiting list with /remove command.


2026-03-03 10:48:15 UTC ℹ️ MergeQueue: merge request added to the queue

The expected merge time in main is approximately 29m (p90).


2026-03-03 11:03:25 UTCMergeQueue: The checks failed on this merge request

Tests failed on this commit 74128e3:

What to do next?

  • Investigate the failures and when ready, re-add your pull request to the queue!
  • If your PR checks are green, try to rebase/merge. It might be because the CI run is a bit old.
  • Any question, go check the FAQ.

@darccio

darccio commented Mar 3, 2026

Copy link
Copy Markdown
Member

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Mar 3, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-03-03 15:18:15 UTC ℹ️ Start processing command /merge


2026-03-03 15:18:22 UTC ℹ️ MergeQueue: waiting for PR to be ready

This pull request is not mergeable according to GitHub. Common reasons include pending required checks, missing approvals, or merge conflicts — but it could also be blocked by other repository rules or settings.
It will be added to the queue as soon as checks pass and/or get approvals. View in MergeQueue UI.
Note: if you pushed new commits since the last approval, you may need additional approval.
You can remove it from the waiting list with /remove command.


2026-03-03 15:42:14 UTC ℹ️ MergeQueue: merge request added to the queue

The expected merge time in main is approximately 29m (p90).


2026-03-03 15:58:39 UTC ℹ️ MergeQueue: This merge request was merged

@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot merged commit 5793394 into main Mar 3, 2026
209 checks passed
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot deleted the kakkoyun/apms-18682 branch March 3, 2026 15:58
kakkoyun added a commit that referenced this pull request May 27, 2026
DoorDash (APMS-19533) reports persistent UDS transport errors after the
trace-agent silently drops idle keep-alive connections under backpressure
- either "write: broken pipe" or "read: connection reset by peer" on the
next request that reuses a stale conn from the client pool. PR #4484
added retry loops to traces and stats, but both are gated on
sendRetries (default 0), so customers who don't explicitly opt in see
no retries.

Two layers of recovery, neither requires user configuration:

1. Mark POSTs replayable. Set Idempotency-Key on the trace and stats
   requests and set req.GetBody on the trace request (stats is
   *bytes.Buffer, which http.NewRequest already handles). Together
   these let net/http.persistConn.shouldRetryRequest treat the POST as
   idempotent and transparently retry on a fresh connection - covering
   the common errServerClosedIdle and read-after-write failures. See
   golang/go#19943.

2. Catch the residual mid-write window. stdlib still refuses to retry
   once any byte has been written, even with Idempotency-Key, because
   the error no longer classifies as nothingWrittenError. A small
   doWithStaleConnRetry helper handles that case: up to two extra
   application-level retries on EPIPE / ECONNRESET / net.ErrClosed,
   rewinding the body via req.GetBody. The agent dedupes traces by
   span ID and ignores Idempotency-Key, so a duplicate is harmless.

Also fixes a Go-precedence bug at writer.go:178 where
attempt+1%5 == 0 parsed as attempt + 1, so the periodic
"failure sending traces" log line never fired.

Verification: TestUDSTransportRecoversFromStaleIdleConn reproduces the
customer's failure on baseline (~30-45% of 400 concurrent POSTs fail
with the same error strings the customer reports) and passes 10/10
times with the fix applied.

Refs: APMS-19533
Refs: #4484

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
kakkoyun added a commit that referenced this pull request Jun 5, 2026
DoorDash (APMS-19533) reports persistent UDS transport errors after the
trace-agent silently drops idle keep-alive connections under backpressure
- either "write: broken pipe" or "read: connection reset by peer" on the
next request that reuses a stale conn from the client pool. PR #4484
added retry loops to traces and stats, but both are gated on
sendRetries (default 0), so customers who don't explicitly opt in see
no retries.

Two layers of recovery, neither requires user configuration:

1. Mark POSTs replayable. Set Idempotency-Key on the trace and stats
   requests and set req.GetBody on the trace request (stats is
   *bytes.Buffer, which http.NewRequest already handles). Together
   these let net/http.persistConn.shouldRetryRequest treat the POST as
   idempotent and transparently retry on a fresh connection - covering
   the common errServerClosedIdle and read-after-write failures. See
   golang/go#19943.

2. Catch the residual mid-write window. stdlib still refuses to retry
   once any byte has been written, even with Idempotency-Key, because
   the error no longer classifies as nothingWrittenError. A small
   doWithStaleConnRetry helper handles that case: up to two extra
   application-level retries on EPIPE / ECONNRESET / net.ErrClosed,
   rewinding the body via req.GetBody. The agent dedupes traces by
   span ID and ignores Idempotency-Key, so a duplicate is harmless.

Also fixes a Go-precedence bug at writer.go:178 where
attempt+1%5 == 0 parsed as attempt + 1, so the periodic
"failure sending traces" log line never fired.

Verification: TestUDSTransportRecoversFromStaleIdleConn reproduces the
customer's failure on baseline (~30-45% of 400 concurrent POSTs fail
with the same error strings the customer reports) and passes 10/10
times with the fix applied.

Refs: APMS-19533
Refs: #4484
kakkoyun added a commit that referenced this pull request Jun 12, 2026
The trace-agent recycles idle keep-alive UDS connections under backpressure.
The tracer's next request on a pooled-but-dead connection fails immediately
with `write: broken pipe` or `read: connection reset by peer`, dropping the
payload. Prior fix #4484 added retry loops gated on sendRetries (default 0),
so customers without WithSendRetries got no retry at all.

Fix — two layers:

1. Make each POST replayable so net/http transparently retries on a fresh
   connection before the error reaches the caller:
   - Set req.GetBody (http.NewRequest doesn't auto-detect the custom payload
     type) so the stdlib can rewind the body on retry.
   - Set an Idempotency-Key header so net/http.Request.isReplayable returns
     true and the stdlib's nothingWrittenError path silently retries.
   - Return the payload directly from GetBody (not via io.NopCloser) so
     payloadV1.Close() is called by the HTTP transport, completing the
     two-bit pool-return handoff (pv1StateBodyClosed via atomic.Or).

2. Add doWithStaleConnRetry: up to 3 extra attempts (4 total) on transient
   connection errors (EPIPE, ECONNRESET, ECONNABORTED, net.ErrClosed, plus
   a cross-platform string fallback for Windows WSA errors). Covers the
   residual mid-write window where stdlib refuses to replay because
   pc.nwrite > startBytesWritten (golang/go#19943).

Retrying is safe: the agent deduplicates traces by span ID and ignores the
Idempotency-Key header, so a duplicate payload is harmless.

Also adds internal/apps/staleidle-soak/: an end-to-end verification harness
that runs the fix against agent v7.77.1 over a real UDS socket. Scenarios
B.2/B.3 (stale-conn injection) show baseline dropping ~250+ traces per run;
patched delivers 0 dropped across 1.5M+ spans. B.4 (hung agent / timeout)
is a boundary scenario that both baseline and patched drop, documenting the
fix targets connection teardown, not agent unresponsiveness.
gh-worker-dd-mergequeue-cf854d Bot pushed a commit that referenced this pull request Jun 12, 2026
## Summary

- Make trace/stats POSTs replayable so `net/http` can transparently recover from idle UDS connections silently dropped by the agent: set `req.GetBody` (custom payload type that `http.NewRequest` doesn't auto-detect) and an `Idempotency-Key` header on both `httpTransport.send` and `httpTransport.sendStats`.
- Add a small `doWithStaleConnRetry` (up to 3 extra attempts (4 total)) on `EPIPE` / `ECONNRESET` / `net.ErrClosed` to cover the residual mid-write window where stdlib refuses to retry — because once any byte hits the wire, the error is no longer classified as `nothingWrittenError` (see [golang/go#19943](golang/go#19943)).
- Fix a Go-precedence bug at `writer.go:178` — `attempt+1%5 == 0` parsed as `attempt + 1`, so the periodic "failure sending traces" log never fired.
- New verification harness under `internal/apps/staleidle-soak/` that runs the fix end-to-end against the customer's exact agent version (`datadog/agent:7.77.1`) over a real UDS socket.

## Motivation

Following up on #4484, ([APMS-19533](https://datadoghq.atlassian.net/browse/APMS-19533)) still saw transport errors after upgrading to `v2.8.2`:

```
v2.8.2 ERROR: lost 2 traces: ... write unix @->/var/run/datadog/apm.socket: write: broken pipe
v2.8.2 ERROR: Error sending stats payload: ... read unix @->/var/run/datadog/apm.socket: read: connection reset by peer
```

PR #4484 added retry loops to `writer.go` and `stats.go`, but both are gated on `sendRetries` (default `0`). Customers who don't explicitly opt in get the loop budget of 1 attempt — i.e. no retry — so any stale-idle race drops a payload.

The fundamental issue: stdlib won't auto-replay a POST after a transport error unless it can both rewind the body and trust the call is idempotent. Without `GetBody` (custom payload) and without `Idempotency-Key`, `net/http.Request.isReplayable` returns `false` and stdlib silently swallows the recovery path.

## Why two layers

Empirically (see `TestUDSTransportRecoversFromStaleIdleConn`), `Idempotency-Key + GetBody` alone fixes ~80% of the failures. The remaining ~20% are `write: broken pipe` mid-request: stdlib sees `pc.nwrite > startBytesWritten` and won't classify it as `nothingWrittenError`, so it refuses to retry even though `isReplayable()` would now return true. The application-level retry covers that residual window.

Retrying is safe: the agent dedupes traces by span ID and ignores `Idempotency-Key`, so a duplicate payload is harmless. We only retry on the narrow set of errors that unambiguously signal a peer-side connection teardown.

## Test plan

### Layer A — unit test against synthetic close-every-conn server

- [x] `TestUDSTransportRecoversFromStaleIdleConn` (new) — UDS server that abruptly closes conns after responding (no `Connection: close` header), 400 concurrent POSTs across `send` + `sendStats`. On `main`: 30–45% of requests fail with the exact error strings the customer reports. With this PR: 10/10 runs green.
- [x] `go test -race -run "^(TestStatsFlushRetries|TestTraceWriter|TestApi|TestUDS|TestTraceCountHeader|TestDefaultHeaders|TestExternalEnvironment|TestFetchAgentFeaturesContainerTagsHash|TestWithUDS|TestWithHTTPClient|TestResolveAgentAddr|TestTransportResponse)$" ./ddtrace/tracer/` — all green.
- [x] `go build ./ddtrace/tracer/...` + `go vet ./ddtrace/tracer/...` — clean.

### Layer B — end-to-end harness against `datadog/agent:7.77.1` over real UDS

New under `internal/apps/staleidle-soak/`: a docker-compose'd agent pinned to the customer's version, a Go load driver, a UDS pass-through proxy that injects failure, and an orchestrator script that runs four scenarios on both branches for A/B comparison. B.1–B.3 are pass/fail; B.4 is a **boundary** scenario that documents the edge of what the fix covers.

| Scenario  | Side     | spans_created | flush_traces | traces_dropped | lost_trace_log |
|-----------|----------|---------------|--------------|----------------|----------------|
| B.1 healthy steady-state  | baseline | 299 980  | 299 980  | 0   | 0 |
| B.1 healthy steady-state  | **patched** | 299 979  | 299 979  | **0**   | **0** |
| B.2 stale-conn (60s)      | baseline | 300 000  | 299 700  | 300 | 1 |
| B.2 stale-conn (60s)      | **patched** | 299 973  | 299 973  | **0**   | **0** |
| B.3 stale-conn (5 min)    | baseline | 1 499 439 | 1 499 189 | 250 | 1 |
| B.3 stale-conn (5 min)    | **patched** | 1 499 951 | 1 499 951 | **0**   | **0** |
| B.4 **hung agent** (60s)  | baseline | 299 957 | 0 | 299 957 | 2 |
| B.4 **hung agent** (60s)  | patched | 299 950 | 0 | 299 950 | 2 |

**B.2 / B.3** inject the customer's failure mode — the agent silently closes idle keep-alive UDS conns (proxy closes each conn after one response). They reproduce the customer's exact log shape:

```
Datadog Tracer v2.10.0-dev ERROR: lost 250 traces: Post "http://UDS__tmp_staleidle-proxy_apm.socket/v0.4/traces": EOF
```

With the patch applied, every span across 1.5 M+ creations on the longest soak is delivered (`spans_created == flush_traces`), with zero error-log lines and zero `api_errors`.

**B.4** is deliberately *not* a pass/fail test. The proxy (`--mode hang`) accepts the request but never responds, simulating a hung/unresponsive agent. The tracer times out (`context deadline exceeded`) — a non-teardown error the fix intentionally does **not** retry (retrying against a wedged agent only stacks load). B.4 therefore drops on *both* baseline and patched, which is the point: it documents that the fix targets connection teardown (the customer's observed error family) and **not** agent unresponsiveness. The customer's reported errors (APMS-19533) are all teardown; none are timeouts.

The harness is checked in so anyone can re-run the same verification (see `internal/apps/staleidle-soak/README.md`).

## Refs

- APMS-19533
- #4484

[APMS-19533]: https://datadoghq.atlassian.net/browse/APMS-19533?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ



Co-authored-by: kemal.akkoyun <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI Assisted AI/LLM assistance used in this PR (partially or fully) mergequeue-status: done

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants