Skip to main content

🔥 Prometheus and Grafana Monitoring Stack

This is where the last two lessons come together. Prometheus collects and stores your metrics; Grafana turns them into dashboards; Alertmanager pages the right person when something breaks. It is the de facto open-source monitoring stack for cloud-native systems — and by the end of this lesson you will have stood the whole thing up with Docker Compose.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Describe Prometheus's pull-based architecture and time-series data model
  • Choose correctly between the four metric types — counter, gauge, histogram, summary
  • Write useful PromQL queries for rate, error ratio, and percentile latency
  • Stand up Prometheus + Grafana + Alertmanager with Docker Compose and provisioning
  • Build a Grafana dashboard and route alerts by severity

Estimated Time: 40–50 minutes  •  Difficulty: Intermediate

Hands-on: Run the full stack locally, scrape a service, and build a RED dashboard.

In This Lesson

The Complementary Pair

Prometheus and Grafana do different jobs, and that is exactly why they pair so well.

  • Prometheus is the meter — it continuously measures, stores time-series data, evaluates rules, and fires alerts. Originally built at SoundCloud, it is now a graduated CNCF project.
  • Grafana is the display — it queries data (from Prometheus and many other sources) and turns it into dashboards, and can alert too.
🏠 A smart-home analogy: Prometheus is the electricity meter on the wall — always measuring, recording, and tripping the breaker at a threshold. Grafana is the smart-home app on your phone — pulling readings from the meter and every other sensor into one clear screen.
flowchart LR A[App /metrics] --> P[Prometheus] N[node_exporter] --> P P --> G[Grafana dashboards] P --> AM[Alertmanager] L[Loki logs] --> G AM --> Slack[Slack] AM --> PD[PagerDuty]

How Prometheus Works

Prometheus is pull-based: instead of your apps pushing data to it, Prometheus reaches out and scrapes a /metrics HTTP endpoint on each target at a fixed interval. This makes targets simple (they just expose a page) and lets Prometheus automatically detect a target that has gone down.

flowchart TD SD[Service discovery] --> SC[Scraper] T1[App exporter] --> SC T2[node_exporter] --> SC SC --> TSDB[(Time-series DB)] TSDB --> Q[PromQL engine] AR[Alert rules] --> TSDB AR --> AM[Alertmanager] Q --> UI[Web UI & Grafana]

The core loop:

  1. Discover targets — statically listed, or dynamically via Kubernetes, Consul, or cloud APIs.
  2. Scrape each target's /metrics endpoint on the scrape_interval.
  3. Store samples in a local time-series database optimized for this shape of data.
  4. Query with PromQL, and evaluate alert rules on the same data.
  5. Fire triggered alerts to Alertmanager, which dedupes, groups, and routes them.

📖 The time-series data model

Every sample belongs to a time series uniquely identified by a metric name plus a set of labels. For example:

http_requests_total{method="GET", status="200", route="/api/users"}

Changing any label value creates a distinct series — which is why bounded label cardinality (from the monitoring lesson) matters so much here.

The Four Metric Types

Picking the right metric type is the most common early mistake. Here is when to use each:

TypeBehaviorUse forExample
CounterOnly goes up (or resets to 0)Cumulative totalshttp_requests_total
GaugeGoes up and downCurrent snapshotsmemory_usage_bytes
HistogramBuckets observationsDistributions (query-side quantiles)http_request_duration_seconds
SummaryPre-computes quantilesClient-side percentilesrequest_latency_summary

💡 Histogram or summary?

Prefer histograms in almost all cases. Because they store raw bucket counts, you can compute any percentile at query time and aggregate across many instances. Summaries pre-compute quantiles on the client, which is cheaper to query but cannot be aggregated across instances — a p95 of five servers' p95s is meaningless.

Declaring one of each with prom-client:

import client from 'prom-client';
const register = new client.Registry();

// Counter — cumulative request count
const requests = new client.Counter({
  name: 'http_requests_total',
  help: 'Total HTTP requests',
  labelNames: ['method', 'route', 'status'],
  registers: [register],
});

// Gauge — a value that moves both ways
const activeSessions = new client.Gauge({
  name: 'active_sessions',
  help: 'Current active user sessions',
  registers: [register],
});

// Histogram — bucketed durations, quantiles computed at query time
const duration = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'Request duration in seconds',
  labelNames: ['method', 'route'],
  buckets: [0.1, 0.3, 0.5, 1, 3, 5],
  registers: [register],
});

requests.inc({ method: 'GET', route: '/api', status: '200' });
activeSessions.set(42);
const stop = duration.startTimer({ method: 'GET', route: '/api' });
// ... handle request ...
stop();

PromQL Essentials

PromQL is a functional query language for selecting and aggregating time series. A few patterns cover most day-to-day needs.

Selecting series

# All series for this metric
http_requests_total

# Filter by labels (= exact, != negate, =~ regex)
http_requests_total{status="200", method="GET"}
http_requests_total{status=~"5.."}

# A range vector — the last 5 minutes of samples
http_requests_total[5m]

The functions you will use constantly

# rate() — per-second average over a window; the go-to for counters
rate(http_requests_total[5m])

# Aggregate across series with sum/avg/max ... by (label)
sum(rate(http_requests_total[5m])) by (route)

# Error rate as a percentage
sum(rate(http_requests_total{status=~"5.."}[5m]))
  / sum(rate(http_requests_total[5m])) * 100

# 95th percentile latency from a histogram
histogram_quantile(0.95,
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le))

# CPU utilization per instance (from node_exporter)
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)

⚠️ Always rate() a counter before graphing it

A raw counter only ever climbs, so its graph is a meaningless up-and-to-the-right line. rate() converts it into the per-second change — the thing you actually care about — and correctly handles counter resets on restart.

Standing Up the Stack

Here is a complete, runnable Docker Compose file bringing up Prometheus, Grafana, Alertmanager, and node_exporter together.

# docker-compose.yml
services:
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
      - ./prometheus/rules:/etc/prometheus/rules
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.retention.time=15d'
    ports: ["9090:9090"]
    restart: unless-stopped

  alertmanager:
    image: prom/alertmanager:latest
    volumes:
      - ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml
    ports: ["9093:9093"]
    restart: unless-stopped

  node-exporter:
    image: prom/node-exporter:latest
    ports: ["9100:9100"]
    restart: unless-stopped

  grafana:
    image: grafana/grafana:latest
    depends_on: [prometheus]
    ports: ["3000:3000"]
    volumes:
      - grafana-storage:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning
    environment:
      - GF_SECURITY_ADMIN_PASSWORD__FILE=/run/secrets/grafana_admin
    restart: unless-stopped

volumes:
  grafana-storage:

The Prometheus config tells it what to scrape and where Alertmanager lives:

# prometheus/prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

alerting:
  alertmanagers:
    - static_configs:
        - targets: ['alertmanager:9093']

rule_files:
  - 'rules/*.yml'

scrape_configs:
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']

  - job_name: 'node'
    static_configs:
      - targets: ['node-exporter:9100']

  - job_name: 'web-app'
    static_configs:
      - targets: ['web-app:3000']

Rather than clicking through Grafana's UI, provision the data source as code so the stack is reproducible:

# grafana/provisioning/datasources/prometheus.yml
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true

💡 In Kubernetes, use the Operator

For real clusters, the kube-prometheus-stack Helm chart installs Prometheus, Grafana, Alertmanager, and dozens of ready-made dashboards, and lets you define scrape targets declaratively with ServiceMonitor resources. Docker Compose is perfect for learning; the Operator is how it is run in production.

Building Dashboards

A Grafana dashboard is a grid of panels, each backed by a query. Panels come in types — time series, stat, gauge, bar, table, heatmap, logs. The art is choosing panels that answer a question at a glance.

A great first dashboard is a RED dashboard for your service — one row, three panels, the whole health of the service in one look:

# Panel 1 — Rate (requests/sec by route), a "time series" panel
sum(rate(http_requests_total[5m])) by (route)

# Panel 2 — Errors (% of requests failing), a "stat" panel with red threshold
sum(rate(http_requests_total{status=~"5.."}[5m]))
  / sum(rate(http_requests_total[5m])) * 100

# Panel 3 — Duration (p50/p95/p99), a "time series" panel with three queries
histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))
histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))

✅ Make dashboards reusable with variables

Add a template variable so one dashboard serves every instance or route. Define a query variable $route with label_values(http_requests_total, route), then reference {route="$route"} in each panel. A dropdown at the top now filters the whole board.

Do not build everything from scratch — Grafana's dashboard library has thousands of community dashboards (the Node Exporter Full dashboard, ID 1860, is a classic) you can import by ID and adapt.

Alerting with Alertmanager

Prometheus evaluates alert rules and, when one fires, hands it to Alertmanager, whose job is to make alerts humane: deduplicate identical alerts, group related ones, silence during maintenance, and route by severity to the right channel.

Recall the alert rules from the monitoring lesson — they live in the rules/ directory Prometheus loads. Alertmanager then decides who gets paged:

# alertmanager/alertmanager.yml
global:
  resolve_timeout: 5m

route:
  group_by: ['alertname', 'job']
  group_wait: 30s        # wait to batch related alerts
  group_interval: 5m
  repeat_interval: 4h    # don't re-page every minute
  receiver: 'team-slack' # default destination
  routes:
    - matchers: [severity="critical"]
      receiver: 'pagerduty'   # wake someone up
    - matchers: [severity="warning"]
      receiver: 'team-slack'  # just notify

receivers:
  - name: 'team-slack'
    slack_configs:
      - api_url: ''
        channel: '#alerts'
        title: '{{ .CommonAnnotations.summary }}'

  - name: 'pagerduty'
    pagerduty_configs:
      - routing_key: ''
flowchart LR P[Prometheus evaluates rules] -->|fires| AM[Alertmanager] AM -->|dedupe + group| R{Route by severity} R -->|critical| PD[PagerDuty] R -->|warning| SL[Slack]

⚠️ Route by severity, not by volume

Send only critical, wake-someone-up alerts to PagerDuty; route warnings to a Slack channel people check during the day. Mixing them trains the team to ignore the pager — the exact failure mode good alerting is meant to prevent.

Hands-on Exercise

🏋️ Run the stack & build a RED dashboard

Objective: Bring up the full monitoring stack, scrape an instrumented service, and visualize its health.

Instructions:

  1. Create the directory layout and the four config files from the "Standing Up the Stack" section, then run docker compose up -d.
  2. Add the instrumented Express app (from the monitoring lesson) as a web-app service in Compose and confirm it exposes /metrics.
  3. Open Prometheus at http://localhost:9090/targets and verify all targets are UP.
  4. Open Grafana at http://localhost:3000, confirm the Prometheus data source is provisioned, and build a RED dashboard with the three queries shown earlier.
  5. Generate traffic against the app and watch the panels move in real time.
💡 Hint

If a target shows DOWN, the usual cause is the service name / port in scrape_configs not matching the Compose service. Prometheus resolves web-app:3000 by the Compose service name, so they must match exactly and be on the same network.

✅ Example solution

A verifying PromQL query you can paste into Grafana's Explore view once traffic is flowing:

# Should show a non-zero, moving line per route
sum(rate(http_requests_total{job="web-app"}[1m])) by (route)

Your finished dashboard: Panel 1 (Rate) shows per-route request rate rising as you send traffic; Panel 2 (Errors) sits near 0% until you hit an error route; Panel 3 (Duration) plots p50/p95/p99 latency. That single row is the health of the service at a glance — exactly what an on-call engineer opens first.

Production Best Practices

✅ Do❌ Avoid
Provision data sources & dashboards as codeHand-clicking config that dies with the container
Set a sensible TSDB retention windowStoring raw metrics forever on local disk
Prefer histograms; keep cardinality boundedSummaries you later try to aggregate
Enable auth & TLS; use secrets for passwordsShipping admin/admin to production
Monitor the monitoring stack itselfAssuming Prometheus never runs out of disk

Scaling beyond one Prometheus

A single Prometheus is not horizontally scalable and is bounded by one machine's disk and RAM. When you outgrow it, reach for:

  • Functional sharding — several Prometheus servers, each owning part of the fleet.
  • Federation — a higher-level Prometheus scraping aggregated series from lower ones for a global view.
  • Thanos, Cortex, or Grafana Mimir — systems that add long-term object-storage, a global query view, and high availability on top of Prometheus.

Summary & Quiz

🎉 Key Takeaways

  • Prometheus pull-scrapes /metrics endpoints into a time-series database; Grafana visualizes it.
  • Use a counter for totals, a gauge for snapshots, and a histogram for distributions (prefer it over summaries).
  • PromQL: rate() counters, aggregate by (label), and use histogram_quantile() for percentiles.
  • Stand the stack up with Docker Compose and provision data sources as code for reproducibility.
  • Alertmanager dedupes, groups, and routes alerts by severity — critical to a pager, warnings to chat.

🎯 Quick Quiz

Question 1: Prometheus uses a "pull" model. What does that mean?

Question 2: You want p95 latency aggregated across ten instances of a service. Which metric type should you have used?

Question 3: What is Alertmanager's primary job, distinct from Prometheus?

📚 Further Reading

🚀 What's Next?

You have now covered the full observability triad — metrics, logs, and dashboards with alerting. Next comes the weekend project, where you will pull everything from this DevOps & Deployment module together into one deployed, monitored application.

🎉 Module milestone!

You can instrument, log, visualize, and alert on a production system. Time to put it all together.