Files
magnus919_agent-skills/site-reliability-engineering/scripts/slo-burn-rate.py
T
2026-07-12 14:34:26 -04:00

383 lines
13 KiB
Python

#!/usr/bin/env python3
"""
SLO Burn Rate Calculator
Calculates error budget burn rate from SLI data.
Usage:
python3 slo-burn-rate.py --slo-target 99.9 --window 1h --good-events 99500 --total-events 100000
python3 slo-burn-rate.py --slo-target 99.99 --window 7d --error-rate 0.001
python3 slo-burn-rate.py --slo-target 99.95 --window 30d --good-events 998500 --total-events 1000000
cat sli_data.json | python3 slo-burn-rate.py --slo-target 99.9 --window 1h
"""
import argparse
import json
import sys
import math
from datetime import timedelta
# ---------------------------------------------------------------------------
# Window parsing
# ---------------------------------------------------------------------------
WINDOW_SECONDS = {
"1h": 3600,
"6h": 21600,
"24h": 86400,
"7d": 604800,
"30d": 2592000,
}
def parse_window(window_str: str) -> int:
"""Convert a window string like '1h', '24h', '7d', '30d' to seconds."""
window_str = window_str.strip().lower()
if window_str in WINDOW_SECONDS:
return WINDOW_SECONDS[window_str]
# Fallback: try to parse programmatically
if window_str.endswith("h"):
hours = int(window_str[:-1])
return hours * 3600
elif window_str.endswith("d"):
days = int(window_str[:-1])
return days * 86400
elif window_str.endswith("m"):
minutes = int(window_str[:-1])
return minutes * 60
elif window_str.endswith("s"):
return int(window_str[:-1])
else:
raise ValueError(
f"Unrecognized window format: '{window_str}'. "
f"Use e.g. 1h, 6h, 24h, 7d, 30d."
)
def format_duration(seconds: int) -> str:
"""Format a duration in seconds to a human-readable string."""
if seconds < 60:
return f"{seconds}s"
elif seconds < 3600:
return f"{seconds // 60}m"
elif seconds < 86400:
hours = seconds // 3600
remainder = seconds % 3600
if remainder == 0:
return f"{hours}h"
return f"{hours}h{remainder // 60}m"
else:
days = seconds // 86400
remainder = seconds % 86400
if remainder == 0:
return f"{days}d"
return f"{days}d{remainder // 3600}h"
# ---------------------------------------------------------------------------
# Core calculation
# ---------------------------------------------------------------------------
def calculate_burn_rate(
slo_target: float,
window_seconds: int,
good_events: int | None = None,
total_events: int | None = None,
error_rate: float | None = None,
) -> dict:
"""
Calculate error budget burn rate and related metrics.
Parameters
----------
slo_target : float
Target availability percentage (e.g. 99.9 for 99.9%).
window_seconds : int
Analysis window in seconds.
good_events : int, optional
Number of successful events.
total_events : int, optional
Total number of events.
error_rate : float, optional
Direct error rate (e.g. 0.001 for 0.1% error rate).
Returns
-------
dict with keys:
sli_target, window, window_seconds, good_events, total_events,
error_rate, measured_availability, burn_rate,
budget_remaining_pct, time_to_exhaustion, status
"""
slo_threshold = slo_target / 100.0 # e.g. 99.9 -> 0.999
allowed_error_rate = 1.0 - slo_threshold # e.g. 0.001
if error_rate is not None:
# Direct error rate provided
measured_error_rate = error_rate
measured_availability = 1.0 - measured_error_rate
if good_events is None and total_events is None:
good_events = None
total_events = None
elif good_events is not None and total_events is not None:
pass # keep both
else:
# Can't derive the missing one
good_events = None
total_events = None
elif good_events is not None and total_events is not None:
if total_events == 0:
return {
"error": "total_events must be greater than 0",
"slo_target": slo_target,
"window": format_duration(window_seconds),
}
measured_availability = good_events / total_events
measured_error_rate = 1.0 - measured_availability
else:
return {
"error": (
"Provide either --good-events + --total-events, "
"or --error-rate directly."
),
"slo_target": slo_target,
"window": format_duration(window_seconds),
}
# Burn rate
if allowed_error_rate == 0:
burn_rate = float("inf") if measured_error_rate > 0 else 0.0
else:
burn_rate = measured_error_rate / allowed_error_rate
# Budget remaining
if total_events is not None and total_events > 0:
total_budget = allowed_error_rate * total_events
consumed = total_events - good_events if good_events is not None else measured_error_rate * total_events
budget_remaining = max(0.0, total_budget - consumed)
budget_remaining_pct = (
(budget_remaining / total_budget * 100.0) if total_budget > 0 else 0.0
)
else:
# Without event counts, use the error rate as a proxy
# Budget remaining = (allowed_error_rate - measured_error_rate) / allowed_error_rate
if allowed_error_rate > 0:
budget_remaining_pct = max(
0.0,
((allowed_error_rate - measured_error_rate) / allowed_error_rate) * 100.0,
)
else:
budget_remaining_pct = 0.0 if measured_error_rate > 0 else 100.0
# Time to exhaustion
if burn_rate > 0:
# How long until the remaining budget burns at the current rate?
window_error_budget = allowed_error_rate # per-unit budget for the window
remaining_fraction = max(0.0, 1.0 - (measured_error_rate / allowed_error_rate)) if allowed_error_rate > 0 else 0.0
if burn_rate > 0 and remaining_fraction > 0:
time_to_exhaustion_secs = (remaining_fraction / burn_rate) * window_seconds
elif remaining_fraction <= 0:
time_to_exhaustion_secs = 0
else:
time_to_exhaustion_secs = float("inf")
else:
time_to_exhaustion_secs = float("inf")
# Status determination
if burn_rate == 0 and measured_error_rate == 0:
status = "healthy"
elif budget_remaining_pct <= 0 or measured_error_rate >= allowed_error_rate:
status = "exhausted"
elif budget_remaining_pct < 30 and burn_rate > 3:
status = "burning"
elif budget_remaining_pct < 70 and burn_rate > 1:
status = "warning"
elif burn_rate > 1:
# Elevated but budget still healthy
status = "warning"
else:
status = "healthy"
if budget_remaining_pct < 5:
status = "exhausted"
elif budget_remaining_pct < 30:
# Re-check; might be "burning" if rate is really high
if burn_rate >= 10:
status = "burning"
elif status != "burning":
status = "warning"
elif budget_remaining_pct < 70:
if burn_rate >= 3:
status = "burning"
elif burn_rate > 1:
status = "warning"
# Format time to exhaustion
if time_to_exhaustion_secs == float("inf"):
formatted_ttl = "infinite"
elif time_to_exhaustion_secs <= 0:
formatted_ttl = "exhausted"
else:
formatted_ttl = format_duration(int(time_to_exhaustion_secs))
return {
"slo_target": slo_target,
"window": format_duration(window_seconds),
"window_seconds": window_seconds,
"good_events": good_events,
"total_events": total_events,
"error_rate": round(measured_error_rate, 6),
"measured_availability": round(measured_availability * 100.0, 4),
"burn_rate": round(burn_rate, 4),
"budget_remaining_pct": round(budget_remaining_pct, 2),
"budget_remaining": round(budget_remaining_pct, 2),
"time_to_exhaustion": formatted_ttl,
"time_to_exhaustion_seconds": (
int(time_to_exhaustion_secs)
if time_to_exhaustion_secs not in (float("inf"), float("-inf"))
else None
),
"status": status,
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Calculate SLO error budget burn rate.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--slo-target",
type=float,
required=True,
help="SLO target as a percentage (e.g. 99.9 for 99.9%%).",
)
parser.add_argument(
"--window",
type=str,
default="1h",
help=(
"Analysis window. Supports: 1h, 6h, 24h, 7d, 30d "
"or custom like 90m, 2h30m, 14d. Default: 1h."
),
)
parser.add_argument(
"--good-events",
type=int,
default=None,
help="Number of successful (good) events.",
)
parser.add_argument(
"--total-events",
type=int,
default=None,
help="Total number of events.",
)
parser.add_argument(
"--error-rate",
type=float,
default=None,
help="Direct error rate (e.g. 0.001 for 0.1%% error rate).",
)
parser.add_argument(
"--pretty",
action="store_true",
default=False,
help="Pretty-print JSON output with indentation.",
)
parser.add_argument(
"--color",
action="store_true",
default=False,
help="Add terminal color codes to the JSON output for readability.",
)
return parser.parse_args(argv)
def _color_status(status: str) -> str:
colors = {
"healthy": "\033[32m", # green
"warning": "\033[33m", # yellow
"burning": "\033[31m", # red
"exhausted": "\033[1;31m", # bold red
}
reset = "\033[0m"
return f"{colors.get(status, '')}{status}{reset}"
def main() -> None:
args = parse_args()
# If reading from stdin, look for JSON data
if not sys.stdin.isatty():
try:
stdin_data = sys.stdin.read().strip()
if stdin_data:
input_data = json.loads(stdin_data)
# Merge stdin data with CLI args, CLI takes precedence
if args.good_events is None and "good_events" in input_data:
args.good_events = input_data["good_events"]
if args.total_events is None and "total_events" in input_data:
args.total_events = input_data["total_events"]
if args.error_rate is None and "error_rate" in input_data:
args.error_rate = input_data["error_rate"]
if args.window == "1h" and "window" in input_data:
# Only override if default hasn't been changed
pass # keep explicit CLI
except (json.JSONDecodeError, Exception):
pass # If stdin isn't valid JSON, just use CLI args
try:
window_seconds = parse_window(args.window)
except ValueError as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
result = calculate_burn_rate(
slo_target=args.slo_target,
window_seconds=window_seconds,
good_events=args.good_events,
total_events=args.total_events,
error_rate=args.error_rate,
)
indent = 2 if args.pretty else None
if args.color:
# Color only the status field for terminal readability
status_colorized = _color_status(result.get("status", ""))
json_str = json.dumps(result, indent=indent)
# Replace the plain status string with colorized version in the JSON
plain_status = result.get("status", "")
safe_status = json.dumps(plain_status)
color_status_with_quotes = json.dumps(plain_status.replace(plain_status, status_colorized))
# Simple approach: just replace the status value
# But this can be fragile. Let's just print JSON clean and add color on top.
print(json_str, file=sys.stderr)
print(f"Status: {_color_status(result.get('status', ''))}")
else:
print(json.dumps(result, indent=indent))
# Exit code indicates health
status = result.get("status", "")
if status == "healthy":
sys.exit(0)
elif status in ("warning", "burning"):
sys.exit(1)
elif status == "exhausted":
sys.exit(2)
elif "error" in result:
sys.exit(3)
if __name__ == "__main__":
main()