LogWisp: from a tail-and-grep habit to a log transport

3 min read

LogWisp began as a way to stop doing the same thing every morning: SSH into each server, tail -F a file, grep it, repeat on the next one. The platforms that solve this are sized for fleets; my servers are a handful, their traffic is low, and I wanted one static binary that streams, filters and forwards logs and asks for nothing else. This is how that binary found its shape.

The first cut

The prototype watched a directory and pushed every new line to a browser as Server-Sent Events. SSE won over WebSockets because it is plain HTTP: one GET, a stream of events, and the browser reconnects on its own. A raw TCP listener came next for machines on the same network, where TLS was somebody else’s job.

It worked, and it was a dead end! A filter, a second destination, a cap on a noisy producer: each meant surgery in the one loop that did everything.

The pipeline

The rewrite made the pipeline the unit, and everything since has been a part that plugs into one:

Sources
Files through rotation, stdin, or another LogWisp node over TCP or HTTP.
Flow
A rate limit with a token bucket and a size cap, include and exclude patterns in order, then a formatter: raw, text or JSON.
Sinks
The console, rotating files, a live SSE or TCP stream, or the next node.

Every queue between them is bounded. A full one drops its own entries and counts them, so a slow reader costs only itself and never stalls a producer. Pipelines are independent, and a changed file rebuilds them in place; a candidate that fails validation leaves the running ones alone.

Transport

Chaining is what turned a tail into a transport. A chain sink hands the next node the structured entry, time, level, message, fields and origin, not a formatted line, so a relay can filter and reformat as if the entry were local and every record keeps the node it came from. An edge tails its files and dials an aggregator; the aggregator listens for its edges and serves one stream or writes one archive. Two presets, edge and aggregator, build that from a few keys; pipe, tail and serve cover the single-machine cases.

Security, once there was a network

TLS 1.2 and 1.3 everywhere, mutual TLS with identity-based authorization, and Argon2id for stored passwords. For links that stay plaintext there is Argon2id-SCRAM: a challenge-response login that doesn’t puts the password (or its hash) on the wire. It does not defeat an attacker in the middle; it keeps unauthorized clients from opening a connection at all, which is the threat on an internal network. Every listener can also refuse addresses before the handshake, cap a client’s connections, and read the real address from a load balancer’s PROXY header. Unknown configuration keys are refused rather than ignored.

Where it is now

lw composes its own configuration: lw --tui on a terminal, and the Configure tab on this site, both running the same engine, the second compiled to WebAssembly. The pipeline behind the ViF lobby’s live log is the same binary on this machine. The code and the documentation are on GitHub.