hermes-agent/tests/telemetry/test_plugin_e2e.py
emozilla edac3a7bae refactor(telemetry): cut dead schema; tests assert what's actually written
Self-review after the #51714 feedback found the reviewer's dead-table finding
was not isolated — the schema advertised far more than the code populates, and
our own tests hid it by hand-feeding fields production never sends. Make the
surface honest by subtraction.

Schema (10 tel_* tables -> 5):
  - Delete tel_gateway_events, tel_cron_events, tel_skill_events,
    tel_memory_events, tel_feedback_events — declared, never written, never read.
  - Drop columns nothing populates: tel_runs.{profile_id,estimated_cost_usd,
    cost_status}; tel_model_calls.{ttft_ms,estimated_cost_usd,cost_status,
    cost_source,end_reason,retry_count}; tel_tool_calls.{backend,retry_count,
    approval}; tel_spans.attrs_json. Cost duplicated the existing sessions
    billing columns and was always NULL here.
  - events.py / emitter _TABLE_COLUMNS / OTLP _span_attrs / rollup / preview
    display all trimmed to match.

Correctness:
  - end_reason no longer hardcodes "completed". Production finalize callers pass
    `reason` (shutdown/session_expired/session_reset); _coarse_end_reason now
    reads it and maps accordingly.
  - Fix a latent bug the trim exposed: the model_call hook passed end_reason= to
    ModelCallEvent, which the @_safe wrapper was silently swallowing — so
    tel_model_calls dropped every row in real runs. Now writes correctly.

Tests:
  - Stop hand-feeding estimated_cost_usd / turn_exit_reason that no production
    call site sends. Finalize is now driven with the real `reason` kwarg, and
    assertions cover only fields that are actually populated. This is what let
    the model_call drop hide — the suite graded on a fictional contract.

Net: a smaller system that does what it says. Verified end-to-end over the real
dispatch path (runs + connected span tree + model/tool rows populate; dead
tables gone). 160 telemetry/state/insights tests green.
2026-07-24 18:54:45 +00:00

127 lines
5 KiB
Python

"""End-to-end telemetry wiring test.
Unlike test_plugin_hooks.py (which calls the plugin's ``_on_*`` callbacks directly),
this drives the REAL dispatch chain that core uses at runtime:
discover_plugins() -> plugin registers hooks -> invoke_hook(name, **kwargs)
-> registered callback -> emitter -> tel_* tables
If the bundled plugin stops auto-loading, stops registering a hook, or the hook name
drifts from what core fires, the hand-written hook tests still pass but real runs go
dark. This test is the guard against that — it only touches public entry points
(``discover_plugins`` / ``invoke_hook``), exactly as core does.
"""
from __future__ import annotations
import sqlite3
import time
import pytest
import hermes_state
@pytest.fixture
def runtime(tmp_path, monkeypatch):
"""A clean HERMES_HOME with state.db, a fresh plugin manager, and a fresh emitter."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
db = tmp_path / "state.db"
hermes_state.SessionDB(db_path=db)
# Reset the global plugin-manager singleton so discovery re-runs in this HERMES_HOME.
import hermes_cli.plugins as plugins_mod
monkeypatch.setattr(plugins_mod, "_plugin_manager", None, raising=False)
# Reset the emitter singleton so it binds to this state.db (and tear it down after).
from agent.telemetry import emitter as emitter_mod
emitter_mod.reset_emitter_for_tests(None)
# Clear the plugin's per-run accumulators between tests.
import plugins.telemetry as plug
plug._runs.clear()
yield db, plugins_mod, emitter_mod, plug
try:
emitter_mod.get_emitter().flush()
except Exception:
pass
emitter_mod.reset_emitter_for_tests(None)
monkeypatch.setattr(plugins_mod, "_plugin_manager", None, raising=False)
def _fire_one_turn(invoke_hook):
"""Fire the hook sequence of a single completed turn, as core does."""
invoke_hook("on_session_start", session_id="s1",
model="anthropic/claude-opus-4", platform="cli")
invoke_hook("post_api_request", session_id="s1", platform="cli",
provider="anthropic", base_url=None, model="claude-opus-4",
api_duration=0.9,
usage={"input_tokens": 1000, "output_tokens": 120,
"cache_read_tokens": 0, "cache_write_tokens": 0,
"reasoning_tokens": 0})
invoke_hook("post_tool_call", session_id="s1", platform="cli",
function_name="web_search", duration_ms=210, result='{"data": "ok"}')
invoke_hook("on_session_finalize", session_id="s1", platform="cli",
reason="shutdown")
def test_real_dispatch_writes_tel_rows(runtime):
"""The bundled plugin, loaded via discover_plugins, captures a turn end to end."""
db, plugins_mod, emitter_mod, _plug = runtime
plugins_mod.discover_plugins(force=True)
# The plugin must have registered the lifecycle hooks core fires.
mgr = plugins_mod.get_plugin_manager()
registered = {k for k, v in getattr(mgr, "_hooks", {}).items() if v}
for hook in ("on_session_start", "post_api_request", "post_tool_call",
"on_session_finalize"):
assert hook in registered, f"core hook {hook!r} not registered by the plugin"
_fire_one_turn(plugins_mod.invoke_hook)
time.sleep(0.5) # let the background writer drain
emitter_mod.get_emitter().flush()
conn = sqlite3.connect(db)
conn.row_factory = sqlite3.Row
assert conn.execute("SELECT COUNT(*) c FROM tel_runs").fetchone()["c"] == 1
assert conn.execute("SELECT COUNT(*) c FROM tel_model_calls").fetchone()["c"] == 1
assert conn.execute("SELECT COUNT(*) c FROM tel_tool_calls").fetchone()["c"] == 1
# Real values, not buckets.
mc = conn.execute("SELECT provider, model FROM tel_model_calls").fetchone()
assert mc["provider"] == "anthropic"
assert mc["model"] == "claude-opus-4"
tc = conn.execute("SELECT tool_name FROM tel_tool_calls").fetchone()
assert tc["tool_name"] == "web_search"
run = conn.execute("SELECT end_reason, model_call_count, tool_call_count "
"FROM tel_runs").fetchone()
assert run["end_reason"] == "completed"
assert run["model_call_count"] == 1
assert run["tool_call_count"] == 1
conn.close()
def test_local_disabled_writes_nothing(runtime, monkeypatch):
"""telemetry.local=false: the plugin does not auto-load, so no rows are written."""
db, plugins_mod, emitter_mod, _plug = runtime
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"telemetry": {"local": False}},
raising=False,
)
plugins_mod.discover_plugins(force=True)
_fire_one_turn(plugins_mod.invoke_hook)
time.sleep(0.3)
try:
emitter_mod.get_emitter().flush()
except Exception:
pass
conn = sqlite3.connect(db)
assert conn.execute("SELECT COUNT(*) FROM tel_runs").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM tel_model_calls").fetchone()[0] == 0
conn.close()