How It Works#
tinytap attaches eBPF probes to a process’s socket syscalls
(accept4/read/write/close/recvfrom/sendto/recvmsg/sendmsg),
parses the payload bytes as HTTP/1.1, pairs each request with its response,
and renders the exchange live, either in the terminal TUI or as JSONL
(one JSON object per exchange) on stdout:
{"reqTsNs":1754887677005000000,"latencyNs":300000,"pid":27122,"comm":"python3","method":"GET","path":"/","status":200,"resBytes":1304,"abandoned":false,...}output = "auto" (the default) picks the TUI when stdout/stdin are an
interactive terminal of at least 120x24; otherwise it prints guidance and
exits rather than silently streaming; the JSONL stream is opt-in via
output = "stdout". output = "tui" forces the TUI (and exits the same way
if the terminal can’t host it). --version prints the build’s version,
commit, and date, and exits without needing root.
eBPF background#
If you’re new to eBPF, here’s the shape of how tinytap is built on it.
eBPF is a sandboxed virtual machine that runs safely inside the Linux
kernel. Traditionally, extending kernel behavior required writing a kernel
module (.ko), and a bug there can crash the entire system. eBPF’s kernel-side
verifier checks a program before loading it instead, so a bug in the eBPF
program gets rejected rather than panicking the kernel.
┌─────────────────────────────────────┐
│ User Space (Go) │
│ │
│ main.go │
│ └─ Load...Objects() │ ← generated by bpf2go
│ └─ loads eBPF into kernel │
│ │
│ reads captured data from maps │
└────────────────┬────────────────────┘
│ syscall (bpf())
┌────────────────▼────────────────────┐
│ Kernel Space (eBPF VM) │
│ │
│ bpf/*.bpf.c runs here │ ← written in C
│ └─ hooks into syscalls/uprobes │
│ └─ writes data to maps │
└─────────────────────────────────────┘eBPF programs are written in C and compiled to eBPF bytecode. The SEC()
macro declares where in the kernel the program attaches:
SEC("tracepoint/syscalls/sys_enter_write")
int handle_write(struct trace_event_raw_sys_enter *ctx) {
// this runs inside the kernel every time write() is called
return 0;
}
char LICENSE[] SEC("license") = "GPL";Data captured in the kernel needs to be passed to user space. eBPF maps (in tinytap’s case, a ring buffer) are the bridge, shared memory accessible from both sides.
Because eBPF programs run inside the kernel, there are strict constraints on what they can do:
| Constraint | Reason |
|---|---|
| Loops must be bounded | prevents infinite loops from freezing the kernel |
No malloc | no dynamic memory allocation |
| Only kernel helper functions | arbitrary function calls are not allowed |
| Stack limited to 512 bytes | small, fixed stack size |
No printf | use bpf_printk() for debug output instead |
Go cannot directly use the compiled .o bytecode files, so bpf2go (part
of cilium/ebpf) acts as the bridge:
bpf/tinytap.bpf.c
↓ clang (invoked by go generate)
tinytap_bpfel.o ← compiled eBPF bytecode (ELF)
↓ bpf2go
tinytap_bpfel.go ← embeds the .o file + generates loader functionsThe generated *_bpfel.go/*_bpfeb.go files are entirely auto-generated:
never edited by hand, just regenerated after changing the C source (see
the event schema for the exact struct layout carried across that boundary).