Files
nginx-logtail/cmd/collector/udp_test.go
T
pim 6647f95be4 RELEASE 1.0.1: v2 log format, source_tag-labeled metrics, lint cleanup
Wire-format and metric overhaul. Both file and UDP ingest now share one
versioned ParseLine that dispatches on the v<N>\t prefix; v1 stays
unchanged, v2 adds $bytes_sent (replacing $body_bytes_sent),
$request_length, $upstream_response_time, and $upstream_status. File
ingest gains the same versioning, and the legacy positional file format
is removed (no live deployments).

Prometheus exposition is rewritten:

  - nginx_http_bytes_sent and nginx_http_request_duration_seconds gain
    a source_tag label.
  - nginx_http_requests_by_source_total gains status_class.
  - New v2-only metrics: nginx_http_request_bytes,
    nginx_http_upstream_duration_seconds,
    nginx_http_upstream_requests_total{status_class}.
  - Dropped nginx_http_response_body_bytes_by_source (subsumed by the
    dual-labeled bytes_sent metric).

Adds 'make fixstyle' (gofmt -w) and clears all golangci-lint findings
across the repo (errcheck, S1001, ST1005, unused).

Docs in design.md FR-2/FR-8 and user-guide.md are rewritten to present
v2 as the recommended log format.
2026-05-01 15:40:53 +02:00

193 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"net"
"testing"
"time"
)
func TestUDPListenerRoundTrip(t *testing.T) {
ch := make(chan LogRecord, 4)
ps := NewPromStore()
// Bind to an ephemeral port on loopback.
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen probe: %v", err)
}
addr := pc.LocalAddr().String()
_ = pc.Close() // release; listener will re-bind
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
u := NewUDPListener(addr, 24, 48, ch)
u.SetProm(ps)
go u.Run(ctx)
// Dial the listener and send one valid and one malformed packet.
conn, err := net.Dial("udp", addr)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
// The listener is started asynchronously; retry for up to 1s.
good := "v1\twww.example.com\t1.2.3.4\tGET\t/\t200\t42\t0.010\t0\t12345\tdirect\t10.0.0.1\thttps"
bad := "not enough\tfields"
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
_, _ = conn.Write([]byte(good))
_, _ = conn.Write([]byte(bad))
select {
case rec := <-ch:
if rec.Website != "www.example.com" || rec.SourceTag != "direct" {
t.Fatalf("bad record: %+v", rec)
}
// Give the listener a moment to process the malformed packet too.
time.Sleep(50 * time.Millisecond)
ps.udpMu.Lock()
pkt, suc, con := ps.udpPacketsReceived, ps.udpLoglinesSuccess, ps.udpLoglinesConsumed
ps.udpMu.Unlock()
if pkt < 2 {
t.Errorf("udpPacketsReceived=%d, want >=2", pkt)
}
if suc < 1 {
t.Errorf("udpLoglinesSuccess=%d, want >=1", suc)
}
if con < 1 {
t.Errorf("udpLoglinesConsumed=%d, want >=1", con)
}
return
case <-time.After(50 * time.Millisecond):
}
}
t.Fatal("no record received within 1s")
}
// TestUDPListenerBatchedDatagram exercises the nginx-ipng-stats-plugin's
// buffer/flush batching: a single UDP datagram may contain many log lines
// separated by '\n'. Each line MUST be counted and parsed independently,
// so packets_received * avg_lines_per_packet ≈ loglines_success (not
// packets_received == success as the earlier single-line code assumed).
func TestUDPListenerBatchedDatagram(t *testing.T) {
ch := make(chan LogRecord, 16)
ps := NewPromStore()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen probe: %v", err)
}
addr := pc.LocalAddr().(*net.UDPAddr)
_ = pc.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
u := NewUDPListener(addr.String(), 24, 48, ch)
u.SetProm(ps)
go u.Run(ctx)
// Three v1 lines in one datagram, '\n'-terminated like nginx's
// log_format renders them.
batch := []byte(
"v1\ta.example.com\t1.2.3.4\tGET\t/a\t200\t10\t0.001\t0\t0\tdirect\t10.0.0.1\thttps\n" +
"v1\tb.example.com\t1.2.3.5\tGET\t/b\t404\t20\t0.002\t0\t0\tdirect\t10.0.0.1\thttps\n" +
"v1\tc.example.com\t1.2.3.6\tGET\t/c\t500\t30\t0.003\t0\t0\tdirect\t10.0.0.1\thttps\n",
)
src, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("src listen: %v", err)
}
defer func() { _ = src.Close() }()
// Drive the listener with retries until all three records land.
got := make(map[string]bool)
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) && len(got) < 3 {
if _, err := src.WriteTo(batch, addr); err != nil {
t.Fatalf("write: %v", err)
}
drain:
for {
select {
case rec := <-ch:
got[rec.Website] = true
case <-time.After(50 * time.Millisecond):
break drain
}
}
}
for _, want := range []string{"a.example.com", "b.example.com", "c.example.com"} {
if !got[want] {
t.Errorf("missing record for %s; got=%v", want, got)
}
}
// Exactly one packet arrived yet three lines should have succeeded.
// Under retries the numbers will be multiples, but the ratio must
// always be success ≈ 3 × packets once we've seen each record.
ps.udpMu.Lock()
pkt, suc := ps.udpPacketsReceived, ps.udpLoglinesSuccess
ps.udpMu.Unlock()
if pkt == 0 || suc < pkt*3 {
t.Errorf("packets=%d success=%d: expected success >= 3*packets", pkt, suc)
}
}
// TestUDPListenerMultipleSources exercises the nginx-reload path: a fresh
// nginx worker set opens brand-new send sockets (different ephemeral source
// ports) and the listener MUST keep accepting their packets without
// needing a restart. Regresses against the `nc -k -u -l` latching pitfall.
func TestUDPListenerMultipleSources(t *testing.T) {
ch := make(chan LogRecord, 16)
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen probe: %v", err)
}
addr := pc.LocalAddr().(*net.UDPAddr)
_ = pc.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go NewUDPListener(addr.String(), 24, 48, ch).Run(ctx)
good := []byte("v1\twww.example.com\t1.2.3.4\tGET\t/\t200\t42\t0.010\t0\t12345\tdirect\t10.0.0.1\thttps")
// sendFromNewSource opens a fresh unconnected UDP socket (new ephemeral
// source port, one per "worker") and WriteTo's the packet. Retries until
// a record arrives or the deadline hits. Unconnected sockets ignore the
// asynchronous ICMP-unreachable that a connected Dial() would latch,
// which keeps the retry loop unaffected by startup races.
sendFromNewSource := func(tag string) {
src, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("%s listen: %v", tag, err)
}
defer func() { _ = src.Close() }()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if _, err := src.WriteTo(good, addr); err != nil {
t.Fatalf("%s write: %v", tag, err)
}
select {
case rec := <-ch:
if rec.Website != "www.example.com" {
t.Fatalf("%s: bad record: %+v", tag, rec)
}
return
case <-time.After(50 * time.Millisecond):
}
}
t.Fatalf("%s: no record received within 1s; listener may be filtering by source addr", tag)
}
// Three independent sockets give three different ephemeral source ports,
// each representing a distinct nginx worker (or a post-reload worker).
sendFromNewSource("worker1")
sendFromNewSource("worker2")
sendFromNewSource("worker3")
}