Search Tech Journey

Find topics, journeys and posts

6-month learning plan78 / 130
back to blog
systemsintermediate 55m read

S078 · Prometheus, Grafana, OpenTelemetry — Hands-on

The default open-source observability stack. Scrape, store, query, visualise, and instrument — all with the CNCF tools every serious team uses.

⚙️SystemsM09 · Observability & SRE· Session 078 of 130 90 min

🎯 Stand up Prometheus + Grafana + OpenTelemetry Collector locally, instrument a service, and build a dashboard + alert.

Why this session exists

Every modern devops interview asks "walk me through your observability stack", and 80% of the industry's honest answer is "Prometheus + Grafana + OpenTelemetry". Not because they're the best of every dimension, but because they are the CNCF-blessed, battle-tested, learn-once-use-everywhere default. Learn this stack and you can walk into any Kubernetes shop, any startup, any SRE team and be productive on day one. Every managed offering (AWS AMP, GCP Managed Prometheus, Datadog, Grafana Cloud) is either a hosted version of these tools or speaks their protocols.

You will be able to
  • Explain the pull-based scrape model of Prometheus and why it beats push for operability.
  • Write PromQL for rate, aggregation, and histograms; explain why avg() is almost always the wrong function.
  • Build a Grafana dashboard with the RED / USE / four-golden-signals rows.
  • Send metrics, logs, and traces from an app through the OTel Collector to different backends.
  • Configure a Prometheus alert rule for a real SLO (error rate above 1% for 5 min).

Prerequisites

  • S077 · The 3 pillars — the model behind these tools.
  • S056 · Docker basics — you'll spin up the stack in containers.


(a) Intuition · 5 min

A weather station for your services
🌍 Real world

Imagine setting up a weather station: temperature, humidity, wind sensors that report readings every minute. A separate server pulls the readings and stores them. A wall display graphs the last 24 hours and turns red if the wind exceeds 60 km/h.

You could push readings from each sensor to the server. Or you could have the server actively poll each sensor. The second option means: (1) you know when a sensor is missing (it didn't respond), (2) new sensors are discovered by the server rather than needing to know the server's address, (3) sensors can't accidentally overload the server by all reporting at once.

💻 Code world

Prometheus is the polling server. Your services expose /metrics endpoints (the sensors). Prometheus scrapes them every 15 seconds. Grafana is the wall display. OpenTelemetry is the sensor library — a vendor-neutral way to instrument your code so metrics, logs, and traces all come out the same shape.

The three together are the boring, correct, industry-standard answer for observability under 10 K services. Above that scale you graduate to Mimir, Thanos, or hosted variants — but the model stays the same.

The core mental model

How the pieces fit
  • Prometheus PULLS metrics from HTTP /metrics endpoints on a schedule (default 15s).
  • Metrics are stored in a time-series database with labels — each unique label combo = one series.
  • PromQL is the query language: SQL-ish but purpose-built for time series (rate, sum, histogram_quantile).
  • Grafana connects to Prometheus (and Loki, Tempo, InfluxDB, etc.) and draws dashboards + fires alerts.
  • OpenTelemetry (OTel) is the instrumentation library + collector. Emits data in a vendor-neutral format; the Collector routes it to Prometheus / Loki / Tempo / vendor of choice.

A history of the stack

  1. 2012
    Prometheus at SoundCloud
    Julius Volz + Matt Proud build a metrics system inspired by Google's Borgmon. Open-sourced.
  2. 2014
    Grafana forks from Kibana
    Torkel Ödegaard wants dashboards that work across many data sources, not just Elasticsearch. Grafana ships.
  3. 2016
    Prometheus joins CNCF
    Second project after Kubernetes. Becomes the de-facto monitoring for K8s.
  4. 2018
    OpenTracing + OpenCensus
    Two competing vendor-neutral trace/metric libraries. Community fatigue growing.
  5. 2019
    OpenTelemetry merger
    OpenTracing + OpenCensus combine into OpenTelemetry. One SDK for metrics, logs, traces.
  6. 2021
    OTel Metrics GA
    Now the full trilogy is stable. Most new instrumentation is via OTel.
  7. 2024
    Prometheus 3 + OTel-native
    Prometheus now speaks OTLP directly. The two ecosystems converge.

(b) Visual walkthrough · 15 min

The stack

The Prometheus data model

The PromQL primitives you must know

11
instant vector

http_requests_total — value(s) at 'now'.

22
range vector

http_requests_total[5m] — the values across the last 5 minutes.

33
rate()

rate(http_requests_total[5m]) — per-second rate over the range. THIS is what you almost always want, not the raw counter.

44
sum by()

sum by(status) (rate(http_requests_total[5m])) — aggregate away all labels except 'status'.

55
histogram_quantile()

histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) — the 'p99' for latency.

