Observability Multi-Cloud

SLOs as Code: Authoring SLIs with OpenSLO and Generating Burn-Rate Alerts via Sloth and Pyrra

Hand-written burn-rate alerts are a maintenance liability. Google’s multi-window multi-burn-rate recipe is four alert rules per objective, each depending on several recording rules across four window lengths, and every one of them has to recompute the right threshold from the SLO target. Author that by hand for one service and it is a pleasant afternoon; author it for sixty services and you have a generator’s worth of copy-paste with subtle per-service bugs nobody finds until the page fails to fire. The threshold 14.4 in a page rule is not a number you should ever type twice, let alone two hundred times.

The fix is to treat the objective as the source of truth and let a tool emit the PromQL. This article builds that pipeline end to end. You describe the SLI and the target once in the vendor-neutral OpenSLO spec — a Kubernetes-style YAML with kind: SLI, kind: SLO, and kind: AlertPolicy — and then compile those documents into Prometheus recording and burn-rate alert rules with Sloth, run Pyrra for a live error-budget UI and an alternative rule generator, wire the resulting alerts into Alertmanager with proper routing and inhibition, and gate all of it behind CI so a malformed spec or a bad metric reference never reaches production. The whole thing lives in Git, gets pull-request review like any other code, and turns “reliability policy” from a slide deck into a set of files that a machine enforces.

The assumption throughout is Prometheus (or a compatible store like Grafana Mimir or Thanos) with the Prometheus Operator managing PrometheusRule objects, Sloth v0.11+, Pyrra v0.7+, and OpenSLO spec v1. Where the mechanics of the burn-rate math and the meaning of an error budget are covered in depth elsewhere — see SLOs and Error Budgets in Practice: Defining SLIs and Building Multi-Window Burn-Rate Alerts — this article is about the specification and generation layer: the spec formats, the generators, the diffs between them, and the pipeline that ties it all together. You will finish able to author an SLI as a reusable object, compile it three different ways, choose deliberately between Sloth, Pyrra, and raw OpenSLO, and defend that choice in a design review.

What problem this solves

The burn-rate alerting pattern is now standard practice, and that is exactly the problem. The pattern is correct and mechanical, which means humans should not be executing it. A single availability SLO at 99.9% over 28 days expands, under the canonical Google SRE workbook recipe, into roughly a dozen Prometheus rules: SLI error-ratio recording rules at five to seven window lengths (5m, 30m, 1h, 2h, 6h, 1d, 3d), two metadata rules carrying the objective and the error budget, and four alert rules pairing a long and short window per severity. Each alert rule embeds a burn-rate threshold — 14.4, 6, 3, 1 — derived arithmetically from the target and the window. Get one threshold wrong and the alert either pages on noise or, far worse, stays silent through a real burn.

What breaks without a generator is not any single rule; it is consistency at scale. Team A writes their rules in Jsonnet, team B in raw YAML, team C copy-pastes from a wiki that is two revisions stale. The long-window page threshold that should key off a 1h window keys off 6h in one service because someone fat-fingered a mixin parameter, and a checkout degradation burns a third of the monthly budget over a weekend before the smoothed slow window crosses threshold. Nobody audits four hundred alert rules by hand. The result is an alerting estate that looks uniform in the runbook and is quietly divergent in production — the single most dangerous state for an on-call system, because you trust it precisely when you should not.

Who hits this: any platform or SRE team running SLO-based alerting across more than a handful of services on Prometheus. It bites hardest on organizations that adopted burn-rate alerting manually (the pattern spread faster than the tooling), teams running multiple Prometheus shards behind Thanos or Mimir where rule drift is invisible across shards, and anyone whose “SLO definitions” live in dashboards and tribal memory rather than in reviewable files. The fix is not “write better rules.” It is “stop writing rules” — declare the objective, generate the rules, and make the generator the only thing that ever types a window length or a burn threshold.

To frame the whole field before the deep dive, here is the layered model this article builds, from the human-authored top to the machine-consumed bottom:

Layer What lives here Who authors it Format Consumed by
Intent “Checkout should succeed 99.9% of the time” Product + SRE English (in the spec’s description) Humans in review
Specification SLI query, target, window, alert policy SRE / service owner OpenSLO / Sloth / Pyrra YAML The generator
Generation Recording + burn-rate alert rules A tool (Sloth / Pyrra) Prometheus rule YAML Prometheus
Evaluation Rule execution, series, alert firing Prometheus PromQL Alertmanager + dashboards
Routing Page vs ticket, grouping, inhibition SRE Alertmanager config PagerDuty / Slack
Enforcement Budget check gating a deploy CI/CD Pipeline step Argo CD / GitHub Actions

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be fluent with Prometheus and PromQL — rate(), sum(), histogram _bucket/_count series, and on()/group_left vector matching. If any of that is shaky, PromQL in Anger: Rate, Histograms, and Aggregation Patterns That Actually Work is the upstream read, and Scaling Prometheus: Recording Rules, Remote-Write, and Long-Term Storage with Thanos and Mimir covers the recording-rule machinery these tools generate. You should understand what an SLI, SLO, and error budget are, and the intuition behind multi-window multi-burn-rate alerting; this article does not re-derive the burn-rate math, and SLOs and Error Budgets in Practice is the conceptual companion. Familiarity with the Prometheus Operator (PrometheusRule, ServiceMonitor) and basic kubectl helps, because both Sloth and Pyrra ship Kubernetes controllers.

This sits in the Observability & SRE track, specifically the reliability engineering layer that sits on top of raw metrics and below incident response. Upstream of it is the metrics pipeline (scraping, recording rules, long-term storage). Downstream of it is on-call — Building an On-Call Practice: PagerDuty Escalation, Alert Routing, and Actionable Runbooks — and the routing layer, Designing Alertmanager Routing Trees: Grouping, Inhibition, Silences, and Dedup, which consumes the labels the generators emit. The dashboards that visualize the generated budget series are their own discipline: Engineering Grafana Dashboards That Get Used: RED, USE, Template Variables, and Provisioning-as-Code. Where this fits in one line: it is the compiler between reliability policy and the alerting/dashboard estate.

A quick map of who owns what across the pipeline, so responsibilities are clear before you build it:

Concern Artifact Owner Reviewed by Changes how often
What “good” means OpenSLO kind: SLI Service owner SRE Rarely (indicator is stable)
The target and window OpenSLO kind: SLO Product + SRE Both Quarterly, deliberately
The alert policy kind: AlertPolicy / Sloth alerting: SRE SRE Rarely (standard windows)
The generated rules PrometheusRule The generator (nobody) CI Every spec change
Routing Alertmanager config SRE On-call leads As teams/receivers change
The budget policy Runbook + gate script Eng leadership + SRE Both Set once, enforced always

Core concepts

Six mental models make every later section obvious.

An SLI is a ratio of good events over valid events — the discipline is entirely in the nouns. An SLI answers “what fraction of the things users asked for went well?” The numerator is good events, the denominator is valid events, and the entire art is choosing those two sets so the ratio moves when — and only when — users hurt. Everything downstream (the target, the budget, the alert) is arithmetic on this ratio; if the ratio is wrong, no amount of generation saves you.

“As code” means the objective is the source of truth, and rules are a build artifact. The inversion at the heart of this article: you do not author Prometheus rules and hope they encode the objective; you author the objective and generate the rules. Rules become like compiled binaries — regenerated deterministically, never edited by hand, never committed to source control. The spec is the source; the rules are dist/. This is what makes drift impossible: no human types a window length or a burn threshold, ever.

Ratio SLIs and threshold SLIs are the two indicator shapes. A ratio metric SLI divides one counter (good, or bad) by another (total) — availability and count-based latency both fit. A threshold metric SLI compares a single gauge against a bound and counts the fraction of time (or samples) it stays within — useful for queue depth, saturation, or freshness where there is no natural “good count / total count”. OpenSLO models both (ratioMetric and thresholdMetric); Sloth and Pyrra are ratio-first. Knowing which shape your indicator is determines the whole spec.

A latency SLI is a count-based ratio over a histogram bucket, never a percentile. The trap: “99.5% of requests under 300ms” is not histogram_quantile(0.995, ...) < 0.3. It is good = count of requests with duration <= 300ms (read straight from the le="0.3" bucket) over total = all requests. A percentile answers “how slow is the 99.5th-slowest request”; the SLI answers “what fraction were fast enough” — different questions, and only the second one composes into an error budget. Build a latency SLI on a percentile and the budget arithmetic is meaningless.

