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:

ConstraintReason
Loops must be boundedprevents infinite loops from freezing the kernel
No mallocno dynamic memory allocation
Only kernel helper functionsarbitrary function calls are not allowed
Stack limited to 512 bytessmall, fixed stack size
No printfuse 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 functions

The 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).