use chrono::{DateTime, Utc};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ToolCallRecord {
pub tool_use_id: String,
pub tool_name: String,
pub is_error: bool,
pub executed_at: DateTime<Utc>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StepNode {
pub iteration: u32,
pub started_at: DateTime<Utc>,
pub ended_at: DateTime<Utc>,
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub tool_calls: Vec<ToolCallRecord>,
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ExecutionGraph {
pub prompt_hash: String,
pub run_started_at: DateTime<Utc>,
pub steps: Vec<StepNode>,
pub tool_sequence: Vec<String>,
}
impl ExecutionGraph {
pub fn new(prompt_hash: String, run_started_at: DateTime<Utc>) -> Self {
Self {
prompt_hash,
run_started_at,
steps: Vec::new(),
tool_sequence: Vec::new(),
}
}
pub fn push_step(&mut self, iteration: u32, started_at: DateTime<Utc>) -> usize {
let idx = self.steps.len();
self.steps.push(StepNode {
iteration,
started_at,
ended_at: started_at,
prompt_tokens: 0,
completion_tokens: 0,
tool_calls: Vec::new(),
finish_reason: None,
});
idx
}
pub fn finalize_step(
&mut self,
step_idx: usize,
ended_at: DateTime<Utc>,
prompt_tokens: u32,
completion_tokens: u32,
finish_reason: Option<String>,
) {
if let Some(s) = self.steps.get_mut(step_idx) {
s.ended_at = ended_at;
s.prompt_tokens = prompt_tokens;
s.completion_tokens = completion_tokens;
s.finish_reason = finish_reason;
}
}
pub fn record_tool_call(&mut self, step_idx: usize, record: ToolCallRecord) {
self.tool_sequence.push(record.tool_name.clone());
if let Some(s) = self.steps.get_mut(step_idx) {
s.tool_calls.push(record);
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RunTelemetry {
pub prompt_hash: String,
pub run_started_at: DateTime<Utc>,
pub run_ended_at: DateTime<Utc>,
pub duration_ms: u64,
pub total_iterations: u32,
pub total_tool_calls: u32,
pub tool_error_count: u32,
pub tools_used: Vec<String>,
pub total_prompt_tokens: u32,
pub total_completion_tokens: u32,
pub total_cost_usd: f64,
pub success: bool,
}
impl RunTelemetry {
pub fn from_graph(
graph: &ExecutionGraph,
run_ended_at: DateTime<Utc>,
success: bool,
total_cost_usd: f64,
) -> Self {
let duration_ms = (run_ended_at - graph.run_started_at)
.num_milliseconds()
.max(0) as u64;
let total_tool_calls: u32 = graph.steps.iter().map(|s| s.tool_calls.len() as u32).sum();
let tool_error_count: u32 = graph
.steps
.iter()
.flat_map(|s| s.tool_calls.iter())
.filter(|tc| tc.is_error)
.count() as u32;
let total_prompt_tokens: u32 = graph.steps.iter().map(|s| s.prompt_tokens).sum();
let total_completion_tokens: u32 = graph.steps.iter().map(|s| s.completion_tokens).sum();
let mut seen = std::collections::HashSet::new();
let tools_used: Vec<String> = graph
.tool_sequence
.iter()
.filter(|n| seen.insert((*n).clone()))
.cloned()
.collect();
Self {
prompt_hash: graph.prompt_hash.clone(),
run_started_at: graph.run_started_at,
run_ended_at,
duration_ms,
total_iterations: graph.steps.len() as u32,
total_tool_calls,
tool_error_count,
tools_used,
total_prompt_tokens,
total_completion_tokens,
total_cost_usd,
success,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn make_graph() -> ExecutionGraph {
ExecutionGraph::new("abc123".to_string(), Utc::now())
}
#[test]
fn test_push_step_returns_index() {
let mut g = make_graph();
let idx0 = g.push_step(1, Utc::now());
let idx1 = g.push_step(2, Utc::now());
assert_eq!(idx0, 0);
assert_eq!(idx1, 1);
assert_eq!(g.steps.len(), 2);
}
#[test]
fn test_finalize_step_sets_tokens() {
let mut g = make_graph();
let idx = g.push_step(1, Utc::now());
let end = Utc::now();
g.finalize_step(idx, end, 100, 50, Some("stop".to_string()));
assert_eq!(g.steps[idx].prompt_tokens, 100);
assert_eq!(g.steps[idx].completion_tokens, 50);
assert_eq!(g.steps[idx].finish_reason, Some("stop".to_string()));
}
#[test]
fn test_record_tool_call_appends_sequence() {
let mut g = make_graph();
let idx = g.push_step(1, Utc::now());
g.record_tool_call(
idx,
ToolCallRecord {
tool_use_id: "u1".to_string(),
tool_name: "read_file".to_string(),
is_error: false,
executed_at: Utc::now(),
},
);
g.record_tool_call(
idx,
ToolCallRecord {
tool_use_id: "u2".to_string(),
tool_name: "write_file".to_string(),
is_error: false,
executed_at: Utc::now(),
},
);
assert_eq!(g.tool_sequence, vec!["read_file", "write_file"]);
assert_eq!(g.steps[idx].tool_calls.len(), 2);
}
#[test]
fn test_telemetry_from_graph() {
let start = Utc::now();
let mut g = ExecutionGraph::new("hash".to_string(), start);
let idx = g.push_step(1, start);
g.finalize_step(idx, Utc::now(), 100, 50, None);
g.record_tool_call(
idx,
ToolCallRecord {
tool_use_id: "u1".to_string(),
tool_name: "bash".to_string(),
is_error: false,
executed_at: Utc::now(),
},
);
g.record_tool_call(
idx,
ToolCallRecord {
tool_use_id: "u2".to_string(),
tool_name: "bash".to_string(),
is_error: true,
executed_at: Utc::now(),
},
);
let telem = RunTelemetry::from_graph(&g, Utc::now(), true, 0.01);
assert_eq!(telem.total_iterations, 1);
assert_eq!(telem.total_tool_calls, 2);
assert_eq!(telem.tool_error_count, 1);
assert_eq!(telem.tools_used, vec!["bash"]);
assert_eq!(telem.total_prompt_tokens, 100);
assert_eq!(telem.total_completion_tokens, 50);
assert!(telem.success);
}
#[test]
fn test_tool_sequence_preserves_order() {
let mut g = make_graph();
let idx = g.push_step(1, Utc::now());
for name in &["a", "b", "c", "b", "a"] {
g.record_tool_call(
idx,
ToolCallRecord {
tool_use_id: "x".to_string(),
tool_name: name.to_string(),
is_error: false,
executed_at: Utc::now(),
},
);
}
assert_eq!(g.tool_sequence, vec!["a", "b", "c", "b", "a"]);
}
}