Metrics
When metrics_endpoint is configured, the WAF serves a JSON metrics document at the given path. The document is emitted by handleMetricsRequest in caddywaf.go and reflects the counters maintained by the runtime.
Endpoint
waf {
metrics_endpoint /waf_metrics
...
}The path must start with /. There is no authentication on the endpoint; if you expose it externally, place authentication in front of it (basic auth, IP allow-list, mTLS at the proxy, etc.).
Response format
- HTTP status:
200 OK Content-Type: application/json- Body: a single JSON object with the fields below.
A representative response:
{
"total_requests": 27004,
"blocked_requests": 25328,
"allowed_requests": 1509,
"rule_hits": {
"block-scanners": 25,
"sql-injection": 7,
"xss-attacks": 8,
"log4j-jndi": 2
},
"rule_hits_by_phase": {
"1": 1461,
"2": 705
},
"geoip_blocked": 0,
"ip_blacklist_hits": 0,
"dns_blacklist_hits": 0,
"rate_limiter_requests": 27004,
"rate_limiter_blocked_requests": 23640,
"version": "v0.4.16",
"schema_version": 2,
"server_time_ms": 1756876543210,
"uptime_seconds": 86400,
"top_rules": [
{ "id": "block-scanners", "hits": 25 },
{ "id": "xss-attacks", "hits": 8 }
],
"top_ips": {
"cap": 50,
"distinct_seen": 318,
"items": [ { "ip": "203.0.113.7", "blocked": 640 } ]
},
"by_country": [ { "country": "RU", "blocked": 900 } ],
"blocked_by_reason": { "rule": 1826, "ip_blacklist": 1290, "rate_limit": 640 },
"recent": {
"cap": 256,
"items": [
{
"ts_ms": 1756876543000, "id": "aca34e69-c2ff-…",
"ip": "203.0.113.7", "method": "POST", "path": "/wp-login.php",
"reason": "rule", "rule_id": "941100", "status": 403, "score": 10,
"country": "RU"
}
]
}
}Field reference
| Field | Type | Source | Meaning |
|---|---|---|---|
total_requests | int | Middleware.totalRequests | Total number of requests that entered ServeHTTP. |
blocked_requests | int | Middleware.blockedRequests | Total number of requests that resulted in a block (Phase 1 pre-checks, anomaly threshold, or mode: block). |
allowed_requests | int | Middleware.allowedRequests | Total number of requests that were forwarded to the next handler and not subsequently blocked. |
rule_hits | object<string,int> | Middleware.ruleHits (atomic counters per rule ID) | Number of times each rule's regex matched. The key is the rule's id. |
rule_hits_by_phase | object<string,int> | Middleware.ruleHitsByPhase | Per-phase total of rule hits. Keys are phase numbers as strings ("1", "2", "3", "4"). |
geoip_blocked | int | Middleware.geoIPBlocked | Number of requests blocked by block_countries, whitelist_countries, or block_asns. |
ip_blacklist_hits | int | Middleware.IPBlacklistBlockCount | Number of requests whose source IP matched the IP blacklist trie. |
dns_blacklist_hits | int | Middleware.DNSBlacklistBlockCount | Number of requests whose Host matched the DNS blacklist map. |
rate_limiter_requests | int | RateLimiter.totalRequests | Total requests counted by the rate limiter (under lock). |
rate_limiter_blocked_requests | int | RateLimiter.blockedRequests | Requests blocked by the rate limiter for exceeding the bucket limit. |
version | string | wafVersion constant in caddywaf.go | Build version of the WAF. |
Dashboard fields (schema 2)
The built-in dashboard (#143) adds the fields below, all back-compatible additions — every field above is unchanged. schema_version lets a consumer feature-detect them. Rates are not stored server-side: a client derives them by diffing successive snapshots using server_time_ms.
| Field | Type | Meaning |
|---|---|---|
schema_version | int | Payload schema version (currently 2). |
server_time_ms | int | Server wall-clock at scrape time (Unix ms), for client-side rate math. |
uptime_seconds | int | Seconds since Provision; a drop signals a restart (counters reset). |
top_rules | array of {id, hits} | The most-hit rules, sorted, capped at 20 — a pre-sorted view of rule_hits. |
top_ips | {cap, distinct_seen, items:[{ip, blocked}]} | The top offending client IPs by block count (capped at 50). distinct_seen is how many distinct IPs the bounded counter has tracked. |
by_country | array of {country, blocked} | Blocks per ISO country, when a GeoIP database is configured. |
blocked_by_reason | object<string,int> | Block counts by category: rule, ip_blacklist, dns_blacklist, rate_limit, country, asn, error. |
recent | {cap, items:[…]} | A bounded ring (256) of the most recent blocked decisions, newest first. Each item: ts_ms, id (the WAF log id, correlates with access logs), ip, method, path (≤256 chars), reason, rule_id (for rule blocks), status, score, country. |
Counter semantics
- All counters are monotonic and process-local. They reset when the process restarts and they are not aggregated across multiple Caddy instances.
rule_hitskeys appear only after the corresponding rule fires for the first time. A rule that has never matched is absent from the map (not present with value0).rule_hits_by_phaseonly contains keys for phases that have at least one hit.total_requestsmay exceedblocked_requests + allowed_requestsbriefly during a request that has not yet finished evaluating, because each counter is incremented at a distinct point in the pipeline.rate_limiter_requestsincludes both blocked and allowed requests, but only requests that actually pass through the limiter (see ratelimit.md for the path-matching subtleties).ip_blacklist_hits,dns_blacklist_hits, andgeoip_blockedcount matches, not blocking decisions. In practice the two are equivalent because a match in any of these layers immediately blocks the request.
Sample queries
# Raw JSON
curl -s http://localhost:8080/waf_metrics | jq .
# Top 10 most-hit rules
curl -s http://localhost:8080/waf_metrics | jq '.rule_hits | to_entries | sort_by(-.value) | .[:10]'
# Block ratio
curl -s http://localhost:8080/waf_metrics | jq '.blocked_requests / .total_requests'
# Hits by phase as a CSV
curl -s http://localhost:8080/waf_metrics \
| jq -r '.rule_hits_by_phase | to_entries | .[] | "\(.key),\(.value)"'Prometheus
The endpoint is plain JSON. To scrape it as Prometheus metrics, use the small exporter described in prometheus.md.
Operational notes
- The metrics handler is wired through
ServeHTTP; if a request matchesmetrics_endpoint, the WAF still runs Phase 1 and Phase 2 checks before the response is generated. A blocked request to the metrics path will return the block response, not the metrics document. - The endpoint serializes the in-memory state under the metrics mutex; cost is O(rules-with-hits) per request.
- Because the response is small and uncached, exposing it on a debugging route or to an internal monitoring system is appropriate; do not put it on a high-RPS public path.