π‘The Metrics Pipeline
Metrics are how a service watches itself β billions of events crushed into cheap, queryable numbers. This is the pipeline that carries one number from your code to an alert, and the engines that store it along the way: Prometheus, InfluxDB, TimescaleDB, and the scaling tier (Thanos, Mimir, VictoriaMetrics) β plus OpenTelemetry, the vendor-neutral layer tying it all together. Built from primary sources, written against the current releases.
TL;DR
- A metric is an aggregation. It is a numeric value, sampled over time, carrying a name and a set of labels. That makes metrics cheap to store and trivial to sum or average β but lossy: you can know that 4% of checkouts failed in the last minute without knowing which ones. That's the opposite trade-off from logs and traces, which keep every event but cost far more.
- It's a pipeline, not a product. The journey is instrument β collect β store β query β visualize β alert. Most "tools" own several of those stages at once, which is why naΓ―vely comparing them is confusing.
- The three you've heard of sit at different layers. Prometheus is a whole vertical β it scrapes, stores, queries (PromQL) and alerts β but is single-node by design. InfluxDB is a purpose-built push TSDB (v3 stores columnar Parquet and answers in SQL). TimescaleDB is just a storage layer β a PostgreSQL extension, so it speaks full SQL and joins against your business tables.
- One constraint rules everything: cardinality. A series is one unique label-set; put an unbounded label (a user ID, a request ID) on a metric and you multiply your series into the millions. Unbounded identifiers belong on logs and traces, never on a metric.
- OpenTelemetry is the connective tissue. OTLP decouples how you instrument from where you store, so you can swap Prometheus for InfluxDB for a vendor without re-instrumenting. Prometheus 3.x even ingests it natively.
The metrics pipeline
Follow a single number β say, the duration of one checkout request β from the line of code that records it to the page that wakes someone up. It passes through six stages, and almost every tool in this space is really a bundle of two or three of them.
- Instrument. Your code creates a metric (a counter, a gauge, a histogram) using a client library or an OpenTelemetry SDK and updates it as the program runs.
- Collect. An agent gathers the numbers β either by scraping an HTTP endpoint your app exposes (pull) or by receiving what your app sends it (push) β then batches, enriches, and forwards them. This is Prometheus's server, the OpenTelemetry Collector, or Telegraf.
- Store. A time-series database persists the samples on disk, compressing aggressively and indexing by label.
- Query. A query engine reads it back and aggregates on demand β PromQL for Prometheus-shaped data, SQL for InfluxDB 3 and TimescaleDB.
- Visualize. A dashboard layer β almost always Grafana β turns queries into graphs.
- Alert. A rules engine evaluates queries on a schedule and fires when a condition holds, routing the notification to a human.
Instrumenting code
Everything starts with a measurement in your program. There are only four shapes a metric takes, and picking the right one is most of the craft.
| Type | What it does | Examples |
|---|---|---|
| Counter | Monotonic total; only ever increases | requests served, errors, bytes sent |
| Gauge | A value that goes up and down | queue depth, in-flight requests, temperature |
| Histogram | Bucketed distribution of observations | request latency, response sizes |
| Summary | Client-side quantiles over a window | latency where you can't pick buckets |
A working example, using the Prometheus Python client. The shape barely changes across languages or with an OpenTelemetry SDK β what matters is the discipline around labels.
from prometheus_client import Counter, Histogram, start_http_server
REQUESTS = Counter("http_requests_total", "Total HTTP requests",
["method", "route", "status"]) # bounded labels only
LATENCY = Histogram("http_request_seconds", "Request latency", ["route"])
@LATENCY.labels(route="/checkout").time() # observe the duration
def handle_checkout(req):
REQUESTS.labels("POST", "/checkout", "200").inc() # count this request
...
start_http_server(9090) # exposes GET /metrics for a scraper to pull
How to choose what to measure
Two methodologies dominate, and they're complementary:
- RED β Rate, Errors, Duration, measured per request or endpoint. It tells you how happy your users are, so it's the right thing to alert on (symptoms, not causes).
- USE β Utilization, Saturation, Errors, measured per resource (CPU, memory, disk). It tells you how happy your machines are, which is where you look to diagnose a symptom RED surfaced.
One modern refinement worth knowing: classic histograms force you to pick bucket boundaries up front, and each bucket is a series. Native (exponential) histograms, a stable Prometheus feature, replace fixed buckets with an automatically-scaled exponential layout β far better resolution at a fraction of the series cost.
Pull vs push
The oldest argument in metrics: does the collector reach out and scrape your service, or does your service send its numbers somewhere? Prometheus is the famous pull system; InfluxDB/Telegraf, OpenTelemetry, and StatsD are push. Each genuinely wins in different places.
Pull (Prometheus) wins on operability: every scrape produces an up metric, so the server knows whether each target is healthy, and when service discovery says an instance is gone, its series simply stop β no cleanup. Push wins on reach: a function that lives for 200 ms, a nightly batch job, or anything behind a firewall can't be scraped, so it sends instead.
Prometheus's Pushgateway exists for one narrow case: service-level batch jobs that finish before a scrape can happen. The docs warn it becomes a single point of failure, you lose the up health signal, and it never forgets a pushed series β they linger until you delete them by API. For genuinely push-shaped telemetry, reach for the OpenTelemetry Collector or an InfluxDB-style sink, not the Pushgateway.
The cardinality problem
If you remember one thing about operating a metrics system, make it this. A time series is one unique combination of a metric name and its label values. Every distinct combination is stored and indexed separately. So the cost of a metric isn't how often you update it β it's how many label combinations it can produce.
This is exactly where the three pillars divide the labor. Metrics are aggregations into bounded series, so unbounded identifiers are toxic to them. Logs and traces are per-event records β high-cardinality IDs are precisely the dimension you want to search them on. The discipline: keep the unbounded ID on the log line, the trace span, or a Prometheus exemplar β and let the metric tell you, cheaply, only that something is wrong.
Storage engines compared
The middle of the pipeline β store and query β is where the well-known databases actually compete. Four worth knowing: Prometheus's built-in TSDB, InfluxDB 3, TimescaleDB, and VictoriaMetrics. Use the controls to filter rows to one dimension or highlight a single engine's column. (Both persist across reloads; Copy as Markdown always exports the full matrix.)
| Dimension | Prometheus | InfluxDB 3 | TimescaleDB | VictoriaMetrics |
|---|---|---|---|---|
| What it is | Monitoring system + local TSDB | Purpose-built time-series DB | PostgreSQL extension | TSDB + monitoring system |
| Core design | Pull-based, single-node by design | Columnar engine on object storage | Time-series on top of Postgres | From-scratch Go TSDB, efficiency-first |
| Primary query language | PromQL | SQL (+ InfluxQL) | Full PostgreSQL SQL | MetricsQL (PromQL superset) |
| Data model | metric + labels; float / native-histogram samples | measurements, tags, fields (line protocol) | ordinary SQL tables (hypertables) | metric + labels (Prometheus-compatible) |
| Ingest model | Pull (scrape); push via remote-write / OTLP | Push β line protocol, Telegraf | Push β SQL INSERT / COPY | Both β scrape (vmagent) or remote-write |
| Native scrape (pull) | Yes β the core model | No (Telegraf can scrape for it) | No | Yes β via vmagent |
| OpenTelemetry / OTLP | Native OTLP receiver (3.x) | Via Telegraf / OTLP | Via collector β SQL | Native OTLP ingestion |
| Storage engine | Local TSDB β 2-hour blocks + WAL | Columnar Parquet in object storage | Hypercore β row + columnar in Postgres | Own on-disk columnar format |
| Long-term / durability | Local disk (~15d default); needs Thanos / Mimir for LTS | Object storage = durable by construction | Postgres durability + compression + retention | Native long-term; cluster for HA |
| Compression | Gorilla-style, very compact | Parquet columnar | Columnar, often ~90%+ | High β a core design goal |
| Horizontal scale | Single node; federate or add an LTS layer | Cloud Dedicated / Clustered editions | Scale-up + read replicas; multi-node via cloud | Native cluster (vminsert / vmstorage / vmselect) |
| Multi-tenancy | No β run a server per tenant | Cloud editions | Postgres roles / databases | Yes β in cluster mode |
| High cardinality | Strained past millions of series / node | Improved in v3 | Workable; watch index cost | Designed for high churn & cardinality |
| Relational joins | No | Limited | Yes β full SQL joins | No |
| First-party / managed | Grafana Cloud, Chronosphere, & others | InfluxDB Cloud (Serverless / Dedicated) | Tiger Cloud (formerly Timescale Cloud) | Managed VictoriaMetrics Cloud |
| License | Apache 2.0 (CNCF graduated) | MIT/Apache (Core); commercial Enterprise | Apache 2.0 core; Timescale License for some features | Apache 2.0 |
| Ecosystem | Huge β exporters, Grafana, Alertmanager, K8s | Telegraf (300+ plugins) | The entire Postgres ecosystem | Drop-in Prometheus-compatible stack |
| Best-fit use case | Cloud-native / Kubernetes infra & app monitoring | IoT, sensor & high-volume event metrics | Metrics alongside relational / business data | Large-scale, cost-efficient Prometheus storage |
| Sharpest footgun | The storage is single-node β not the model | Flux is gone in v3; migrating off v2 is a rewrite | It is still Postgres β operate & tune it like Postgres | Smaller community than Prometheus itself |
Read the columns as philosophies. Prometheus assumes a single node is enough for one team's recent data and pushes everything else to a separate long-term layer. InfluxDB rebuilt itself around columnar files in object storage and SQL. TimescaleDB makes the radical-but-pragmatic bet that you shouldn't need a separate database at all β time-series is just Postgres with good partitioning. VictoriaMetrics keeps Prometheus's interface but rewrites the engine underneath for efficiency at scale.
Scaling Prometheus
Prometheus is deliberately a single binary on a single node: no clustering, no built-in long-term storage (the local disk defaults to roughly 15 days of retention), and no multi-tenancy. Its own scaling features β functional sharding and federation β only move aggregated series upward, not a complete global view. That gap created an entire ecosystem of long-term, horizontally-scalable backends that speak Prometheus's protocols.
| Backend | Relation to Prometheus | Storage | Origin |
|---|---|---|---|
| Thanos | Sidecar (reads TSDB blocks) + receive | Object storage | Reuses Prometheus's TSDB format (CNCF) |
| Cortex | Remote-write receiver | Object storage | Original multi-tenant scaler (CNCF) |
| Grafana Mimir | Remote-write (+ OTLP) | Object storage | Cortex fork; tested to ~1 billion series |
| VictoriaMetrics | Remote-write and its own scrape | Own format (object store for backups) | Written from scratch in Go |
Note the family split: Thanos, Cortex, and Mimir all reuse Prometheus's own code and storage format, while VictoriaMetrics is a clean-room reimplementation that merely speaks the same protocols. Mimir is the Cortex fork that Grafana hardened for extreme scale and runs behind Grafana Cloud.
OpenTelemetry & OTLP
For years, picking a metrics backend meant picking an instrumentation library β switching vendors meant re-instrumenting every service. OpenTelemetry (a CNCF project, and now the de-facto standard) breaks that coupling. You instrument once against the OTel SDK, emit OTLP over the wire, and decide later and separately where it lands.
The workhorse is the OpenTelemetry Collector, a small pipeline of its own: receivers take telemetry in (by push or by scraping Prometheus targets), processors batch / filter / transform it, and exporters fan it out to one or more backends. It runs either as a per-host agent or as a central gateway.
Interop runs both ways. The Collector's prometheus receiver scrapes existing targets; its exporters write to Prometheus, Mimir, or InfluxDB. And Prometheus 3.x now ingests OTLP natively (enable the OTLP receiver and POST to /api/v1/otlp/v1/metrics).
OpenTelemetry can emit counters as delta (the change since last export); Prometheus expects cumulative (the running total). Mixing them silently breaks rate(). When piping OTel into Prometheus, configure cumulative temporality (or use the delta-to-cumulative conversion) β it's the single most common OTel-to-Prometheus footgun.
Query, visualize, alert
Querying
Two query philosophies. PromQL (Prometheus, and VictoriaMetrics' compatible MetricsQL) is a functional language built around time-series math β rate(), histogram_quantile(), vector matching. SQL (InfluxDB 3 and TimescaleDB) trades that purpose-built terseness for the familiarity, joins, and tooling of the relational world. A taste of PromQL, the dialect you'll meet most:
# per-second rate of 5xx responses over the last 5 minutes
rate(http_requests_total{status=~"5.."}[5m])
# p99 latency, reconstructed from histogram buckets
histogram_quantile(0.99, rate(http_request_seconds_bucket[5m]))
Visualizing
Grafana is the near-universal dashboard layer, and its superpower is being multi-datasource: a single dashboard can pull one panel from Prometheus, another from InfluxDB, another from a SQL database. It's why the storage decision and the visualization decision are largely independent β almost everything graphs in Grafana.
Alerting
Two rule types do the work. Recording rules pre-compute an expensive query on a schedule and save the result as a new series, so dashboards and alerts read something cheap. Alerting rules evaluate a condition and, when it holds, fire to Alertmanager, which deduplicates, groups, silences, and routes the notification to a human. The best-practice pairing with RED: alert on symptoms (a rising error rate users feel), then use your dashboards to find the cause.
Deployment & decision guide
The honest mapping from situation to stack:
- Cloud-native infra & app monitoring (Kubernetes). Reach for Prometheus + Grafana + Alertmanager β the default, with first-class service discovery and a vast exporter catalog. Add Mimir, Thanos, or VictoriaMetrics when one node isn't enough.
- Metrics next to relational / business data, with a SQL team. Reach for TimescaleDB β keep metrics and the rest of your data in one Postgres, join freely, and skip operating a second database.
- High-volume push, IoT, or sensor telemetry. Reach for InfluxDB β line-protocol ingest, the Telegraf agent ecosystem, and a columnar engine built for write-heavy event streams.
- Huge scale or tight cost on a Prometheus-shaped stack. Reach for VictoriaMetrics or Grafana Mimir β same PromQL and Grafana, far more series per dollar.
- Future-proof instrumentation, undecided backend. Instrument with OpenTelemetry and route through the Collector β you can change your mind about storage later without touching code.
Common misconceptions
Its collection model scales fine β what's single-node is its local storage. The fix isn't to abandon Prometheus; it's to add a long-term layer (Thanos, Mimir, Cortex, or VictoriaMetrics) that keeps PromQL and Grafana intact.
Flux is in maintenance mode and is not supported in InfluxDB 3. The current path is SQL (with InfluxQL for compatibility). Moving a v2 Flux codebase to v3 is a rewrite, not an upgrade.
It's a PostgreSQL extension. You operate, back up, secure, and tune it exactly like Postgres β which is the upside (one familiar system) and the catch (its ceilings are Postgres's ceilings).
That's the classic cardinality bomb: an unbounded label multiplies your series into the millions and can take the database down. Slice by customer in logs or traces; keep metric labels bounded.
It's for service-level batch jobs only. It's a single point of failure, it drops the up health signal, and it never forgets a pushed series. For real push telemetry use the OpenTelemetry Collector or a push-native sink.
Further reading
Prometheus & the scaling tier
- prometheus.io β the primary source for the data model, local storage, PromQL, pull model, and Alertmanager; see also the Prometheus 3.0 announcement and the naming & cardinality guidance.
- Thanos, Cortex, Grafana Mimir, and VictoriaMetrics β the long-term / horizontally-scalable backends.
InfluxDB & TimescaleDB
- InfluxDB 3 docs β Core / Enterprise / Cloud editions, line protocol, SQL, and the Arrow/DataFusion/Parquet engine; Telegraf for collection.
- TimescaleDB (by Tiger Data) β hypertables, continuous aggregates, and the Hypercore columnar engine; the docs.
OpenTelemetry & methodology
- OpenTelemetry β metrics, the OTLP spec, the Collector architecture, and the observability primer (metrics vs logs vs traces); plus Prometheus's OTLP guide.
- The RED method (and USE) β what to measure and what to alert on.
On this site
- ClickHouse: An Engineering Doc β the columnar, vectorized OLAP engine that shares this world's DNA (and is itself a popular store for very high-volume metrics and observability data).