mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Addresses the review on #51714: the trace/span layer was declared but unwired — tel_spans was never written, call rows had no timestamp, and nothing set parent lineage, so the store was metrics-only and couldn't reconstruct a trace. Wire the span layer (keeping the praised star-schema shape): - New SpanEvent (span_id/trace_id/run_id/parent_span_id/name/kind/start_ns/end_ns) mapped into tel_spans via the emitter's _TABLE_COLUMNS. - The plugin mints a root span per run and, on each model/tool call, emits a SpanEvent (timing + parent = the run's root) keyed by the SAME span_id as the detail row, so tel_model_calls / tel_tool_calls JOIN to their span. - Call hooks fire on completion, so end_ns = now and start_ns is reconstructed from the measured latency/duration. The run's root span is emitted at finalize with the true run start/end. Result: tel_spans is a connected, single-trace_id, run -> calls tree a desktop waterfall (or any reader) can render directly, ordered by start_ns. Existing metrics rows (tel_runs/model_calls/tool_calls) are unchanged. OTLP: spans now flow to the exporter with their trace/parent/timing attributes. The exporter still emits one OTel span per event rather than reconstructing OTel SpanContexts into a connected trace tree; that projection is left for a follow-up and the module docstring now says so plainly instead of over-claiming. Adds test_spans_trace.py (connected-tree + detail-row JOIN) over the real dispatch path. Accurate (pre-hook) start times, real OTLP SpanContexts, and subagent cross-run lineage remain follow-ups.
109 lines
4.1 KiB
Python
109 lines
4.1 KiB
Python
"""Trace/span layer: tel_spans is populated as a connected run -> calls tree.
|
|
|
|
Drives the real dispatch chain (discover_plugins -> invoke_hook) and asserts the
|
|
timing/lineage backbone in tel_spans:
|
|
- one root span per run (kind="run", parent_span_id NULL),
|
|
- one child span per model/tool call parented to the root,
|
|
- a single trace_id across the run,
|
|
- call detail rows (tel_model_calls / tel_tool_calls) JOIN to their span by span_id,
|
|
- reconstructed durations match the reported latency/duration.
|
|
|
|
This is the regression guard for the waterfall a desktop trace viewer renders.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import time
|
|
|
|
import pytest
|
|
|
|
import hermes_state
|
|
|
|
|
|
@pytest.fixture
|
|
def runtime(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
db = tmp_path / "state.db"
|
|
hermes_state.SessionDB(db_path=db)
|
|
import hermes_cli.plugins as plugins_mod
|
|
monkeypatch.setattr(plugins_mod, "_plugin_manager", None, raising=False)
|
|
from agent.telemetry import emitter as emitter_mod
|
|
emitter_mod.reset_emitter_for_tests(None)
|
|
import plugins.telemetry as plug
|
|
plug._runs.clear()
|
|
yield db, plugins_mod, emitter_mod
|
|
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 _one_turn(invoke_hook):
|
|
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", model="claude-opus-4", api_duration=0.9,
|
|
usage={"input_tokens": 1000, "output_tokens": 120})
|
|
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",
|
|
turn_exit_reason="completed", estimated_cost_usd=0.01, cost_status="known")
|
|
|
|
|
|
def test_tel_spans_forms_connected_trace(runtime):
|
|
db, plugins_mod, emitter_mod = runtime
|
|
plugins_mod.discover_plugins(force=True)
|
|
_one_turn(plugins_mod.invoke_hook)
|
|
time.sleep(0.5)
|
|
emitter_mod.get_emitter().flush()
|
|
|
|
conn = sqlite3.connect(db)
|
|
conn.row_factory = sqlite3.Row
|
|
spans = conn.execute(
|
|
"SELECT span_id, parent_span_id, kind, name, start_ns, end_ns, status, trace_id "
|
|
"FROM tel_spans"
|
|
).fetchall()
|
|
|
|
# root + model + tool
|
|
assert len(spans) == 3
|
|
roots = [s for s in spans if s["parent_span_id"] is None]
|
|
children = [s for s in spans if s["parent_span_id"] is not None]
|
|
assert len(roots) == 1
|
|
assert roots[0]["kind"] == "run"
|
|
assert len(children) == 2
|
|
|
|
# single trace, all children parented to the root
|
|
assert len({s["trace_id"] for s in spans}) == 1
|
|
assert all(c["parent_span_id"] == roots[0]["span_id"] for c in children)
|
|
|
|
# spans are time-ordered and carry real durations
|
|
by_kind = {s["kind"]: s for s in spans}
|
|
assert (by_kind["model"]["end_ns"] - by_kind["model"]["start_ns"]) == 900 * 1_000_000
|
|
assert (by_kind["tool"]["end_ns"] - by_kind["tool"]["start_ns"]) == 210 * 1_000_000
|
|
assert by_kind["run"]["end_ns"] >= by_kind["run"]["start_ns"]
|
|
|
|
|
|
def test_detail_rows_join_to_spans(runtime):
|
|
db, plugins_mod, emitter_mod = runtime
|
|
plugins_mod.discover_plugins(force=True)
|
|
_one_turn(plugins_mod.invoke_hook)
|
|
time.sleep(0.5)
|
|
emitter_mod.get_emitter().flush()
|
|
|
|
conn = sqlite3.connect(db)
|
|
conn.row_factory = sqlite3.Row
|
|
mc = conn.execute(
|
|
"SELECT m.model, s.kind, s.trace_id FROM tel_model_calls m "
|
|
"JOIN tel_spans s ON m.span_id = s.span_id"
|
|
).fetchone()
|
|
assert mc is not None and mc["model"] == "claude-opus-4" and mc["kind"] == "model"
|
|
|
|
tc = conn.execute(
|
|
"SELECT t.tool_name, s.kind FROM tel_tool_calls t "
|
|
"JOIN tel_spans s ON t.span_id = s.span_id"
|
|
).fetchone()
|
|
assert tc is not None and tc["tool_name"] == "web_search" and tc["kind"] == "tool"
|
|
conn.close()
|