yeet-src/httpwatch
http requests served to your browser. Every plaintext HTTP request crossing the box — decoded off the wire by eBPF and rendered live in native browser components. No proxy, no sidecar, no app chang...
5 stars · 0 forks · 0 watchers
README
httpwatch
httpinspect, served to your browser. Every plaintext HTTP request crossing the box, decoded off the wire by eBPF and rendered live in the browser. No proxy, no sidecar, no app changes — one Docker command.
httpwatch turns the plaintext HTTP crossing your host into a live web dashboard. Every METHOD host path endpoint, ranked by traffic, with a running count, req/s, and p95 latency. Click a row for a live detail panel — that route's individual requests newest-first and color-coded by status class, plus percentiles, a status-code mix, and a req/s sparkline. Same eBPF capture as httpinspect, rendered in the browser instead of a terminal. TipIt's not a TUI piped to the browser. The probe ships raw aggregated JSON out of the yeet isolate; the page receives it over SSE and draws its own table, panels, and sparklines in plain DOM — no framework, no CDN, no terminal emulator.
Contents Run it — Quick start (Linux) · macOS · From source Learn it — HTTP-on-the-wire primer · Use cases · What you're looking at · Without a browser · How it works Reference — Building from source · Try it without traffic · What the dashboard reports · Requirements · FAQ · License Quick start One command — no clone, no build. The image is multi-arch, so it pulls the right build for amd64 or arm64 automatically: docker run --rm -it \ --cap-add SYS_ADMIN \ --cap-add NET_ADMIN \ --cap-add BPF \ --cap-add PERFMON \ --security-opt apparmor=unconfined \ --pid=host \ --network=host \ -v /sys/kernel/btf/vmlinux:/sys/kernel/btf/vmlinux:ro \ -v "$HOME/.local/state/httpwatch:/data" -e STATE_UID="$(id -u)" -e STATE_GID="$(id -g)" \ ghcr.io/yeet-src/httpwatch:latest # → http://localhost:8080 SYS_ADMIN mounts the container-private bpffs, NET_ADMIN attaches the TCX programs, and BPF/PERFMON load the program and its maps. apparmor=unconfined lifts Docker's default profile, which otherwise denies the bpffs mount — it's the one thing --privileged relaxes that a capability can't. The read-only BTF mount (a world-readable kernel file) lets the probe CO-RE-relocate to your kernel. The last line is the only writable mount: it keeps your Slack alert rules in ~/.local/state/httpwatch/alerts.json so they survive --rm, and the two STATE_* vars just make that file yours rather than root's. Drop that line entirely if you don't want alerts to persist — everything else works the same, rules simply live and die with the container. Open http://localhost:8080 (or http://<host>:8080 over your network) and the table fills as plaintext HTTP flows. On Linux, add sudo if you're not in the docker group; on macOS, see Running on macOS. Tune it with environment variables (-e VAR=…); the interface set and body capture are also editable live in the UI:
var default meaning
PORT 8080 port the dashboard is served on (bound on the host — must be free)
IFACE all up ifaces comma-separated interfaces to watch, e.g. lo,eth0 (initial set)
KEEP_QUERY off keep query strings distinct — /x?id=1 and /x?id=2 stay separate rows
BODIES response which message bodies to capture: none, response, or both. both captures request bodies too — that's where passwords and tokens live, and anyone who can reach the dashboard can then read them
BODY_STORE_BYTES 67108864 how much captured body the server holds for reading back (64MB). Older exchanges are evicted first; a row whose body is gone says so
BODY_STORE_MAX 4000 how many exchanges the body store holds, whichever limit is reached first
SLACK_CHANNEL #alerts channel new alert rules default to
PUBLIC_URL learned from the Host header base URL for the "open in httpwatch" link in alerts, e.g. https://watch.example.com. A dotted host or IP also gets a real Slack button instead of a plain link — Slack rejects buttons pointing at localhost or any dotless name
ALERTS_FILE /data/alerts.json where alert rules are persisted inside the container; set to empty to keep them in memory only
STATE ~/.local/state/httpwatch (make only) host directory bind-mounted at /data, so alert rules outlive the container. Any value containing / is a host path; a bare name uses a docker volume instead
RECENT_ROWS 500 how many individual exchanges the server keeps for the markdown views (the browser accumulates its own from SSE; a one-shot reader can't). 0 disables the tail
YEET_AUTH_KEY — log the host in at startup
For a persistent deployment, run it detached and self-healing: docker run -d \ --name httpwatch \ --restart unless-stopped \ --cap-add SYS_ADMIN \ --cap-add NET_ADMIN \ --cap-add BPF \ --cap-add PERFMON \ --security-opt apparmor=unconfined \ --pid=host \ --network=host \ -v /sys/kernel/btf/vmlinux:/sys/kernel/btf/vmlinux:ro \ -v "$HOME/.local/state/httpwatch:/data" -e STATE_UID="$(id -u)" -e STATE_GID="$(id -g)" \ -e PUBLIC_URL="http://$(hostname -f 2>/dev/null || hostname):8080" \ ghcr.io/yeet-src/httpwatch:latest For a long-lived deployment the state mount matters more, not less — it's what keeps alert rules across image updates — and PUBLIC_URL makes the links in Slack alerts work for people who aren't on the box. Running on macOS (Docker Desktop) Docker Desktop runs a Linux VM shared by all your containers, so --network=host lets the probe watch your other containers' plaintext HTTP — not your Mac's own apps, which live outside the VM. Two things to get right:
Update Docker Desktop — the VM kernel needs TCX (6.6+), or the probe fails to attach with tcx: -EINVAL. Publish the UI port — --network=host captures but doesn't expose the UI to macOS, so add -p $PORT:$PORT.
export PORT=8080 # any free port on your Mac; export so $PORT expands below docker run --rm -it \ --cap-add SYS_ADMIN \ --cap-add NET_ADMIN \ --cap-add BPF \ --cap-add PERFMON \ --security-opt apparmor=unconfined \ --pid=host \ --network=host \ -e PORT=$PORT \ -p $PORT:$PORT \ -v /sys/kernel/btf/vmlinux:/sys/kernel/btf/vmlinux:ro \ ghcr.io/yeet-src/httpwatch:latest # → http://localhost:$PORT From source To build it yourself or hack on it, clone and drive it with the Makefile (VAR=… make run forwards the same env vars, and it falls back to sudo docker automatically): git clone git@github.com:yeet-src/httpwatch.git && cd httpwatch make run # build the image, run yeetd + server + probe, serve on :8080 The first make run builds a self-contained image (base, yeet toolchain, eBPF object, yeetd) — a few minutes, internet needed once; after that it starts in seconds. Other targets: make up # detached + --restart unless-stopped (persistent deployment) make down # stop and remove it A 30-second primer on HTTP-on-the-wire What the probe reads (identical to httpinspect):
A request is text. An HTTP/1.x request opens with a request line — GET /path HTTP/1.1 — then headers, then a blank line. The first bytes of the TCP payload are that line. The endpoint is METHOD host path. Method and path from the request line; host from the Host: header (or the absolute-form target on a proxied/CONNECT request). Traffic is tallied by that triple. Plaintext only. It works because the bytes on the wire are the request. Under TLS the payload is ciphertext here, so HTTPS is invisible.
Common use cases A ground-truth view of the plaintext HTTP crossing a host — from a browser, over the network, no terminal on the box:
A service is slow — which endpoint is getting hammered, at what rate? Open its detail for the request stream and p95. Suspected retry storm or 5xx wave — sort by REQ/S, click the route, watch responses tick past color-coded. Auditing a remote box over your tailnet — what plaintext HTTP is flowing, and to which hosts, with no SSH or TUI. Local microservices over lo — see the chatter without instrumenting any of them.
What you're looking at A top bar with the watched interfaces (click the iface: pill to change them live), a bodies: pill for what gets captured, an alerts: pill that opens every alert rule in one place (it reads 3 · 1 failing when one can't deliver), and a connection indicator; the endpoints table, one row per METHOD host path sorted busiest-first (click a header to re-sort, a row to open its detail); and a footer with totals — requests, endpoints, bytes on the wire, uptime.
column meaning
# rank by the current sort
METHOD HTTP method
HOST Host: header (or authority from an absolute-form target)
PATH request path, shown in full (wraps, never truncated); query string collapsed unless KEEP_QUERY
COUNT cumulative requests seen for this endpoint
REQ/S requests in the last second (· when idle)
p95 95th-percentile on-the-wire latency
LAST how long ago this endpoint was last hit
Click any row for the detail panel — the live breakdown where the web version goes beyond the TUI:
total requests and share of traffic, current and peak req/s
latency p50 / p95 / max, from pairing each response with its request on the wire
status codes by class (2xx / 3xx / 4xx / 5xx)
a req/s sparkline over the last minute
a live request stream — completed requests newest-first, color-coded by status class (2xx green · 3xx cyan · 4xx yellow · 5xx red), each with status, latency, and a ms timestamp
click any request in that stream to read it — status line, headers, and body, un-chunked, un-gzipped and JSON pretty-printed (and syntax-highlighted) in the browser, so a 500 shows you the actual error instead of just the code. With request bodies enabled you get both halves of the exchange, request first — reading a 400 usually means reading the payload that caused it. Each half has copy body and copy message buttons (the message is the head exactly as captured plus the decoded body), and text inside an open response is freely selectable — clicking or dragging in there won't collapse it, only the row itself toggles
Slack alerts — "+ Set alert", pick a condition (any 5xx, any 4xx, either, or one specific code), a channel, a quiet period, and optionally the response body so the alert carries the actual error. Every alert links back to the endpoint's panel here. Tick every endpoint on this host for a catchall instead of a single route — the sane starting point before you know which routes matter. The rule takes effect on the next tick: nothing restarts and no counters reset. Matches keep counting while a rule is cooling down, and the next alert says how many it covered and where.
Everything updates in place over SSE. Collapse the panel with the drawer icon or Esc; Esc closes an open response first. Go full screen when the panel gets tight. The expand icon (or f) hands the endpoint the whole window: the list steps aside, the aggregates take a column, and the request stream gets the full height — plus taller header and body views, so reading a response stops being a scroll through a 460px column. It's a real URL — /detail?endpoint=<METHOD host path> — so the full-screen view can be pasted to someone and it opens the way you left it. Expanding pushes a history entry, so ← (or Esc) drops you back to the list with the panel still open. The request stream holds still while you read it. Scroll off the top or expand a request and new rows queue up behind a "N new requests · click to resume" pill instead of shifting what you're looking at — and nothing gets trimmed away underneath you. Scroll back to the top, collapse the request, or click the pill to start following again. Reading it without a browser curl the dashboard and you get markdown, not the HTML shell: $ curl localhost:8080
httpwatch
- Probe: running
- Watching: `lo` (available: `lo`, `eth0`)
- Requests: 12,481 across 37 endpoints
Depth URL What it gives you
1 / probe state, totals, the endpoint table — every row linking to its detail page. &limit= up to 500, &sort=count|rate|p95|p50|bytes|last|errors
2 /detail?endpoint=<METHOD host path> percentiles, status-code shares, req/s for the last minute as numbers, and the tail of individual exchanges — each linking to its body. &rows= up to 500
3 /api/body/<id>?format=md one exchange's head and body, decoded — dechunked, gunzipped, fenced, with a note when the capture cut it short. &dir=req|res|both, &max= up to 200,000 characters
Every page also lists the JSON routes (/healthz, /api/alerts, /events) for anything that would rather parse than read. How the view is chosen. For the two page routes, anything that doesn't ask for text/html in its Accept header gets markdown — plain curl, wget, a script, an agent — and a browser gets the app. No User-Agent sniffing: a list of bot names is wrong the day something new appears. Override it either way with ?format=md or ?format=html, or fetch /index.md and /detail.md. HTML responses advertise their twin with a Link: <…?format=md>; rel=alternate header, and both carry Vary: Accept. /api/* is exempt and keeps returning JSON unless the URL asks for markdown outright (?format=md or a .md suffix). Its callers are fetch(), which sends a wildcard Accept — including the dashboard's own body viewer — so negotiating there would hand the page markdown where it expected JSON. Nothing is lost: every markdown link to a body carries format=md already. The login gate still applies. Logged out, these return 401 with markdown explaining that the host needs yeet login — there is no header a caller could add, so a bare 401 would send someone hunting for a token that doesn't exist. /healthz needs no login and stays JSON. How it works The eBPF capture is httpinspect verbatim, vendored under agent/. The one new piece is a headless entry that prints JSON instead of drawing a TUI; the rest is the node server and the browser app. ┌──────── Docker container (--cap-add SYS_ADMIN,NET_ADMIN,BPF,PERFMON · host pid+net) ───────┐ │ yeetd ◄── privileged BPF load ── [ yeet isolate: the httpinspect exporter (agent/) ] │ │ │ probe.js + httptop.js (unchanged capture) │ │ │ export.js (NEW: samples signals → JSON/1s) │ │ ▼ console.log(JSON) snapshot/1s + body parts │ │ yeet console WebSocket portal │ │ │ │ │ node server ── connects as a ws client ── latest snapshot + captured-body store │ │ :8080 ├─ GET / dashboard HTML, snapshot inlined for instant hydration │ │ ├─ GET /events SSE: one snapshot per tick ─────────► browser (DOM) │ │ └─ GET /api/body/N one exchange's head + body, on demand ◄── row expanded │ └────────────────────────────────────────────────────────────────────────────────────────────┘
agent/ the httpinspect exporter (vendored, unchanged capture) src/probes/probe.js loads the shared BPF object, attaches TCX, exposes `control` src/probes/httptop.js ingest: parse, pair responses for latency, aggregate → signals src/lib/format.js pure formatters (percentiles, sparkline scaling) src/export.js NEW headless entry: samples the signals → prints a JSON snapshot/1s src/main.jsx the original TUI entry (kept for reference; unused by the web build) server/ portal.js spawns the exporter isolate, connects its console WS portal, parses snapshots bodies.js the captured-body store: reassembles the streamed parts, serves them by id auth.js host login — yeet whoami / yeet login (scrapes the login URL) alerts.js per-endpoint alert rules: status-tally diffing, cooldowns, delivery slack-alert.yeet.js one-shot yeet script that calls yeet.alert (the only place it can run) index.js HTTP + SSE server: inline hydration, live iface switching, respawn markdown.js the same pages as markdown for anything that isn't a browser decode.js dechunk + gunzip a captured body (shared by alerts and markdown) public/ index.html · style.css · app.js (native dashboard, no framework/CDN) docker/entrypoint.sh mount a private bpffs → start yeetd → wait for socket → start the server Dockerfile · Makefile multi-stage build; one slim image (yeetd + server + probe)
The yeet side agent/src/probes/ is the only BPF-aware code — it loads the object, attaches the two TC programs, and ships decoded http_events over a ring buffer; httptop.js parses method + Host + path, pairs responses with requests for on-the-wire latency, and aggregates into reactive signals. export.js reads those signals and prints a JSON snapshot once a second (the endpoint table plus newly completed request/response pairs). The build points esbuild at export.js instead of the TUI's main.jsx, so the bundle is the exporter. For the capture internals, see httpinspect. Alerts Rules can be created from an endpoint's detail panel ("+ Set alert") or from the alerts: pill in the top bar, which opens every rule at once — each row editable in place (condition, channel, quiet period), deletable, and showing when it last fired or why it failed. Clicking a rule's endpoint jumps to that endpoint. Editing a rule keeps its cooldown if it still watches the same thing, and resets it if you repoint it, so a rule can't inherit a quiet period it never earned. yeet.alert only exists inside a yeet isolate — there's no CLI or HTTP equivalent — and the running exporter can't be told about new rules (no control channel into a live isolate, and restarting it would reset every counter). So rules live in the server and delivery is a one-shot yeet run server/slack-alert.yeet.js per notification, which buys runtime-editable rules for the cost of a process per alert. What an alert looks like. It's a Block Kit message with a coloured bar down its left edge — red for server errors, amber for client errors, blue for one specific code — carrying a header that names the condition and the endpoint, the match count with an Open in httpwatch button beside it, which status codes it was (500 ×2 · 503 ×1 — the thing a class-wide rule like any 5xx would otherwise never tell you), the endpoints that contributed as a bulleted list of up to ten with the rest counted, the response body if the rule asked for one, and a footer with the interface, the quiet period, and the time rendered in each reader's own timezone. A single-endpoint rule skips the list, since its header already says where. The button needs a routable URL. Slack validates a button's target when the message is posted and refuses a hostname with no dot — http://localhost:3000, http://httpwatch:8080, a bare container or LAN name — which is exactly what the link is by default, since it's learned from the Host header. So the alert checks the URL first and falls back to an ordinary mrkdwn link, which Slack doesn't validate that way and which works just as well: it opens in the reader's own browser, and Slack never fetches it, so pointing at an internal address is fine. Set PUBLIC_URL to a dotted name or an IP to get the button. IPs and IPv6 literals pass. The server passes those parts separately and the yeet script owns the layout, so t
