Skip to content

feat(runner/slo): multi-window burn-rate evaluation for SLOs - #554

Open
RamanKharchee wants to merge 2 commits into
mainfrom
feat/slo-multi-window-burn-rate
Open

feat(runner/slo): multi-window burn-rate evaluation for SLOs#554
RamanKharchee wants to merge 2 commits into
mainfrom
feat/slo-multi-window-burn-rate

Conversation

@RamanKharchee

Copy link
Copy Markdown
Contributor

Summary

Ports coroot's multi-window multi-burn-rate SLO alerting (model/alert.go + watchers/incidents.go on coroot main) into slo_generator. runner/pkg/enrichers/slo.go was originally a port of coroot's auditor/slo.go, but the multi-window machinery did not come across — only the constant 14.4.

What was wrong

  • Single window (1h), single threshold. Fired on any hour that burned >14.4x, including an hour that had already recovered, and never fired at all on a slow burn that still exhausts a 30-day budget.
  • No data-coverage guard, so a low-traffic service whose exporter went quiet read as a total outage.
  • alert_message computed int(burnRate / 3600) — a burn rate divided by seconds-per-hour, always 0. Every message read "within 0 hours". Coroot formats the window there (br.LongWindow / timeseries.Hour); the port had substituted the rate for the window.

What this does

Each rule pairs a long and a short window and fires only when both exceed the threshold — the long window establishes the burn is significant, the short window confirms it is still happening:

Burn rate Long Short Budget consumed
14.4x 1h 5m 2%
6x 6h 15m 5%

Kept in sync with coroot main — note upstream has since dropped the 3x/24h WARNING tier and tightened tier 2's short window from 30m to 15m.

Coverage guard: a window is judged only when ≥50% of it reported data. Coroot counts defined points in a raw timeseries; the agent only had a single increase() scalar, so the window is now evaluated at a window/12 step and defined points counted across good ∪ bad ∪ valid — a workload with zero errors has no filter_bad series at all and must not be penalised for it.

Backward compatibility

burn_rates and severity are additive. Legacy alert/alert_message are still emitted, and are left untouched when the burn-rate pass yields nothing — so a backend that predates burn_rates keeps working, and a Prometheus hiccup degrades the report rather than blanking the SLO. The agent ships per-cluster on the customer's upgrade cadence, so this had to be version-skew safe in both directions.

Type of change

  • Bug fix (non-breaking)
  • New feature (non-breaking)

Chart version

Test plan

10 new unit tests in slo_burnrate_test.go, including the one that motivates the change:

  • TestCalcBurnRates_ShortWindowRecoveredDoesNotFire — long window 20x, short window 0.1x ⇒ does not fire. Single-window evaluation alerts here.
  • TestCalcBurnRates_BothWindowsOverThresholdFires — 20x/30x ⇒ CRITICAL
  • TestCalcBurnRates_LowCoverageSkipsRule — coverage 0.4 ⇒ rule not judged
  • TestCalcBurnRates_ValidSeriesDerivesBaddistribution_cut shape (bad = valid − good, coroot's slowF = total − fast)
  • TestFormatSLOStatus_UsesLongWindow — regression guard for the within 0 hours bug
  • TestAttachBurnRates_EmptyKeepsLegacyFields — version-skew guard
go build ./...    # pass
go vet ./...      # pass
gofmt -l          # clean
go test ./pkg/enrichers/   # ok

Not yet exercised against a live cluster — the burn-rate math is unit-tested, but the extra PromQL fan-out (4 windows x 2 queries per config, vs 2 before) should be watched on a real Prometheus before this goes to prod.

Related issues

Backend counterpart (consumes burn_rates, adds NO_DATA status): nudgebee-enterprise PR — linked separately.
Refs https://github.com/nudgebee/nudgebee-enterprise/issues/28482

🤖 Generated with Claude Code

Port coroot's multi-window multi-burn-rate alerting (model/alert.go +
watchers/incidents.go on coroot main) into slo_generator.

