Skip to content

Observability & Site Reliability Engineering

What this lesson gives you

A system you can't see is a system you can't operate. Observability is how you understand a running system from the outside; SRE is the discipline of keeping it reliable on purpose.

Estimated time: 26 min read · Part: Designing Python Systems at Scale

The alert that cried wolf

A microservices team had 200 alerts configured. Half fired on CPU > 80%. On-call engineers woke up in the middle of the night, SSHed into servers, found CPU at 82%, saw no user impact in any logs, and went back to sleep. Alert fatigue set in within weeks. When a real incident occurred — p99 latency climbing from 80 ms to 4 seconds on the checkout service — engineers noticed it in the morning standup, not at 2 a.m. They had the tools to detect it; their alerts were pointed at the wrong signals. They switched every CPU alert to an SLO-based burn-rate alert: "consuming a week's error budget in one hour." Alert volume dropped 90%. The first real incident after the change paged them within 4 minutes of the user-facing degradation starting. Observability is not about collecting everything — it is about asking the right questions of the right data.

The car dashboard

Observability is like the dashboard of a car. You don't open the hood every minute to check if the engine is running — you glance at the speedometer, fuel gauge, temperature, and warning lights. The four golden signals (latency, traffic, errors, saturation) are those gauges. A check-engine light is a symptom-based alert: something is wrong; investigate. Alerting on "coolant temperature sensor value is above factory nominal" (CPU % alert) instead of "engine temperature warning light" (error rate alert) means you get paged for things that don't matter and miss things that do.

The Three Pillars of Observability

A fully observable system produces three complementary data types:

Pillar What it is Answers Storage format
Logs Timestamped, structured records of discrete events "What happened and when?" JSON lines; indexed in Loki, Elasticsearch, CloudWatch
Metrics Numeric time-series measurements aggregated over time "How is the system behaving over time?" Prometheus TSDB; InfluxDB; CloudWatch Metrics
Traces Causal chain of a single request across services and components "Where did this specific request spend its time?" Jaeger, Tempo, Datadog APM, Honeycomb

OpenTelemetry is the vendor-neutral CNCF standard that unifies all three pillars under a single SDK, collector, and wire protocol (OTLP). Instrument once; ship to any backend.

Structured Logging with structlog

A log line like ERROR: payment failed for user 1001 is nearly useless in production. A structured JSON log with fields is queryable, filterable, and aggregatable:

log line example
{
  "timestamp": "2026-07-20T14:23:01.452Z",
  "level": "error",
  "event": "payment_failed",
  "service": "checkout",
  "user_id": 1001,
  "order_id": "ORD-9842",
  "amount": 99.99,
  "error": "card_declined",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id":  "00f067aa0ba902b7"
}

Now you can query: all errors for user 1001 in the last hour, error rate by error type, full request trace by trace_id.

logging_setup.py
import logging
import structlog

def configure_logging(level: str = "INFO") -> None:
    """Configure structlog for production: JSON output, timestamps, context vars."""
    structlog.configure(
        processors=[
            structlog.contextvars.merge_contextvars,      # merge per-request context
            structlog.stdlib.add_logger_name,
            structlog.stdlib.add_log_level,
            structlog.processors.TimeStamper(fmt="iso"),  # ISO-8601 timestamp
            structlog.processors.StackInfoRenderer(),
            structlog.processors.format_exc_info,
            structlog.processors.JSONRenderer(),          # output as JSON
        ],
        wrapper_class=structlog.make_filtering_bound_logger(
            getattr(logging, level.upper(), logging.INFO)
        ),
        context_class=dict,
        logger_factory=structlog.PrintLoggerFactory(),
    )

configure_logging(level="INFO")
logger = structlog.get_logger()

# FastAPI middleware: inject correlation ID into all log calls for this request
from fastapi import Request
import uuid

async def correlation_id_middleware(request: Request, call_next):
    trace_id = request.headers.get("traceparent", str(uuid.uuid4()))
    structlog.contextvars.clear_contextvars()
    structlog.contextvars.bind_contextvars(
        trace_id=trace_id,
        method=request.method,
        path=request.url.path,
    )
    response = await call_next(request)
    structlog.contextvars.clear_contextvars()
    return response

# Usage in business logic — context fields from middleware are automatically included
def process_payment(order_id: str, amount: float, user_id: int) -> dict:
    log = logger.bind(order_id=order_id, user_id=user_id, amount=amount)
    log.info("payment_started")
    try:
        result = gateway.charge(order_id=order_id, amount=amount)
        log.info("payment_succeeded", transaction_id=result["id"])
        return result
    except gateway.DeclinedError as exc:
        log.warning("payment_declined", reason=str(exc))
        raise
    except Exception as exc:
        log.error("payment_error", exc_info=True)
        raise

Log levels: when to use each

  • DEBUG — detailed diagnostic information; disable in production (log volume); enable per-service on demand.
  • INFO — normal operational events: request received, payment started, task completed. Noisy but expected.
  • WARNING — unexpected but handled: retry attempt, fallback to default, deprecated API called. Worth tracking; not urgent.
  • ERROR — an operation failed: exception caught, external call failed, data validation error. Requires investigation.
  • CRITICAL — system health compromised: database unreachable, secret missing, memory exhausted. Page someone now.

Metrics: Types & Prometheus

Metrics are numeric measurements sampled or accumulated over time. Four Prometheus metric types cover virtually all use cases:

Type Behaviour What to use it for Query pattern
Counter Monotonically increasing; never decreases; resets on restart Request count, error count, bytes sent rate(counter[5m]) → requests/sec
Gauge Current value; can go up or down Active connections, queue depth, memory usage, temperature Direct value; gauge > threshold
Histogram Counts observations in configurable buckets; exposes _count, _sum, _bucket Request latency, response size, file processing time histogram_quantile(0.99, rate(hist_bucket[5m]))
Summary Pre-computed quantiles on the client side Same as histogram but quantiles are not aggregatable across instances Direct quantile labels; avoid for multi-instance
metrics.py
from prometheus_client import Counter, Histogram, Gauge, start_http_server
import time
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware

# Define metrics (module-level singletons)
HTTP_REQUESTS_TOTAL = Counter(
    "http_requests_total",
    "Total HTTP requests",
    labelnames=["method", "path", "status_code"],
)

HTTP_REQUEST_DURATION_SECONDS = Histogram(
    "http_request_duration_seconds",
    "HTTP request latency in seconds",
    labelnames=["method", "path"],
    buckets=[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
)

ACTIVE_REQUESTS = Gauge(
    "http_active_requests",
    "Number of HTTP requests currently being processed",
)

PAYMENT_FAILURES_TOTAL = Counter(
    "payment_failures_total",
    "Total payment failures",
    labelnames=["reason"],
)

class PrometheusMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        # Normalize path to avoid high-cardinality labels (e.g., /users/42 -> /users/{id})
        path = request.url.path
        method = request.method

        ACTIVE_REQUESTS.inc()
        start = time.perf_counter()
        try:
            response = await call_next(request)
            return response
        finally:
            duration = time.perf_counter() - start
            status = getattr(response, "status_code", 500)

            HTTP_REQUESTS_TOTAL.labels(method=method, path=path, status_code=status).inc()
            HTTP_REQUEST_DURATION_SECONDS.labels(method=method, path=path).observe(duration)
            ACTIVE_REQUESTS.dec()

# FastAPI setup
from fastapi import FastAPI
from prometheus_client import make_asgi_app

app = FastAPI()
app.add_middleware(PrometheusMiddleware)

# Mount /metrics endpoint
metrics_app = make_asgi_app()
app.mount("/metrics", metrics_app)

# Usage in business logic
def process_payment(amount: float, token: str) -> dict:
    try:
        result = gateway.charge(amount, token)
        return result
    except gateway.DeclinedError:
        PAYMENT_FAILURES_TOTAL.labels(reason="card_declined").inc()
        raise
    except gateway.FraudError:
        PAYMENT_FAILURES_TOTAL.labels(reason="fraud_detected").inc()
        raise

OpenTelemetry: Traces & Context Propagation

A distributed trace is the complete causal record of a single request as it traverses services: web server → order service → inventory service → database. Each step is a span; spans share a trace ID and form a parent-child tree. Traces answer: where did this slow request spend its time?

Context propagation: the W3C TraceContext standard defines the traceparent HTTP header (version-trace_id-span_id-flags). When service A calls service B, it injects the current trace context into the outgoing request header. Service B extracts it and creates a child span.

otel_setup.py
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
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor

def configure_tracing(service_name: str, otlp_endpoint: str = "http://localhost:4317") -> trace.Tracer:
    """Configure OpenTelemetry with OTLP export (works with Jaeger, Tempo, Datadog, Honeycomb)."""
    resource = Resource.create({"service.name": service_name, "deployment.environment": "production"})
    provider = TracerProvider(resource=resource)
    exporter = OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)
    provider.add_span_processor(BatchSpanProcessor(exporter))
    trace.set_tracer_provider(provider)
    return trace.get_tracer(service_name)

tracer = configure_tracing("checkout-service")

# Auto-instrument FastAPI (injects spans for every HTTP request automatically)
FastAPIInstrumentor.instrument_app(app)

# Auto-instrument outgoing HTTP calls (propagates traceparent header)
HTTPXClientInstrumentor().instrument()

# Auto-instrument SQLAlchemy (captures every query as a span)
SQLAlchemyInstrumentor().instrument(engine=engine)

# Manual spans for business-logic operations
def process_payment(order_id: str, amount: float) -> dict:
    with tracer.start_as_current_span("payment.charge") as span:
        span.set_attribute("order.id", order_id)
        span.set_attribute("payment.amount", amount)
        span.set_attribute("payment.currency", "USD")
        span.add_event("Calling payment gateway")

        try:
            result = gateway.charge(order_id=order_id, amount=amount)
            span.set_attribute("payment.transaction_id", result["id"])
            span.set_status(trace.StatusCode.OK)
            return result
        except Exception as exc:
            span.record_exception(exc)
            span.set_status(trace.StatusCode.ERROR, str(exc))
            raise

# Inject trace context into structlog for correlated logs + traces
from opentelemetry import trace as otel_trace

def get_current_trace_context() -> dict:
    ctx = otel_trace.get_current_span().get_span_context()
    if ctx.is_valid:
        return {
            "trace_id": format(ctx.trace_id, "032x"),
            "span_id": format(ctx.span_id, "016x"),
        }
    return {}

# Call this in your logging middleware to add trace context to every log line
structlog.contextvars.bind_contextvars(**get_current_trace_context())

The Four Golden Signals

Google's SRE book identifies four signals that, if monitored, cover the vast majority of user-facing reliability issues:

Signal What it measures Alert threshold example Prometheus query
Latency Time to serve a request; distinguish successful vs error latency p99 > 500 ms for 5 min histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))
Traffic Demand on the system; requests/sec, messages/sec Traffic drops >50% vs 7-day average (anomaly) rate(http_requests_total[5m])
Errors Rate of failed requests (5xx, timeouts, exceptions) Error rate > 1% for 5 min rate(http_requests_total{status_code=~"5.."}[5m]) / rate(http_requests_total[5m])
Saturation How full the service is; the constrained resource (CPU, memory, queue depth, connection pool) DB connection pool utilisation > 90% sqlalchemy_pool_checkedout / sqlalchemy_pool_size

SLI, SLO, SLA, and Error Budgets

SLI (Service Level Indicator) — the actual measured value: "99.2% of requests in the last 28 days succeeded in under 300 ms."

SLO (Service Level Objective) — the internal target you commit to: "SLI ≥ 99.5%." This is the promise between engineering and the product team.

SLA (Service Level Agreement) — the external contractual commitment with customers: "99.0% availability or we issue refunds." SLAs should be looser than SLOs; the SLO is your internal guardrail.

Error budget — the allowed amount of unreliability: 100% − SLO target. For an SLO of 99.5%, the error budget is 0.5% of requests, or about 3.6 hours per month. While budget remains, teams ship features freely. When it is exhausted, reliability work takes priority over feature work. This converts reliability from a feeling into a quantitative, negotiable trade-off between velocity and stability.

slo_budget.py
from dataclasses import dataclass
from datetime import timedelta

@dataclass
class SLO:
    name: str
    target: float           # e.g. 0.995 for 99.5%
    window_days: int = 28

    @property
    def error_budget(self) -> float:
        """Fraction of requests that can fail."""
        return 1.0 - self.target

    def budget_remaining(self, current_error_rate: float) -> float:
        """Fraction of error budget remaining (0.0 = exhausted, 1.0 = fully remaining)."""
        if current_error_rate >= self.error_budget:
            return 0.0
        return 1.0 - (current_error_rate / self.error_budget)

    def hours_until_exhausted(self, current_error_rate: float) -> float | None:
        """
        If the current error rate continues unchanged, how many hours until
        the error budget is exhausted?
        Returns None if budget is not being consumed.
        """
        if current_error_rate <= 0:
            return None
        window_hours = self.window_days * 24
        # Budget consumed per hour at this error rate
        consumption_rate = current_error_rate / self.error_budget
        if consumption_rate <= 0:
            return None
        return window_hours * (1 - 0) / consumption_rate

    def burn_rate(self, current_error_rate: float) -> float:
        """
        Burn rate: 1.0 = consuming budget at exactly the sustainable rate.
        > 1.0 = consuming faster than sustainable (will exhaust before window ends).
        """
        return current_error_rate / self.error_budget

# Example: calculate budget status
checkout_slo = SLO(name="checkout-availability", target=0.995, window_days=28)

current_error_rate = 0.012   # 1.2% of requests are failing

print(f"SLO target:            {checkout_slo.target:.1%}")
print(f"Error budget:          {checkout_slo.error_budget:.2%}")
print(f"Current error rate:    {current_error_rate:.2%}")
print(f"Burn rate:             {checkout_slo.burn_rate(current_error_rate):.1f}x sustainable")
print(f"Budget remaining:      {checkout_slo.budget_remaining(current_error_rate):.1%}")
remaining_hours = checkout_slo.hours_until_exhausted(current_error_rate)
if remaining_hours:
    print(f"Budget exhausted in:   {remaining_hours:.1f} hours")

# Output:
# SLO target:            99.5%
# Error budget:          0.50%
# Current error rate:    1.20%
# Burn rate:             2.4x sustainable
# Budget remaining:      0.0%  (already exceeded)
# Budget exhausted in:   280.0 hours (at the SLO target consumption rate)

Alerting Philosophy: Symptoms, Not Causes

Alert on what users experience, not on internal system state:

Bad alert (cause) Good alert (symptom) Why
CPU > 80% p99 latency > 500 ms for 5 min CPU can be high with no user impact; latency directly measures user experience
Heap memory > 70% Error rate > 1% for 5 min High heap with GC under control causes no errors; error rate is user-facing
Disk usage > 85% SLO error budget burn rate > 5x for 1 hr Disk can be high for logs; budget burn alerts on the rate of reliability erosion
Replica lag > 100 ms DB query p95 > 200 ms for 10 min Small replica lag has no user impact; slow queries do

Alert fatigue kills reliability

Every false-positive alert trains your team to ignore alerts. When a real incident fires, it looks the same as the hundred false alarms before it. Maintain a strict "every alert must be actionable and urgent" standard. If an alert fires and the on-call engineer's first action is to check whether it matters, the alert is wrong. Delete it or convert it to a dashboard metric.

SRE Practices: Post-Mortems, Runbooks, and Chaos Engineering

Blameless post-mortems are the cornerstone of an SRE culture. When an incident occurs, the team reconstructs a timeline of events, identifies contributing factors using 5-whys root cause analysis, and produces action items to prevent recurrence — without assigning blame to individuals. Systems fail; people respond. The goal is systemic improvement, not scapegoating.

A blameless post-mortem template:

  • Impact — affected users, error rate, duration, revenue impact.
  • Timeline — chronological reconstruction from first symptom to resolution.
  • Root cause — what specific condition caused the incident? (5-whys chain.)
  • Contributing factors — what made the system vulnerable? (Missing tests, unclear runbook, insufficient alerting.)
  • Action items — specific, owned, time-bounded tasks to reduce recurrence probability.

Runbooks are step-by-step remediation guides for known alert types. A runbook is linked from the alert itself (Prometheus alert annotation, PagerDuty rule). It reduces mean time to detect (MTTD) and mean time to recover (MTTR) by giving on-call engineers a decision tree rather than blank-page debugging at 3 a.m.

Chaos engineering deliberately injects faults into a production-like environment to prove that the system degrades gracefully. Examples: kill a random pod, delay a service by 500 ms, exhaust disk, block network between two services. If the system handles the fault as designed, confidence increases. If not, you find the gap before a real outage does. Tools: Chaos Monkey (Netflix, random instance termination), Gremlin (controlled fault injection), k6 (load testing + fault simulation), LitmusChaos (Kubernetes-native).

Observability Stacks

Tool Open-source? Handles Hosted option Strengths
Prometheus Yes Metrics (M) Grafana Cloud Pull-based; huge ecosystem; PromQL is powerful
Grafana Yes Dashboards + alerts Grafana Cloud Connects to any datasource; best-in-class visualisation
Loki Yes Logs (L) Grafana Cloud Label-indexed (not full-text); low storage cost; integrates with Grafana
Tempo Yes Traces (T) Grafana Cloud Object-storage backend (cheap); trace-to-log linking in Grafana
Jaeger Yes Traces (T) None native CNCF project; easy local setup; good for tracing only
Datadog No M + L + T SaaS only Unified platform; excellent APM; easy onboarding; expensive at scale
Honeycomb No Traces + events (L+T) SaaS only Columnar store; ultra-fast high-cardinality trace queries; BubbleUp analysis
New Relic No M + L + T SaaS only All-in-one; generous free tier; AI-assisted anomaly detection

The LGTM stack (Loki + Grafana + Tempo + Mimir/Prometheus) is the open-source answer to Datadog: all four pillars in a single Grafana-integrated system, self-hosted or via Grafana Cloud.

The OpenTelemetry Collector is a vendor-neutral ingest layer. Applications send OTLP to the collector; the collector routes to any backend. Switching from Jaeger to Tempo, or from self-hosted to Datadog, requires only a collector config change — no application code change.

flowchart LR
    subgraph App["Application (Python)"]
        SDK[OpenTelemetry SDK<br/>auto + manual instrumentation]
        structlog[structlog<br/>JSON logs]
        prom_client[prometheus_client<br/>/metrics endpoint]
    end

    subgraph Collector["OTel Collector"]
        R[Receivers<br/>OTLP · Prometheus scrape]
        P[Processors<br/>batching · filtering · sampling]
        E[Exporters<br/>OTLP · Prometheus · Loki]
    end

    subgraph Backends
        Prometheus[(Prometheus<br/>Metrics)]
        Loki[(Loki<br/>Logs)]
        Tempo[(Tempo<br/>Traces)]
    end

    subgraph Viz
        Grafana[Grafana<br/>Dashboards & Alerts]
        PD[PagerDuty<br/>On-call routing]
    end

    SDK -->|OTLP gRPC| R
    structlog -->|log shipper<br/>(Promtail / Vector)| R
    prom_client -->|scrape| R
    R --> P --> E
    E --> Prometheus & Loki & Tempo
    Prometheus & Loki & Tempo --> Grafana
    Grafana -->|alert webhook| PD
    PD -->|page| OnCall[On-call engineer<br/>+ runbook link]

Consulting lens

Observability and SRE close the loop on everything in this course — you built it, tested it, shipped it via CI/CD, and now you prove it works and keep it healthy. For clients, the pitch is risk reduction in business terms: "We instrument with OpenTelemetry, define SLOs with an error budget, and alert on the four golden signals — so we catch regressions before customers do and make reliability a measurable, negotiable target instead of a surprise." The error budget reframe is particularly powerful in client conversations: it gives product managers a quantified trade-off between feature velocity and reliability investment, and it removes the adversarial dynamic between "shipping features" and "keeping the lights on."

Key takeaways

  • The three pillars — logs, metrics, traces — are complementary; you need all three; OpenTelemetry unifies them under one SDK.
  • Structured JSON logs are machine-queryable; use structlog with correlation IDs to link logs to traces across services.
  • Counter, Gauge, Histogram: use Histogram for latency (enables percentile queries); never use absolute counter values, always use rate().
  • Auto-instrument with OpenTelemetry FastAPIInstrumentor; add manual spans for business-critical operations with attributes.
  • The four golden signals — latency, traffic, errors, saturation — cover the vast majority of user-facing reliability issues.
  • SLO − 100% = error budget; burn rate alerts fire when you are consuming the budget faster than sustainable.
  • Alert on symptoms (p99 latency, error rate) not causes (CPU %, memory); false-positive alerts breed the fatigue that hides real incidents.
  • The LGTM stack (Loki, Grafana, Tempo, Mimir) is a complete open-source observability platform; the OTel Collector lets you swap backends without touching application code.
Knowledge check

InterviewWhat is an "error budget" in SRE, and what is it used for?

  • The maximum number of bugs a team is allowed to ship per sprint
  • The cost allocated for handling production incidents and on-call engineers
  • The allowed amount of unreliability (100% minus the SLO target) used to balance feature velocity against reliability work
  • The number of retries allowed before a request is marked as failed
Answer

The allowed amount of unreliability (100% minus the SLO target) used to balance feature velocity against reliability work

The error budget is 100% minus the SLO target. For a 99.5% availability SLO, the error budget is 0.5% of requests. While budget remains, teams can ship features freely; when the budget is exhausted (the SLI has fallen below the SLO target), the team shifts focus to reliability work before shipping more features. This mechanism converts reliability from a feeling or an argument into a quantitative, shared trade-off: product and engineering agree on the target, and the budget governs the pace of change.

Exercise

Exercise 14.3 · SLO Burn Rate Alert Using the SLO class from this lesson, implement a should_page function that returns True when the current burn rate exceeds a given threshold (e.g. 14x) — meaning the team would exhaust the entire monthly error budget in two hours if this rate continued. Print a human-readable alert message including the burn rate, hours until exhaustion, and a suggested runbook link. Then write a second function should_warn that fires at a lower burn rate threshold (e.g. 3x) for a warning (but not a page).

Show solution
burn_rate_alert.py
from slo_budget import SLO

checkout_slo = SLO(name="checkout-availability", target=0.995, window_days=28)

RUNBOOK_URL = "https://wiki.internal/runbooks/checkout-availability"

def should_page(slo: SLO, current_error_rate: float, burn_threshold: float = 14.0) -> bool:
    """
    Page on-call if burn rate exceeds threshold.
    14x burn = exhausting a 28-day budget in 48 hours (2 days).
    """
    rate = slo.burn_rate(current_error_rate)
    if rate >= burn_threshold:
        hours = slo.hours_until_exhausted(current_error_rate)
        print(
            f"[PAGE] SLO '{slo.name}' burn rate {rate:.1f}x sustainable. "
            f"Budget exhausted in {hours:.1f}h at current rate. "
            f"Runbook: {RUNBOOK_URL}"
        )
        return True
    return False

def should_warn(slo: SLO, current_error_rate: float, warn_threshold: float = 3.0) -> bool:
    """
    Warning-level ticket if burn rate exceeds a lower threshold.
    3x burn = exhausting a 28-day budget in ~9 days.
    """
    rate = slo.burn_rate(current_error_rate)
    if rate >= warn_threshold:
        hours = slo.hours_until_exhausted(current_error_rate)
        print(
            f"[WARN] SLO '{slo.name}' elevated burn rate {rate:.1f}x sustainable. "
            f"Budget exhausted in {hours:.1f}h. Monitor closely. "
            f"Dashboard: {RUNBOOK_URL}"
        )
        return True
    return False

# Test scenarios
scenarios = [
    ("Normal", 0.001),       # 0.1% error rate — under budget
    ("Elevated", 0.002),     # 0.2% error rate — 0.4x burn (fine)
    ("Warning zone", 0.008), # 0.8% error rate — 1.6x burn (warn)
    ("Page zone", 0.07),     # 7% error rate — 14x burn (page!)
]

for name, error_rate in scenarios:
    print(f"\n--- Scenario: {name} (error_rate={error_rate:.1%}) ---")
    paged = should_page(checkout_slo, error_rate)
    if not paged:
        warned = should_warn(checkout_slo, error_rate)
        if not warned:
            print(f"[OK] Burn rate {checkout_slo.burn_rate(error_rate):.2f}x — within budget.")