Metrics
Kapkan exposes Prometheus metrics under the kapkan_ namespace at GET /metrics on the
API listener (api.listen, conventionally 127.0.0.1:8080 — the value in the example config; the field is required, so set it). They cover the full pipeline —
flow ingest, the detection engine, RTBH mitigation, notifications, and the in-kernel XDP
data plane where it is enabled — so you can scrape
throughput, latency, active attacks and announced routes into your existing monitoring.
i/metrics stays open
The /metrics endpoint is always unauthenticated, even when you set an API token with
api.token_env. Only the /api/v1 data endpoints require Authorization: Bearer <token>.
Bind the listener accordingly — see Authentication.
Available metrics
| Metric | Labels | Meaning |
|---|---|---|
kapkan_ingest_flows_total | proto | Total flows ingested, by flow protocol. |
kapkan_ingest_packets_total | exporter, proto | Total telemetry datagrams received, by source exporter (cardinality-bounded; see flow_sources) and protocol. |
kapkan_ingest_decode_errors_total | proto | Telemetry datagrams that failed to decode, by protocol. |
kapkan_ingest_dropped_flows_total | — | Flows dropped because the engine input queue was full. |
kapkan_engine_active_attacks | — | Gauge of attacks currently in progress. |
kapkan_engine_attacks_total | — | Counter of attacks detected since start. |
kapkan_engine_process_latency_seconds | — | Histogram of hot-path processing latency, observed once per ingest batch (not per flow). |
kapkan_engine_tracked_hosts | — | Gauge of hosts currently tracked by the engine. |
kapkan_engine_boundary_debug_bytes_total | exporter, iface, dir | Sampling-corrected bytes toward (dir=in) or from (dir=out) protected hosts, broken down by exporter and interface (iface = ifIndex). Only appears while sampling.boundary_debug: true — a temporary aid to find your edge interfaces. It is NOT cardinality-bounded; disable it again after reading the breakdown. |
kapkan_engine_events_dropped_total | kind | Engine lifecycle events shed because the event channel was full, by kind (attack_started, attack_ongoing, attack_ended). A dropped attack_started or attack_ended is a real loss — that episode's mitigation or notification is missed; a dropped attack_ongoing heartbeat self-heals on the next detection window. Should sit at zero; a rising counter means the event consumer cannot keep up. |
kapkan_mitigate_announced_routes | mode | Gauge of blackhole routes currently announced, split by real / dry_run mode. Counts only the rungs that ask a peer to enforce something (blackhole, divert, flowspec); bans on the local dataplane rung are in kapkan_mitigate_dataplane_bans. |
kapkan_mitigate_flowspec_rules | mode | Gauge of FlowSpec rules currently announced, split by real / dry_run mode. Watch this against your routers' FlowSpec route limit. |
kapkan_mitigate_dataplane_bans | mode | Gauge of bans currently enforced by this host's own XDP data plane rather than by an upstream, split by real / dry_run mode. These announce nothing to any peer and keep filtering through a BGP outage. |
kapkan_mitigate_dataplane_rules | mode | Gauge of rules those bans installed in the kernel, split by real / dry_run mode. A ban contributes 1 rule for a cleanly classified single-vector attack and up to 8 for a mixed or unclassified one, so this is at least kapkan_mitigate_dataplane_bans and at most 8× it — watch it against dataplane.limits.max_dynamic_rules the way kapkan_mitigate_flowspec_rules is watched against an upstream's route limit. |
kapkan_mitigate_bans_rejected_total | reason | Bans refused by a safety guard, by reason: max_active_bans, blast_radius_fraction, blast_radius_rate, or max_active_prefix_bans (carpet). |
kapkan_mitigate_source_blocks | mode | Gauge of live source blocks — the source→victim pairs installed through POST /api/v1/dataplane/sources — split by real / dry_run. Same mode semantics as the gauges above: dry_run means recorded and reported, nothing in a kernel map. These pairs draw on the same policy slots as bans, so watch the sum against dataplane.limits.max_dynamic_rules. |
kapkan_mitigate_source_blocks_rejected_total | reason | Source-block requests refused by policy, by reason: no_dataplane, victim_protected, source_allowlisted, source_in_networks, victims_full (this source already holds 8 victims), slots_full (no slots left after every ban's reservation), install_failed, or other. Input mistakes (a bad address, an out-of-range TTL) are the caller's bug and are deliberately not counted here — a rising source_allowlisted or slots_full is the signal worth an alert, because it means an integration is asking for blocks that will never take effect. |
kapkan_mitigate_fallback_total | from, to | Mitigation announces that degraded to a fallback method because the peer rejected the primary, by from/to. A non-zero from="flowspec" series flags upstreams that do not honor FlowSpec. |
kapkan_notify_notifications_total | channel, result | Notifications attempted, by channel (telegram, slack, email, webhook, exec) and result (ok, error, or dropped when delivery slots were saturated). |
kapkan_storage_rows_total | table, result | Rows handed to the optional ClickHouse storage, by table and written / dropped / error. dropped = the storage queue was full when the row was handed over, so it never reached an insert — a sink that stalls, or a burst bigger than queue_size. error = the insert failed and the batch was discarded, which is where a ClickHouse that is down counts, since a refused connection fails at once. Storage never blocks detection either way, so flows are still processed and mitigated. |
kapkan_build_info | version, revision, goversion, goos, goarch | Constant 1 info gauge carrying the running build in its labels (the node_exporter idiom). Query fleet version drift with count by (version)(kapkan_build_info) — zero phone-home. |
kapkan_update_available | latest_version, security | 1 when the opt-in update_check finds a newer release (absent otherwise); security="true" flags a security-relevant release. |
The mode label on kapkan_mitigate_announced_routes is the fastest way to confirm dry-run
state at a glance: while dry_run: true, every would-be route is counted under
mode="dry_run" and nothing under mode="real". See Mitigation and the
Safety model.
The two kapkan_mitigate_dataplane_* gauges are the mitigator's account of what it installed,
attributed to the bans that own it and filed under each ban's frozen dry-run flag.
kapkan_dataplane_rules measures the neighbouring quantity from the other end: every rule the
kernel is actually running — your config's statics plus those same dynamic rules — filed under
the datapath's own flag. So under mode="real", kapkan_mitigate_dataplane_rules should track
the dynamic half of kapkan_dataplane_rules.
Graph both and compare them rather than adding them up. They are two independent paths to the
same number, and a lasting gap between them in real mode is a fault — a withdraw that
failed, or rules the kernel expired underneath a ban that still considers itself active — which
a single summed figure would hide.
Do not alert on the dry_run buckets. A dry-run ban is never handed to the installer, so
nothing enters the kernel: kapkan_mitigate_dataplane_rules{mode="dry_run"} reports the rules
that would have been installed, while the dynamic half of kapkan_dataplane_rules stays at
zero. That gap is the intended difference between intent and measurement, and since dry_run
is the shipped default it is what a new deployment will show.
The proto label is the wire protocol the flow arrived on: sflow5, netflow5,
netflow9, or ipfix (e.g. kapkan_ingest_flows_total{proto="sflow5"}).
Data plane (XDP)
The kapkan_dataplane_* family reports the in-kernel XDP data plane
(dataplane.enabled: true). Most of these series are created as the data
plane writes them, so on a host that mitigates through BGP alone they are absent rather than
zero.
Much of the family is lifecycle, and that is deliberate. An XDP program that is not attached looks exactly like one that is — the daemon is up, the API answers, bans are recorded — and the only visible difference is that the packets you asked to drop are not being dropped. So "is it attached" is a metric rather than something to infer from a log line at boot.
!Five series exist even with no data plane
kapkan_dataplane_degraded, kapkan_dataplane_pins_rebuilt,
kapkan_dataplane_shadowed_static_rules, kapkan_dataplane_policy_generation and
kapkan_dataplane_policy_apply_seconds carry no
labels, so they are registered at startup and exported by every build — reading 0 on a host
that has never loaded a BPF program. Do not read kapkan_dataplane_degraded == 0 as "the data
plane is healthy": on a BGP-only host it means there is nothing to degrade. Gate data-plane
alerts on the data plane actually running — the presence of kapkan_dataplane_xdp_mode is the
cheapest such guard.
| Metric | Labels | Meaning |
|---|---|---|
kapkan_dataplane_degraded | — | 1 when at least one configured interface has no live XDP attachment. This is the single series to alert on. /healthz reports the same condition as dataplane: DEGRADED (n/m interfaces attached) and still returns 200 — a restart cannot conjure a missing NIC. |
kapkan_dataplane_xdp_mode | interface, mode | 1 when the program is attached to this interface in this mode (native or generic), 0 for the other, so a fallback across a restart never leaves a stale series claiming both. BOTH series read 0 for an interface that is not filtering at all — that is the difference between "on the generic path" and "not protected". generic costs roughly 10x the per-packet CPU of native. |
kapkan_dataplane_attach_errors_total | interface | Failed attach attempts, including the watcher's retries. Rising with kapkan_dataplane_degraded is a NIC that will not take the program; rising without it is a flapping link that is being recovered. |
kapkan_dataplane_reattach_total | interface | Times the program was re-attached to an interface after losing the attachment. This is what makes an intermittent NIC visible: every individual scrape says healthy, and this counter says it was not. |
kapkan_dataplane_pins_rebuilt | — | 1 when an existing pinned program was found at startup, rejected and rebuilt — expected after an upgrade that changes the BPF object, its map layout or dataplane.limits. The cost is that the previous process's dynamic rules are gone; active attacks are re-mitigated on their next detection interval. |
kapkan_dataplane_shadowed_static_rules | — | Config static rules that can never fire, because the allowlist or an earlier static rule already takes every packet they select. 0 is the only healthy value, and it is worth an alert at any other — this defect has no other numeric symptom, since the dead rule's own counter also sits at zero, which is exactly what a correct rule looks like when its traffic has not arrived. Republished on every policy apply, so it clears on the reload that fixes the config. See Rules that can never fire. |
kapkan_dataplane_packets_total | verdict | Packets by terminal XDP verdict (pass_default, pass_allow_src, drop_static, drop_rl, …). Exactly one terminal verdict is counted per packet — see Terminal verdicts and observations before summing it. |
kapkan_dataplane_bytes_total | verdict | Bytes, by the same terminal verdicts. A packets-per-second graph alone cannot tell a 64-byte SYN flood from a 1500-byte amplification reflection, and the two call for different responses. |
kapkan_dataplane_observations_total | kind | Datapath observations that accompany a terminal verdict rather than replacing it: dryrun_would_drop, pass_rule_expired, pass_frag_noports, err_policy_missing, and the fingerprint plane's copy sampler fp_emitted / fp_throttled / fp_ring_full. A separate metric on purpose — see below. |
kapkan_dataplane_filter_bypass_packets_total | reason | An alarm, not a statistic. Packets forwarded without a single rule being evaluated, because they hit a datapath parse limit first. One reason exists today, ipv6_exthdr_cap. Alert on any non-zero rate — see The filter-bypass alarm. |
kapkan_dataplane_filter_bypass_bytes_total | reason | Bytes for the same, by the same reason. It separates a probe from a flood: a handful of crafted packets an hour is somebody measuring your parser, and a sustained bitrate is the attack that measurement was for. |
kapkan_dataplane_rules | mode | Rules the kernel is currently enforcing — static policy plus the mitigator's dynamic rules — split real / dry_run exactly as kapkan_mitigate_flowspec_rules is. The mode is the datapath's own flag read back from the kernel, not the config file's: an adopted pin set can still be running the previous process's flag. |
kapkan_dataplane_map_entries | map | max_entries of each BPF map as actually created, after dataplane.limits were applied. |
kapkan_dataplane_map_bytes | map | Kernel footprint estimate per BPF map, in bytes (the memlock field of the map's fdinfo). |
kapkan_dataplane_policy_generation | — | The generation of the double-buffered static policy currently live in the kernel. The value is uninteresting; the slope is the alert — every flip walks the policy map under the lock that also serialises rule installs, so a generation climbing once a second means something is republishing policy in a loop. |
kapkan_dataplane_policy_apply_seconds | — | Histogram of the time to build and publish one generation of static policy. That span holds the lock a rule install waits on, so this is the metric that answers "could a config reload have delayed mitigating an attack?". |
Terminal verdicts and observations
!Never add the two families together
kapkan_dataplane_packets_total and kapkan_dataplane_observations_total are two metrics
on purpose. Observation counters are bumped alongside the terminal verdict for the same
packet — a dry-run rewrite bumps both dryrun_would_drop and the pass it was rewritten
to — so a single metric carrying both would make the obvious query over-count exactly the
packets you most want counted correctly.
With the two split, exactly one terminal verdict is counted per packet, so
sum(rate(kapkan_dataplane_packets_total[1m]))
is packets through the datapath, and
sum(rate(kapkan_dataplane_packets_total{verdict=~"drop_.*"}[1m]))
/ sum(rate(kapkan_dataplane_packets_total[1m]))
is the drop rate. Neither query should ever include kapkan_dataplane_observations_total.
During a dry run the observation to watch is dryrun_would_drop: it counts the packets that
would have been dropped, and it is the whole argument for turning dry_run off.
sum(rate(kapkan_dataplane_observations_total{kind="dryrun_would_drop"}[1m]))
The filter-bypass alarm
kapkan_dataplane_filter_bypass_packets_total{reason="ipv6_exthdr_cap"} counts packets the
data plane forwarded without evaluating a single rule. Every other pass_* verdict means
the rules ran and none of them said drop; this one means the rules never ran at all — allow
lists, drop rules and rate limits alike. For those packets the filter was, in effect, switched
off.
The cause is a parse budget: the datapath walks at most eight IPv6 extension headers, and a packet carrying more is passed on unexamined. That it is passed and not dropped is deliberate — a parse limit must never become a default-deny — which means this counter is the entire mitigation. Operating & monitoring covers the mechanism and what to do about it.
No legitimate traffic chains eight extension headers, so the alert threshold is zero rather than some rate you tune:
sum(rate(kapkan_dataplane_filter_bypass_packets_total[5m])) > 0
Both series are published even while nothing has been bypassed, so a healthy data plane shows
an explicit 0 rather than "No data" — that is how you can tell the alert is wired up at all.
!A second view, not extra traffic
These packets are also counted in kapkan_dataplane_packets_total{verdict="pass_exthdr_cap"}.
The duplication is deliberate: packets_total still partitions the traffic exactly once, and
this family lifts one of its members out under a name an alert rule can state plainly. Never
add the two together.
Counters across a restart
kapkan_dataplane_packets_total and kapkan_dataplane_bytes_total start at zero with the
process, but the kernel maps they are read from do not — a process that adopts an existing
pin set inherits the previous one's totals. The scraper seeds its baseline from the first
read and publishes only what this process observed, so rate() is correct at startup
instead of showing a spike as wide as the previous process's whole lifetime. The absolute
kernel totals are on /api/v1/status if you need them.
The same reseeding happens when the counters go backwards, which can only mean they were reset underneath the process (pins rebuilt, a map recreated). A rebuild therefore shows up as a gap in the rate, never as a negative spike.
Map sizing and memory
kapkan_dataplane_map_entries and kapkan_dataplane_map_bytes are the feedback loop for
dataplane.limits. BPF map memory is charged to the unit's memory cgroup in one step at
load, so a MemoryMax= that was fine before the data plane existed can OOM the unit at
startup. Two maps dominate the footprint — kapkan_rl_src4 and kapkan_rl_src6, sized from
dataplane.limits.max_ratelimit_sources — and being LRU hashes they are pre-allocated in
full whether or not a single source is ever rate-limited. Lower the limit and read the result
off these gauges rather than inferring it:
sum(kapkan_dataplane_map_bytes)
Per-ban drop counts
How much the kernel dropped for one victim is deliberately not here. /metrics is
unauthenticated, and a victim address in a label would publish who is under attack to anyone
who can reach the listener. Those per-ban and per-rule counters are on the authenticated
/api/v1/bans instead, under each ban's dataplane object.
Fingerprint plane
The fingerprint plane (dataplane.fingerprint.enabled: true)
reports its userspace reader here; its in-kernel copy sampler is on
kapkan_dataplane_observations_total{kind="fp_emitted"|"fp_throttled"|"fp_ring_full"} above.
| Metric | Labels | Meaning |
|---|---|---|
kapkan_fingerprint_events_total | result | Ring events by reader outcome: classified (a JA4 was computed), blocked, would_block (a dry-run match), suppressed (a repeat within the block's cooldown), block_error (refused by policy — allowlisted / protected / budget full), unparsed (truncated, not a handshake, or an undecryptable QUIC packet), malformed, unknown_axis, panic. A rising block_error is the signal worth an alert; a rising unparsed under load is usually just truncated snapshots and is benign. |
fp_throttled climbing while fp_emitted plateaus is the sampler doing its job under a handshake
flood; a sustained fp_ring_full means the reader cannot drain the ring fast enough.
Edge nodes
An edge node exposes its own series on status_listen (/metrics), beside the
Go runtime's:
| Metric | Type | Labels | Meaning |
|---|---|---|---|
kapkan_edge_decisions_total | counter | zone, result | Decision-service verdicts: allow, allow_marked (allowed with a mark), allow_cleared (a valid clearance cookie passed the rung), deny_rate, deny_concurrency, deny_table, challenge (a 401: the client must clear the rung — nginx serves the clearance page), would_deny / would_challenge (a dry-run deny or challenge, answered as allow), untracked (the per-source tables were full, the request passed undecided), mode_none (a zone without decisions), unknown_zone, bad_request (a subrequest off the contract). |
kapkan_edge_challenge_active | gauge | zone | 1 while the zone-wide challenge of the node's auto.zone_rps trigger is in force on this node, 0 otherwise (a manual zone or an operator's lever challenges everyone too, but as the zone's mode, which the report and GET /api/v1/edge/zones/status show). Whether the flip bites or previews, and its reason, are in the node's report, not a label. |
kapkan_edge_clearance_total | counter | zone, result | What the clearance page did: page (the challenge page was served), page_json (a non-GET original got the compact refusal), issued (a solved puzzle earned a clearance), issued_nojs (the timed no-JS ticket did), invalid (a wrong, stale or not-yet-valid answer or ticket), rate_limited (the issuance cap held: 6 per source, 6 000 per zone a minute), unknown_zone (a zone this node does not serve, or whose rung is off), bad_request, error (the page could not answer — a 5xx). |
kapkan_edge_log_records_total | counter | result | Access-log datagrams from the terminator: ok, malformed, oversized, dropped (the node's own handler could not keep up — its 8192-record queue was full), unknown_zone. Datagrams the kernel discards never reach the node and are not counted here. |
kapkan_edge_inflight_resets_total | counter | — | In-flight counters the decision service reset because a busy source saw no completion for a whole idle period — its log stream was lossy or dead. A steady rate means access-log datagrams are being lost before they reach the node: raise net.unix.max_dgram_qlen (the node warns at start when it is below 512). |
kapkan_edge_verdict_table_entries | gauge | — | Live deny, challenge and mark entries in the node's verdict table (a challenge or a mark hidden beneath a live deny counts, a lapsed one does not). |
kapkan_api_node_binding_refused_total | counter | route | Brain-side: requests refused because an agent token bound to one node (api.tokens[].node) named another node — or, on a poll, none — by route: edge_zones, edge_report, edge_acme (slot and challenges), dataplane_rules, scrub_report. A rising count is a misconfigured or leaked token; the brain's log names it, once a minute per token. |
kapkan_api_zone_refused_total | counter | route | Brain-side: requests a tenant-scoped token made on a zone it does not own — another tenant's, unlabelled, or gone from the zones file — by route: edge_lever (the lever), edge_history (the history and source reads). The caller gets one uniform refusal (404 unknown zone on the lever, 403 on the reads — counted on the reads only while storage is on: with it off the zone is not looked at); a rising count is a misconfigured or leaked scoped token, and the brain's log names it once a minute per token. |
kapkan_edge_cert_not_after_seconds | gauge | zone | Expiry of the zone's certificate as a Unix timestamp — the T−30 d alarm: alert when it is less than 30 days away. Dropped when a zone leaves the document. |
kapkan_edge_acme_attempts_total | counter | zone, result | Certificate orders: issued, renewed, failed (on either CA), and fallback — a certificate obtained from the fallback CA, counted alongside its issued/renewed entry, so sum issued+renewed+failed for attempts. |
kapkan_edge_h3_ready | gauge | — | 1 when this node may render HTTP/3: its nginx -V probe found --with-http_v3_module and edge.yaml's quic.h3 is not off. 0 otherwise — the reason (no_module, node_off, unknown) is terminator.h3.state in the node's report and h3.state on /healthz, not a label. The probe runs once at start, so the gauge stays where that probe left it until the node restarts. |
kapkan_edge_requests_total | counter | zone, protocol | Requests seen in the terminator's access log, by zone and protocol: h1, h2, h3 (the log's proto field, $server_protocol), other (absent — a node whose render predates the field — or unexpected). The share of h3 is the rollout's progress; the zone label is bounded by the document. |
kapkan_edge_history_dropped_total | counter | reason | Brain-side: parts of an edge node's report the edge history did not write, by reason — unknown_zone (a zone the zones file does not have — a window, a certificate or a challenge naming it), outside_scope (a zone the file has but the reporting node's placement does not cover), no_at (a window that carries counters but no close time), duplicate (a window already written: a re-sent report — expected once per burst end when reports come more often than windows close), extra_window (a second window for one zone in one report), bad_source (a source that is not an address), source_cap (more telling sources than the 20 a window keeps). The report itself is still accepted and shown live; a quiet zone (no window closed, nothing counted) is nothing to write and is not counted. Counted only while storage is on. A rising count other than duplicate means the history cannot use part of what some node reports; the brain's log names the node when it refuses a report. |
The brain adds nothing per edge node to its own /metrics; a node's liveness and last report
are read from GET /api/v1/edge/nodes, and the zones' rates and
would-be sets across the fleet from GET /api/v1/edge/zones/status.
Scraping
Add Kapkan as a scrape target in your Prometheus config:
scrape_configs:
- job_name: kapkan
metrics_path: /metrics
static_configs:
- targets: ["127.0.0.1:8080"]
Use the host and port from your api.listen value (conventionally 127.0.0.1:8080). If you expose the listener beyond
localhost, set an API token — /metrics stays reachable without it, so your scraper needs
no credentials.
To inspect the raw output directly:
curl -s localhost:8080/metrics | grep kapkan_
Grafana dashboard
An official Grafana dashboard for the kapkan_* set is available to download directly at
kapkan-overview.json (its source lives in the repository under
engine/deploy/grafana/).
It covers the full pipeline — ingest flow/datagram/decode rates and dropped flows, active and
started attacks, tracked hosts, hot-path latency quantiles, announced routes and FlowSpec
rules by mode, bans rejected by reason, mitigation fallbacks, and notification and storage
results. A Data plane row covers the XDP metrics above: packets per second by verdict,
rules in the kernel by mode, attachment by interface and mode, attach errors, policy generation
and apply-time quantiles, and BPF map entries.
In Grafana, go to Dashboards → New → Import, upload the JSON, and pick your Prometheus
data source when prompted (it is templatized as DS_PROMETHEUS). The dashboard targets
Grafana 10+ and is import-verified against Grafana 11. See the
README next to
it for provisioning.
Related
- REST API — the data endpoints served alongside
/metrics. - In-kernel data plane — the XDP backend behind the
kapkan_dataplane_*set. - Storage (ClickHouse) — the optional persistence behind
kapkan_storage_rows_total. - Deployment — running Kapkan as a service and choosing a listen address.