use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::WyrdError;
use crate::vala::ids::{SpanId, TraceId};
use crate::vala::trace::{GenAiEvalResult, Resource, SpanStatus};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GenAiSpanRecord {
pub trace_id: TraceId,
pub span_id: SpanId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_span_id: Option<SpanId>,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub duration_ms: u64,
pub status: SpanStatus,
pub resource: Resource,
pub provider_name: String,
pub operation_name: String,
pub request_model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_id: Option<String>,
#[serde(default)]
pub response_finish_reasons: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_time_to_first_chunk_seconds: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_temperature: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_top_p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_top_k: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_max_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_frequency_penalty: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_presence_penalty: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_seed: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_choice_count: Option<u32>,
#[serde(default)]
pub request_stop_sequences: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_stream: Option<bool>,
#[serde(default)]
pub request_encoding_formats: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_input_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_output_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_cache_creation_input_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_cache_read_input_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_reasoning_output_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_source_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embeddings_dimension_count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_address: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_messages: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_messages: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_instructions: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_definitions: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_arguments: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_result: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retrieval_documents: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retrieval_query_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retrieval_top_k: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_reasoning_level: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_compacted: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_store_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_record_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_record_count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_query_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_records: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openai_api_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openai_request_service_tier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openai_response_service_tier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openai_response_system_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_method_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_protocol_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_resource_uri: Option<String>,
#[serde(default)]
pub eval_results: Vec<GenAiEvalResult>,
#[serde(default)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl GenAiSpanRecord {
pub fn validate(&self) -> Result<(), WyrdError> {
const MAX_BLOB_BYTES: usize = 1_048_576;
if self.provider_name.is_empty() {
return Err(WyrdError::Validation {
message: "gen_ai_span.provider_name must be non-empty".to_string(),
details: serde_json::Value::Null,
});
}
if self.operation_name.is_empty() {
return Err(WyrdError::Validation {
message: "gen_ai_span.operation_name must be non-empty".to_string(),
details: serde_json::Value::Null,
});
}
if self.request_model.is_empty() {
return Err(WyrdError::Validation {
message: "gen_ai_span.request_model must be non-empty".to_string(),
details: serde_json::Value::Null,
});
}
if self.end_time < self.start_time {
return Err(WyrdError::Validation {
message: format!(
"gen_ai_span.end_time {:?} < start_time {:?}",
self.end_time, self.start_time
),
details: serde_json::Value::Null,
});
}
let derived = super::duration_ms_from_timestamps(self.start_time, self.end_time);
if self.duration_ms != derived {
return Err(WyrdError::Validation {
message: format!(
"gen_ai_span.duration_ms {} != end_time - start_time = {}",
self.duration_ms, derived
),
details: serde_json::Value::Null,
});
}
for (field, value) in [
("input_messages", &self.input_messages),
("output_messages", &self.output_messages),
("system_instructions", &self.system_instructions),
("tool_definitions", &self.tool_definitions),
("tool_call_arguments", &self.tool_call_arguments),
("tool_call_result", &self.tool_call_result),
("retrieval_documents", &self.retrieval_documents),
("memory_records", &self.memory_records),
] {
if let Some(v) = value {
let size = serde_json::to_vec(v).map_or(0, |b| b.len());
if size > MAX_BLOB_BYTES {
return Err(WyrdError::Validation {
message: format!(
"gen_ai_span.{field} exceeds {MAX_BLOB_BYTES} bytes, got {size}"
),
details: serde_json::Value::Null,
});
}
}
}
if self.extra.len() > 128 {
return Err(WyrdError::Validation {
message: format!(
"gen_ai_span.extra count must be <= 128, got {}",
self.extra.len()
),
details: serde_json::Value::Null,
});
}
for (field, value) in [
("request_temperature", self.request_temperature),
("request_top_p", self.request_top_p),
("request_frequency_penalty", self.request_frequency_penalty),
("request_presence_penalty", self.request_presence_penalty),
(
"response_time_to_first_chunk_seconds",
self.response_time_to_first_chunk_seconds,
),
] {
if let Some(value) = value
&& !value.is_finite()
{
return Err(WyrdError::Validation {
message: format!("gen_ai_span.{field} must be finite, got {value}"),
details: serde_json::Value::Null,
});
}
}
self.resource.validate()?;
for (index, eval_result) in self.eval_results.iter().enumerate() {
eval_result.validate().map_err(|error| match error {
WyrdError::Validation { message, .. } => WyrdError::Validation {
message: format!("eval_results[{index}]: {message}"),
details: serde_json::Value::Null,
},
other => other,
})?;
}
Ok(())
}
}