use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TraceEventKind {
InvokeAgent,
Chat,
ExecuteTool,
ToolApproval,
}
impl TraceEventKind {
pub fn as_str(self) -> &'static str {
match self {
Self::InvokeAgent => "invoke_agent",
Self::Chat => "chat",
Self::ExecuteTool => "execute_tool",
Self::ToolApproval => "tool_approval",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw {
"invoke_agent" => Some(Self::InvokeAgent),
"chat" => Some(Self::Chat),
"execute_tool" => Some(Self::ExecuteTool),
"tool_approval" => Some(Self::ToolApproval),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TraceDecision {
Allowed,
Blocked,
Approved,
Edited,
PendingApproval,
Denied,
ApprovedForSession,
Failed,
}
impl TraceDecision {
pub fn as_str(self) -> &'static str {
match self {
Self::Allowed => "allowed",
Self::Blocked => "blocked",
Self::Approved => "approved",
Self::Edited => "edited",
Self::PendingApproval => "pending_approval",
Self::Denied => "denied",
Self::ApprovedForSession => "approved_for_session",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TraceEvent {
pub kind: TraceEventKind,
pub label: String,
pub workspace_id: String,
pub capability_class: Option<String>,
pub decision: Option<TraceDecision>,
pub input_tokens: Option<u32>,
pub output_tokens: Option<u32>,
pub latency_ms: Option<u64>,
}
impl TraceEvent {
const MAX_LABEL_CHARS: usize = 64;
fn new(kind: TraceEventKind, label: &str, workspace_id: &str) -> Self {
Self {
kind,
label: truncate_label(label),
workspace_id: truncate_label(workspace_id),
capability_class: None,
decision: None,
input_tokens: None,
output_tokens: None,
latency_ms: None,
}
}
pub fn with_class(mut self, class: Option<&str>) -> Self {
self.capability_class = class.map(truncate_label);
self
}
pub fn with_decision(mut self, decision: TraceDecision) -> Self {
self.decision = Some(decision);
self
}
pub fn with_tokens(mut self, input: Option<u32>, output: Option<u32>) -> Self {
self.input_tokens = input;
self.output_tokens = output;
self
}
pub fn with_latency(mut self, latency: Duration) -> Self {
self.latency_ms = Some(latency.as_millis() as u64);
self
}
pub fn to_detail(&self) -> String {
let mut parts = vec![format!("kind={}", self.kind.as_str())];
if !self.label.is_empty() {
parts.push(format!("label={}", self.label));
}
if !self.workspace_id.is_empty() {
parts.push(format!("workspace={}", self.workspace_id));
}
if let Some(class) = &self.capability_class {
parts.push(format!("class={class}"));
}
if let Some(decision) = self.decision {
parts.push(format!("decision={}", decision.as_str()));
}
if let (Some(i), Some(o)) = (self.input_tokens, self.output_tokens) {
parts.push(format!("tokens={i}/{o}"));
}
if let Some(ms) = self.latency_ms {
parts.push(format!("latency_ms={ms}"));
}
parts.join(" ")
}
pub fn invoke_agent(turn_id: &str, workspace_id: &str) -> Self {
Self::new(TraceEventKind::InvokeAgent, turn_id, workspace_id)
}
pub fn execute_tool(
tool_name: &str,
workspace_id: &str,
class: Option<&str>,
decision: TraceDecision,
) -> Self {
Self::new(TraceEventKind::ExecuteTool, tool_name, workspace_id)
.with_class(class)
.with_decision(decision)
}
pub fn chat(turn_id: &str, workspace_id: &str) -> Self {
Self::new(TraceEventKind::Chat, turn_id, workspace_id)
}
pub fn tool_approval(
tool_name: &str,
workspace_id: &str,
class: Option<&str>,
decision: TraceDecision,
) -> Self {
Self::new(TraceEventKind::ToolApproval, tool_name, workspace_id)
.with_class(class)
.with_decision(decision)
}
}
impl std::fmt::Display for TraceEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_detail())
}
}
fn truncate_label(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.chars().count() <= TraceEvent::MAX_LABEL_CHARS {
return trimmed.to_string();
}
let kept: String = trimmed.chars().take(TraceEvent::MAX_LABEL_CHARS).collect();
format!("{kept}…")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detail_carries_only_structured_fields() {
let e =
TraceEvent::tool_approval("write_file", "ws_a", Some("write"), TraceDecision::Blocked)
.with_latency(Duration::from_millis(12));
let detail = e.to_detail();
assert!(detail.contains("kind=tool_approval"));
assert!(detail.contains("label=write_file"));
assert!(detail.contains("class=write"));
assert!(detail.contains("decision=blocked"));
assert!(detail.contains("latency_ms=12"));
}
#[test]
fn label_is_truncated_loudly() {
let long = "x".repeat(500);
let e = TraceEvent::chat(&long, "ws");
assert!(e.label.chars().count() <= TraceEvent::MAX_LABEL_CHARS + 1);
assert!(e.label.ends_with('…'), "truncation must be visible");
}
#[test]
fn label_truncation_respects_char_boundaries() {
let long = "é".repeat(200);
let e = TraceEvent::chat(&long, "ws");
assert!(e.label.chars().count() <= TraceEvent::MAX_LABEL_CHARS + 1);
}
#[test]
fn short_label_is_untouched() {
let e = TraceEvent::chat("turn-7", "ws_a");
assert_eq!(e.label, "turn-7");
assert!(!e.label.contains('…'));
}
#[test]
fn tokens_are_carried_when_present() {
let e = TraceEvent::chat("t", "ws").with_tokens(Some(120), Some(45));
assert!(e.to_detail().contains("tokens=120/45"));
}
#[test]
fn absent_optionals_are_omitted_entirely() {
let detail = TraceEvent::chat("t", "ws").to_detail();
assert!(!detail.contains("class="));
assert!(!detail.contains("decision="));
assert!(!detail.contains("tokens="));
assert!(!detail.contains("latency_ms="));
}
#[test]
fn parse_round_trips_and_rejects_junk() {
for kind in [
TraceEventKind::InvokeAgent,
TraceEventKind::Chat,
TraceEventKind::ExecuteTool,
TraceEventKind::ToolApproval,
] {
assert_eq!(TraceEventKind::parse(kind.as_str()), Some(kind));
}
assert_eq!(TraceEventKind::parse("nope"), None);
}
}