hermes-agent/agent/trace_export.py
Brooklyn Nicholson 74ab798b49 feat(trace): derive OTel-style execution traces from the session store
Reconstruct per-session and per-turn span trees (AGENT/LLM/TOOL, with
subagents nested under their delegate_task span) straight from SQLite — no
new write path. Turn-scoping splits on real user prompts and folds
synthetic continuations ([ASYNC DELEGATION ...], [IMPORTANT: ...]) into the
turn that spawned them. Exports to OTLP/JSON and Chrome Trace formats.
2026-06-26 04:36:26 -05:00

170 lines
5.6 KiB
Python

"""Render a :class:`~agent.trace_builder.Trace` into portable file formats.
Two formats, both hand-built (no OTel SDK dependency):
* **OTLP/JSON** with OpenInference semantic conventions — the industry standard
for LLM/agent traces. Ingestible by Arize Phoenix and any OpenTelemetry
backend, so we can confirm our spans are correct in a real viewer.
* **Chrome Trace Event format** — each session becomes its own track, viewable
by dropping the file into https://ui.perfetto.dev or ``chrome://tracing``.
Keeping these as plain dict/JSON builders means the trace layer has zero new
third-party dependencies.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any, Dict, List
from agent.trace_builder import STATUS_ERROR, STATUS_OK, Trace
_SERVICE_NAME = "hermes-agent"
_SCOPE_NAME = "hermes.tracing"
def _hex_id(value: str, nbytes: int) -> str:
"""Deterministic hex id of ``nbytes`` bytes from an arbitrary string."""
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()
return digest[: nbytes * 2]
def _otlp_any_value(value: Any) -> Dict[str, Any]:
if isinstance(value, bool):
return {"boolValue": value}
if isinstance(value, int):
return {"intValue": str(value)}
if isinstance(value, float):
return {"doubleValue": value}
return {"stringValue": str(value)}
def _otlp_attributes(attrs: Dict[str, Any]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for key, value in attrs.items():
if value is None:
continue
out.append({"key": key, "value": _otlp_any_value(value)})
return out
def _otlp_status(status: str) -> Dict[str, Any]:
if status == STATUS_OK:
return {"code": 1}
if status == STATUS_ERROR:
return {"code": 2}
return {"code": 0}
def _to_nanos(seconds: float) -> str:
return str(int(seconds * 1_000_000_000))
def to_otlp_json(trace: Trace) -> Dict[str, Any]:
"""Build an OTLP/JSON ``TracesData`` document with OpenInference attributes."""
trace_hex = _hex_id(trace.trace_id, 16)
otlp_spans: List[Dict[str, Any]] = []
for span in trace.spans:
attributes = dict(span.attributes)
# OpenInference: the GenAI span kind travels as an attribute; the OTLP
# SpanKind stays INTERNAL (1).
attributes["openinference.span.kind"] = span.kind
if span.session_id:
attributes.setdefault("session.id", span.session_id)
otlp_span: Dict[str, Any] = {
"traceId": trace_hex,
"spanId": _hex_id(span.span_id, 8),
"name": span.name,
"kind": 1,
"startTimeUnixNano": _to_nanos(span.start),
"endTimeUnixNano": _to_nanos(span.end),
"attributes": _otlp_attributes(attributes),
"status": _otlp_status(span.status),
}
if span.parent_id:
otlp_span["parentSpanId"] = _hex_id(span.parent_id, 8)
otlp_spans.append(otlp_span)
return {
"resourceSpans": [
{
"resource": {
"attributes": _otlp_attributes(
{
"service.name": _SERVICE_NAME,
"session.id": trace.root_session_id,
"hermes.source": trace.metadata.get("source"),
}
)
},
"scopeSpans": [
{
"scope": {"name": _SCOPE_NAME},
"spans": otlp_spans,
}
],
}
]
}
def to_chrome_trace(trace: Trace) -> Dict[str, Any]:
"""Build a Chrome Trace Event document (one track per session)."""
base = trace.start
events: List[Dict[str, Any]] = []
# Stable, compact track ids per session — each session is its own lane.
tids: Dict[str, int] = {}
def tid_for(session_id: str) -> int:
if session_id not in tids:
tids[session_id] = len(tids) + 1
return tids[session_id]
for span in trace.spans:
sid = span.session_id or trace.root_session_id
events.append(
{
"name": span.name,
"cat": span.kind,
"ph": "X",
"ts": (span.start - base) * 1_000_000,
"dur": max(0.0, span.duration) * 1_000_000,
"pid": 1,
"tid": tid_for(sid),
"args": {k: v for k, v in span.attributes.items() if v is not None},
}
)
# Name each track after its session (root first) for legible lanes.
for session_id, tid in tids.items():
label = "root" if session_id == trace.root_session_id else f"subagent {session_id[:8]}"
events.append(
{
"name": "thread_name",
"ph": "M",
"pid": 1,
"tid": tid,
"args": {"name": label},
}
)
return {"traceEvents": events, "displayTimeUnit": "ms"}
def dumps(trace: Trace, fmt: str = "otlp", *, indent: int = 2) -> str:
"""Serialize ``trace`` to a JSON string in the requested format."""
fmt = (fmt or "otlp").lower()
if fmt in {"otlp", "otlp-json", "openinference"}:
doc = to_otlp_json(trace)
elif fmt in {"chrome", "perfetto", "trace-event"}:
doc = to_chrome_trace(trace)
else:
raise ValueError(f"unknown trace format: {fmt!r} (use 'otlp' or 'chrome')")
return json.dumps(doc, ensure_ascii=False, indent=indent, default=str)
__all__ = ["dumps", "to_chrome_trace", "to_otlp_json"]