The OTel Collector pipeline

Anatomy of a Collector config

Receivers
How data enters. otlp (protobuf/gRPC), prometheus (scrape), filelog (tail files), etc.
in
Processors
Transformations in-flight. batch (efficient), attributes (add/remove labels), memory_limiter (backpressure).
middle
Exporters
How data leaves. prometheusremotewrite, loki, otlp/tempo, jaeger, datadog, honeycomb, etc.
out
Pipelines
Wire receivers → processors → exporters, one per signal (metrics, logs, traces). YAML.
flow

Vendor comparison

Self-hosted Prometheus + Grafana + Tempo

The FOSS default

  • Zero licensing cost
  • Full control, full responsibility (you run + scale it)
  • Great for K8s (helm chart in 10 minutes)
  • Long-term retention needs Thanos/Mimir
  • Best for: small-to-mid teams that value control + are OK with ops overhead
Grafana Cloud

Same tools, hosted

  • Free tier: 10K series, 50GB logs, 50GB traces
  • Managed Mimir (metrics), Loki (logs), Tempo (traces)
  • Same OSS APIs — migration is trivial
  • Best for: teams that want the OSS stack without operating it
Datadog

The commercial full-stack

  • One vendor, one UI, everything integrated
  • Notoriously expensive at scale ($$$$ per host + per metric + per log)
  • AI-assisted alerting, service maps
  • Best for: enterprises willing to pay for polish + one-throat-to-choke

The mental model to hold


Common misconception
✗ What most people think

"Prometheus scrapes my app, so I have my metrics. If I need per-user or per-request detail, I just add a label."

✓ What is actually true

Prometheus stores one time series per unique label-set. Adding a label whose value is unbounded — user ID, request ID, full URL path, customer tenant — multiplies your series count by the cardinality of that label. Each active series costs memory in the head block and disk in every 2h block, permanently. High cardinality does not degrade Prometheus gracefully; it OOM-kills it.

Why the myth is so sticky

The myth is sticky because labels behave exactly like columns in every analytics system you have used before. In Kusto or Spark, adding a column is nearly free — the engine scans and groups at query time. Prometheus inverts that: it materialises the group-by at ingest, one series per combination, indexed forever. Your instinct is calibrated on a columnar store; Prometheus is an inverted index over label-sets. That is the whole difference.

Prove it to yourself

Watch series count move as you add a dimension. Instrument with a bounded label first, then an unbounded one, and query the meta-metrics:

# total active series in the head block
prometheus_tsdb_head_series

# which metric name is the offender
topk(10, count by (__name__) (scrape_samples_scraped))

# cardinality of one label on one metric
count(count by (user_id) (http_requests_total))

Run the third query before and after adding user_id. The number it returns is the multiplier you just applied to your storage bill.

From first principles
Start with the question

Why does Prometheus pull (scrape) instead of letting services push? Every other telemetry pipeline you have built — event hubs, Kafka, log agents — pushes. This looks like a contrarian design choice. It isn't.

  1. 1
    A monitoring system's single most important property is that it is trustworthy when everything else is broken.
    forced by · if it fails in the same failure mode as the system it watches, it tells you nothing exactly when you need it
  2. 2
    With push, "no data arrived" is ambiguous: the target may be down, the network may be partitioned, the target may have been descheduled, or it may simply never have existed. The monitoring system has no independent list of what should exist.
    forced by · the set of senders is defined by the senders themselves
  3. 3
    With pull, the monitoring system owns a service-discovery list of targets it expects. Failing to scrape a known target is an unambiguous, actionable signal — and it is emitted as a metric (up == 0) by the scraper itself, not by the dead process.
    forced by · only an external observer can report that something stopped existing
  4. 4
    Pull also puts rate control on the observer. The scrape interval is a server-side config, so a misbehaving or restarting fleet cannot flood the metrics backend — a load-shedding property push systems have to bolt on with queues and backpressure.
    forced by · the party that can be overwhelmed should be the party that sets the rate
  5. 5
    Finally, a pull endpoint is just an HTTP handler exposing current values. It is stateless, idempotent, and independently inspectable with curl — so debugging instrumentation never requires the pipeline to be healthy.
    forced by · the target holds current gauge/counter state anyway; exposing it is cheaper than shipping it
⇒ Therefore

