Learn Prometheus in a Single Post: Complete Tutorial From Metrics and the Pull Model to PromQL, Alerting, and Grafana
Prometheus is the metrics half of the observability stack β a time-series database that scrapes metrics from your services, stores them, and lets you query them with PromQL to build dashboards and alerts. Itβs the de-facto standard for cloud-native monitoring (born at SoundCloud, graduated by the CNCF, used by every Kubernetes cluster). This single post covers the whole system in five stages, with hand-drawn diagrams and runnable config.
Learning Roadmap
The roadmap moves from metric types (Stage 1), through the pull model (Stage 2), PromQL (Stage 3), alerting (Stage 4), and production (Stage 5). The Observability tutorial is the prerequisite β this post goes deep on the metrics pillar specifically.
Stage 1 β Metric Types
The four metric types
| Type | Behavior | Example |
|---|---|---|
| Counter | monotonic increasing (resets only on restart) | http_requests_total, errors_total |
| Gauge | arbitrary value, goes up and down | cpu_usage, queue_depth, temperature |
| Histogram | bucketed observations + sum + count | http_request_duration_seconds |
| Summary | quantiles computed client-side | precomputed p50/p90/p99 |
Counter vs Gauge β the fundamental distinction
- Counter β only goes up (or resets to 0 on restart). You always query its rate of change (
rate(http_requests_total[5m])), never the raw value (which is meaningless β β10,000 total requestsβ tells you nothing without a time window). - Gauge β the value itself is meaningful (
cpu_usage = 0.73is β73% right nowβ). Query it directly.
Histogram vs Summary β for quantiles
- Histogram β buckets observations into configurable buckets (
le="0.1",le="0.5",le="1", β¦). Quantiles are computed server-side withhistogram_quantile(). Buckets can be aggregated across instances. This is the recommended choice β itβs aggregatable. - Summary β computes quantiles client-side (the application calculates p50/p90/p99). Quantiles canβt be aggregated across instances (you canβt average p99s). Use only when you need exact quantiles and canβt aggregate.
Labels and cardinality
# A metric with labels (dimensions)
http_requests_total{method="GET", status="200", route="/api/users"}
Labels are key=value pairs that slice a metric into separate time series. method, status, route are good labels β low cardinality (a few dozen values each).
Pitfall: Never put high-cardinality values as labels β
user_id,request_id,session_id,
Stage 2 β The Pull Model
Prometheus scrapes targets (pull, not push)
Prometheus pulls metrics from targets β each target exposes a /metrics endpoint in the text exposition format, and Prometheus scrapes it on a schedule (default every 15s):
# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",status="200"} 1027
http_requests_total{method="POST",status="500"} 3
# prometheus.yml
scrape_configs:
- job_name: "myapp"
scrape_interval: 15s
static_configs:
- targets: ["app1:8080", "app2:8080"]
- job_name: "kubernetes-pods"
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: true
Why pull, not push?
- Push (StatsD, Datadog Agent) β the app sends metrics to the server. Problem: if the server is down, the app buffers or drops; you donβt know which apps are being monitored.
- Pull (Prometheus) β the server asks each target. If a target is down, the scrape fails and you see it (an
up == 0series). The server controls the scrape rate. No agent needed on the target β just expose/metrics.
Service discovery
For dynamic environments (Kubernetes, cloud), the target list changes constantly. Service discovery generates the target list at runtime:
kubernetes_sd_configsβ discover Kubernetes pods/services by label/annotation.ec2_sd_configsβ discover AWS EC2 instances.consul_sd_configsβ discover Consul-registered services.file_sd_configsβ read targets from a file (updated by an external process).
Push Gateway β for batch jobs
from prometheus_client import CollectorRegistry, Counter, push_to_gateway
registry = CollectorRegistry()
jobs_done = Counter('batch_jobs_done_total', 'Batch jobs completed', registry=registry)
jobs_done.inc()
push_to_gateway('pushgateway:9091', job='nightly_etl', registry=registry)
Batch jobs (cron, ETL) exit before Prometheus can scrape them. They push their metrics to the Push Gateway, which exposes them for Prometheus to scrape. Only use the Push Gateway for batch jobs β never for long-running services (it breaks the pull model and becomes a single point of failure).
Pitfall: Donβt use the Push Gateway for services. A service should expose
/metricsand let Prometheus pull. The Push Gateway is only for jobs that start, do work, and exit β and even then, push the metrics at the end of the job, not throughout.
Stage 3 β PromQL
Instant vs range vectors
| Β | What it returns | Example |
|---|---|---|
| Instant vector | one value per series, at this instant | http_requests_total |
| Range vector | values over a time window | http_requests_total[5m] |
| Scalar | a single number | 5 |
rate() β the workhorse
# requests per second over the last 5 minutes
rate(http_requests_total[5m])
# total increase over the last hour
increase(http_requests_total[1h])
rate() computes the per-second rate of increase of a counter over a window. It handles counter resets (if the counter resets to 0, rate still works). Always use rate() on counters, never the raw value.
Aggregation: sum by
# total request rate, grouped by job
sum by (job) (rate(http_requests_total[5m]))
# error rate per route
sum by (route) (rate(http_requests_total{status=~"5.."}[5m]))
/ sum by (route) (rate(http_requests_total[5m]))
sum by (label) aggregates series that share a label value. without (label) aggregates everything except the listed labels. The classic pattern: sum by (job) (rate(...)) β group the per-second rate by job.
histogram_quantile() β p99 from histograms
# 99th percentile request duration over 5m
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
histogram_quantile(quantile, buckets) computes a quantile from histogram buckets. Note the sum by (le) β you must aggregate the _bucket series by the le (less-than-or-equal) label before computing the quantile.
Recording rules β precompute expensive queries
# rules.yml
groups:
- name: precomputed
rules:
- record: job:request_rate:5m
expr: sum by (job) (rate(http_requests_total[5m]))
A recording rule precomputes a frequently-used, expensive query and stores the result as a new time series. Dashboards and alerts then query the precomputed series (job:request_rate:5m) instead of re-running the expensive query on every render. This is the #1 Prometheus performance optimization.
Pitfall: A dashboard querying
rate(http_requests_total[5m])across 1000 series on every refresh is expensive. Precompute it as a recording rule (job:request_rate:5m) and query that β 1000 series becomes 10 (one per job), and the query is instant.
Stage 4 β Alerting
Alerting rules
# alerts.yml
groups:
- name: app-alerts
rules:
- alert: HighErrorRate
expr: |
sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
/ sum by (job) (rate(http_requests_total[5m])) > 0.05
for: 10m # must be true for 10 minutes
labels:
severity: page # routes to on-call
annotations:
summary: "High error rate on "
description: " of requests are 5xx"
exprβ the PromQL condition that triggers the alert.forβ how long the condition must hold before firing (prevents flapping).labelsβ metadata used for routing (severity: pageβ PagerDuty;severity: warnβ Slack).annotationsβ the human-readable message (templated with$labelsand$value).
Alertmanager β routing, dedupe, silence
Prometheus fires alerts to Alertmanager, which handles the delivery:
- Routing β send
severity=pageto PagerDuty,severity=warnto Slack,severity=infoto email. - Deduplication β if 100 instances all alert βHighErrorRate,β Alertmanager sends one notification, not 100.
- Grouping β group alerts by
alertname+clusterso they arrive as one message. - Silencing β mute an alert during a maintenance window or a known incident.
- Inhibition β if
ClusterDownfires, suppressNodeDownalerts for that cluster (the root cause subsumes the symptoms).
# alertmanager.yml
route:
receiver: default
group_by: ["alertname", "cluster"]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
- matchers: ['severity="page"']
receiver: pagerduty
- matchers: ['severity="warn"']
receiver: slack
receivers:
- name: pagerduty
pagerduty_configs:
- service_key: "..."
- name: slack
slack_configs:
- api_url: "..."
Pitfall: An alert without
for:fires the instant the condition is true β and flaps on/off every scrape. Always addfor: 5m(or appropriate) so transient spikes donβt page on-call at 3am.
Stage 5 β Production
Grafana β dashboards
Grafana is the visualization layer β it queries Prometheus with PromQL and renders dashboards (graphs, stats, tables, heatmaps). Features: templating ($job, $instance dropdowns), alerting (Grafana can alert too, though Alertmanager is the standard), and shared community dashboards.
Federation β pull from other Prometheis
# a global Prometheus federates from per-cluster Prometheis
scrape_configs:
- job_name: "federate"
honor_labels: true
metrics_path: "/federate"
params:
"match[]": ['{job="myapp"}']
static_configs:
- targets: ["prometheus-cluster1:9090", "prometheus-cluster2:9090"]
Federation lets one Prometheus scrape selected series from others β a global view over per-cluster Prometheus instances. Use for hierarchical aggregation (cluster Prometheus β global Prometheus).
Thanos / Mimir / Cortex β long-term storage + global view
Prometheus stores data locally for ~15 days (itβs not designed for long-term retention). For long-term storage and a global query view across multiple Prometheis:
- Thanos β ships Prometheus data to object storage (S3/GCS), provides a global query layer (Thanos Query), and deduplicates across HA pairs. The most popular option.
- Mimir / Cortex β horizontally-scalable, multi-tenant long-term storage (Grafana Labs).
remote_writefrom Prometheus to Mimir.
# prometheus.yml β ship metrics to long-term storage
remote_write:
- url: "http://mimir:9009/api/v1/push"
High availability
Prometheus itself is not HA by default β run two identical Prometheus instances (each scraping all targets) and deduplicate with Thanos or alert on both (Alertmanager dedupes the alerts). Donβt put Prometheus behind a load balancer (each instance must scrape everything; theyβre not sharded stateless services).
Retention
# prometheus.yml
storage.tsdb.retention.time: 15d # local retention
storage.tsdb.retention.size: 100GB # or by size
Keep local retention short (15d) for fast queries; ship to Thanos/Mimir for 1+ year retention. Long local retention makes restarts slow and queries over old data expensive.
Quick-Start Checklist
- Run Prometheus β
docker run -p 9090:9090 prom/prometheus. - Open the UI at
http://localhost:9090β queryupto see targets. - Instrument an app β
prometheus_client(Python),prom-client(Node),prometheus(Go); expose/metrics. - Add a scrape config β
job_name: myapp,targets: ["app:8080"]. - Query with PromQL β
rate(http_requests_total[5m]),sum by (job) (...). - Add a histogram for request duration; query
histogram_quantile(0.99, ...). - Write a recording rule β precompute a hot query.
- Write an alerting rule β
expr + for: 5m + labels.severity. - Run Alertmanager β route
pageto PagerDuty,warnto Slack. - Connect Grafana β add Prometheus as a datasource, build a dashboard.
Common Pitfalls
- High-cardinality labels β
user_id/request_idas labels β cardinality explosion β OOM. Never. - Querying raw counter values β meaningless; always
rate()orincrease(). - No
for:on alerts β flaps on every scrape; addfor: 5m. - Push Gateway for services β only for batch jobs; services expose
/metricsfor pull. - No recording rules β expensive queries re-run on every dashboard refresh; precompute.
- Long local retention β slow restarts, expensive queries; ship to Thanos/Mimir for long-term.
- Prometheus behind a load balancer β wrong; each instance scrapes everything, dedupe with Thanos.
- Summary for aggregatable quantiles β Summary quantiles canβt be aggregated across instances; use Histogram.
Further Reading
- Prometheus Docs β the official reference
- PromQL Cheatsheet β the query functions
- Prometheus: Up & Running by Brian Brazil β the canonical book
- Thanos Docs β long-term storage
- Awesome Prometheus Alerts β alerting rule collection
Related guides
Prometheus is the metrics pillar of observability β these PyShine tutorials connect to it:
- Learn Observability in One Post β the umbrella; metrics + logs + traces. Prometheus is the metrics half.
- Learn Kubernetes in One Post β the Prometheus Operator runs Prometheus as a K8s resource; service discovery is built in.
- Learn Docker in One Post β run Prometheus + Grafana + Alertmanager in containers.
- Learn Python in One Post β
prometheus_clientis the Python instrumentation library. - Learn Ansible in One Post β deploy Prometheus to a fleet of hosts with a playbook.
Prometheusβs value is the pull model + PromQL + a metric-type system that forces good instrumentation β you scrape targets (so you know whatβs being monitored), you query with a purpose-built language (not SQL on a generic DB), and the counter/gauge/histogram types make you think about what each metric means. The five stages here β metrics, pull, PromQL, alerting, production β cover everything from a single Enjoyed this post? Never miss out on future posts by following us /metrics endpoint to a federated, Thanos-backed, Alertmanager-routed, Grafana-visualized production monitoring system. The two habits that pay off: never use high-cardinality labels (it OOMs Prometheus), and always rate() counters + for: on alerts (raw counter values are meaningless; un-forβd alerts flap). Instrument an app with prometheus_client, expose /metrics, query rate(http_requests_total[5m]) in the Prometheus UI, and watch the per-second rate appear β once youβve seen PromQL return a rate, the model clicks.