Internals

Architecture

One sentence: perf record → agent → TCP + zstd → server → parser → source mapper → SSE → browser.

PerfLens architecture: target device runs perf record + agent; local machine runs server + parser + source mapper + SSE; browser renders the UI
The agent collects on the target. The server parses and broadcasts. The browser does no perf work.

On the target device

The agent is the only thing running on the target: a single static C binary with vendored zstd and zero runtime dependencies. Prebuilt binaries ship for five architectures, install with one curl command, and self-update with --update.

Capability probing

Before collecting anything, the agent inspects the kernel:

The probe is batched — one perf stat over every candidate, one perf record over the survivors — and took 10.8 s on a hybrid x86_64 container and 12.7 s on an 8-core ARM64 board when measured for 0.12.0. It runs once per connection; switching to another process only re-checks that the new pid can be recorded.

Continuous collection

Collection is one long-lived pipeline, perf record -o - | perf script -i -, so there is no sampling dead time and symbol tables are parsed once. The agent cuts its output into chunks at sample boundaries every N seconds (default 8) or every 16 MB of text, whichever comes first, and runs perf stat rounds back to back so every interval is counted. Each chunk — perf script text optionally followed by a ### PERF_STAT ### section — is compressed with in-process zstd (level 1, vendored in the agent) and pushed over TCP with a 5-byte header. Measured compression on device captures: about 20×.

Some old perf builds produce samples through a pipe but drop their call chains (measured on perf 4.4). The capability probe detects that, and the agent falls back to discrete rounds: perf record to a file for N seconds, then perf script on it, with the next round's perf record started while the previous round's perf script runs. perf script runs at nice 5 in both modes, so the profiler yields to the workload.

Health metrics

Independent of the perf pipeline, the agent collects device health every 2 seconds — CPU per-core, memory, load, temperature, process stats, network bytes — and streams them as JSON frames with flag 4. Disk I/O (per-device + per-process) and per-thread CPU collectors are opt-in — off by default so embedded targets pay nothing, enabled at runtime from the UI via the configure_metrics command. The browser renders sparklines, gauges, and per-core CPU bars without affecting the perf collection, and dragging across a sparkline scopes the flame graph to that time window.

On the local machine

One Python process (perflens serve) owns everything on the local side. web.py runs a FastAPI/uvicorn HTTP layer for the UI and JSON API; agentlink.py owns the agent side on plain threads — the TCP listener and the recv loops — and state.py runs the aggregation rebuild worker. One agent at a time, any number of SSE browser clients.

Parser

parser.py parses perf script output, and aggregator.py folds each new chunk incrementally — O(new samples) per chunk, not O(total) — into:

The parser is defensive on purpose. The perf script format drifts across kernel versions; the optional [cpu], pid/tid, and flags fields appear in different combinations on 2.6, 3.x, 4.x, 5.x, and 6.x. The parser handles all of them and silently tolerates lines it doesn't recognize.

Source mapper

source_mapper.py pipelines sample addresses through addr2line -f (or -fi when --inline is on) in batches of 500. A single mapper is created at server startup and shared across requests — no per-request forking. Resolutions and source-file indexes persist under ~/.perflens/cache (symcache.py), so warm restarts skip the work.

Lookups feed a heat map: each source line gets a sample count, and the UI colors it red → amber → green by share of the file's total. With --toolchain-prefix, the same flag derives the right addr2line and readelf for a cross-compiled target in one step. --sysroot resolves shared-library module paths and source files under a target tree, similar to perf --symfs.

Sessions

Every agent connection becomes a session. Raw chunks are written to disk under ~/.perflens/sessions/<id>/ as the agent streams them; metadata (events, sample totals, perf stat values, platform) is written when the connection closes.

Replay is lazy: the UI hits /api/sessions/<id> only when you click a session, and the server re-parses the raw chunks on demand. A standalone perf.data file can also be imported — with --import at startup or uploaded through /api/sessions/import — the server runs perf script against it once and exposes the result as a session.

Wire protocol

Every message is a 5-byte header followed by a payload of exactly LEN bytes.

Wire protocol: 4-byte length + 1-byte flag + payload
header = struct.pack('!IB', len(payload), flag)
sock.sendall(header + payload)
FieldSizeMeaning
LEN4 bytes (uint32, big-endian)Payload length in bytes
FLAG1 byte (uint8)See flag table below
PAYLOADLEN bytesFrame body (perf data, JSON, etc.)

Flag values

FlagDirectionPayload
0agent → serverRaw UTF-8 perf script output
1agent → serverZstd-compressed perf script output
2server → agentCommand request (JSON): start, stop, pause, resume, configure, list_processes, reprobe, …
3agent → serverCommand response (JSON)
4agent → serverHealth metrics (JSON, default every 2 s; interval + opt-in collectors set via configure_metrics)

Payloads with flag 1 are decompressed in-process via the zstandard package (external zstd binary as a fallback). Perf data payloads carry plain perf script text, optionally followed by a ### PERF_STAT ### section the parser splits out.

Two connection modes

The agent can be the connector or the listener — the wire protocol is the same either way.

Broadcast to the browser

The server pushes updates to the UI through a Server-Sent Events stream at /api/stream: status, agent, data_version (bumped per chunk, and carrying the event-type list), perf_stat, and metrics. When data_version bumps, the browser fetches the cached gzip snapshot for the event it is viewing from /api/snapshot and rerenders. There is no polling.

Per-thread analysis, source views, and exports are pull-based: the UI hits /api/threads/<tid>, /api/source, or the export endpoints on demand — see the Reference for the full list.

Known limits