Skip to content

Repository files navigation

httpwatch

Every plaintext HTTP request crossing your host, live in a browser tab. Decoded off the wire by eBPF. No proxy, no sidecar, no app changes, one container.

Linux: kernel 6.6+ with BTF and TCXBuilt with yeet + eBPFNative browser components, no framework and no CDNGPL-2.0Discord

httpwatch: a live HTTP endpoint dashboard in the browser

httpwatch is an eBPF HTTP traffic inspector for Linux: it ranks every METHOD host path crossing a host by traffic and lets you click one open and read the actual requests. Count, req/s, p95 latency, status mix, and a live request stream you can click into for the decoded headers and body. A 500 shows you the error it returned, not just the number.

It captures at the kernel's TC layer, so it sees what actually crossed the wire, including loopback. Your app's own access log tells you what your app thinks it served. This tells you what the box did.

Where you'd otherwise reach for tcpdump piped into Wireshark, or bolt a sidecar proxy in front of a service to see its traffic, httpwatch attaches to the interfaces already there. Nothing is rerouted and nothing gets reconfigured.

Tip

It's not a TUI piped to a 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.

Questions this tool answers

How can I see what HTTP requests my server is actually receiving, without changing the application? Run httpwatch on the box. Every request crossing any interface is decoded at the kernel's TC layer and ranked live by METHOD host path, with nothing added to the request path and no redeploy. What you get is what the machine received, which is not always what the app's access log says it served.

One of my endpoints is returning 500s and the logs aren't telling me why. How do I see the actual error response? Click the endpoint, then click the request in its stream. You get the status line, headers and body, dechunked, gunzipped and JSON pretty-printed, so a 500 shows the error it returned instead of just the number. Set BODIES=both and you get the request body too, which is usually what you need to read a 400.

My service is slow and I don't know which endpoint is causing it. How do I find out? Sort the table by p95 and the tail contributors come to the top; sort by REQ/S to catch a spike that hasn't accumulated volume yet, which is the shape a retry storm has before it shows up in totals. Open a row for its full percentile spread and its live request stream. Latency here is on-the-wire, so for a remote caller it includes network RTT: that's what the client experienced, not what the handler spent.

How do I inspect HTTP traffic on a Linux server without tcpdump or Wireshark? httpwatch decodes and aggregates continuously and serves the result to a browser, so there's no capture file to open afterwards and no desktop needed. You can read a remote box from your laptop over a tailnet with no SSH session and no X forwarding. For anything non-HTTP, or for the packet detail this deliberately throws away, those two are still the right tools.

Can I monitor HTTP traffic with eBPF without adding a proxy, a sidecar, or an agent to my app? Yes, and that's the whole design. eBPF programs attach at tcx/ingress and tcx/egress and observe segments as the kernel moves them. Nothing is routed through httpwatch, no port is pointed at it, no application is reconfigured, and traffic is copied rather than held, modified or redirected. A service doesn't know it's being watched.

How do I see the traffic between two services running on the same host? Watch lo. Loopback crosses the TC layer like anything else, so east-west chatter on one box is captured without instrumenting either side. This is the traffic a sidecar proxy sees only half of and a network tap misses entirely.

How do I get request rate, error rate and latency for a route nobody instrumented? All three fall out of the capture: REQ/S, the per-class status tally, and p50 / p95 / max from pairing each response to its request on the wire. Three of the four golden signals with no client library, no exporter and no code change. Saturation isn't one of them, since httpwatch measures traffic rather than the resources serving it.

How do I get a Slack alert when an endpoint starts throwing 5xx? Set a rule on the endpoint, or a catchall across every endpoint on the host, and point it at a channel. It fires on the next tick carrying the specific codes, the contributing endpoints, and optionally the response body, so the alert delivers the error instead of just announcing one. See Alerts to Slack.

Why can't I see my HTTPS traffic? Because TLS encrypts the payload before it reaches the wire, so at this layer there's no request line to parse. That's a property of capturing at the TC layer, not a limitation a flag turns off. Reading it would need a uprobe on SSL_write/SSL_read, which is a different tool. See What it can't see.

Is this a replacement for Datadog, Prometheus, or my APM? No, and it isn't trying to be. There's no retention, no query language and no history beyond what's in memory, so it answers "what is this box doing right now," not "what happened last Tuesday." It's what you reach for when the dashboards say something is wrong and you need the actual bytes. One instance per host, too: there's no aggregation layer, so fleet-wide questions stay with your metrics stack.

When should I use this instead of tcpdump, mitmproxy, or an eBPF platform like Pixie? Use httpwatch when you want decoded HTTP for a whole Linux box, right now, with one docker run and nothing in the request path. Reach for something else when: you need HTTPS (mitmproxy terminates TLS and can decrypt it, at the cost of being a proxy in the path), you need non-HTTP protocols or packet-level detail (tcpdump and Wireshark), you need HTTP/2 or gRPC (see grpcsnoop), or you need cluster-wide service maps and retention across a fleet (Pixie, Coroot, or your APM). httpwatch is deliberately one box, one protocol, no storage, and that's what makes it something you can run in thirty seconds and turn off when you're done.

Contents

Run itHave an agent set it up · Run it with Docker · macOS · From source

Learn itQuestions this tool answers · A 30-second primer · What you're looking at · Alerts to Slack · Without a browser

ReferenceHow it works · Environment · Requirements · What it can't see · FAQ · License

Have an agent set it up

Paste this into a coding agent on the box you want to watch. It runs the dashboard, gives it something to capture, and tells you whether it actually worked.

Get https://github.com/yeet-src/httpwatch running on this machine, then tell me
whether it actually works.
Run the prebuilt image (ghcr.io/yeet-src/httpwatch:latest) — the exact docker run
line, with all the capability flags, is in the README under "Run it with Docker".
Don't drop a flag to make it start; each one is load-bearing and the failure
modes are quiet.
Start the traffic generator BEFORE you judge it: `bash agent/demo/traffic.sh`
(clone the repo for that part). An empty dashboard and a broken dashboard look
identical, so there has to be something on the wire first.
Verify with `curl localhost:8080` — that returns markdown, not HTML, and it
should list endpoints like `GET shop.internal /api/orders` with non-zero counts.
"The container is up" is not the same as "it works". If the table is empty,
check `docker logs httpwatch` for a TCX attach failure, which means the kernel
is older than 6.6.
This is Linux-only in the sense that matters: on Docker Desktop or OrbStack you
are watching the Linux VM, not the Mac. If that's where you are, say so, add
`-p 8080:8080`, and tell me you're showing me the VM's traffic.

Prefer to drive it yourself? It's one command.

Run it with Docker

No clone, no build. The image is multi-arch, so it pulls the right build for amd64 or arm64:

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

Open http://localhost:8080 and the table fills as plaintext HTTP flows. Add sudo if you're not in the docker group.

Every flag on that command is doing something, and dropping one mostly fails quietly:

  • SYS_ADMIN mounts the container-private bpffs. NET_ADMIN attaches the TCX programs. BPF and PERFMON load the program and its maps.
  • apparmor=unconfined lifts Docker's default profile, which denies the bpffs mount even with CAP_SYS_ADMIN. This is the one thing --privileged relaxes that a capability can't, which is why it's here and --privileged isn't.
  • --network=host is what makes the capture real: the probe attaches to your host's interfaces and the server binds your host's port. Without it you'd be inspecting an empty container network.
  • The BTF mount is read-only and a world-readable kernel file. It's what lets the probe CO-RE-relocate to your kernel.
  • The /data mount is the only writable one. It keeps alert rules in ~/.local/state/httpwatch/alerts.json so they survive --rm, and the two STATE_* vars make that file yours instead of root's. Drop the line entirely if you don't want alerts to persist; everything else works the same.

For a persistent deployment, swap --rm -it for -d --name httpwatch --restart unless-stopped, and add -e PUBLIC_URL="http://$(hostname -f):8080" so the links in Slack alerts work for people who aren't on the box.

Give it something to capture

On a quiet box, an empty dashboard is indistinguishable from a broken one. agent/demo/ is a self-contained loopback traffic source:

bash agent/demo/traffic.sh # fake server + a steady request mix on 127.0.0.1:8731

It starts demo/server.py itself, sends a weighted mix of methods, hosts and latencies, prints a heartbeat every couple of seconds, and cleans up on Ctrl-C. Don't start server.py separately; the script refuses a busy port rather than quietly generating traffic for whatever else is listening. PORT=9001 moves it.

/api/orders fails about 8% of the time, so there's something red to click. The bodies are one-liners though. For exercising the response viewer properly, a route returning a real gzipped or chunked error is more interesting.

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 change:

  1. Update Docker Desktop. The VM kernel needs TCX (6.6+), or the probe fails to attach with tcx: -EINVAL.
  2. Publish the port.--network=host captures but doesn't expose the UI to macOS, so add -p 8080:8080.

From source

