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.

⚠ Scope: rupu's own HTTP egress only. Netflow does not capture what an agent's 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.
providers LLM APIs SCM github, gitlab cp serve fleet HTTP auth / system tokens, ASN netflow client the one HTTP door reqwest middleware ledger flows.jsonl transcript Event::NetFlow control plane graph · table · summary
Every rupu subsystem that speaks HTTP goes through one instrumented client. The middleware fans each record out to the append-only ledger and (when a run is active) the run transcript; the control plane reads both back and enriches them with ASN at render time.

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.

Capture must never break a request. That is non-negotiable. The ledger sink writes through a bounded channel to a background task; on overflow it drops the record and increments a counter the UI surfaces as "N flows dropped"visible loss, not silent loss. Sink and write failures log once and continue, ASN lookup degrades to "unknown" and is never fallible in a way a caller must handle, and a failure to open the ledger degrades to transcript-only capture rather than failing the run.

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:

FieldWhat it holds
idULID for this flow. Minted by the caller for streamed bodies so a later completion line can find it.
tsUTC timestamp, taken when the record is emitted.
ctxAttribution: run_id, step_id, agent, workspace_id (each optional) and origin.
ctx.originWhich subsystem opened the connection — provider / scm / mcp carry a name; webhook, update, cp, system are bare.
fidelitycoarse | http | full — how much of this record is actually known. See below.
methodHTTP method.
schemehttps / http.
hostEndpoint hostname.
portEndpoint port. host:port is the endpoint identity used by the rollup and the graph.
pathRequest path, query-stripped — never contains a ?.
peer_ipThe peer actually connected to. Absent at coarse fidelity. This is the field ASN enrichment keys on.
resolved_ipsEvery A/AAAA answer the custom DNS resolver saw for the host — strictly more than the one peer used.
http_versionNegotiated HTTP version, when known.
statusResponse status code, when a response was received.
outcomeok | http_error | transport_error | timeout.
errorError text for a failed flow. URLs and query strings are kept out of it.
bytes_outRequest bytes, when observable.
bytes_inResponse bytes. Declared (from Content-Length) at emit time; observed once a completion line finalizes it.
body_completefalse while a streamed body is still draining; flipped by the completion line folded in at read time.
ttfb_msTime to first byte.
duration_msTotal 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

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.

FidelitySourceWhat 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:

PathHolds
<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:

LineMeaning
flowA full FlowRecord.
completeFinalizes a streamed body written earlier — carries the flow id, observed bytes_in, and duration_ms. Folded into its flow at read time.
droppedRecords 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.

The ledger directory ignores itself. It holds every host, IP, path and timing rupu contacted during a run, so .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.

SurfaceScope
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.

Why not a world map. A geographic map was considered and rejected: nearly every endpoint resolves to a CDN edge, so a country pin shows a point of presence rather than who was actually contacted — visually striking, forensically false. A topology answers the real question without pretending to know something it doesn't; ASN/org is the honest form of "where did this go." Edge weight is a visual scale, never a reported total — a 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:

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:

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:

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.

KeyTypeDefaultWhat 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 laterWhat 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.