Ctrl K
ComunidadGuías…

soc_ai/metrics.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Lightweight Prometheus exposition for soc-ai.

Avoids the ``prometheus_client`` dependency to keep the install slim;
emits Prometheus 0.0.4 plain-text format from scratch. The metric set
is intentionally small — only the operationally-interesting counters
the orchestrator already tracks. Add new metrics as they justify
themselves.

Counters / gauges exposed:

- ``socai_build_info`` (gauge, labeled by ``version``) — always 1.
- ``socai_investigations_total`` — number of /investigate requests
  the orchestrator has fully streamed (any verdict).
- ``socai_investigation_errors_total`` — investigations that yielded
  any ``error`` event.
- ``socai_investigation_retasks_total`` — investigations where the
  retask round fired (synthesis confidence below floor).
- ``socai_investigation_fallback_verdicts_total`` — investigations
  where a synthesis-failure fallback (synthetic/M27) report was
  emitted. Watch this stays under 5% of investigations.
- ``socai_investigation_zero_tool_verdicts_total`` — zero-tool TP/FP
  verdicts blocked/coerced by the evidence gate (QVOD early-warning).
- ``socai_oracle_refusals_total`` — Oracle adjudications refused because
  the independent residue gate flagged the sanitized payload. A nonzero
  and climbing value means the cloud second opinion is effectively off
  (the sanitizer and the gate disagree on some recurring shape).
- ``socai_tool_calls_total`` (labeled by ``tool``) — per-tool call
  counts, for spotting which read tools are hot or broken.
- ``socai_llm_tokens_total`` (labeled by ``phase``, ``direction``) —
  cumulative token usage; ``direction`` is ``input`` / ``output``.

The orchestrator's ``audit`` plumbing already produces every input;
we just keep a small in-process counter set that ``/metrics``
serializes on demand. No persistence — counters reset on restart,
which is appropriate for Prometheus's pull model (each scrape is a
delta against the previous one, computed by Prometheus itself).
"""

from __future__ import annotations

import asyncio
import time
from collections import defaultdict
from typing import Any


class _Metrics:
    """In-process metric counter set. Thread-safe under the asyncio loop."""

    def __init__(self) -> None:
        self.lock = asyncio.Lock()
        self.investigations_total = 0
        self.investigation_errors_total = 0
        self.investigation_retasks_total = 0
        self.investigation_fallback_verdicts_total = 0
        self.investigation_zero_tool_verdicts_total = 0
        self.oracle_refusals_total = 0
        self.tool_calls_total: dict[str, int] = defaultdict(int)
        # (phase, direction) -> tokens
        self.llm_tokens_total: dict[tuple[str, str], int] = defaultdict(int)
        self.start_time = time.time()

    async def record_event(self, kind: str, payload: dict[str, Any]) -> None:
        """Update counters from a single SSE event payload."""
        async with self.lock:
            if kind == "tool_call":
                tool = payload.get("tool_name") or "unknown"
                self.tool_calls_total[tool] += 1
            elif kind == "usage":
                phase = payload.get("phase") or "unknown"
                self.llm_tokens_total[(phase, "input")] += int(payload.get("input_tokens") or 0)
                self.llm_tokens_total[(phase, "output")] += int(payload.get("output_tokens") or 0)
            elif kind == "retask":
                self.investigation_retasks_total += 1
            elif kind == "fallback_verdict":
                self.investigation_fallback_verdicts_total += 1
            elif kind == "zero_tool_verdict_blocked":
                self.investigation_zero_tool_verdicts_total += 1
            elif kind == "error":
                self.investigation_errors_total += 1
            elif kind == "done":
                self.investigations_total += 1

    async def record_oracle_refusal(self) -> None:
        """Increment on an Oracle residue-gate refusal.

        Called from :func:`soc_ai.oracle.client.adjudicate` when the independent
        residue sweep flags the sanitized outbound payload and egress is refused
        (the local verdict is kept). Surfaces a silently-disabled Oracle — a log
        line already exists, but nothing was counted, so a gate refusing every
        transcript was invisible until now.
        """
        async with self.lock:
            self.oracle_refusals_total += 1


_GLOBAL = _Metrics()


def get_metrics() -> _Metrics:
    """Module-level metrics accessor (overridable in tests if needed)."""
    return _GLOBAL


def _esc(label_value: str) -> str:
    """Escape a label value per Prometheus exposition rules."""
    return label_value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")


def render(version: str) -> str:
    """Return the full Prometheus exposition text for the current state."""
    m = get_metrics()
    lines: list[str] = []

    lines.append("# HELP socai_build_info soc-ai build info; always 1.")
    lines.append("# TYPE socai_build_info gauge")
    lines.append(f'socai_build_info{{version="{_esc(version)}"}} 1')

    lines.append("# HELP socai_uptime_seconds Seconds since the soc-ai process booted.")
    lines.append("# TYPE socai_uptime_seconds gauge")
    lines.append(f"socai_uptime_seconds {int(time.time() - m.start_time)}")

    lines.append("# HELP socai_investigations_total Investigations fully streamed (any verdict).")
    lines.append("# TYPE socai_investigations_total counter")
    lines.append(f"socai_investigations_total {m.investigations_total}")

    lines.append(
        "# HELP socai_investigation_errors_total Investigations that yielded any error event."
    )
    lines.append("# TYPE socai_investigation_errors_total counter")
    lines.append(f"socai_investigation_errors_total {m.investigation_errors_total}")

    lines.append(
        "# HELP socai_investigation_retasks_total Investigations where the retask round fired."
    )
    lines.append("# TYPE socai_investigation_retasks_total counter")
    lines.append(f"socai_investigation_retasks_total {m.investigation_retasks_total}")

    lines.append(
        "# HELP socai_investigation_fallback_verdicts_total "
        "Investigations that emitted a synthesis-failure fallback (synthetic/M27) report."
    )
    lines.append("# TYPE socai_investigation_fallback_verdicts_total counter")
    lines.append(
        f"socai_investigation_fallback_verdicts_total {m.investigation_fallback_verdicts_total}"
    )

    lines.append(
        "# HELP socai_investigation_zero_tool_verdicts_total "
        "Zero-tool TP/FP verdicts blocked/coerced by the evidence gate."
    )
    lines.append("# TYPE socai_investigation_zero_tool_verdicts_total counter")
    lines.append(
        f"socai_investigation_zero_tool_verdicts_total {m.investigation_zero_tool_verdicts_total}"
    )

    lines.append(
        "# HELP socai_oracle_refusals_total "
        "Oracle adjudications refused because the residue gate flagged the sanitized payload."
    )
    lines.append("# TYPE socai_oracle_refusals_total counter")
    lines.append(f"socai_oracle_refusals_total {m.oracle_refusals_total}")

    lines.append("# HELP socai_tool_calls_total Per-tool call counts.")
    lines.append("# TYPE socai_tool_calls_total counter")
    for tool, count in sorted(m.tool_calls_total.items()):
        lines.append(f'socai_tool_calls_total{{tool="{_esc(tool)}"}} {count}')

    lines.append("# HELP socai_llm_tokens_total Cumulative LLM token usage by phase + direction.")
    lines.append("# TYPE socai_llm_tokens_total counter")
    for (phase, direction), count in sorted(m.llm_tokens_total.items()):
        lines.append(
            f'socai_llm_tokens_total{{phase="{_esc(phase)}",direction="{_esc(direction)}"}} {count}'
        )

    lines.append("")  # trailing newline
    return "\n".join(lines)