use crate::model::{SpanKind, TokenUsage};
use std::collections::BTreeMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const ALLOWED: &[&str] = &[
"service.name",
"gen_ai.operation.name",
"gen_ai.provider.name",
"gen_ai.request.model",
"gen_ai.response.model",
"gen_ai.conversation.id",
"gen_ai.agent.name",
"gen_ai.tool.name",
"gen_ai.usage.input_tokens",
"gen_ai.usage.output_tokens",
"gen_ai.usage.cache_read.input_tokens",
"gen_ai.usage.cache_write.input_tokens",
"gen_ai.usage.prompt_tokens",
"gen_ai.usage.completion_tokens",
"gen_ai.system",
"agent_top.harness",
"agent_top.session_id",
"agent_top.model",
"agent_top.kind",
"agent_top.sidechain",
"agent_top.mcp.server",
"agent_top.open",
];
pub fn keep(key: &str) -> bool {
ALLOWED.contains(&key)
}
#[derive(Debug, Clone, PartialEq)]
pub enum Attr {
Str(String),
Int(i64),
Float(f64),
Bool(bool),
}
impl Attr {
pub fn as_str(&self) -> Option<&str> {
match self {
Attr::Str(s) => Some(s),
_ => None,
}
}
pub fn as_u64(&self) -> Option<u64> {
match self {
Attr::Int(i) => u64::try_from(*i).ok(),
Attr::Float(f) if *f >= 0.0 && f.fract() == 0.0 => Some(*f as u64),
Attr::Str(s) => s.parse().ok(),
_ => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
Attr::Bool(b) => Some(*b),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ReceivedSpan {
pub trace_id: String,
pub span_id: String,
pub parent_span_id: Option<String>,
pub name: String,
pub start_unix_nano: u64,
pub end_unix_nano: u64,
pub error: bool,
pub attrs: BTreeMap<String, Attr>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageRole {
Inference,
Agent,
None,
}
impl ReceivedSpan {
fn str(&self, key: &str) -> Option<&str> {
self.attrs.get(key).and_then(Attr::as_str)
}
fn count(&self, key: &str) -> Option<u64> {
self.attrs.get(key).and_then(Attr::as_u64)
}
pub fn operation(&self) -> Option<&str> {
self.str("gen_ai.operation.name")
}
pub fn kind(&self) -> Option<SpanKind> {
match self.operation() {
Some("chat" | "generate_content" | "text_completion") => Some(SpanKind::Inference),
Some("execute_tool") => Some(SpanKind::Tool),
Some("invoke_agent" | "invoke_workflow") => Some(SpanKind::Turn),
Some(_) => None,
None => match self.str("agent_top.kind") {
Some("inference") => Some(SpanKind::Inference),
Some("tool") => Some(SpanKind::Tool),
Some("turn") => Some(SpanKind::Turn),
_ => None,
},
}
}
pub fn usage_role(&self) -> UsageRole {
match self.operation() {
Some("chat" | "generate_content" | "text_completion" | "embeddings") => UsageRole::Inference,
Some("invoke_agent" | "invoke_workflow") => UsageRole::Agent,
_ => UsageRole::None,
}
}
pub fn display_name(&self) -> String {
match (self.kind(), self.str("gen_ai.tool.name")) {
(Some(SpanKind::Tool), Some(t)) => t.to_string(),
_ => self.name.clone(),
}
}
pub fn model(&self) -> Option<&str> {
self.str("gen_ai.response.model").or_else(|| self.str("gen_ai.request.model")).or_else(|| self.str("agent_top.model"))
}
pub fn provider(&self) -> Option<&str> {
self.str("gen_ai.provider.name").or_else(|| self.str("gen_ai.system"))
}
pub fn service(&self) -> &str {
self.str("service.name").unwrap_or("unknown_service")
}
pub fn harness(&self) -> &str {
self.str("agent_top.harness").unwrap_or("otel")
}
pub fn conversation(&self) -> &str {
self.str("gen_ai.conversation.id").or_else(|| self.str("agent_top.session_id")).unwrap_or(&self.trace_id)
}
pub fn session_id(&self) -> String {
format!("{}/{}", self.service(), self.conversation())
}
pub fn sidechain(&self) -> bool {
self.attrs.get("agent_top.sidechain").and_then(Attr::as_bool).unwrap_or(false)
}
pub fn usage(&self) -> TokenUsage {
let cache_read = self.count("gen_ai.usage.cache_read.input_tokens").unwrap_or(0);
let cache_write = self.count("gen_ai.usage.cache_write.input_tokens").unwrap_or(0);
let input = self.count("gen_ai.usage.input_tokens").or_else(|| self.count("gen_ai.usage.prompt_tokens")).unwrap_or(0);
let output = self.count("gen_ai.usage.output_tokens").or_else(|| self.count("gen_ai.usage.completion_tokens")).unwrap_or(0);
TokenUsage {
input: input.saturating_sub(cache_read + cache_write),
cache_write_5m: cache_write,
cache_read,
output,
..Default::default()
}
}
pub fn started_at(&self) -> SystemTime {
UNIX_EPOCH + Duration::from_nanos(self.start_unix_nano)
}
pub fn duration_ms(&self) -> u64 {
self.end_unix_nano.saturating_sub(self.start_unix_nano) / 1_000_000
}
pub fn is_open(&self) -> bool {
self.attrs.get("agent_top.open").and_then(Attr::as_bool).unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn span(attrs: &[(&str, Attr)]) -> ReceivedSpan {
ReceivedSpan {
trace_id: "t".into(),
span_id: "s".into(),
parent_span_id: None,
name: "chat claude".into(),
start_unix_nano: 1_000_000_000,
end_unix_nano: 1_250_000_000,
error: false,
attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(),
}
}
#[test]
fn content_attributes_are_not_on_the_allow_list() {
for k in [
"gen_ai.input.messages",
"gen_ai.output.messages",
"gen_ai.system_instructions",
"gen_ai.tool.call.arguments",
"gen_ai.tool.call.result",
"gen_ai.prompt.0.content",
] {
assert!(!keep(k), "{k}");
}
assert!(keep("gen_ai.usage.input_tokens"));
}
#[test]
fn input_tokens_include_the_cache_and_are_split_out() {
let s = span(&[
("gen_ai.operation.name", Attr::Str("chat".into())),
("gen_ai.usage.input_tokens", Attr::Int(165)),
("gen_ai.usage.cache_read.input_tokens", Attr::Int(100)),
("gen_ai.usage.cache_write.input_tokens", Attr::Int(25)),
("gen_ai.usage.output_tokens", Attr::Str("12".into())),
]);
let u = s.usage();
assert_eq!((u.input, u.cache_read, u.cache_write_5m, u.output), (40, 100, 25, 12));
assert_eq!(u.total(), 177);
assert_eq!(s.kind(), Some(SpanKind::Inference));
assert_eq!(s.usage_role(), UsageRole::Inference);
assert_eq!(s.duration_ms(), 250);
}
#[test]
fn older_names_still_count() {
let s = span(&[("gen_ai.usage.prompt_tokens", Attr::Int(10)), ("gen_ai.usage.completion_tokens", Attr::Int(3))]);
assert_eq!((s.usage().input, s.usage().output), (10, 3));
}
#[test]
fn kinds_come_from_the_operation_or_the_trace_export() {
let tool = span(&[("gen_ai.operation.name", Attr::Str("execute_tool".into())), ("gen_ai.tool.name", Attr::Str("search".into()))]);
assert_eq!((tool.kind(), tool.display_name().as_str()), (Some(SpanKind::Tool), "search"));
assert_eq!(span(&[("gen_ai.operation.name", Attr::Str("invoke_agent".into()))]).kind(), Some(SpanKind::Turn));
assert_eq!(span(&[("gen_ai.operation.name", Attr::Str("embeddings".into()))]).kind(), None);
assert_eq!(span(&[("agent_top.kind", Attr::Str("tool".into()))]).kind(), Some(SpanKind::Tool));
assert_eq!(span(&[]).kind(), None);
}
#[test]
fn a_session_is_keyed_by_service_and_conversation() {
let s = span(&[("service.name", Attr::Str("triage-bot".into())), ("gen_ai.conversation.id", Attr::Str("c1".into()))]);
assert_eq!(s.session_id(), "triage-bot/c1");
assert_eq!(span(&[]).session_id(), "unknown_service/t");
assert_eq!(s.harness(), "otel");
}
}