Therefore pull is not a stylistic preference — it is the only model in which "target is missing" is a first-class, alertable fact rather than silence.

And note what this predicts: pull must break for workloads that do not live long enough to be scraped. That is exactly why the Pushgateway exists and why its documented use is restricted to batch jobs — and why pushing normal service metrics through it re-creates every ambiguity above, since the Pushgateway keeps serving the last value of a job that died.

Mental modelThree pipes, one clock

Picture three separate pipes leaving every service: metrics (numbers aggregated over time, cheap, always on), traces (the causal path of one request across services, sampled), logs (verbatim text, expensive, kept briefly). They are not three views of one dataset — they have different costs, retentions, and cardinality budgets.

What joins them is not a shared store, it is a shared identifier and clock: trace ID and exemplars. Metrics tell you that p99 moved; the exemplar attached to that bucket hands you a trace ID; the trace tells you which hop; the logs for that span tell you why.

  • Metrics answer "is something wrong and how much" — bounded cardinality, infinite retention. Never put an ID in a label.
  • Traces answer "where in the call graph" — unbounded cardinality, so they must be sampled. Sample the decision at the edge and propagate it, or you get broken partial traces.
  • Logs answer "why, in the developer's own words" — always attach trace ID and span ID, or they are unjoinable and you are back to grep.
  • OTel is the wire format and SDK, not a backend. Instrument once with OTel, then choose Prometheus/Grafana/whatever behind it. That indirection is the entire point.
🔔 Fires when you see

Fire this the moment you see: a dashboard that shows a spike but cannot explain it · someone proposing to add request_id as a Prometheus label · logs that cannot be tied to the request that produced them · a "we'll just query the logs" answer for a p99 latency question · a vendor migration where all instrumentation has to be rewritten.

The tradeoff

How do you make per-request latency answerable: histogram buckets, native/exponential histograms, or summaries (client-computed quantiles)?

Classic histogram (fixed le buckets)
+ you gain buckets are counters, so they are perfectly aggregatable across instances — you can compute a true fleet-wide p99 with histogram_quantile over a sum. Cheap to compute at write time.
− you pay you must guess the bucket boundaries in advance; if real latency lands between two widely-spaced buckets your quantile is an interpolation, i.e. a guess. Each bucket is its own time series, so 12 buckets × your other labels is a 12× cardinality multiplier.
pick when you already know the latency range within roughly an order of magnitude and you need cross-instance aggregation — the default for HTTP and RPC latency
Native (exponential) histogram
+ you gain bucket boundaries are generated from a scale factor, so it covers many orders of magnitude at bounded relative error with far fewer stored series; no advance guessing
− you pay newer, so support across scrapers, remote-write receivers, exporters and dashboards is uneven — you inherit a compatibility matrix. Requires protobuf/native-write paths rather than plain text exposition.
pick when your latency distribution spans orders of magnitude (batch jobs, cold-vs-warm cache paths) and your whole stack — client SDK, Prometheus version, and backend — already supports it
Summary (quantiles computed in the client)
+ you gain exact-ish quantiles for that one instance with no bucket configuration at all, and no bucket cardinality
− you pay quantiles are not aggregatable. Averaging the p99 of ten pods is a meaningless number, and there is no correct way to recover the fleet p99 from summaries. Also shifts CPU cost into the hot path of your service.
pick when a genuinely single-instance component, or an offline batch job where "the fleet" is one process
What a senior engineer actually does

Default to classic histograms and treat bucket boundaries as a real design decision, not a copy-paste — put boundaries where your SLO thresholds are, because a bucket edge at exactly your SLO turns the SLO query into an exact counter ratio instead of an interpolation. Then delete buckets you never query; each one is permanent storage.

Reach for native histograms when your range is genuinely wide and your stack supports them end to end. Avoid summaries for anything that runs on more than one replica — the aggregation problem is not a limitation you can engineer around, it is arithmetic.


(c) Hands-on · 25 min

Let's stand up the full stack with docker-compose, instrument a Python app, and build a real query.

# docker-compose.yml — Prometheus + Grafana + OTel Collector
# Run: docker compose up -d
# Then: http://localhost:3000 (grafana) / http://localhost:9090 (prometheus)
version: "3.9"
services:
  prometheus:
    image: prom/prometheus:v2.53.0
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
    ports: ["9090:9090"]
 
  grafana:
    image: grafana/grafana:11.1.0
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    ports: ["3000:3000"]
 
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.104.0
    volumes:
      - ./otel-config.yaml:/etc/otelcol/config.yaml:ro
    command: ["--config=/etc/otelcol/config.yaml"]
    ports:
      - "4317:4317"  # OTLP gRPC
      - "4318:4318"  # OTLP HTTP
      - "8889:8889"  # /metrics for Prometheus to scrape
# prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s
 
scrape_configs:
  - job_name: "app"
    static_configs:
      - targets: ["host.docker.internal:5000"]  # your app's /metrics
  - job_name: "otel-collector"
    static_configs:
      - targets: ["otel-collector:8889"]  # OTel's metrics passthrough
 
rule_files:
  - "alert_rules.yml"
# otel-config.yaml
receivers:
  otlp:
    protocols:
      grpc: { endpoint: "0.0.0.0:4317" }
      http: { endpoint: "0.0.0.0:4318" }
 
processors:
  batch:
    timeout: 5s
  attributes:
    actions:
      - key: env
        value: dev
        action: insert
 
exporters:
  prometheus:
    endpoint: "0.0.0.0:8889"
  debug:
    verbosity: basic
 
service:
  pipelines:
    metrics:
      receivers: [otlp]
      processors: [batch, attributes]
      exporters: [prometheus, debug]
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]
# alert_rules.yml — the ONE alert every service should have
groups:
  - name: SLO-alerts
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[5m]))
          /
          sum(rate(http_requests_total[5m]))
          > 0.01
        for: 5m
        labels:
          severity: page
        annotations:
          summary: "Error rate above 1% for 5m"
          description: "The 5xx ratio has exceeded 1% for the last 5 minutes."

Now the Python app that exposes /metrics:

#!/usr/bin/env python3
"""app.py — a Flask app that exposes Prometheus metrics AND sends
traces to the OTel Collector over OTLP.
 
Run:
  pip install flask prometheus-client opentelemetry-api \\
              opentelemetry-sdk opentelemetry-exporter-otlp-proto-grpc
  python app.py
  # then hammer it:
  while true; do curl -s localhost:5000/order/$RANDOM > /dev/null; sleep 0.1; done
"""
from __future__ import annotations
import random, time
from flask import Flask
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
 
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
 
# --- Tracing setup: emit OTLP to the local Collector ---
trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(
    BatchSpanProcessor(
        OTLPSpanExporter(endpoint="localhost:4317", insecure=True)
    )
)
tracer = trace.get_tracer(__name__)
 
# --- Metrics: scraped by Prometheus at /metrics ---
REQUESTS = Counter("http_requests_total",
                   "HTTP requests", ["method", "endpoint", "status"])
LATENCY = Histogram("http_request_duration_seconds",
                    "HTTP latency", ["endpoint"],
                    buckets=(.005, .01, .025, .05, .1, .25, .5, 1, 2.5))
 
app = Flask(__name__)
 
 
@app.route("/order/<int:order_id>")
def get_order(order_id: int):
    start = time.time()
    with tracer.start_as_current_span("get_order") as span:
        span.set_attribute("order.id", order_id)
        # Simulate variable work
        with tracer.start_as_current_span("db_lookup"):
            time.sleep(random.uniform(0.005, 0.05))
        with tracer.start_as_current_span("compute"):
            time.sleep(random.uniform(0.001, 0.02))
        # 5% error rate
        if random.random() < 0.05:
            status = "500"
        else:
            status = "200"
    REQUESTS.labels("GET", "/order/<id>", status).inc()
    LATENCY.labels("/order/<id>").observe(time.time() - start)
    return ({"order_id": order_id}, int(status))
 
 
@app.route("/metrics")
def metrics():
    return generate_latest(), 200, {"Content-Type": CONTENT_TYPE_LATEST}
 
 
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

What each block does

Anatomy of the stack

docker-compose · 3 services
Prometheus (metrics store), Grafana (viewer), OTel Collector (universal receiver). One command to reproduce the whole stack anywhere.
infra
prometheus.yml scrape_configs
Prometheus polls each target's /metrics endpoint every 15s. host.docker.internal is the Docker Desktop shortcut for 'the host running Docker'.
scrape
alert_rules.yml · HighErrorRate
The canonical 'error budget burn' alert. Reads: '5xx rate divided by total rate exceeds 1% for 5 minutes → page'.
alert
otel-config · receivers/processors/exporters
Universal pipeline. Change 'exporters' from prometheus to datadog and you've migrated vendors without changing app code.
collector
batch processor
Batches spans before export. Without this, you're doing one gRPC call per span — Collector melts under load.
efficiency
OTLPSpanExporter
Sends spans to the Collector via OTLP gRPC. In a real deployment the Collector runs as a DaemonSet in K8s.
SDK
Try itBuild a 'four golden signals' Grafana dashboard for this app

