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, tokens_before, tokens_after, tokens_saved)
37//! ├─ budget.warning (ratio)
38//! └─ run.end (gen_ai.usage.*, total_tokens, model_calls, tool_calls,
39//! tool_failures, compactions, tokens_saved, duration_ms)
40//! ```
41//!
42//! To export over OTLP, enable the crate's `otel` feature and call
43//! [`crate::otel::init_tracing_with_otlp`] from your binary; see that module.
44//!
45//! Wire it like any hook:
46//! ```ignore
47//! let loop_ = AgentLoop::new(model).with_hook(std::sync::Arc::new(TelemetryHook::new()));
48//! ```
49
50use harness_core::{Event, Hook, HookOutcome, World};
51use std::collections::HashMap;
52use std::sync::Mutex;
53use std::time::Instant;
54
55/// Emits a span per run and a structured event per model call, tool call,
56/// sensor, compaction, and budget warning. See the module docs for the OTLP
57/// bridge.
58pub struct TelemetryHook {
59 /// The current run's span. Events are recorded inside it so an OTLP exporter
60 /// nests them under one trace.
61 run: Mutex<Option<tracing::Span>>,
62 /// `call_id -> dispatch start`, so `tool.call` can report a duration.
63 tool_starts: Mutex<HashMap<String, Instant>>,
64 /// When the current model call was handed off, so the run summary can say
65 /// how much of the wall clock was spent waiting on the provider.
66 model_start: Mutex<Option<Instant>>,
67 /// True until the current streamed call produces its first fragment — the
68 /// latency a person actually experiences, as distinct from how long the
69 /// whole answer took.
70 awaiting_first_token: Mutex<bool>,
71 /// Running totals for the whole run, so `run.end` can answer "what did this
72 /// cost?" without the reader summing per-turn lines by hand.
73 totals: Mutex<RunTotals>,
74}
75
76/// What a run added up to, accumulated across its turns.
77#[derive(Default)]
78struct RunTotals {
79 started: Option<Instant>,
80 input_tokens: u64,
81 output_tokens: u64,
82 cached_input_tokens: u64,
83 cache_write_input_tokens: u64,
84 model_calls: u64,
85 tool_calls: u64,
86 tool_failures: u64,
87 compactions: u64,
88 tokens_saved: u64,
89 /// Wall-clock spent inside model calls, and the summed duration of tool
90 /// calls. Tools dispatched in parallel overlap, so `tool_ms` can exceed the
91 /// wall clock it occupied — it is a cost, not a span.
92 model_ms: u64,
93 tool_ms: u64,
94 /// Read-only calls that exactly repeated an earlier one — wasted rounds the
95 /// stuck-detector cannot see, because they are not consecutive.
96 repeats: u64,
97 /// Time to the first streamed fragment of the run's first model call: what
98 /// the person watching waited before anything appeared. Zero when the run
99 /// did not stream.
100 first_token_ms: u64,
101}
102
103impl TelemetryHook {
104 pub fn new() -> Self {
105 Self {
106 run: Mutex::new(None),
107 tool_starts: Mutex::new(HashMap::new()),
108 model_start: Mutex::new(None),
109 awaiting_first_token: Mutex::new(false),
110 totals: Mutex::new(RunTotals::default()),
111 }
112 }
113
114 /// Run `f` inside the current run span (if any), so its events attach to the
115 /// run's trace. Falls back to the ambient subscriber if no run is active.
116 fn in_run<F: FnOnce()>(&self, f: F) {
117 let guard = self.run.lock().unwrap();
118 match &*guard {
119 Some(span) => span.in_scope(f),
120 None => f(),
121 }
122 }
123}
124
125impl Default for TelemetryHook {
126 fn default() -> Self {
127 Self::new()
128 }
129}
130
131impl Hook for TelemetryHook {
132 fn name(&self) -> &str {
133 "telemetry"
134 }
135 fn matches(&self, _ev: &Event<'_>) -> bool {
136 true
137 }
138
139 fn fire(&self, ev: &Event<'_>, _world: &mut World) -> HookOutcome {
140 match ev {
141 Event::SessionStart { source } => {
142 let span = tracing::info_span!(
143 target: "harness.telemetry",
144 "agent_run",
145 "gen_ai.operation.name" = "invoke_agent",
146 source = format!("{source:?}")
147 );
148 span.in_scope(|| {
149 tracing::info!(target: "harness.telemetry", event = "run.start");
150 });
151 *self.run.lock().unwrap() = Some(span);
152 *self.totals.lock().unwrap() = RunTotals {
153 started: Some(Instant::now()),
154 ..Default::default()
155 };
156 }
157 Event::PreModel { .. } => {
158 *self.model_start.lock().unwrap() = Some(Instant::now());
159 *self.awaiting_first_token.lock().unwrap() = true;
160 }
161 Event::ModelTokenDelta { .. } => {
162 // Only the first fragment of each call; the rest are noise.
163 let mut awaiting = self.awaiting_first_token.lock().unwrap();
164 if !*awaiting {
165 return HookOutcome::Allow;
166 }
167 *awaiting = false;
168 drop(awaiting);
169 let ttft = self
170 .model_start
171 .lock()
172 .unwrap()
173 .map(|s| s.elapsed().as_millis() as u64)
174 .unwrap_or(0);
175 {
176 let mut t = self.totals.lock().unwrap();
177 // The first call's figure is the one a person felt; later
178 // calls are the agent thinking, not the answer starting.
179 if t.first_token_ms == 0 {
180 t.first_token_ms = ttft;
181 }
182 }
183 self.in_run(|| {
184 tracing::info!(
185 target: "harness.telemetry",
186 event = "model.first_token",
187 ttft_ms = ttft,
188 );
189 });
190 }
191 Event::Heartbeat { iter } => self.in_run(|| {
192 tracing::info!(target: "harness.telemetry", event = "iter", iter = *iter);
193 }),
194 Event::PostModel { out } => self.in_run(|| {
195 let waited = self
196 .model_start
197 .lock()
198 .unwrap()
199 .take()
200 .map(|s| s.elapsed().as_millis() as u64)
201 .unwrap_or(0);
202 {
203 let mut t = self.totals.lock().unwrap();
204 t.model_calls += 1;
205 t.model_ms += waited;
206 t.input_tokens += out.usage.input_tokens as u64;
207 t.output_tokens += out.usage.output_tokens as u64;
208 t.cached_input_tokens += out.usage.cached_input_tokens as u64;
209 t.cache_write_input_tokens += out.usage.cache_write_input_tokens as u64;
210 }
211 let stop = format!("{:?}", out.stop_reason);
212 tracing::info!(
213 target: "harness.telemetry",
214 event = "model.complete",
215 // OTel GenAI semantic conventions:
216 "gen_ai.operation.name" = "chat",
217 "gen_ai.usage.input_tokens" = out.usage.input_tokens,
218 "gen_ai.usage.output_tokens" = out.usage.output_tokens,
219 "gen_ai.usage.cached_input_tokens" = out.usage.cached_input_tokens,
220 "gen_ai.usage.cache_write_input_tokens" = out.usage.cache_write_input_tokens,
221 "gen_ai.response.finish_reasons" = %stop,
222 // pre-convention aliases:
223 input_tokens = out.usage.input_tokens,
224 output_tokens = out.usage.output_tokens,
225 cached_input_tokens = out.usage.cached_input_tokens,
226 cache_write_input_tokens = out.usage.cache_write_input_tokens,
227 tool_calls = out.tool_calls.len(),
228 stop = %stop,
229 duration_ms = waited,
230 );
231 }),
232 Event::PreToolUse { action } => {
233 self.tool_starts
234 .lock()
235 .unwrap()
236 .insert(action.call_id.clone(), Instant::now());
237 }
238 Event::PostToolUse { action, result } => {
239 let duration_ms = self
240 .tool_starts
241 .lock()
242 .unwrap()
243 .remove(&action.call_id)
244 .map(|s| s.elapsed().as_millis() as u64)
245 .unwrap_or(0);
246 let repeat = result
247 .content
248 .get("repeat_of_earlier_call")
249 .and_then(|v| v.as_bool())
250 .unwrap_or(false);
251 {
252 let mut t = self.totals.lock().unwrap();
253 t.tool_calls += 1;
254 t.tool_ms += duration_ms;
255 if repeat {
256 t.repeats += 1;
257 }
258 if !result.ok {
259 t.tool_failures += 1;
260 }
261 }
262 self.in_run(|| {
263 tracing::info!(
264 target: "harness.telemetry",
265 event = "tool.call",
266 "gen_ai.operation.name" = "execute_tool",
267 "gen_ai.tool.name" = %action.tool,
268 ok = result.ok,
269 duration_ms,
270 tool = %action.tool, // alias
271 );
272 });
273 }
274 Event::PostSensor { sensor, signals } => self.in_run(|| {
275 tracing::debug!(
276 target: "harness.telemetry",
277 event = "sensor",
278 sensor = %sensor,
279 signals = signals.len(),
280 );
281 }),
282 Event::PostCompact {
283 stage,
284 before,
285 after,
286 } => self.in_run(|| {
287 let saved = before.saturating_sub(*after);
288 {
289 let mut t = self.totals.lock().unwrap();
290 t.compactions += 1;
291 t.tokens_saved += saved as u64;
292 }
293 // At info, not debug: compaction is what keeps a long run
294 // affordable, and "it ran" without "it saved 12k" is not an
295 // observation anyone can act on.
296 tracing::info!(
297 target: "harness.telemetry",
298 event = "compact",
299 stage = format!("{stage:?}"),
300 tokens_before = *before,
301 tokens_after = *after,
302 tokens_saved = saved,
303 );
304 }),
305 Event::BudgetWarning { ratio } => self.in_run(|| {
306 tracing::warn!(
307 target: "harness.telemetry",
308 event = "budget.warning",
309 ratio = *ratio,
310 );
311 }),
312 Event::SessionEnd => {
313 let t = std::mem::take(&mut *self.totals.lock().unwrap());
314 self.in_run(|| {
315 // One line with the whole bill. Per-turn events answer "what
316 // happened"; this answers "what did it cost", which is the
317 // question asked after every run and previously required
318 // adding the turns up by hand.
319 tracing::info!(
320 target: "harness.telemetry",
321 event = "run.end",
322 "gen_ai.usage.input_tokens" = t.input_tokens,
323 "gen_ai.usage.output_tokens" = t.output_tokens,
324 "gen_ai.usage.cached_input_tokens" = t.cached_input_tokens,
325 "gen_ai.usage.cache_write_input_tokens" = t.cache_write_input_tokens,
326 total_tokens = t.input_tokens + t.output_tokens,
327 model_calls = t.model_calls,
328 tool_calls = t.tool_calls,
329 tool_failures = t.tool_failures,
330 compactions = t.compactions,
331 tokens_saved = t.tokens_saved,
332 model_ms = t.model_ms,
333 tool_ms = t.tool_ms,
334 repeat_calls = t.repeats,
335 first_token_ms = t.first_token_ms,
336 duration_ms = t.started.map(|s| s.elapsed().as_millis() as u64).unwrap_or(0),
337 );
338 });
339 *self.run.lock().unwrap() = None;
340 }
341 _ => {}
342 }
343 HookOutcome::Allow
344 }
345}