Skip to content

Commit 2c94e8b

Browse files
authored
Stream the body in the trace-agent dogstatsd proxy (#54226)
### What does this PR do? Replaces the eager `bytes.Split` in the trace-agent dogstatsd proxy handler (`/dogstatsd/v1/proxy`, `/dogstatsd/v2/proxy`) with a `bufio.Scanner` that walks the request body one payload at a time, and: - skips empty payloads, which carry nothing for dogstatsd to parse - caps the payloads relayed per request (`maxDogstatsdProxyPayloads`, 100k), returning 413 beyond that ### Motivation The handler read the whole body and then split it on newlines before relaying each payload over UDP. `bytes.Split` pre-materializes one 24-byte slice header per newline byte, so a body made mostly of newlines used several times its own size in memory — at the default 25MB body limit that is hundreds of MB of headers, and the memory is not returned promptly afterwards. Separately, the number of UDP writes one request could drive was bounded only by the body size. Scanning keeps the extra memory proportional to the longest line, and the cap keeps the writes a single request can cause to a fixed cost. ### Describe how you validated your changes `TestDogStatsDReverseProxyPayloadRelay` is a table test binding a real UDP socket and asserting both the status code and what actually reaches dogstatsd: | subtest | body | expects | |---|---|---| | `newlines only` | 100k newlines | 200, nothing relayed | | `unreadable body` | one line then a failing reader | 500, first line relayed | | `body over size limit` | limit cuts mid second line | 500, first line relayed | | `more payloads than the proxy relays` | cap + 1 lines | 413, first payload relayed | The pre-existing end-to-end UDP and UDS proxy tests still pass, and the whole package passes with `-test.short=false` (545 tests). `dda inv linter.go --targets=./pkg/trace/api` is clean. ### Additional Notes The `bufio.Scanner` default 64KiB token limit is above the maximum UDP payload (65507 bytes), so it cannot reject a payload that would otherwise have been deliverable. Co-authored-by: andrew.glaude <andrew.glaude@datadoghq.com>
1 parent f25b244 commit 2c94e8b

3 files changed

Lines changed: 163 additions & 11 deletions

File tree

pkg/trace/api/dogstatsd.go

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
package api
77

88
import (
9-
"bytes"
10-
"io"
9+
"bufio"
1110
"net"
1211
"net/http"
1312
"strconv"
@@ -16,10 +15,25 @@ import (
1615
"github.com/DataDog/datadog-agent/pkg/trace/log"
1716
)
1817

18+
// maxDogstatsdProxyLines bounds the number of lines read from the body of a
19+
// single proxied request. Without it, both the number of UDP writes and the
20+
// number of scanner iterations one request causes are only bounded by the
21+
// request size, so a body of two-byte lines can drive one write per two bytes
22+
// sent, and a body of newlines one iteration per byte sent. Empty lines relay
23+
// nothing but still count, so that the work a request can ask for stays a fixed
24+
// cost. The limit stays well above any realistic batch, which is a handful of
25+
// metrics per request.
26+
const maxDogstatsdProxyLines = 100_000
27+
1928
// dogstatsdProxyHandler returns a new HTTP handler which will proxy requests to
2029
// the DogStatsD endpoint in the Core Agent over UDP. Communication between the
2130
// proxy and the agent does not support UDS (see #13628), and so does not guarantee delivery of
2231
// all statsd payloads.
32+
//
33+
// The request body is relayed as it is read, so a body that turns out to be
34+
// unreadable, over the size limit, or over maxDogstatsdProxyLines is
35+
// reported as an error only after its earlier payloads have been sent. A client
36+
// that retries such a request may therefore submit those payloads twice.
2337
func (r *HTTPReceiver) dogstatsdProxyHandler() http.Handler {
2438
if !r.conf.StatsdEnabled {
2539
log.Info("DogstatsD disabled in the Agent configuration. The DogstatsD proxy endpoint will be non-functional.")
@@ -41,25 +55,40 @@ func (r *HTTPReceiver) dogstatsdProxyHandler() http.Handler {
4155
}
4256
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
4357
req.Body = apiutil.NewLimitedReader(req.Body, r.conf.MaxRequestBytes)
44-
body, err := io.ReadAll(req.Body)
45-
if err != nil {
46-
http.Error(w, err.Error(), http.StatusInternalServerError)
47-
return
48-
}
49-
payloads := bytes.Split(body, []byte("\n"))
50-
5158
conn, err := net.DialUDP("udp", nil, addr)
5259
if err != nil {
5360
log.Errorf("Error connecting to %s endpoint at %q: %v", "udp", addr, err)
5461
http.Error(w, err.Error(), http.StatusInternalServerError)
5562
return
5663
}
5764
defer conn.Close()
58-
for _, p := range payloads {
59-
if _, err := conn.Write(p); err != nil {
65+
66+
// Scan the body one line at a time rather than splitting it up front:
67+
// splitting materializes a slice header per newline byte, so a body made
68+
// of newlines multiplies its own size in memory several times over. The
69+
// scanner keeps the extra memory proportional to the longest line.
70+
scanner := bufio.NewScanner(req.Body)
71+
lines := 0
72+
for scanner.Scan() {
73+
lines++
74+
if lines > maxDogstatsdProxyLines {
75+
log.Errorf("Dogstatsd proxy request contains more than %d lines, dropping the rest.", maxDogstatsdProxyLines)
76+
http.Error(w, "too many dogstatsd payloads in request", http.StatusRequestEntityTooLarge)
77+
return
78+
}
79+
payload := scanner.Bytes()
80+
if len(payload) == 0 {
81+
// Nothing for DogStatsD to parse; don't spend a syscall on it.
82+
continue
83+
}
84+
if _, err := conn.Write(payload); err != nil {
6085
http.Error(w, err.Error(), http.StatusInternalServerError)
6186
return
6287
}
6388
}
89+
if err := scanner.Err(); err != nil {
90+
http.Error(w, err.Error(), http.StatusInternalServerError)
91+
return
92+
}
6493
})
6594
}

pkg/trace/api/dogstatsd_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@ package api
77

88
import (
99
"bytes"
10+
"errors"
1011
"fmt"
1112
"io"
1213
"net"
1314
"net/http"
1415
"net/http/httptest"
1516
"strconv"
17+
"strings"
18+
"sync"
1619
"testing"
20+
"testing/iotest"
1721
"time"
1822

1923
"github.com/stretchr/testify/require"
@@ -78,6 +82,116 @@ func TestDogStatsDReverseProxy(t *testing.T) {
7882
})
7983
}
8084

85+
// TestDogStatsDReverseProxyPayloadRelay covers how the proxy turns a request
86+
// body into DogStatsD payloads: newlines on their own carry no payload, and a
87+
// body that is unreadable, over the size limit, or over the payload cap is
88+
// reported as an error only once the payloads read before it have been relayed.
89+
func TestDogStatsDReverseProxyPayloadRelay(t *testing.T) {
90+
const line = "users.online:1|c"
91+
testCases := []struct {
92+
name string
93+
// maxRequestBytes overrides the default body size limit when non-zero.
94+
maxRequestBytes int64
95+
body io.Reader
96+
errCode int
97+
// wantFirstPayload is the first payload expected to reach DogStatsD,
98+
// or empty when nothing at all should be relayed.
99+
wantFirstPayload string
100+
}{
101+
{
102+
name: "newlines only",
103+
body: bytes.NewReader(bytes.Repeat([]byte("\n"), maxDogstatsdProxyLines)),
104+
errCode: http.StatusOK,
105+
},
106+
{
107+
// Empty lines relay nothing, but still count towards the limit.
108+
name: "more newlines than the proxy scans",
109+
body: bytes.NewReader(bytes.Repeat([]byte("\n"), maxDogstatsdProxyLines+1)),
110+
errCode: http.StatusRequestEntityTooLarge,
111+
},
112+
{
113+
name: "unreadable body",
114+
body: io.MultiReader(strings.NewReader(line+"\n"), iotest.ErrReader(errors.New("read failed"))),
115+
errCode: http.StatusInternalServerError,
116+
wantFirstPayload: line,
117+
},
118+
{
119+
// The limit cuts the body mid way through its second line.
120+
name: "body over size limit",
121+
maxRequestBytes: int64(len(line)) + 5,
122+
body: strings.NewReader(line + "\n" + line),
123+
errCode: http.StatusInternalServerError,
124+
wantFirstPayload: line,
125+
},
126+
{
127+
name: "more payloads than the proxy relays",
128+
body: strings.NewReader(strings.Repeat("x\n", maxDogstatsdProxyLines+1)),
129+
errCode: http.StatusRequestEntityTooLarge,
130+
wantFirstPayload: "x",
131+
},
132+
}
133+
for _, tc := range testCases {
134+
t.Run(tc.name, func(t *testing.T) {
135+
// Bind a UDP socket so we can observe what the proxy relays.
136+
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
137+
require.NoError(t, err)
138+
defer conn.Close()
139+
140+
cfg := config.New()
141+
cfg.StatsdHost = "127.0.0.1"
142+
cfg.StatsdPort = conn.LocalAddr().(*net.UDPAddr).Port
143+
if tc.maxRequestBytes != 0 {
144+
cfg.MaxRequestBytes = tc.maxRequestBytes
145+
}
146+
receiver := newTestReceiverFromConfig(cfg)
147+
proxy := receiver.dogstatsdProxyHandler()
148+
require.NotNil(t, proxy)
149+
150+
// Drain the socket while the proxy runs, so that a case relaying
151+
// many payloads cannot fill the receive buffer and have the rest
152+
// dropped; the first payload is kept for the assertion below.
153+
var mu sync.Mutex
154+
var first []byte
155+
var count int
156+
go func() {
157+
buf := make([]byte, 1024)
158+
for {
159+
n, _, err := conn.ReadFrom(buf)
160+
if err != nil { // the socket was closed, the subtest is over
161+
return
162+
}
163+
mu.Lock()
164+
if count == 0 {
165+
first = append([]byte(nil), buf[:n]...)
166+
}
167+
count++
168+
mu.Unlock()
169+
}
170+
}()
171+
// Counting rather than inspecting first, so that an empty payload
172+
// is not mistaken for no payload at all.
173+
relayed := func() bool {
174+
mu.Lock()
175+
defer mu.Unlock()
176+
return count > 0
177+
}
178+
179+
rec := httptest.NewRecorder()
180+
proxy.ServeHTTP(rec, httptest.NewRequest("POST", "/", tc.body))
181+
require.Equal(t, tc.errCode, rec.Code)
182+
183+
if tc.wantFirstPayload == "" {
184+
require.Never(t, relayed, 100*time.Millisecond, 10*time.Millisecond, "expected no payload to be relayed")
185+
return
186+
}
187+
require.Eventually(t, relayed, 5*time.Second, 10*time.Millisecond, "expected a payload to be relayed")
188+
mu.Lock()
189+
defer mu.Unlock()
190+
require.Equal(t, tc.wantFirstPayload, string(first))
191+
})
192+
}
193+
}
194+
81195
func testDogStatsDReverseProxyEndToEndUDP(t *testing.T, cfg *config.AgentConfig) {
82196
hosts := []string{"localhost", "127.0.0.1", "::1"}
83197
for _, host := range hosts {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed a bug in the trace-agent DogStatsD proxy endpoints
5+
(``/dogstatsd/v1/proxy`` and ``/dogstatsd/v2/proxy``) where a request body
6+
was split into all of its newline-separated payloads at once, so a body
7+
containing many newlines used several times its own size in memory. The
8+
body is now scanned one payload at a time, empty payloads are skipped, and
9+
the number of payloads relayed per request is capped.

0 commit comments

Comments
 (0)