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