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.
🎯 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.
- 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
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.
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
- 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
- 2012Prometheus at SoundCloudJulius Volz + Matt Proud build a metrics system inspired by Google's Borgmon. Open-sourced.
- 2014Grafana forks from KibanaTorkel Ödegaard wants dashboards that work across many data sources, not just Elasticsearch. Grafana ships.
- 2016Prometheus joins CNCFSecond project after Kubernetes. Becomes the de-facto monitoring for K8s.
- 2018OpenTracing + OpenCensusTwo competing vendor-neutral trace/metric libraries. Community fatigue growing.
- 2019OpenTelemetry mergerOpenTracing + OpenCensus combine into OpenTelemetry. One SDK for metrics, logs, traces.
- 2021OTel Metrics GANow the full trilogy is stable. Most new instrumentation is via OTel.
- 2024Prometheus 3 + OTel-nativePrometheus 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
http_requests_total — value(s) at 'now'.
http_requests_total[5m] — the values across the last 5 minutes.
rate(http_requests_total[5m]) — per-second rate over the range. THIS is what you almost always want, not the raw counter.
sum by(status) (rate(http_requests_total[5m])) — aggregate away all labels except 'status'.
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
Vendor comparison
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
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
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
"Prometheus scrapes my app, so I have my metrics. If I need per-user or per-request detail, I just add a label."
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.
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.
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.
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.
- 1A 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
- 2With 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
- 3With 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 - 4Pull 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
- 5Finally, 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 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.
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.
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.
How do you make per-request latency answerable: histogram buckets, native/exponential histograms, or summaries (client-computed quantiles)?
histogram_quantile over a sum. Cheap to compute at write time.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
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).
(d) Production reality · 15 min
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.
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.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.Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Teach these three, no notes:
- Prometheus's pull model — why is it a big deal?
- The PromQL trilogy — rate, histogram_quantile, sum by. One-sentence what each does.
- 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.