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_source_blocksmodeGauge 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_totalreasonSource-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_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 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_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, 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_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.

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.

MetricLabelsMeaning
kapkan_fingerprint_events_totalresultRing 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:

MetricTypeLabelsMeaning
kapkan_edge_decisions_totalcounterzone, resultDecision-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_activegaugezone1 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_totalcounterzone, resultWhat 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_totalcounterresultAccess-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_totalcounterIn-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_entriesgaugeLive 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_totalcounterrouteBrain-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_totalcounterrouteBrain-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_secondsgaugezoneExpiry 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_totalcounterzone, resultCertificate 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_readygauge1 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_totalcounterzone, protocolRequests 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_totalcounterreasonBrain-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.

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