feat(governance): emit adr100 slo metrics
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 1m0s
CD Pipeline / build-and-deploy (push) Successful in 3m21s
CD Pipeline / post-deploy-checks (push) Successful in 1m16s

This commit is contained in:
Your Name
2026-05-14 18:57:03 +08:00
parent 1670ff1960
commit 13cf02b740
7 changed files with 334 additions and 9 deletions

View File

@@ -76,13 +76,13 @@ from src.api.v1 import terminal as terminal_v1 # Phase 19.1: Omni-Terminal SSE
from src.api.v1 import timeline as timeline_v1
from src.api.v1 import webhooks as webhooks_v1
from src.core.config import settings
from src.core.feature_flags import aiops_flags # ADR-080: AI 自主化飛輪 feature flags 啟動驗證
from src.core.http_client import close_all_http_clients, init_all_http_clients
from src.core.logging import get_logger, setup_logging
from src.core.redis_client import close_redis_pool, init_redis_pool
from src.services.flywheel_stats_service import get_flywheel_stats_service
from src.core.sse import get_publisher
from src.core.telemetry import setup_telemetry, shutdown_telemetry
from src.services.adr100_slo_metrics_service import get_adr100_slo_metrics_service
from src.services.flywheel_stats_service import get_flywheel_stats_service
# CTO-201: Database & Executor
from src.db.base import close_db, init_db
@@ -554,7 +554,6 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 2026-04-27 P3.1-T3 by Claude
try:
from src.utils.timezone import now_taipei
from datetime import datetime as _dt
async def _run_kb_rot_cleaner_loop() -> None:
from src.jobs.kb_rot_cleaner import get_kb_rot_cleaner
@@ -1016,6 +1015,13 @@ async def prometheus_metrics() -> Response:
content += flywheel_metrics.to_prometheus_lines()
except Exception:
logger.warning("prometheus_metrics_flywheel_error")
# 2026-05-14 Codex — T18 ADR-100 SLO emitter
# GovernanceAgent 讀 Prometheus recording rules若 /metrics 不吐底層 DB totals
# sli:* rules 會全空並每小時重複發 governance_slo_data_gap。
try:
content += await get_adr100_slo_metrics_service().to_prometheus_lines()
except Exception as exc:
logger.warning("prometheus_metrics_adr100_slo_error", error=str(exc))
return Response(content=content, media_type=CONTENT_TYPE_LATEST)

View File

@@ -0,0 +1,217 @@
"""
ADR-100 SLO metrics emitter.
Prometheus recording rules for the AI flywheel SLOs expect a small set of
counter-like metrics. The source of truth already lives in PostgreSQL, so this
read-side emitter exposes DB totals on /metrics without changing runtime write
paths or introducing another state store.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from time import time
from sqlalchemy import text
from src.db.base import get_db_context
@dataclass(frozen=True)
class AutomationOperationSample:
outcome: str
operation_type: str
count: int
@dataclass(frozen=True)
class VerificationSample:
outcome: str
count: int
@dataclass(frozen=True)
class Adr100SloMetricsSnapshot:
automation_operations: list[AutomationOperationSample] = field(default_factory=list)
post_execution_verifications: list[VerificationSample] = field(default_factory=list)
knowledge_entries_total: int = 0
high_confidence_total: int = 0
high_confidence_success_total: int = 0
emitted_at: float = field(default_factory=time)
class Adr100SloMetricsService:
"""Build ADR-100 Prometheus samples from production DB state."""
async def to_prometheus_lines(self) -> str:
snapshot = await self.fetch_snapshot()
return render_adr100_slo_metrics(snapshot)
async def fetch_snapshot(self) -> Adr100SloMetricsSnapshot:
async with get_db_context() as db:
automation_rows = (
await db.execute(text(_AUTOMATION_OPERATION_SQL))
).fetchall()
verification_rows = (
await db.execute(text(_POST_EXECUTION_VERIFICATION_SQL))
).fetchall()
knowledge_total = int(
(await db.execute(text("SELECT count(*) FROM knowledge_entries"))).scalar()
or 0
)
confidence_row = (
await db.execute(text(_HIGH_CONFIDENCE_APPROVAL_SQL))
).one()
return Adr100SloMetricsSnapshot(
automation_operations=[
AutomationOperationSample(
outcome=str(row.outcome),
operation_type=str(row.operation_type),
count=int(row.count or 0),
)
for row in automation_rows
],
post_execution_verifications=[
VerificationSample(
outcome=str(row.outcome),
count=int(row.count or 0),
)
for row in verification_rows
],
knowledge_entries_total=knowledge_total,
high_confidence_total=int(confidence_row.high_confidence_total or 0),
high_confidence_success_total=int(
confidence_row.high_confidence_success_total or 0
),
)
def render_adr100_slo_metrics(snapshot: Adr100SloMetricsSnapshot) -> str:
"""Render ADR-100 SLO metrics in Prometheus text exposition format."""
lines: list[str] = [
"",
"# HELP automation_operation_log_total DB-derived AI automation operation count for ADR-100 SLOs",
"# TYPE automation_operation_log_total counter",
]
if snapshot.automation_operations:
for sample in snapshot.automation_operations:
lines.append(
"automation_operation_log_total"
f'{{outcome="{_escape_label(sample.outcome)}",'
f'operation_type="{_escape_label(sample.operation_type)}"}} '
f"{sample.count}"
)
else:
lines.append(
'automation_operation_log_total{outcome="none",operation_type="none"} 0'
)
lines.extend([
"# HELP post_execution_verification_total DB-derived post execution verification result count for ADR-100 SLOs",
"# TYPE post_execution_verification_total counter",
])
if snapshot.post_execution_verifications:
for sample in snapshot.post_execution_verifications:
lines.append(
"post_execution_verification_total"
f'{{outcome="{_escape_label(sample.outcome)}"}} {sample.count}'
)
else:
lines.append('post_execution_verification_total{outcome="none"} 0')
lines.extend([
"# HELP knowledge_entries_total DB-derived knowledge entry count for ADR-100 SLOs",
"# TYPE knowledge_entries_total counter",
f"knowledge_entries_total {snapshot.knowledge_entries_total}",
"# HELP approval_records_high_confidence_total DB-derived high confidence approval decisions for ADR-100 SLOs",
"# TYPE approval_records_high_confidence_total counter",
f"approval_records_high_confidence_total {snapshot.high_confidence_total}",
"# HELP approval_records_high_confidence_success_total DB-derived high confidence approval decisions with successful verification for ADR-100 SLOs",
"# TYPE approval_records_high_confidence_success_total counter",
(
"approval_records_high_confidence_success_total "
f"{snapshot.high_confidence_success_total}"
),
"# HELP adr100_slo_emitter_last_success_timestamp Last successful ADR-100 DB metrics emission timestamp",
"# TYPE adr100_slo_emitter_last_success_timestamp gauge",
f"adr100_slo_emitter_last_success_timestamp {snapshot.emitted_at:.0f}",
"",
])
return "\n".join(lines)
def _escape_label(value: str) -> str:
return value.replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')
_AUTOMATION_OPERATION_SQL = """
SELECT
CASE
WHEN status <> 'success' THEN status
WHEN actor = 'approval_execution'
AND COALESCE(input->>'requested_by', '') NOT ILIKE 'auto%%'
THEN 'human_required'
ELSE 'auto_executed'
END AS outcome,
operation_type,
count(*) AS count
FROM automation_operation_log
GROUP BY outcome, operation_type
ORDER BY outcome, operation_type
"""
_POST_EXECUTION_VERIFICATION_SQL = """
SELECT verification_result AS outcome, count(*) AS count
FROM incident_evidence
WHERE verification_result IS NOT NULL
GROUP BY verification_result
ORDER BY verification_result
"""
_HIGH_CONFIDENCE_APPROVAL_SQL = """
WITH approval_confidence AS (
SELECT
id,
incident_id,
COALESCE(
CASE
WHEN extra_metadata->>'confidence_score' ~ '^[0-9]+(\\.[0-9]+)?$'
THEN (extra_metadata->>'confidence_score')::numeric
ELSE NULL
END,
CASE
WHEN extra_metadata->>'confidence' ~ '^[0-9]+(\\.[0-9]+)?$'
THEN (extra_metadata->>'confidence')::numeric
ELSE NULL
END,
composite_score,
0
) AS confidence
FROM approval_records
)
SELECT
count(*) FILTER (WHERE confidence >= 0.8) AS high_confidence_total,
count(*) FILTER (
WHERE confidence >= 0.8
AND EXISTS (
SELECT 1
FROM incident_evidence ev
WHERE ev.incident_id = approval_confidence.incident_id
AND ev.verification_result = 'success'
)
) AS high_confidence_success_total
FROM approval_confidence
"""
_adr100_slo_metrics_service: Adr100SloMetricsService | None = None
def get_adr100_slo_metrics_service() -> Adr100SloMetricsService:
global _adr100_slo_metrics_service
if _adr100_slo_metrics_service is None:
_adr100_slo_metrics_service = Adr100SloMetricsService()
return _adr100_slo_metrics_service

View File

@@ -447,13 +447,13 @@ class GovernanceAgent:
"status": "skipped",
"error": "no_data",
"reason": "prometheus_empty_result_metric_not_emitted",
"hint": "ADR-100 emitter 未實作或 PROMETHEUS_MULTIPROC_DIR 未設",
"hint": "ADR-100 emitter 未輸出、Prometheus recording rule 未載入,或 multiprocess 目錄未掛載",
}
logger.warning(
"governance_slo_no_data",
slo=name,
query=query,
hint="ADR-100 emitter not yet implemented",
hint="ADR-100 metrics, recording rules, or multiprocess mount not ready",
)
continue
value = float(result_list[0]["value"][1])
@@ -655,15 +655,15 @@ class GovernanceAgent:
},
"remediation": {
"items": [
"補齊 ADR-100 SLO emitterautomation_operation_log_total / post_execution_verification_total / km_entries_total",
"設置 PROMETHEUS_MULTIPROC_DIR 並掛載可寫目錄(如 emptyDir",
"補齊 ADR-100 SLO emitterautomation_operation_log_total / post_execution_verification_total / knowledge_entries_total",
"確認 Prometheus recording rules 已載入,且 API Pod multiprocess 目錄可寫",
],
"next_action": "run_adr100_slo_emit_playbook",
"hint": "ADR-100 emitter 未實作或 PROMETHEUS_MULTIPROC_DIR 未設",
"hint": "ADR-100 emitter、Prometheus recording rules、或 multiprocess 目錄任一環節未就緒",
},
"actionable": {
"items": [
"先確認所有 API Pod 是否有 PROMETHEUS_MULTIPROC_DIR 掛載",
"先確認 /metrics 是否已輸出 ADR-100 底層指標",
"檢查 Prometheus rule 是否已載入 sli:autonomy_rate:5m 等 4 項告警規則",
],
},