Defining SLIs from Python Request Metrics
A service level indicator is a measurement of what users experience, expressed as the fraction of events that were good. For a Python web service, the two that matter first are availability — the fraction of requests that did not fail — and latency — the fraction that completed within a threshold. Both can be computed from metrics most services already export: a request counter and a latency histogram. This article shows the queries, the recording rules and the decisions behind them. It belongs to SLOs, alerts and dashboards from Python metrics in the Python metrics and instrumentation section.
Prerequisites
A service exporting a request counter and a latency histogram — through prometheus_client, as in instrumenting FastAPI with Prometheus metrics, or through the OpenTelemetry SDK. A Prometheus-compatible backend to evaluate recording rules.
curl -s localhost:8000/metrics | grep -E '^http_request(s_total|_duration_seconds_bucket)' | head -5
Implementation steps
Step 1 — Check the labels and buckets. The counter needs a status label (or a status_class label with values like 2xx and 5xx) and a route label with templated routes, not raw paths. The histogram needs a boundary at the latency threshold. If the threshold is 300 milliseconds and the buckets are 0.25, 0.5, the indicator will be interpolated — change the buckets first.
Step 2 — Write the availability ratio. Good events over valid events, with health checks and the metrics endpoint excluded from both.
sum(rate(http_requests_total{service="orders-api", route!~"/health|/metrics", status!~"5.."}[5m]))
/
sum(rate(http_requests_total{service="orders-api", route!~"/health|/metrics"}[5m]))
Step 3 — Write the latency ratio. The bucket counter at the threshold over the total count. Because histogram buckets are cumulative, the le="0.3" bucket already counts every request that took 300 milliseconds or less.
sum(rate(http_request_duration_seconds_bucket{service="orders-api", route!~"/health|/metrics", le="0.3"}[5m]))
/
sum(rate(http_request_duration_seconds_count{service="orders-api", route!~"/health|/metrics"}[5m]))
A choice hides here: should failed requests count against latency? A 500 returned in ten milliseconds is fast and bad. Most teams measure latency over successful requests only, adding status!~"5.." to both sides, so that failures are counted once, in availability, rather than improving the latency SLI by being quick.
Step 4 — Record at every window the consumers need. Burn-rate alerts use 5 minutes, 30 minutes, 1 hour and 6 hours; dashboards and budget calculations use 30 days. Recording rules compute each once.
groups:
- name: orders-api-sli
interval: 30s
rules:
- record: sli:orders_api:availability:ratio_rate5m
expr: |
sum(rate(http_requests_total{service="orders-api",route!~"/health|/metrics",status!~"5.."}[5m]))
/ sum(rate(http_requests_total{service="orders-api",route!~"/health|/metrics"}[5m]))
- record: sli:orders_api:latency300:ratio_rate5m
expr: |
sum(rate(http_request_duration_seconds_bucket{service="orders-api",route!~"/health|/metrics",status!~"5..",le="0.3"}[5m]))
/ sum(rate(http_request_duration_seconds_count{service="orders-api",route!~"/health|/metrics",status!~"5.."}[5m]))
The same pair repeats with [30m], [1h], [6h]; the 30-day ratio is better computed from the 5-minute series with avg_over_time, which is much cheaper than a thirty-day rate. Strictly, averaging ratios weights quiet periods the same as busy ones; for a precise 30-day figure, record the good and valid rates separately and divide their 30-day sums.
Checking the indicator against reality
An indicator is a claim about user experience, and the claim can be tested. Plot a month of the availability and latency SLIs next to the incident log. Every incident users noticed should appear as a dip; every dip should correspond to something users noticed, or to something they would have noticed with more traffic.
Mismatches are the useful part. An incident that did not move the indicator means it is measuring the wrong requests — perhaps the failures happened at a load balancer that returned errors before reaching the Python process, or the affected route was excluded, or the failures were 200 responses with an error body. A dip nobody noticed may mean the indicator counts something users do not care about, such as a background polling endpoint, or that it is working as intended and caught a problem early. Each mismatch either fixes the definition or earns the indicator some trust.
Two classes of failure are commonly invisible to in-process indicators. Requests that never reach the process — rejected at the ingress, timed out in a queue, lost to a crashed pod — are not counted at all. And responses that are technically successful but wrong — an empty list instead of the user's orders, a stale cache — look good to any status-code-based indicator. The first is addressed by measuring at the ingress as well; the second needs a correctness check, often a synthetic probe that asserts on content.
Indicators for OpenTelemetry metrics
With the OpenTelemetry SDK, the histogram instrument exports buckets according to the view's explicit boundaries, and a Prometheus-compatible backend exposes them under the familiar _bucket, _count and _sum suffixes. The queries are the same once names are mapped: an instrument named http.server.request.duration typically appears as http_server_request_duration_seconds, and the status code attribute as http_response_status_code.
Two details differ. The default OpenTelemetry bucket boundaries for duration are in seconds and may not include the threshold, so a view setting them explicitly is needed, as in choosing histogram buckets for latency SLOs. And if the SDK exports delta temporality, the backend must accumulate before rate gives meaningful results; delta vs cumulative temporality covers when that happens.
How many indicators, and at what granularity
A service-wide availability SLI and a service-wide latency SLI are the right place to start, and for many services they are enough. The pressure to add more comes from the averaging problem: a route that fails completely but carries one percent of traffic moves the service-wide availability by one percentage point, which may or may not cross the objective, while every user of that route is fully affected.
Per-route indicators solve that and create another problem. A route handling a few requests a minute produces a ratio that swings between zero and one on individual requests, and alerts on it fire on single failures. The practical compromise is a short list of routes that matter disproportionately — checkout, login, the public API's core resource — each with its own indicator and objective, and everything else covered by the service-wide figure.
A related decision is whether to split by client type. A mobile app and a web front end may call the same routes with different expectations, and a partner integration may have contractual latency terms. When different groups of callers have genuinely different requirements, an indicator per group, distinguished by a bounded label such as the calling application, reflects that. When they do not, one indicator is simpler and less noisy.
Whatever the granularity, the indicator's name and definition should be written down next to the recording rule: which requests, which status codes, which threshold, and why. Six months later, when someone asks why a particular incident did not breach the objective, the answer is in that definition, and without it the rule is just a query nobody wants to change.
Configuration options
| Decision | Common choice | Reason |
|---|---|---|
| Bad event (availability) | 5xx | client errors are not the service's |
| Excluded routes | health, readiness, metrics | frequent, cheap, always succeed |
| Latency population | successful requests | failures counted once |
| Threshold | a bucket boundary | exact ratio |
| Recording windows | 5m, 30m, 1h, 6h | the burn-rate alert windows |
| 30-day figure | sums of good and valid | precise budget |
| Route-level SLIs | a few critical routes | not every route |
Verification
Generate a known mix — say 1 000 requests, 10 of which return 500 and 50 of which sleep past the threshold — against a test instance, then query the 5-minute recording rules:
sli:orders_api:availability:ratio_rate5m
sli:orders_api:latency300:ratio_rate5m
Expected Output: availability at 0.99 and latency near 0.95 — within the rounding of rate over the window. Values that disagree point to a label that is not what it seems: a status recorded as "500" in one place and 500 in another, or a route template that differs from the one in the selector.
Common mistakes
Raw paths as the route label. Error signature: an SLI query that times out, or a series count in the millions. Root cause: identifiers in URLs becoming label values. Remediation: the framework's matched route template.
Threshold between buckets. Error signature: an indicator that moves when the request mix changes. Root cause: interpolation. Remediation: a boundary at the threshold.
Averaging percentiles. Error signature: fleet latency figures that do not match any instance. Root cause: percentiles do not combine. Remediation: ratios of bucket counts.
Fast failures improving latency. Error signature: latency SLI rising during an outage. Root cause: quick 500s counted as fast requests. Remediation: measure latency over successful requests.
An indicator never checked against incidents. Error signature: an SLO that was met during an outage users complained about. Root cause: the indicator measured the wrong thing. Remediation: compare a month of it with the incident log before alerting on it.
Probes left in the denominator. Error signature: availability that barely dips during a real outage. Root cause: health checks and scrapes, which always succeed, outnumbering affected requests. Remediation: exclude them by route on both sides of the ratio.
Status labels with mixed formats. Error signature: a ratio above one, or a good-event count of zero. Root cause: some code paths record "500" and others "5xx" or an integer. Remediation: one helper that records the status label everywhere.
Frequently Asked Questions
Why a ratio instead of p99 latency?
A ratio of fast requests to all requests can be summed across instances, averaged over any window, compared with an objective and turned into an error budget. A percentile can do none of those correctly — percentiles from different instances or windows cannot be combined.
Can I compute SLIs from OpenTelemetry metrics?
Yes. An OpenTelemetry histogram exported to a Prometheus-compatible backend produces the same bucket and count series, and the queries are identical once the metric names are known. With delta temporality the backend has to convert to cumulative first.
Should the availability SLI count 4xx responses as failures?
Usually not. Client errors are mostly caused by the client, and counting them lets a misbehaving client spend the service's budget. Some teams count 429 or specific 4xx codes that indicate a service problem.
How do I handle a route with very little traffic?
Low-traffic routes produce noisy ratios, since one failure out of ten requests is ten percent. Grouping them into the service-wide SLI, or using longer windows for them, avoids alerts driven by single requests.
What if the latency threshold is not a bucket boundary?
Add a boundary at the threshold. A ratio computed from interpolated buckets is an estimate that shifts with the distribution of requests inside the bucket, which makes the indicator drift for reasons unrelated to users.