Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
08b7a7a
Capture HTTP across ECS awsvpc task network namespaces
julian-goldstein Jun 24, 2026
ca24e3e
TUI table: status / RPS / P99 / flexible host columns
julian-goldstein Jun 24, 2026
458a17e
Inspect pane: 4 KB capture + scrollable, syntax-colored JSON
julian-goldstein Jun 24, 2026
93858d2
Vibrant color palette: drop dim/grey throughout
julian-goldstein Jun 24, 2026
eaec04c
Detail screen: payloads as an accordion
julian-goldstein Jun 24, 2026
ab06d41
Highlight the selected accordion payload with a full-width bar
julian-goldstein Jun 24, 2026
d5c2954
Detail accordion: line-scroll long bodies + wrap long values
julian-goldstein Jun 24, 2026
f3a0d8c
Drop the highlight bar on the expanded accordion payload
julian-goldstein Jun 24, 2026
8b86de4
Detail: three-pane transaction inspector (in/out, scrollable panes)
julian-goldstein Jun 24, 2026
46650f6
Detail: full-width stacked panes, drop the txn-list highlight block
julian-goldstein Jun 24, 2026
c686d16
Detail: Wireshark-style requests table → drill into body
julian-goldstein Jun 24, 2026
7e9ba86
Detail table: width-based columns, color, and body section markers
julian-goldstein Jun 24, 2026
e17c61c
Detail: collapsible headers/body sections + selected-row highlight
julian-goldstein Jun 24, 2026
90daed7
Detail body view: in/out as Tab-toggled tabs; arrows back out
julian-goldstein Jun 24, 2026
9a57315
Loud in/out tabs: bright filled bg (cyan in, pink out) with dark ink
julian-goldstein Jun 24, 2026
0b47f64
Endpoints list: → drills into requests (like Enter)
julian-goldstein Jun 24, 2026
4c6ec26
Endpoints list: drop the › cursor (row tint already marks selection)
julian-goldstein Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,28 @@ yeet run . # watch every up interface, including loopback

With any plaintext HTTP flowing on the box, that's it — `httpinspect` enumerates the up interfaces, attaches at the TC layer, and starts ranking endpoints. Flags tune what it watches and how it groups (pass them after `--`, so the runtime routes them to the script):

| flag | default | meaning |
| ---------------- | ------------- | -------------------------------------------------------------------- |
| `--iface=<list>` | all up ifaces | comma-separated interface names to watch, e.g. `--iface=lo,eth0` |
| `--keep-query` | off | keep query strings distinct — `/x?id=1` and `/x?id=2` stay separate rows instead of collapsing into one |
| flag | default | meaning |
| ------------------ | -------------- | -------------------------------------------------------------------- |
| `--iface=<list>` | all (wildcard) | comma-separated interface names to watch, e.g. `--iface=lo,eth0`. Unset → the daemon wildcard-attaches every host interface (this skips `lo`); naming interfaces keeps `lo`. |
| `--keep-query` | off | keep query strings distinct — `/x?id=1` and `/x?id=2` stay separate rows instead of collapsing into one |
| `--no-tasks` | off | don't attach into ECS task network namespaces (host netns only) |
| `--task-loopback` | off | also hook the in-task `lo` (the 127.0.0.1 leg); best-effort, TCX-on-lo EINVALs on some kernels |
| `--reconcile-ms=N` | 5000 | how often to re-scan for ECS tasks that started or stopped |
| `--selftest` | off | headless capture check — aggregate for ~4s, print counts, exit (no TUI) |

```sh
yeet run . -- --iface lo,eth0 # only these interfaces
yeet run . -- --iface lo,eth0 # only these interfaces (explicit list keeps lo)
yeet run . -- --keep-query # /x?id=1 and /x?id=2 stay separate rows
yeet run . -- --task-loopback # also capture same-task 127.0.0.1 upstreams
```

On a host daemon, requests served from **ECS awsvpc tasks** live in each task's
own network namespace, which a host-netns attach can't reach. `httpinspect`
discovers running tasks (by their `/ecs/<taskId>` cgroup) and attaches into each
task's netns, re-scanning every `--reconcile-ms` as tasks come and go. Running
it as a sidecar inside the task instead captures that task's `eth0` + `lo`
directly.

Runs until `Ctrl-C`. Resize the terminal and the table reflows; needs a real terminal (it's a TUI — don't pipe or redirect the output).

## A 30-second primer on HTTP-on-the-wire
Expand Down Expand Up @@ -218,7 +230,7 @@ Because it's encrypted before it hits the wire. At the TC layer the payload is c
That's the `Host:` header the client sent. Services addressed by name show their name; those addressed by IP show the IP.

