Zero-footprint L4 telemetry agent - TCP handshake RTT, sub-1 MB RAM, no runtime deps.
ICMP ping is blocked by most firewalls and tells you nothing about the TCP stack.
wire-probe measures the time for a full TCP three-way handshake - the same
latency a real client sees - with no kernel module, no eBPF, no agent framework.
- Server mode -
io_uringaccept/drop loop; accepts SYNs and immediately closes them. RSS stays ~460 KB regardless of connection rate because nothing is allocated per connection - but throughput is bounded by one accept in flight, so a rate high enough to fill the listen backlog delays handshakes rather than growing memory. Falls back to blockingaccept()whereio_uringis unavailable - Probe mode - blocking
TcpStream::connect_timeout, with the RTT read from the kernel (TCP_INFO.tcpi_rtt) rather than timed in userspace; the--timeoutflag is the only timing bound - Fire-and-forget export - UDP (Telegraf Influx Line Protocol) or Unix domain socket / stdout (Collectd PUTVAL); no buffering, no retries
- Single static binary - musl-linked, ~490 KB stripped, zero glibc version dependency, works on Ubuntu, Fedora, Debian, RHEL, Alpine and any other Linux
curl -sSf https://raw.githubusercontent.com/vorjdux/wire-probe/main/install.sh | shOr install a specific version:
curl -sSf https://raw.githubusercontent.com/vorjdux/wire-probe/main/install.sh | VERSION=0.1.9 shPre-built tarballs for every platform are on the releases page.
| Platform | Tarball |
|---|---|
| Any Linux x86_64 (static) | wire-probe-<ver>-linux-x86_64.tar.gz |
| Any Linux aarch64 (static) | wire-probe-<ver>-linux-aarch64.tar.gz |
| Ubuntu 22.04 | wire-probe-<ver>-ubuntu22.04-x86_64.tar.gz |
| Ubuntu 24.04 | wire-probe-<ver>-ubuntu24.04-x86_64.tar.gz |
| Fedora 40 / 41 | wire-probe-<ver>-fedora40-x86_64.tar.gz |
| Debian 12 | wire-probe-<ver>-debian12-x86_64.tar.gz |
| Rocky Linux 9 / AlmaLinux 9 | wire-probe-<ver>-rockylinux9-x86_64.tar.gz |
The
linux-x86_64andlinux-aarch64tarballs are statically linked against musl libc and run on any Linux kernel ≥ 5.1 regardless of distro or glibc version.
On the target host (DB node):
wire-probe --mode server --port 9999On the observer host (PLT node):
# Export to Telegraf via UDP (Influx Line Protocol)
wire-probe --mode probe \
--target db-host:9999 \
--target-name mdb_primary \
--az eu-west \
--interval 1000ms \
--export telegraf-udp://127.0.0.1:8094
# Export to Collectd via stdout (Exec plugin)
wire-probe --mode probe \
--target db-host:9999 \
--target-name mdb_primary \
--interval 10s \
--export collectd-execRuns as a resident daemon on the target host. Uses an io_uring accept/drop
loop - accepts each TCP SYN and immediately closes the socket without reading
any data. No thread is spawned per connection.
wire-probe --mode server [--port <port>] [--bind <addr>]
| Flag | Default | Description |
|---|---|---|
--port |
9999 |
TCP port to listen on |
--bind |
0.0.0.0 |
Bind address (use a private IP to restrict exposure) |
A ready-made unit ships in
packaging/systemd/wire-probe-server.service:
curl -sSfL https://raw.githubusercontent.com/vorjdux/wire-probe/main/packaging/systemd/wire-probe-server.service \
-o /etc/systemd/system/wire-probe-server.service
# adjust --port / --bind if needed
systemctl edit --full wire-probe-server.service
systemctl daemon-reload
systemctl enable --now wire-probe-server
systemctl status wire-probe-serverThe unit assumes the binary is at /usr/local/bin/wire-probe (the default
install.sh location when run as root) and restarts on failure with backoff.
It runs under DynamicUser=yes with a locked-down sandbox: no capabilities, a
read-only filesystem, AF_INET/AF_INET6 only, and SystemCallFilter=@system-service
(which nests @aio, where the io_uring syscalls live). systemd-analyze security rates it 1.3 OK, against 9.4 UNSAFE for the same unit unhardened.
Binding a port below 1024 needs CAP_NET_BIND_SERVICE added back - see the
commented lines in the unit.
Runs on the observer host. Measures the TCP handshake RTT to the target and exports the result on every interval. The target address is resolved once at startup - use an IP address if DNS reliability on your network is a concern.
wire-probe --mode probe --target <host:port> [options]
| Flag | Default | Description |
|---|---|---|
--target |
(required) | host:port of the wire-probe server |
--target-name |
derived from --target |
Label used in metric names |
--az |
default |
Availability-zone tag (Telegraf only) |
--interval |
1000ms |
Time between probes (ms or s suffix); min 100ms, max 24h |
--timeout |
5000ms |
Connect timeout per probe; max 60s |
--export |
collectd-exec |
Export destination (see below) |
--export telegraf-udp://<host>:<port>
Sends one UDP datagram per probe in Influx Line Protocol format:
tcp_latency,target=mdb_primary,az=eu-west rtt_ms=4.12,success=1i 1686561230000000000
A failed probe still emits a point, carrying success=0i and no rtt_ms:
tcp_latency,target=mdb_primary,az=eu-west success=0i 1686561231000000000
This is deliberate. Over fire-and-forget UDP a missing point is
indistinguishable from a lost datagram, so alerting on absence cannot separate
"target down" from "probe down". Alert on success instead, and note that
rtt_ms is absent rather than zero on failure, so averages stay clean.
Telegraf configuration:
[[inputs.socket_listener]]
service_address = "udp://127.0.0.1:8094"
data_format = "influx"--export collectd-exec
Writes PUTVAL lines to stdout. Use with collectd's
Exec plugin:
PUTVAL hostname/wire-probe-tcp/latency-mdb_primary interval=10 N:4.12
A failed probe sends N:U, collectd's "undefined" marker, rather than nothing:
PUTVAL hostname/wire-probe-tcp/latency-mdb_primary interval=10 N:U
<Plugin exec>
Exec "nobody" "/usr/local/bin/wire-probe"
"--mode" "probe"
"--target" "db-host:9999"
"--target-name" "mdb_primary"
"--interval" "10s"
"--export" "collectd-exec"
</Plugin>--export collectd-uds:///var/run/collectd-unixsock
Streams PUTVAL lines directly to collectd's
UnixSock plugin.
A drop-in replacement for collectd's ping plugin - same value types
(ping, ping_droprate, ping_stddev), no recompilation of collectd
required.
Being a Python module, it is loaded via the python plugin - not with a
bare <Plugin wire_probe> block (that will not load the module):
LoadPlugin python
<Plugin python>
ModulePath "/usr/lib/collectd/wire_probe"
Import "wire_probe"
<Module wire_probe>
Host "db-node-01"
Host "db-node-02"
Host "app-node-01"
Port 9999
Timeout 5.0
PingCount 1
</Module>
</Plugin>Accuracy on a busy host. Both the plugin and the Rust probe read the handshake RTT from the kernel (
TCP_INFO.tcpi_rtt, computed from the SYN → SYN-ACK exchange) rather than timingconnect()in userspace. A stopwatch aroundconnect()also times getting the process scheduled again once the handshake completes - inside CPython that means waiting for the GIL, and it lands in the value as latency the network never saw.Measured on loopback, where the true RTT is ~0.005 ms:
p50 p99 max userspace stopwatch, GIL contention 41 ms 461 ms 918 ms kernel tcpi_rtt, same contention0.036 ms 0.067 ms 0.15 ms Rust probe, every core saturated 0.038 ms 0.064 ms 0.07 ms The kernel value is used whenever it is available, with the wall clock as a fallback: non-Linux, a socket with no sample yet, or a handshake whose SYN was retransmitted. That last case matters - the kernel's smoothed RTT reflects the exchange that finally succeeded, so on partial packet loss it would report ~1 ms for a connect that really waited out a second-long RTO, hiding exactly what this probe exists to catch.
tcpi_total_retransis checked and the wall clock wins there. Verified withtc qdisc add dev lo root netem loss 25%: a clean loopback reported p50 0.035 ms, and under loss the same probe reported p50 1023 ms with a maximum of 2054 ms - one and two SYN retransmission timeouts, exactly the wait the kernel value would have hidden.Aggregate "min"exists for that fallback path, where the minimum ofPingCountsamples is the one least contaminated by scheduling.The server side was never implicated: 500 concurrent handshakes against it measured p99 0.37 ms with no failures, no worse than a plain listener with a backlog of 128.
LoadPlugin python is only needed if the python plugin is not already loaded
elsewhere in collectd.conf - drop that line if it is, to avoid a duplicate
LoadPlugin warning at startup.
Where the data lands in InfluxDB: collectd's InfluxDB naming derives the measurement from the plugin name, not the value type - so probes appear in
wire_probe_value(tagtypeinping/ping_droprate/ping_stddev,type_instance= target host), not inping_value. To fold the data into the existingping_valuemeasurement instead, setv.plugin = "ping"inwire_probe.py- at the cost of mixing L4 RTT with real ICMP ping.
Install:
curl -sSf https://raw.githubusercontent.com/vorjdux/wire-probe/main/install-plugin.sh | sudo shOr manually:
mkdir -p /usr/lib/collectd/wire_probe
cp plugin/collectd/wire_probe.py /usr/lib/collectd/wire_probe/
cp plugin/collectd/wire_probe.conf /etc/collectd/conf.d/
systemctl restart collectd # collectd has no reloadSee plugin/collectd/wire_probe.conf for
the full configuration reference.
Requires Rust 1.85+ (the crate is on edition = "2024") and Linux kernel
= 5.1 (for
io_uring).
git clone https://github.com/vorjdux/wire-probe
cd wire-probe
cargo build --releaseStatic musl binary (runs anywhere):
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl| Concern | Decision |
|---|---|
| Async runtime | None - io_uring for the server accept loop, blocking threads for probes |
| Memory baseline | ~460 KB RSS (server), ~440 KB RSS (probe), measured on x86_64/musl |
| Export allocations | Zero - ryu/itoa format into pre-allocated stack buffers |
| Export reliability | Fire-and-forget; no retry, no queue - if Telegraf/Collectd is down, the probe skips |
| Binary size | ~490 KB musl static, ~380 KB glibc (fat LTO, panic = "abort", strip = true) |
wire-probe was built to measure pure L3/L4 Data Plane latency, completely isolated from SDN Control Plane throttling (which heavily skews ICMP ping on cloud providers like Azure) and L7 application bottlenecks. To achieve a zero-footprint observer effect, every architectural decision prioritized bypassing userland overhead.
Including a standard async runtime (like tokio) imposes an unacceptable baseline memory footprint (2–5 MB RSS) and scheduler overhead for a binary whose sole purpose is handling socket file descriptors.
- Server mode (
io_uring): The TCP accept loop is submitted to the Linux kernel's asynchronous submission/completion queues viaio_uring. The daemon maintains an RSS around ~460 KB regardless of load because there are no per-connection allocations - accepted fds are closed immediately with a plainlibc::close. Note: the current implementation uses a single outstanding accept (serial re-arm per connection); throughput is bounded by onesubmit_and_waitsyscall per connection, which is sufficient for telemetry use but not for high-PPS scenarios. - Probe mode (native blocking): Uses
TcpStream::connect_timeouton a blocking thread. The--timeoutflag is the only timing bound - it maps directly to the OS-level connect timeout. The RTT itself is read from the kernel viaTCP_INFO.tcpi_rtt, which measures the SYN → SYN-ACK exchange;Instant::now()around the connect call is the fallback for when the kernel has no sample, and the value used when the handshake needed a SYN retransmission, since the kernel's smoothed RTT would hide that wait. DNS is resolved at startup, outside the measurement, and every returned address is kept: after five consecutive failures the probe re-resolves and moves to the next one, so an unreachable AAAA in front of a working A recovers instead of pinning the process forever.
To guarantee the export hot path runs within the CPU's L1/L2 caches and avoids memory fragmentation during long-running execution, the binary is extremely dense.
- Compiled statically via
musl-libcwith fat LTO andpanic = "abort". Measured on x86_64 with rustc 1.96: the static musl binary is ~490 KB stripped (the glibc build is ~380 KB), with ~460 KB RSS for the server and ~440 KB for the probe. - Heap allocations are eliminated on the export hot path: the metric prefix is built once at construction, the send buffer is reused via
clear(), andryu/itoawrite directly into stack-allocated buffers with noformat!orStringintermediary. The buffer is sized from the prefix length plus fixed headroom for the value and timestamp, so long hostnames or tags do not force a reallocation on the first send.
An observability probe must not block, and must not be brought down, because the downstream telemetry pipeline degraded. How completely that holds depends on the exporter, and only the UDP path is fire-and-forget in the strict sense:
| Export path | Can a stalled consumer block the probe? |
|---|---|
telegraf-udp:// |
No. send() on a UDP socket never blocks on the receiver; the kernel tail-drops. If Telegraf is not listening, the ICMP port-unreachable surfaces as ECONNREFUSED on the next send, which is logged and skipped. |
collectd-uds:// |
Bounded. A stream socket blocks once the kernel buffer fills, so the write timeout is capped at 1s; the send then fails and the probe continues. |
collectd-exec |
Yes, in principle. stdout is a pipe owned by collectd; if collectd stops reading and the pipe fills, the write blocks. Not capped, because a timeout cannot be set on a pipe the way it can on a socket. |
collectd-uds:// also requires collectd to be listening at startup: the connect
is made once, and the probe exits if it fails.
Note also that the release profile sets panic = "abort", so a panic terminates
the process rather than unwinding - the guarantee is about backpressure, not that
the process is panic-proof. Restart supervision is the systemd unit's job.
- By forcing metric injection via UDP datagrams (Telegraf/Influx) or Unix domain sockets (Collectd),
wire-probestructurally outsources backpressure handling to the Linux kernel. - If the destination TSDB stalls or the Telegraf process hangs, the kernel applies a silent tail-drop at the receive buffer. This isolates the probe, shielding it from file descriptor exhaustion or OOM kills.
Collectd's Exec plugin runs a child process once and reads its stdout in a long-lived loop - it does not re-fork per interval. The real cost it imposes is an out-of-process boundary: every read cycle crosses a pipe, a process boundary, and a shell.
wire_probe.pyis a full Python reimplementation of the probe logic (not a wrapper around the Rust binary). It registers directly with collectd's C runtime via the Python plugin API (register_readcallback), running in-process with no child process at all. This eliminates the pipe/process boundary entirely. The trade-off: the Rust binary's musl, zero-alloc, andio_uringproperties do not apply on this path - you get CPython doing blockingsocket.create_connectioncalls. For collectd environments the in-process scheduling and drop-inping/ping_droprate/ping_stddevmetric names make it the right integration point.
MIT - see LICENSE.