Prometheus
Write PromQL for incidents, read counters and histograms correctly, write alerting and recording rules, and keep series cardinality under control.
On this page
Cheatsheet#
| Question | Query |
|---|---|
| Request rate by service | sum by (service) (rate(http_requests_total[5m])) |
| Error ratio by service | sum by (service) (rate(http_requests_total{status=~"5.."}[5m])) / sum by (service) (rate(http_requests_total[5m])) |
| p99 latency (classic histogram) | histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) |
| p99 latency (native histogram) | histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m]))) |
| Mean latency | rate(http_request_duration_seconds_sum[5m]) / rate(http_request_duration_seconds_count[5m]) |
| Errors in the last hour | increase(http_requests_total{status=~"5.."}[1h]) |
| Targets that failed their last scrape | up == 0 |
| Top CPU pods | topk(5, sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))) |
| Containers restarted in 24 h | increase(kube_pod_container_status_restarts_total[24h]) > 0 |
| Filesystem full within 4 days | predict_linear(node_filesystem_avail_bytes[6h], 4*24*3600) < 0 |
| Memory against limit | sum by (pod) (container_memory_working_set_bytes) / on (pod) group_left sum by (pod) (kube_pod_container_resource_limits{resource="memory"}) |
| Slowest scrapes | topk(10, scrape_duration_seconds) |
| Series per metric (expensive) | topk(10, count by (__name__) ({__name__=~".+"})) |
Read a ratio next to its rate. A 100% error ratio over two requests is noise; the same ratio over two thousand is an outage.
The series-count query touches every series in the head block. On a large server prefer the TSDB status API in HTTP API checks.
Instant and range vectors#
An instant vector holds one sample per series at the evaluation time. A range vector holds every sample per series inside a window. Functions convert between them, and most PromQL type errors are a mismatch between the two.
http_requests_total # instant vector
http_requests_total[5m] # range vector
rate(http_requests_total[5m]) # range in, instant out
sum(http_requests_total[5m]) # error: sum takes an instant vectorOnly instant vectors (and scalars) can be graphed. For an instant selector Prometheus returns the most recent sample within the lookback delta (5 minutes by default, --query.lookback-delta), so a series that stopped reporting keeps appearing for up to 5 minutes.
Since Prometheus 3.0 range selectors are left-open: a sample exactly on the left boundary is excluded. Subqueries with aligned steps lose a point, so foo[1m:1m] returns one sample instead of two and rate over it returns nothing. Widen the window, for example [2m:1m]. See the 3.0 migration guide.
Counters, gauges and histograms#
A counter only increases and resets to zero when the process restarts, so its raw value is rarely useful. rate, irate and increase treat any decrease as a reset and compensate.
rate(http_requests_total[5m]) # per-second average over the window, reset-aware, extrapolated to the window edges
irate(http_requests_total[5m]) # per-second from the last two samples only: responsive, noisy
increase(http_requests_total[1h]) # rate × window; can return non-integers because of extrapolationrate needs at least two samples in the window. Use a window of at least four scrape intervals so one missed scrape does not produce a gap: [2m] at a 30 s interval, [5m] as a safe default. Use irate on dashboards for spiky detail, not in alerts, because a single sample pair decides the result.
Always rate first and aggregate second. rate(sum(...)) cannot see resets in the individual counters.
Gauges go up and down and are read directly or with *_over_time functions.
node_memory_MemAvailable_bytes
avg_over_time(node_memory_MemAvailable_bytes[1h])
delta(node_filesystem_avail_bytes[1h]) # change over the window, gauges only
predict_linear(node_filesystem_avail_bytes[6h], 4*24*3600) < 0 # linear fit, 4 days aheadA classic histogram is a set of cumulative bucket counters labelled le, plus _sum and _count. histogram_quantile interpolates linearly inside the bucket that holds the quantile, so accuracy is bounded by the bucket layout. If the largest finite bucket is le="1" and the true p99 is 4 s, the query returns 1 s and nothing downstream can correct it.
histogram_quantile(0.99, sum by (le, service) (rate(http_request_duration_seconds_bucket[5m]))) # correct: rate, sum keeping le, then quantile
histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])) # per-series quantile; usually not what you wantQuantiles do not average
Averaging per-instance p99 values does not give a fleet p99. Sum the bucket rates across instances and compute the quantile from the total. Summaries (quantile label) cannot be aggregated at all.
Native histograms store sparse, exponential buckets in one series, so there is no le label and no _bucket suffix. They are stable from Prometheus 3.8 but still opt-in per scrape with scrape_native_histograms: true; from 3.9 the old --enable-feature=native-histograms flag does nothing. See the native histogram spec.
Prometheus 3.0 normalises le and quantile values to floats on ingestion. A selector such as {le="1"} must now be written {le="1.0"}.
Selectors and aggregation#
Regex matchers are anchored at both ends, so status=~"5.." matches 500 and not x500. Since 3.0, . also matches a newline.
http_requests_total{job="api", status="500"}
http_requests_total{status=~"5.."}
http_requests_total{status!~"2..|3.."}
{__name__=~"http_.+", job="api"}
sum by (service, status) (rate(http_requests_total[5m])) # keep only these labels
sum without (instance, pod) (rate(http_requests_total[5m])) # drop these, keep the rest
topk(5, sum by (pod) (rate(container_cpu_usage_seconds_total[5m])))
count by (job) (up == 1)without keeps labels added upstream later; by silently drops them. Use by in alerts where the label set must stay stable, without in recording rules that should pass labels through.
Joins with on and group_left#
Binary operators match series with identical label sets. on (labels) restricts the match key, and group_left allows many left-hand series per right-hand series. That is how an info-style metric contributes labels to a value.
# Attach the owning workload to per-pod CPU
sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
* on (pod) group_left (owner_name)
kube_pod_owner
# Memory used as a fraction of the limit
sum by (pod) (container_memory_working_set_bytes)
/ on (pod) group_left
sum by (pod) (kube_pod_container_resource_limits{resource="memory"})many-to-many matching not allowed means the on labels do not identify one series on the “one” side. Add labels to on, or aggregate that side first so it is unique.
PromQL recipes#
Patterns that recur in incidents and reviews. Each is written to be pasted into the expression browser with the metric names swapped.
# Availability (SLI) over 30 days as a ratio
1 - (sum(increase(http_requests_total{status=~"5.."}[30d])) / sum(increase(http_requests_total[30d])))
# Error budget burn rate: 1.0 means spending exactly the budget for a 99.9% SLO
(sum(rate(http_requests_total{status=~"5.."}[1h])) / sum(rate(http_requests_total[1h]))) / (1 - 0.999)
# Multi-window burn-rate page: fast and slow windows must both burn
(sum(rate(http_requests_total{status=~"5.."}[1h])) / sum(rate(http_requests_total[1h])) > (14.4 * 0.001))
and
(sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > (14.4 * 0.001))
# Fraction of requests under 300 ms (Apdex-style) from a classic histogram
sum(rate(http_request_duration_seconds_bucket{le="0.3"}[5m])) / sum(rate(http_request_duration_seconds_count[5m]))
# Week-over-week comparison of request rate
sum(rate(http_requests_total[5m])) / sum(rate(http_requests_total[5m] offset 1w))
# Value now versus one hour ago, as a percentage change
(sum(rate(http_requests_total[5m])) - sum(rate(http_requests_total[5m] offset 1h))) / sum(rate(http_requests_total[5m] offset 1h)) * 100
# Which series appeared in the last hour that did not exist an hour ago (new deployments, label explosions)
count by (job) (up) unless count by (job) (up offset 1h)
# Labels present on a metric without pulling values
group by (job, instance) (http_requests_total)
# Guard a division against zero: return nothing rather than NaN or Inf
sum(rate(a_total[5m])) / (sum(rate(b_total[5m])) > 0)
# Default when a series is missing: 0 if no matching series
sum(rate(http_requests_total{job="api"}[5m])) or vector(0)
# Per-pod CPU throttling ratio (above 25% sustained means the limit is too low)
sum by (pod) (rate(container_cpu_cfs_throttled_periods_total[5m])) / sum by (pod) (rate(container_cpu_cfs_periods_total[5m]))
# Node memory pressure without cache
1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
# Disk read/write latency per operation from node_exporter
rate(node_disk_read_time_seconds_total[5m]) / rate(node_disk_reads_completed_total[5m])
# Time since a counter last moved (stalled consumer, frozen job)
time() - timestamp(max_over_time(jobs_processed_total[1h]) and changes(jobs_processed_total[1h]) > 0)
# Kubernetes: pods not ready for more than 15 minutes, excluding completed jobs
min_over_time(kube_pod_status_ready{condition="true"}[15m]) == 0 unless on (pod, namespace) kube_pod_status_phase{phase="Succeeded"} == 1
# Kubernetes: deployments with fewer available replicas than desired
kube_deployment_status_replicas_available < kube_deployment_spec_replicas
# Kubernetes: HPA at its ceiling
kube_horizontalpodautoscaler_status_current_replicas >= kube_horizontalpodautoscaler_spec_max_replicas
# Scrape health: targets whose scrape takes longer than half their interval
scrape_duration_seconds > 7.5 # for a 15 s interval
# Prometheus itself: samples ingested per second and head series
rate(prometheus_tsdb_head_samples_appended_total[5m])
prometheus_tsdb_head_series
# Percentiles across a fleet, keeping one label
histogram_quantile(0.95, sum by (le, region) (rate(http_request_duration_seconds_bucket[5m])))
# Subquery: the maximum 5 minute rate seen in the last day
max_over_time(sum(rate(http_requests_total[5m]))[1d:1m])
# Aggregation over labels whose names you do not know: quantile across instances
quantile by (job) (0.9, rate(process_cpu_seconds_total[5m]))
# Label manipulation: copy a label, then aggregate on the copy
sum by (env) (label_replace(rate(http_requests_total[5m]), "env", "$1", "namespace", "(prod|staging)-.*"))
# Info metric join: attach the image version to restart counts
increase(kube_pod_container_status_restarts_total[1h]) * on (pod, container, namespace) group_left (image) kube_pod_container_infooffset and @ modifiers pin a selector in time (up @ 1700000000, up @ end()); a negative offset needs --enable-feature=promql-negative-offset before 2.x and is on by default in 3.x. unless is the set-difference operator and the cleanest way to write “A that is not in B”. Every rate over a window shorter than four scrape intervals is a source of gaps, whatever the recipe.
Alerting rules#
for requires the expression to return the series at every evaluation for that long before the alert moves from pending to firing. One evaluation without the series resets the timer. keep_firing_for (Prometheus 2.42 and later) holds a firing alert after the condition clears, which stops flapping alerts from resolving and re-paging.
groups:
- name: api
interval: 30s
rules:
- alert: ApiHighErrorRate
expr: |
sum by (service) (rate(http_requests_total{status=~"5.."}[5m]))
/
sum by (service) (rate(http_requests_total[5m]))
> 0.05
for: 10m
keep_firing_for: 5m
labels:
severity: page
annotations:
summary: "{{ $labels.service }} returning {{ $value | humanizePercentage }} errors"
runbook_url: https://wiki.example.com/runbooks/api-errors
- alert: TargetDown
expr: up == 0
for: 5m
labels:
severity: ticketAlert on what a user feels: error ratio, latency, saturation of something finite such as disk or connection pools. CPU at 90% is often fine and makes a poor page.
An alert on a series that disappears (the target was removed, or the metric is only emitted on error) never fires. Cover it with absent(up{job="api"}) or absent_over_time(...).
Rule unit tests run the expression against synthetic series and assert the alerts (or recorded values) at a point in time. They are the only way to prove that for and the label set behave before an alert reaches production.
# tests.yaml, run with: promtool test rules tests.yaml
rule_files: [rules/api.yaml]
evaluation_interval: 30s
tests:
- interval: 30s
input_series:
- series: 'http_requests_total{service="api", status="500"}'
values: '0+10x40' # starts at 0, +10 per interval, 40 samples
- series: 'http_requests_total{service="api", status="200"}'
values: '0+90x40'
alert_rule_test:
- eval_time: 12m # past the 10m for
alertname: ApiHighErrorRate
exp_alerts:
- exp_labels: { service: api, severity: page }
exp_annotations:
summary: "api returning 10% errors"
runbook_url: https://wiki.example.com/runbooks/api-errors
- eval_time: 5m # still pending: expect nothing
alertname: ApiHighErrorRate
exp_alerts: []
promql_expr_test:
- expr: job:http_requests:rate5m
eval_time: 5m
exp_samples: [{ labels: 'job:http_requests:rate5m', value: 3.3333333333333335 }] # sum by (job) drops service; no job label in the inputSeries notation: 'a+bxc' is a start value, an increment per interval and a count; _ is a missing sample and stale a staleness marker. humanizePercentage renders 0.1 as 10%, and the test compares the rendered annotation string exactly.
Alertmanager routing#
Prometheus sends every firing alert to every configured Alertmanager; Alertmanager deduplicates, groups, routes, silences and inhibits. The routing tree is matched top-down: the first child whose matchers match wins unless continue: true, and unmatched alerts fall through to the root receiver. Grouping is per route: group_by decides which alerts share one notification, group_wait how long to hold the first alert to collect its group, group_interval how often to send updates for the same group, and repeat_interval how often to re-send an unchanged group.
# alertmanager.yml
global:
resolve_timeout: 5m
route:
receiver: default-ticket
group_by: [alertname, cluster, namespace]
group_wait: 30s
group_interval: 5m
repeat_interval: 12h
routes:
- matchers: [severity="page"]
receiver: oncall-pager
group_wait: 10s
repeat_interval: 4h
routes:
- matchers: [team="db"]
receiver: db-pager # more specific route wins for db pages
- matchers: [alertname=~"Watchdog|InfoInhibitor"]
receiver: blackhole # alerts that exist to be present
- matchers: [severity="ticket"]
receiver: default-ticket
active_time_intervals: [business-hours]
continue: true # also fall through to the chat route
- matchers: [severity=~"ticket|warning"]
receiver: team-chat
time_intervals:
- name: business-hours
time_intervals:
- weekdays: ["monday:friday"]
times: [{ start_time: "09:00", end_time: "17:00" }]
location: Australia/Sydney
inhibit_rules:
- source_matchers: [alertname="NodeDown"]
target_matchers: [severity=~"page|ticket"]
equal: [instance] # silence everything else from a node that is down
- source_matchers: [severity="page"]
target_matchers: [severity="ticket"]
equal: [alertname, namespace] # do not ticket what is already paging
receivers:
- name: blackhole
- name: oncall-pager
pagerduty_configs:
- routing_key_file: /etc/alertmanager/secrets/pagerduty # never inline the key
severity: '{{ if eq .CommonLabels.severity "page" }}critical{{ else }}warning{{ end }}'
- name: db-pager
pagerduty_configs: [{ routing_key_file: /etc/alertmanager/secrets/pagerduty-db }]
- name: default-ticket
webhook_configs: [{ url_file: /etc/alertmanager/secrets/ticket-webhook, send_resolved: true }]
- name: team-chat
slack_configs:
- api_url_file: /etc/alertmanager/secrets/slack
channel: "#alerts"
title: '{{ .GroupLabels.alertname }} ({{ .Alerts.Firing | len }} firing)'
text: '{{ range .Alerts }}{{ .Annotations.summary }}{{ "\n" }}{{ end }}'amtool talks to the Alertmanager API and is the tool for checking why an alert went where it did:
amtool check-config alertmanager.yml # parse and template check
amtool config routes test --config.file alertmanager.yml severity=page team=db # which receiver a label set reaches
amtool config routes show --config.file alertmanager.yml # routing tree
amtool alert query --alertmanager.url http://localhost:9093 severity=page # currently firing, as Alertmanager sees them
amtool alert query -o json | jq -r '.[] | [.labels.alertname, .status.state, (.status.inhibitedBy|length), (.status.silencedBy|length)] | @tsv'
amtool silence add --alertmanager.url http://localhost:9093 alertname=ApiHighErrorRate service=api -d 2h -c "deploy in progress" -a alice
amtool silence query; amtool silence expire <id>
amtool cluster show # HA peers and gossip stateA missing notification is one of: not sent by Prometheus (prometheus_notifications_sent_total, ALERTS{alertstate="firing"}), inhibited or silenced (the status fields above), routed to another receiver (routes test), grouped and waiting (group_wait), or rejected by the integration (alertmanager_notifications_failed_total by integration). Run at least two Alertmanagers in a cluster and point every Prometheus at all of them; the gossip protocol deduplicates.
Service discovery and relabelling#
relabel_configs runs before the scrape and decides which discovered targets are kept and what their labels become; metric_relabel_configs runs on the scraped samples. Discovered metadata arrives as __meta_* labels that are dropped after relabelling, so anything you want to keep must be copied to a normal label. __address__ is the target, __metrics_path__ the path, __scheme__ the scheme, and __param_<name> becomes a URL parameter.
scrape_configs:
- job_name: kubernetes-pods
kubernetes_sd_configs:
- role: pod
namespaces: { names: [my-namespace, monitoring] }
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: "true" # opt-in by annotation
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+) # only when the annotation exists
- source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
regex: ([^:]+)(?::\d+)?;(\d+)
replacement: $1:$2
target_label: __address__ # swap the port for the annotated one
- action: labelmap
regex: __meta_kubernetes_pod_label_(.+) # every pod label becomes a metric label
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- source_labels: [__meta_kubernetes_pod_container_init]
action: drop
regex: "true"
- source_labels: [__meta_kubernetes_pod_phase]
action: drop
regex: Pending|Succeeded|Failed
- action: hashmod # shard targets across two servers
source_labels: [__address__]
modulus: 2
target_label: __tmp_shard
- source_labels: [__tmp_shard]
action: keep
regex: "0"
- job_name: blackbox
metrics_path: /probe
params: { module: [http_2xx] }
static_configs:
- targets: [https://app.example.com/healthz, https://api.example.com/ready]
relabel_configs:
- source_labels: [__address__]
target_label: __param_target # the URL becomes the ?target= parameter
- source_labels: [__param_target]
target_label: instance # keep it visible
- target_label: __address__
replacement: blackbox-exporter.monitoring:9115 # scrape the exporter, not the URL| Action | Effect |
|---|---|
replace (default) | Write replacement (with $1 groups from regex on the joined source_labels) to target_label |
keep / drop | Keep or drop the target (or sample) when regex matches |
keepequal / dropequal | Keep or drop when the joined source_labels equal target_label |
labelmap | Copy labels whose names match regex to names given by replacement |
labeldrop / labelkeep | Remove labels by name pattern (results must remain unique) |
hashmod | Set target_label to hash of source_labels modulo modulus, for sharding |
lowercase / uppercase | Case-fold the joined source_labels into target_label |
regex is anchored (^(?:...)$) and source_labels are joined with ;. Status > Service Discovery in the UI shows every discovered target with its __meta_* labels before and after relabelling, and promtool check config catches syntax but not logic; test a relabel rule by keeping only one target and reading the result on the Targets page. Discovery roles for Kubernetes are node, pod, service, endpoints, endpointslice and ingress; endpointslice is the one to scrape a Service’s ready backends.
Remote write and long-term storage#
A Prometheus server keeps 15 days by default (--storage.tsdb.retention.time) and has no replication. Remote write streams every sample to another system (Thanos Receive, Mimir, Cortex, VictoriaMetrics, another Prometheus with --web.enable-remote-write-receiver) for durability, global queries and longer retention, while the local server keeps answering fast queries and evaluating rules.
remote_write:
- url: https://mimir.example.com/api/v1/push
name: mimir
protobuf_message: io.prometheus.write.v2.Request # remote write 2.0 (3.x); omit for 1.0 receivers
headers: { X-Scope-OrgID: my-tenant }
basic_auth:
username: prometheus
password_file: /etc/prometheus/secrets/remote-write # never inline
queue_config:
capacity: 10000 # samples buffered per shard
max_shards: 50
max_samples_per_send: 2000
batch_send_deadline: 5s
min_backoff: 30ms
max_backoff: 5s
retry_on_http_429: true
write_relabel_configs: # runs before sending; drop what long-term storage does not need
- source_labels: [__name__]
regex: 'go_.*|process_.*'
action: drop
metadata_config: { send: true }Remote write is at-least-once with in-order delivery per shard; when the receiver is down the write-ahead log buffers for --storage.tsdb.wal-segment-size times the retained segments (roughly two hours by default), after which samples are lost while prometheus_remote_storage_samples_dropped_total rises. Watch prometheus_remote_storage_samples_pending, prometheus_remote_storage_shards against max_shards, and prometheus_remote_storage_highest_timestamp_in_seconds - prometheus_remote_storage_queue_highest_sent_timestamp_seconds for lag. external_labels (cluster, replica) are added to every remote-written sample and are what a receiver uses to deduplicate an HA pair. Prometheus 3.x also accepts OTLP metrics on /api/v1/otlp/v1/metrics with --web.enable-otlp-receiver, which is how an OpenTelemetry Collector ships metrics in without a Prometheus exporter.
Recording rules#
Precompute expressions that dashboards evaluate repeatedly or alerts evaluate expensively. The naming convention level:metric:operations records what was aggregated and how.
groups:
- name: api-recording
interval: 30s
rules:
- record: job:http_requests:rate5m
expr: sum by (job) (rate(http_requests_total[5m]))Validate rule and config files before a reload. promtool ships in the Prometheus release tarball and image.
promtool check rules rules/*.yaml # syntax and PromQL parse
promtool check config prometheus.yml # also checks referenced rule files
promtool test rules tests.yaml # unit tests with synthetic seriesCardinality#
Memory use follows the number of active series, not the sample rate. Every unique combination of metric name and label values is a separate series with its own in-memory chunk, so one unbounded label multiplies every other label.
Never label with a user ID, request ID, raw URL path, email address, IP address of a client or a timestamp. A path label is fine as the route template /users/{id} and a problem as /users/91823.
Drop or rewrite at scrape time with metric_relabel_configs, which runs after the scrape and before storage:
metric_relabel_configs:
- source_labels: [__name__]
regex: 'go_gc_duration_seconds.*'
action: drop # drop whole metrics
- regex: 'request_id'
action: labeldrop # drop one label from every series; results must still be uniqueSet per-scrape limits so one bad deploy fails its own scrape instead of the server:
scrape_configs:
- job_name: api
sample_limit: 50000 # scrape fails if exceeded
label_limit: 30Dropping a label that was the only thing distinguishing two series leaves duplicate series in the scrape, and Prometheus rejects the duplicates. Check lastError for the target after changing relabelling.
HTTP API checks#
These assume Prometheus listens on localhost:9090 and jq is installed. All are read-only except the reload.
# Targets that are not up, with the scrape error
curl -s localhost:9090/api/v1/targets | jq -r '.data.activeTargets[] | select(.health!="up") | [.labels.job, .scrapeUrl, .lastError] | @tsv'
# Metrics with the most series in the head block
curl -s localhost:9090/api/v1/status/tsdb | jq -r '.data.seriesCountByMetricName[] | [.value, .name] | @tsv'
# Labels with the most distinct values
curl -s localhost:9090/api/v1/status/tsdb | jq -r '.data.labelValueCountByLabelName[] | [.value, .name] | @tsv'
# Series count for one metric
curl -s 'localhost:9090/api/v1/series?match[]=http_requests_total' | jq '.data | length'
# Run an instant query
curl -s --data-urlencode 'query=sum by (job) (up)' localhost:9090/api/v1/query | jq -r '.data.result[] | [.metric.job, .value[1]] | @tsv'
# Evaluate at a past instant
curl -s --data-urlencode 'query=up' --data-urlencode "time=$(date -d '1 hour ago' +%s)" localhost:9090/api/v1/query | jq '.data.result | length'
# Rules that fail to evaluate
curl -s localhost:9090/api/v1/rules | jq -r '.data.groups[].rules[] | select(.health!="ok") | [.name, .lastError] | @tsv'
# Firing alerts, counted by name and severity
curl -s localhost:9090/api/v1/alerts | jq -r '.data.alerts[] | select(.state=="firing") | [.labels.alertname, .labels.severity] | @tsv' | sort | uniq -c
# Effective runtime flags
curl -s localhost:9090/api/v1/status/flags | jq
# Reload config and rules (needs --web.enable-lifecycle; otherwise send SIGHUP)
curl -sX POST localhost:9090/-/reload
# Build info and version
curl -s localhost:9090/api/v1/status/buildinfo | jq -r '.data | "\(.version) \(.goVersion)"'
# Runtime: head series, chunks, WAL replay status, retention in use
curl -s localhost:9090/api/v1/status/runtimeinfo | jq '.data | {timeSeriesCount, chunkCount, storageRetention, corruptionCount, reloadConfigSuccess}'
# Scrape pools and how many targets are healthy in each
curl -s localhost:9090/api/v1/targets | jq -r '.data.activeTargets | group_by(.scrapePool)[] | "\(.[0].scrapePool)\t\(map(select(.health=="up")) | length)/\(length)"'
# Targets discovered but dropped by relabelling, with the reason labels
curl -s 'localhost:9090/api/v1/targets?state=dropped' | jq -r '.data.droppedTargets[] | .discoveredLabels | "\(.__address__)\t\(.job // .__meta_kubernetes_namespace // "-")"' | head
# Metadata (type and help) for one metric across every target
curl -s 'localhost:9090/api/v1/metadata?metric=http_requests_total' | jq '.data'
# Every label name in use
curl -s localhost:9090/api/v1/labels | jq -r '.data[]'
# Values of one label, optionally restricted by a matcher
curl -s 'localhost:9090/api/v1/label/job/values' | jq -r '.data[]'
curl -s 'localhost:9090/api/v1/label/pod/values?match[]=up{namespace="my-namespace"}' | jq -r '.data[]'
# Series cardinality of a label combination without a PromQL query
curl -s 'localhost:9090/api/v1/series?match[]={__name__=~"http_.*",job="api"}' | jq '.data | group_by(.__name__) | map({name: .[0].__name__, series: length}) | sort_by(-.series)'
# Range query, printed as a CSV of time and value
curl -s --data-urlencode 'query=sum(rate(http_requests_total[5m]))' --data-urlencode "start=$(date -d '1 hour ago' +%s)" --data-urlencode "end=$(date +%s)" --data-urlencode 'step=60' localhost:9090/api/v1/query_range | jq -r '.data.result[0].values[] | "\(.[0] | todate),\(.[1])"'
# Instant query with a query timeout and evaluation statistics
curl -s --data-urlencode 'query=count({__name__=~".+"})' --data-urlencode 'timeout=30s' --data-urlencode 'stats=all' localhost:9090/api/v1/query | jq '.data.stats.timings'
# Alerting rules with their current state and active alert count
curl -s 'localhost:9090/api/v1/rules?type=alert' | jq -r '.data.groups[].rules[] | "\(.state)\t\(.alerts | length)\t\(.name)"' | sort
# Rule groups by evaluation time, slowest first
curl -s localhost:9090/api/v1/rules | jq -r '.data.groups[] | "\(.evaluationTime | . * 1000 | floor) ms\t\(.file):\(.name)"' | sort -rn | head
# Alerts pending longer than expected (activeAt older than 30 minutes)
curl -s localhost:9090/api/v1/alerts | jq -r --arg t "$(date -d '30 min ago' -u +%FT%TZ)" '.data.alerts[] | select(.state=="pending" and .activeAt < $t) | "\(.labels.alertname)\t\(.activeAt)"'
# Alertmanagers Prometheus is sending to, and any it dropped
curl -s localhost:9090/api/v1/alertmanagers | jq '.data | {active: [.activeAlertmanagers[].url], dropped: [.droppedAlertmanagers[].url]}'
# Configuration as loaded (secrets are redacted as <secret>)
curl -s localhost:9090/api/v1/status/config | jq -r .data.yaml | head -40
# WAL replay progress after a restart
curl -s localhost:9090/api/v1/status/walreplay | jq .data
# Health and readiness endpoints for probes
curl -sf localhost:9090/-/healthy && curl -sf localhost:9090/-/ready && echo ready
# TSDB snapshot for a backup (needs --web.enable-admin-api; writes under data/snapshots/)
curl -sX POST localhost:9090/api/v1/admin/tsdb/snapshot | jq -r .data.name
# Delete series matching a selector, then compact (admin API; irreversible)
curl -sX POST -g 'localhost:9090/api/v1/admin/tsdb/delete_series?match[]={job="old-job"}' && curl -sX POST localhost:9090/api/v1/admin/tsdb/clean_tombstones
# promtool: query the server from the shell
promtool query instant http://localhost:9090 'sum by (job) (up)'
promtool query range --start "$(date -d '1 hour ago' +%s)" --end "$(date +%s)" --step 5m http://localhost:9090 'sum(rate(http_requests_total[5m]))'
# promtool: label cardinality analysis of a running server
promtool tsdb analyze /var/lib/prometheus/data | head -60 # on the server; reads the blocks directly
promtool query labels http://localhost:9090 job # label values via the API
# promtool: scrape an endpoint once and check the exposition format
curl -s localhost:9100/metrics | promtool check metrics
# promtool: which metrics an exporter exposes, by type
curl -s localhost:9100/metrics | grep '^# TYPE' | awk '{print $4}' | sort | uniq -c
# promtool: test a relabel rule against a label set without a server
promtool check service-discovery prometheus.yml kubernetes-pods --timeout 30s | head -40
# Fan out one query to several servers
for s in prom-a prom-b; do printf '%s\t' "$s"; curl -s --data-urlencode 'query=count(up==1)' "http://$s.example.com:9090/api/v1/query" | jq -r '.data.result[0].value[1]'; done
# Metrics that have no HELP text (usually hand-rolled instrumentation)
curl -s localhost:9090/api/v1/metadata | jq -r '.data | to_entries[] | select(.value[0].help=="") | .key'
# Compare the label sets of two series to see why a join fails
curl -s --data-urlencode 'query=kube_pod_owner{pod="my-app-7c9d"}' localhost:9090/api/v1/query | jq '.data.result[].metric | keys'Scripts#
Rank metrics by series count and identify the label responsible on each, the first step in any cardinality clean-up.
#!/usr/bin/env bash
# cardinality-report.sh [prometheus-url] [top-n]: top metrics by series and the highest-cardinality label on each
set -euo pipefail
url=${1:-http://localhost:9090}; top=${2:-15}
total=$(curl -sf "$url/api/v1/status/tsdb" | jq '.data.headStats.numSeries')
printf 'head series: %s\n\n%-8s %-50s %s\n' "$total" SERIES METRIC "HIGHEST-CARDINALITY LABEL"
curl -sf "$url/api/v1/status/tsdb" | jq -r '.data.seriesCountByMetricName[] | "\(.value) \(.name)"' | head -n "$top" \
| while read -r count name; do
worst=$(curl -sf -g "$url/api/v1/series?match[]=$name" \
| jq -r '[.data[] | to_entries[] | select(.key != "__name__")] | group_by(.key) | map({label: .[0].key, values: (map(.value) | unique | length)}) | max_by(.values) | "\(.label) (\(.values) values)"')
printf '%-8s %-50s %s\n' "$count" "$name" "$worst"
doneCheck every scrape target and rule group and exit non-zero when something is wrong, for a cron job or a CI smoke test after a config change.
#!/usr/bin/env bash
# prom-health.sh [prometheus-url]: targets down, rules failing, config reload state, remote write lag; exit 1 on any finding
set -euo pipefail
url=${1:-http://localhost:9090}; rc=0
q() { curl -sf --max-time 10 --data-urlencode "query=$1" "$url/api/v1/query" | jq -r '.data.result[0].value[1] // "0"'; }
curl -sf --max-time 5 "$url/-/ready" >/dev/null || { echo "CRIT: not ready"; exit 2; }
if [ "$(curl -sf "$url/api/v1/status/runtimeinfo" | jq -r .data.reloadConfigSuccess)" != true ]; then
echo "CRIT: last config reload failed"; rc=1
fi
down=$(curl -sf "$url/api/v1/targets" | jq -r '.data.activeTargets[] | select(.health!="up") | " \(.labels.job)\t\(.scrapeUrl)\t\(.lastError)"')
[ -z "$down" ] || { echo "WARN: targets down:"; echo "$down"; rc=1; }
bad=$(curl -sf "$url/api/v1/rules" | jq -r '.data.groups[] | .name as $g | .rules[] | select(.health!="ok") | " \($g)/\(.name)\t\(.lastError)"')
[ -z "$bad" ] || { echo "WARN: rules failing:"; echo "$bad"; rc=1; }
missed=$(q 'sum(increase(prometheus_rule_group_iterations_missed_total[1h]))')
[ "${missed%.*}" -eq 0 ] 2>/dev/null || { echo "WARN: $missed rule evaluations missed in the last hour (groups too slow)"; rc=1; }
lag=$(q 'max(prometheus_remote_storage_highest_timestamp_in_seconds - ignoring(remote_name, url) group_right prometheus_remote_storage_queue_highest_sent_timestamp_seconds)')
[ "${lag%.*}" -lt 60 ] 2>/dev/null || { echo "WARN: remote write lag ${lag}s"; rc=1; }
dropped=$(q 'sum(increase(prometheus_remote_storage_samples_dropped_total[1h]))')
[ "${dropped%.*}" -eq 0 ] 2>/dev/null || { echo "WARN: $dropped samples dropped by remote write in the last hour"; rc=1; }
[ $rc -eq 0 ] && echo "OK: $(q 'count(up==1)') targets up, $(q 'prometheus_tsdb_head_series') series"
exit $rcSilence an alert for a maintenance window with an expiry and an audit comment, and remove any earlier silences with the same matchers so they do not stack.
#!/usr/bin/env bash
# silence.sh DURATION COMMENT matcher [matcher...] e.g. silence.sh 2h "db-01 patching" alertname=~".*" instance="db-01:9100"
set -euo pipefail
: "${ALERTMANAGER_URL:=http://localhost:9093}"
duration=$1; comment=$2; shift 2
author=${USER:-unknown}
existing=$(amtool silence query --alertmanager.url "$ALERTMANAGER_URL" -o json "$@" | jq -r --arg a "$author" '.[] | select(.createdBy==$a) | .id')
for id in $existing; do amtool silence expire --alertmanager.url "$ALERTMANAGER_URL" "$id" && echo "expired earlier silence $id"; done
id=$(amtool silence add --alertmanager.url "$ALERTMANAGER_URL" -a "$author" -d "$duration" -c "$comment" "$@")
echo "silence $id active for $duration: $*"
amtool alert query --alertmanager.url "$ALERTMANAGER_URL" --silenced "$@" -o json | jq -r '.[] | " now silenced: \(.labels.alertname) \(.labels.instance // "")"'Troubleshooting#
| Symptom | Likely cause | Check |
|---|---|---|
| Query returns nothing | Label typo, series not scraped, or window shorter than two scrapes | Run the bare selector; widen the rate window |
up == 0 for a target | Endpoint down, wrong port or path, TLS or auth failure, NetworkPolicy | lastError from /api/v1/targets; curl the scrapeUrl from the Prometheus pod |
Target missing from /targets | Service discovery did not select it, or relabelling dropped it | Status > Service Discovery shows discovered and dropped labels |
| Scrape fails after upgrade to 3.x | Exporter sends no valid Content-Type | Set fallback_scrape_protocol: PrometheusText0.0.4 on the job |
rate shows a huge spike | Two targets write the same series, so the interleaved values look like resets | Graph the raw counter; look for duplicate instance labels |
| p99 flat at a round number | True latency above the largest finite bucket | Inspect _bucket series; add buckets or move to native histograms |
| Alert pending but never firing | Expression drops out between evaluations, resetting for | Graph the alert expression at the rule interval |
| Alert never fires though the service is down | Series disappears instead of reporting an error | Add an absent() rule |
many-to-many matching not allowed | Join key not unique on one side | Aggregate that side or add labels to on |
vector cannot contain metrics with the same labelset | An operation removed the metric name and left duplicates | Aggregate with sum by before the operation |
| Memory climbs, OOM kills | Cardinality growth | TSDB status API; prometheus_tsdb_head_series over time |
out of order sample or duplicate sample for timestamp | Two sources write the same series, often HA pairs without distinct external labels | Compare instance and job labels; check relabelling |
| Reload returns 403 or 404 | Lifecycle API disabled | Start with --web.enable-lifecycle or send SIGHUP |
| Alert fires in Prometheus but nobody is notified | Inhibited, silenced, routed to a blackhole, or the receiver rejected it | amtool alert query -o json status fields; amtool config routes test; alertmanager_notifications_failed_total |
| Same alert pages twice | Two Alertmanagers not clustered, or Prometheus pairs with different external_labels | amtool cluster show; compare external_labels |
| Grouped notification arrives late | group_wait or group_interval too long for the route | amtool config routes show; per-route timings |
| Target discovered but not scraped | A keep relabel rule did not match or a drop did | Status > Service Discovery, dropped targets with their __meta_* labels |
| Every pod label became a metric label | labelmap on __meta_kubernetes_pod_label_(.+) without a narrower regex | Restrict the regex; drop with labeldrop |
| Remote write lag grows, then samples dropped | Receiver slow or down; shards at max_shards | prometheus_remote_storage_shards, ..._samples_pending, ..._samples_dropped_total; receiver logs |
rule evaluations missed or slow groups | Group takes longer than interval | prometheus_rule_group_last_duration_seconds vs prometheus_rule_group_interval_seconds; split the group |
| Restart takes minutes with no scrapes | WAL replay | /api/v1/status/walreplay; smaller --storage.tsdb.min-block-duration is not the fix, less churn is |
promtool test rules passes, alert never fires live | Test interval differs from the group’s, or live series have extra labels | Match evaluation_interval to the group; compare label sets |
| OTLP metrics arrive with dots replaced by underscores or missing units | 3.x translation strategy | otlp.translation_strategy in the config; see the OTLP receiver docs |
Further reading#
- PromQL basics and function reference
- Metric and label naming and histograms and summaries
- Alerting rules and HTTP API
- Configuration reference (relabelling, service discovery, remote write) and unit testing rules
- Alertmanager configuration and notification templates