Skip to main content

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//! The spans come from [`AgentLoop`](crate::AgentLoop) itself — it knows when
22//! a call really starts and ends, which a hook does not (parallel read-only
23//! tools are dispatched before their `PreToolUse` fires). This hook adds the
24//! events and the run totals. Span/event shape (target `harness.telemetry`):
25//!
26//! ```text
27//! agent_run (span, from the loop: gen_ai.operation.name=invoke_agent,
28//! │          gen_ai.request.model, gen_ai.provider.name, outcome)
29//!   ├─ run.start         (source)
30//!   ├─ iter              (iter)
31//!   ├─ chat              (span, from the loop, one per model call:
32//!   │   │                 otel.name="chat {model}", gen_ai.usage.*,
33//!   │   │                 gen_ai.response.finish_reasons, tool_calls)
34//!   │   └─ model.first_token (ttft_ms)
35//!   ├─ model.complete    (gen_ai.operation.name=chat,
36//!   │                     gen_ai.usage.input_tokens, gen_ai.usage.output_tokens,
37//!   │                     gen_ai.usage.cached_input_tokens,
38//!   │                     gen_ai.response.finish_reasons
39//!   │                     + aliases: input_tokens, output_tokens,
40//!   │                       cached_input_tokens, tool_calls, stop)
41//!   ├─ execute_tool      (span, from the loop, one per tool call:
42//!   │                     otel.name="execute_tool {tool}", gen_ai.tool.name,
43//!   │                     gen_ai.tool.call.id, ok, otel.status_code=error on failure)
44//!   ├─ tool.call         (gen_ai.operation.name=execute_tool, gen_ai.tool.name,
45//!   │                     ok, duration_ms + alias: tool)
46//!   ├─ sensor            (sensor, signals)
47//!   ├─ compact           (stage, tokens_before, tokens_after, tokens_saved)
48//!   ├─ budget.warning    (ratio)
49//!   ├─ run.cancelled     (warn: the caller stopped the run)
50//!   └─ run.end           (gen_ai.usage.*, total_tokens, model_calls, tool_calls,
51//!                         tool_failures, compactions, tokens_saved, duration_ms)
52//! ```
53//!
54//! To export over OTLP, enable the crate's `otel` feature and call
55//! [`crate::otel::init_tracing_with_otlp`] from your binary; see that module.
56//!
57//! Wire it like any hook:
58//! ```ignore
59//! let loop_ = AgentLoop::new(model).with_hook(std::sync::Arc::new(TelemetryHook::new()));
60//! ```
61
62use harness_core::{Event, Hook, HookOutcome, World};
63use std::collections::HashMap;
64use std::sync::Mutex;
65use std::time::Instant;
66
67/// Emits a span per run and a structured event per model call, tool call,
68/// sensor, compaction, and budget warning. See the module docs for the OTLP
69/// bridge.
70pub struct TelemetryHook {
71    /// `call_id -> PreToolUse time`: which calls are in flight, for a cancel
72    /// to settle, and a duration fallback for a loop that sends no `ToolTimed`.
73    tool_starts: Mutex<HashMap<String, Instant>>,
74    /// `call_id -> how long it really ran`, from `ToolTimed`. Parallel calls
75    /// finish before their `PreToolUse` fires, so the fallback reads ~0 for them.
76    tool_ran_ms: Mutex<HashMap<String, u64>>,
77    /// When the current model call was handed off, so the run summary can say
78    /// how much of the wall clock was spent waiting on the provider.
79    model_start: Mutex<Option<Instant>>,
80    /// True until the current streamed call produces its first fragment — the
81    /// latency a person actually experiences, as distinct from how long the
82    /// whole answer took.
83    awaiting_first_token: Mutex<bool>,
84    /// Running totals for the whole run, so `run.end` can answer "what did this
85    /// cost?" without the reader summing per-turn lines by hand.
86    totals: Mutex<RunTotals>,
87}
88
89/// What a run added up to, accumulated across its turns.
90#[derive(Default)]
91struct RunTotals {
92    started: Option<Instant>,
93    input_tokens: u64,
94    output_tokens: u64,
95    cached_input_tokens: u64,
96    cache_write_input_tokens: u64,
97    /// `model_calls` counts calls that *completed* — a cancelled one has no
98    /// usage to attribute, so it adds only its wait to `model_ms`. `tool_calls`
99    /// counts *dispatches*, including one cut short by a cancel, so it
100    /// reconciles with `Outcome::Cancelled.tools_called`, which is incremented
101    /// before the cancel check.
102    model_calls: u64,
103    tool_calls: u64,
104    tool_failures: u64,
105    compactions: u64,
106    tokens_saved: u64,
107    /// Wall-clock spent inside model calls, and the summed duration of tool
108    /// calls. Tools dispatched in parallel overlap, so `tool_ms` can exceed the
109    /// wall clock it occupied — it is a cost, not a span.
110    model_ms: u64,
111    tool_ms: u64,
112    /// Read-only calls that exactly repeated an earlier one — wasted rounds the
113    /// stuck-detector cannot see, because they are not consecutive.
114    repeats: u64,
115    /// Time to the first streamed fragment of the run's first model call: what
116    /// the person watching waited before anything appeared. Zero when the run
117    /// did not stream.
118    first_token_ms: u64,
119}
120
121impl TelemetryHook {
122    pub fn new() -> Self {
123        Self {
124            tool_starts: Mutex::new(HashMap::new()),
125            tool_ran_ms: Mutex::new(HashMap::new()),
126            model_start: Mutex::new(None),
127            awaiting_first_token: Mutex::new(false),
128            totals: Mutex::new(RunTotals::default()),
129        }
130    }
131}
132
133impl Default for TelemetryHook {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl Hook for TelemetryHook {
140    fn name(&self) -> &str {
141        "telemetry"
142    }
143    fn matches(&self, _ev: &Event<'_>) -> bool {
144        true
145    }
146
147    fn fire(&self, ev: &Event<'_>, _world: &mut World) -> HookOutcome {
148        match ev {
149            Event::SessionStart { source } => {
150                tracing::info!(
151                    target: "harness.telemetry",
152                    event = "run.start",
153                    source = format!("{source:?}"),
154                );
155                *self.totals.lock().unwrap() = RunTotals {
156                    started: Some(Instant::now()),
157                    ..Default::default()
158                };
159                // Per-call state, too. A tool call a hook denied fired
160                // PreToolUse and never PostToolUse, so its entry would sit here
161                // for the hook's lifetime and a later cancel would count it as
162                // a dispatch of *that* run. Every run starts from a clean slate.
163                self.tool_starts.lock().unwrap().clear();
164                self.tool_ran_ms.lock().unwrap().clear();
165                *self.model_start.lock().unwrap() = None;
166                *self.awaiting_first_token.lock().unwrap() = false;
167            }
168            Event::PreModel { .. } => {
169                *self.model_start.lock().unwrap() = Some(Instant::now());
170                *self.awaiting_first_token.lock().unwrap() = true;
171            }
172            Event::ModelTokenDelta { .. } => {
173                // Only the first fragment of each call; the rest are noise.
174                let mut awaiting = self.awaiting_first_token.lock().unwrap();
175                if !*awaiting {
176                    return HookOutcome::Allow;
177                }
178                *awaiting = false;
179                drop(awaiting);
180                let ttft = self
181                    .model_start
182                    .lock()
183                    .unwrap()
184                    .map(|s| s.elapsed().as_millis() as u64)
185                    .unwrap_or(0);
186                {
187                    let mut t = self.totals.lock().unwrap();
188                    // The first call's figure is the one a person felt; later
189                    // calls are the agent thinking, not the answer starting.
190                    if t.first_token_ms == 0 {
191                        t.first_token_ms = ttft;
192                    }
193                }
194                {
195                    tracing::info!(
196                        target: "harness.telemetry",
197                        event = "model.first_token",
198                        ttft_ms = ttft,
199                    );
200                };
201            }
202            Event::Heartbeat { iter } => {
203                tracing::info!(target: "harness.telemetry", event = "iter", iter = *iter);
204            }
205            Event::PostModel { out } => {
206                let waited = self
207                    .model_start
208                    .lock()
209                    .unwrap()
210                    .take()
211                    .map(|s| s.elapsed().as_millis() as u64)
212                    .unwrap_or(0);
213                {
214                    let mut t = self.totals.lock().unwrap();
215                    t.model_calls += 1;
216                    t.model_ms += waited;
217                    t.input_tokens += out.usage.input_tokens as u64;
218                    t.output_tokens += out.usage.output_tokens as u64;
219                    t.cached_input_tokens += out.usage.cached_input_tokens as u64;
220                    t.cache_write_input_tokens += out.usage.cache_write_input_tokens as u64;
221                }
222                let stop = format!("{:?}", out.stop_reason);
223                tracing::info!(
224                    target: "harness.telemetry",
225                    event = "model.complete",
226                    // OTel GenAI semantic conventions:
227                    "gen_ai.operation.name" = "chat",
228                    "gen_ai.usage.input_tokens" = out.usage.input_tokens,
229                    "gen_ai.usage.output_tokens" = out.usage.output_tokens,
230                    "gen_ai.usage.cached_input_tokens" = out.usage.cached_input_tokens,
231                    "gen_ai.usage.cache_write_input_tokens" = out.usage.cache_write_input_tokens,
232                    "gen_ai.response.finish_reasons" = %stop,
233                    // pre-convention aliases:
234                    input_tokens = out.usage.input_tokens,
235                    output_tokens = out.usage.output_tokens,
236                    cached_input_tokens = out.usage.cached_input_tokens,
237                    cache_write_input_tokens = out.usage.cache_write_input_tokens,
238                    tool_calls = out.tool_calls.len(),
239                    stop = %stop,
240                    duration_ms = waited,
241                );
242            }
243            Event::PreToolUse { action } => {
244                self.tool_starts
245                    .lock()
246                    .unwrap()
247                    .insert(action.call_id.clone(), Instant::now());
248            }
249            Event::ToolTimed {
250                action,
251                started_ms,
252                finished_ms,
253            } => {
254                self.tool_ran_ms.lock().unwrap().insert(
255                    action.call_id.clone(),
256                    finished_ms.saturating_sub(*started_ms).max(0) as u64,
257                );
258            }
259            Event::PostToolUse { action, result } => {
260                let since_pre = self
261                    .tool_starts
262                    .lock()
263                    .unwrap()
264                    .remove(&action.call_id)
265                    .map(|s| s.elapsed().as_millis() as u64);
266                let duration_ms = self
267                    .tool_ran_ms
268                    .lock()
269                    .unwrap()
270                    .remove(&action.call_id)
271                    .or(since_pre)
272                    .unwrap_or(0);
273                let repeat = result
274                    .content
275                    .get("repeat_of_earlier_call")
276                    .and_then(|v| v.as_bool())
277                    .unwrap_or(false);
278                {
279                    let mut t = self.totals.lock().unwrap();
280                    t.tool_calls += 1;
281                    t.tool_ms += duration_ms;
282                    if repeat {
283                        t.repeats += 1;
284                    }
285                    if !result.ok {
286                        t.tool_failures += 1;
287                    }
288                }
289                {
290                    tracing::info!(
291                        target: "harness.telemetry",
292                        event = "tool.call",
293                        "gen_ai.operation.name" = "execute_tool",
294                        "gen_ai.tool.name" = %action.tool,
295                        ok = result.ok,
296                        duration_ms,
297                        tool = %action.tool, // alias
298                    );
299                };
300            }
301            Event::PostSensor { sensor, signals } => {
302                tracing::debug!(
303                    target: "harness.telemetry",
304                    event = "sensor",
305                    sensor = %sensor,
306                    signals = signals.len(),
307                );
308            }
309            Event::PostCompact {
310                stage,
311                before,
312                after,
313            } => {
314                let saved = before.saturating_sub(*after);
315                {
316                    let mut t = self.totals.lock().unwrap();
317                    t.compactions += 1;
318                    t.tokens_saved += saved as u64;
319                }
320                // At info, not debug: compaction is what keeps a long run
321                // affordable, and "it ran" without "it saved 12k" is not an
322                // observation anyone can act on.
323                tracing::info!(
324                    target: "harness.telemetry",
325                    event = "compact",
326                    stage = format!("{stage:?}"),
327                    tokens_before = *before,
328                    tokens_after = *after,
329                    tokens_saved = saved,
330                );
331            }
332            Event::BudgetWarning { ratio } => {
333                tracing::warn!(
334                    target: "harness.telemetry",
335                    event = "budget.warning",
336                    ratio = *ratio,
337                );
338            }
339            Event::Cancelled => {
340                // A cancelled dispatch fired PreToolUse but will never fire
341                // PostToolUse: the loop returns before it. Settle its entry
342                // here, for two reasons. `run.end` must agree with
343                // `Outcome::Cancelled.tools_called`, which counts that dispatch;
344                // and `tool_starts` must not grow by one per cancel across a
345                // long `Session` that reuses this hook.
346                let dangling: Vec<Instant> = self
347                    .tool_starts
348                    .lock()
349                    .unwrap()
350                    .drain()
351                    .map(|(_, started)| started)
352                    .collect();
353                // The same for a model call cut off between PreModel and
354                // PostModel: the wait was real and belongs in `model_ms`, so
355                // `duration_ms` does not exceed `model_ms + tool_ms` by an
356                // unexplained gap; the call itself never completed, so
357                // `model_calls` is left alone. Clearing the two fields is
358                // hygiene — the next PreModel would overwrite them anyway.
359                let cut_off_model = self.model_start.lock().unwrap().take();
360                *self.awaiting_first_token.lock().unwrap() = false;
361                {
362                    let mut t = self.totals.lock().unwrap();
363                    for started in dangling {
364                        t.tool_calls += 1;
365                        t.tool_ms += started.elapsed().as_millis() as u64;
366                    }
367                    if let Some(started) = cut_off_model {
368                        t.model_ms += started.elapsed().as_millis() as u64;
369                    }
370                }
371                {
372                    // Warn, not info: a cancel is the person deciding the run was
373                    // not worth finishing, which is worth seeing in a trace that
374                    // would otherwise look like any other run.end.
375                    tracing::warn!(target: "harness.telemetry", event = "run.cancelled");
376                };
377            }
378            Event::SessionEnd => {
379                let t = std::mem::take(&mut *self.totals.lock().unwrap());
380                {
381                    // One line with the whole bill. Per-turn events answer "what
382                    // happened"; this answers "what did it cost", which is the
383                    // question asked after every run and previously required
384                    // adding the turns up by hand.
385                    tracing::info!(
386                        target: "harness.telemetry",
387                        event = "run.end",
388                        "gen_ai.usage.input_tokens" = t.input_tokens,
389                        "gen_ai.usage.output_tokens" = t.output_tokens,
390                        "gen_ai.usage.cached_input_tokens" = t.cached_input_tokens,
391                        "gen_ai.usage.cache_write_input_tokens" = t.cache_write_input_tokens,
392                        total_tokens = t.input_tokens + t.output_tokens,
393                        model_calls = t.model_calls,
394                        tool_calls = t.tool_calls,
395                        tool_failures = t.tool_failures,
396                        compactions = t.compactions,
397                        tokens_saved = t.tokens_saved,
398                        model_ms = t.model_ms,
399                        tool_ms = t.tool_ms,
400                        repeat_calls = t.repeats,
401                        first_token_ms = t.first_token_ms,
402                        duration_ms = t.started.map(|s| s.elapsed().as_millis() as u64).unwrap_or(0),
403                    );
404                };
405            }
406            _ => {}
407        }
408        HookOutcome::Allow
409    }
410}