Copy this dashboard JSON snippet for a starting panel:

{
  "type": "timeseries",
  "title": "Request rate by status",
  "targets": [{
    "expr": "sum by (status) (rate(http_requests_total[1m]))",
    "legendFormat": "{{status}}"
  }]
}

Add three more panels using the queries above. Save as service-overview.json. Commit it to your infra repo — dashboards must be code-controlled, not click-created (or you'll lose them on every Grafana upgrade).

💡 Hint · Four panels: (1) Latency: histogram_quantile(0.99, ...) for p99. (2) Traffic: sum(rate(http_requests_total[1m])) for total RPS. (3) Errors: sum by (status) (rate(http_requests_total{status=~'5..'}[5m])). (4) Saturation: process_resident_memory_bytes / go_memstats_heap_sys_bytes or similar. Save the dashboard JSON — commit to the repo so it can be recreated.

(d) Production reality · 15 min

War story SoundCloudOrigin story
🔥 What broke

Around 2012 SoundCloud was outgrowing its metrics setup (Graphite + Statsd). Cardinality was destroying Graphite; Statsd's push model was overloading the server during traffic spikes.

🧯 The fix
Julius Volz and Matt Proud (both ex-Google) built a from-scratch metrics system inspired by Google's internal Borgmon. Pull-based (so Prometheus, not clients, controls the scrape rate). Label-oriented data model. Custom TSDB. Called it Prometheus. Open-sourced in 2015. Now runs half the industry's observability.
🎓 Lesson to steal
The 'pull' model was the killer feature. In a large K8s cluster, pods come and go — a push system needs constant config. A pull system just needs service discovery (K8s API tells Prometheus 'these are the live pods'), and dead pods self-clean by simply not being scraped.
Post-mortem
War story Common failure — cardinality explosion killing PrometheusDocumented across hundreds of engineering blogs and postmortems
🔥 What broke
Team adds user_id as a label to their requests_total metric to enable 'per-customer analytics'. Prometheus series count jumps from 50K to 5M overnight. Query latency goes from ms to minutes. Memory usage triples. Some queries OOM the Prometheus pod.
🧯 The fix
Immediate: use metric_relabel_configs to drop the offending label at scrape time. Long-term: move per-user analytics to a purpose-built OLAP system (ClickHouse, BigQuery) or an event-based observability tool (Honeycomb). Prometheus is optimised for low-cardinality aggregate metrics — don't fight it.
🎓 Lesson to steal
Cardinality is Prometheus's Achilles heel. Every label you add multiplies the series count by that label's cardinality. IDs (user_id, request_id, order_id) belong in logs / traces / events, NEVER in metric labels.
Post-mortem
War story Grafana Labs· 2022Open-sourced Mimir for horizontal Prometheus
🔥 What broke
As adoption scaled, teams hit the single-node Prometheus wall: ~1-2M active series before performance degraded. Global companies needed 100M+. Cortex was the earlier solution (from Grafana + Weaveworks) but got complicated.
🧯 The fix
Grafana Labs open-sourced Mimir — a horizontally scalable, multi-tenant Prometheus-compatible backend. Same PromQL, same scrape config, but distributes series across many nodes with a shuffle-sharding trick. Runs at 1B+ series in production. Also AWS AMP and Google Cloud Managed Prometheus solve the same problem in managed form.
🎓 Lesson to steal
Single-node Prometheus is perfect until it isn't. When you outgrow it, the migration to Mimir/Cortex/Thanos is nearly zero-code because they speak the same protocols. This is why 'invest in Prometheus' is safe advice: even at massive scale, your queries and dashboards keep working.
Post-mortem

Where this shows up in the rest of the plan

Prometheus + Grafana + OTel is the substrate for everything reliability-related
S077 · 3 pillars
The conceptual foundation; this session is the tools.
S079 · SLIs / SLOs
SLIs are Prometheus queries; SLOs are Prometheus alerts.
S080 · Incident response
Dashboards + alerts are the muscle memory of incident response.
S090 · Kubernetes
kube-prometheus-stack is the default K8s observability install.
S089 · Rate limiting
Saturation metrics from Prometheus tell rate limiters when to kick in.
S056 · Docker
You just ran the stack in docker-compose — the same pattern extends to prod.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

Teach these three, no notes:

  1. Prometheus's pull model — why is it a big deal?
  2. The PromQL trilogy — rate, histogram_quantile, sum by. One-sentence what each does.
  3. What does the OTel Collector actually do? — the vendor-neutral pitch.

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.