Single-window evaluation fired on any hour whose burn rate exceeded
14.4x -- including an hour that had already recovered -- and never fired
at all on a slow burn that still exhausts a 30-day budget.

Each rule now pairs a long and a short window and fires only when BOTH
exceed the threshold, matching the SRE workbook:

  14.4x  1h / 5m
  6x     6h / 15m

Also:

- Data-coverage guard. A window is judged only when at least half of it
  reported data, so a low-traffic service whose exporter goes quiet no
  longer reads as a total outage. Coroot counts defined points in a raw
  series; the agent had one increase() scalar, so the window is now
  evaluated at a window/12 step and the defined points counted across
  good/bad/valid (a workload with zero errors has no filter_bad series
  and must not be penalised for it).

- alert_message computed int(burnRate / 3600) -- a rate divided by
  seconds-per-hour, always 0, so every message read "within 0 hours".
  Coroot formats the WINDOW there. Fixed in both the new and legacy
  paths.

burn_rates and severity are additive. The legacy alert/alert_message
fields are still emitted, and are left untouched when the burn-rate pass
yields nothing, so a backend that predates burn_rates keeps working and
a Prometheus hiccup degrades the report rather than blanking it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@RamanKharchee
RamanKharchee requested a review from a team as a code owner August 1, 2026 04:45

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces multi-window multi-burn-rate SLO evaluation, ported from Coroot, to improve alerting accuracy and prevent false alerts on recovered short-term spikes. It adds slo_burnrate.go and corresponding tests. The review feedback highlights a potential bug where partial query failures could lead to incorrect burn rate calculations, and suggests running the window queries in parallel to reduce network latency bottlenecks.

Comment on lines +215 to +218
a := &AppStatsEnricher{q: s.q}
results := a.runQueries(ctx, queries, startTime, endTime, step, "", "", "")

out := map[string]windowStats{}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If any of the Prometheus queries fail, runQueries silently swallows the error and omits the key from the results map. Proceeding with incomplete results (e.g., missing filter_bad or filter_good) will lead to incorrect burn rate calculations (such as assuming 0 bad events, which falsely clears active alerts). We should check if len(results) < len(queries) and return an error to safely skip the window.

	a := &AppStatsEnricher{q: s.q}
	results := a.runQueries(ctx, queries, startTime, endTime, step, "", "", "")
	if len(results) < len(queries) {
		return nil, fmt.Errorf("slo_generator: some queries failed or returned no data for window %ds", window)
	}

	out := map[string]windowStats{}

Comment on lines +3 to +8
import (
"context"
"fmt"
"math"
"time"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add "sync" to the import block to support thread-safe parallel execution of window queries.

Suggested change
import (
"context"
"fmt"
"math"
"time"
)
import (
"context"
"fmt"
"math"
"sync"
"time"
)

Comment on lines +241 to +249
func (s *SLOEnricher) burnRatesByApp(ctx context.Context, cfg sloConfig, endTime time.Time) map[string][]burnRate {
byWindow := map[int]map[string]windowStats{}
for _, w := range requiredWindows() {
stats, err := s.statsForWindow(ctx, cfg, w, endTime)
if err != nil {
continue
}
byWindow[w] = stats
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The required windows are currently queried sequentially. Since Prometheus queries are network round-trips and can be slow, querying them sequentially introduces a significant latency bottleneck. We should run statsForWindow in parallel for all required windows using a sync.WaitGroup and protect writes to byWindow with a sync.Mutex.

func (s *SLOEnricher) burnRatesByApp(ctx context.Context, cfg sloConfig, endTime time.Time) map[string][]burnRate {
	byWindow := map[int]map[string]windowStats{}
	var mu sync.Mutex
	var wg sync.WaitGroup
	for _, w := range requiredWindows() {
		wg.Add(1)
		go func(w int) {
			defer wg.Done()
			stats, err := s.statsForWindow(ctx, cfg, w, endTime)
			if err != nil {
				return
			}
			mu.Lock()
			byWindow[w] = stats
			mu.Unlock()
		}(w)
	}
	wg.Wait()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant