Metrics
Pi-hole can expose its statistics in the Prometheus text exposition format at GET /api/metrics. This allows Prometheus (or any other OpenMetrics-compatible collector) to scrape your Pi-hole directly, without running a separate exporter that translates the JSON API into metrics.
The endpoint is disabled by default and only becomes available after you have generated a scrape token (see below).
How authentication works here¶
The metrics endpoint does not use the session-based authentication described in Authentication. Instead, it uses a dedicated bearer token. There are two reasons for this:
- A scraper runs permanently and would otherwise occupy one of the limited number of concurrent sessions - or would have to log in every few seconds.
- The endpoint stays protected even on installations without a web interface password, where the remaining API is accessible without authentication.
FTL never stores the token itself, only its SHA-256 hash in webserver.api.prometheus.token. The raw token is returned exactly once, when you generate it.
Why SHA-256 and not the balloon hash used for passwords?
The token is a 256-bit random value generated by Pi-hole itself, so it cannot be guessed or brute-forced. A deliberately slow hash would only burn CPU cycles on every single scrape while a fast digest already stores a preimage-resistant value at rest.
Enabling the endpoint¶
The quickest way is the command line:
sudo pihole-FTL --prometheus-token
New Prometheus scrape token (shown only once):
7ua0rHPZixX6B3bTa5o4Dv08iyEIm/2q9qgLrF9MNVw=
Stored hash in webserver.api.prometheus.token
The token is active immediately, a running pihole-FTL picks up the change without a restart.
Using the API instead
If you prefer to enable the endpoint remotely, e.g., when automating the setup of several Pi-holes, generate the token with POST /api/auth/prometheus. This request uses the regular API authentication, see Authentication: a valid session ID (SID), unless your Pi-hole has no web interface password, in which case no authentication is needed.
curl -k -X POST "https://pi.hole/api/auth/prometheus" -H "X-FTL-SID: vFA+EP4MQ5JJvJg+3Q2Jnw="
import requests
url = "https://pi.hole/api/auth/prometheus"
headers = {"X-FTL-SID": "vFA+EP4MQ5JJvJg+3Q2Jnw="}
response = requests.request("POST", url, headers=headers, verify=False)
print(response.text)
The reply repeats the token exactly once:
{
"prometheus": {
"token": "7ua0rHPZixX6B3bTa5o4Dv08iyEIm/2q9qgLrF9MNVw=",
"hash": "9e99d5834ce965262b7eefd9694aad95f0dd24ca0214be659bc06570008d2096"
},
"took": 0.003
}
token is the raw token you configure in your scraper. hash is the value FTL has just stored in webserver.api.prometheus.token and is only returned for reference.
The token is shown only once
FTL stores only the hash, so the raw token cannot be retrieved later. Store it in a safe place right away. Generating a new token replaces the stored hash and thereby immediately invalidates the previous token.
Both ways write the new hash to /etc/pihole/pihole.toml automatically. If your configuration is in read-only mode (misc.readOnly = true), the CLI refuses the change altogether, while the API still returns a working token that is, however, silently lost on the next restart of pihole-FTL.
Scraping the endpoint¶
The scraper sends the raw token in an Authorization: Bearer <token> header:
Scrape the metrics endpoint
curl -k "https://pi.hole/api/metrics" -H "Authorization: Bearer 7ua0rHPZixX6B3bTa5o4Dv08iyEIm/2q9qgLrF9MNVw="
import requests
url = "https://pi.hole/api/metrics"
headers = {"Authorization": "Bearer 7ua0rHPZixX6B3bTa5o4Dv08iyEIm/2q9qgLrF9MNVw="}
response = requests.request("GET", url, headers=headers, verify=False)
print(response.text)
Success response
Response code: HTTP/1.1 200 OK
Content type: text/plain; version=0.0.4; charset=utf-8
# HELP pihole_queries Number of DNS queries in FTL's history window (webserver.api.maxHistory)
# TYPE pihole_queries gauge
pihole_queries 8412
# HELP pihole_queries_blocked Number of blocked DNS queries in FTL's history window
# TYPE pihole_queries_blocked gauge
pihole_queries_blocked 1337
...
The token is accepted only in this header. It is deliberately not accepted as a query string parameter (where it would end up in access logs and proxy logs) and not as a cookie (which would reintroduce the CSRF attack surface the session cookie is protected against).
| Response code | Meaning |
|---|---|
200 |
Success, the body contains the metrics |
401 |
The Authorization header is missing or the token is wrong |
404 |
The endpoint is disabled because no token is configured |
The response is identical for a missing and for an incorrect token, and tokens are compared in constant time. As the token is a 256-bit random value, no rate limiting is applied to this endpoint.
Disabling the endpoint¶
Revoking the token disables the endpoint again - GET /api/metrics then returns 404 Not Found, just as on an installation that never enabled it:
sudo pihole-FTL --prometheus-token revoke
This is the same as setting webserver.api.prometheus.token to an empty string, which you can also do through the API, the web interface, or by editing /etc/pihole/pihole.toml directly:
Disable the metrics endpoint through the API
curl -k -X PATCH "https://pi.hole/api/config/webserver/api/prometheus/token" \
-H "X-FTL-SID: vFA+EP4MQ5JJvJg+3Q2Jnw=" \
--data '{"config":{"webserver":{"api":{"prometheus":{"token":""}}}}}'
Generating a new token invalidates the old one, so there is no need to revoke a token before replacing it.
Configuration options¶
| Setting | Default | Description |
|---|---|---|
webserver.api.prometheus.token |
"" |
SHA-256 hash of the scrape token. Empty disables the endpoint. Set by pihole-FTL --prometheus-token or POST /api/auth/prometheus, see above. Reading it back through the API or the CLI yields ********, as the value is write-only. |
webserver.api.prometheus.perEntityMetrics |
false |
Whether per-domain and per-client time series are exported in addition to the aggregate metrics. |
webserver.api.prometheus.topN |
100 |
Upper limit for the number of top domains and top clients exported when perEntityMetrics is enabled (0 - 1000). 0 disables the per-entity series. |
Exported metrics¶
Query statistics¶
| Metric | Type | Description |
|---|---|---|
pihole_queries |
gauge | Number of DNS queries in FTL's history window |
pihole_queries_blocked |
gauge | Number of blocked DNS queries in FTL's history window |
pihole_queries_forwarded |
gauge | Number of forwarded DNS queries in FTL's history window |
pihole_queries_cached |
gauge | Number of cached DNS queries in FTL's history window |
pihole_query_frequency |
gauge | Queries per second (rolling average) |
pihole_queries_by_type |
gauge | Queries by record type, label type (e.g., A, AAAA, HTTPS) |
pihole_queries_by_status |
gauge | Queries by processing status, label status (e.g., GRAVITY, FORWARDED, CACHE) |
pihole_queries_by_reply |
gauge | Queries by reply type, label reply (e.g., NODATA, NXDOMAIN, IP) |
Why are the query totals gauges?
These values describe the queries currently held in FTL's in-memory history, which spans the last webserver.api.maxHistory seconds (24 hours by default). Garbage collection decrements them as queries age out, so they can go down as well as up and are not monotonically increasing counters. Applying rate() or increase() to them yields meaningless results - use the counter metrics from the DNS and DHCP sections below for that.
Clients, domains and gravity¶
| Metric | Type | Description |
|---|---|---|
pihole_clients_total |
gauge | Number of known clients |
pihole_clients_active |
gauge | Number of clients active within the last 24 hours |
pihole_domains_total |
gauge | Number of unique domains seen |
pihole_upstreams_total |
gauge | Number of known upstream destinations |
pihole_gravity_domains |
gauge | Number of domains on the gravity (block) list |
pihole_gravity_last_update_timestamp_seconds |
gauge | Unix time of the last gravity update |
DNS¶
| Metric | Type | Description |
|---|---|---|
pihole_dns_cache_size |
gauge | Number of entries in the DNS cache |
pihole_dns_cache_inserted_total |
counter | Number of entries inserted into the DNS cache |
pihole_dns_cache_evicted_total |
counter | Number of live cache entries evicted before their TTL |
pihole_dns_cache_expired_total |
counter | Number of expired DNS cache entries |
pihole_dns_cache_immortal |
gauge | Number of immortal DNS cache entries |
pihole_dns_replies_total |
counter | DNS replies by source, label source (local, forwarded, optimized, unanswered, auth) |
DHCP¶
| Metric | Type | Description |
|---|---|---|
pihole_dhcp_messages_total |
counter | DHCP messages by type, label type (ack, decline, discover, inform, nak, offer, release, request, noanswer, bootp, pxe) |
pihole_dhcp_leases |
gauge | DHCP leases, labels family (ipv4, ipv6) and operation (allocated, pruned) |
These counters are also exported when the DHCP server is disabled - they simply stay at zero.
Per-domain and per-client metrics (optional)¶
When webserver.api.prometheus.perEntityMetrics is enabled, two additional metrics are exported:
| Metric | Type | Description |
|---|---|---|
pihole_top_domain_queries |
gauge | Permitted queries for the most active domains, label domain |
pihole_top_client_queries |
gauge | Queries of the most active clients, labels ip and name |
The client IP is used as the primary label because it uniquely identifies a client - two clients sharing a hostname would otherwise produce duplicate time series, which makes Prometheus reject the entire scrape. The hostname is added as a secondary label for readability.
Privacy and cardinality
These series reveal which domains are queried in your network and which clients are active, which is why they are disabled by default. They honor your configured privacy level as well as the webserver.api.excludeDomains and webserver.api.excludeClients filters, exactly like the corresponding JSON API endpoints do.
Keep in mind that every domain and client is a separate time series in your Prometheus database. webserver.api.prometheus.topN limits how many of them are exported, but even the default of 100 domains plus 100 clients adds up over time as the top lists change.
Prometheus configuration example¶
prometheus.yml
scrape_configs:
- job_name: 'pi-hole'
metrics_path: '/api/metrics'
scheme: 'https'
authorization:
type: 'Bearer'
credentials: '7ua0rHPZixX6B3bTa5o4Dv08iyEIm/2q9qgLrF9MNVw='
tls_config:
# Not needed if your Pi-hole uses a certificate your Prometheus trusts
insecure_skip_verify: true
static_configs:
- targets: ['pi.hole']
We recommend scraping over HTTPS so the token is not sent in the clear. Pi-hole's self-signed certificate is not trusted by default, so either add the certificate authority to the trust store of the machine running Prometheus (see TLS/SSL) or skip the verification as shown above. Prometheus can also read the token from a file instead (credentials_file), which keeps it out of your configuration file.