To build it yourself or hack on it, clone and drive it with the Makefile. It forwards the same environment variables and 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
make up # same, detached and self-healing
make down # stop and remove it

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. The eBPF object and JS bundle compile inside the build via the vendored yeet toolchain (clang, bpftool, esbuild), so you need no system C/BPF toolchain and no local Node. The build is multi-stage: the ~190MB toolchain stays in the build stage, and the runtime image ships only the compiled probe, the bundle and the server.

vmlinux.h is committed here (unlike in httpinspect) because the build sandbox has no /sys/kernel/btf to regenerate it from. CO-RE relocates the object to whatever kernel ends up running the container.

A 30-second primer on HTTP-on-the-wire

What the probe reads:

  • 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 or CONNECT request). Traffic is tallied by that triple.
  • Plaintext only. This works because the bytes on the wire are the request. Under TLS the payload is ciphertext at this layer, so HTTPS is invisible. That's a property of where the capture sits, not a missing feature.

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 rule in one place (it reads 3 · 1 failing when one can't deliver), and a connection indicator. Then the endpoints table, one row per METHOD host path, busiest first. Click a header to re-sort, a row to open its detail. A footer carries totals: requests, endpoints, bytes on the wire, uptime.

columnmeaning
#rank by the current sort
METHODHTTP method
HOSTHost: header, or the authority from an absolute-form target
PATHrequest path, shown in full (wraps, never truncated); query string collapsed unless KEEP_QUERY
COUNTcumulative requests seen for this endpoint
REQ/Srequests in the last second (· when idle)
p9595th-percentile on-the-wire latency
LASThow long ago this endpoint was last hit

The detail panel

Click any row. This is where the browser version goes past what a terminal can do:

  • 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, and 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 with syntax highlighting. With request bodies enabled you get both halves, request first, because reading a 400 usually means reading the payload that caused it. Each half has copy body and copy message buttons, and text inside an open response is freely selectable: dragging in there won't collapse it, only the row itself toggles.

The stream holds still while you read it. Scroll off the top or expand a request and new rows queue behind a "N new requests · click to resume" pill instead of shifting what you're looking at, and nothing is trimmed away underneath you. Scroll back to the top, collapse the request, or click the pill to start following again.

Go full screen when the panel gets tight. The expand icon (or f) hands the endpoint the whole window, with 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 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 with the panel still open.

Alerts to Slack

Create a rule from an endpoint's detail panel ("+ Set alert") or from the alerts: pill, which opens every rule at once, each editable in place. 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. Rules take effect on the next tick: nothing restarts and no counters reset.

Tick every endpoint on this host for a catchall, which is the sane starting point before you know which routes matter. A catchall keeps one cooldown for the whole rule, so a bad deploy across fifty routes is one message naming the worst offenders rather than fifty messages:

httpwatch: any 5xx on any endpoint
10 matching responses since the last alert across 3 endpoints:
GET shop.internal /api/orders ×7
POST auth.internal /login ×2
GET cdn.internal /a.js ×1

Rules live in a plain file you own.~/.local/state/httpwatch/alerts.json survives the container being stopped, deleted and recreated, and it's the authoritative copy: edit it while the container is down and the change is picked up on the next start. It's meant to be hand-edited. It carries only configuration, explains itself in a _readme field, and skips a rule that doesn't validate rather than refusing to start. The minimum viable setup is one line:

{ "rules": [ { "key": "*", "when": "5xx" } ] }

Timing lives in a separate alerts.state.json that the server owns. That split is deliberate: keeping timestamps out of the rules file is what lets it stay short enough to read, and persisting them separately is what stops a restart from re-opening every quiet period. Without it, a container in a restart loop would alert on every loop. State is keyed by what a rule is (endpoint plus condition) rather than by its id, so reordering the file never applies one rule's timing to another.

Why delivery is a subprocess, and other implementation notes

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. That buys runtime-editable rules for the cost of a process per alert.

What an alert looks like. 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), a header naming the condition and endpoint, the match count with an Open in httpwatch button, which status codes it was (500 ×2 · 503 ×1, the thing a class-wide rule would otherwise never tell you), the contributing endpoints as a list of up to ten with the rest counted, the response body if the rule asked for one, and a footer with the interface, quiet period and time in each reader's own timezone.

The button needs a routable URL. Slack validates a button's target when the message is posted and refuses a hostname with no dot, which is exactly what the link is by default since it's learned from the Host header. So the alert checks 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 browser and Slack never fetches it, so an internal address is fine. Set PUBLIC_URL to a dotted name or an IP to get the button.

