Getting started: from zero to first real data
Most "install" guides stop the moment the control plane answers /readyz. That
feels like success — but a control plane on its own observes nothing. This
guide takes you the rest of the way: to actual data on the screen.
The one idea that explains everything: consumer vs. producer
probectl has two kinds of moving part, and almost every "why do I see nothing?" question comes from blurring them:
- The control plane is a consumer. It receives data, stores it, correlates it, and serves the API/UI. It produces no observations of its own. (Its tagline — see everything, send nothing — is about telemetry never leaving your network, not about the box magically seeing traffic.)
- Agents and collectors are the producers. They are what actually watch the network: a synthetic probe sends traffic and times the answer; an eBPF agent reads the kernel's view of real connections; a flow collector receives NetFlow from your routers. Each one ships what it sees to the control plane.
No producers = no data. A freshly installed control plane is a perfectly healthy database with nothing writing to it.
/readyzis green and the dashboards are empty — that is expected, not a bug. The fix is always the same: attach a producer.
This guide brings up one control plane, then walks three independent ways to put first data behind it, and finally combines them into the payoff probectl is built for — a single correlated incident drawn from more than one plane.
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':50,'rankSpacing':45,'padding':12}}}%%
flowchart LR
subgraph P["producers (what you add in this guide)"]
A["canary agent<br/>(synthetic probes)"]
E["eBPF host agent<br/>(real flows)"]
end
A -- "gRPC / mTLS" --> CP["control plane<br/>(consumer)"]
E -- "bus (Kafka)" --> CP
CP --> UI["API / UI<br/>(data on the screen)"]
One command, if you just want to see it work
make first-data
That drives the evaluation stack plus its synthetic overlay end to end: Postgres, Kafka, the
control plane, the agent CA, an enrolled canary, and a probe whose result travels the same path
production uses — agent, mutual TLS over gRPC, the bus, the consumer, the tenant-scoped API. It
waits on readiness rather than a clock, prints the first measurement it receives, and names the
commands for reading more. It generates the one secret this path needs into
deploy/compose/.env.eval and reuses it on later runs.
It is evaluation only, for the reasons the next section explains, and as written it ends at the API rather than the web UI: dev auth is refused on anything but a loopback bind, so nothing on this stack is reachable from a browser.
To evaluate the interface, add one overlay — deploy/compose/eval-sso.yml. It turns dev auth
off and puts the same demo identity provider in front, so the stack runs real session auth; the
loopback rule governs dev auth alone, so with it off the UI is simply reachable:
export DEX_CLIENT_SECRET="$(openssl rand -hex 32)"
export DEX_DEMO_PASSWORD_HASH="$(htpasswd -bnBC 12 demo 'choose-a-password' | cut -d: -f2)"
export PROBECTL_SESSION_HMAC_KEY="$(openssl rand -hex 32)"
docker compose -f deploy/compose/eval.yml -f deploy/compose/eval-sso.yml up --build -d
open https://localhost:8443/ui/ # sign in as demo@probectl.local
That is real OIDC, real sessions and real RBAC on a stack whose bus and certificate are still the
evaluation ones. For the full production shape, use the production-shaped stack
(install.md plus deploy/compose/dex-demo.yml).
The rest of this section is the same path by hand, which is worth reading once.
The fastest way to first data: the evaluation stack
If you just want to watch probectl collect data with one command and no Go
toolchain, use the evaluation stack (deploy/compose/eval.yml).
It is a local-only, clearly-fenced build that removes the three things stopping
the shipped all-in-one from showing data on a laptop (it publishes no agent port,
ships no evaluation auth, and expects an external identity provider). Everything
here runs entirely inside Docker.
Eval-only, on purpose. The eval stack runs the API in
devauth — every request is an unauthenticated, all-powerful admin — so the control plane binds loopback only and is never published to your network (loopback is127.0.0.1, the interface whose packets never leave their host — an API bound there is physically unreachable from outside); you read it through an in-container helper. It also uses a plaintext bus and a self-signed certificate. Perfect for a laptop, never for production. The production-shaped stack isdeploy/compose/probectl.yml(seeinstall.md).
See first data — no enrollment, any OS
This brings up Postgres + Kafka + the control plane, plus an eBPF agent in fixture mode that replays a recorded SAMPLE flow file onto the bus. The control plane consumes it and folds the flows into the service-map (topology) graph:
# Builds the local eval images on first run; duration depends on host and cache.
docker compose -f deploy/compose/eval.yml up --build -d
# The viewer waits for control-plane readiness and sample topology data:
docker compose -f deploy/compose/eval.yml --profile tools run --rm --no-deps viewer
The viewer is a tiny curl helper that shares the control plane's network
namespace — the only way to reach the loopback-only API. It prints the
/v1/topology graph: nodes and flow edges built from the sample connections
(e.g. 10.0.1.5 → 10.0.2.9:443). That JSON is real data through the real
pipeline — agent → Kafka → consumer → topology — proving the loop end to end.
It is clearly sample input, not traffic on your machine. The viewer retries
readiness and the sample topology for a bounded window, then fails loudly
instead of printing an empty graph as success. If it exhausts that window,
watch docker compose -f deploy/compose/eval.yml logs control for
tenant isolation posture verified followed by starting probectl-control.
The --no-deps flag is intentional: the preceding up command already started
the stack, and this prevents Compose from rerunning the one-shot certificate
generator underneath the live control plane.
Tear it all down with docker compose -f deploy/compose/eval.yml down -v. If you
also layered the synthetic overlay below, name it and its profile too, or
down leaves the canary container and its identity volume behind (DPR-138) —
and the next run reuses an agent certificate signed by a CA that no longer
exists, which surfaces much later as tls: unknown certificate authority:
docker compose -f deploy/compose/eval.yml -f deploy/compose/eval-synthetic.yml \
--profile synthetic --profile browser-synthetic down -v
Add synthetic probes — the canary, with enrollment
Active probes need an enrolled, mutually-authenticated agent, so they layer on
deploy/compose/eval-synthetic.yml,
which provisions the agent CA and turns on the control plane's agent gRPC
listener. Mint a join token, hand it to the canary (which self-enrolls on
boot), then read its results:
docker compose -f deploy/compose/eval.yml -f deploy/compose/eval-synthetic.yml up --build -d
# 1. mint a one-time join token (prints pjt_…):
docker compose -f deploy/compose/eval.yml -f deploy/compose/eval-synthetic.yml \
exec control /usr/local/bin/app enroll-token \
-tenant 00000000-0000-0000-0000-000000000001 -name laptop
# 2. hand the token to the canary — it enrolls itself on boot, then probes:
PROBECTL_JOIN_TOKEN=pjt_xxx docker compose -f deploy/compose/eval.yml \
-f deploy/compose/eval-synthetic.yml --profile synthetic up --build -d canary
# 3. read its synthetic results:
docker compose -f deploy/compose/eval.yml --profile tools run --rm \
-e URL=/v1/results/latest viewer
The web First-run setup page measures the same end-to-end path. It keeps
credential creation outside the readiness count, then waits for four
server-observed milestones: authenticated agent connection, fresh producer
heartbeat, first tenant-scoped result, and a named first-finding receipt. The
default first test is an ICMP check of 127.0.0.1, so the proof is a real local
observation and does not depend on outbound Internet access. From a healthy
reference Compose stack, this path is budgeted at no more than seven UI
interactions and 15 minutes; the deterministic journey test records four UI
interactions plus the shell enrollment handoff.
The synthetic overlay is the most intricate part of the eval stack and the least exercised. If a step misbehaves, don't fight it — the from-source walkthrough below is the fully verified canary path and needs none of this overlay.
You already have data — you can jump to where to go next. The rest of this guide builds the same thing by hand from source, which is worth reading if you want to understand every moving part, or to drive the real production-shaped control plane rather than the eval build.
Building it yourself from source (the ground truth under the eval stack)
This matters, because it changes the commands below — so read it once.
The shipped all-in-one (deploy/compose/probectl.yml)
is deliberately minimal: it runs only the control plane (HTTPS on 8443) plus a
Postgres. It does not publish the agent gRPC port, does not wire the
agent-transport TLS files, and runs a release image — which (by design) has
no built-in evaluation auth. It is the right thing for a production-shaped
control plane, and install.md is its home. But you cannot attach a
canary to it, or drive its API without an identity provider, without extra
wiring — so it is not the smoothest "see data today" path.
The dev dependency stack (deploy/compose/dev.yml)
is the opposite shape: it brings up the backing services a fuller control
plane talks to — Postgres + Kafka + ClickHouse + Prometheus — but no control
plane (you run that from source against it).
The eval stack above is the one-command shortcut over exactly this shape. To do it by hand — to see every moving part, or to drive the production-shaped control plane rather than the eval build — build the binaries from source once, run the dev dependency stack, and run the control plane from source wired to it. That single control plane serves all three producer paths below.
Everything below assumes one tenant, the built-in default tenant
00000000-0000-0000-0000-000000000001. (A tenant is one isolated customer or organization in a deployment — the outermost boundary every record, agent, and query is scoped by.) In a real multi-tenant or provider deployment you create tenants first (seeadmin.md); the single-tenant case is just the one-tenant version of the same code.
Shared setup — one control plane for all three paths
1. Build the binaries (Go 1.26+). make build produces every component in
./bin; make build-devauth produces a control plane with evaluation auth
compiled in (so you can drive the API without an IdP — see the warning below).
make build # ./bin/probectl-control, probectl-agent, probectl-ebpf-agent, ...
make build-devauth # ./bin/probectl-control-devauth (LOCAL EVALUATION ONLY)
2. Start the backing services (Postgres — the relational store for durable state; Kafka — the message bus; ClickHouse — the high-volume event store; Prometheus — metrics):
make compose-up # docker compose -f deploy/compose/dev.yml up -d --wait
3. Run the control plane from source, wired to those services. This single environment turns on what every path below needs — the agent gRPC/mTLS listener (gRPC is the HTTP/2-based remote-procedure-call protocol the agents stream over; mTLS is mutual TLS — both ends present certificates, not just the server) for the canary, and the Kafka bus + ClickHouse stores for the bus-based producers:
# Apply the schema once.
PROBECTL_DATABASE_URL='postgres://probectl:probectl@localhost:5432/probectl?sslmode=disable' \
./bin/probectl-control migrate
# Create the agent certificate hierarchy once (Path 1 uses it; harmless otherwise).
# The root key prints ONCE — copy it somewhere safe; runtime never needs it again.
PROBECTL_DATABASE_URL='postgres://probectl:probectl@localhost:5432/probectl?sslmode=disable' \
./bin/probectl-control agent-ca init
# A self-signed server cert (tls.crt/tls.key/ca.crt). The control plane serves
# HTTPS with it, and Path 1's agent enrollment reuses it. SANs: localhost,127.0.0.1.
./bin/probectl-control gen-cert ./certs
# Export the agent CA's PUBLIC trust bundle (root + intermediate certificates,
# never a key) into that directory. This is the file the gRPC listener checks
# agent certificates against.
PROBECTL_DATABASE_URL='postgres://probectl:probectl@localhost:5432/probectl?sslmode=disable' \
./bin/probectl-control agent-ca export ./certs/agent-ca.crt
# Run the control plane. (Run this in its own terminal; it stays in the foreground.)
PROBECTL_AUTH_MODE=dev \
PROBECTL_DEV_AUTH_ACK=i-understand \
PROBECTL_HTTP_ADDR=127.0.0.1:8443 \
PROBECTL_TLS_CERT_FILE=./certs/tls.crt \
PROBECTL_TLS_KEY_FILE=./certs/tls.key \
PROBECTL_DATABASE_URL='postgres://probectl:probectl@localhost:5432/probectl?sslmode=disable' \
PROBECTL_REQUIRE_AT_REST_ENCRYPTION=false \
PROBECTL_ALLOW_KEYLESS_DEV=true \
PROBECTL_BUS_MODE=kafka \
PROBECTL_BUS_BROKERS=localhost:9092 \
PROBECTL_BUS_ALLOW_PLAINTEXT=true \
PROBECTL_FLOWSTORE_MODE=clickhouse \
PROBECTL_FLOWSTORE_URL=http://localhost:8123 \
PROBECTL_AGENT_GRPC_ADDR=127.0.0.1:9443 \
PROBECTL_AGENT_TLS_CERT_FILE=./certs/tls.crt \
PROBECTL_AGENT_TLS_KEY_FILE=./certs/tls.key \
PROBECTL_AGENT_TLS_CA_FILE=./certs/agent-ca.crt \
./bin/probectl-control-devauth
This control plane serves HTTPS on loopback (https://127.0.0.1:8443), the
agent gRPC/mTLS listener on 127.0.0.1:9443 (Path 1 needs it), and has the
Kafka bus and ClickHouse wired (Path 2 needs them). The gRPC listener is
all-or-nothing: all four PROBECTL_AGENT_* values must be set, and it reads the
cert files at startup — which is exactly why gen-cert and agent-ca export
ran first. One process now serves all three producer paths; if you only want
Path 2, the agent lines are harmless.
⚠️
PROBECTL_AUTH_MODE=devis evaluation-only, and it is strictDev auth gives every request an all-permissions principal with no authentication. It exists so you can poke the API on a laptop without standing up SSO. Because it is dangerous, the code makes it hard to enable by accident — all three of these are required, or the control plane refuses to start:
- the binary must be built with dev auth (
make build-devauth— the shipped release image does not contain it, soPROBECTL_AUTH_MODE=devon the shipped compose just crashes with a clear error);PROBECTL_DEV_AUTH_ACK=i-understandmust be set; and- the listener must bind loopback only (
127.0.0.1:...) — never a network interface.This is why the shared setup binds
127.0.0.1. For anything beyond a laptop, use the real path:PROBECTL_AUTH_MODE=sessionwith an OIDC IdP — OIDC is OpenID Connect, the standard web-login protocol, and the IdP is your identity provider: Okta, Entra ID, Keycloak, … (seeinstall.mdandadmin.md). The control plane is HTTPS/TLS everywhere by design — in production there is no plaintext or no-auth mode to lean on.
Dev auth and TLS are independent: the listener binds loopback (required for
dev auth) and serves HTTPS (from the cert you just generated). Confirm it is
up — pass the generated CA so curl trusts the self-signed cert:
curl --cacert ./certs/ca.crt https://127.0.0.1:8443/readyz # -> {"status":"ready"}
That is your consumer, ready and empty. Now pick a path.
First authenticated production API call
The commands above use dev auth so a laptop can prove the data path without an
identity provider. In a production-shaped deployment you should also prove the
same API path with session auth (PROBECTL_AUTH_MODE=session) and a
tenant-scoped bearer token. Think of the browser session as your interactive
login, and the bearer token as a script badge for one already-provisioned user:
the token inherits that user's tenant and RBAC permissions.
A brand-new deployment has no administrator yet: every SSO login lands with no roles. Grant the first admin from the control host (idempotent, audited; it creates the user row if the person has not logged in yet):
PROBECTL_DATABASE_URL='postgres://probectl:probectl@localhost:5432/probectl?sslmode=disable' \
./bin/probectl-control bootstrap-admin -tenant 00000000-0000-0000-0000-000000000001 -email operator@example.com
After the user has logged in once through OIDC, or has been provisioned by SCIM,
mint a one-time-visible control-plane bearer token on the control host. The token
uses the same hash-only mcp_tokens store as the MCP server, but it also works
for normal /v1 API and CLI calls:
export PROBECTL_DATABASE_URL='postgres://probectl:probectl@localhost:5432/probectl?sslmode=disable'
export TENANT_ID=00000000-0000-0000-0000-000000000001
export USER_EMAIL='operator@example.com'
# Find the user row that the session-mode login or SCIM sync created.
export USER_ID="$(
psql "$PROBECTL_DATABASE_URL" -At \
-v tenant="$TENANT_ID" \
-v email="$USER_EMAIL" \
-c "SELECT id FROM users WHERE tenant_id = :'tenant'::uuid AND email = :'email' LIMIT 1;"
)"
test -n "$USER_ID"
# Print the bearer secret once. PROBECTL_LOG_LEVEL=error keeps stdout token-only.
PROBECTL_LOG_LEVEL=error \
./bin/probectl-control mcp-token \
-tenant "$TENANT_ID" \
-user "$USER_ID" \
-name first-api-smoke > .probectl-api-token
export PROBECTL_API_TOKEN="$(cat .probectl-api-token)"
Now make the first authenticated API read. Authorization proves the user, and
X-Probectl-Tenant is a tenant assertion: it must match the token's tenant or
the request fails closed.
curl --cacert ./certs/ca.crt \
-H "Authorization: Bearer $PROBECTL_API_TOKEN" \
-H "X-Probectl-Tenant: $TENANT_ID" \
https://127.0.0.1:8443/v1/me
curl --cacert ./certs/ca.crt \
-H "Authorization: Bearer $PROBECTL_API_TOKEN" \
-H "X-Probectl-Tenant: $TENANT_ID" \
https://127.0.0.1:8443/v1/results/latest
The same tenant-scoped read through the CLI, in JSON form:
PROBECTL_API_URL=https://probectl.example.com \
PROBECTL_API_TOKEN="$PROBECTL_API_TOKEN" \
PROBECTL_TENANT="$TENANT_ID" \
probectl --json result latest
Use the CLI form against a deployment whose TLS certificate is trusted by the
host running the CLI. For the self-signed laptop certificate in this guide, the
curl --cacert ./certs/ca.crt form is the exact smoke test.
The remaining curl examples keep the production-shaped auth headers visible. If
you are only running the local dev auth control plane from the source
walkthrough, you may omit those two headers; dev auth synthesizes the admin
principal for loopback evaluation only.
Path 1 — Synthetic probes (the canary agent)
Any OS, unprivileged, no Kafka. The canary agent sends traffic on a schedule — an HTTP GET, an ICMP ping, a DNS lookup — and times the answer. It streams results straight to the control plane over gRPC/mTLS, so it needs nothing on the bus: just the control plane reachable. This is the lightest possible "first data."
Why this needs the agent CA (the mTLS part)
The agent connection is mutually authenticated: the control plane verifies the
agent, and the agent verifies the control plane. The agent proves who it is with
a short-lived certificate — an SVID — whose identity names its tenant
(spiffe://probectl/tenant/<t>/agent/<a>). Those certificates are issued by the
agent CA you created once in the shared setup (agent-ca init), and
agent-ca export wrote that CA's public trust bundle to
./certs/agent-ca.crt — the file the shared setup pointed
PROBECTL_AGENT_TLS_CA_FILE at, so the gRPC listener checks every agent
certificate against it. The trust wiring is already done; all that's left is to
give one agent an identity.
1. Mint a one-time join token
The token, not the agent, names the tenant — that is why -tenant is required.
This is an operator CLI that works directly against the control plane's
database (hence PROBECTL_DATABASE_URL); pointing PROBECTL_TLS_CERT_FILE at
the serving cert additionally makes it print the server-certificate pin:
PROBECTL_DATABASE_URL='postgres://probectl:probectl@localhost:5432/probectl?sslmode=disable' \
PROBECTL_TLS_CERT_FILE=./certs/tls.crt \
./bin/probectl-control enroll-token \
-tenant 00000000-0000-0000-0000-000000000001 \
-name laptop
It prints a pjt_… token once (single-use, expires in ~1h) and — because we
pointed it at the server cert — the server certificate pin for first contact.
Copy the token.
2. Enroll the agent
enroll generates the agent's private key locally (it never leaves the host),
redeems the token over HTTPS, and writes four files into --dir:
cert.pem (the SVID), key.pem, ca.pem (the agent CA trust bundle — the
same public bundle agent-ca export wrote, delivered to the agent side), and
server-ca.pem (the trust it verified the control plane with — here a copy of
./certs/ca.crt — so the agent can verify the server the same way).
./bin/probectl-agent enroll \
--server https://localhost:8443 \
--token pjt_REPLACE_WITH_THE_TOKEN_YOU_MINTED \
--dir ./identity \
--ca-file ./certs/ca.crt
The --ca-file ./certs/ca.crt tells the agent how to trust the control plane's
self-signed certificate on first contact; alternatively pass --ca-pin <hex-sha256> using the pin the mint step printed. No control-plane restart is
needed — the gRPC listener has been up since the shared setup, already trusting
the agent CA.
Two CAs, two jobs. Two independent trust checks happen on the gRPC handshake. The agent verifies the control plane's server cert against its
tls.ca_file(here, the quickstartca.crt). The control plane verifies the agent's client cert againstPROBECTL_AGENT_TLS_CA_FILE(the agent CA bundle fromagent-ca export). They are different CAs doing different jobs. In a real deployment you would mint a proper gRPC server certificate and distribute the agent CA bundle through your config management; on a laptop, reusing the quickstart server cert is the shortest honest path.
3. Write a minimal agent config
Point the agent at the gRPC listener and the identity files, and give it one
inline probe — an http check against a public URL. Save as agent.yml:
control_plane:
grpc_addr: "localhost:9443" # the control plane's PROBECTL_AGENT_GRPC_ADDR
tls:
cert_file: ./identity/cert.pem # the agent's SVID (proves the agent to the server)
key_file: ./identity/key.pem
ca_file: ./identity/server-ca.pem # verifies the SERVER to the agent: the trust
# enroll captured (a copy of ./certs/ca.crt), NOT ca.pem
server_name: "localhost" # must match a SAN on the server cert
agent:
capabilities: ["http"]
heartbeat_interval: 30s
canaries:
- type: http
target: "https://example.com/"
interval: 30s
timeout: 10s
params:
method: "GET"
expect_status: "2xx,3xx"
The full set of probe types and per-probe parameters is in
deploy/agent/probectl-agent.example.yml
and configuration.md. Probes run straight from this config
— you do not have to create anything server-side first.
Why
server-ca.pemand notca.pem. The gRPC listener presents the control plane's server certificate — here the quickstart cert signed by./certs/ca.crt— so the agent must verify it with that CA.enrollcaptured exactly that trust (the--ca-fileyou passed, or the certificate a--ca-pinmatched) intoserver-ca.pem, which is why the snippet it prints works as-is.ca.pemis the agent CA bundle: it is what the control plane trusts (PROBECTL_AGENT_TLS_CA_FILE) to verify agents, and it verifies the server only in deployments that issue the gRPC server certificate from the agent CA itself. Same handshake, two CAs, two jobs.
4. Run the agent
./bin/probectl-agent -config agent.yml
Within one interval (30s) it connects, runs the http probe, and streams the
result.
5. See the result
curl --cacert ./certs/ca.crt \
-H "Authorization: Bearer $PROBECTL_API_TOKEN" \
-H "X-Probectl-Tenant: $TENANT_ID" \
https://127.0.0.1:8443/v1/results/latest
You will see the latest synthetic result per target — your https://example.com/
probe with its DNS / connect / TLS / time-to-first-byte breakdown. In the UI, the
same data is on the Targets / synthetic results screen.
Why
curl/UI and not theprobectlCLI here? TheprobectlCLI speaks the same/v1API and supports--json, but it relies on the host trust store for TLS. Against this guide's self-signed HTTPS endpoint,curl --cacert ca.crtis the exact tool. Against a production/session-mode endpoint with a trusted cert, use the CLI form above.
Where is the UI? The control plane serves the web UI itself, embedded in the binary (ARCH-004): open the control plane's base URL —
/redirects to/ui/— behind the same TLS + CSP as the API. Release binaries bundle the built SPA; a from-source build that hasn't runnpm --prefix web run buildserves a placeholder page that says so (the API at/v1works regardless).
For the full enrollment lifecycle (rotation, revocation, the bootstrap threat
model), see agent/enrollment.md.
Path 2 — Real host flows (the eBPF agent)
Linux with CAP_BPF (the Linux capability that permits loading eBPF
programs without root) for live capture; any OS (including macOS) for the
recorded-sample path. The eBPF agent loads tiny, sandboxed observation programs
into the Linux kernel and watches every real TCP connection the host makes or
accepts — with the process and container behind it — and rolls those into a live
service map (the directed "who talks to whom" graph). It is strictly
observe-only: it never blocks or modifies a packet.
Why this needs the bus (and where the data lands)
Unlike the canary, the eBPF agent does not stream over gRPC. It is a separate
process from the control plane, so the two cannot share an in-process channel —
they meet on the message bus. The agent publishes flow + service-edge batches
to the topic probectl.ebpf.flows; a control-plane consumer drains that topic and
folds the edges into the topology graph. That bus is Kafka — the
open-source distributed message log: producers append, consumers read at their
own pace, and neither has to be up for the other to keep working — which is why
the shared setup runs dev.yml and wired the control plane to
PROBECTL_BUS_MODE=kafka.
Where eBPF data shows up — read this carefully. The eBPF host agent feeds the topology / service-map plane (
GET /v1/topology). The/v1/flows/*analytics views (top talkers, capacity, anomalies) are a different producer — the NetFlow/IPFIX/sFlow flow collector (probectl-flow-agent) or the local cloud-flow import path, which land in ClickHouse and are covered inflow.md. Both are "flow data" in plain English, but they are distinct planes with distinct producers. This path is the eBPF service map.
Option A — recorded sample flows (no kernel; works on a Mac)
The agent ships with a recorded flow fixture
(internal/ebpf/testdata/flows.json) so
you can light up the service map with clearly-labeled SAMPLE data on any OS,
no privileges. Point the agent at that file and at the dev Kafka:
PROBECTL_EBPF_TENANT_ID=00000000-0000-0000-0000-000000000001 \
PROBECTL_EBPF_FIXTURE_PATH=internal/ebpf/testdata/flows.json \
PROBECTL_EBPF_BUS_MODE=kafka \
PROBECTL_EBPF_BUS_BROKERS=localhost:9092 \
PROBECTL_EBPF_BUS_ALLOW_PLAINTEXT=true \
./bin/probectl-ebpf-agent
The sample contains a few synthetic connections (10.0.1.5 → 10.0.2.9:443, …→ 10.0.3.3:5432) — not real traffic on your machine. It is exactly enough to see
the pipeline work end-to-end.
PROBECTL_EBPF_BUS_ALLOW_PLAINTEXT=trueis required only because the dev Kafka broker is plaintext. Like the control plane, the agent refuses a plaintext broker unless you opt in explicitly — production Kafka is TLS (*_BUS_TLS_ENABLED).
Option B — live flows (Linux + CAP_BPF)
The live kernel loader is compiled in only with the -tags ebpf build, which
needs a Linux host, a BTF-enabled kernel (BTF — the kernel's embedded type
catalog, which is what lets one compiled agent adapt to any kernel), and clang
(the C compiler that builds the BPF objects; the shipped
probectl-ebpf-agent container image is this live build). Build it and run with a
config:
make ebpf-agent # builds ./bin/probectl-ebpf-agent WITH the live CO-RE loader
# needs CAP_BPF (+CAP_PERFMON); the plaintext-bus opt-in is env-only:
sudo PROBECTL_EBPF_BUS_ALLOW_PLAINTEXT=true ./bin/probectl-ebpf-agent -config ebpf.yml
(CO-RE — compile once, run everywhere — is the eBPF build model where the compiled program carries relocation info and adapts itself to the running kernel at load time, instead of being recompiled per kernel.)
A minimal ebpf.yml for the dev stack (see
deploy/agent/probectl-ebpf-agent.example.yml):
tenant_id: "00000000-0000-0000-0000-000000000001"
bus:
mode: kafka
brokers: ["localhost:9092"]
(The plaintext-bus escape hatch is not a config-file field — pass it as the
PROBECTL_EBPF_BUS_ALLOW_PLAINTEXT=true environment variable, as above. With a
real TLS broker you would instead set PROBECTL_EBPF_BUS_TLS_ENABLED=true.)
Either option emits batches every flush_interval (default 10s). The kernel,
capabilities, and hardening details are in ebpf-agent.md.
See the service map
curl --cacert ./certs/ca.crt https://127.0.0.1:8443/v1/topology
The response is the dependency graph — nodes for the hosts/services seen and flow edges between them, built from the agent's batches. In the UI this is the Topology view; the same edges power the change-aware what-if analysis.
Path 3 — Combine both (the actual payoff)
Each path on its own is one plane. probectl's reason to exist is cross-plane correlation: when something breaks, you want one incident that pulls evidence from every plane at once, rather than five dashboards you have to mentally join.
The shared-setup control plane already runs the gRPC listener and the Kafka bus — nothing to reconfigure; just run both producers at once:
# terminal 1 — the control plane from the shared setup (gRPC listener + Kafka bus on)
# terminal 2 — the canary
./bin/probectl-agent -config agent.yml
# terminal 3 — the eBPF agent (sample flows, any OS)
PROBECTL_EBPF_TENANT_ID=00000000-0000-0000-0000-000000000001 \
PROBECTL_EBPF_FIXTURE_PATH=internal/ebpf/testdata/flows.json \
PROBECTL_EBPF_BUS_MODE=kafka PROBECTL_EBPF_BUS_BROKERS=localhost:9092 \
PROBECTL_EBPF_BUS_ALLOW_PLAINTEXT=true \
./bin/probectl-ebpf-agent
Now you have synthetic results (Path 1) and a service map (Path 2) behind one tenant, on one control plane. Confirm both planes are populated:
curl --cacert ./certs/ca.crt \
-H "Authorization: Bearer $PROBECTL_API_TOKEN" \
-H "X-Probectl-Tenant: $TENANT_ID" \
https://127.0.0.1:8443/v1/results/latest # synthetic plane
curl --cacert ./certs/ca.crt \
-H "Authorization: Bearer $PROBECTL_API_TOKEN" \
-H "X-Probectl-Tenant: $TENANT_ID" \
https://127.0.0.1:8443/v1/topology # eBPF service map
See a correlated incident
The control plane continuously groups related signals across planes into a single incident, tenant-scoped. List what it has correlated:
curl --cacert ./certs/ca.crt \
-H "Authorization: Bearer $PROBECTL_API_TOKEN" \
-H "X-Probectl-Tenant: $TENANT_ID" \
https://127.0.0.1:8443/v1/incidents
# then drill into one:
curl --cacert ./certs/ca.crt \
-H "Authorization: Bearer $PROBECTL_API_TOKEN" \
-H "X-Probectl-Tenant: $TENANT_ID" \
https://127.0.0.1:8443/v1/incidents/<id>
When a synthetic probe to a service starts failing and that service is a node in the eBPF map, those signals land in one incident with evidence from both planes — the canary failure and the affected service edge — instead of two unrelated alerts. (The deepest demonstration of this is the cross-plane correlation gate that the test suite runs against an injected multi-plane fault; the same machinery is what you are watching here in miniature.) In the UI this is the Incidents view, and each incident shows its cross-plane evidence and the related change/topology context.
You have data — where to go next
You have proven the loop end to end: producers observe, the control plane consumes and correlates, the API and UI show it. From here:
- Add more producers.
deploying-agents.mdis the catalog of every producer and which infrastructure each needs (gRPC-to-control vs. bus). The depth docs per plane:- synthetic —
agent/enrollment.md,configuration.md - device/cloud flow analytics (NetFlow/IPFIX/sFlow + cloud flow logs) —
flow.md - eBPF host/L7 —
ebpf-agent.md - device / streaming telemetry (SNMP/gNMI) —
device-telemetry.md
- synthetic —
- Make it production-shaped. Swap dev auth for OIDC SSO and run behind real
TLS — start with
install.md(the shipped all-in-one and the Helm chart) andadmin.md(tenants, roles, audit, SSO). - Tune every knob. Every
PROBECTL_*control-plane variable and every agent config key is documented inconfiguration.md.