hermes-agent/agent/telemetry/rollup.py
emozilla 3e28eaccde feat(telemetry): local-first telemetry & observability
Add a built-in telemetry system that records what the agent does — workflows,
model calls, tool calls, errors — to the local machine, powers `/insights`, and
can export to an operator-chosen destination. Default-on locally; nothing leaves
the machine unless the user exports it or opts into the aggregate plane.

Three planes with a hard wall between them:
  - local: full-fidelity observability (real model/provider/tool names), on by
    default, never leaves the machine.
  - aggregate: opt-in metadata, default off. No uploader ships — consent is
    recorded via telemetry.consent_state, and `preview` shows what would be
    produced, computed locally.
  - trajectories: full message content, opt-in, exported only to the operator's
    own destination.

Mechanism:
  - Bundled `telemetry` plugin registers observational lifecycle hooks
    (on_session_start / post_api_request / post_tool_call / on_session_finalize).
    No core call sites are edited; hooks already carry the data.
  - Fire-and-forget emitter: emit() returns in microseconds, never blocks or
    raises into a model/tool call. A daemon thread writes events to an
    append-only JSONL log and the tel_* tables in state.db (its own sqlite
    connection, separate from SessionDB).
  - tel_runs / tel_model_calls / tel_tool_calls live in the declarative
    SCHEMA_SQL and are reconciled automatically; SCHEMA_VERSION 16 -> 17.
  - metrics derives rollups for /usage and /insights; rollup builds per-run
    summaries for `hermes telemetry preview`.

Consent is config, not a parallel command surface. The config file is the root
of trust: set telemetry.consent_state with `hermes config set`, or pin any
telemetry.* key (including allow_aggregate) via managed scope, which overrides
the user's value per key. `hermes telemetry` exposes only what config cannot:
status (report), preview (query), and export.

Export:
  - exporter_bulk writes telemetry (and, when the trajectories plane is enabled,
    session content) to ndjson/json.
  - otlp_exporter streams spans to a configured OpenTelemetry Collector over
    OTLP/HTTP. The SDK is an optional extra (hermes-agent[otlp]), lazily
    installed via tools.lazy_deps on first use.
  - Secrets are always redacted on every export path
    (redact_sensitive_text(force=True)); content export is gated by the
    trajectories plane, and PII scrubbing follows telemetry.content_redaction.
    OTLP auth headers reference environment variable names, never inline values.

No outbound emission to Nous. The aggregate uploader is intentionally not built.
2026-07-24 18:54:45 +00:00

145 lines
5 KiB
Python

"""Build per-run summary events from the local telemetry tables.
Reads the ``tel_*`` tables and projects each completed run into a summary dict holding
the recorded values: provider, models used, tool names, token totals, duration, and
cost. Powers ``hermes telemetry preview``. No aggregation or bucketing is applied here.
"""
from __future__ import annotations
import platform
import sqlite3
from pathlib import Path
from typing import Any, Dict, List, Optional
def _os_family() -> str:
s = platform.system().lower()
if s.startswith("lin"):
return "linux"
if s == "darwin":
return "macos"
if s.startswith("win"):
return "windows"
return "other"
def _hermes_version() -> str:
try:
from hermes_cli import __version__
return str(__version__)
except Exception:
return "0.0.0"
def _open(db_path: Optional[Path], conn: Optional[sqlite3.Connection]):
if conn is not None:
prev = conn.row_factory
conn.row_factory = sqlite3.Row
return conn, prev, False
if db_path is None:
from hermes_constants import get_hermes_home
db_path = get_hermes_home() / "state.db"
c = sqlite3.connect(str(db_path), timeout=5.0)
c.row_factory = sqlite3.Row
return c, None, True
def _run_events(c: sqlite3.Connection, since_ns: Optional[int]) -> List[Dict[str, Any]]:
"""Project completed runs into per-run summary dicts."""
where = " WHERE end_ns IS NOT NULL"
if since_ns:
where += f" AND start_ns >= {int(since_ns)}"
rows = c.execute(
"SELECT run_id, entrypoint, platform, end_reason, start_ns, end_ns, "
"model_call_count, tool_call_count, error_count, estimated_cost_usd "
"FROM tel_runs" + where
).fetchall()
events: List[Dict[str, Any]] = []
for r in rows:
# Models actually used in this run (real ids), with token totals.
models = [
{"provider": m["provider"], "model": m["model"],
"calls": m["n"], "input_tokens": int(m["inp"] or 0),
"output_tokens": int(m["outp"] or 0)}
for m in c.execute(
"SELECT provider, model, COUNT(*) n, SUM(input_tokens) inp, "
"SUM(output_tokens) outp FROM tel_model_calls WHERE run_id = ? "
"GROUP BY provider, model ORDER BY n DESC",
(r["run_id"],),
).fetchall()
]
tools = [
row["tool_name"]
for row in c.execute(
"SELECT DISTINCT tool_name FROM tel_tool_calls WHERE run_id = ?",
(r["run_id"],),
).fetchall()
if row["tool_name"]
]
trow = c.execute(
"SELECT SUM(input_tokens) inp, SUM(output_tokens) outp "
"FROM tel_model_calls WHERE run_id = ?",
(r["run_id"],),
).fetchone()
duration_ms = (r["end_ns"] - r["start_ns"]) / 1e6 if r["end_ns"] else None
events.append({
"event_name": "workflow_completed",
"run_id": r["run_id"],
"entrypoint": r["entrypoint"] or "cli",
"platform": r["platform"],
"end_reason": r["end_reason"] or "completed",
"models_used": models,
"tools_used": tools,
"model_call_count": r["model_call_count"] or 0,
"tool_call_count": r["tool_call_count"] or 0,
"error_count": r["error_count"] or 0,
"duration_ms": round(duration_ms, 1) if duration_ms is not None else None,
"input_tokens": int((trow["inp"] if trow else 0) or 0),
"output_tokens": int((trow["outp"] if trow else 0) or 0),
"estimated_cost_usd": r["estimated_cost_usd"],
})
return events
def build_aggregate_events(
*,
install_id: str,
db_path: Optional[Path] = None,
since_ns: Optional[int] = None,
conn: Optional[sqlite3.Connection] = None,
include_heartbeat: bool = True,
) -> List[Dict[str, Any]]:
"""Return per-run summary events plus an optional heartbeat."""
c, prev_factory, owned = _open(db_path, conn)
try:
events = _run_events(c, since_ns)
if include_heartbeat:
events.append({
"event_name": "heartbeat",
"install_id": install_id,
"hermes_version": _hermes_version(),
"os_family": _os_family(),
"entrypoint": "cli",
})
return events
finally:
if owned:
c.close()
elif prev_factory is not None:
c.row_factory = prev_factory
def summarize(events: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Counts by event_name + field coverage, for status/preview output."""
by_name: Dict[str, int] = {}
fields = set()
for e in events:
name = e.get("event_name", "?")
by_name[name] = by_name.get(name, 0) + 1
fields.update(e.keys())
return {"total": len(events), "by_event_name": by_name, "fields_present": sorted(fields)}
__all__ = ["build_aggregate_events", "summarize"]