Skip to content

Commit 4cc3a92

Browse files
committed
fix(alerting): pause budgets with incomplete cost coverage
1 parent 06586a1 commit 4cc3a92

5 files changed

Lines changed: 590 additions & 49 deletions

File tree

.github/workflows/ci.yml

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ jobs:
6161
- name: Deployment declarations preserve scope and remain plain text
6262
run: python tests/deployment_inventory_invariants.py
6363

64+
- name: Budget alerts preserve safe lifecycle state
65+
run: python tests/budget_alert_lifecycle_invariants.py
66+
6467
- name: Workflows are code, lint them too
6568
if: matrix.python == '3.13'
6669
run: |
@@ -226,6 +229,113 @@ jobs:
226229
assert full["dashboard"]["panels"], d["title"]
227230
print(f"{len(ds)} dashboards live:", [d["title"] for d in ds])
228231
PY
232+
- name: Budget coverage changes pause the scoped live rule and preserve that pause
233+
env: {GRAFANA_URL: "http://localhost:3000", GRAFANA_USER: admin, GRAFANA_PASSWORD: admin}
234+
run: |
235+
python - <<'PY'
236+
import base64, copy, json, pathlib, subprocess, sys, urllib.request
237+
238+
auth = base64.b64encode(b"admin:admin").decode()
239+
def get(path):
240+
request = urllib.request.Request(
241+
"http://localhost:3000" + path,
242+
headers={"Authorization": "Basic " + auth})
243+
with urllib.request.urlopen(request, timeout=15) as response:
244+
return json.load(response)
245+
246+
endpoint = "/api/v1/provisioning/alert-rules"
247+
initial_rules = {r["uid"]: r for r in get(endpoint)}
248+
assert initial_rules, "the initial deployment must provide unrelated rules"
249+
initial_folders = {r["folderUID"] for r in initial_rules.values()}
250+
folders_before = {uid: get("/api/folders/" + uid) for uid in initial_folders}
251+
org_id = get("/api/org")["id"]
252+
root = pathlib.Path("budget-lifecycle-e2e")
253+
root.mkdir()
254+
cap = json.load(open("cap.json"))
255+
ds_uid = next(uid for uid, signals in cap["signals"].items() if "otel_genai" in signals)
256+
entry = copy.deepcopy(cap["signals"][ds_uid]["otel_genai"])
257+
# Keep the token histogram and sum; omit request/latency signals so this
258+
# dedicated fixture generates only the budget rule, with real datasource UIDs.
259+
entry["metric_names"] = [name for name in entry["metric_names"]
260+
if "token_usage" in name and name.endswith(("_bucket", "_sum"))]
261+
assert len(entry["metric_names"]) == 2, entry["metric_names"]
262+
entry["providers_seen"] = ["openai"]
263+
cap["signals"] = {ds_uid: {"otel_genai": entry}}
264+
cap["datasources"]["prometheus"] = [d for d in cap["datasources"]["prometheus"]
265+
if d["uid"] == ds_uid]
266+
267+
def deploy(stage, models):
268+
entry["models_seen"] = models
269+
entry["discovery_coverage"] = {
270+
"scope": "backend_returned_values", "local_truncation": False,
271+
"backend_completeness": "unknown",
272+
"counts": {key: len(entry[key])
273+
for key in ("metric_names", "models_seen", "providers_seen")}}
274+
capability = root / (stage + ".json")
275+
capability.write_text(json.dumps(cap), encoding="utf-8")
276+
out = root / stage
277+
subprocess.run([
278+
sys.executable, "scripts/forge_dashboards.py", "--capability", str(capability),
279+
"--blueprints", "finops", "--cost-mode", "inline", "--deploy", "--with-alerts",
280+
"--folder", "Forge CI budget lifecycle", "--uid-scope", "ci-budget-lifecycle",
281+
"--out-dir", str(out)], check=True)
282+
manifest = json.loads((out / "deploy_manifest.json").read_text(encoding="utf-8"))
283+
assert manifest["deployment_status"] == "success" and not manifest["errors"], stage
284+
assert manifest["deployed"] and manifest["uid_scope"] == "ci-budget-lifecycle", stage
285+
return manifest
286+
287+
def assert_unrelated_unchanged(budget_uid):
288+
current = {r["uid"]: r for r in get(endpoint)}
289+
assert set(current) == set(initial_rules) | {budget_uid}, "unexpected rule set change"
290+
assert {uid: current[uid] for uid in initial_rules} == initial_rules, "unrelated rules changed"
291+
assert {uid: get("/api/folders/" + uid) for uid in initial_folders} == folders_before
292+
293+
complete = ["gpt-5.4"]
294+
partial = complete + ["forge-ci-unpriced-budget-model"]
295+
first = deploy("eligible", complete)
296+
assert first["financial_source"]["coverage"]["budget_eligible"] is True
297+
assert len(first["alerts"]) == 1, "fixture must isolate the budget rule"
298+
budget_uid = first["alerts"][0]["uid"]
299+
assert "llm-daily-budget" in budget_uid and budget_uid not in initial_rules
300+
folder_uid = first["folder_uid"]
301+
assert folder_uid not in initial_folders
302+
live = get(endpoint + "/" + budget_uid)
303+
assert live["uid"] == budget_uid and live["ruleGroup"] == "llmops-slo"
304+
assert live["orgID"] == org_id and live["folderUID"] == folder_uid
305+
assert live["labels"]["origin"] == "llmops-forge" and live["labels"]["severity"] == "warning"
306+
assert next(query for query in live["data"] if query["refId"] == "A")["datasourceUid"] == ds_uid
307+
assert live["isPaused"] is False, "eligible budget must initially be active"
308+
assert_unrelated_unchanged(budget_uid)
309+
310+
for stage, result in (("partial", "paused"), ("partial-again", "already_paused")):
311+
manifest = deploy(stage, partial)
312+
assert get(endpoint + "/" + budget_uid)["isPaused"] is True, stage
313+
coverage = manifest["financial_source"]["coverage"]
314+
assert coverage["status"] == "partial_prices" and coverage["budget_eligible"] is False
315+
assert len(manifest["alerts"]) == 1, "only the budget maintenance operation is expected"
316+
maintenance = manifest["alerts"][0]
317+
assert maintenance["uid"] == budget_uid and maintenance["action"] == "pause"
318+
assert maintenance["status"] == "succeeded" and maintenance["result"] == result
319+
assert manifest["resources"]["alerts"] == {
320+
"requested": 1, "succeeded": 1, "failed": 0, "skipped": 0}
321+
state = manifest["budget_alert"]
322+
assert state["uid"] == budget_uid and state["folder_uid"] == folder_uid
323+
assert state["org_id"] == org_id and state["action"] == "pause"
324+
assert state["result"] == result and state["is_paused"] is True, state
325+
assert state["reason"] and state["pause_policy"] == "preserve_existing_pause"
326+
assert_unrelated_unchanged(budget_uid)
327+
328+
restored = deploy("eligible-again", complete)
329+
assert restored["financial_source"]["coverage"]["budget_eligible"] is True
330+
assert [a["uid"] for a in restored["alerts"]] == [budget_uid]
331+
assert get(endpoint + "/" + budget_uid)["isPaused"] is True, "restoring coverage must not unpause"
332+
state = restored["budget_alert"]
333+
assert state["uid"] == budget_uid and state["action"] == "upsert"
334+
assert state["result"] == "upserted_paused" and state["is_paused"] is True, state
335+
assert state["pause_policy"] == "preserve_existing_pause"
336+
assert_unrelated_unchanged(budget_uid)
337+
print("Budget lifecycle PASS: active -> paused -> already_paused -> still paused; unrelated rules intact")
338+
PY
229339
- name: Diagnose on failure (compose ps + container logs)
230340
if: failure()
231341
run: |

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ Discovery preserves every metric name, model and provider value returned by the
9292

9393
Inline composition is capped at **40 models with usable prices**. Above that limit, no monetary subtotal or budget is generated: select the financial datasource with `--datasource`, generate and load its recording rules, then repeat discovery and forge. All priced models supplied to the rule generator are retained, including beyond 60 models. Recorded queries are shorter, but execution cost still depends on series count and time window. Native and recorded sources retain unverified upstream coverage and do not depend on registry coverage.
9494

95+
With `--deploy --with-alerts`, an ineligible budget also pauses an existing Forge budget in the resolved organization and folder. Forge checks its deterministic UID, rule group and ownership labels before updating only `isPaused`; an absent budget is not created. The manifest records `budget_alert.action`, `result` (`paused`, `already_paused`, `absent` or `failed`) and the coverage reason. Alert resource counts include this maintenance operation, including confirmed absence or an already paused rule. A collision or unconfirmed HTTP response makes the deployment partial/failed. Generation, dry-run and deployments without `--with-alerts` do not perform this check. When coverage becomes eligible again, Forge updates the budget while preserving its existing pause (`upserted_paused`): review the coverage and resume it explicitly in Grafana. Other SLO pause behavior is unchanged.
96+
9597
### Provider origin and declared deployment locations
9698

9799
The registry field and Prometheus label `region` remain compatible and describe **provider origin**. They establish neither processing nor storage location. Governance always includes a separate deployment panel: locations default to **unknown**. Optionally pass `--deployment-inventory ../instance/deployment_inventory.json`, keeping real inventory outside the packaged skill. A US-origin model can have declared processing in France; both facts are shown separately.

