33Defines SLO targets, error budgets, burn-rate alerting, and an evaluator
44that reads from the Prometheus metrics registry to compute live SLO status.
55
6- Implements Google's multi-window burn-rate alerting model:
7- - Critical (14.4x): exhausts 30-day budget in 2 hours
8- - High (6x): exhausts budget in 5 hours
6+ Uses burn-rate alerting inspired by Google's SRE model, with simplified
7+ instantaneous-ratio evaluation (not windowed rate-of-change). The three
8+ alert tiers map to budget-exhaustion pace:
9+ - Critical (14.4x): would exhaust 30-day budget in ~2 hours at this pace
10+ - High (6x): would exhaust budget in ~5 hours at this pace
911 - Warning (1x): on-pace to exhaust budget by window end
1012
13+ Note: This is a point-in-time approximation from cumulative counters.
14+ True multi-window burn-rate alerting requires rate-of-change queries
15+ over sliding time windows, which needs a time-series query engine.
16+
1117Usage::
1218
1319 from openspace.observability.slos import SLOEvaluator
2329import json
2430import time
2531from dataclasses import dataclass , field
26- from typing import Any , Dict , List , Optional
32+ from typing import Any , Dict , List , Optional , Tuple
33+
34+ import math
2735
2836from openspace .observability .metrics import MetricsRegistry
2937
3038
3139# ── SLO Target ───────────────────────────────────────────────────────
3240
3341
34- @dataclass
42+ @dataclass ( frozen = True )
3543class SLOTarget :
3644 """Definition of a single SLO target."""
3745
@@ -46,6 +54,12 @@ def __post_init__(self) -> None:
4654 raise ValueError (
4755 f"objective must be in (0, 1.0], got { self .objective } "
4856 )
57+ if not isinstance (self .name , str ) or not self .name .strip ():
58+ raise ValueError ("name must be a non-empty string" )
59+ if not isinstance (self .threshold , (int , float )) or math .isnan (self .threshold ) or math .isinf (self .threshold ):
60+ raise ValueError (f"threshold must be a finite number, got { self .threshold } " )
61+ if self .threshold < 0 :
62+ raise ValueError (f"threshold must be non-negative, got { self .threshold } " )
4963
5064 def to_dict (self ) -> Dict [str , Any ]:
5165 return {
@@ -59,7 +73,7 @@ def to_dict(self) -> Dict[str, Any]:
5973
6074# ── Default targets ──────────────────────────────────────────────────
6175
62- DEFAULT_TARGETS : List [SLOTarget ] = [
76+ DEFAULT_TARGETS : Tuple [SLOTarget , ... ] = (
6377 SLOTarget (
6478 name = "execution_latency_p99" ,
6579 objective = 0.99 ,
@@ -81,7 +95,7 @@ def to_dict(self) -> Dict[str, Any]:
8195 unit = "ratio" ,
8296 description = "System availability above 99%" ,
8397 ),
84- ]
98+ )
8599
86100
87101# ── Error Budget ─────────────────────────────────────────────────────
@@ -114,9 +128,20 @@ def status(
114128 "exhausted" : False ,
115129 }
116130
131+ # Zero-tolerance SLO (objective=1.0): any failure exhausts budget
132+ if budget_total <= 1e-9 :
133+ exhausted = failed_requests > 0
134+ return {
135+ "budget_total" : 0 ,
136+ "budget_consumed" : failed_requests ,
137+ "budget_remaining" : 0 ,
138+ "budget_remaining_pct" : 0.0 if exhausted else 100.0 ,
139+ "exhausted" : exhausted ,
140+ }
141+
117142 consumed = failed_requests
118143 remaining = budget_total - consumed
119- remaining_pct = (remaining / budget_total * 100 ) if budget_total > 0 else 100.0
144+ remaining_pct = (remaining / budget_total * 100 )
120145
121146 return {
122147 "budget_total" : budget_total ,
@@ -160,15 +185,18 @@ class BurnRateCalculator:
160185 default_factory = lambda : list (_DEFAULT_ALERT_THRESHOLDS )
161186 )
162187
188+ # Maximum burn rate to avoid non-JSON-serializable float('inf')
189+ MAX_BURN_RATE = 1000.0
190+
163191 def burn_rate (self , total_requests : int , failed_requests : int ) -> float :
164- """Calculate current burn rate."""
192+ """Calculate current burn rate (clamped to MAX_BURN_RATE) ."""
165193 if total_requests == 0 :
166194 return 0.0
167195 allowed_error_rate = 1 - self .objective
168- if allowed_error_rate == 0 :
169- return float ( "inf" ) if failed_requests > 0 else 0.0
196+ if allowed_error_rate <= 1e-15 :
197+ return self . MAX_BURN_RATE if failed_requests > 0 else 0.0
170198 actual_error_rate = failed_requests / total_requests
171- return actual_error_rate / allowed_error_rate
199+ return min ( actual_error_rate / allowed_error_rate , self . MAX_BURN_RATE )
172200
173201 def check_alerts (
174202 self , total_requests : int , failed_requests : int
@@ -217,12 +245,17 @@ def _get_error_objective(self) -> float:
217245 return 0.95 # default
218246
219247 def _get_request_counts (self ) -> tuple [int , int ]:
220- """Read total and failed request counts from the registry."""
248+ """Read total and failed request counts from the registry.
249+
250+ Aggregates across all agent labels, filtering to _total samples only
251+ to avoid counting _created timestamp samples.
252+ """
221253 total = 0
222254 failed = 0
223255 try :
224- # Sum across all agent labels
225256 for sample in self ._registry .execution_total .collect ()[0 ].samples :
257+ if not sample .name .endswith ("_total" ):
258+ continue
226259 val = int (sample .value )
227260 total += val
228261 if sample .labels .get ("status" ) == "error" :
@@ -337,26 +370,52 @@ def _evaluate_target(
337370 }
338371
339372 def _estimate_p99 (self ) -> Optional [float ]:
340- """Estimate p99 latency from histogram buckets."""
373+ """Estimate p99 latency from histogram buckets.
374+
375+ Aggregates bucket counts across all label combinations (agent × status)
376+ by `le` value, then uses linear interpolation between bucket boundaries
377+ (standard Prometheus histogram_quantile approach).
378+ """
341379 try :
342380 samples = self ._registry .execution_latency .collect ()[0 ].samples
343- buckets = []
381+ # Aggregate bucket counts by le value across all label sets
382+ bucket_sums : Dict [float , float ] = {}
344383 count = 0
345384 for sample in samples :
346385 if sample .name .endswith ("_bucket" ):
347386 le = sample .labels .get ("le" )
348387 if le and le != "+Inf" :
349- buckets .append ((float (le ), sample .value ))
388+ le_f = float (le )
389+ bucket_sums [le_f ] = bucket_sums .get (le_f , 0 ) + sample .value
350390 elif sample .name .endswith ("_count" ):
351391 count += sample .value
352392
353393 if count == 0 :
354394 return None
355395
356396 target_count = count * 0.99
357- for le , bucket_count in sorted (buckets ):
397+ sorted_buckets = sorted (bucket_sums .items ())
398+
399+ # Linear interpolation between bucket boundaries
400+ prev_le = 0.0
401+ prev_count = 0.0
402+ for le , bucket_count in sorted_buckets :
358403 if bucket_count >= target_count :
359- return le
404+ # Interpolate within this bucket
405+ bucket_width = le - prev_le
406+ bucket_fraction = bucket_count - prev_count
407+ if bucket_fraction <= 0 :
408+ return le
409+ remaining = target_count - prev_count
410+ return prev_le + bucket_width * (remaining / bucket_fraction )
411+ prev_le = le
412+ prev_count = bucket_count
413+
414+ # All observations exceed the highest finite bucket — return the
415+ # highest boundary as a conservative lower-bound estimate.
416+ # This prevents hiding latency incidents when p99 > max bucket.
417+ if sorted_buckets :
418+ return sorted_buckets [- 1 ][0 ]
360419 return None
361420 except (IndexError , AttributeError , ValueError ):
362421 return None
0 commit comments