Module 6 ended on an awkward question: the platform is secure, but do we know whether it works? In a monolith, "it works" meant looking at one process and one log. Kilometre Zero has six services, three databases, a Kafka cluster, Redis, MinIO, a gateway and an IdP, spread across dozens of containers: nobody can watch all of that at once, and when something goes wrong it rarely shouts about it. An inventory service that answers in 900 ms instead of 40 is still "working"; a consumer of orders.events that has fallen three hours behind raises no exception; a Vault certificate that expires at 03:00 on a Sunday only gets noticed when the first order of the morning fails. This lesson is about making the platform observable through its first pillar, metrics: what to measure, how to collect it with Prometheus, how to put into numbers what Kilometre Zero promises Anna, and how to raise the alarm before she finds out. Logs and traces, the other two pillars, are the subject of lesson 07-02.

Contents

  1. Observability and its three pillars
  2. What to measure: infrastructure, platform, application and business
  3. Metric types and the cardinality problem
  4. Prometheus: the pull model, exporters and discovery
  5. Essential PromQL: rates, percentiles and aggregations
  6. SLI, SLO, SLA and the error budget
  7. Alerts: symptoms, burn rate and Alertmanager
  8. Grafana dashboards and active monitoring
  9. Common mistakes and tips
  10. Exercises and solutions
  11. Conclusion

  1. Observability and its three pillars

Monitoring means checking things we already know can fail: the disk fills up, the process dies, latency climbs. Observability is a property of the system: the ability to answer questions we had not anticipated ("why have Lleida orders containing fresh-cheese been taking twice as long since yesterday?") from what the system emits to the outside. In a distributed system the difference matters because unanticipated questions are the norm: the interesting failures are combinations of parts, not isolated parts.

The three signals a system emits, which together provide observability, are:

Pillar What it is Question it answers Cost per event Lesson
Metrics Numbers aggregated over time (counters, measurements) "How much? How often? Is it worse than an hour ago?" Very low (one series per label combination, not per event) 07-01
Logs Discrete events with context "What exactly happened to order P-2026-000125?" High (one record per event) 07-02
Traces The path of a request through several services "Where did this request's time go? What called what?" Medium-high (sampled) 07-02

Metrics are the starting point because they are the only one of the three that scales with the number of series, not with traffic: counting 10 orders per minute costs the same as counting 10,000. They are the signal that fires alerts and the one that draws the Grape Harvest Week dashboard. Logs and traces come in afterwards, once the metric says "something is wrong" and you need to find out what.

  1. What to measure: infrastructure, platform, application and business

A common mistake is to start with infrastructure (CPU, memory) because that is what agents give you out of the box, and end up with a hundred graphs that don't tell you whether Anna can buy anything. It is better to think in four layers, from the bottom up, and pick a few meaningful metrics in each.

2.1 Infrastructure

This is what node_exporter measures on each machine or cAdvisor on each container: CPU, memory, disk (space and I/O latency), network (bytes, errors, dropped packets). Brendan Gregg's USE method applies to these resources: for each one, Utilisation (percentage of time busy), Saturation (queued work that doesn't fit: run queue, swap, disk queue) and Errors.

2.2 Platform

The pieces in the middle have metrics of their own that give early warning of application problems:

  • Kafka: the lag of each consumer group (messages produced minus messages consumed, per partition). Growing lag in analytics is tolerable; in delivery it means the dashboard shows stale positions for van-3. Also partitions without an in-sync replica (covered in 07-03).
  • PostgreSQL: active connections against the maximum (max_connections), transactions per second, how far the orders-db-replica replica lags behind the primary, table sizes and locks being waited on.
  • Redis: the hit ratio of the catalogue cache (hits / (hits + misses)), memory used against the limit, evicted keys, command latency.
  • Cassandra: read and write latency per table, pending compactions, nodes seen as down by gossip.

2.3 Application: the four golden signals

In Site Reliability Engineering, Google popularised the four golden signals, the ones to watch on any service that handles requests:

  1. Latency: how long it takes to respond, distinguishing successful requests from failed ones (a fast error is not "good latency").
  2. Traffic: how much demand it receives (requests per second, messages consumed per second).
  3. Errors: what fraction of requests fail, whether explicitly (5xx, gRPC UNAVAILABLE) or implicitly (it answered 200 but took longer than agreed).
  4. Saturation: how "full" the service is: busy threads, pool connections in use, work queue.

The RED method (Tom Wilkie) is the version for services: Rate, Errors, Duration. It matches the first three golden signals and is what we instrument uniformly across every service in km0/.

Method Applies to Measures Example at Kilometre Zero
RED Services (things that handle requests) Rate, Errors, Duration orders: requests/s to POST /orders, % of 5xx, p99 duration
USE Resources (things with finite capacity) Utilization, Saturation, Errors Kafka broker disk: 85% used, I/O queue of 12, 0 errors
Golden signals User-facing systems Latency, traffic, errors, saturation All of the above plus "connection pool to km0_orders at 90%"

Golden signals per service:

Service Latency Traffic Errors Saturation
catalog p99 of GET /catalog/* (target < 200 ms) requests/s per market % 5xx; Redis failures Redis hit ratio; PostgreSQL connections
orders p99 of POST /orders (< 500 ms) orders/min % 5xx; compensated sagas / total km0_orders pool; busy HTTP threads
inventory p99 of ReserveStock (< 100 ms) gRPC calls/s per method % UNAVAILABLE + DEADLINE_EXCEEDED connections to km0_inventory; replication lag of inv-bcn/inv-vlc
payments p99 of Charge (< 2 s, depends on the gateway) charges/min % rejected because of a technical error (not a declined card) in-flight calls to the gateway
delivery delay between a position being emitted and displayed positions/s (140 couriers × 1/5 s ≈ 28/s) rejected messages lag of the delivery.positions consumer
analytics duration of the km0_daily_sales DAG events/s consumed failed Airflow tasks lag on orders.events; busy Spark executors

2.4 Business

Business metrics are the ones that tell Jordan Hall and Martha Hill whether the platform is serving its purpose, and they often catch failures that technical metrics miss: if orders answers 200 in 80 ms but orders per minute drop to zero at 11:00 on a Saturday, something upstream has broken (the front end, Kong, Keycloak). For Kilometre Zero: orders created per minute (per market and per producer), payment rejection rate, average order value, failed stock reservations per product, on-time deliveries. They are instrumented exactly like the technical ones, in the same Prometheus.

  1. Metric types and the cardinality problem

Prometheus (and OpenTelemetry Metrics, which uses the same conceptual model) distinguishes four types:

Type What it is Can only Example Typical query
Counter Cumulative count since the process started Go up (resets to 0 if the process restarts) km0_orders_total{status="confirmed"} rate(...[5m])
Gauge Instantaneous value Go up and down km0_stock_units{product="pink-tomato"}, active connections raw value, avg_over_time
Histogram Distribution in cumulative buckets + sum + count Go up km0_request_duration_seconds_bucket{le="0.5"} histogram_quantile(0.99, ...)
Summary Quantiles computed on the client Go up ..._duration{quantile="0.99"} raw value (cannot be aggregated across instances)

Two clarifications that prevent a lot of mistakes:

  • A counter is never read raw. km0_orders_total is 1,284,522 and that tells you nothing; what matters is its derivative: rate(km0_orders_total[5m]) = orders per second over the last 5 minutes. Prometheus handles restarts (when the counter goes down, it assumes a restart and does not produce a negative rate).
  • Histogram versus summary: the histogram stores how many observations fell below each bound (le="0.1", le="0.25", le="0.5", ...). Percentiles are computed on the server and, above all, can be aggregated across instances: the p99 of orders with 3 replicas is obtained by adding up buckets. A summary computes the p99 in each process, and there is no correct way to combine three p99s (the average of percentiles is not a percentile). In a distributed system, barring very specific cases, you use a histogram.

Cardinality

Each distinct combination of name + labels is a time series that Prometheus keeps in memory and on disk. km0_request_duration_seconds{service, method, code} with 6 services × 10 methods × 5 codes × 12 buckets = 3,600 series: fine. If someone decides to add order_id as a label, every order creates a new series per bucket: a million orders means twelve million series, and Prometheus dies. The rule: a label may only take a small, bounded set of values (service, method, status code, market, order status). Never entity identifiers (order_id, user, request_id), client IP addresses, or paths with parameters that haven't been normalised (/orders/P-2026-000123 must be recorded as /orders/{id}). Anything that needs the identifier goes to logs or traces (07-02).

An interesting borderline case is km0_stock_units{product}: with a few hundred products it is acceptable; with a hundred thousand it is not. At Kilometre Zero it is limited to the products in active campaigns and, for the rest, a gauge aggregated per producer is published.

  1. Prometheus: the pull model, exporters and discovery

Prometheus works in pull mode: it doesn't wait for services to send it data; instead, every 15 seconds (configurable) it issues a GET /metrics to each target and stores what it finds. The format is plain text:

# HELP km0_orders_total Orders processed by final status
# TYPE km0_orders_total counter
km0_orders_total{status="confirmed"} 1284522
km0_orders_total{status="rejected_stock"} 3311
km0_orders_total{status="rejected_payment"} 8720

Pulling has important consequences in a distributed system:

  • Prometheus knows when a target isn't responding: the metric up{job="orders", instance="orders-2:8000"} is 0. With a push model, a dead service simply stops sending and you have to infer the absence.
  • Services don't need to know Prometheus's address or manage send queues.
  • It needs to discover its targets: in docker-compose listing them is enough; in Kubernetes (07-05) it discovers them by asking the API; in the cloud, by asking the provider's API. For short-lived jobs (a Job that lasts 3 seconds) there is the Pushgateway, which is the exception, not the rule.

Components that don't expose /metrics themselves are covered by exporters: small processes that translate the state of PostgreSQL, Kafka, Redis and so on into the Prometheus format. At Kilometre Zero:

flowchart LR
    subgraph services["km0 services (prometheus_client)"]
        C[catalog :8000/metrics]
        O[orders :8000/metrics]
        I[inventory :9464/metrics]
        D[delivery]
    end
    subgraph exporters["Exporters"]
        PGX[postgres_exporter<br/>km0_inventory, orders-db-*]
        KX[kafka_exporter<br/>lag per group]
        RX[redis_exporter<br/>hit ratio, memory]
        NX[node_exporter<br/>CPU, disk, network]
        BX[blackbox_exporter<br/>HTTP/TLS probes]
    end
    PROM[(Prometheus<br/>scrape every 15 s)]
    AM[Alertmanager]
    G[Grafana]
    OPS[#km0-operations channel<br/>Jordan, Martha]
    services --> PROM
    exporters --> PROM
    PROM -- alerting rules --> AM
    AM --> OPS
    G -- PromQL --> PROM

4.1 Instrumenting the services: services/common/metrics.py

Every service shares one module with the common metrics, so that names and labels are identical and the dashboards work for any of them.

# km0/services/common/metrics.py
"""Prometheus metrics shared by the Kilometre Zero services."""
import time
from contextlib import contextmanager

from prometheus_client import Counter, Gauge, Histogram, start_http_server

# Buckets designed for service latencies: from 5 ms to 10 s.
# Each bucket is "how many observations fell below this bound".
LATENCY_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10)

ORDERS_TOTAL = Counter(
    "km0_orders_total",
    "Orders processed by final status",
    ["status"],  # confirmed | rejected_stock | rejected_payment | compensated
)

REQUEST_DURATION = Histogram(
    "km0_request_duration_seconds",
    "Duration of the requests handled (HTTP or gRPC)",
    ["service", "method", "code"],
    buckets=LATENCY_BUCKETS,
)

STOCK_UNITS = Gauge(
    "km0_stock_units",
    "Units available per product (campaign products only)",
    ["product"],
)

REQUESTS_IN_FLIGHT = Gauge(
    "km0_requests_in_flight",
    "Requests being handled right now (saturation)",
    ["service"],
)


@contextmanager
def measure(service: str, method: str):
    """Times a block and records it with its exit code.

    Usage:
        with measure("orders", "POST /orders") as ctx:
            ...
            ctx["code"] = "201"
    The code is set inside the block; if an exception is raised, 500 is recorded.
    """
    ctx = {"code": "200"}
    REQUESTS_IN_FLIGHT.labels(service=service).inc()
    start = time.perf_counter()
    try:
        yield ctx
    except Exception:
        ctx["code"] = "500"
        raise
    finally:
        duration = time.perf_counter() - start
        REQUEST_DURATION.labels(
            service=service, method=method, code=ctx["code"]
        ).observe(duration)
        REQUESTS_IN_FLIGHT.labels(service=service).dec()


def expose_metrics(port: int = 9464) -> None:
    """Starts a minimal HTTP server with /metrics in a separate thread.

    Used by the services that have no HTTP server of their own (inventory, delivery).
    Those that do (orders, catalog) mount /metrics on their app.
    """
    start_http_server(port)

Points worth understanding:

  • Counter, Gauge and Histogram are process-wide global registrations: they are declared once, at module level, and any part of the service uses them. prometheus_client exposes them all together on /metrics.
  • .labels(...) selects the specific series; the first time a combination is used, it is created. That is why you have to control which values get passed in (cardinality).
  • measure records the code even when there is an exception: without that except, requests that fail with an internal error would not show up in the histogram, and latency would look better than it is.
  • REQUESTS_IN_FLIGHT is the cheapest saturation signal to obtain: how many requests there are at once. If in orders it exceeds the number of server threads, the next ones wait in a queue.

4.2 gRPC interceptor in inventory

inventory already has AuthInterceptor and ServiceInterceptor (06-04). We add one more, which wraps every call and turns the gRPC status code into a label:

# km0/services/inventory/metrics_interceptor.py
import grpc
from services.common.metrics import measure


class MetricsInterceptor(grpc.ServerInterceptor):
    """Records the duration and code of every RPC of the inventory service."""

    def intercept_service(self, continuation, handler_call_details):
        # handler_call_details.method is "/km0.inventory.v1.Inventory/ReserveStock"
        method = handler_call_details.method.rsplit("/", 1)[-1]
        handler = continuation(handler_call_details)
        if handler is None or not handler.unary_unary:
            return handler  # streaming: would be measured differently

        original = handler.unary_unary

        def wrapped(request, context):
            with measure("inventory", method) as ctx:
                try:
                    response = original(request, context)
                    # If the handler called context.abort(), we never get here.
                    ctx["code"] = "OK"
                    return response
                except grpc.RpcError as e:
                    ctx["code"] = e.code().name  # UNAVAILABLE, NOT_FOUND, ...
                    raise

        return grpc.unary_unary_rpc_method_handler(
            wrapped,
            request_deserializer=handler.request_deserializer,
            response_serializer=handler.response_serializer,
        )

And at server start-up the order of the interceptors matters: metrics first, so that calls rejected by authentication are measured too.

# km0/services/inventory/server.py (excerpt)
from services.common.metrics import expose_metrics, STOCK_UNITS

server = grpc.server(
    futures.ThreadPoolExecutor(max_workers=32),
    interceptors=[MetricsInterceptor(), AuthInterceptor(), ServiceInterceptor()],
)
expose_metrics(port=9464)

# After each adjustment or confirmed reservation, the service refreshes the gauge:
def publish_stock(product: str, units: int) -> None:
    if product in CAMPAIGN_PRODUCTS:  # pink-tomato, aged-cheese, crianza-wine...
        STOCK_UNITS.labels(product=product).set(units)

4.3 HTTP middleware in orders and the /metrics endpoint

orders is a FastAPI application; a middleware measures every request and /metrics is mounted on the same app:

# km0/services/orders/app.py (excerpt)
from fastapi import FastAPI, Request
from prometheus_client import make_asgi_app
from services.common.metrics import measure, ORDERS_TOTAL

app = FastAPI()
app.mount("/metrics", make_asgi_app())  # exposes every metric in the process


@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
    # Normalise the path: /orders/P-2026-000123 -> /orders/{id}
    route = request.scope.get("route")
    template = route.path if route else "unknown"
    with measure("orders", f"{request.method} {template}") as ctx:
        response = await call_next(request)
        ctx["code"] = str(response.status_code)
        return response


@app.post("/orders", status_code=201)
async def create_order(order: OrderInput):
    result = await order_saga.execute(order)   # 03-05
    ORDERS_TOTAL.labels(status=result.status).inc()
    return result

Using the route template (/orders/{id}) instead of the real URL is the cardinality rule put into practice.

4.4 observability/prometheus.yml

# km0/observability/prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s     # how often the alerting rules are evaluated
  external_labels:
    environment: production

rule_files:
  - /etc/prometheus/alerts.yml

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

scrape_configs:
  - job_name: orders
    static_configs:
      - targets: ["orders-1:8000", "orders-2:8000", "orders-3:8000"]
  - job_name: catalog
    static_configs:
      - targets: ["catalog-1:8000", "catalog-2:8000"]
  - job_name: inventory
    static_configs:
      - targets: ["inventory-1:9464", "inventory-2:9464"]
  - job_name: delivery
    static_configs:
      - targets: ["delivery:9464"]

  - job_name: postgres
    static_configs:
      - targets: ["postgres-exporter-inventory:9187",
                  "postgres-exporter-orders-primary:9187",
                  "postgres-exporter-orders-replica:9187"]
  - job_name: kafka
    static_configs:
      - targets: ["kafka-exporter:9308"]
  - job_name: redis
    static_configs:
      - targets: ["redis-exporter:9121"]
  - job_name: nodes
    static_configs:
      - targets: ["node-exporter:9100"]

  # Synthetic probes: Prometheus asks the blackbox_exporter to check each URL.
  - job_name: blackbox_http
    metrics_path: /probe
    params:
      module: [http_2xx]
    static_configs:
      - targets:
          - https://api.km0.example/api/v1/catalog/markets
          - https://api.km0.example/health/live
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target      # the URL is passed as the ?target= parameter
      - source_labels: [__param_target]
        target_label: instance            # and is kept as a label
      - target_label: __address__
        replacement: blackbox-exporter:9115   # which is what actually gets scraped

In docker-compose.yml the observability stack is added alongside the services:

# km0/docker-compose.yml (excerpt)
  prometheus:
    image: prom/prometheus:v2.53.0
    volumes:
      - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./observability/alerts.yml:/etc/prometheus/alerts.yml:ro
      - prometheus-data:/prometheus
    command: ["--config.file=/etc/prometheus/prometheus.yml",
              "--storage.tsdb.retention.time=30d"]
    ports: ["9090:9090"]
  alertmanager:
    image: prom/alertmanager:v0.27.0
    volumes:
      - ./observability/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
    ports: ["9093:9093"]
  grafana:
    image: grafana/grafana:11.1.0
    volumes:
      - ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
      - ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
    environment:
      GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin
    ports: ["3000:3000"]
  postgres-exporter-inventory:
    image: prometheuscommunity/postgres-exporter:v0.15.0
    environment:
      DATA_SOURCE_NAME: "postgresql://exporter@inventory-db:5432/km0_inventory?sslmode=require"
  kafka-exporter:
    image: danielqsj/kafka-exporter:v1.7.0
    command: ["--kafka.server=kafka-1:9092", "--kafka.server=kafka-2:9092"]
  redis-exporter:
    image: oliver006/redis_exporter:v1.62.0
    environment:
      REDIS_ADDR: "redis:6379"
  node-exporter:
    image: prom/node-exporter:v1.8.2
    pid: host
  blackbox-exporter:
    image: prom/blackbox-exporter:v0.25.0

The PostgreSQL exporter user is read-only on the pg_stat_* views; in a real deployment its credentials would come from Vault (06-04).

  1. Essential PromQL: rates, percentiles and aggregations

PromQL is the query language. Four constructs cover 90% of uses:

rate(counter[window]): per-second rate over the window. Requests per second to orders, summing the three replicas and broken down by method:

sum by (method) (rate(km0_request_duration_seconds_count{service="orders"}[5m]))

The histogram's _count suffix (number of observations) is used as a request counter: every measured request is one observation.

Error rate: the fraction of requests with a 5xx code:

sum(rate(km0_request_duration_seconds_count{service="orders", code=~"5.."}[5m]))
/
sum(rate(km0_request_duration_seconds_count{service="orders"}[5m]))

histogram_quantile(q, buckets): a percentile computed from the buckets. The p99 of orders, aggregating every instance:

histogram_quantile(
  0.99,
  sum by (le) (rate(km0_request_duration_seconds_bucket{service="orders", method="POST /orders"}[5m]))
)

The sum by (le) is mandatory: the buckets of all instances must be added up while preserving the le label (the bucket bound), which is what histogram_quantile needs in order to interpolate.

increase(counter[window]): how much it has gone up over the window (useful for "orders in the last hour"):

sum(increase(km0_orders_total{status="confirmed"}[1h]))

Others that come up daily: avg_over_time(gauge[10m]) to smooth a gauge, absent(up{job="orders"}) to detect that there are no targets at all, topk(5, ...) for the five products with the least stock, and operators between vectors (/, and, unless) that match series by their labels.

Percentiles versus averages

With 1,000 requests at 50 ms and 10 at 5 s, the average is 99 ms: "all good". The p99 is 5 s: one customer in a hundred waits five seconds. In a distributed system, where one catalogue page makes several internal calls, tail latency is amplified: if each call has a 1% chance of being slow and a request makes 10 of them, 10% of requests see at least one slow call. That is why targets are set on percentiles (p50 for "the normal case", p99 for "what somebody suffers every minute") and never on averages.

  1. SLI, SLO, SLA and the error budget

In 01-03 we worked out the "nines": a service with 99.9% availability can be down for 43 minutes a month. That calculation talked about "being down"; now we have the instruments to define it precisely.

  • SLI (Service Level Indicator): a concrete measurement of the service, expressed as the fraction of good events over the total. "The fraction of requests to POST /orders that respond without a 5xx in under 500 ms."
  • SLO (Service Level Objective): the internal target for that SLI over a window. "99.9% of requests, measured over 30-day windows."
  • SLA (Service Level Agreement): the contractual commitment, with consequences (penalties). Always looser than the SLO: Kilometre Zero promises producers 99.5% and holds itself to 99.9%.

The SLOs of the orders service:

SLI SLO How it is computed in PromQL
Availability: requests without a 5xx 99.9% over 30 days 1 - (sum(rate(..._count{code=~"5.."}[30d])) / sum(rate(..._count[30d])))
Latency: requests lasting < 500 ms 99% over 30 days (p99 < 500 ms) sum(rate(..._bucket{le="0.5"}[30d])) / sum(rate(..._count[30d]))

Notice how the histogram lets you express the latency SLO as a fraction: "how many observations fell into the le="0.5" bucket" divided by the total is exactly "the fraction of requests under 500 ms". This only works if 0.5 is a bucket bound, which is why LATENCY_BUCKETS includes 0.5 and not, say, 0.4 and 0.6.

Error budget

A 99.9% SLO means that 0.1% of bad requests is allowed: that is the error budget. With 2 million requests a month, that is 2,000 errors. The budget turns reliability into a currency you can spend:

  • If there is budget left, the team deploys quickly, tries things out, runs chaos experiments (07-06).
  • If the budget runs out (a 40-minute incident uses up nearly all of it), non-urgent deployments are frozen and the effort goes into reliability.

It is also what gives alerts their meaning: a spike of errors doesn't matter in itself; what matters is how fast the budget is being consumed.

  1. Alerts: symptoms, burn rate and Alertmanager

7.1 Alert on symptoms, not causes

An alert should wake someone up only when a user is suffering or is about to, and that someone must be able to do something about it. "CPU at 85% on inventory-2" meets neither condition: it may be normal during Grape Harvest Week, and it doesn't say what to do. "2% of orders have been failing for 5 minutes" does. Causes (CPU, memory, lag) go on dashboards, for diagnosis when the symptom alert fires, or become low-priority alerts (a ticket, not a phone call).

The reasonable exceptions are predictive alerts on causes with a known deadline: a certificate that expires in 12 h or a disk that will fill up in 4 h at the current rate (predict_linear) are warnings that prevent the symptom.

7.2 Multi-window burn rate

The burn rate is the speed at which the error budget is being consumed, relative to the "fair" speed. A burn rate of 1 means that, at the current pace, the 30-day budget runs out in exactly 30 days. A burn rate of 14.4 means it runs out in 2 days. The technique recommended by the SRE Workbook is to alert on two windows at once, a long one and a short one:

Severity Long window Short window Burn rate Budget consumed before alerting Runs out in
Page (phone call) 1 h 5 min 14.4 2% ~2 days
Page 6 h 30 min 6 5% ~5 days
Ticket 3 days 6 h 1 10% 30 days

The long window stops a 30-second spike from waking anyone up; the short window makes the alert resolve as soon as the problem stops (without it, the 1 h alert would stay active for an hour after the fix).

7.3 observability/alerts.yml

# km0/observability/alerts.yml
groups:
  - name: slo_orders
    rules:
      # Recording rules: they precompute the error rate over several windows.
      # Conventional name: level:metric:operation_window
      - record: job:km0_orders_error_rate:ratio_rate5m
        expr: |
          sum(rate(km0_request_duration_seconds_count{service="orders",code=~"5.."}[5m]))
          / sum(rate(km0_request_duration_seconds_count{service="orders"}[5m]))
      - record: job:km0_orders_error_rate:ratio_rate1h
        expr: |
          sum(rate(km0_request_duration_seconds_count{service="orders",code=~"5.."}[1h]))
          / sum(rate(km0_request_duration_seconds_count{service="orders"}[1h]))
      - record: job:km0_orders_error_rate:ratio_rate30m
        expr: |
          sum(rate(km0_request_duration_seconds_count{service="orders",code=~"5.."}[30m]))
          / sum(rate(km0_request_duration_seconds_count{service="orders"}[30m]))
      - record: job:km0_orders_error_rate:ratio_rate6h
        expr: |
          sum(rate(km0_request_duration_seconds_count{service="orders",code=~"5.."}[6h]))
          / sum(rate(km0_request_duration_seconds_count{service="orders"}[6h]))

      # SLO 99.9% => budget 0.001. Burn rate 14.4 => error rate > 0.0144
      - alert: OrdersFastBurnRate
        expr: |
          job:km0_orders_error_rate:ratio_rate1h > (14.4 * 0.001)
          and
          job:km0_orders_error_rate:ratio_rate5m > (14.4 * 0.001)
        for: 2m
        labels:
          severity: page
          service: orders
        annotations:
          summary: "orders is burning its error budget 14x faster than allowed"
          description: "1h error rate = {{ $value | humanizePercentage }}. Runbook: runbooks/orders-errors.md"

      - alert: OrdersSlowBurnRate
        expr: |
          job:km0_orders_error_rate:ratio_rate6h > (6 * 0.001)
          and
          job:km0_orders_error_rate:ratio_rate30m > (6 * 0.001)
        for: 15m
        labels:
          severity: page
          service: orders
        annotations:
          summary: "orders is burning its error budget 6x faster than allowed (6h)"

      - alert: OrdersLatencyP99
        expr: |
          histogram_quantile(0.99, sum by (le) (
            rate(km0_request_duration_seconds_bucket{service="orders",method="POST /orders"}[10m])
          )) > 0.5
        for: 10m
        labels:
          severity: page
          service: orders
        annotations:
          summary: "p99 of POST /orders above 500 ms for 10 minutes"

  - name: platform
    rules:
      - alert: KafkaLagDelivery
        expr: sum by (consumergroup) (kafka_consumergroup_lag{topic="delivery.positions"}) > 5000
        for: 5m
        labels: {severity: page, service: delivery}
        annotations:
          summary: "The delivery dashboard is running behind: lag {{ $value }} on delivery.positions"

      - alert: KafkaLagAnalytics
        expr: sum by (consumergroup) (kafka_consumergroup_lag{topic="orders.events", consumergroup="analytics"}) > 200000
        for: 30m
        labels: {severity: ticket, service: analytics}
        annotations:
          summary: "analytics is building up lag on orders.events (not urgent, but worth a look)"

      - alert: PostgresHighConnections
        expr: pg_stat_activity_count / pg_settings_max_connections > 0.85
        for: 5m
        labels: {severity: ticket}
        annotations:
          sumary: "{{ $labels.instance }} is using more than 85% of its connections"

      - alert: TargetDown
        expr: up == 0
        for: 3m
        labels: {severity: page}
        annotations:
          summary: "{{ $labels.job }}/{{ $labels.instance }} is not responding to the scrape"

  - name: operational_security
    rules:
      # blackbox_exporter publishes the expiry date of the certificate it sees when probing
      - alert: CertificateExpiringSoon
        expr: (probe_ssl_earliest_cert_expiry - time()) < 12 * 3600
        labels: {severity: page}
        annotations:
          summary: "The certificate of {{ $labels.instance }} expires in less than 12 h"

      # Each service exposes km0_vault_lease_seconds_remaining (gauge) for its dynamic credentials
      - alert: VaultLeaseNotRenewed
        expr: km0_vault_lease_seconds_remaining < 900
        for: 5m
        labels: {severity: page}
        annotations:
          summary: "{{ $labels.service }} has a Vault lease with under 15 min left and it is not being renewed"

      - alert: AuditChainBroken
        expr: increase(km0_audit_failed_verifications_total[15m]) > 0
        labels: {severity: page}
        annotations:
          summary: "Verification of the audit chain has failed (06-05)"

About these rules:

  • Recording rules (record:) compute the expensive expression once and store it as a new metric; alerts and dashboards reuse it. With 6 h windows over histograms, doing it this way saves Prometheus a great deal of work.
  • for: requires the condition to hold for that long before firing. It is the first filter against alerts caused by flapping.
  • Vault's 24 h certificates (06-04) make the 12 h alert the natural threshold: if a certificate hasn't been renewed halfway through its life, automatic renewal is broken.
  • km0_vault_lease_seconds_remaining and km0_audit_failed_verifications_total are metrics that the services themselves publish: business and security instrumentation uses the same mechanism as latency instrumentation.

7.4 Alertmanager: routes, grouping, silences

Prometheus evaluates and fires; Alertmanager decides whom to notify, how to group and when to keep quiet:

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

route:
  receiver: operators-ticket            # default destination
  group_by: [alertname, service]        # one notification per alert+service, not per instance
  group_wait: 30s                       # waits so that alerts arriving together get grouped
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - matchers: [severity = page]
      receiver: operators-oncall
      repeat_interval: 1h
    - matchers: [service = analytics]
      receiver: data-team

receivers:
  - name: operators-oncall
    slack_configs:
      - api_url_file: /run/secrets/slack_webhook
        channel: "#km0-operations"
        title: "[ON-CALL] {{ .CommonAnnotations.summary }}"
        text: "{{ range .Alerts }}{{ .Annotations.description }}\n{{ end }}"
    # In production, also: pagerduty_configs / opsgenie_configs to ring the phone
  - name: operators-ticket
    email_configs:
      - to: [email protected]
  - name: data-team
    slack_configs:
      - api_url_file: /run/secrets/slack_webhook
        channel: "#km0-data"

inhibit_rules:
  # If a target is down, don't also notify about its latency or its errors
  - source_matchers: [alertname = TargetDown]
    target_matchers: [severity = page]
    equal: [service]

Silences: when Jordan is about to restart inventory-2 for a migration, he creates a 30-minute silence with a matcher (instance="inventory-2:9464") from the UI or with amtool silence add instance=inventory-2:9464 --duration=30m --comment="migration". Without silences, the alternative is ignoring alerts, which is where alert fatigue begins: when half the notifications are noise, nobody reads the one that matters. Rules of thumb: every page alert must have a runbook (07-03) and a possible action; an alert that fires and "fixes itself" three times in a row is downgraded to a ticket or removed; the number of pages per week is reviewed like any other metric.

  1. Grafana dashboards and active monitoring

8.1 What to put on the Grape Harvest Week dashboard

A dashboard is not a collection of everything that can be graphed; it is a top-to-bottom narrative: first whether the business is doing well, then whether the services are meeting their targets, then why. For the Roble Alto Winery campaign:

Row Panels Source
Business Orders/min (total and containing crianza-wine); payment rejection rate; error budget remaining for the month (%) km0_orders_total, recording rules
orders SLO 5m/1h error rate with a line at 0.1%; p50/p95/p99 with a line at 500 ms; current burn rate recording rules, histogram_quantile
Dependencies p99 of ReserveStock; stock of crianza-wine in inv-bcn/inv-vlc; Redis hit ratio; lag on orders.events per group inventory, km0_stock_units, exporters
Saturation km0_requests_in_flight per service; PostgreSQL connections; broker CPU gauges, exporters
Edge 429s per minute at Kong (06-05); synthetic probes green/red Kong, blackbox

Grafana is provisioned from files so that the dashboard lives in the repository, not in clicks:

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

# km0/observability/grafana/provisioning/dashboards/km0.yml
apiVersion: 1
providers:
  - name: km0
    folder: Kilometre Zero
    type: file
    options:
      path: /var/lib/grafana/dashboards

And a JSON dashboard cut down to two panels, to show the structure:

{
  "title": "KM0 - Grape Harvest Week",
  "uid": "km0-harvest",
  "refresh": "30s",
  "panels": [
    {
      "type": "stat", "title": "Orders / min", "gridPos": {"x": 0, "y": 0, "w": 6, "h": 4},
      "targets": [{"expr": "sum(rate(km0_orders_total{status=\"confirmed\"}[5m])) * 60"}]
    },
    {
      "type": "timeseries", "title": "POST /orders p50 / p95 / p99", "gridPos": {"x": 6, "y": 0, "w": 18, "h": 8},
      "fieldConfig": {"defaults": {"unit": "s", "thresholds": {"steps": [
        {"color": "green", "value": null}, {"color": "red", "value": 0.5}]}}},
      "targets": [
        {"legendFormat": "p50", "expr": "histogram_quantile(0.50, sum by (le) (rate(km0_request_duration_seconds_bucket{service=\"orders\",method=\"POST /orders\"}[5m])))"},
        {"legendFormat": "p95", "expr": "histogram_quantile(0.95, sum by (le) (rate(km0_request_duration_seconds_bucket{service=\"orders\",method=\"POST /orders\"}[5m])))"},
        {"legendFormat": "p99", "expr": "histogram_quantile(0.99, sum by (le) (rate(km0_request_duration_seconds_bucket{service=\"orders\",method=\"POST /orders\"}[5m])))"}
      ]
    }
  ]
}

8.2 Passive and active monitoring

Everything so far is passive: it measures whatever traffic there is. If there is no traffic at 04:00, the error rate is 0/0 and tells you nothing; if Kong has a broken route that nobody has used yet, it says nothing either. Active monitoring (synthetic probes) generates artificial traffic to check the whole path: the blackbox_exporter requests GET /api/v1/catalog/markets through Kong every 15 s and publishes probe_success, probe_duration_seconds and probe_ssl_earliest_cert_expiry. A more ambitious probe (a small script that creates and cancels a test order as the user [email protected], flagged so that it doesn't count in analytics) verifies the entire saga. These probes are the ones that catch Sunday's expired certificate before Anna does.

8.3 OpenTelemetry Metrics

Everything we have seen uses prometheus_client directly. OpenTelemetry is the open standard that unifies metrics, logs and traces in one SDK and one protocol (OTLP): with it, km0_request_duration_seconds would be a Histogram from OTel's MeterProvider, exported to a Collector that hands it over to Prometheus (or to any other backend). The semantics (types, labels, cardinality, PromQL) are the same. Kilometre Zero keeps prometheus_client for metrics because it is simpler, and adopts OpenTelemetry for traces and log correlation in 07-02, which is where it adds the most value.

Common Mistakes and Tips

  • Measuring infrastructure only. CPU and memory don't tell you whether Anna can buy. Start with RED on every service and two business metrics; add USE for resources to support diagnosis.
  • High-cardinality labels. order_id, user, IPs or non-normalised paths multiply the series until Prometheus falls over. If you need it for an investigation, it goes to logs or traces.
  • Summaries on replicated services. Quantiles computed on the client cannot be aggregated across instances. Use histograms with buckets that include the thresholds of your SLOs.
  • Alerting on averages or on causes. The average hides the tail; CPU at 80% is not a problem in itself. Alert on user-facing SLIs and burn rate; everything else goes to dashboards or tickets.
  • Alerts with no for: and no runbook. Every blip wakes someone up and nobody knows what to do. Every page alert has a for, an owner and a runbook linked in the annotation.
  • Forgetting exceptions in the middleware. If the metric is only recorded when the request finishes successfully, the latency and errors of the ones that fail disappear. Record in finally.
  • Relying on passive monitoring alone. No traffic, no signal. One synthetic probe per critical path (catalogue, create order, login) covers the quiet hours and untested configuration changes.
  • Dashboards built by hand in Grafana. They get lost, they get duplicated, nobody knows which one is the good one. Provision them from the repository, next to the code they instrument.

Exercises

Exercise 1. During Artisan Cheese Week, Montblanc Dairy asks how many orders containing aged-cheese were confirmed in the last hour, and Martha wants to know whether inventory is responding worse than yesterday. (a) Can the first question be answered with km0_orders_total{status="confirmed"} as it is currently defined? If not, what would you change and what cardinality risk would it carry? (b) Write the PromQL query that gives the p95 of ReserveStock over the last 5 minutes, aggregating the two inventory instances, and explain why a per-instance avg(histogram_quantile(...)) won't do. (c) Which gauge would tell you whether the problem lies in the inv-bcn/inv-vlc replicas rather than in the service?

Exercise 2. The orders SLO is 99.9% success over 30 days and the service receives an average of 3 million requests a month. (a) How many errors fit in the budget? (b) At 10:00 on Saturday an incident begins in which 8% of requests fail. With traffic at 100 requests/s, how long does it take to use up the entire budget? (c) Which of the two burn rate alerts (14.4 over 1 h/5 min; 6 over 6 h/30 min) will fire first, and roughly when, taking the for: clauses into account? (d) At 10:20 the incident is resolved; why does the alert resolve shortly afterwards even though the 1 h window is still contaminated?

Exercise 3. One Monday Jordan finds 47 notifications from the weekend in #km0-operations: 30 of PostgresHighConnections on the orders replica (which climbs to 90% every night during the km0_daily_sales DAG and comes back down by itself), 12 of TargetDown for delivery during a scheduled 10-minute deployment, and 5 of KafkaLagAnalytics. None of them corresponded to a real problem. For each group, propose the specific change (in alerts.yml, in alertmanager.yml or in the procedure) that avoids the noise without losing detection of a real problem, and explain which symptom signal would cover the case where it really was one.

Solutions

Exercise 1.

(a) No: km0_orders_total only has the status label; it doesn't know which products each order contained. Two options: a separate counter km0_order_lines_total{product} incremented for every confirmed line (cardinality = number of products, acceptable if it is limited to campaign products or if the catalogue has a few hundred; with tens of thousands of products you would have to aggregate per producer or answer that question from analytics, not from Prometheus), or adding product to km0_orders_total, which is worse because an order with three products would be counted three times and would break the orders metric. Never order_id. (b) histogram_quantile(0.95, sum by (le) (rate(km0_request_duration_seconds_bucket{service="inventory", method="ReserveStock"}[5m]))). Adding up the buckets of both instances by le first rebuilds the joint distribution, and the percentile is computed over that; averaging two p95s computed separately does not give the p95 of the whole (if one instance handles 90% of the traffic with p95 = 40 ms and the other 10% with p95 = 800 ms, the average, 420 ms, describes nobody). (c) The replication lag published by postgres_exporter (pg_replication_lag_seconds or the equivalent over pg_stat_replication) for inv-bcn and inv-vlc; if that gauge goes up, reads on the replica return stale stock and inventory may be retrying or waiting; if it is at zero, the problem lies in the service or in the primary.

Exercise 2.

(a) 0.1% of 3,000,000 = 3,000 errors a month. (b) 100 requests/s × 8% = 8 errors/s; 3,000 / 8 = 375 s ≈ six and a quarter minutes. The whole month's budget is gone in a little over six minutes. (c) Burn rate = 0.08 / 0.001 = 80, far above both thresholds. The 5 min window exceeds 1.44% almost immediately; the 1 h window needs to accumulate: with the previous hour clean, the 1 h error rate reaches 1.44% about 11 minutes into the incident (0.08 × t / 60 min = 0.0144 → t ≈ 10.8 min); with for: 2m, OrdersFastBurnRate notifies at around 10:13. The 6 h one would need 0.006 × 360 / 0.08 = 27 minutes plus for: 15m, so it never fires if the incident lasts 20 minutes. (d) Because the rule requires both windows at once (and): once the incident is resolved, the 5 min window drops below the threshold within a few minutes, and even though the 1 h window stays above it until 11:20, the conjunction no longer holds and Alertmanager marks the alert as resolved. That is precisely the point of the short window.

Exercise 3.

PostgresHighConnections on the replica during the DAG: it is a cause, not a symptom, and it is expected behaviour; options: raise the threshold for the replica only with an unless/matcher on instance, add for: 30m (the DAG takes less than that), or better, downgrade it to severity: ticket (it already is) and route tickets to a daily digest instead of to Slack in real time (long group_interval/repeat_interval on the default route); the real symptom that should notify is the p99 latency of orders or the replication lag, which are covered by OrdersLatencyP99. TargetDown during deployments: the deployment procedure must create a silence (amtool silence add job=delivery --duration=15m --comment="deployment") as a pipeline step; in addition, with several replicas the alert should be "every instance of the job is down" (absent(up{job="delivery"} == 1) or count(up{job="delivery"} == 1) == 0), not a single instance; the symptom that covers a genuinely broken deployment is the lag on delivery.positions (KafkaLagDelivery) and the dashboard's synthetic probe. KafkaLagAnalytics: 200,000 with for: 30m is too sensitive for a batch consumer that catches up every night; raise the threshold, lengthen for to several hours, or change it to an alert on the analytics symptom: "the km0_daily_sales DAG hasn't finished by 07:00" (an Airflow metric) or "the last run failed". In every case the principle is the same: a page alert protects a specific user experience; everything else is information for diagnosis.

Conclusion

A distributed system doesn't tell you when it fails; you have to build the instruments. Observability rests on three signals and this lesson has developed the first: metrics, the only one that scales with the number of series and not with traffic. We have seen what to measure in four layers (infrastructure with USE, platform, application with RED and the golden signals, business), the four metric types and why the histogram is the right type for latencies on replicated services, the cardinality rule that forbids order_id as a label, Prometheus's pull model with its exporters and probes, and the indispensable PromQL (rate, histogram_quantile with sum by (le), increase). On that foundation, the "nines" from 01-03 have become concrete SLIs and SLOs for orders (99.9% success, p99 < 500 ms), with an error budget that gets spent and multi-window burn rate alerts that notify on symptoms, routed by Alertmanager to whoever can act, and a provisioned dashboard that tells the story of Grape Harvest Week from top to bottom.

Now, when OrdersFastBurnRate fires at 10:13 on a Saturday, Jordan will know that 8% of orders are failing. What the metrics won't tell him is which ones, or why: whether it is inventory rejecting reservations of crianza-wine, the payment gateway, or a traceparent that got lost in a Kafka header. For that you need the other two pillars: structured, centralised logs, which tell you what happened to order P-2026-000125, and distributed traces, which show in which service the 480 ms went. That is the next lesson.

Distributed Architectures Course

Module 1: Introduction to Distributed Systems

Module 2: Communication in Distributed Systems

Module 3: Consistency and Replication

Module 4: Distributed Storage

Module 5: Distributed Computing

Module 6: Security in Distributed Systems

Module 7: Monitoring and Maintenance

Module 8: Case Studies and Applications

© Copyright 2026. All rights reserved