scripts/forge_dashboards.py

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2015,6 +2015,13 @@ def main() -> int:
20152015
folder_uid = det_uid(args.folder, "fold", args.uid_scope)
20162016
alert_rules = (build_alerts(ctx, folder_uid, args.daily_budget, args.slo_target)
20172017
if args.with_alerts else [])
2018+
budget_uid = det_uid("llm-daily-budget", "alr", args.uid_scope)
2019+
budget_generated = any(rule["uid"] == budget_uid for rule in alert_rules)
2020+
budget_reason = (None if budget_generated else
2021+
(ctx.cost_source.coverage["budget_omission_reason"] if ctx.cost_source else None)
2022+
or "No usable financial source or budget cost expression.")
2023+
pause_budget = operation == "deploy" and args.with_alerts and not budget_generated
2024+
budget_action = ("upsert" if budget_generated else "pause") if args.with_alerts else "not_requested"
20182025
manifest = {
20192026
"schema": "grafana-llmops-forge/deployment-manifest",
20202027
"version": 2,
@@ -2029,12 +2036,20 @@ def main() -> int:
20292036
"uid_scope": args.uid_scope,
20302037
"financial_source": ctx.financial_manifest(),
20312038
"deployment_inventory": ctx.deployment_manifest(),
2039+
"budget_alert": {
2040+
"uid": budget_uid, "org_id": ctx.org_id, "folder_uid": folder_uid,
2041+
"action": budget_action,
2042+
"result": ("pending" if operation == "deploy" else "not_executed")
2043+
if args.with_alerts else "not_requested",
2044+
"reason": budget_reason if args.with_alerts else None,
2045+
"is_paused": None, "pause_policy": "preserve_existing_pause",
2046+
},
20322047
"resources": {
20332048
"folder": {"requested": 1 if operation == "deploy" else 0,
20342049
"succeeded": 0, "failed": 0, "skipped": 0},
20352050
"dashboards": {"requested": len(boards), "succeeded": 0,
20362051
"failed": 0, "skipped": 0},
2037-
"alerts": {"requested": len(alert_rules), "succeeded": 0,
2052+
"alerts": {"requested": len(alert_rules) + int(pause_budget), "succeeded": 0,
20382053
"failed": 0, "skipped": 0},
20392054
},
20402055
"errors": [],
@@ -2059,6 +2074,11 @@ def main() -> int:
20592074
{"uid": rule["uid"], "title": rule["title"],
20602075
"status": "pending" if operation == "deploy" else "succeeded"}
20612076
for rule in alert_rules]
2077+
if pause_budget:
2078+
# This is a scoped maintenance operation, not a generated replacement rule.
2079+
manifest["alerts"].append(
2080+
{"uid": budget_uid, "title": "LLM financial budget", "action": "pause",
2081+
"status": "pending", "reason": budget_reason})
20622082
if operation == "generate":
20632083
manifest["resources"]["dashboards"]["succeeded"] = len(boards)
20642084
manifest["resources"]["alerts"]["succeeded"] = len(alert_rules)
@@ -2083,7 +2103,9 @@ def _perm_hint(op, err):
20832103
except (GrafanaError, SystemExit) as e:
20842104
manifest["resources"]["folder"]["failed"] = 1
20852105
manifest["resources"]["dashboards"]["skipped"] = len(boards)
2086-
manifest["resources"]["alerts"]["skipped"] = len(alert_rules)
2106+
manifest["resources"]["alerts"]["skipped"] = len(manifest["alerts"])
2107+
if args.with_alerts:
2108+
manifest["budget_alert"]["result"] = "not_executed"
20872109
for entry in manifest["dashboards"] + manifest["alerts"]:
20882110
entry["status"] = "skipped"
20892111
manifest["errors"].append(
@@ -2098,6 +2120,7 @@ def _perm_hint(op, err):
20982120
else:
20992121
actual_folder_uid = folder.get("uid") or folder_uid
21002122
manifest["folder_uid"] = actual_folder_uid
2123+
manifest["budget_alert"]["folder_uid"] = actual_folder_uid
21012124
manifest["resources"]["folder"]["succeeded"] = 1
21022125
manifest["deployed"] = True
21032126
print(f"\nFolder '{folder.get('title')}' (uid {actual_folder_uid})")
@@ -2128,18 +2151,62 @@ def _perm_hint(op, err):
21282151
print(f"\n[partial] {ds_stats['succeeded']}/{ds_stats['requested']} "
21292152
"dashboards deployed. Re-running after fixing the role is safe: "
21302153
"deterministic UIDs make it an update.", file=sys.stderr)
2131-
if alert_rules and not client.contact_points():
2132-
print(" [warn] no contact point configured: alerts will fire with no "
2133-
"recipient (Alerting -> Contact points).")
2154+
if pause_budget:
2155+
expected_budget = {
2156+
"uid": budget_uid, "orgID": ctx.org_id, "folderUID": actual_folder_uid,
2157+
"ruleGroup": "llmops-slo",
2158+
"labels": {"origin": "llmops-forge",
2159+
"llmops_rule_identity": alert_logical_identity("llm-daily-budget")},
2160+
}
2161+
entry = manifest["alerts"][-1]
2162+
try:
2163+
result = client.pause_budget_alert_rule(expected_budget)
2164+
except (Exception, SystemExit) as e:
2165+
manifest["budget_alert"]["result"] = "failed"
2166+
entry.update(status="failed", result="failed", error=str(e))
2167+
manifest["resources"]["alerts"]["failed"] += 1
2168+
manifest["errors"].append(
2169+
{"resource_type": "alert", "identifier": "LLM financial budget",
2170+
"uid": budget_uid, "operation": "pause",
2171+
"status": getattr(e, "status", 0), "message": _perm_hint("alert", e)})
2172+
print(f" [fail] budget pause was not confirmed: {_perm_hint('alert', e)}",
2173+
file=sys.stderr)
2174+
else:
2175+
manifest["budget_alert"].update(
2176+
result=result, is_paused=True if result != "absent" else None)
2177+
entry.update(status="succeeded", result=result)
2178+
manifest["resources"]["alerts"]["succeeded"] += 1
2179+
print(f" [ok] budget: {result}; {budget_reason}")
2180+
if alert_rules:
2181+
try:
2182+
contacts = client.contact_points()
2183+
except (GrafanaError, SystemExit) as e:
2184+
# This informational lookup must not discard a deployment failure manifest.
2185+
print(f" [warn] contact point availability could not be checked: {e}",
2186+
file=sys.stderr)
2187+
else:
2188+
if not contacts:
2189+
print(" [warn] no contact point configured: alerts will fire with no "
2190+
"recipient (Alerting -> Contact points).")
21342191
for i, rule in enumerate(alert_rules):
21352192
# Folder UID is part of the alert body and must match the resolved folder.
21362193
rule["folderUID"] = actual_folder_uid
21372194
try:
2138-
client.upsert_alert_rule(rule)
2195+
result = client.upsert_alert_rule(rule)
2196+
if rule["uid"] == budget_uid:
2197+
paused = result.get("isPaused") if isinstance(result, dict) else None
2198+
manifest["budget_alert"].update(
2199+
result="upserted_paused" if paused is True else "upserted",
2200+
is_paused=paused)
2201+
if paused is True:
2202+
print(" [info] budget remains paused; review coverage and resume "
2203+
"it explicitly in Grafana when appropriate.")
21392204
manifest["resources"]["alerts"]["succeeded"] += 1
21402205
manifest["alerts"][i]["status"] = "succeeded"
21412206
print(f" [ok] alert: {rule['title']}")
2142-
except Exception as e: # droits alerting et transports hétérogènes
2207+
except (Exception, SystemExit) as e: # droits alerting et transports hétérogènes
2208+
if rule["uid"] == budget_uid:
2209+
manifest["budget_alert"]["result"] = "failed"
21432210
manifest["resources"]["alerts"]["failed"] += 1
21442211
manifest["alerts"][i]["status"] = "failed"
21452212
manifest["alerts"][i]["error"] = str(e)

0 commit comments

Comments
 (0)