Netflow
rupu records where it reaches out to, not just what an agent does — every outbound HTTP call rupu itself makes becomes a flow, written to an append-only ledger, enriched with ASN and org, and rendered as a topology in the Control Plane.
What it is
rupu already has deep observability for what an agent does —
tool calls, command runs, tool audits,
coverage ledgers, findings,
usage accounting. Netflow closes the other half: where an agent
reaches. Every request rupu's own HTTP clients send —
model provider APIs and SCM / issue
connectors, plus the control plane's own fleet
traffic and its auth / system calls — is
captured as a FlowRecord: method, host, port,
query-stripped path, peer IP, status, byte counts and timings, tagged
with the run, step and agent that produced it.
Those records land in an append-only JSONL ledger next to
the run store, and — for flows observed during a run — as
Event::NetFlow lines in that run's transcript.
At read time the control plane resolves each peer IP to an
ASN and org and paints a bipartite topology
graph, a sortable flow table, and a per-host rollup.
The whole subsystem exists to let a viewer tell "we could not observe
this" apart from "it was zero". That distinction is enforced
end to end: an unknown byte count is never summed as 0,
an unmeasured percentile is never rendered as 0 ms,
a dropped record is never silently absorbed, and every row carries a
fidelity badge saying how much of it is actually known.
bash
subprocesses do. A curl, a
cargo build contacting crates.io, or an
npm install postinstall script phoning home
inside a tool call is not recorded — the
bash tool locks cwd and scrubs
the environment, but the subprocess still has unrestricted network access.
Netflow also cannot see non-HTTP egress: git2 clones
(often a run's largest byte volume by far), object-store bucket traffic,
and the tunnel node
WebSocket are all invisible here. A full-fidelity backend (frame-level
capture in a microVM) is designed for but not built — the
record schema, ledger format and API contract are fixed so it can slot in,
and until it does the views say so. The CP restates this limit on every
Network surface rather than letting a full-looking table imply completeness.
The choke point
Capture only works if there is exactly one door. Before netflow there
wasn't one — roughly thirty
reqwest::Client::new() /
Client::builder() sites were scattered across
six crates. Netflow created the choke point and then nailed it
shut: a clippy.toml lint denies
reqwest::Client::new,
Client::default,
ClientBuilder::new,
ClientBuilder::default and — the actual bypass
point — ClientBuilder::build everywhere except
inside the netflow crate itself, backed by a repo-level test. A regression
is a build failure, not a review miss.
Attribution is bound when a client is constructed, not
discovered at request time. Task-locals and tracing spans both lose context
across a tokio::spawn, which would produce flows
with silently missing attribution — worse than none. Provider and SCM
clients are already built per run, so binding the context there costs no new
plumbing. Process-global clients (the control plane's host registry, the ASN
refresh) simply carry no run id — that is the honest shape
of the data, and precisely why the ledger exists alongside the transcript
events rather than being a convenience index.
What a flow records
One FlowRecord is one outbound request. Fields
typed as optional are genuinely unknown when absent — they
are never defaulted to zero:
| Field | What it holds |
|---|---|
id | ULID for this flow. Minted by the caller for streamed bodies so a later completion line can find it. |
ts | UTC timestamp, taken when the record is emitted. |
ctx | Attribution: run_id, step_id, agent, workspace_id (each optional) and origin. |
ctx.origin | Which subsystem opened the connection — provider / scm / mcp carry a name; webhook, update, cp, system are bare. |
fidelity | coarse | http | full — how much of this record is actually known. See below. |
method | HTTP method. |
scheme | https / http. |
host | Endpoint hostname. |
port | Endpoint port. host:port is the endpoint identity used by the rollup and the graph. |
path | Request path, query-stripped — never contains a ?. |
peer_ip | The peer actually connected to. Absent at coarse fidelity. This is the field ASN enrichment keys on. |
resolved_ips | Every A/AAAA answer the custom DNS resolver saw for the host — strictly more than the one peer used. |
http_version | Negotiated HTTP version, when known. |
status | Response status code, when a response was received. |
outcome | ok | http_error | transport_error | timeout. |
error | Error text for a failed flow. URLs and query strings are kept out of it. |
bytes_out | Request bytes, when observable. |
bytes_in | Response bytes. Declared (from Content-Length) at emit time; observed once a completion line finalizes it. |
body_complete | false while a streamed body is still draining; flipped by the completion line folded in at read time. |
ttfb_ms | Time to first byte. |
duration_ms | Total duration. Absent for a flow still in flight. |
Read back through the control plane, each flow gains one more field —
asn — resolved at render time rather than
stamped at write time (see ASN enrichment below).
Deliberate omissions
- Query strings and headers are never stored. They routinely carry tokens. The path is stored query-stripped, and there is no opt-out.
- No TLS version or cipher. The HTTP layer doesn't expose it — a field that could only ever be empty doesn't belong in the schema.
- No ASN on the record. Stamping it at write time would freeze history against whatever dataset happened to be on disk that day.
Fidelity — the honesty field
Every flow declares how much of itself is real, and every CP view renders the badge. The subsystem never claims coverage it does not have.
| Fidelity | Source | What is true |
|---|---|---|
http |
The instrumented client | Exact request and response metadata, including peer IP and byte counts. |
coarse |
A connector whose HTTP stack rupu does not own (the GitHub client) | Host, outcome and timing are real; byte counts and peer IP were not observable. Such a flow gets no ASN, and its bytes render as a dash, never 0 B. |
full |
Frame-level capture from the isolated runtime | Reserved — not emitted today. When that backend lands, the same views begin showing full with no schema change. |
Streaming bodies
Every provider chat path is a streamed response, where
Content-Length doesn't exist. Netflow does not
estimate. The middleware emits the record at response-header
time with an unknown bytes_in and
body_complete: false; the stream-consuming loop
— which already counts bytes — later calls
complete(flow_id, bytes_in, duration_ms) to
finalize it. The caller minted the flow id and attached it to the request,
so it knows exactly which record to close: explicit, no ambient magic.
The ledger
Flows are written to an append-only JSONL ledger. There are two, and which one a flow lands in depends on who produced it:
| Path | Holds |
|---|---|
<project>/.rupu/netflow/flows.jsonl |
Every flow a rupu run in that project produced — provider and SCM calls with a run id, plus that process's unattributed system egress. Rooted at the project root, so invoking rupu from a nested directory doesn't fragment the ledger. |
~/.rupu/netflow/flows.jsonl |
The rupu cp serve daemon's own ledger. A daemon has no single project to anchor to, so its fleet HTTP traffic and its ASN-refresh downloads land here. Honors $RUPU_HOME. |
The ledger is strictly append-only — a record written at header time is never rewritten. Each line is one of three envelopes:
| Line | Meaning |
|---|---|
flow | A full FlowRecord. |
complete | Finalizes a streamed body written earlier — carries the flow id, observed bytes_in, and duration_ms. Folded into its flow at read time. |
dropped | Records visible loss when the writer channel overflowed, with a count and a timestamp. |
Reads tolerate damage: a missing file is an empty ledger, not an error, and a malformed line is skipped so a torn write at the tail can't lose the whole history.
.rupu/netflow/ gets its own
.gitignore containing a bare
* — written when the directory is created, so
the protection travels with the directory rather than depending on a
project-level entry that older projects never got. An existing
.gitignore there is never clobbered.
How flows tie back to runs
For flows observed while a run is active, netflow also appends an
Event::NetFlow line to that
run's transcript JSONL — so
they appear live wherever transcripts are already tailed, with no new
plumbing. That's not redundancy with the ledger; it's what makes run scope
correct. Only provider flows carry a run id: SCM, auth, system and
update flows are run-less by design, so filtering the ledger by run id alone
would silently drop every connector call the process made while the run
was active. The run's Network tab therefore merges both sources.
Flows are transcript events, not workflow-executor events —
they do not appear in events.jsonl, which is a
step-level lifecycle log where a per-request record has no business.
In the Control Plane
Netflow follows the same placement rule as findings: a tab on a run, a tab on a project, and a global page — never on a workflow definition. A flow belongs to a run, not to a workflow.
| Surface | Scope |
|---|---|
| Run → Network | This run's flows: its slice of the project ledger merged with its transcript's NetFlow events, so run-less connector calls made during the run aren't lost. |
| Project → Network | The whole workspace ledger with no run filter — including system egress that has no run to attach to. |
| Network (global) | The union across every registered workspace, plus the CP daemon's own ledger. This is the only scope where CP fleet traffic and ASN-refresh downloads appear — they belong to the daemon, not to any one project. |
Behind those surfaces sit four read-only endpoints:
# run, project, and global scopes GET /api/runs/:id/netflow GET /api/projects/:id/netflow GET /api/netflow # precomputed topology; scope is run:<id>, project:<id>, or absent for global GET /api/netflow/graph?scope=run:01J9Z4W7Q0X8Y6V5K3M2N1P0R8
The topology graph
The primary visualization is a bipartite topology graph:
sources on the left — one node per run, plus a
system node for unattributed process-global
egress — and endpoints on the right, one per
host:port. Edges connect who reached what,
thickness scaled by call count, and an edge that carried
any failure is drawn in the same red the run graph uses to mark a problem.
coarse flow contributes nothing to it and simply
renders thin, which is why byte counts are never labelled on the graph.
The flow table
A sortable, per-flow list — time, origin, host, path, network (ASN and org), outcome, bytes and timings — with a fidelity badge on every row, so a coarse row never looks as complete as an instrumented one. Three states are called out rather than glossed:
- Dropped records get a loud banner — "N flows dropped — the capture buffer overflowed, so this list is incomplete." It renders even when the table is empty, because a scope where everything was dropped is exactly where silent incompleteness does the most damage.
- Unavailable ASN enrichment gets its own note, so a blank Network column reads as "we couldn't look it up" rather than "this peer has no ASN."
- An empty table restates netflow's scope limit, so "no rows" never implies "no network activity happened."
The summary
A per-endpoint rollup — one row per host:port
with calls, errors, bytes in / out, p50 and p95 latency —
computed server-side so the percentile and unknown-bytes
rules have exactly one implementation. Both rules are strict: if
any contributing flow had an unobservable byte count, that total
collapses to unknown rather than under-reporting (and in/out are tracked
independently, so an in-flight stream doesn't blank a known request total).
If no flow had a known duration, the percentile is unknown — because a
displayed 0 ms can't be told apart from a genuine
sub-millisecond p50.
The fidelity badge and the scope disclosure
Two small components carry most of netflow's integrity. The fidelity badge is rendered next to every flow and explains itself on hover — coarse means host, outcome and timing are real but bytes and peer IP were not observable for that connector. The scope disclosure is a single authored sentence, rendered on all three Network surfaces, naming exactly what netflow covers at that scope and what it cannot see. It is scope-aware on purpose: CP fleet traffic is only claimed on the global page, because that's the only place it can actually appear. Every scope-limit sentence in the CP — including the table's and the graph's empty states — draws from that one source, so the wording can't drift apart into claims the code doesn't back.
ASN enrichment
A peer IP on its own is nearly unreadable. Netflow resolves it to an
autonomous system number and org — so a flow reads
AS13335 Cloudflare rather than a bare address —
and that is the enrichment the flow table's Network column shows.
The operator never runs a command for this. The dataset is
acquired and refreshed automatically from a combined IPv4+IPv6
prefix→ASN table, compacted on ingest into a sorted binary range table
at ~/.rupu/netflow/asn.db (v4 and v6 held
separately, binary-searched; unrouted rows skipped). It isn't bundled with
the binary — compacted it's meaningful bloat on a binary that already embeds
the CP's web assets. Two triggers keep it current:
-
The
rupu cp servesweep loop gains an ASN freshness tick, alongside the gate sweep and the cron tick. -
Any netflow read that finds the table missing or stale
spawns a detached background fetch — so operators who never run
cp servestill get enrichment. A burst of requests collapses to a single download via a process-wide single-flight guard.
Enrichment is resolved at read time, never stamped onto the record. Three consequences fall out of that, and they're the reason for the design:
- A dataset that arrives late automatically improves every historical record. No backfill job exists, or is needed.
- The write path never blocks on a file that may not be there.
- Offline and air-gapped installs degrade to "ASN data not loaded" while every other view works unchanged.
Refresh is best-effort and fails safe: on any failure the existing table is left untouched, and a download that parses to an empty table (a gzipped error page, say) is refused rather than allowed to destroy working enrichment. The parsed table is cached in-process and keyed on the file's mtime, so three Network tabs opening at once don't reparse hundreds of thousands of ranges three times. And the refresh request is itself recorded as a system flow — the subsystem stays honest about its own egress.
Configuration
Netflow capture itself needs no configuration — it's on wherever a sink is
installed. The [netflow] section governs one
thing: automatic acquisition and refresh of the ASN table.
| Key | Type | Default | What it does |
|---|---|---|---|
asn_auto_refresh |
bool | true |
Acquire and refresh the ASN table automatically. Defaults on so enrichment works without an operator running anything. Set false for an air-gapped install — flows are still captured and every other view works; the Network column just reports enrichment unavailable. |
asn_refresh_interval_days |
integer (days) | 7 |
Refresh cadence. BGP prefixes move slowly, so weekly is ample. A table older than this counts as stale; 0 means always refresh. |
asn_source_url |
string | https://iptoasn.com/data/ip2asn-combined.tsv.gz |
Source for the combined IPv4+IPv6 prefix→ASN table. Point it at an internal mirror if outbound access to the public dataset isn't available. |
Unknown keys in this section are rejected, and any key you leave out takes its default:
# ~/.rupu/config.toml — every [netflow] key, at its default [netflow] asn_auto_refresh = true asn_refresh_interval_days = 7 asn_source_url = "https://iptoasn.com/data/ip2asn-combined.tsv.gz"
# air-gapped: keep capture, skip the download entirely [netflow] asn_auto_refresh = false
Telemetry only
Netflow is pure telemetry. A flow carries no severity, no triage state, and no coupling to the findings subsystem. Anomalous egress does not become a finding, and nothing in netflow decides what "anomalous" means.
That's a deliberate decision, not an omission. Judgement baked into a recording layer is judgement you can't revise without rewriting history — and a record that carries a verdict stops being evidence. Keeping the data inert means the flows stay exactly as trustworthy as their fidelity field claims, and nothing more. Should anomaly surfacing be wanted later, it lands as an additive detector sitting on top of this data, reading the same ledger and emitting findings of its own — not as a change to what a flow is.
The same restraint applies to enforcement: netflow observes egress, it does not block it. Deny-by-default egress policy needs the isolated-runtime substrate that the full-fidelity backend would bring, and is out of scope until then.
Still coming
Everything above ships today. What follows genuinely does not, and the page says so rather than implying otherwise:
| Coming later | What it adds |
|---|---|
| Full-fidelity capture | Frame-level capture inside a microVM — the only mechanism that behaves identically on macOS and Linux. Attribution becomes definitional rather than correlated (the VM is the run), and it finally covers the agent's bash subprocesses, every port and protocol, and DNS lookups that resolve and never connect. It feeds the existing record schema, ledger format and API contract unchanged — the views simply start reporting full. |
| Non-HTTP egress | git2 clones, object-store bucket traffic and the tunnel-node WebSocket are invisible to an HTTP-layer capture. They arrive with the frame-level backend, not before. |
Lifting coarse to http |
The GitHub connector brings its own HTTP stack, so its flows are coarse. It exposes a service layer that a middleware could hook, which would give it exact bytes and peer IP. |
| Egress enforcement | Netflow records; it does not block. Deny-by-default policy needs the isolated runtime as its substrate. |