use serde_json::Value;
use super::LLMEvent;
pub fn events_from_span(span: &Value) -> Vec<LLMEvent> {
let mut out = Vec::new();
let attrs = span.get("attributes").unwrap_or(&Value::Null);
let span_events = span
.get("events")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if let Some(content) = first_event_content(&span_events, "gen_ai.user.message") {
out.push(LLMEvent::TurnStart {
user_message: content,
});
}
if let Some(content) = first_event_content(&span_events, "gen_ai.assistant.message") {
out.push(LLMEvent::TurnComplete {
full_response: content,
});
}
let tokens_in = attrs
.get("gen_ai.usage.input_tokens")
.and_then(Value::as_u64)
.map(|n| n as u32);
let tokens_out = attrs
.get("gen_ai.usage.output_tokens")
.and_then(Value::as_u64)
.map(|n| n as u32);
if tokens_in.is_some() || tokens_out.is_some() {
let wallclock_ms = span_duration_ms(span).unwrap_or(0);
let provider = attrs
.get("gen_ai.system")
.and_then(Value::as_str)
.map(String::from);
out.push(LLMEvent::Cost {
tokens_in: tokens_in.unwrap_or(0),
tokens_out: tokens_out.unwrap_or(0),
wallclock_ms,
provider,
});
}
for tool_event in span_events.iter().filter(|e| {
e.get("name").and_then(Value::as_str) == Some("gen_ai.tool.message")
}) {
let tool_attrs = tool_event.get("attributes").unwrap_or(&Value::Null);
let tool_name = tool_attrs
.get("gen_ai.tool.name")
.and_then(Value::as_str)
.map(String::from);
if let Some(name) = tool_name.as_ref() {
if let Some(args) = tool_attrs.get("gen_ai.tool.arguments") {
let args_json = match args {
Value::String(s) => Some(s.clone()),
Value::Null => None,
other => Some(other.to_string()),
};
out.push(LLMEvent::ToolCall {
tool_name: name.clone(),
args_json,
});
}
if let Some(duration_ms) = tool_attrs
.get("gen_ai.tool.duration_ms")
.and_then(Value::as_u64)
{
let error_summary = tool_attrs
.get("error.type")
.or_else(|| tool_attrs.get("gen_ai.tool.error"))
.and_then(Value::as_str)
.map(String::from);
let success = error_summary.is_none();
out.push(LLMEvent::ToolResult {
tool_name: name.clone(),
success,
duration_ms,
error_summary,
});
}
}
}
out
}
fn first_event_content(events: &[Value], event_name: &str) -> Option<String> {
for event in events {
if event.get("name").and_then(Value::as_str) == Some(event_name) {
if let Some(content) = event
.get("attributes")
.and_then(|a| a.get("content"))
.and_then(Value::as_str)
{
return Some(content.to_string());
}
}
}
None
}
fn span_duration_ms(span: &Value) -> Option<u32> {
let start = span.get("start_time_unix_nano").and_then(Value::as_u64)?;
let end = span.get("end_time_unix_nano").and_then(Value::as_u64)?;
if end < start {
return None;
}
let diff_ms = (end - start) / 1_000_000;
Some(diff_ms.min(u32::MAX as u64) as u32)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn empty_span_yields_no_events() {
let span = json!({});
assert!(events_from_span(&span).is_empty());
}
#[test]
fn span_with_only_random_attrs_yields_no_events() {
let span = json!({
"name": "chat",
"attributes": {"http.status_code": 200}
});
assert!(events_from_span(&span).is_empty());
}
#[test]
fn user_message_event_becomes_turn_start() {
let span = json!({
"events": [
{"name": "gen_ai.user.message", "attributes": {"content": "hello"}}
]
});
let events = events_from_span(&span);
assert_eq!(events.len(), 1);
match &events[0] {
LLMEvent::TurnStart { user_message } => assert_eq!(user_message, "hello"),
other => panic!("expected TurnStart, got {other:?}"),
}
}
#[test]
fn assistant_message_event_becomes_turn_complete() {
let span = json!({
"events": [
{"name": "gen_ai.assistant.message", "attributes": {"content": "world"}}
]
});
let events = events_from_span(&span);
assert_eq!(events.len(), 1);
match &events[0] {
LLMEvent::TurnComplete { full_response } => assert_eq!(full_response, "world"),
other => panic!("expected TurnComplete, got {other:?}"),
}
}
#[test]
fn usage_attributes_become_cost_event() {
let span = json!({
"attributes": {
"gen_ai.system": "anthropic",
"gen_ai.usage.input_tokens": 25_u32,
"gen_ai.usage.output_tokens": 800_u32
},
"start_time_unix_nano": 1_000_000_000_u64,
"end_time_unix_nano": 1_500_000_000_u64
});
let events = events_from_span(&span);
assert_eq!(events.len(), 1);
match &events[0] {
LLMEvent::Cost {
tokens_in,
tokens_out,
wallclock_ms,
provider,
} => {
assert_eq!(*tokens_in, 25);
assert_eq!(*tokens_out, 800);
assert_eq!(*wallclock_ms, 500); assert_eq!(provider.as_deref(), Some("anthropic"));
}
other => panic!("expected Cost, got {other:?}"),
}
}
#[test]
fn missing_one_token_counter_still_emits_cost() {
let span = json!({
"attributes": {
"gen_ai.usage.output_tokens": 100_u32
}
});
let events = events_from_span(&span);
assert_eq!(events.len(), 1);
match &events[0] {
LLMEvent::Cost {
tokens_in: 0,
tokens_out: 100,
..
} => {}
other => panic!("expected Cost with only output tokens, got {other:?}"),
}
}
#[test]
fn both_messages_emit_in_canonical_order() {
let span = json!({
"events": [
{"name": "gen_ai.assistant.message", "attributes": {"content": "resp"}},
{"name": "gen_ai.user.message", "attributes": {"content": "req"}}
]
});
let events = events_from_span(&span);
assert_eq!(events.len(), 2);
assert!(matches!(events[0], LLMEvent::TurnStart { .. }));
assert!(matches!(events[1], LLMEvent::TurnComplete { .. }));
}
#[test]
fn tool_call_event_emits_tool_call_llm_event() {
let span = json!({
"events": [
{
"name": "gen_ai.tool.message",
"attributes": {
"gen_ai.tool.name": "search_orders",
"gen_ai.tool.arguments": "{\"user_id\": 42}"
}
}
]
});
let events = events_from_span(&span);
assert_eq!(events.len(), 1);
match &events[0] {
LLMEvent::ToolCall {
tool_name,
args_json,
} => {
assert_eq!(tool_name, "search_orders");
assert_eq!(args_json.as_deref(), Some("{\"user_id\": 42}"));
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn tool_event_with_duration_also_emits_tool_result() {
let span = json!({
"events": [
{
"name": "gen_ai.tool.message",
"attributes": {
"gen_ai.tool.name": "search_orders",
"gen_ai.tool.arguments": "{}",
"gen_ai.tool.duration_ms": 120_u64
}
}
]
});
let events = events_from_span(&span);
assert_eq!(events.len(), 2);
assert!(matches!(events[0], LLMEvent::ToolCall { .. }));
match &events[1] {
LLMEvent::ToolResult {
tool_name,
success,
duration_ms,
..
} => {
assert_eq!(tool_name, "search_orders");
assert!(*success);
assert_eq!(*duration_ms, 120);
}
other => panic!("expected ToolResult, got {other:?}"),
}
}
#[test]
fn tool_event_with_error_type_marks_failure() {
let span = json!({
"events": [
{
"name": "gen_ai.tool.message",
"attributes": {
"gen_ai.tool.name": "db.query",
"gen_ai.tool.duration_ms": 250_u64,
"error.type": "timeout"
}
}
]
});
let events = events_from_span(&span);
let tool_result = events
.iter()
.find_map(|e| {
if let LLMEvent::ToolResult {
success,
error_summary,
..
} = e
{
Some((success, error_summary))
} else {
None
}
})
.expect("tool result should be present");
assert!(!*tool_result.0, "error.type should mark failure");
assert_eq!(tool_result.1.as_deref(), Some("timeout"));
}
#[test]
fn full_turn_span_produces_canonical_event_ordering() {
let span = json!({
"name": "agent.turn",
"attributes": {
"gen_ai.system": "openai",
"gen_ai.usage.input_tokens": 50_u32,
"gen_ai.usage.output_tokens": 400_u32
},
"events": [
{"name": "gen_ai.user.message", "attributes": {"content": "find order 42"}},
{
"name": "gen_ai.tool.message",
"attributes": {
"gen_ai.tool.name": "search_orders",
"gen_ai.tool.arguments": "{\"id\": 42}",
"gen_ai.tool.duration_ms": 80_u64
}
},
{"name": "gen_ai.assistant.message", "attributes": {"content": "order found"}}
],
"start_time_unix_nano": 2_000_000_000_u64,
"end_time_unix_nano": 2_750_000_000_u64
});
let events = events_from_span(&span);
assert_eq!(events.len(), 5, "events emitted: {events:?}");
assert!(matches!(events[0], LLMEvent::TurnStart { .. }));
assert!(matches!(events[1], LLMEvent::TurnComplete { .. }));
assert!(matches!(events[2], LLMEvent::Cost { .. }));
assert!(matches!(events[3], LLMEvent::ToolCall { .. }));
assert!(matches!(events[4], LLMEvent::ToolResult { .. }));
}
#[test]
fn negative_duration_clamps_to_none_then_zero() {
let span = json!({
"attributes": {"gen_ai.usage.output_tokens": 100_u32},
"start_time_unix_nano": 1_500_000_000_u64,
"end_time_unix_nano": 1_000_000_000_u64
});
let events = events_from_span(&span);
assert_eq!(events.len(), 1);
if let LLMEvent::Cost { wallclock_ms, .. } = &events[0] {
assert_eq!(*wallclock_ms, 0);
} else {
panic!("expected Cost event");
}
}
#[test]
fn end_to_end_feeds_regulator_cleanly() {
use crate::Regulator;
let span = json!({
"attributes": {
"gen_ai.usage.input_tokens": 25_u32,
"gen_ai.usage.output_tokens": 800_u32
},
"events": [
{"name": "gen_ai.user.message", "attributes": {"content": "hello"}},
{"name": "gen_ai.assistant.message", "attributes": {"content": "hi back"}}
],
"start_time_unix_nano": 1_u64,
"end_time_unix_nano": 500_000_000_u64
});
let mut r = Regulator::for_user("otel_user");
for event in events_from_span(&span) {
r.on_event(event);
}
let _ = r.decide();
}
}