GitHub

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

MetricLabelsMeaning
kapkan_ingest_flows_totalprotoTotal flows ingested, by flow protocol.
kapkan_ingest_packets_totalexporter, protoTotal telemetry datagrams received, by source exporter (cardinality-bounded; see flow_sources) and protocol.
kapkan_ingest_decode_errors_totalprotoTelemetry datagrams that failed to decode, by protocol.
kapkan_ingest_dropped_flows_totalFlows dropped because the engine input queue was full.
kapkan_engine_active_attacksGauge of attacks currently in progress.
kapkan_engine_attacks_totalCounter of attacks detected since start.
kapkan_engine_process_latency_secondsHistogram of hot-path processing latency, observed once per ingest batch (not per flow).
kapkan_engine_tracked_hostsGauge of hosts currently tracked by the engine.
kapkan_engine_boundary_debug_bytes_totalexporter, iface, dirSampling-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_totalkindEngine 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_routesmodeGauge 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_rulesmodeGauge of FlowSpec rules currently announced, split by real / dry_run mode. Watch this against your routers' FlowSpec route limit.
kapkan_mitigate_dataplane_bansmodeGauge 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_rulesmodeGauge 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_totalreasonBans refused by a safety guard, by reason: max_active_bans, blast_radius_fraction, blast_radius_rate, or max_active_prefix_bans (carpet).
kapkan_mitigate_fallback_totalfrom, toMitigation 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_totalchannel, resultNotifications attempted, by channel (telegram, slack, email, webhook, exec) and result (ok, error, or dropped when delivery slots were saturated).
kapkan_storage_rows_totaltable, resultRows handed to the optional ClickHouse storage, by table and written / dropped / error. dropped = the storage queue was full (storage never blocks detection, so flows are still processed and mitigated).
kapkan_build_infoversion, revision, goversion, goos, goarchConstant 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_availablelatest_version, security1 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.

MetricLabelsMeaning
kapkan_dataplane_degraded1 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_modeinterface, mode1 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_totalinterfaceFailed 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_totalinterfaceTimes 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_rebuilt1 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_rulesConfig 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_totalverdictPackets 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_totalverdictBytes, 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_totalkindDatapath observations that accompany a terminal verdict rather than replacing it: dryrun_would_drop, pass_rule_expired, pass_frag_noports, err_policy_missing. A separate metric on purpose — see below.
kapkan_dataplane_filter_bypass_packets_totalreasonAn 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_totalreasonBytes 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_rulesmodeRules 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_entriesmapmax_entries of each BPF map as actually created, after dataplane.limits were applied.
kapkan_dataplane_map_bytesmapKernel footprint estimate per BPF map, in bytes (the memlock field of the map's fdinfo).
kapkan_dataplane_policy_generationThe 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_secondsHistogram 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.

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.

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