**Can I get a quick check without the full TUI?**
Yes. `yeet run src/probes/probe.js` attaches the probe, aggregates for ~4s, and prints the counts before exiting — a headless sanity check of the capture + parse pipeline.
Yes. `yeet run . -- --selftest` attaches the probe (host + each ECS task netns), aggregates for ~4s, and prints the counts before exiting — a headless sanity check of the capture + parse pipeline. (It's a flag, not a separate entry: `yeet run` bundles the whole app into one `src/index.jsx`, so `import.meta.main` can't distinguish the probe from the app.)

## License

Expand Down
16 changes: 7 additions & 9 deletions src/bpf/httptop.bpf.c
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

#define TCX_NEXT (-1) /* passive observer: run next prog / default-pass */

#define DATA_MAX 512 /* must be a power of two (see mask below) */
#define DATA_MAX 4096 /* must be a power of two (see mask below) */
#define MIN_REQ 16 /* "GET / HTTP/1.1\r\n" is already 16 bytes */

#define DIR_EGRESS 0
Expand All @@ -39,8 +39,6 @@
#define KIND_REQUEST 0
#define KIND_RESPONSE 1

#define RESP_CAP 32 /* responses: only the status line is parsed */

struct http_event {
__u64 ts; /* bpf_ktime_get_ns() at capture (monotonic) */
__u16 sport;
Expand All @@ -59,7 +57,7 @@ __attribute__((used)) static const struct http_event __http_event_anchor;

struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 8 << 20);
__uint(max_entries, 16 << 20); /* larger events (4 KiB) → more headroom */
} events SEC(".maps");

/* Does the 8-byte prefix begin with an HTTP method token (method + space)? */
Expand Down Expand Up @@ -157,12 +155,12 @@ static __always_inline int handle(struct __sk_buff *skb, __u8 dir)
else if (is_http_response(m)) kind = KIND_RESPONSE;
else return TCX_NEXT;

/* Requests carry the line + Host header (parsed in JS); responses only need
the status line, so cap them short to spare ringbuf bandwidth. */
/* Copy the captured prefix of this first segment — request/response line,
headers, and as much body as fits — so JS can parse the line and the
inspect pane can show the (JSON) body. */
__u32 cap = plen;
__u32 limit = kind == KIND_RESPONSE ? RESP_CAP : (DATA_MAX - 1);
if (cap > limit)
cap = limit;
if (cap > DATA_MAX - 1)
cap = DATA_MAX - 1;
cap &= (DATA_MAX - 1); /* make the bound explicit for the verifier */
if (cap == 0)
return TCX_NEXT;
Expand Down
234 changes: 185 additions & 49 deletions src/components/detail.jsx
Original file line number Diff line number Diff line change
@@ -1,68 +1,204 @@
// Detail screen: a per-endpoint breakdown for the endpoint the user pressed
// Enter on. Reads `focusKey` (which endpoint) and `tick` — the endpoint's
// fields mutate in place, so reading `tick` is what re-renders this panel as
// they change. `endpoint()` looks the row up; `totals` gives the share.
import { Box, Text, bold, dim, fg } from "yeet:tui";
// Detail: a two-screen drill for one endpoint.
// • requests table — a Wireshark-style list of the endpoint's recent calls
// (one row each: #, age, status, latency, size). ↑/↓ select, ⏎ to open.
// • body view — the opened request's headers + body, scrollable, with `<`/`>`
// flipping the in (request) / out (response). esc steps back out.
// Reads `focusKey`, `tick`, and the nav signals (`txnSel`, `open`, `txnDir`,
// `scroll`). `open` distinguishes the two screens.
import { Box, Text, bold, fg, bg, rgb } from "yeet:tui";
import {
methodColor, accent, rateOn, grid, label, W_METHOD,
fmtCount, fmtBytes, fmtAgo, fmtMs, percentile, statusColor, sparkline,
methodColor, accent, rateOn, grid, label, muted, selBg, W_METHOD,
fmtCount, fmtBytes, fmtAgo, fmtMs, statusColor, latColor,
} from "@/lib/format.js";

// Components are called `(opts, ...children)` by the JSX runtime, so read the
// value pieces from the rest args — not a `children` prop.
function Field(opts, ...children) {
return (
<Box direction="row" height="fit">
<Text width={12}>{fg(label)(opts.name)}</Text>
<Text width="1fr" overflow="ellipsis">{children.flat(Infinity)}</Text>
</Box>
);
// Vibrant JSON syntax palette.
const J_KEY = rgb(0x8be9fd), J_STR = rgb(0xf1fa8c), J_NUM = rgb(0xbd93f9), J_LIT = rgb(0xff79c6);

const JSON_TOK = /("(?:\\.|[^"\\])*"\s*:)|("(?:\\.|[^"\\])*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)|(true|false|null)|([{}\[\],])/g;
function colorJsonLine(line) {
const spans = [];
let last = 0, m;
while ((m = JSON_TOK.exec(line)) !== null) {
if (m.index > last) spans.push(line.slice(last, m.index));
if (m[1]) spans.push(fg(J_KEY)(m[1]));
else if (m[2]) spans.push(fg(J_STR)(m[2]));
else if (m[3]) spans.push(fg(J_NUM)(m[3]));
else if (m[4]) spans.push(fg(J_LIT)(m[4]));
else spans.push(fg(muted)(m[5]));
last = JSON_TOK.lastIndex;
}
if (last < line.length) spans.push(line.slice(last));
return spans.length ? spans : [line];
}

function wrapTo(line, width) {
if (line.length <= width) return [line];
const out = [];
for (let i = 0; i < line.length; i += width) out.push(line.slice(i, i + width));
return out;
}

function headerLine(l) {
const i = l.indexOf(":");
return i < 0 ? [fg(muted)(l)] : [fg(label)(l.slice(0, i + 1)), l.slice(i + 1)];
}

// A captured message → wrapped+colored display lines. Headers and body are
// collapsible sections (▾ open / ▸ collapsed), driven by hOpen/bOpen.
function msgLines(text, width, hOpen, bOpen) {
const sep = text.indexOf("\r\n\r\n");
const headText = sep >= 0 ? text.slice(0, sep) : text;
const body = sep >= 0 ? text.slice(sep + 4) : "";
const rule = (lbl) => [fg(label)(`${lbl} ` + "─".repeat(Math.max(0, width - lbl.length - 1)))];
const headerCount = headText.split(/\r?\n/).length;

const out = [rule(`${hOpen ? "▾" : "▸"} headers (${headerCount})`)];
if (hOpen) {
headText.split(/\r?\n/).forEach((l, j) =>
wrapTo(l, width).forEach((c, k) => out.push(j === 0 ? [bold(c)] : (k === 0 ? headerLine(c) : [c]))));
}

out.push([" "], rule(`${bOpen ? "▾" : "▸"} body`));
if (bOpen) {
const t = body.trim();
if (!t) {
out.push([fg(muted)("(no body)")]);
} else {
let src = body;
if (t.startsWith("{") || t.startsWith("[")) {
try { src = JSON.stringify(JSON.parse(t), null, 2); } catch { /* truncated: color raw */ }
}
for (const line of src.split(/\r?\n/)) for (const c of wrapTo(line, width)) out.push(colorJsonLine(c));
}
}
return out;
}

/* Status-code tallies as colored "200×120 404×3" spans, busiest first. */
// Max scroll offset of the body view, published each render for main.jsx.
export const detailView = { max: 0 };
// Top row of the requests-table window, kept across renders.
let tableTop = 0;

function statusSpans(status) {
const codes = Object.entries(status).sort((a, b) => b[1] - a[1]).slice(0, 6);
if (codes.length === 0) return dim("— no responses paired yet");
if (codes.length === 0) return fg(muted)("none paired");
return codes.flatMap(([code, n], i) =>
[i ? " " : "", fg(statusColor(Number(code)))(code), dim(`×${n}`)]);
[i ? " " : "", bold(fg(statusColor(Number(code)))(code)), fg(muted)(`×${n}`)]);
}

export default function DetailPanel({ focusKey, tick, endpoint, totals, size }) {
// in / out as clickable-looking tabs; the active one is loud — a bright filled
// bg (cyan for in, pink for out) with dark ink for contrast.
const TAB_INK = rgb(0x14142b);
const tab = (lbl, active, hue) => active
? bg(hue)(bold(fg(TAB_INK)(` ${lbl} `)))
: fg(muted)(` ${lbl} `);

// Endpoint header shown on both screens.
function endpointHead(r, totals) {
const share = totals.reqs ? (r.count / totals.reqs) * 100 : 0;
return [
<Box direction="row" height="fit">
<Text width={W_METHOD + 1}>{bold(fg(methodColor(r.method))(r.method))}</Text>
<Text width="1fr" overflow="ellipsis">{bold(fg(accent)(`${r.host}${r.path}`))}</Text>
</Box>,
<Text overflow="ellipsis">{[
bold(fg(accent)(fmtCount(r.count))), fg(muted)(" reqs · "),
fg(muted)(`${share.toFixed(1)}% · `),
r.rate > 0 ? bold(fg(rateOn)(`${r.rate}/s`)) : fg(muted)("0/s"),
fg(muted)(" · "), ...[].concat(statusSpans(r.status)),
]}</Text>,
];
}

// ---- requests table (Wireshark-style packet list) ----
// Width-based columns (a single padded Text gets its spaces trimmed, which
// collapses the columns) — one Text per cell, like the endpoint list.
const C_TIME = 9, C_CODE = 6, C_LAT = 9, C_SIZE = 8;

function tableHeader() {
return (
<Box direction="row" height="1">
<Text width={2}>{" "}</Text>
<Text width={C_TIME}>{bold(fg(accent)("Time"))}</Text>
<Text width={C_CODE}>{bold(fg(accent)("Code"))}</Text>
<Text width={C_LAT}>{bold(fg(accent)("Latency"))}</Text>
<Text width={C_SIZE}>{bold(fg(accent)("Size"))}</Text>
<Text width="1fr">{bold(fg(accent)("Info"))}</Text>
</Box>
);
}

function tableRow(t, on, now) {
const info = t.in.split(/\r?\n/)[0] || "";
const method = info.split(" ")[0] || "";
return (
<Box direction="row" height="1" bg={on ? selBg : undefined}>
<Text width={2}>{on ? fg(accent)("▸") : " "}</Text>
<Text width={C_TIME}>{fg(on ? accent : muted)(`${fmtAgo(now - t.ts)} ago`)}</Text>
<Text width={C_CODE}>{t.status ? bold(fg(statusColor(t.status))(String(t.status))) : fg(muted)(t.out === null ? "·" : "—")}</Text>
<Text width={C_LAT}>{t.ms != null ? fg(latColor(t.ms))(fmtMs(t.ms)) : fg(muted)("·")}</Text>
<Text width={C_SIZE}>{fg(muted)(fmtBytes((t.in?.length || 0) + (t.out?.length || 0)))}</Text>
<Text width="1fr" overflow="ellipsis">{fg(methodColor(method))(method)}{fg(on ? accent : muted)(info.slice(method.length))}</Text>
</Box>
);
}

export default function DetailPanel({ focusKey, tick, endpoint, totals, size, txnSel, open, txnDir, scroll, hOpen, bOpen }) {
return (
<Box border={{ line: "round", fg: grid }} padding={1} direction="column"
width="1fr" height="1fr" overflow="hidden">
{() => {
tick.get(); // re-render on each state tick (fields below mutate in place)
tick.get(); txnSel.get(); open.get(); txnDir.get(); scroll.get(); hOpen.get(); bOpen.get();
const r = endpoint(focusKey.get());
if (!r) return <Text>{dim("endpoint no longer tracked — press esc to go back")}</Text>;
if (!r) return <Text>{fg(muted)("endpoint no longer tracked — press esc to go back")}</Text>;
const now = Date.now();
const share = totals.reqs ? (r.count / totals.reqs) * 100 : 0;
const sparkW = Math.max(10, Math.min(r.hist.length || 1, size.get().cols - 18));
const lat = r.lat.length
? `p50 ${fmtMs(percentile(r.lat, 50))} · p95 ${fmtMs(percentile(r.lat, 95))} · ` +
`max ${fmtMs(Math.max(...r.lat))} ${"·"} ${r.lat.length} samples`
: dim("no responses paired yet");
return [
<Box direction="row" height="fit">
<Text width={W_METHOD + 1}>{bold(fg(methodColor(r.method))(r.method))}</Text>
<Text width="1fr" overflow="ellipsis">{bold(`${r.host}${r.path}`)}</Text>
</Box>,
<Text> </Text>,
<Field name="Requests">{bold(fg(accent)(fmtCount(r.count)))}{dim(` (${r.count})`)}</Field>,
<Field name="Share">{`${share.toFixed(1)}% of all requests`}</Field>,
<Field name="Req/s now">{r.rate > 0 ? fg(rateOn)(String(r.rate)) : dim("0")}{dim(` peak ${r.peak}/s`)}</Field>,
<Field name="Latency">{lat}</Field>,
<Field name="Status">{statusSpans(r.status)}</Field>,
<Field name="Bytes">{fmtBytes(r.bytes)}{dim(" on the wire")}</Field>,
<Field name="First seen">{`${fmtAgo(now - r.first)} ago`}</Field>,
<Field name="Last seen">{`${fmtAgo(now - r.last)} ago`}</Field>,
<Text> </Text>,
<Text>{fg(label)("Req/s, last minute")}</Text>,
<Text overflow="hidden">{fg(rateOn)(sparkline(r.hist, sparkW, r.peak))}</Text>,
<Text> </Text>,
<Text>{fg(label)("Latency, recent responses")}</Text>,
<Text overflow="hidden">{fg(accent)(sparkline(r.lat, sparkW))}</Text>,
];
const { rows, cols } = size.get();
const txns = r.txns;
const sel = Math.min(Math.max(0, txnSel.get()), Math.max(0, txns.length - 1));

if (!open.get()) {
// ── requests table ──
const vis = Math.max(3, rows - 9);
if (sel < tableTop) tableTop = sel;
else if (sel >= tableTop + vis) tableTop = sel - vis + 1;
tableTop = Math.max(0, Math.min(tableTop, Math.max(0, txns.length - vis)));
return (
<Box direction="column" width="1fr" height="1fr">
{endpointHead(r, totals)}
<Text overflow="ellipsis">{fg(label)(`requests (${txns.length})`)}{fg(muted)(" ↑/↓ select · ⏎ open · esc back")}</Text>
{txns.length === 0
? <Text>{fg(muted)("no requests captured yet")}</Text>
: [tableHeader(), ...txns.slice(tableTop, tableTop + vis).map((t, i) => tableRow(t, tableTop + i === sel, now))]}
</Box>
);
}

// ── body view ──
const txn = txns[sel];
const dir = txnDir.get(); // 0 = in (request), 1 = out (response)
const msg = txn ? (dir === 1 ? txn.out : txn.in) : null;
const W = Math.max(24, cols - 6);
const lines = msg ? msgLines(msg, W, hOpen.get(), bOpen.get())
: [[fg(muted)(dir === 1 ? "no response captured (egress not seen)" : "no request captured")]];
const vis = Math.max(3, rows - 8);
detailView.max = Math.max(0, lines.length - vis);
const off = Math.min(Math.max(0, scroll.get()), detailView.max);
return (
<Box direction="column" width="1fr" height="1fr">
{endpointHead(r, totals)}
<Text overflow="ellipsis">{[
fg(label)(`req ${txns.length ? sel + 1 : 0}/${txns.length}`),
txn && txn.status ? fg(muted)(" · ") : "",
txn && txn.status ? bold(fg(statusColor(txn.status))(String(txn.status))) : "",
txn && txn.ms != null ? fg(muted)(` · ${fmtMs(txn.ms)}`) : "",
" ", tab("in", dir === 0, accent), " ", tab("out", dir === 1, rgb(0xff79c6)),
fg(muted)(" tab · h/b collapse · ↑/↓ scroll · ←/→ back"),
]}</Text>
<Box direction="column" width="1fr" height="1fr" overflow="hidden">
{lines.slice(off, off + vis).map((l) => <Text height="1" break="none" overflow="hidden">{l}</Text>)}
</Box>
</Box>
);
}}
</Box>
);
Expand Down
27 changes: 17 additions & 10 deletions src/components/footer.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
// Running totals pinned to the bottom. Reads the plain `totals` object every
// render; the uptime ticks because the surrounding tree re-renders on state
// ticks (the list via `rows`, the detail screen via `tick`).
import { Text, dim } from "yeet:tui";
import { fmtCount, fmtBytes, fmtUptime } from "@/lib/format.js";
// Running totals pinned to the bottom. `totals` is a plain object and
// `endpointCount()` reads a plain Map, so neither makes this reactive on its
// own — the thunk reads the `tick` signal (bumped every redraw in httptop.js)
// to re-render, which is what advances the counters.
import { Text, bold, fg } from "yeet:tui";
import { accent, rateOn, muted, label, fmtCount, fmtBytes, fmtUptime } from "@/lib/format.js";

export default function Footer({ totals, endpointCount }) {
export default function Footer({ totals, endpointCount, tick }) {
return (
<Text>{() => dim(
`${fmtCount(totals.reqs)} reqs · ${endpointCount()} endpoints · ` +
`${fmtBytes(totals.bytes)} seen · up ${fmtUptime(Date.now() - totals.startMs)}`
)}</Text>
<Text>{() => {
tick.get(); // dependency: re-render on each sample/redraw tick
const sep = fg(muted)(" · ");
return [
bold(fg(accent)(fmtCount(totals.reqs))), fg(muted)(" reqs"), sep,
bold(fg(rateOn)(String(endpointCount()))), fg(muted)(" endpoints"), sep,
bold(fg(label)(fmtBytes(totals.bytes))), fg(muted)(" seen"), sep,
fg(muted)("up "), bold(fg(accent)(fmtUptime(Date.now() - totals.startMs))),
];
}}</Text>
);
}
Loading
Loading