Flow analytics — NetFlow / IPFIX / sFlow / cloud flow logs
What this is
Every router and switch can tell you, after the fact, who talked to whom: it summarizes the packets it forwarded into flow records (a 5-tuple — source IP, destination IP, source port, destination port, protocol — plus byte/packet counts) and ships them off the box. NetFlow, IPFIX, and sFlow are the three common wire formats for that export: NetFlow is Cisco's original (v5 with a fixed layout, v9 template-based), IPFIX is the IETF standard that grew out of v9, and sFlow ships sampled raw packet headers instead of summaries. For cloud-first estates, probectl also imports already-exported AWS VPC Flow Logs, Azure NSG Flow Logs, and GCP VPC Flow Logs from local files or object-export pipelines; it does not call cloud APIs by default.
If a synthetic probe is placing a test call yourself, a flow record is a line
on the itemized phone bill: it can't tell you how the call sounded, but it
tells you — for every call, after the fact — who talked to whom, when, and for
how many bytes. probectl's flow plane is that bill: the passive,
after-the-fact view of real traffic, the complement to the active testing
plane (synthetic probes the agent sends on purpose). Network gear exports flow
records to a small collector, probectl-flow-agent; the collector decodes
every format into one normalized, tenant-bound record (a tenant is one
isolated customer/organization in the deployment, and every record is stamped
with its tenant before anything downstream sees it); the control plane
enriches and stores them in ClickHouse (a column-oriented database built for
high-volume event data); and the API serves the three questions operators
actually ask of flow data: who are my top talkers, is a link filling up, and
is anything anomalous?
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart LR
R[routers / switches<br/>NetFlow v5/v9 · IPFIX · sFlow v5] -- UDP --> C[probectl-flow-agent<br/>decode · templates · sampling]
CL[cloud flow-log exports<br/>AWS VPC · Azure NSG · GCP VPC] -- "local file / object export" --> C
C -- "probectl.flow.events (FlowBatch, tenant-keyed)" --> B[(bus)]
C -- "probectl.flow.ingest-quality (bounded receipt, tenant-keyed)" --> B
B --> P[control-plane FlowConsumer<br/>verify tenant · ASN/geo enrich (opt-in)]
B --> Q[FlowQualityConsumer<br/>verify tenant + agent · validate allowlists]
P --> S[(ClickHouse probectl_flows<br/>tenant-first partition + ORDER BY)]
Q --> PG[(PostgreSQL current receipts<br/>tenant_id + forced RLS)]
S --> API["/v1/flows/top · /v1/flows/capacity · /v1/flows/anomalies"]
PG --> APIQ["/v1/flows/ingest-quality"]
The path through the code is worth holding in your head, because every other section is just a zoom-in on one arrow:
- devices export UDP datagrams to the collector (
internal/flow/collector.go); - the collector decodes them into a normalized
Recordand emits aFlowBatchon the bus topicprobectl.flow.events(internal/flow/emit.go); - the control plane's
FlowConsumer(internal/pipeline/flow.go) verifies the tenant, optionally enriches ASN/geo, and inserts rows into ClickHouse (internal/store/flowstore/); - the
/v1/flows/*handlers (internal/control/flows.go) run the analytics queries, tenant-scoped, against that store.
In parallel, the collector summarizes accepted-packet health into a small
FlowIngestQualityReceipt. This is a control receipt, not another copy of flow
analytics: it travels on probectl.flow.ingest-quality, is identity-verified by
an independent consumer, and lands as one current forced-RLS PostgreSQL row per
tenant/agent/exporter/protocol.
Sources
The collector binds three UDP listeners — one socket shared by NetFlow v5 and v9 (it sniffs the version word in the header), plus IPFIX and sFlow on their own IANA ports. Disable any listener you don't run.
| Source | Port / input | Notes |
|---|---|---|
| NetFlow v5 | :2055 |
fixed-layout records; sampling interval lives in the v5 header |
| NetFlow v9 | :2055 |
template-based (RFC 3954); shares the v5 socket (version-sniffed) |
| IPFIX | :4739 |
RFC 7011; unknown/enterprise + variable-length fields are skipped by their declared length |
| sFlow v5 | :6343 |
raw-packet-header samples, parsed Ethernet / 802.1Q / IPv4 / IPv6 / TCP / UDP far enough for the 5-tuple |
| AWS VPC Flow Logs | local line export | default v2 text fields; log-status != OK rows are ignored |
| Azure NSG Flow Logs | local JSON Lines | Network Watcher v2 flow-tuples are normalized into the same 5-tuple/counter shape |
| GCP VPC Flow Logs | local JSON Lines | Cloud Logging VPC flow entries are normalized from jsonPayload.connection |
Templates (v9 / IPFIX). Unlike v5's fixed layout, v9 and IPFIX describe
their record shape in a template the exporter sends periodically — the legend
mailed separately from the map: until the legend arrives, the symbols on a data
record mean nothing, so the record is undecodable. Data that shows up before its
template is counted as a template miss and dropped — the exporter re-sends
templates on its refresh cycle, so the gap self-heals. Template state is keyed
per (exporter, observation domain) (the observation domain is the exporter's
own sub-source ID — a line card or engine — so two engines on one box never mix
templates), expires on a TTL (default 30m,
PROBECTL_FLOW_TEMPLATE_TTL), and is size-capped (default 4096,
PROBECTL_FLOW_MAX_TEMPLATES) so a hostile or misconfigured exporter cannot
grow collector memory without bound.
Sampling. High-rate links don't export every flow — they sample (say, 1 in
1000) to keep export volume sane, the way an exit poll asks one voter in a
thousand and multiplies up. A record that represents 1-in-N traffic would
undercount by Nx if you trusted its raw counters, so every Record keeps the
raw exported counters and the sampling rate, and carries pre-scaled
estimates (bytes_scaled = bytes × rate, packets_scaled = packets × rate)
that all analytics read. The rate comes from wherever each protocol declares
it: the v5 header's sampling field; for v9/IPFIX, the options-template sampler
elements (information elements 34 / 50 / 305) set a per-exporter rate, which an
inline element on the record itself overrides; and an sFlow sample carries its
own. Unsampled traffic is rate 1, so scaling is always safe (it never
multiplies by zero).
Normalized record and its OpenTelemetry names
All four formats decode into one Record (internal/flow/model.go), serialized
on the bus as probectl.flow.v1.FlowRecord. The schema was modeled on
OpenTelemetry conventions from its first field, so the OTLP layer exposes these
signals rather than remapping them. The mapping (see
otel-mapping.md):
- the 5-tuple uses the standard OTel keys —
source.address,source.port,destination.address,destination.port,network.transport(tcp/udp/icmp, or the IP protocol number as a string when there's no standard name),network.type(ipv4/ipv6); - flow-specific detail with no OTel home uses the
probectl.flow.*namespace —probectl.flow.exporter.address(the device that emitted the datagram),probectl.flow.protocol(netflow5|netflow9|ipfix|sflow5),probectl.flow.interface.in/.interface.out,probectl.flow.sampling.rate; - enrichment uses the ECS-aligned
source.as.number,source.as.organization.name,source.geo.country.iso_code(and thedestination.*equivalents), because OTel has no AS/geo convention (ECS is the Elastic Common Schema — another widely-used naming dictionary).
Cloud flow logs
Cloud flow-log import lives in internal/flow/cloudflow. It is intentionally a
local connector, not a cloud SDK client: an operator points probectl at files or
object-store exports that already exist inside their environment. That keeps the
default no-phone-home posture intact and lets air-gapped deployments use the same
normalization path after they move logs across their boundary.
Every imported row is stamped with the authenticated agent/operator tenant before
storage; tenant identity is never accepted from the cloud log payload. AWS
interface IDs, Azure NSG resource IDs/MACs, and GCP subnetwork labels are kept as
exporter provenance, while flow_protocol records which cloud format was parsed.
Use probectl-flow-agent in one-shot import mode with
PROBECTL_FLOW_CLOUD_PROVIDER and PROBECTL_FLOW_CLOUD_FILE (or the equivalent
cloud_import YAML block). The agent publishes the normalized records to the
same tenant-keyed probectl.flow.events topic and exits; the control plane then
uses the ordinary flow consumer and store path.
Security posture
Flow export is plaintext UDP with no authentication — by protocol design
(the same reality every flow collector lives with). probectl treats it as an
untrusted ingestion surface (see
security/threat-model.md):
- every datagram or cloud-log line is untrusted input: decoders are pure and bounds-checked, record counts, line size, and template state are capped, and malformed input is counted and dropped — never a panic in a production path;
- every enabled UDP listener has an exporter source ACL (
allowed_sourcesin YAML orPROBECTL_FLOW_*_ALLOWED_SOURCESin env). Non-loopback listeners without an ACL refuse startup, and packets from unlisted source IPs are dropped before decode and counted assource_drops; - the tenant on every record comes from the collector's own tenant binding (its config, the SPIFFE workload identity on its client certificate, or the authenticated local import context), never from anything the datagram or cloud log claims — source payloads cannot assert which tenant they belong to;
- deploy the collector adjacent to its exporters (management network / same site) so flow datagrams never cross an untrusted segment;
- everything downstream of the collector rides the standard authenticated paths (bus → control plane → ClickHouse over HTTP(S)). The control-plane consumer re-verifies each batch's claimed tenant against the agent registry and drops unverifiable batches fail-closed.
Enrichment (opt-in)
Raw flow records often lack the AS number and country of an IP — the AS
(autonomous system) being the independently-operated network an address
belongs to, like "AS13335, Cloudflare". The
control-plane consumer can fill source.as.number / destination.as.number,
the AS organization name, and the ISO country code via the opendata enricher —
but it is opt-in (PROBECTL_FLOW_ENRICH_ASN=true), because the Team Cymru
lookups it uses are outbound DNS and probectl never phones home by default.
Air-gapped deployments can fill the country from local files instead: an
operator-supplied GeoLite2 database (PROBECTL_FLOW_ENRICH_GEOIP_DB) or RIR
delegated stats (PROBECTL_FLOW_ENRICH_RIR_DIR) enrich with no egress at all,
and GET /v1/opendata/enrichment?ip= serves the full merged context (geo,
allocation, IXP presence) for one address — see
configuration.md for the source-by-source keys.
Device-asserted AS numbers (NetFlow v5/v9/IPFIX can export them) always pass
through and are never overridden — enrichment only fills blanks, is cached, and
degrades gracefully: a down or rate-limited source never blocks ingest.
Per-exporter ingest quality receipts
“No flows” is ambiguous: the exporter may be quiet, disconnected, sending the
wrong protocol, waiting for a template, or losing records locally. The flow
agent therefore emits the versioned
probectl.flow-ingest-quality/v1 contract once per reporting window for each
ACL-accepted exporter/protocol it has observed.
Each receipt is deliberately small and closed:
- identity: agent, normalized exporter IP, and allowlisted protocol;
- time: bounded window, last accepted packet, and last valid record;
- saturating counters: packets, decoded records, decode-error packets, template misses, local queue drops, and local bus-emission drops;
- allowlisted template/sampling state; and
- an honest
healthy,degraded, orstalestate with a fixed reason and fixed safe next action.
The state matrix is deterministic:
| Evidence | State | Safe action |
|---|---|---|
| recent packet + valid record + no observed loss | healthy |
continue monitoring |
| template missing/learning | degraded |
verify exporter templates |
| decode failures or no valid records | degraded |
verify exporter protocol/version |
| local queue loss | degraded |
reduce local ingest pressure |
| local bus delivery loss | degraded |
verify the local bus |
| last accepted packet older than three minutes | stale |
verify exporter delivery |
Privacy is structural. Rejected-source addresses never enter the aggregator. Raw datagrams, decoded five-tuples, credentials, and free-form error strings are not fields in the protobuf, Go model, migration, API schema, or support-bundle view. Vocabulary is allowlisted at producer, consumer, and storage edges. Agent memory is capped at 1,024 exporter/protocol pairs; the tenant store is capped at 4,096 current rows with 30-day cleanup. Counters saturate instead of wrapping.
The bus message is keyed by tenant. The control-plane consumer verifies
tenant+agent binding before persistence; a namespaced lane overwrites a forged
payload tenant with its authenticated lane tenant. PostgreSQL stores receipts
under tenant_id with ENABLE ROW LEVEL SECURITY and FORCE ROW LEVEL SECURITY, and every query retains an explicit tenant predicate as defense in
depth.
This path is entirely first-party and self-hosted. It adds no external runtime, dashboard dependency, browser call, or outbound request. The authoritative UI is the native Planes → Flow receipt list, also visible to administrators under Admin.
Storage
Records land in the ClickHouse table probectl_flows, created idempotently by
the flow store (internal/store/flowstore/clickhouse.go):
PARTITION BY (tenant_id, toYYYYMMDD(ts))andORDER BY (tenant_id, ts, exporter, src_addr, dst_addr)— think one labeled drawer per (tenant, day): because the tenant leads both keys, a tenant-scoped read prunes at the storage layer — it opens only that tenant's drawers and never scans another tenant's data (the tenancy guardrail enforced below the query) — and a single day's parts stay bounded even at NetFlow volumes;PROBECTL_FLOW_RETENTION_DAYS=Napplies a ClickHouse delete-TTL (toDateTime(ts) + INTERVAL N DAY DELETE— a time-to-live: ClickHouse deletes expired rows itself). The default is90;0disables the TTL and keeps flows indefinitely, which the control plane warns about at boot;- hourly rollups (
probectl_flow_rollups_hour) are materialized before raw rows age out. They keep tenant-scoped lower-resolution bytes/packets/flow counts queryable for long-term cost and trend views. Each rollup already retains its contributing exporter identity, so observation multiplicity remains derivable without a schema migration. A controlled backfill path rebuilds a tenant+window idempotently by deleting that rollup window first, then re-materializing duplicate-safe hourly facts from raw rows; - a
memorystore (the default) implements the sameStorecontract for the lightweight / single-node deploy, and is the reference implementation the ClickHouse SQL must agree with (the two share one local anomaly model, so both backends flag identically). The control plane selects the backend withPROBECTL_FLOWSTORE_MODE=memory|clickhouse(+PROBECTL_FLOWSTORE_URLfor ClickHouse).
In siloed/hybrid isolation, a per-tenant ClickHouse database holds that tenant's
probectl_flows table; the store routes each tenant to its target and fails
closed on any routing error rather than landing a siloed tenant's rows in the
pooled table.
Query API (tenant-scoped, flow.read)
Four read endpoints, all gated by the flow.read permission and scoped to the
authenticated principal's tenant before any value is read (the tenant never
comes from a query parameter):
GET /v1/flows/top?by=<facet>&window=1h&bucket=3m&limit=10&filter=protocol:ipfix&filter=port:443
GET /v1/flows/capacity?exporter=&direction=in|out&window=1h&bucket=3m
GET /v1/flows/anomalies?window=1h&bucket=3m&k=3&min_bps=1000
GET /v1/flows/ingest-quality?agent_id=&exporter=&protocol=&state=&limit=100
- Top-talkers aggregates the sampling-corrected bytes / packets / flow-counts
by the requested key and returns the highest contributors (
limitdefaults to 10, capped at 1000), plus bucketed history for the first six rows so the web workspace can show the ranked table and time series from the same query. Every ranked row and time bucket also returnsexporter_count, the number of distinct non-empty exporter identities that contributed after the tenant boundary and exact-match filters were applied. Empty or whitespace-only identities do not become observers; a zero count is disclosed as exporter identity unavailable in the native UI and CLI. Positive counts are described as observed by N exporters. It is observation multiplicity only: multiple exporters may see overlapping traffic along a path, so probectl does not claim the byte, packet, flow, or conversation totals were deduplicated. For a positive count on any non-exporter grouping, the native Flow workspace offers one View exporter / View N exporters evidence action. It applies that exact row's existing filters and switches the same tenant-authorized query toby=exporter; the resulting normal table is the bounded contributor list. Unknown identity and rows already grouped by exporter stay inert. The action is local query navigation only—it does not fetch a separate identity list, alter totals, or infer duplicate conversations.byacceptssrc,dst,pair,src_asn,dst_asn,as_name,src_country,dst_country,port,protocol, andexporter.by=pairgroups source→destination.as_nameuses the destination AS organization with a source fallback;portuses the destination service port with a source fallback. - Narrowing filters are repeated exact-match
filter=field:valuequery values and are ANDed. The publicas_nameandportfilters retain their any-endpoint meaning.group_as_nameandgroup_portinstead match the destination-with-source-fallback key used by those two groupings; the native row and contributor actions use these exact variants so another endpoint cannot widen the selected aggregate. The remaining allowlist is every grouping exceptpair; use onesrcand onedstfilter for a pair. The authenticated principal supplies tenant scope before filters are evaluated—tenant_idis not a valid filter. ClickHouse receives every filter value as a typed bound parameter; only the allowlisted field enum becomes SQL structure. - Capacity buckets per-
(exporter, interface)throughput into bps/pps (bits per second / packets per second) over time.directionselects which interface (ingress/egress) to group by (defaultin);bucketdefaults towindow/20, with a one-minute floor. - Anomalies runs the capacity series through the local-default anomaly model
(
local-zscore-v1): for each interface, the latest bucket is compared against what the model learned from that interface's own preceding buckets — each link is judged against its own history, never against other links. The model is pluggable, but the default is fully in-process and air-gapped: it makes no network calls and refuses to train on features whosetenant_iddiffers from the query tenant. It needs at least three training buckets plus the one under test.kdefaults to 3 andmin_bpsto 1000 (so tiny links don't trip). The response includes the model name, training-window provenance, the latest tenant-local feature vector, and feature citations. The same detector runs over both store backends. - Ingest quality returns at most 500 current receipts from the forced-RLS
PostgreSQL store. State is recalculated at read time, so a stopped agent cannot
leave a permanently green receipt.
ingest_running:falsemeans the receipt store is not wired (see the centralized limitations ledger); an emptyitemsarray withingest_running:truemeans no ACL-approved exporter has been observed yet.
Operations
- The collector logs a stats line every 60s with: packets, records, decode errors, template misses, queue drops, emit errors, dropped records (telemetry lost after emit retries were exhausted — never silently), and cached templates. probectl observes probectl.
- High-volume tuning. Raise
PROBECTL_FLOW_READ_BUFFER_BYTES(the kernel socket buffer that absorbs bursts) andPROBECTL_FLOW_WORKERS(readers per socket) first. Queue overflow is counted, not back-pressured: UDP has no brakes — senders never wait — so on a UDP reader, back-pressure only moves the drop into the kernel, where it's invisible. probectl drops visibly and keeps a counter instead. - Decode throughput is gated in CI (
make test-performancerunsTestHighVolumeDecodewithout race instrumentation or package contention, with a deliberately conservative 50k records/s floor so slow runners stay green; real hardware is far above it).
Example
# collector (run it near the devices)
PROBECTL_FLOW_TENANT=t-acme PROBECTL_FLOW_BUS_MODE=kafka \
PROBECTL_FLOW_BUS_BROKERS=localhost:9092 ./bin/probectl-flow-agent
# point a device at it (Cisco-style)
# flow exporter EXP destination <collector-ip> transport udp 2055
# ask the API (tenant comes from the authenticated principal, not the URL)
curl -s "https://localhost:8443/v1/flows/top?by=src_asn&window=15m&limit=5"
# CLI parity: pivot, then retain both narrowing filters.
probectl flow top --query by=dst_country \
--query filter=protocol:ipfix \
--query filter=port:443
# Human output includes "observed by N exporters" or "exporter identity
# unavailable"; --json exposes the exact exporter_count.
# Inspect bounded, secret-free exporter health without a dashboard dependency.
probectl flow quality --query state=degraded --query protocol=ipfix
See deploying-agents.md for where the collector sits
in the producer catalog (placement, service files, the full
producer-to-first-data path), deploy/agent/probectl-flow-agent.example.yml
for the YAML form of every key, and configuration.md for
the full PROBECTL_FLOW_* reference.