harness_loop/telemetry.rs
1//! `TelemetryHook` — maps the agent's lifecycle [`Event`] stream onto
2//! structured `tracing` spans and events, so a run becomes observable in any
3//! `tracing` subscriber.
4//!
5//! Why `tracing` and not a hard OpenTelemetry dependency? Because `tracing` is
6//! the idiomatic Rust instrumentation seam: the library emits spans + events,
7//! and the *binary* chooses the exporter. Attach
8//! [`tracing-opentelemetry`](https://docs.rs/tracing-opentelemetry) with an
9//! OTLP pipeline and every span below is exported to Jaeger / Tempo / any OTLP
10//! backend with **zero changes here**; attach `tracing_subscriber::fmt().json()`
11//! and you get newline-delimited JSON for log pipelines. One instrumentation,
12//! many backends.
13//!
14//! Field names follow the OpenTelemetry **GenAI semantic conventions**
15//! (`gen_ai.*`), so any OTel-aware backend — Logfire, SigNoz, Langfuse via OTLP,
16//! Grafana — recognizes token counts, model, and finish reason automatically and
17//! computes cost/latency with zero mapping. The pre-convention flat names
18//! (`input_tokens`, `tool`, …) are kept alongside as aliases for existing
19//! consumers.
20//!
21//! Span/event shape (target `harness.telemetry`):
22//!
23//! ```text
24//! agent_run (span, fields: source, gen_ai.operation.name=invoke_agent)
25//! ├─ run.start
26//! ├─ iter (iter)
27//! ├─ model.complete (gen_ai.operation.name=chat,
28//! │ gen_ai.usage.input_tokens, gen_ai.usage.output_tokens,
29//! │ gen_ai.usage.cached_input_tokens,
30//! │ gen_ai.response.finish_reasons
31//! │ + aliases: input_tokens, output_tokens,
32//! │ cached_input_tokens, tool_calls, stop)
33//! ├─ tool.call (gen_ai.operation.name=execute_tool, gen_ai.tool.name,
34//! │ ok, duration_ms + alias: tool)
35//! ├─ sensor (sensor, signals)
36//! ├─ compact (stage)
37//! ├─ budget.warning (ratio)
38//! └─ run.end
39//! ```
40//!
41//! To export over OTLP, enable the crate's `otel` feature and call
42//! [`crate::otel::init_tracing_with_otlp`] from your binary; see that module.
43//!
44//! Wire it like any hook:
45//! ```ignore
46//! let loop_ = AgentLoop::new(model).with_hook(std::sync::Arc::new(TelemetryHook::new()));
47//! ```
48
49use harness_core::{Event, Hook, HookOutcome, World};
50use std::collections::HashMap;
51use std::sync::Mutex;
52use std::time::Instant;
53
54/// Emits a span per run and a structured event per model call, tool call,
55/// sensor, compaction, and budget warning. See the module docs for the OTLP
56/// bridge.
57pub struct TelemetryHook {
58 /// The current run's span. Events are recorded inside it so an OTLP exporter
59 /// nests them under one trace.
60 run: Mutex<Option<tracing::Span>>,
61 /// `call_id -> dispatch start`, so `tool.call` can report a duration.
62 tool_starts: Mutex<HashMap<String, Instant>>,
63}
64
65impl TelemetryHook {
66 pub fn new() -> Self {
67 Self {
68 run: Mutex::new(None),
69 tool_starts: Mutex::new(HashMap::new()),
70 }
71 }
72
73 /// Run `f` inside the current run span (if any), so its events attach to the
74 /// run's trace. Falls back to the ambient subscriber if no run is active.
75 fn in_run<F: FnOnce()>(&self, f: F) {
76 let guard = self.run.lock().unwrap();
77 match &*guard {
78 Some(span) => span.in_scope(f),
79 None => f(),
80 }
81 }
82}
83
84impl Default for TelemetryHook {
85 fn default() -> Self {
86 Self::new()
87 }
88}
89
90impl Hook for TelemetryHook {
91 fn name(&self) -> &str {
92 "telemetry"
93 }
94 fn matches(&self, _ev: &Event<'_>) -> bool {
95 true
96 }
97
98 fn fire(&self, ev: &Event<'_>, _world: &mut World) -> HookOutcome {
99 match ev {
100 Event::SessionStart { source } => {
101 let span = tracing::info_span!(
102 target: "harness.telemetry",
103 "agent_run",
104 "gen_ai.operation.name" = "invoke_agent",
105 source = format!("{source:?}")
106 );
107 span.in_scope(|| {
108 tracing::info!(target: "harness.telemetry", event = "run.start");
109 });
110 *self.run.lock().unwrap() = Some(span);
111 }
112 Event::Heartbeat { iter } => self.in_run(|| {
113 tracing::info!(target: "harness.telemetry", event = "iter", iter = *iter);
114 }),
115 Event::PostModel { out } => self.in_run(|| {
116 let stop = format!("{:?}", out.stop_reason);
117 tracing::info!(
118 target: "harness.telemetry",
119 event = "model.complete",
120 // OTel GenAI semantic conventions:
121 "gen_ai.operation.name" = "chat",
122 "gen_ai.usage.input_tokens" = out.usage.input_tokens,
123 "gen_ai.usage.output_tokens" = out.usage.output_tokens,
124 "gen_ai.usage.cached_input_tokens" = out.usage.cached_input_tokens,
125 "gen_ai.response.finish_reasons" = %stop,
126 // pre-convention aliases:
127 input_tokens = out.usage.input_tokens,
128 output_tokens = out.usage.output_tokens,
129 cached_input_tokens = out.usage.cached_input_tokens,
130 tool_calls = out.tool_calls.len(),
131 stop = %stop,
132 );
133 }),
134 Event::PreToolUse { action } => {
135 self.tool_starts
136 .lock()
137 .unwrap()
138 .insert(action.call_id.clone(), Instant::now());
139 }
140 Event::PostToolUse { action, result } => {
141 let duration_ms = self
142 .tool_starts
143 .lock()
144 .unwrap()
145 .remove(&action.call_id)
146 .map(|s| s.elapsed().as_millis() as u64)
147 .unwrap_or(0);
148 self.in_run(|| {
149 tracing::info!(
150 target: "harness.telemetry",
151 event = "tool.call",
152 "gen_ai.operation.name" = "execute_tool",
153 "gen_ai.tool.name" = %action.tool,
154 ok = result.ok,
155 duration_ms,
156 tool = %action.tool, // alias
157 );
158 });
159 }
160 Event::PostSensor { sensor, signals } => self.in_run(|| {
161 tracing::debug!(
162 target: "harness.telemetry",
163 event = "sensor",
164 sensor = %sensor,
165 signals = signals.len(),
166 );
167 }),
168 Event::PostCompact { stage } => self.in_run(|| {
169 tracing::debug!(
170 target: "harness.telemetry",
171 event = "compact",
172 stage = format!("{stage:?}"),
173 );
174 }),
175 Event::BudgetWarning { ratio } => self.in_run(|| {
176 tracing::warn!(
177 target: "harness.telemetry",
178 event = "budget.warning",
179 ratio = *ratio,
180 );
181 }),
182 Event::SessionEnd => {
183 self.in_run(|| {
184 tracing::info!(target: "harness.telemetry", event = "run.end");
185 });
186 *self.run.lock().unwrap() = None;
187 }
188 _ => {}
189 }
190 HookOutcome::Allow
191 }
192}