Skip to content

Commit c4faa4d

Browse files
Brian KrafftCopilot
andcommitted
fix: address all review findings - 5 agents, 12 fixes, 22 new tests
Security: - Wrap check_slos in try/except + handle_mcp_exception (error oracle) - Clamp burn_rate to MAX_BURN_RATE=1000.0 (no float('inf') in JSON) - Add SLOTarget validation: non-empty name, finite non-negative threshold - Filter _get_request_counts to _total suffix (PROMETHEUS_ENABLE_CREATED_SERIES safety) Correctness: - Fix objective=1.0 contradictory state (exhausted + 100% remaining) - Fix p99 multi-label aggregation (sum by le across agent × status) - Add p99 linear interpolation between bucket boundaries - Fix p99 overflow: return highest bucket boundary instead of None - Correct docstring: simplified burn-rate ratio, not Google multi-window Hardening: - DEFAULT_TARGETS immutable tuple (was mutable list) - SLOTarget frozen=True dataclass - ErrorBudget zero-budget path consistent status Tests: 22 new adversarial tests (1,884 total passed) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1ae586d commit c4faa4d

3 files changed

Lines changed: 408 additions & 23 deletions

File tree

openspace/mcp/tool_handlers.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -836,11 +836,16 @@ async def check_slos() -> str:
836836
remaining, burn rate, and any active alerts. Use this to monitor
837837
system health against defined reliability targets.
838838
"""
839-
from openspace.observability.metrics import metrics
840-
from openspace.observability.slos import SLOEvaluator
839+
try:
840+
from openspace.observability.metrics import metrics
841+
from openspace.observability.slos import SLOEvaluator
842+
843+
evaluator = SLOEvaluator(registry=metrics)
844+
return evaluator.to_json()
845+
except Exception as e:
846+
from openspace.errors import EXECUTION_ERROR, handle_mcp_exception
841847

842-
evaluator = SLOEvaluator(registry=metrics)
843-
return evaluator.to_json()
848+
return handle_mcp_exception(e, tool_name="check_slos", error_code=EXECUTION_ERROR)
844849

845850

846851
# ---------------------------------------------------------------------------

openspace/observability/slos.py

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33
Defines SLO targets, error budgets, burn-rate alerting, and an evaluator
44
that 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+
1117
Usage::
1218
1319
from openspace.observability.slos import SLOEvaluator
@@ -23,15 +29,17 @@
2329
import json
2430
import time
2531
from 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

2836
from openspace.observability.metrics import MetricsRegistry
2937

3038

3139
# ── SLO Target ───────────────────────────────────────────────────────
3240

3341

34-
@dataclass
42+
@dataclass(frozen=True)
3543
class 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

Comments
 (0)