mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-15 21:46:29 +03:00
383 lines
13 KiB
Python
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()
|