Skip to content

Commit c13425e

Browse files
[mq] [skip ddci] working branch - merge ef517d6 on top of main at 7a849eb
{"baseBranch":"main","baseCommit":"7a849eb98fd3e71256e8f046912e36f30f935ec1","createdAt":"2026-03-02T17:05:08.578971Z","headSha":"ef517d61a57e2aa0706760b5e8095b42db6e3993","id":"6009d045-4860-410f-aa9d-71f451f27c79","nextMergeabilityCheckAt":"2026-03-02T18:09:05.003955Z","priority":"200","pullRequestNumber":"4484","queuedAt":"2026-03-02T17:09:17.400284Z","status":"STATUS_QUEUED"}
2 parents f7a57bb + ef517d6 commit c13425e

6 files changed

Lines changed: 319 additions & 3 deletions

File tree

ddtrace/tracer/span.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ func (s *Span) BaggageItem(key string) string {
358358
func safeStringerValue(v fmt.Stringer, original any) (result string) {
359359
defer func() {
360360
if e := recover(); e != nil {
361-
if rv := reflect.ValueOf(original); rv.Kind() == reflect.Ptr && rv.IsNil() {
361+
if rv := reflect.ValueOf(original); rv.Kind() == reflect.Pointer && rv.IsNil() {
362362
result = "<nil>"
363363
return
364364
}

ddtrace/tracer/stats.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,17 @@ func (c *concentrator) flushAndSend(timenow time.Time, includeCurrent bool) {
252252
for _, csp := range csps {
253253
csp.ProcessTags = processtags.GlobalTags().String()
254254
flushedBuckets += len(csp.Stats)
255-
if err := c.cfg.transport.sendStats(csp, obfVersion); err != nil {
255+
var err error
256+
for attempt := 0; attempt <= c.cfg.sendRetries; attempt++ {
257+
err = c.cfg.transport.sendStats(csp, obfVersion)
258+
if err == nil {
259+
break
260+
}
261+
if attempt < c.cfg.sendRetries {
262+
time.Sleep(c.cfg.internalConfig.RetryInterval())
263+
}
264+
}
265+
if err != nil {
256266
c.statsd().Incr("datadog.tracer.stats.flush_errors", nil, 1)
257267
log.Error("Error sending stats payload: %s", err.Error())
258268
}

ddtrace/tracer/stats_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
package tracer
77

88
import (
9+
"errors"
10+
"fmt"
911
"sync/atomic"
1012
"testing"
1113
"time"
@@ -14,6 +16,7 @@ import (
1416
"github.com/stretchr/testify/require"
1517

1618
"github.com/DataDog/datadog-agent/pkg/obfuscate"
19+
pb "github.com/DataDog/datadog-agent/pkg/proto/pbgo/trace"
1720
"github.com/DataDog/datadog-go/v5/statsd"
1821

1922
"github.com/DataDog/dd-trace-go/v2/ddtrace/ext"
@@ -335,3 +338,76 @@ func TestStatsIncludeHTTPMethodAndEndpoint(t *testing.T) {
335338
assert.Equal(t, uniqueMethod, group.GetHTTPMethod())
336339
assert.Equal(t, uniqueEndpoint, group.GetHTTPEndpoint())
337340
}
341+
342+
// failingStatsTransport is a transport whose sendStats fails a configurable
343+
// number of times before succeeding, used to test retry behaviour.
344+
type failingStatsTransport struct {
345+
dummyTransport
346+
failCount int
347+
sendAttempts int
348+
statsSent bool
349+
}
350+
351+
func (t *failingStatsTransport) sendStats(_ *pb.ClientStatsPayload, _ int) error {
352+
t.sendAttempts++
353+
if t.failCount > 0 {
354+
t.failCount--
355+
return errors.New("stats send failed")
356+
}
357+
t.statsSent = true
358+
return nil
359+
}
360+
361+
func TestStatsFlushRetries(t *testing.T) {
362+
testcases := []struct {
363+
configRetries int
364+
retryInterval time.Duration
365+
failCount int
366+
statsSent bool
367+
expAttempts int
368+
}{
369+
{configRetries: 0, retryInterval: time.Millisecond, failCount: 0, statsSent: true, expAttempts: 1},
370+
{configRetries: 0, retryInterval: time.Millisecond, failCount: 1, statsSent: false, expAttempts: 1},
371+
372+
{configRetries: 1, retryInterval: time.Millisecond, failCount: 0, statsSent: true, expAttempts: 1},
373+
{configRetries: 1, retryInterval: time.Millisecond, failCount: 1, statsSent: true, expAttempts: 2},
374+
{configRetries: 1, retryInterval: time.Millisecond, failCount: 2, statsSent: false, expAttempts: 2},
375+
376+
{configRetries: 2, retryInterval: time.Millisecond, failCount: 0, statsSent: true, expAttempts: 1},
377+
{configRetries: 2, retryInterval: time.Millisecond, failCount: 1, statsSent: true, expAttempts: 2},
378+
{configRetries: 2, retryInterval: time.Millisecond, failCount: 2, statsSent: true, expAttempts: 3},
379+
{configRetries: 2, retryInterval: time.Millisecond, failCount: 3, statsSent: false, expAttempts: 3},
380+
}
381+
382+
bucketSize := int64(500_000)
383+
s := Span{
384+
name: "http.request",
385+
start: time.Now().UnixNano() + 3*bucketSize,
386+
duration: 1,
387+
metrics: map[string]float64{keyMeasured: 1},
388+
}
389+
390+
for _, test := range testcases {
391+
name := fmt.Sprintf("retries=%d/fails=%d/sent=%v", test.configRetries, test.failCount, test.statsSent)
392+
t.Run(name, func(t *testing.T) {
393+
p := &failingStatsTransport{failCount: test.failCount}
394+
cfg, err := newTestConfig(func(c *config) {
395+
c.transport = p
396+
c.sendRetries = test.configRetries
397+
c.internalConfig.SetRetryInterval(test.retryInterval, internalconfig.OriginCode)
398+
c.internalConfig.SetEnv("someEnv", internalconfig.OriginCode)
399+
})
400+
require.NoError(t, err)
401+
402+
c := newConcentrator(cfg, bucketSize, &statsd.NoOpClientDirect{})
403+
ss, ok := c.newTracerStatSpan(&s, nil)
404+
require.True(t, ok)
405+
c.Start()
406+
c.In <- ss
407+
c.Stop()
408+
409+
assert.Equal(t, test.expAttempts, p.sendAttempts)
410+
assert.Equal(t, test.statsSent, p.statsSent)
411+
})
412+
}
413+
}

ddtrace/tracer/transport_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import (
1616
"path/filepath"
1717
"strconv"
1818
"strings"
19+
"sync"
20+
"sync/atomic"
1921
"testing"
22+
"time"
2023

2124
pb "github.com/DataDog/datadog-agent/pkg/proto/pbgo/trace"
2225

@@ -596,3 +599,68 @@ func TestClientComputedStatsHeader(t *testing.T) {
596599
assert.Equal("t", headerValue, "Datadog-Client-Computed-Stats header should be set to 't' when both conditions are met")
597600
})
598601
}
602+
603+
// TestConcurrentTraceFlushOverUDS verifies that multiple goroutines can send trace
604+
// payloads concurrently through the HTTP transport backed by a real UDS socket without
605+
// errors. This exercises the connection pool fix (MaxIdleConnsPerHost=100) under
606+
// realistic end-to-end conditions rather than just asserting config values.
607+
func TestConcurrentTraceFlushOverUDS(t *testing.T) {
608+
if testing.Short() {
609+
t.Skip("skipping concurrent UDS transport test in short mode")
610+
}
611+
612+
dir, err := os.MkdirTemp("", "uds-transport-test")
613+
require.NoError(t, err)
614+
defer os.RemoveAll(dir)
615+
616+
socketPath := filepath.Join(dir, "traces.socket")
617+
ln, err := net.Listen("unix", socketPath)
618+
require.NoError(t, err)
619+
620+
var received atomic.Int64
621+
srv := &http.Server{
622+
Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
623+
received.Add(1)
624+
w.WriteHeader(http.StatusOK)
625+
w.Write([]byte(`{"rate_by_service":{}}`)) //nolint:errcheck
626+
}),
627+
}
628+
go srv.Serve(ln) //nolint:errcheck
629+
defer srv.Close()
630+
631+
udsURL := internal.UnixDataSocketURL(socketPath).String()
632+
client := internal.UDSClient(socketPath, 5*time.Second)
633+
transport := newHTTPTransport(udsURL, client)
634+
635+
const numGoroutines = 20
636+
637+
start := make(chan struct{})
638+
errs := make([]error, numGoroutines)
639+
var wg sync.WaitGroup
640+
641+
for i := range numGoroutines {
642+
wg.Go(func() {
643+
<-start
644+
p, encErr := encode(getTestTrace(1, 1))
645+
if encErr != nil {
646+
errs[i] = encErr
647+
return
648+
}
649+
body, sendErr := transport.send(p)
650+
if sendErr != nil {
651+
errs[i] = sendErr
652+
return
653+
}
654+
body.Close()
655+
})
656+
}
657+
658+
close(start)
659+
wg.Wait()
660+
661+
for i, e := range errs {
662+
assert.NoError(t, e, "goroutine %d send failed", i)
663+
}
664+
assert.Equal(t, int64(numGoroutines), received.Load(),
665+
"server should have received all %d trace payloads", numGoroutines)
666+
}

internal/uds.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ func UDSClient(socketPath string, timeout time.Duration) *http.Client {
3333
Net: "unix",
3434
}).String())
3535
},
36-
MaxIdleConns: 100,
36+
MaxIdleConns: 100,
37+
// All UDS requests share a single synthetic hostname, so MaxIdleConnsPerHost
38+
// must match MaxIdleConns to prevent connection churn under concurrent flushes.
39+
MaxIdleConnsPerHost: 100,
3740
IdleConnTimeout: 90 * time.Second,
3841
TLSHandshakeTimeout: 10 * time.Second,
3942
ExpectContinueTimeout: 1 * time.Second,

internal/uds_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@
66
package internal
77

88
import (
9+
"net"
10+
"net/http"
911
"net/url"
12+
"os"
13+
"path/filepath"
14+
"sync"
15+
"sync/atomic"
1016
"testing"
17+
"time"
1118

1219
"github.com/stretchr/testify/assert"
20+
"github.com/stretchr/testify/require"
1321
)
1422

1523
func TestUnixDataSocketURL(t *testing.T) {
@@ -146,3 +154,154 @@ func TestUnixDataSocketURL(t *testing.T) {
146154
})
147155
}
148156
}
157+
158+
func TestUDSClientTransportConfig(t *testing.T) {
159+
client := UDSClient("/var/run/datadog/apm.socket", 10*time.Second)
160+
tr, ok := client.Transport.(*http.Transport)
161+
require.True(t, ok, "Transport should be *http.Transport")
162+
assert.Equal(t, 100, tr.MaxIdleConns)
163+
assert.Equal(t, 100, tr.MaxIdleConnsPerHost)
164+
assert.Equal(t, 90*time.Second, tr.IdleConnTimeout)
165+
assert.Equal(t, 10*time.Second, tr.TLSHandshakeTimeout)
166+
assert.Equal(t, 1*time.Second, tr.ExpectContinueTimeout)
167+
}
168+
169+
// TestUDSConcurrentConnectionReuse verifies that MaxIdleConnsPerHost=100 prevents
170+
// connection churn when many goroutines send requests concurrently over a UDS socket.
171+
// Before the fix, MaxIdleConnsPerHost defaulted to 2, which forced new connections
172+
// for every request beyond the 2-connection idle pool, causing "connection reset by
173+
// peer" errors under agent backpressure.
174+
func TestUDSConcurrentConnectionReuse(t *testing.T) {
175+
if testing.Short() {
176+
t.Skip("skipping connection pool test in short mode")
177+
}
178+
179+
dir, err := os.MkdirTemp("", "uds-pool-test")
180+
require.NoError(t, err)
181+
defer os.RemoveAll(dir)
182+
183+
socketPath := filepath.Join(dir, "test.socket")
184+
ln, err := net.Listen("unix", socketPath)
185+
require.NoError(t, err)
186+
187+
var newConnections atomic.Int64
188+
srv := &http.Server{
189+
Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
190+
w.WriteHeader(http.StatusOK)
191+
}),
192+
ConnState: func(_ net.Conn, state http.ConnState) {
193+
if state == http.StateNew {
194+
newConnections.Add(1)
195+
}
196+
},
197+
}
198+
go srv.Serve(ln) //nolint:errcheck
199+
defer srv.Close()
200+
201+
client := UDSClient(socketPath, 5*time.Second)
202+
203+
const (
204+
numGoroutines = 50
205+
requestsEach = 10
206+
)
207+
208+
start := make(chan struct{})
209+
var wg sync.WaitGroup
210+
211+
for range numGoroutines {
212+
wg.Go(func() {
213+
<-start
214+
for range requestsEach {
215+
req, err := http.NewRequest(http.MethodGet, "http://localhost/", nil)
216+
if err != nil {
217+
return
218+
}
219+
resp, err := client.Do(req)
220+
if err != nil {
221+
return
222+
}
223+
resp.Body.Close()
224+
}
225+
})
226+
}
227+
228+
close(start)
229+
wg.Wait()
230+
231+
// With MaxIdleConnsPerHost=100, each goroutine reuses its connection for all
232+
// 10 requests. Expect ~50 new connections (one per goroutine), not 500 (one
233+
// per request as would happen with the old MaxIdleConnsPerHost=2 default).
234+
assert.LessOrEqual(t, newConnections.Load(), int64(55),
235+
"connections should be reused; got %d new connections for %d requests",
236+
newConnections.Load(), numGoroutines*requestsEach)
237+
}
238+
239+
// TestUDSServerCloseRecovery verifies that the UDS HTTP client recovers transparently
240+
// from server-side connection closes, which reproduce agent backpressure / restart
241+
// scenarios that previously caused "broken pipe" or "connection reset by peer" errors.
242+
func TestUDSServerCloseRecovery(t *testing.T) {
243+
if testing.Short() {
244+
t.Skip("skipping connection recovery test in short mode")
245+
}
246+
247+
dir, err := os.MkdirTemp("", "uds-recovery-test")
248+
require.NoError(t, err)
249+
defer os.RemoveAll(dir)
250+
251+
socketPath := filepath.Join(dir, "test.socket")
252+
ln, err := net.Listen("unix", socketPath)
253+
require.NoError(t, err)
254+
255+
var requestCount atomic.Int64
256+
srv := &http.Server{
257+
Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
258+
n := requestCount.Add(1)
259+
// Force connection close every 5th request to simulate agent backpressure.
260+
if n%5 == 0 {
261+
w.Header().Set("Connection", "close")
262+
}
263+
w.WriteHeader(http.StatusOK)
264+
}),
265+
}
266+
go srv.Serve(ln) //nolint:errcheck
267+
defer srv.Close()
268+
269+
client := UDSClient(socketPath, 5*time.Second)
270+
271+
const (
272+
numGoroutines = 20
273+
requestsEach = 20
274+
)
275+
276+
start := make(chan struct{})
277+
var successes atomic.Int64
278+
var wg sync.WaitGroup
279+
280+
for range numGoroutines {
281+
wg.Go(func() {
282+
<-start
283+
for range requestsEach {
284+
req, err := http.NewRequest(http.MethodGet, "http://localhost/", nil)
285+
if err != nil {
286+
continue
287+
}
288+
resp, err := client.Do(req)
289+
if err != nil {
290+
continue
291+
}
292+
resp.Body.Close()
293+
if resp.StatusCode == http.StatusOK {
294+
successes.Add(1)
295+
}
296+
}
297+
})
298+
}
299+
300+
close(start)
301+
wg.Wait()
302+
303+
// All requests must succeed. The HTTP client transparently opens a new
304+
// connection after a server-forced close, so no request should be lost.
305+
assert.Equal(t, int64(numGoroutines*requestsEach), successes.Load(),
306+
"all requests should succeed despite periodic server-side connection closes")
307+
}

0 commit comments

Comments
 (0)