Delivery walks down a ladder (coloured with a button → coloured with a link → blocks with a button → blocks with a link → plain text) and logs any downgrade, because Slack validates presentation as a whole and rejects it as a whole. The button is given up before the colour: a refused URL is the likeliest rejection, and losing the colour, the codes and the endpoint list over a link that renders fine as text would be a bad trade.

Bodies in alerts are opt-in per rule because they post a payload into a Slack channel, and Slack has no spoiler markup, nothing that hides it behind a click the way Discord's ||…|| does. It goes in a preformatted block, the most contained thing Slack offers, and because that block carries literal text rather than markup, nothing in a payload can break out or be reinterpreted as formatting. When there's no body to send, the alert says which reason it was (capture off, body budget dropped it, or evicted from the store before the alert fired) rather than looking like an empty response.

Detection diffs each snapshot's status tallies rather than reading the streamed request rows. The tallies are cumulative aggregates that never lose a response; rows are subject to the per-frame row budget. A probe restart re-baselines instead of alerting on the reset, but cooldowns deliberately survive it, so toggling interfaces isn't a way around the throttle.

Is Slack connected? The exporter polls yeet.caps() every 30s (it's isolate-only, so nothing else can ask). Connected means nothing to report; definitely-not gets a banner linking to yeet.cx/settings and an N failing pill; unknown (not logged in, call failed or timed out) gets a softer note that delivery is unverified. Not-connected doesn't block creating rules, since configuring alerts before wiring Slack is a normal order to work in. Delivery is still attempted on unknown, because a capability hiccup must never silence alerting.

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| # | Endpoint | Reqs | req/s | p50 ms | p95 ms | Status | Detail || 1 | `GET shop.internal /api/orders` | 4,120 | 3.2 | 4.2 | 31.0 | 200 ×4,102 · 500 ×16 | [open](/detail?endpoint=GET%20shop.internal%20%2Fapi%2Forders&format=md) |

The page is a shell that hydrates from an inlined snapshot and then streams SSE, so without this the most convenient way to inspect a host, asking it over HTTP, was the one way that didn't work. The same three URLs answer in either representation, and the markdown carries its own links, so a reader starting at / can reach a decoded response body without being told how:

DepthURLWhat 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=mdone 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

How the view is chosen. For the two page routes, anything that doesn't ask for text/html 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 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 outright. Its callers are fetch(), which sends a wildcard Accept, including the dashboard's own body viewer, so negotiating there handed 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's 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/, and its README is the deep dive on the kernel side. The one new piece here 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 (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/export.js headless entry: samples the signals → a JSON snapshot per second
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
bodies.js the captured-body store: reassembles streamed parts, serves by id
auth.js host login — yeet whoami / yeet login
alerts.js 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 server
Dockerfile · Makefile multi-stage build; one slim image (yeetd + server + probe)

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 and 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 build points esbuild at export.js instead of the TUI's main.jsx, so the bundle is the exporter.

Environment

Pass with -e VAR=…. The interface set and body capture are also editable live in the UI.

vardefaultmeaning
PORT8080port the dashboard is served on (bound on the host, so it must be free)
IFACEall up ifacescomma-separated interfaces to watch, e.g. lo,eth0 (initial set)
KEEP_QUERYoffkeep query strings distinct, so /x?id=1 and /x?id=2 stay separate rows
BODIESresponsewhich bodies to capture: none, response, or both. both includes request bodies, which is where passwords and tokens live, and anyone who can reach the dashboard can then read them
BODY_STORE_BYTES67108864how much captured body the server holds for reading back (64MB). Oldest evicted first; a row whose body is gone says so
BODY_STORE_MAX4000how many exchanges the body store holds, whichever limit is hit first
SLACK_CHANNEL#alertschannel new alert rules default to
PUBLIC_URLlearned from the Host headerbase URL for the "open in httpwatch" link in alerts. A dotted host or IP also gets a real Slack button; see Alerts
ALERTS_FILE/data/alerts.jsonwhere rules are persisted inside the container; empty keeps them in memory only
STATE~/.local/state/httpwatch(make only) host directory bind-mounted at /data. Any value containing / is a host path; a bare name uses a docker volume
RECENT_ROWS500how many 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_KEYlog the host in at startup

Requirements

Important

  • A Linux host (or a Linux VM you want to observe) with BTF + TCX, kernel 6.6+. The default on current Fedora, Arch, Ubuntu and Debian 12+. CO-RE means no per-kernel recompile.
  • Docker that can grant the eBPF capabilities and lift AppArmor. The container runs with SYS_ADMIN, NET_ADMIN, BPF, PERFMON, --security-opt apparmor=unconfined and --pid=host --network=host, but not--privileged, plus a read-only mount of the host's kernel BTF. The bpffs is private to the container; nothing else is shared.
  • macOS/Windows Docker Desktop watches the VM, not your laptop. Handy for inspecting your other containers, but for host-level capture use Linux. See Running on macOS.

Where captured data goes. The heads and bodies stay in the server's memory and travel to exactly two places you choose: your browser, and any Slack channel you point an alert at. Nothing is shipped anywhere else. Which also means the dashboard's port is the boundary that matters: anyone who can reach it can read the bodies you captured, so put it on a tailnet or behind a reverse proxy rather than on the public internet.

What it can't see

httpwatch is observability, not enforcement. It tells you what crossed the wire; it does not stop, hold or modify anything.

  • Plaintext HTTP only. TLS payloads are ciphertext at this layer, so HTTPS is invisible. Capturing it would need a uprobe on SSL_write/SSL_read, which is a different tool. (Talk to us about custom yeet scripts.)
  • HTTP/1.x only, and nine methods. Detection matches a leading ASCII method token (GET, PUT, HEAD, POST, TRACE, PATCH, DELETE, OPTIONS, CONNECT) or a HTTP/ status line. HTTP/2 and HTTP/3 are binary with compressed headers, so cleartext h2c and prior-knowledge gRPC don't match and won't appear. If your internal services speak h2c, expect an empty table. For gRPC specifically, see grpcsnoop.
  • Bodies are bounded by a budget, not by the message. Roughly 64KB per message, ~256KB for a 4xx/5xx, ~32KB from any single segment, and metered in aggregate. What doesn't fit is truncated and flagged as such, with the UI reporting how much of the sender's Content-Length you're actually looking at. It's never silently shortened.
  • Latency is on-the-wire, not server-internal. It's the time between request and response segments as seen at this host's TC layer, so it includes network RTT for remote hosts. Responses pair to requests FIFO per flow, which is correct for ordered HTTP/1.x but approximate under pipelining. Unmatched requests are dropped after 10s.
  • Counts are a close lower bound, not an exact tally. Under heavy load or a slow link some segments aren't captured. Rows that exceed the per-frame budget are counted in recentDropped rather than disappearing quietly.
  • The endpoint table survives what the request stream doesn't. Aggregates are diffed from cumulative tallies, so they never lose a response even when individual rows get dropped. If a number in the table and a count in the stream disagree, trust the table.
  • IPv6 packets carrying TCP behind extension-header chains (rare) are skipped.
  • Changing the watched interfaces restarts the probe, which resets every counter and invalidates held bodies. That's a spawn-time argument with no control channel into a live isolate, not an oversight.

FAQ

Do I have to clone the repo? No. docker run … ghcr.io/yeet-src/httpwatch:latest runs the prebuilt image. Cloning is only for building, hacking, or getting the demo traffic generator.

Why did my counts reset? You changed the watched interfaces in the UI, which restarts the probe. Capture settings are spawn-time arguments and there's no control channel into a running isolate, so changing them means a restart, and a restart means new counters.

Do I need to be logged in to yeet? The host does, and the dashboard gates on it. Logged out, the UI offers a sign-in button that drives the ordinary device flow; YEET_AUTH_KEY skips the click for a deployment. /healthz answers either way.

Is it safe to put this on the internet? No. The dashboard shows decoded request and response bodies, and with BODIES=both those include credentials. Treat the port as the security boundary and keep it on a tailnet, a VPN, or behind a reverse proxy with real auth.

The dashboard is empty but I know there's traffic. What's wrong? Three usual causes, in order. It's HTTPS, so there's nothing to parse at this layer. It's HTTP/2 or h2c, which this doesn't decode. Or the probe never attached, which means a kernel older than 6.6 and a tcx: -EINVAL in docker logs httpwatch. On a quiet box, confirm the pipeline first with bash agent/demo/traffic.sh.

How is this different from tcpdump or Wireshark?tcpdump gives you packets and leaves you to reassemble streams; Wireshark does that well but wants a capture file and a desktop. httpwatch decodes HTTP continuously for the whole box, including loopback, and serves it to a browser over the network. What it gives up is everything non-HTTP and everything encrypted, which is what those two are still for.

License

GPL-2.0.


Built with yeet, a JS runtime for writing eBPF programs on Linux machines. Join us on discord.

About

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 changes; one Docker command.

Topics

Resources

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages