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:
{
"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.
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 |
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.
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.
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
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.")