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