data_observability.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818
  1. """Cross-domain data observability, alert aggregation and incident operations."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import math
  6. import re
  7. import uuid
  8. from collections.abc import Callable
  9. from datetime import datetime
  10. from typing import Any
  11. from app.core.common.identifiers import new_governance_uid
  12. from app.core.common.timezone_utils import now_china
  13. SLI_CATALOG = (
  14. {
  15. "type": "freshness",
  16. "name": "新鲜度",
  17. "unit": "seconds",
  18. "description": "源数据观测时间到当前时间的延迟",
  19. },
  20. {
  21. "type": "completeness",
  22. "name": "完整性",
  23. "unit": "ratio",
  24. "description": "约定字段和记录满足完整性要求的比例",
  25. },
  26. {
  27. "type": "quality",
  28. "name": "质量",
  29. "unit": "score",
  30. "description": "确定性质量规则计算得到的质量分数",
  31. },
  32. {
  33. "type": "delivery",
  34. "name": "交付",
  35. "unit": "ratio",
  36. "description": "约定窗口内成功完成采集或交付的比例",
  37. },
  38. )
  39. SLI_TYPES = frozenset(item["type"] for item in SLI_CATALOG)
  40. LAYERS = ("service", "task", "data", "capacity")
  41. SEVERITIES = ("info", "warning", "error", "critical")
  42. IMPACT_TYPES = frozenset(
  43. {"asset", "data_product", "business_domain", "user_group", "service"}
  44. )
  45. OPERATORS = frozenset({">=", "<="})
  46. SECRET_TOKENS = frozenset(
  47. {
  48. "apikey",
  49. "authorization",
  50. "connectionstring",
  51. "credential",
  52. "dsn",
  53. "password",
  54. "secret",
  55. "token",
  56. }
  57. )
  58. CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,119}$")
  59. def _normalized_key(value: Any) -> str:
  60. return re.sub(r"[^a-z0-9]", "", str(value).casefold())
  61. def _reject_secret_material(value: Any, path: str = "$") -> None:
  62. if isinstance(value, dict):
  63. for key, item in value.items():
  64. normalized = _normalized_key(key)
  65. if any(token in normalized for token in SECRET_TOKENS):
  66. raise ValueError(f"secret material is not allowed at {path}.{key}")
  67. _reject_secret_material(item, f"{path}.{key}")
  68. elif isinstance(value, list):
  69. for index, item in enumerate(value):
  70. _reject_secret_material(item, f"{path}[{index}]")
  71. def _closed_object(
  72. value: Any,
  73. allowed: set[str] | frozenset[str],
  74. label: str,
  75. ) -> dict[str, Any]:
  76. if not isinstance(value, dict):
  77. raise ValueError(f"{label} must be an object")
  78. _reject_secret_material(value)
  79. unknown = sorted(set(value) - set(allowed))
  80. if unknown:
  81. raise ValueError(
  82. f"{label} contains unsupported fields: {', '.join(unknown)}"
  83. )
  84. return dict(value)
  85. def _string(value: Any, label: str, maximum: int = 500) -> str:
  86. if not isinstance(value, str) or not value.strip():
  87. raise ValueError(f"{label} is required")
  88. result = value.strip()
  89. if len(result) > maximum:
  90. raise ValueError(f"{label} exceeds {maximum} characters")
  91. return result
  92. def _uid(value: Any, label: str) -> str:
  93. try:
  94. return str(uuid.UUID(str(value)))
  95. except (TypeError, ValueError, AttributeError) as error:
  96. raise ValueError(f"{label} must be a UUID") from error
  97. def _number(value: Any, label: str) -> float:
  98. if isinstance(value, bool):
  99. raise ValueError(f"{label} must be numeric")
  100. try:
  101. result = float(value)
  102. except (TypeError, ValueError) as error:
  103. raise ValueError(f"{label} must be numeric") from error
  104. if not math.isfinite(result):
  105. raise ValueError(f"{label} must be finite")
  106. return result
  107. def _timestamp(value: Any, label: str) -> datetime:
  108. if isinstance(value, datetime):
  109. result = value
  110. else:
  111. try:
  112. result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
  113. except (TypeError, ValueError) as error:
  114. raise ValueError(f"{label} must be an ISO-8601 datetime") from error
  115. if result.tzinfo is None:
  116. raise ValueError(f"{label} must include a timezone")
  117. return result
  118. def _canonical_hash(value: Any) -> str:
  119. serialized = json.dumps(
  120. value,
  121. ensure_ascii=False,
  122. sort_keys=True,
  123. separators=(",", ":"),
  124. )
  125. return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
  126. def _severity_rank(value: str) -> int:
  127. try:
  128. return SEVERITIES.index(value)
  129. except ValueError as error:
  130. raise ValueError("severity is invalid") from error
  131. class DataObservabilityService:
  132. """Operate deterministic SLO evidence without inventing root causes."""
  133. def __init__(
  134. self,
  135. repository,
  136. *,
  137. uid_factory: Callable[[], str] = new_governance_uid,
  138. now_factory: Callable[[], datetime] = now_china,
  139. commit: Callable[[], None] = lambda: None,
  140. rollback: Callable[[], None] = lambda: None,
  141. ):
  142. self.repository = repository
  143. self.uid_factory = uid_factory
  144. self.now_factory = now_factory
  145. self.commit = commit
  146. self.rollback = rollback
  147. @staticmethod
  148. def sli_catalog() -> list[dict[str, str]]:
  149. return [dict(item) for item in SLI_CATALOG]
  150. def create_slo(self, payload: Any, *, actor_uid: str) -> dict[str, Any]:
  151. body = _closed_object(
  152. payload,
  153. {
  154. "code",
  155. "name",
  156. "sli_type",
  157. "scope_type",
  158. "scope_uid",
  159. "operator",
  160. "target",
  161. "window_seconds",
  162. "owner_uid",
  163. },
  164. "SLO policy",
  165. )
  166. code = _string(body.get("code"), "code", 120).upper()
  167. if not CODE_PATTERN.fullmatch(code):
  168. raise ValueError("SLO policy code is invalid")
  169. sli_type = _string(body.get("sli_type"), "sli_type", 30)
  170. if sli_type not in SLI_TYPES:
  171. raise ValueError("SLO policy sli_type is invalid")
  172. operator = _string(body.get("operator"), "operator", 2)
  173. if operator not in OPERATORS:
  174. raise ValueError("SLO policy operator is invalid")
  175. target = _number(body.get("target"), "target")
  176. window_seconds = int(_number(body.get("window_seconds"), "window_seconds"))
  177. if window_seconds < 60 or window_seconds > 31_536_000:
  178. raise ValueError("window_seconds must be between 60 and 31536000")
  179. now = self.now_factory().isoformat()
  180. record = {
  181. "uid": self.uid_factory(),
  182. "code": code,
  183. "name": _string(body.get("name"), "name", 300),
  184. "sli_type": sli_type,
  185. "scope_type": _string(body.get("scope_type"), "scope_type", 40),
  186. "scope_uid": _string(body.get("scope_uid"), "scope_uid", 500),
  187. "operator": operator,
  188. "target": target,
  189. "window_seconds": window_seconds,
  190. "owner_uid": _uid(body.get("owner_uid"), "owner_uid"),
  191. "status": "active",
  192. "created_by": _uid(actor_uid, "actor_uid"),
  193. "created_at": now,
  194. "updated_at": now,
  195. }
  196. try:
  197. stored = self.repository.create_slo(record)
  198. self.commit()
  199. return stored
  200. except Exception:
  201. self.rollback()
  202. raise
  203. def list_slos(self) -> list[dict[str, Any]]:
  204. return self.repository.list_slos()
  205. def collect(self, *, actor_uid: str) -> dict[str, Any]:
  206. actor = _uid(actor_uid, "actor_uid")
  207. counters = {
  208. "processed": 0,
  209. "ignored": 0,
  210. "created_alerts": 0,
  211. "aggregated_alerts": 0,
  212. "recovered_alerts": 0,
  213. }
  214. incident_uids: list[str] = []
  215. try:
  216. for raw_signal in self.repository.pending_signals():
  217. signal = self._signal(raw_signal)
  218. if self.repository.source_event_exists(
  219. signal["source_event_key"]
  220. ):
  221. counters["ignored"] += 1
  222. continue
  223. claimed = self.repository.record_source_event(
  224. {
  225. "uid": self.uid_factory(),
  226. "source_event_key": signal["source_event_key"],
  227. "source_type": signal["source_type"],
  228. "source_uid": signal["source_uid"],
  229. "status": signal["status"],
  230. "signal": signal,
  231. "processed_at": self.now_factory().isoformat(),
  232. }
  233. )
  234. if claimed is False:
  235. counters["ignored"] += 1
  236. continue
  237. result = self._process_signal(signal, actor_uid=actor)
  238. counters["processed"] += 1
  239. counters[result["counter"]] += 1
  240. if (
  241. result.get("incident_uid")
  242. and result["incident_uid"] not in incident_uids
  243. ):
  244. incident_uids.append(result["incident_uid"])
  245. self.commit()
  246. except Exception:
  247. self.rollback()
  248. raise
  249. return {**counters, "incident_uids": incident_uids}
  250. def _signal(self, value: Any) -> dict[str, Any]:
  251. body = _closed_object(
  252. value,
  253. {
  254. "source_event_key",
  255. "source_type",
  256. "source_uid",
  257. "correlation_key",
  258. "layer",
  259. "sli_type",
  260. "status",
  261. "severity",
  262. "title",
  263. "owner_uid",
  264. "observed_at",
  265. "actual",
  266. "target",
  267. "evidence",
  268. "impacts",
  269. },
  270. "observability signal",
  271. )
  272. layer = _string(body.get("layer"), "layer", 20)
  273. if layer not in LAYERS:
  274. raise ValueError("observability signal layer is invalid")
  275. sli_type = _string(body.get("sli_type"), "sli_type", 30)
  276. if sli_type not in SLI_TYPES:
  277. raise ValueError("observability signal sli_type is invalid")
  278. status = _string(body.get("status"), "status", 20)
  279. if status not in {"violated", "recovered"}:
  280. raise ValueError("observability signal status is invalid")
  281. severity = _string(body.get("severity"), "severity", 20)
  282. _severity_rank(severity)
  283. evidence = body.get("evidence")
  284. if not isinstance(evidence, dict) or not evidence.get("deterministic"):
  285. raise ValueError("signal evidence must be deterministic")
  286. impacts = body.get("impacts")
  287. if not isinstance(impacts, list) or not impacts:
  288. raise ValueError("signal impacts are required")
  289. normalized_impacts = []
  290. for raw_impact in impacts:
  291. impact = _closed_object(
  292. raw_impact,
  293. {"target_type", "target_uid", "label"},
  294. "signal impact",
  295. )
  296. target_type = _string(
  297. impact.get("target_type"), "impact target_type", 40
  298. )
  299. if target_type not in IMPACT_TYPES:
  300. raise ValueError("impact target_type is invalid")
  301. normalized_impacts.append(
  302. {
  303. "target_type": target_type,
  304. "target_uid": _string(
  305. impact.get("target_uid"), "impact target_uid", 500
  306. ),
  307. "label": _string(impact.get("label"), "impact label", 300),
  308. }
  309. )
  310. observed_at = _timestamp(body.get("observed_at"), "observed_at")
  311. return {
  312. "source_event_key": _string(
  313. body.get("source_event_key"), "source_event_key", 500
  314. ),
  315. "source_type": _string(
  316. body.get("source_type"), "source_type", 80
  317. ),
  318. "source_uid": _string(body.get("source_uid"), "source_uid", 500),
  319. "correlation_key": _string(
  320. body.get("correlation_key"), "correlation_key", 500
  321. ),
  322. "layer": layer,
  323. "sli_type": sli_type,
  324. "status": status,
  325. "severity": severity,
  326. "title": _string(body.get("title"), "title", 300),
  327. "owner_uid": _uid(body.get("owner_uid"), "owner_uid"),
  328. "observed_at": observed_at.isoformat(),
  329. "actual": _number(body.get("actual"), "actual"),
  330. "target": _number(body.get("target"), "target"),
  331. "evidence": dict(evidence),
  332. "impacts": normalized_impacts,
  333. }
  334. def _process_signal(
  335. self,
  336. signal: dict[str, Any],
  337. *,
  338. actor_uid: str,
  339. ) -> dict[str, Any]:
  340. now = self.now_factory()
  341. dedup_key = _canonical_hash(
  342. {
  343. "correlation_key": signal["correlation_key"],
  344. "sli_type": signal["sli_type"],
  345. }
  346. )
  347. alert = self.repository.find_alert(dedup_key)
  348. if signal["status"] == "recovered":
  349. result = self._recover(
  350. signal,
  351. alert=alert,
  352. dedup_key=dedup_key,
  353. actor_uid=actor_uid,
  354. now=now,
  355. )
  356. else:
  357. result = self._violate(
  358. signal,
  359. alert=alert,
  360. dedup_key=dedup_key,
  361. actor_uid=actor_uid,
  362. now=now,
  363. )
  364. return result
  365. def _violate(
  366. self,
  367. signal: dict[str, Any],
  368. *,
  369. alert: dict[str, Any] | None,
  370. dedup_key: str,
  371. actor_uid: str,
  372. now: datetime,
  373. ) -> dict[str, Any]:
  374. if alert is None or alert["status"] == "recovered":
  375. alert = {
  376. "uid": self.uid_factory(),
  377. "dedup_key": dedup_key,
  378. "incident_uid": None,
  379. "layer": signal["layer"],
  380. "sli_type": signal["sli_type"],
  381. "title": signal["title"],
  382. "severity": signal["severity"],
  383. "status": "open",
  384. "occurrence_count": 1,
  385. "escalation_level": 1,
  386. "owner_uid": signal["owner_uid"],
  387. "first_observed_at": signal["observed_at"],
  388. "last_observed_at": signal["observed_at"],
  389. "suppressed_until": None,
  390. "suppression_reason": None,
  391. "delivery_status": "pending",
  392. "delivery_receipt": {},
  393. "evidence": signal["evidence"],
  394. "created_at": now.isoformat(),
  395. "updated_at": now.isoformat(),
  396. }
  397. alert_counter = "created_alerts"
  398. else:
  399. alert["occurrence_count"] = int(alert["occurrence_count"]) + 1
  400. alert["escalation_level"] = min(
  401. 3, int(alert["escalation_level"]) + 1
  402. )
  403. if _severity_rank(signal["severity"]) > _severity_rank(
  404. alert["severity"]
  405. ):
  406. alert["severity"] = signal["severity"]
  407. alert["last_observed_at"] = signal["observed_at"]
  408. suppressed_until = alert.get("suppressed_until")
  409. if not suppressed_until or _timestamp(
  410. suppressed_until, "suppressed_until"
  411. ) <= now:
  412. alert["status"] = "open"
  413. alert["delivery_status"] = "pending"
  414. alert["evidence"] = signal["evidence"]
  415. alert["updated_at"] = now.isoformat()
  416. alert_counter = "aggregated_alerts"
  417. incident = self.repository.find_open_incident(dedup_key)
  418. if incident is None:
  419. incident_uid = self.uid_factory()
  420. incident = {
  421. "uid": incident_uid,
  422. "code": f"INC-{now:%Y%m%d}-{incident_uid[-6:].upper()}",
  423. "dedup_key": dedup_key,
  424. "title": signal["title"],
  425. "severity": signal["severity"],
  426. "status": "open",
  427. "owner_uid": signal["owner_uid"],
  428. "escalation_level": alert["escalation_level"],
  429. "first_detected_at": signal["observed_at"],
  430. "last_observed_at": signal["observed_at"],
  431. "created_by": actor_uid,
  432. "created_at": now.isoformat(),
  433. "updated_at": now.isoformat(),
  434. "closed_by": None,
  435. "closed_at": None,
  436. }
  437. self.repository.create_incident(incident)
  438. else:
  439. incident["last_observed_at"] = signal["observed_at"]
  440. incident["escalation_level"] = max(
  441. int(incident["escalation_level"]),
  442. int(alert["escalation_level"]),
  443. )
  444. if _severity_rank(signal["severity"]) > _severity_rank(
  445. incident["severity"]
  446. ):
  447. incident["severity"] = signal["severity"]
  448. incident["status"] = "open"
  449. incident["updated_at"] = now.isoformat()
  450. self.repository.update_incident(incident)
  451. alert["incident_uid"] = incident["uid"]
  452. if alert_counter == "created_alerts":
  453. self.repository.create_alert(alert)
  454. action = "alert_created"
  455. else:
  456. self.repository.update_alert(alert)
  457. action = "alert_aggregated"
  458. self.repository.save_impacts(incident["uid"], signal["impacts"])
  459. self._timeline(
  460. incident["uid"],
  461. action,
  462. actor_uid=actor_uid,
  463. evidence={
  464. **signal["evidence"],
  465. "source_event_key": signal["source_event_key"],
  466. "alert_uid": alert["uid"],
  467. },
  468. now=now,
  469. )
  470. return {"counter": alert_counter, "incident_uid": incident["uid"]}
  471. def _recover(
  472. self,
  473. signal: dict[str, Any],
  474. *,
  475. alert: dict[str, Any] | None,
  476. dedup_key: str,
  477. actor_uid: str,
  478. now: datetime,
  479. ) -> dict[str, Any]:
  480. if alert is None or alert.get("incident_uid") is None:
  481. return {"counter": "ignored", "incident_uid": None}
  482. alert["status"] = "recovered"
  483. alert["last_observed_at"] = signal["observed_at"]
  484. alert["delivery_status"] = "recovery_pending"
  485. alert["evidence"] = signal["evidence"]
  486. alert["updated_at"] = now.isoformat()
  487. self.repository.update_alert(alert)
  488. incident = self.repository.get_incident(alert["incident_uid"])
  489. if incident is None:
  490. return {"counter": "ignored", "incident_uid": None}
  491. incident["status"] = "monitoring"
  492. incident["last_observed_at"] = signal["observed_at"]
  493. incident["updated_at"] = now.isoformat()
  494. self.repository.update_incident(incident)
  495. self._timeline(
  496. incident["uid"],
  497. "recovered",
  498. actor_uid=actor_uid,
  499. evidence={
  500. **signal["evidence"],
  501. "source_event_key": signal["source_event_key"],
  502. "alert_uid": alert["uid"],
  503. },
  504. now=now,
  505. )
  506. return {
  507. "counter": "recovered_alerts",
  508. "incident_uid": incident["uid"],
  509. }
  510. def suppress_alert(
  511. self,
  512. alert_uid: str,
  513. *,
  514. until: datetime | str,
  515. reason: str,
  516. actor_uid: str,
  517. ) -> dict[str, Any]:
  518. alert = self.repository.get_alert(_uid(alert_uid, "alert_uid"))
  519. if alert is None:
  520. raise LookupError("alert was not found")
  521. if alert["status"] == "recovered":
  522. raise RuntimeError("recovered alert cannot be suppressed")
  523. deadline = _timestamp(until, "until")
  524. now = self.now_factory()
  525. if deadline <= now:
  526. raise ValueError("suppression deadline must be in the future")
  527. alert.update(
  528. {
  529. "status": "suppressed",
  530. "suppressed_until": deadline.isoformat(),
  531. "suppression_reason": _string(reason, "reason", 500),
  532. "delivery_status": "suppressed",
  533. "updated_at": now.isoformat(),
  534. }
  535. )
  536. return self._save_alert_action(
  537. alert,
  538. action="suppressed",
  539. actor_uid=actor_uid,
  540. evidence={"reason": alert["suppression_reason"], "deterministic": True},
  541. )
  542. def acknowledge_delivery(
  543. self,
  544. alert_uid: str,
  545. *,
  546. channel: str,
  547. receipt_id: str,
  548. actor_uid: str,
  549. ) -> dict[str, Any]:
  550. alert = self.repository.get_alert(_uid(alert_uid, "alert_uid"))
  551. if alert is None:
  552. raise LookupError("alert was not found")
  553. now = self.now_factory()
  554. alert.update(
  555. {
  556. "delivery_status": "acknowledged",
  557. "delivery_receipt": {
  558. "channel": _string(channel, "channel", 80),
  559. "receipt_id": _string(receipt_id, "receipt_id", 200),
  560. "acknowledged_by": _uid(actor_uid, "actor_uid"),
  561. "acknowledged_at": now.isoformat(),
  562. },
  563. "updated_at": now.isoformat(),
  564. }
  565. )
  566. return self._save_alert_action(
  567. alert,
  568. action="delivery_acknowledged",
  569. actor_uid=actor_uid,
  570. evidence={
  571. "receipt_id": alert["delivery_receipt"]["receipt_id"],
  572. "deterministic": True,
  573. },
  574. )
  575. def _save_alert_action(
  576. self,
  577. alert: dict[str, Any],
  578. *,
  579. action: str,
  580. actor_uid: str,
  581. evidence: dict[str, Any],
  582. ) -> dict[str, Any]:
  583. try:
  584. stored = self.repository.update_alert(alert)
  585. self._timeline(
  586. alert["incident_uid"],
  587. action,
  588. actor_uid=_uid(actor_uid, "actor_uid"),
  589. evidence=evidence,
  590. now=self.now_factory(),
  591. )
  592. self.commit()
  593. return stored
  594. except Exception:
  595. self.rollback()
  596. raise
  597. def escalate_incident(
  598. self,
  599. incident_uid: str,
  600. *,
  601. reason: str,
  602. actor_uid: str,
  603. ) -> dict[str, Any]:
  604. uid = _uid(incident_uid, "incident_uid")
  605. incident = self.repository.get_incident(uid)
  606. if incident is None:
  607. raise LookupError("incident was not found")
  608. if incident["status"] == "closed":
  609. raise RuntimeError("closed incident cannot be escalated")
  610. incident["escalation_level"] = min(
  611. 3, int(incident["escalation_level"]) + 1
  612. )
  613. incident["updated_at"] = self.now_factory().isoformat()
  614. try:
  615. stored = self.repository.update_incident(incident)
  616. self._timeline(
  617. uid,
  618. "escalated",
  619. actor_uid=_uid(actor_uid, "actor_uid"),
  620. evidence={
  621. "reason": _string(reason, "reason", 500),
  622. "level": incident["escalation_level"],
  623. "deterministic": True,
  624. },
  625. now=self.now_factory(),
  626. )
  627. self.commit()
  628. return stored
  629. except Exception:
  630. self.rollback()
  631. raise
  632. def close_incident(
  633. self,
  634. incident_uid: str,
  635. payload: Any,
  636. *,
  637. actor_uid: str,
  638. ) -> dict[str, Any]:
  639. uid = _uid(incident_uid, "incident_uid")
  640. body = _closed_object(
  641. payload,
  642. {
  643. "root_cause",
  644. "user_impact",
  645. "corrective_actions",
  646. "closure_evidence",
  647. },
  648. "incident postmortem",
  649. )
  650. required = {
  651. "root_cause",
  652. "user_impact",
  653. "corrective_actions",
  654. "closure_evidence",
  655. }
  656. if set(body) != required:
  657. raise ValueError("incident postmortem evidence is incomplete")
  658. detail = self.repository.incident_detail(uid)
  659. if detail is None:
  660. raise LookupError("incident was not found")
  661. if detail["status"] == "closed":
  662. return detail
  663. if not detail.get("owner_uid") or not detail.get("impacts"):
  664. raise RuntimeError("incident responsibility and impact are required")
  665. if not detail.get("alerts") or any(
  666. item["status"] != "recovered" for item in detail["alerts"]
  667. ):
  668. raise RuntimeError("all incident alerts must recover before closure")
  669. actions = body["corrective_actions"]
  670. evidence = body["closure_evidence"]
  671. if (
  672. not isinstance(actions, list)
  673. or not actions
  674. or not isinstance(evidence, list)
  675. or not evidence
  676. ):
  677. raise ValueError("incident postmortem evidence is incomplete")
  678. normalized_actions = [
  679. _string(item, "corrective action", 500) for item in actions
  680. ]
  681. normalized_evidence = []
  682. for item in evidence:
  683. value = _closed_object(
  684. item, {"kind", "ref"}, "closure evidence"
  685. )
  686. normalized_evidence.append(
  687. {
  688. "kind": _string(value.get("kind"), "evidence kind", 80),
  689. "ref": _string(value.get("ref"), "evidence ref", 500),
  690. }
  691. )
  692. actor = _uid(actor_uid, "actor_uid")
  693. now = self.now_factory()
  694. postmortem = {
  695. "uid": self.uid_factory(),
  696. "incident_uid": uid,
  697. "root_cause": _string(
  698. body.get("root_cause"), "root_cause", 2_000
  699. ),
  700. "user_impact": _string(
  701. body.get("user_impact"), "user_impact", 2_000
  702. ),
  703. "corrective_actions": normalized_actions,
  704. "closure_evidence": normalized_evidence,
  705. "created_by": actor,
  706. "created_at": now.isoformat(),
  707. }
  708. incident = self.repository.get_incident(uid)
  709. incident.update(
  710. {
  711. "status": "closed",
  712. "closed_by": actor,
  713. "closed_at": now.isoformat(),
  714. "updated_at": now.isoformat(),
  715. }
  716. )
  717. try:
  718. self.repository.save_postmortem(postmortem)
  719. self.repository.update_incident(incident)
  720. self._timeline(
  721. uid,
  722. "closed",
  723. actor_uid=actor,
  724. evidence={
  725. "postmortem_uid": postmortem["uid"],
  726. "closure_evidence_count": len(normalized_evidence),
  727. "deterministic": True,
  728. },
  729. now=now,
  730. )
  731. self.commit()
  732. return self.repository.incident_detail(uid)
  733. except Exception:
  734. self.rollback()
  735. raise
  736. def _timeline(
  737. self,
  738. incident_uid: str,
  739. action: str,
  740. *,
  741. actor_uid: str,
  742. evidence: dict[str, Any],
  743. now: datetime,
  744. ) -> None:
  745. _reject_secret_material(evidence)
  746. self.repository.append_timeline(
  747. {
  748. "uid": self.uid_factory(),
  749. "incident_uid": incident_uid,
  750. "action": action,
  751. "actor_uid": actor_uid,
  752. "evidence": evidence,
  753. "created_at": now.isoformat(),
  754. }
  755. )
  756. def list_alerts(self) -> list[dict[str, Any]]:
  757. return self.repository.list_alerts()
  758. def list_incidents(self) -> list[dict[str, Any]]:
  759. return self.repository.list_incidents()
  760. def incident_detail(self, uid: str) -> dict[str, Any]:
  761. value = self.repository.incident_detail(_uid(uid, "incident_uid"))
  762. if value is None:
  763. raise LookupError("incident was not found")
  764. return value
  765. def overview(self) -> dict[str, Any]:
  766. counts = self.repository.overview_counts()
  767. return {
  768. "layers": {layer: dict(counts.get(layer) or {}) for layer in LAYERS},
  769. "sli_catalog": self.sli_catalog(),
  770. "slos": self.list_slos(),
  771. "open_incident_count": sum(
  772. item["status"] != "closed" for item in self.list_incidents()
  773. ),
  774. }