netdiag is an experimental Linux flight recorder for explaining TCP latency
regressions with local, evidence-backed captures.
It records low-cost host, interface and kernel-networking counters over a bounded interval, writes a versioned JSON capture, and turns counter deltas into conservative findings. The goal is not to be a metrics dashboard; the goal is to preserve the right evidence during an incident and make the next debugging step explicit.
This is still early-stage software. Findings describe measured correlations, not proven root cause. Later milestones will add per-flow TCP timing and deeper kernel-path attribution.
- Bounded local recording with
--duration,--intervaland--max-samples - Atomic
0600JSON writes using a temporary file and rename - UTC timestamps plus monotonic elapsed nanoseconds for stable interval analysis
- Recording-level collector manifest with status, failure reason and visibility scope
- Best-effort optional collectors that degrade without aborting the capture
- Local analyzer that reports findings with evidence and a concrete next step
- Baseline-versus-incident comparison for two recordings
- Fixture coverage for procfs/sysfs parsing and diagnostic rules
- Root-enabled eBPF integration test for controlled TCP retransmissions
- Reproducible packet-loss experiment using an isolated network namespace
- Reproducible CPU-contention experiment for receive-path concentration
- Reproducible selected-process scheduler-delay experiment
- Capture-overhead benchmark scripts
Requirements:
- Linux
- Go
maketcfrom iproute2 when collecting qdisc counters or running experimentsssfrom iproute2 when enabling optional TCP info collection- root or suitable BPF/perf capabilities when enabling the eBPF collector
Build and run tests:
make test
make buildRecord a 30 second capture:
sudo ./bin/netdiag record \
--duration 30s \
--interval 1s \
--max-samples 3600 \
--interface eth0 \
--output capture.jsonAnalyze the capture:
./bin/netdiag analyze capture.jsonCompare a baseline capture against an incident capture:
./bin/netdiag compare baseline.json incident.jsonMeasure workload-level TCP connect latency with the synthetic workload client:
./bin/netdiag-workload client \
--url http://127.0.0.1:18080/payload \
--duration 10s \
--concurrency 8 \
--new-connection--new-connection disables keepalives so each request creates a TCP
connection. This is workload output, not netdiag record evidence.
Disable eBPF explicitly when running unprivileged:
./bin/netdiag record --ebpf=false --duration 30s --output capture.jsonRecord selected-process scheduler counters:
./bin/netdiag record --pid 1234 --duration 30s --output capture.jsonLimit serialized TCP socket queue snapshots:
./bin/netdiag record --max-tcp-socket-queues=16 --duration 30s --output capture.jsonUse --max-tcp-socket-queues=0 to keep aggregate socket queue counters while
omitting per-socket queue tuples from each sample. Socket queue tuples include
local and remote IP addresses and ports.
Collect optional TCP RTT and congestion details from ss -tin:
./bin/netdiag record --tcp-info --max-tcp-info-sockets=32 --duration 30s --output capture.jsonUse --max-tcp-info-sockets=0 to keep the collector visibility entry while
omitting per-socket TCP info tuples. TCP info tuples include local and remote IP
addresses and ports.
Limit serialized eBPF per-flow retransmit entries:
./bin/netdiag record --max-ebpf-flows=64 --duration 30s --output capture.jsonUse --max-ebpf-flows=0 to keep the host-wide eBPF retransmission counter and
flow-count metadata while omitting per-flow entries from each sample.
Limit how many samples can serialize eBPF per-flow entries:
./bin/netdiag record --max-ebpf-flow-samples=10 --duration 30s --output capture.jsonUse --max-ebpf-flow-samples=-1 for the default unlimited sample budget.
Recordings stop when either --duration or --max-samples is reached. The
sample limit defaults to 3600 and must be positive.
The completed recording is written to a temporary file with 0600 permissions
and atomically renamed over the output path. If the process is interrupted
during writing, an existing capture is not replaced by partial JSON.
The recording format is versioned JSON. Each sample includes:
- wall-clock UTC timestamp for correlation with external events;
- monotonic elapsed nanoseconds for duration and delta calculations;
- collected counter groups;
- optional collector outputs when available.
Version 3 and later recordings use monotonic elapsed time during analysis, so wall-clock corrections cannot create invalid capture durations.
TCP socket queue tuples are bounded by --max-tcp-socket-queues. Aggregate TCP
socket queue counters are still collected when the tuple limit is zero. Samples
include socket_queue_count and top_queues_truncated when more non-empty
socket queues were observed than serialized. Socket queue tuples include local
and remote IP addresses and ports; set --max-tcp-socket-queues=0 to omit them
while keeping aggregate socket queue counters.
eBPF per-flow retransmission entries are bounded twice: the kernel map uses an
LRU cap, and the recorder serializes at most --max-ebpf-flows entries per
sample. --max-ebpf-flow-samples can also limit how many samples serialize
flow details during a recording. Samples include tcp_retransmit_flow_count,
tcp_retransmit_flows_truncated and, when a recording-wide budget omits flow
details, tcp_retransmit_flows_omitted_reason.
TCP info collection is opt-in because it stores endpoint metadata from
established sockets. When enabled with --tcp-info, the recorder runs ss -tin
once per sample and stores a bounded list sorted by highest RTT first. Samples
include count and truncated when more established sockets were observed
than serialized.
| Signal | Source | Scope |
|---|---|---|
| TCP segments, retransmits and input errors | /proc/net/snmp |
network namespace |
| TCP socket queue aggregates | /proc/net/tcp, /proc/net/tcp6 |
network namespace |
| TCP RTT and congestion details | ss -tin |
network namespace, opt-in, endpoint metadata, truncated by --max-tcp-info-sockets |
NET_RX and NET_TX softirq counters |
/proc/softirqs |
per CPU and host totals |
| CPU scheduler counters | /proc/stat |
per CPU |
| CPU pressure | /proc/pressure/cpu |
host, optional |
| Selected process scheduler counters | /proc/<pid>/schedstat |
selected process when --pid is set |
| Interface packets, bytes, drops and errors | /sys/class/net/*/statistics |
selected interface |
| IRQ counts and affinity | /proc/interrupts, /proc/irq/*/smp_affinity_list, /sys/class/net/*/device/msi_irqs |
selected interface when discoverable |
| Qdisc counters | tc -s qdisc show dev <iface> |
selected interface |
| TCP retransmit tracepoint count | eBPF tcp_retransmit_skb |
host-wide |
| TCP retransmit per-flow counters | eBPF tcp_retransmit_skb |
bounded IPv4 and IPv6 flow tuples, host-wide source, truncated by --max-ebpf-flows |
| Host metadata | hostname, kernel release | host |
The IRQ, qdisc, TCP info and eBPF collectors are best-effort optional signals.
If one of them is unavailable, netdiag records the failure in the collector
manifest and continues capturing required counters. eBPF recordings also include an
ebpf_features section so individual eBPF signals can report enabled,
disabled or unavailable as the collector grows beyond the initial
all-or-nothing retransmit tracepoint object.
Required collectors:
proc_tcpproc_tcp_socketsproc_softirqproc_cpuinterface_statswhen--interfaceis set
Optional collectors:
proc_pid_schedstatproc_interruptstc_qdiscss_tcp_infoebpf_tcp_retransmit
The analyzer currently reports conservative evidence-backed findings:
- elevated TCP retransmissions;
- TCP socket queue growth;
- elevated TCP RTT when
--tcp-infois enabled; - selected-process runqueue wait growth when
--pidis set; - selected-interface drops or errors;
- cumulative counter resets during the capture;
- network receive processing concentrated on a busy CPU.
When eBPF per-flow retransmission data is available, TCP retransmission findings
include the top IPv4 and IPv6 flow tuples as supporting evidence. If the serialized flow
list was capped by --max-ebpf-flows, the finding also reports the truncation.
If an eBPF feature was disabled or unavailable, the finding reports that
visibility gap with the recorded reason when one is available.
When --tcp-info is enabled, TCP RTT findings report the highest observed RTT
from ss -tin, the socket endpoint tuple, and cwnd/retransmission metadata
when present. This is a conservative signal; it does not prove path congestion
by itself.
Each finding includes:
- severity;
- confidence;
- factual evidence;
- next verification step.
netdiag compare groups findings into incident-only, shared and baseline-only
sets, reports collector visibility differences, and prints key counter delta
changes such as TCP retransmits and qdisc drops. TCP retransmit deltas include
outbound segment denominators and percentages so raw retransmit counts are not
misread without traffic volume, and TCP socket queue deltas show the final
aggregate queue sizes, non-empty socket counts, and top bounded socket queue
tuples when available. Captures recorded with --pid also include
selected-process scheduler deltas. Captures recorded with --tcp-info include
the highest observed TCP RTT when either side has TCP info socket data. When
both captures include eBPF retransmit flow data, the comparison also shows the
top IPv4 and IPv6 flow tuples and whether a flow list was truncated by
--max-ebpf-flows. If an eBPF feature reports sample-level errors in either
capture, compare includes an eBPF feature errors row.
Example:
Finding 1: TCP retransmissions were elevated during the capture
Confidence: strong correlation
Severity: warning
Evidence: 153 retransmitted of 1101 outbound TCP segments (13.90%)
Evidence: eBPF observed 161 tcp_retransmit_skb tracepoint events
Evidence: Top retransmitting IPv4 flow: 127.0.0.1:43946 -> 127.0.0.1:40981 had 4 retransmits
Next step: Check packet loss, ECN/congestion signals, peer health, and interface error counters.
TCP info example:
Finding 1: TCP RTT was elevated during the capture
Confidence: possible
Severity: warning
Evidence: highest observed TCP RTT was 123.4 ms
Evidence: Top TCP RTT socket: tcp4 10.0.0.3:50001 -> 10.0.0.4:443 state ESTAB had 123.4 ms RTT
Evidence: congestion window was 10 segments
Next step: Check packet loss, congestion window, retransmissions, peer health, and whether the path is congested.
When eBPF visibility is missing, evidence uses the feature-level status from the capture:
Evidence: eBPF tcp_retransmit_events unavailable: permission denied
Evidence: eBPF tcp_retransmit_ipv4_flows disabled
Evidence: eBPF tcp_retransmit_ipv6_flows disabled
Comparison example:
Comparison: qdisc-drop-baseline.json -> qdisc-drop-impaired.json
Visibility differences:
- none
Incident-only findings:
- TCP retransmissions were elevated during the capture
- The selected interface qdisc recorded drops or overlimits
Shared findings:
- none
Baseline-only findings:
- none
Key delta changes:
- TCP retransmits: 3411/15204971 outbound segments (0.02%) -> 694/1829 outbound segments (37.94%)
- highest TCP RTT: 12.3 ms -> 123.4 ms
- top NET_RX softirq CPU: CPU14 7.3% of 629844 -> CPU2 92.1% of 8663
- top NET_RX CPU busy: CPU14 50.6% -> CPU2 8.0%
- qdisc drops: 0 -> 882
- qdisc overlimits: 0 -> 0
- interface drops: 0 -> 0
- interface errors: 0 -> 0
make test
make build
make fmt
make generatemake generate regenerates the committed eBPF Go bindings and embedded BPF
object:
go generate ./internal/ebpfcollectorThe generated files are committed so users do not need Clang to build the CLI.
The eBPF integration tests load the real tcp_retransmit_skb program, create
isolated network namespaces, apply 100% packet loss to each namespace's loopback
device, and verify that the BPF event count increases with matching IPv4 and
IPv6 loopback flow entries. They do not modify the host network namespace.
Requirements:
- Linux
- root privileges
unshareipandtcfrom iproute2
Run:
make test-integrationEquivalent direct command:
sudo env NETDIAG_ROOT_TESTS=1 /usr/local/go/bin/go test -count=1 -v ./internal/ebpfcollector -run '^TestTCPRetransmit.*Integration$'Regular make test remains unprivileged and skips this test.
Run the reproducible isolated tc netem experiment with:
make experiment-lossThe experiment records host procfs and eBPF retransmission evidence without applying loss to an existing host interface. See docs/experiments/tcp-loss.md for setup, configuration, expected evidence and visibility limitations.
Run the CPU-contention experiment with:
make experiment-cpu-contentionIt records a baseline capture and an impaired capture with a CPU burner pinned to the target receive CPU. See docs/experiments/cpu-contention.md for parameters, expected evidence and tuning guidance.
Run the qdisc-drop collection experiment with:
make experiment-qdisc-dropIt records a baseline capture and an impaired capture with a tiny delayed netem queue on a temporary host veth. See docs/experiments/qdisc-drop.md for expected raw qdisc evidence and tuning guidance.
Run the TCP receive-queue experiment with:
make experiment-rx-queueIt records a loopback TCP connection where the server accepts but does not read. See docs/experiments/tcp-rx-queue.md for expected socket queue evidence and tuning guidance.
Run the TCP RTT delay experiment with:
make experiment-rtt-delayIt records baseline and delayed veth captures with --tcp-info enabled and
validates the elevated TCP RTT finding. See
docs/experiments/tcp-rtt-delay.md for
expected RTT evidence and visibility limits.
Run the workload-level TCP connect-latency experiment with:
make experiment-connect-latencyIt runs netdiag-workload client --new-connection against a temporary
namespace workload and validates that tc netem delay increases connect
latency. See
docs/experiments/connect-latency.md for
expected output and visibility limits.
Run the unprivileged recorder benchmark with:
make benchmarkRun the privileged eBPF-enabled recorder benchmark with:
make benchmark-ebpfSet INTERFACE to include interface, IRQ and qdisc collectors:
INTERFACE=eth0 make benchmarkSee docs/benchmarks/capture-overhead.md for the test matrix, privileged eBPF mode and interpretation limits.
Run the end-to-end workload impact benchmark with:
make benchmark-workload-impactThis compares local HTTP throughput with and without netdiag record running
against an isolated veth workload driven by the netdiag-workload benchmark
binary. See
docs/benchmarks/workload-impact.md for
method, tuning and interpretation limits.
The Phase 0 discovery package contains the initial NGINX proxy workload, diagnostic hypotheses, controlled incident narratives, artifact and interview templates, privacy policy, and provisional NIC driver targets.
The Phase 2 status note summarizes current per-flow TCP and scheduler attribution evidence, remaining gaps, operator guidance and the next engineering direction.
- The eBPF retransmit event count is host-wide. Bounded IPv4 per-flow
retransmit counters are also collected, but they are not scoped to the
selected interface or process and do not include IPv6. Per-flow entries are
capped per sample by
--max-ebpf-flows. - Procfs TCP counters are network-namespace scoped while the eBPF counter is host-wide, so their retransmission deltas need not match.
- Counter correlation does not locate latency precisely inside the kernel path.
- Qdisc collection depends on
tcand netlink access. - IRQ-to-interface matching depends on kernel/driver naming and sysfs metadata.
- CPU concentration findings are conservative correlations, not proof that CPU contention caused latency.
- Queue-level NIC driver counters are deferred to Phase 4.
- No packet payloads are collected by default.
See ROADMAP.md for product scope and implementation milestones.