Multi-window multi-burn-rate is a fixed table the generator owns. One threshold cannot be both fast and quiet. A high burn rate over a short window catches a sharp outage in minutes; a low burn rate over a long window catches a slow bleed without paging on every transient blip. The canonical recipe pairs a long and short window per severity so an alert fires only when both agree there is sustained burn. This table — windows and burn-rate thresholds — is identical across every SLO of a given target, which is precisely why it belongs baked into a generator and unit-tested upstream, not retyped per service.

The budgeting method decides how budget is consumed. Occurrences (the default for request-driven SLIs) counts every bad event against the budget — one failed request spends one request’s worth of budget. Timeslices counts every bad window — if a one-minute slice violates the target, the whole minute is bad, regardless of how many requests it held. Occurrences is right for high-throughput APIs; Timeslices suits low-traffic or time-based services (a batch that must finish by 06:00) where “the slice was bad” is the meaningful unit.

The vocabulary in one table

Before the deep sections, pin down every moving part. The glossary repeats these for lookup; this table is the model side by side:

Concept One-line definition Where it lives Why it matters
SLI Ratio of good events over valid events kind: SLI / sli: block The measurement; wrong SLI = wrong everything
SLO An SLI plus a target over a window kind: SLO The promise; source of the budget
Objective / target The number (e.g. 0.999) the SLI must meet objectives: / objective: budget = 1 - target
Error budget Allowed bad fraction = 1 - target Derived; a metadata rule The currency you spend
Ratio metric good/total counter division ratioMetric Availability, count-based latency
Threshold metric A gauge compared to a bound thresholdMetric Saturation, freshness, depth
Budgeting method How budget is consumed budgetingMethod Occurrences vs Timeslices
Burn rate How fast budget is spent vs. the flat rate Generated alert expr 14.4× = budget gone in ~2 days
AlertPolicy Conditions that trigger notification kind: AlertPolicy Ties the SLO to paging
Recording rule Precomputed SLI ratio at a window Generated PrometheusRule slo:sli_error:ratio_rate5m
Sloth OpenSLO/native → Prometheus rule compiler CLI + K8s controller The canonical generator
Pyrra Compiler + live error-budget UI CRD + UI + filesystem Operator-facing view
OpenSLO Vendor-neutral SLO spec YAML documents The portable source format

Authoring SLIs: ratio vs threshold, good vs total

The SLI is the load-bearing decision. Get it right and the target is a negotiation; get it wrong and every alert lies. Three indicator families cover almost every request-driven service, and each has a canonical shape.

Availability — the ratio of not-errors

Availability is the fraction of valid requests served without a server-side error. The clean definition is good = requests that are not 5xx, total = all requests that reached the service. Two subtleties that separate a correct SLI from a plausible-looking wrong one:

Latency — the count-based ratio you must not turn into a percentile

Latency is the fraction of valid requests faster than a threshold T. This is a count, read from the histogram bucket at le="T", divided by the total request count:

# GOOD latency SLI: fraction of requests at or under 300ms.
# Numerator reads the cumulative bucket at le="0.3"; denominator is _count.
sum(rate(http_request_duration_seconds_bucket{service="checkout", le="0.3"}[5m]))
  /
sum(rate(http_request_duration_seconds_count{service="checkout"}[5m]))

Never build a latency SLI on histogram_quantile(). The percentile tells you how slow the tail is; the SLI must tell you what fraction was fast enough, and only the latter composes into a budget you can spend and track. If your metric is a native histogram (Prometheus 2.40+) rather than classic buckets, the bucket selection differs but the principle is identical — count the fast requests, divide by all requests.

Quality / correctness — the failures a status code cannot see

The SLI that most teams miss. A request can return 200 and still be a failure: a recommendation API that degrades to an empty list under load, a search that returns stale results, a payment that “succeeds” but writes an inconsistent record. The availability SLI cannot see any of these because HTTP is happy. The fix is to emit an explicit signal — a result="degraded" label, a correct="false" counter — and count it as bad. This is where the good/total framing earns its keep: good is not “returned 200”, it is “returned a usable answer”, and you get to define usable.

The indicator families side by side, with the canonical query shape for each:

SLI family Good events Valid (total) events Metric type Common mistake
Availability Requests not 5xx All requests at the edge Counter ratio Counting 4xx as failures
Latency Requests <= T (bucket le="T") All requests (_count) Counter ratio (histogram) Using a percentile instead of a bucket count
Quality / correctness Requests with usable result All requests Counter ratio (custom label) Trusting 200 as “good”
Freshness / lag Time since update <= T Sampling windows Threshold (gauge) Ratio-ing a gauge that has no “total”
Throughput / saturation Fraction of time under limit Sampling windows Threshold (gauge) Alerting on the raw gauge, not the SLO

And the two indicator shapes, so you pick the right OpenSLO block:

Property Ratio metric Threshold metric
OpenSLO block ratioMetric (good/total or bad/total) thresholdMetric + op + value
Natural for Availability, latency, correctness Queue depth, freshness, saturation
Underlying data Two counters One gauge
“Good” defined as Count in the good set Samples satisfying the comparison
Sloth support Native (events / raw) Not first-class (model as ratio)
Pyrra support ratio / latency indicators latency/bool_gauge variants
Budget meaning Fraction of bad events Fraction of bad windows/samples

Two rules keep any SLI honest, and they are worth stating as a test you run mentally before writing a line of YAML:

If you can imagine the SLI looking healthy while users are unhappy — or looking sick while users are fine — the indicator is wrong. A latency SLI that ignores the empty-result degradation, an availability SLI that counts synthetic health checks, a correctness SLI missing entirely — each passes review and fails users. Fix the indicator before you argue about the target.

Modeling objectives in the OpenSLO spec

OpenSLO is a vendor-neutral, Kubernetes-style YAML specification for SLOs. Its value is decoupling: the SLI and target live in documents that are independent of whichever engine compiles them, so you can move from Sloth to Pyrra to a commercial platform (Nobl9, which co-authored the spec) without rewriting your intent. The current stable version is openslo/v1, and it defines several kinds that compose: SLI, SLO, Service, AlertPolicy, AlertCondition, and AlertNotificationTarget.

The kind: SLI — a reusable indicator

Define the availability SLI once as its own object so many SLOs can reference it. The ratioMetric with counter: true tells consumers these are monotonic counters to be rate()-d:

# slis/checkout-availability.openslo.yaml
apiVersion: openslo/v1
kind: SLI
metadata:
  name: checkout-availability
  displayName: Checkout availability
spec:
  description: Fraction of checkout requests not served as 5xx, measured at ingress.
  ratioMetric:
    counter: true
    good:
      metricSource:
        type: Prometheus
        spec:
          query: |
            sum(rate(nginx_ingress_controller_requests{service="checkout", status!~"5.."}[1m]))
    total:
      metricSource:
        type: Prometheus
        spec:
          query: |
            sum(rate(nginx_ingress_controller_requests{service="checkout"}[1m]))

You can express the same indicator as bad/total instead of good/total — Sloth internally works in error ratio, so a bad definition maps most directly, but either is valid and the compiler handles the arithmetic.

The kind: SLO — target, window, and budgeting

Bind the SLI to a target and a window in an SLO object. indicatorRef points at the reusable SLI by name; budgetingMethod: Occurrences consumes budget per bad event (the right default for request SLIs); the window is a rolling 28 days — long enough that a single failure does not blow a tight target, short enough to stay relevant to a monthly cadence:

# slos/checkout-availability.openslo.yaml
apiVersion: openslo/v1
kind: SLO
metadata:
  name: checkout-availability
  displayName: Checkout availability
spec:
  service: checkout
  indicatorRef: checkout-availability
  timeWindow:
    - duration: 28d
      isRolling: true
  budgetingMethod: Occurrences
  objectives:
    - displayName: 99.9% of checkout requests succeed
      target: 0.999

For the latency SLO, the SLI counts requests below the threshold by reading the histogram bucket directly. The le="0.3" selector means “300 ms or faster”, and the denominator is the histogram’s _count series:

# slos/checkout-latency.openslo.yaml
apiVersion: openslo/v1
kind: SLI
metadata:
  name: checkout-latency-300ms
spec:
  ratioMetric:
    counter: true
    good:
      metricSource:
        type: Prometheus
        spec:
          query: |
            sum(rate(http_request_duration_seconds_bucket{service="checkout", le="0.3"}[1m]))
    total:
      metricSource:
        type: Prometheus
        spec:
          query: |
            sum(rate(http_request_duration_seconds_count{service="checkout"}[1m]))
---
apiVersion: openslo/v1
kind: SLO
metadata:
  name: checkout-latency
spec:
  service: checkout
  indicatorRef: checkout-latency-300ms
  timeWindow:
    - duration: 28d
      isRolling: true
  budgetingMethod: Occurrences
  objectives:
    - displayName: 99.5% of checkout requests under 300ms
      target: 0.995

The kind: AlertPolicy — connecting the SLO to paging

OpenSLO models alerting as its own kind, so the policy (burn-rate conditions, notification targets) is decoupled from the SLO and reusable across many objectives. An AlertCondition expresses a burn-rate threshold over short and long lookback windows; an AlertPolicy bundles conditions with notification targets:

# alerts/fast-burn.openslo.yaml
apiVersion: openslo/v1
kind: AlertCondition
metadata:
  name: fast-burn
spec:
  description: Page when burning budget fast enough to exhaust it in ~2 days.
  severity: page
  condition:
    kind: burnrate
    op: gte
    threshold: 14.4
    lookbackWindow: 1h
    alertAfter: 5m
---
apiVersion: openslo/v1
kind: AlertPolicy
metadata:
  name: checkout-alerts
spec:
  alertWhenBreaching: true
  conditions:
    - conditionRef: fast-burn
  notificationTargets:
    - targetRef: payments-pagerduty

Not every generator consumes every OpenSLO kind. Sloth reads SLI and SLO and emits its own alerting from the spec’s target; the AlertPolicy/AlertCondition kinds are most fully honored by the reference oslo CLI and commercial backends. This is the first place where “portable spec” and “what a given tool actually does with it” diverge — a theme the tooling-comparison section makes explicit. The OpenSLO kinds and their support status, so you author only what your generator reads:

OpenSLO kind Purpose Sloth reads it Pyrra equivalent oslo CLI
SLI Reusable indicator (good/total) Yes Inline in CRD Yes
SLO Target + window + budgeting Yes ServiceLevelObjective Yes
Service Groups SLOs under a service As a label metadata.labels Yes
AlertPolicy Bundles alert conditions Uses target, not policy Generated from target Yes
AlertCondition A single burn-rate rule Implicit (fixed table) Implicit (fixed table) Yes
AlertNotificationTarget Where alerts go No (Alertmanager’s job) No (Alertmanager’s job) Yes
DataSource Reusable metric source Partially prometheus-url flag Yes

The budgeting-method choice, concretely, because it changes what a bad minute costs:

Aspect Occurrences Timeslices
Unit of consumption One bad event One bad time-slice
Right for High-throughput request APIs Low-traffic / time-based services
Budget of 0.1% means 0.1% of requests may fail 0.1% of slices may violate
A 1-min total outage at 1000 rps Spends ~60,000 events of budget Spends 1 slice of budget
Sensitivity to volume Scales with traffic Independent of traffic
Sloth default Yes (events-based) Not the native model

A target of 0.999 over 28 days is an error budget of 0.1%. Against a checkout that does roughly 50M requests a month, that is a budget of about 50,000 bad requests — a quantity you can spend, track, and reason about, not a vague “43 minutes of downtime”. That subtraction, budget = 1 - target, and its translation from a percentage into a count of events, is the whole point of modeling the objective as data.

Generating Prometheus rules with Sloth

Sloth turns an SLO spec into the full set of Prometheus recording rules and the four multi-window multi-burn-rate alert rules. It speaks its own denser prometheus/v1 format and also reads OpenSLO directly, so you can standardize on OpenSLO as the source and use Sloth purely as the compiler.

The native Sloth spec

Sloth’s native format gives direct control over alert names and labels. The {{.window}} templating is the key move — Sloth fills it with each window length it needs (5m, 30m, 1h, 2h, 6h, 1d, 3d), so you write the query shape once and the generator produces every windowed variant:

# sloth/checkout.sloth.yaml
version: "prometheus/v1"
service: "checkout"
labels:
  team: "payments"
slos:
  - name: "requests-availability"
    objective: 99.9
    description: "Checkout availability measured at ingress."
    sli:
      events:
        error_query: |
          sum(rate(nginx_ingress_controller_requests{service="checkout", status=~"5.."}[{{.window}}]))
        total_query: |
          sum(rate(nginx_ingress_controller_requests{service="checkout"}[{{.window}}]))
    alerting:
      name: CheckoutAvailabilityBurn
      labels:
        category: availability
      annotations:
        summary: "Checkout is burning its availability error budget."
      page_alert:
        labels:
          severity: page
      ticket_alert:
        labels:
          severity: ticket

Sloth offers three SLI input styles, and choosing the right one avoids fighting the tool:

Sloth SLI style Shape Use when Note
events error_query + total_query You have separate bad and total counters Most common; Sloth divides them
raw A single error_ratio_query You already compute the error ratio You own the [{{.window}}] placement
Plugin (sli_plugin) Named reusable Go plugin Standard SLIs repeated across services Central library of indicator shapes

Compiling and validating

Generate the PrometheusRule-ready output from either the native spec or an OpenSLO document — the output is identical:

# Native Sloth spec -> Prometheus rules
sloth generate -i sloth/checkout.sloth.yaml -o rules/checkout.rules.yaml

# OpenSLO spec -> the same output, no rewrite needed
sloth generate \
  --input slos/checkout-availability.openslo.yaml \
  --out rules/checkout-availability.rules.yaml

Validate before you ship — this is your CI gate. validate walks a directory and exits non-zero on any malformed spec:

# Validate every spec under a directory; non-zero exit on failure
sloth validate -i ./sloth/

What Sloth emits

Per SLO, Sloth produces three tiers of rules. Understanding the generated series names is essential because your dashboards and budget queries reference them directly:

Rule group Example series What it holds Windows
SLI error ratios slo:sli_error:ratio_rate5m Bad/total error ratio at a window 5m, 30m, 1h, 2h, 6h, 1d, 3d
Metadata slo:objective:ratio The target (e.g. 0.999) as a series Constant
Metadata slo:error_budget:ratio The budget (1 - target) as a series Constant
Metadata slo:time_period:days The window length in days Constant
Metadata slo:current_burn_rate:ratio Live burn rate for dashboards Recomputed
Metadata slo:period_burn_rate:ratio Burn rate over the whole window Recomputed
Metadata slo:period_error_budget_remaining:ratio Fraction of budget left Recomputed
Alerts CheckoutAvailabilityBurn (page/ticket) The four MWMBR alert rules 1h/5m, 6h/30m, 1d/2h, 3d/6h

Two details that hand-rolled rules almost always miss and Sloth gets right for free:

The Kubernetes controller

In a cluster, skip the CLI and run the controller. It reconciles a sloth.slok.dev/v1 PrometheusServiceLevel custom resource into a managed monitoring.coreos.com/v1 PrometheusRule that the Prometheus Operator then loads:

# k8s/checkout-psl.yaml
apiVersion: sloth.slok.dev/v1
kind: PrometheusServiceLevel
metadata:
  name: checkout
  namespace: monitoring
spec:
  service: "checkout"
  labels:
    team: "payments"
  slos:
    - name: "requests-availability"
      objective: 99.9
      sli:
        events:
          error_query: sum(rate(nginx_ingress_controller_requests{service="checkout", status=~"5.."}[{{.window}}]))
          total_query: sum(rate(nginx_ingress_controller_requests{service="checkout"}[{{.window}}]))
      alerting:
        name: CheckoutAvailabilityBurn
        page_alert:
          labels: {severity: page}
        ticket_alert:
          labels: {severity: ticket}
kubectl apply -f k8s/checkout-psl.yaml
# The controller (deployed once, cluster-wide) writes the PrometheusRule:
kubectl get prometheusrules -n monitoring
# checkout   1   30s

The two Sloth delivery modes, and when each fits:

Mode Input Output Best for Trade-off
CLI (sloth generate) Spec file(s) Rule YAML on disk CI pipelines, GitOps repos You wire the deploy of the output
Controller (PrometheusServiceLevel) CR in the cluster Managed PrometheusRule Operator-based clusters Runtime dependency on the controller

Running Pyrra for live error-budget dashboards

Sloth is a compiler; Pyrra is a compiler and a UI. It reads its own ServiceLevelObjective CRD, generates the equivalent burn-rate recording rules, and serves a dashboard showing remaining budget, current burn rate, and projected exhaustion per objective. Many teams run both: Sloth as the canonical rule generator that CI enforces, Pyrra for the operator-facing view during incidents.

The Pyrra ServiceLevelObjective

The Pyrra objective is intentionally terse — target is a string percentage and window accepts week units. Note that Pyrra takes the metric selector directly and builds the rate()/sum() itself, where Sloth takes the full query:

# pyrra/checkout-availability.yaml
apiVersion: pyrra.dev/v1alpha1
kind: ServiceLevelObjective
metadata:
  name: checkout-availability
  namespace: monitoring
  labels:
    prometheus: k8s
    role: alert-rules
spec:
  target: "99.9"
  window: 4w
  description: Checkout availability at ingress.
  indicator:
    ratio:
      errors:
        metric: nginx_ingress_controller_requests{service="checkout", status=~"5.."}
      total:
        metric: nginx_ingress_controller_requests{service="checkout"}

The latency indicator uses the histogram bucket for success and the _count series for total — the same count-based approach, expressed in Pyrra’s latency indicator:

# pyrra/checkout-latency.yaml
apiVersion: pyrra.dev/v1alpha1
kind: ServiceLevelObjective
metadata:
  name: checkout-latency
  namespace: monitoring
spec:
  target: "99.5"
  window: 4w
  indicator:
    latency:
      success:
        metric: http_request_duration_seconds_bucket{service="checkout", le="0.3"}
      total:
        metric: http_request_duration_seconds_count{service="checkout"}

Pyrra supports several indicator kinds, and picking the right one matters because each generates different PromQL:

Pyrra indicator Fields Models Notes
ratio errors, total Availability, correctness Errors as a subset of total
latency success (bucket), total (_count) Count-based latency Reads the le bucket
latencyNative total (native histogram) Latency on native histograms Prometheus 2.40+
boolGauge metric Up/down or boolean state For a 0/1 signal

The Pyrra deployment topology

Pyrra runs as a split deployment: an api process serving the UI and aggregating across objectives, plus a backend that watches your source of truth and writes rules. The Kubernetes backend reconciles the CRD into PrometheusRule objects; the filesystem backend watches a directory and writes rule files — use it when you are not on the Operator:

# UI + aggregating API (talks to Prometheus for live budget numbers)
pyrra api --prometheus-url=http://prometheus.monitoring:9090

# Backend (choose one):
pyrra kubernetes      # watches ServiceLevelObjective CRs, writes PrometheusRules
pyrra filesystem \
  --config-files="/etc/pyrra/*.yaml" \
  --prometheus-folder="/etc/prometheus/pyrra/"

Pyrra’s generated recording rules carry burn rates at the windows it needs — http_requests:burnrate5m, :burnrate1h, :burnrate6h, and so on — alongside :increase series over the full window that power the dashboard’s budget bar. The naming differs from Sloth’s, which matters if you point a dashboard at one and switch to the other.

The Pyrra process model, so you deploy the right pieces:

Component Command Responsibility Runs where
API / UI pyrra api Serves UI, aggregates, queries Prometheus live One deployment
Kubernetes backend pyrra kubernetes Reconciles CRDs → PrometheusRule One deployment
Filesystem backend pyrra filesystem Watches files → writes rule files Sidecar to Prometheus

Sloth vs Pyrra vs OpenSLO: choosing deliberately

These three are not competitors so much as three layers, and confusing them is the most common design mistake. OpenSLO is a specification — a portable file format with no runtime. Sloth is a generator — CLI plus controller, no UI. Pyrra is a generator plus a UI — its own CRD, a dashboard, and a filesystem/Kubernetes backend. The clean decision is: author intent in OpenSLO if portability matters; generate rules with Sloth (most flexible, CI-friendly); add Pyrra when operators want a live budget dashboard without building Grafana panels by hand.

The head-to-head on the axes that decide a real adoption:

Axis OpenSLO Sloth Pyrra
What it is Spec / file format Rule generator Generator + UI
Runtime component None CLI + optional controller API + backend (always running)
Reads OpenSLO (is OpenSLO) Yes No (own CRD)
Generates Prometheus rules No (needs a tool) Yes Yes
Multi-window multi-burn-rate Modeled, not generated Yes (fixed table) Yes (fixed table)
Live error-budget UI No No Yes (built-in)
Absent-metric alert Modeled Yes (SLOMetricAbsent) No (as of v0.7)
Kubernetes CRD No (plain YAML) PrometheusServiceLevel ServiceLevelObjective
Filesystem (non-K8s) mode N/A CLI writes files pyrra filesystem
Threshold (gauge) SLIs Yes (thresholdMetric) Model as ratio boolGauge
SLI plugins / reuse DataSource refs Go sli_plugin library Metric-selector reuse
Portability to other backends High (Nobl9, etc.) Sloth-specific spec Pyrra-specific CRD
Best single use Source of truth for intent The canonical compiler The operator dashboard

The generated-rule differences that bite when you mix them:

Detail Sloth Pyrra
SLI ratio series name slo:sli_error:ratio_rate5m http_requests:burnrate5m (per-metric)
Budget series slo:error_budget:ratio :increase + computed in UI
Standard labels sloth_service, sloth_slo, sloth_id slo, plus your CRD labels
Alert naming You set it (alerting.name) Derived from the objective
Window set 5m,30m,1h,2h,6h,1d,3d 5m,30m,1h,2h,6h,1d,4d (config-dependent)
Dashboard coupling Bring your own Grafana Built-in Pyrra UI

The common architectures, from simplest to most complete:

Setup Source of truth Generator UI When it fits
Sloth-only Sloth or OpenSLO spec Sloth Grafana (hand-built) You already have Grafana SLO dashboards
Pyrra-only Pyrra CRD Pyrra Pyrra You want a turnkey budget UI
OpenSLO → Sloth OpenSLO spec Sloth Grafana Portability + CI flexibility
Sloth (rules) + Pyrra (UI) OpenSLO / Sloth spec Sloth canonical Pyrra CI-enforced rules and a live dashboard
Commercial (Nobl9) OpenSLO spec Vendor Vendor Managed SLO platform, OpenSLO in

The recommendation for most teams: standardize the source on OpenSLO (portable, reviewable), make Sloth the canonical generator that CI enforces (it has the absent-metric alert and the widest SLI flexibility), and run Pyrra in read-mostly mode for the dashboard if operators want one — feeding it the same objectives. This gives you one source format, one enforced generator, and a live UI, with no single vendor lock-in.

Wiring multi-window multi-burn-rate alerts to Alertmanager

The reason for multiple windows is that one threshold cannot be both fast and quiet. A high burn rate over a short window catches a sharp outage in minutes; a lower burn rate over a long window catches a slow bleed without paging on every transient blip. The canonical table pairs a long and short window per severity so an alert fires only when both agree there is sustained burn — the short window confirms it is happening now, the long window confirms it is sustained:

Severity Long window Short window Burn rate Budget consumed if sustained Detects
Page 1h 5m 14.4 ~2% in 1h Sharp, fast outage
Page 6h 30m 6 ~5% in 6h Sustained moderate burn
Ticket 1d (24h) 2h 3 ~10% in 1d Slow bleed
Ticket 3d (72h) 6h 1 ~10% in 3d Very slow chronic degradation

Both Sloth and Pyrra emit exactly this structure, so you never write the PromQL. Your job is routing. Sloth labels page alerts severity: page and ticket alerts severity: ticket (per your spec); route on those, and group by the service/SLO labels the generator provides:

# alertmanager.yaml
route:
  receiver: default
  group_by: ["sloth_service", "sloth_slo"]
  routes:
    - matchers:
        - severity = "page"
      receiver: pagerduty-payments
      group_wait: 30s
      group_interval: 5m
      repeat_interval: 4h
    - matchers:
        - severity = "ticket"
      receiver: slack-payments
      group_wait: 1m
      repeat_interval: 24h

inhibit_rules:
  # A firing page silences the matching ticket for the same SLO —
  # do not open a Jira while someone is already paged for it.
  - source_matchers: [severity = "page"]
    target_matchers: [severity = "ticket"]
    equal: ["sloth_service", "sloth_slo"]

receivers:
  - name: default
  - name: pagerduty-payments
    pagerduty_configs:
      - routing_key: "<integration-key>"
  - name: slack-payments
    slack_configs:
      - api_url: "<webhook-url>"
        channel: "#payments-slo"

The inhibition rule matters because during a real outage both the page (1h/5m) and the ticket (1d/2h) alerts will eventually fire for the same SLO — the fast page trips first, and as the burn sustains, the slower ticket condition crosses too. Inhibiting the ticket while the page is active stops the on-call from drowning in duplicate noise about a single incident. The full routing behavior of Alertmanager (grouping timers, silence semantics, dedup) is its own topic — see Designing Alertmanager Routing Trees: Grouping, Inhibition, Silences, and Dedup — but the generator hands you clean, consistent labels precisely so this routing is a few matchers, not a regex nightmare.

The labels the generators emit that you route and group on:

Label Emitted by Value Use in Alertmanager
severity Both (your spec) page / ticket Route to PagerDuty vs Slack
sloth_service Sloth Service name group_by, inhibition equal
sloth_slo Sloth SLO name group_by, inhibition equal
sloth_id Sloth service-slo Unique key
slo Pyrra Objective name group_by (Pyrra)
category Your spec e.g. availability Optional routing/dashboards
team Your spec e.g. payments Route to the owning team

Versioning SLO definitions in Git and validating in CI

The specs are code, so they get the same treatment: pull requests, review, and a gate that refuses anything that will not compile. Lay the repo out by concern and keep generated rules out of source control — regenerate them in CI so the spec is unambiguously the source of truth and nobody is ever tempted to hand-edit a generated PrometheusRule:

slo-definitions/
  slis/        # reusable OpenSLO SLI objects
  slos/        # OpenSLO SLO objects (one per objective)
  alerts/      # OpenSLO AlertPolicy / AlertCondition
  pyrra/       # Pyrra ServiceLevelObjective CRs (if using Pyrra UI)
  tests/       # promtool test-rules fixtures
  .github/workflows/slo.yaml

A GitHub Actions job that validates with Sloth and dry-run-renders the output catches the two failures that actually happen in practice: a malformed spec (bad YAML, wrong field), and a spec that compiles but references a metric or label that does not exist (a renamed metric, a typo in a selector):

# .github/workflows/slo.yaml
name: validate-slos
on: [pull_request]
jobs:
  sloth:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Sloth
        run: |
          curl -sL \
            https://github.com/slok/sloth/releases/latest/download/sloth-linux-amd64 \
            -o /usr/local/bin/sloth
          chmod +x /usr/local/bin/sloth
      - name: Validate specs
        run: sloth validate -i ./slos/
      - name: Render rules (fails on bad templates)
        run: |
          for f in slos/*.openslo.yaml; do
            sloth generate -i "$f" -o /dev/null
          done

Add a promtool step to assert the rules are not just well-formed YAML but valid Prometheus rules, and unit-test the burn-rate logic with sample series so a refactor cannot silently break a page. promtool test rules is the step most teams skip and most regret skipping — it is the only check that proves the alert fires when it should and stays silent when it should not:

# Render, then let Prometheus itself check the rules are valid
sloth generate -i slos/checkout-availability.openslo.yaml -o /tmp/checkout.rules.yaml
promtool check rules /tmp/checkout.rules.yaml

# Unit-test the burn-rate math against synthetic series
promtool test rules tests/checkout_burnrate_test.yaml

A minimal promtool test rules fixture that injects a known error rate and asserts the page fires:

# tests/checkout_burnrate_test.yaml
rule_files:
  - /tmp/checkout.rules.yaml
evaluation_interval: 1m
tests:
  - interval: 1m
    input_series:
      # 15% error rate — well above the 14.4x page threshold for a 99.9% SLO
      - series: 'nginx_ingress_controller_requests{service="checkout", status="500"}'
        values: '0+150x60'
      - series: 'nginx_ingress_controller_requests{service="checkout", status="200"}'
        values: '0+850x60'
    alert_rule_test:
      - eval_time: 10m
        alertname: CheckoutAvailabilityBurn
        exp_alerts:
          - exp_labels:
              severity: page
              sloth_service: checkout
              sloth_slo: requests-availability

The CI gates and what each one actually catches — layer them, because each catches a class the others miss:

CI step Command Catches Misses
Spec validation sloth validate Malformed spec, bad fields Wrong metric names
Render check sloth generate -o /dev/null Bad templates, unresolvable refs Semantic errors
Rule lint promtool check rules Invalid PromQL, bad rule structure Wrong thresholds
Rule unit test promtool test rules Alert fires/silent at wrong burn rate Missing SLIs entirely
Metric existence Query Prometheus in CI (optional) Selector matches no series Rules that never fire
Diff review PR review of the spec Wrong target, wrong SLI intent Everything a human misses

The pipeline stages end to end, from commit to enforcement:

Stage Trigger Action Failure blocks
Author Developer edits spec Write OpenSLO YAML
PR open Push to branch sloth validate + render Merge
PR review Reviewer Human reviews intent + target Merge
Merge Merge to main Regenerate rules, apply to cluster Deploy
Runtime Prometheus loads rules Evaluate SLI + burn rate
Release gate Deploy pipeline Query remaining budget Promotion

Reporting budget consumption and gating releases

The error budget only changes behavior if someone looks at it on a cadence and it has teeth. Two consumers matter: a weekly stakeholder report, and the deploy pipeline. The budget policy — what happens when it is exhausted — is the negotiated line between feature velocity and stability, and enforcing it automatically is what stops it from being re-litigated in every planning meeting.

For reporting, query the budget directly. Sloth’s metadata rules expose the objective and the SLI error ratio as series, so a single PromQL expression gives you “fraction of budget remaining”:

# Fraction of 28d error budget remaining for the availability SLO.
# 1 = full budget, 0 = exhausted, < 0 = over budget.
1 - (
  slo:sli_error:ratio_rate28d{sloth_service="checkout", sloth_slo="requests-availability"}
  / on(sloth_service, sloth_slo)
  slo:error_budget:ratio{sloth_service="checkout", sloth_slo="requests-availability"}
)

If you emitted the slo:period_error_budget_remaining:ratio metadata rule, you can read remaining budget directly without the subtraction — Sloth precomputes it. Either way, the number is a first-class series you can graph, alert on, and query from a pipeline.

For release gating, the pipeline asks the same question before promoting. If the budget is exhausted, the policy is to freeze risky changes and spend the next cycle on reliability. The gate turns policy into a non-negotiable check:

# Returns the remaining-budget fraction; gate promotion on it.
REMAINING=$(curl -sG http://prometheus:9090/api/v1/query \
  --data-urlencode 'query=1 - (slo:sli_error:ratio_rate28d{sloth_service="checkout",sloth_slo="requests-availability"} / on(sloth_service,sloth_slo) slo:error_budget:ratio{sloth_service="checkout",sloth_slo="requests-availability"})' \
  | jq -r '.data.result[0].value[1]')

# Block prod promotion if less than 10% of the budget remains.
awk -v r="$REMAINING" 'BEGIN { exit (r >= 0.10) }' \
  && { echo "Budget below 10% — prod promotion blocked"; exit 1; } \
  || echo "Budget OK ($REMAINING remaining)"

An error-budget policy is a table of thresholds and consequences, agreed once and enforced by the gate:

Budget remaining Policy Enforcement
> 50% Ship freely None
20–50% Ship, but review risky changes Advisory PR label
10–20% Feature flags only; harden Gate warns, requires ack
0–10% Freeze non-hotfix promotions Gate blocks deploy
< 0% (over budget) Reliability sprint; incident review Gate blocks + page eng lead

The budget series you can query and what each answers:

Series / expression Answers Used by
slo:error_budget:ratio What is the total budget? Reference denominator
slo:sli_error:ratio_rate28d Error ratio over the window Budget consumption
slo:period_error_budget_remaining:ratio Fraction of budget left Report + gate
slo:current_burn_rate:ratio How fast are we burning now? Live dashboard
Projected exhaustion (burn × remaining) When will it run out? Pyrra UI / capacity planning

Architecture at a glance

Read the pipeline as a left-to-right flow from human intent to enforced behavior, with a single source of truth at the top feeding three consumers at the bottom. On the left, a service owner authors two kinds of document in Git: reusable kind: SLI objects that define good-and-total for availability, latency, and correctness, and kind: SLO objects that bind each SLI to a target (0.999) and a rolling 28-day window with a budgeting method. These plain-YAML documents are the only thing a human ever writes; they carry the intent in a description and the math in target.

In the middle sits the generation layer, and this is the pivot of the whole design: the specs flow into Sloth, the canonical compiler, which — via CLI in CI or via the PrometheusServiceLevel controller in-cluster — expands each SLO into a full PrometheusRule: SLI error-ratio recording rules at seven window lengths (slo:sli_error:ratio_rate5m through rate3d), metadata rules carrying the objective and the error budget as series, an absent-metric alert, and the four multi-window multi-burn-rate alert rules. No human types a window or a 14.4; the four-window table is baked into the generator and unit-tested upstream. Optionally, the same objectives feed Pyrra, which generates its own equivalent burn-rate rules and, more importantly, serves a live error-budget dashboard.

On the right are the three consumers of what the generator produced. Prometheus evaluates the recording rules and the burn-rate alerts, and when a burn is both fast and sustained it fires an alert carrying clean severity: page / severity: ticket and sloth_service / sloth_slo labels. Alertmanager routes on those labels — page to PagerDuty, ticket to Slack — with an inhibition rule that silences the ticket while the page is active, so one incident is one notification stream. Dashboards and the release gate read the budget series directly: Grafana (or the Pyrra UI) graphs remaining budget and projected exhaustion, and the CI/CD pipeline queries 1 - (error_ratio / budget) before every promotion, blocking risky deploys when less than 10% of the budget remains. The whole diagram is one arrow: intent in Git → rules by machine → alerts, dashboards, and gates that enforce reliability policy without a human ever retyping a threshold. Notice that every downstream consumer keys off labels and series the generator produced — which is exactly why generating them consistently, rather than hand-writing them per service, is the point.

Real-world scenario

Meridian Payments ran 70+ services across three Prometheus shards behind Thanos, serving a card-processing platform with a hard regulatory reporting obligation on availability. They had migrated to burn-rate alerting two years earlier — good instinct — but authored every rule by hand in Jsonnet mixins. The SRE team was six engineers; the observability stack cost roughly ₹9,00,000/month across compute and storage.

The incident that forced the change was quiet, which was the worst part. A checkout dependency began returning intermittent 5xx on a Friday evening. The burn was real — about 8% error rate — but it did not page. The post-mortem found why: a Jsonnet template bug had set the long-window page threshold to key off a 6h window where the spec called for 1h. The slower window smoothed the Friday-evening spike below the 14.4× threshold, so the page condition never crossed. By the time the ticket alert (1d/2h) fired on Saturday morning, the degradation had burned 30% of the monthly error budget over roughly fourteen hours. Nobody had audited the generated rules against the spec because there were four hundred of them and they all “looked right.”

The constraint was that they could not rip out Jsonnet wholesale — dashboards, mixins, and CI all depended on it, and a big-bang migration was itself a reliability risk. So they inverted the relationship instead of replacing the stack. SLO definitions moved to OpenSLO specs in a dedicated slo-definitions repo, one kind: SLI and kind: SLO per objective, reviewed like code. A CI job compiled them with Sloth into the PrometheusRule objects that Jsonnet had previously hand-built. Jsonnet kept owning dashboards but consumed the Sloth-generated slo:objective:ratio and slo:error_budget:ratio series instead of redefining them. The window-length bug class disappeared entirely, because no human typed a window length again — the four-window table lives in Sloth’s generator and is unit-tested upstream, and promtool test rules in their CI now asserts the page fires at 14.4× and stays silent below it.

The piece that sold it to leadership was the gate. They wired the remaining-budget query into the Argo CD pre-sync hook so a service whose budget was exhausted could not promote a non-hotfix change to production. A PrometheusRule flipped a gate: freeze label when budget hit zero, and the sync hook read that alert’s state via the Alertmanager API:

# A PrometheusRule that flips an inhibiting label when budget is gone.
# Argo CD's pre-sync gate reads this alert's state via the Alertmanager API.
- alert: CheckoutBudgetExhausted
  expr: |
    (slo:sli_error:ratio_rate28d{sloth_service="checkout", sloth_slo="requests-availability"}
      / on(sloth_service, sloth_slo)
     slo:error_budget:ratio{sloth_service="checkout", sloth_slo="requests-availability"}) >= 1
  for: 15m
  labels:
    severity: ticket
    gate: freeze
  annotations:
    summary: "Checkout availability budget exhausted; non-hotfix promotions frozen."

Within a quarter, two things changed measurably. The page-on-noise rate dropped sharply, because the generated multi-window logic was correct and consistent where the hand-rolled Jsonnet had drifted. And the two budget-blown incidents that occurred after the migration were both caught by the short window in minutes, not over a weekend — the exact failure mode that had cost them 30% of a month’s budget was now impossible to reproduce, because the thresholds were generated, tested, and identical across all 70 services. The migration took one engineer about six weeks, running Sloth-generated rules side-by-side with the Jsonnet ones for two weeks to confirm parity before cutting over.

The incident and the fix as a timeline, because the order is the lesson:

Time Event Root cause / action Effect
Fri 19:00 Checkout dependency 5xx begins ~8% error rate Budget burning, no page
Fri 19:00–Sat 09:00 Burn continues, page silent Page keyed off 6h window (spec: 1h) 30% of month’s budget gone
Sat 09:00 Ticket alert finally fires 1d/2h condition crosses Investigation starts, late
Post-mortem Root cause found Jsonnet template window bug Decision: generate, don’t hand-write
Week 1–2 OpenSLO specs authored One SLI+SLO per objective Intent in reviewable files
Week 3–4 Sloth in CI, side-by-side Rules generated, parity checked Confidence before cutover
Week 5 Cutover + Argo gate Sloth rules canonical; budget gate live Drift impossible; freezes automatic
+1 quarter Two more budget incidents Caught by short window in minutes Weekend-long burn now impossible

Advantages and disadvantages

Generating SLO rules from a spec both solves the drift problem and introduces a build dependency you must operate. Weigh it honestly:

Advantages (why generate) Disadvantages (the cost)
No human ever types a window length or a burn threshold — the drift bug class disappears A generator is a new dependency to install, version, and keep current across CI and cluster
The four-window MWMBR table is baked in and unit-tested upstream, not re-derived per service The abstraction hides the PromQL; engineers must learn to read generated rules to debug
One spec change regenerates all rules consistently across 70+ services A generator bug affects every SLO at once (mitigated by pinning versions + testing)
Specs are reviewable in PRs; intent and target are visible, not buried in Jsonnet Two spec formats (Sloth native vs OpenSLO) and multiple tools invite confusion
Absent-metric alerts (Sloth) catch the “silently stopped evaluating” failure hand-rolled rules miss Portability is partial: AlertPolicy and threshold SLIs aren’t uniformly supported
Budget series are first-class — report, graph, and gate on them with one query Live UIs (Pyrra) add an always-running component and its own CRD to operate
Portable source (OpenSLO) decouples intent from any single vendor The generated series names differ by tool; switching generators breaks dashboards

The model is right for any team running SLO-based alerting across more than a handful of services, where consistency and reviewability matter more than the marginal control of hand-written PromQL. It is overkill for a single service with one SLO that one engineer owns end to end — there, a hand-written rule you fully understand may be simpler than installing a generator. It bites hardest when a team adopts both Sloth and Pyrra without deciding which is canonical, or when they commit generated rules to Git and then hand-edit them, defeating the entire “spec is source of truth” premise. The disadvantages are all manageable — pin versions, pick one canonical generator, never commit generated output, and test the rules — but only if you know they exist.

Hands-on lab

Author an OpenSLO availability SLI and SLO, generate the full rule set with Sloth, load it into a local Prometheus, and unit-test the burn-rate alert — all locally, no cloud, free. You need Docker and about 20 minutes.

Step 1 — Install Sloth and set up a workspace.

mkdir -p slo-lab/{slis,slos,rules,tests} && cd slo-lab
curl -sL https://github.com/slok/sloth/releases/latest/download/sloth-$(uname -s | tr '[:upper:]' '[:lower:]')-amd64 \
  -o /usr/local/bin/sloth && chmod +x /usr/local/bin/sloth
sloth version

Expected: a version string like v0.11.0.

Step 2 — Author the OpenSLO SLI and SLO. Write a single file with both kinds:

cat > slos/checkout.openslo.yaml <<'EOF'
apiVersion: openslo/v1
kind: SLI
metadata:
  name: checkout-availability
spec:
  ratioMetric:
    counter: true
    good:
      metricSource:
        type: Prometheus
        spec:
          query: sum(rate(http_requests_total{job="checkout", code!~"5.."}[{{.window}}]))
    total:
      metricSource:
        type: Prometheus
        spec:
          query: sum(rate(http_requests_total{job="checkout"}[{{.window}}]))
---
apiVersion: openslo/v1
kind: SLO
metadata:
  name: checkout-availability
spec:
  service: checkout
  indicatorRef: checkout-availability
  timeWindow:
    - duration: 28d
      isRolling: true
  budgetingMethod: Occurrences
  objectives:
    - displayName: 99.9% availability
      target: 0.999
EOF

Step 3 — Validate and generate the rules.

sloth validate -i ./slos/
sloth generate -i slos/checkout.openslo.yaml -o rules/checkout.rules.yaml

Expected: validate prints a success line and exits 0; generate writes rules/checkout.rules.yaml. Inspect it — you should see recording rules named slo:sli_error:ratio_rate5m through rate3d, metadata rules slo:objective:ratio and slo:error_budget:ratio, a SLOMetricAbsent alert, and the multi-window burn-rate alert group.

grep -E "record:|alert:" rules/checkout.rules.yaml | head -30

Step 4 — Lint the generated rules with promtool.

docker run --rm -v "$PWD/rules:/rules" prom/prometheus:latest \
  promtool check rules /rules/checkout.rules.yaml

Expected: SUCCESS: N rules found.

Step 5 — Unit-test the burn-rate alert. Create a fixture that injects a 15% error rate and asserts the page fires:

cat > tests/burnrate_test.yaml <<'EOF'
rule_files:
  - /rules/checkout.rules.yaml
evaluation_interval: 1m
tests:
  - interval: 1m
    input_series:
      - series: 'http_requests_total{job="checkout", code="500"}'
        values: '0+150x120'
      - series: 'http_requests_total{job="checkout", code="200"}'
        values: '0+850x120'
    alert_rule_test:
      - eval_time: 10m
        alertname: checkout-availability
        exp_alerts:
          - exp_labels:
              severity: page
              sloth_service: checkout
              sloth_slo: checkout-availability
EOF

docker run --rm -v "$PWD/rules:/rules" -v "$PWD/tests:/tests" prom/prometheus:latest \
  promtool test rules /tests/burnrate_test.yaml

Expected: SUCCESS. If the alertname or labels differ, run grep "alert:" rules/checkout.rules.yaml to read the exact generated names and adjust the fixture — this is the real skill: reading what the generator produced.

Step 6 — Load the rules into a live Prometheus (optional).

cat > prometheus.yml <<'EOF'
global:
  evaluation_interval: 15s
rule_files:
  - /rules/checkout.rules.yaml
EOF
docker run --rm -d --name prom -p 9090:9090 \
  -v "$PWD/prometheus.yml:/etc/prometheus/prometheus.yml" \
  -v "$PWD/rules:/rules" prom/prometheus:latest
# Open http://localhost:9090/rules — the SLO recording/alert rules appear with no eval errors.

Validation checklist. You authored intent as OpenSLO, generated a dozen correct rules from a two-field target, linted them with the same promtool Prometheus uses, and proved the page fires at a known burn rate — without writing a single threshold or window length. The steps mapped to what each proves:

Step What you did What it proves
2 Author OpenSLO SLI + SLO Intent is a small, reviewable file
3 sloth validate + generate A dozen rules come from two fields
4 promtool check rules The output is valid Prometheus, not just YAML
5 promtool test rules The alert fires at the right burn rate
6 Load into Prometheus The rules evaluate live with no errors

Teardown.

docker rm -f prom 2>/dev/null
cd .. && rm -rf slo-lab

Cost note. Entirely local and free — Docker pulls the Prometheus image once (~250 MB); no cloud resources are created.

Common mistakes & troubleshooting

The failure modes of an SLO-as-code pipeline, as a symptom → cause → confirm → fix table you can scan mid-incident:

# Symptom Root cause Confirm Fix
1 Alert never fires despite a real outage SLI selector matches no series (renamed metric) Query the good/total PromQL directly in Prometheus — empty result Fix the selector; add a metric-existence check in CI
2 SLI ratio > 1 or negative good and total count different sets (good not a subset of total) Graph slo:sli_error:ratio_rate5m — should be 0–1 Ensure goodtotal; use bad/total if clearer
3 Latency SLO passes while the tail is terrible Built on a percentile, not a bucket count Read the SLI query — if it uses histogram_quantile, it’s wrong Rewrite as bucket{le="T"} / _count
4 Page fires on every transient blip Only a short window used, or alertAfter/for too low Check the alert expr for a missing long-window and Use the generator’s MWMBR output; don’t hand-trim it
5 Slow burn never pages, only tickets late Long-window threshold on the wrong window (the Meridian bug) Diff generated rule windows against the spec Regenerate; never hand-edit generated rules
6 sloth generate fails on a valid-looking spec {{.window}} missing from the query, or wrong SLI style Run sloth validate -i for the exact error Add [{{.window}}]; match events/raw correctly
7 Rules load but nothing appears in /rules PrometheusRule label doesn’t match the Prometheus ruleSelector kubectl get prometheusrule -o yaml — check labels Add the label the Prometheus CR selects (e.g. role: alert-rules)
8 Budget query returns no data Wrong sloth_service/sloth_slo label values List labels: count by(sloth_service,sloth_slo)(slo:objective:ratio) Use the exact generated label values
9 Both page and ticket page the on-call for one incident Missing inhibition rule in Alertmanager Check amtool config routes / firing alerts Add the pageticket inhibit with equal on service+slo
10 Alert fires but severity label absent → routes to default Sloth spec missing page_alert/ticket_alert labels Grep the generated alert for severity Add the labels to the spec’s alerting: block
11 Pyrra dashboard budget disagrees with the Grafana panel Grafana panel points at Sloth series, Pyrra computes its own Compare the two series names Pick one generator’s series as canonical for dashboards
12 Rules silently stopped evaluating after a metric rename No absent-metric alert Check for a SLOMetricAbsent in the rule file Use Sloth (emits it); or add an absent() alert manually
13 CI passes but the alert never fires in prod promtool check passed, but no test rules for logic No alert_rule_test in CI Add promtool test rules with a known-burn fixture
14 Generated rules drifted from the spec in Git Someone committed and hand-edited generated output git blame the PrometheusRule Remove generated rules from source control; regenerate in CI

The entries that bite hardest, expanded:

1. The alert never fires because the SLI matches nothing. The single most dangerous failure — it looks healthy. A metric got renamed (http_requests_totalhttp_server_requests_total), and the good/total queries now return empty. The error ratio is 0/0 = nothing, no alert, no page, ever. Confirm by pasting the SLI’s good and total queries straight into the Prometheus expression browser; an empty result is the smoking gun. Fix the selector, and add a CI step that queries Prometheus to assert each SLI’s total matches at least one series — the only check that catches a rename before production does.

3. The latency SLO passes while p99 is a disaster. The SLI was written as histogram_quantile(0.995, ...) < 0.3, which is not a ratio and does not compose into a budget. It “passes” because the expression is true most of the time, but it is measuring the wrong thing entirely. Confirm by reading the SLI query — any histogram_quantile in an SLI definition is a bug. Fix by rewriting as a count-based ratio: sum(rate(..._bucket{le="0.3"}[w])) / sum(rate(..._count[w])).

5. The slow burn never pages — the Meridian failure. A hand-edited or mis-templated rule has the long-window condition keyed off the wrong window (6h instead of 1h), so a sustained-but-moderate burn stays smoothed below threshold and only the ticket fires, late. Confirm by diffing the windows in the generated alert against the canonical MWMBR table. Fix by regenerating from the spec and — critically — never hand-editing generated rules, so the generator’s tested table is always what runs.

12. Rules silently stopped evaluating. A metric rename or a scrape config change means the SLI series simply stops existing, and without an absent-metric alert, nothing tells you. Confirm by checking whether the rule file contains a SLOMetricAbsent alert. Fix by using Sloth (which emits it automatically) or, if you must hand-roll, adding absent(slo:sli_error:ratio_rate5m{...}) as an alert of its own.

Best practices

Security notes

The security posture of the pipeline, control by control:

Control Mechanism Protects against
Metric-source auth Token in Secret, not spec Credential leak via committed YAML
Generator RBAC Namespaced Role on PrometheusRule Over-privileged controller
Annotation hygiene Review description/annotations Topology leak to Slack/PagerDuty
Fail-closed gate Gate blocks on query failure Silent deploy of unreliable code
PR review on targets Branch protection on SLO repo Quiet target-loosening
Scoped CI identity OIDC / scoped service account Cluster-wide compromise from CI

Cost & sizing

The cost of SLO-as-code is almost entirely cardinality and rule-evaluation load, not licensing — Sloth, Pyrra, and OpenSLO are all open source and free. What drives the bill:

Rough figures for a mid-size estate (70 services, ~200 SLOs) on self-managed Prometheus + Thanos: the incremental compute for rule evaluation and the extra series is on the order of ₹8,000–20,000/month in additional Prometheus/Thanos capacity, and Pyrra’s components add perhaps ₹2,000–4,000/month in pod resources. Against the cost of a single budget-blown incident that a correct, generated alert would have caught in minutes, this is negligible — the Meridian weekend burn alone was worth far more than a year of the pipeline’s overhead. The cost drivers and what each buys:

Cost driver What you pay for Rough INR/month (mid estate) Reduce it by
Extra recording-rule series ~13 series/SLO ingestion + storage ₹5,000–12,000 Fewer SLOs; drop unused windows
Rule evaluation CPU Long-window (rate28d) scans ₹3,000–8,000 Downsampling (Thanos/Mimir)
Pyrra components api + backend pods + live queries ₹2,000–4,000 Run Pyrra read-only, or skip it
Sloth controller One lightweight pod < ₹1,000 Use CLI-in-CI instead
Long-term budget history Retained metadata series ₹1,000–3,000 Shorter retention on non-reporting series
Tooling licenses Nothing — all OSS ₹0

Interview & exam questions

1. Why generate SLO rules from a spec instead of writing PromQL by hand? Because the multi-window multi-burn-rate pattern is mechanical and consistent across services, and humans introduce drift at scale — a mistyped window length or burn threshold in one of hundreds of rules can silence a real page. A generator bakes the tested table in once, so no human types a threshold, and one spec change regenerates every rule identically. Consistency at scale is the win, not saving keystrokes.

2. What is the difference between a ratio SLI and a threshold SLI? A ratio SLI divides one counter (good or bad) by another (total) — availability and count-based latency fit. A threshold SLI compares a single gauge against a bound and counts the fraction satisfying it — useful for saturation, freshness, or queue depth where there is no natural good/total count. OpenSLO models both (ratioMetric, thresholdMetric); Sloth and Pyrra are ratio-first.

3. Why must a latency SLI be a count-based bucket ratio and not a percentile? A percentile answers “how slow is the 99.5th-slowest request”; the SLI must answer “what fraction of requests were fast enough”. Only the second composes into an error budget you can spend and track. A latency SLI is sum(rate(bucket{le="T"}[w])) / sum(rate(_count[w])) — the count at or under the threshold over the total — never histogram_quantile().

4. Explain budgetingMethod: Occurrences vs Timeslices. Occurrences consumes budget per bad event — one failed request spends one request’s worth, right for high-throughput APIs where volume is the meaningful unit. Timeslices consumes per bad window — a violating one-minute slice spends one slice regardless of request count, right for low-traffic or time-based services where “the slice was bad” is the unit. Sloth is events/Occurrences-native.

5. What does Sloth generate from a single SLO, and what would you miss writing it by hand? SLI error-ratio recording rules at ~7 windows (5m to 3d), metadata rules for the objective and error budget, the four MWMBR alert rules (page and ticket), and a SLOMetricAbsent alert. The absent-metric alert and consistent labels are what hand-rolled rules almost always miss — the former catches a rule that silently stopped evaluating; the latter makes routing and inhibition tractable.

6. How do OpenSLO, Sloth, and Pyrra relate? OpenSLO is a spec (a portable file format, no runtime). Sloth is a generator (CLI + controller, no UI) that reads OpenSLO or its own format. Pyrra is a generator plus a UI with its own CRD, a live budget dashboard, and filesystem/Kubernetes backends. A common architecture is OpenSLO as source, Sloth as the canonical generator, Pyrra for the operator dashboard.

7. Your burn-rate alert never fired during a real outage — what is the first thing you check? Whether the SLI’s good/total queries match any series. A renamed metric leaves the ratio computing on nothing (0/0), so no alert ever fires and everything looks healthy. Paste the SLI queries into Prometheus; an empty result is the cause. Prevent it with a CI check that asserts each SLI’s total matches at least one series, and with a SLOMetricAbsent alert.

8. Why keep generated rules out of source control? So the spec is unambiguously the source of truth and nobody can hand-edit a generated rule into drift. Committing generated output invites someone to tweak the PrometheusRule directly, silently diverging it from the spec — exactly the drift the generator was meant to eliminate. Regenerate in CI on merge instead.

9. How do you route the generated alerts, and why is inhibition needed? Route on the severity: page / severity: ticket labels the generator emits (page → PagerDuty, ticket → Slack), grouping by sloth_service/sloth_slo. Inhibition is needed because during a real outage both the fast page (1h/5m) and the slower ticket (1d/2h) conditions fire for the same SLO; an inhibit rule silencing the ticket while the page is active keeps one incident to one notification stream.

10. What CI gates would you put on an SLO-definitions repo? sloth validate (malformed specs), sloth generate -o /dev/null (bad templates/refs), promtool check rules (invalid PromQL/structure), and — the one most skip — promtool test rules with a known-burn fixture that proves the page fires at the right burn rate and stays silent below it. Optionally, a query against Prometheus to confirm each SLI selector matches real series.

11. How does the error budget gate a release, and what is the failure mode to avoid? The pipeline queries remaining budget (1 - error_ratio/budget) before promoting and blocks if it is below a threshold (e.g. 10%). The failure mode to avoid is a gate that fails open — if Prometheus is unreachable the gate must block or require explicit override, never silently pass, or it is not a gate.

12. When is generating SLO rules overkill? For a single service with one SLO owned end to end by one engineer who fully understands the hand-written rule — there, installing and operating a generator may add more complexity than it removes. The value of generation is consistency across many services; with one SLO there is nothing to be consistent with.

These map to SRE and platform-engineering interviews rather than a single vendor cert, though the Prometheus/PromQL depth aligns with the PCA (Prometheus Certified Associate) and the SLO/error-budget material with Google’s SRE body of knowledge. A compact revision map:

Question theme Relevant body of knowledge
SLI/SLO/error-budget definitions Google SRE Workbook; PCA
PromQL for ratios/histograms Prometheus Certified Associate
Multi-window burn-rate alerting SRE Workbook (Alerting on SLOs)
Generators (Sloth/Pyrra) + OpenSLO Platform/SRE tooling
CI/CD gating on budgets DevOps / release-engineering

Quick check

  1. You need “99.5% of requests under 300ms” as an SLI. Write the shape of the good and total queries, and name the mistake you must avoid.
  2. True or false: you should commit the generated PrometheusRule files to Git so they are versioned.
  3. Sloth and Pyrra both generate burn-rate rules. What does Pyrra add that Sloth does not, and what does Sloth emit that Pyrra (as of v0.7) does not?
  4. Your burn-rate page alert never fired during a genuine 8% error outage, and the SLI dashboard shows no data. What is the single most likely cause?
  5. What is the difference between budgetingMethod: Occurrences and Timeslices, in one sentence each?

Answers

  1. good = sum(rate(http_request_duration_seconds_bucket{le="0.3"}[w])), total = sum(rate(http_request_duration_seconds_count[w])) — a count-based ratio over the le="0.3" histogram bucket. The mistake to avoid is using histogram_quantile() (a percentile), which measures how slow the tail is, not what fraction was fast enough, and does not compose into a budget.
  2. False. Generated rules are a build artifact; committing them invites hand-editing and drift. Keep only the spec in Git and regenerate the rules in CI on every merge, so the spec is the unambiguous source of truth.
  3. Pyrra adds a live error-budget UI (dashboard of remaining budget, burn rate, projected exhaustion) via its own ServiceLevelObjective CRD. Sloth emits a SLOMetricAbsent alert — firing when the SLI series disappears entirely — which Pyrra does not, and which catches a rule that has silently stopped evaluating.
  4. The SLI’s good/total queries match no series — almost certainly a renamed or mis-selected metric, so the error ratio computes on nothing and no alert can fire. Paste the SLI queries into Prometheus; an empty result confirms it. Prevent it with a CI metric-existence check and an absent-metric alert.
  5. Occurrences consumes budget per bad event (one failed request spends one request’s worth) — right for high-throughput APIs. Timeslices consumes budget per bad time-slice (a violating minute spends one slice regardless of request count) — right for low-traffic or time-based services.

Glossary

Next steps

You can now author SLIs and SLOs as code, generate correct multi-window burn-rate rules from them, and enforce the budget in CI. Build outward:

slosliopensloslothpyrraerror-budgetburn-rateprometheus
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading