use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize)]
pub struct IngestionRequest {
pub batch: Vec<IngestionEvent>,
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
#[allow(clippy::enum_variant_names)] pub enum IngestionEvent {
TraceCreate {
id: String,
timestamp: String,
body: TraceBody,
},
SpanCreate {
id: String,
timestamp: String,
body: SpanBody,
},
GenerationCreate {
id: String,
timestamp: String,
body: Box<GenerationBody>,
},
ScoreCreate {
id: String,
timestamp: String,
body: ScoreBody,
},
}
#[derive(Debug, Serialize)]
pub struct TraceBody {
pub id: String,
pub timestamp: String,
pub name: Option<String>,
#[serde(rename = "userId")]
pub user_id: Option<String>,
pub input: Option<serde_json::Value>,
pub output: Option<serde_json::Value>,
#[serde(rename = "sessionId")]
pub session_id: Option<String>,
pub release: Option<String>,
pub version: Option<String>,
pub metadata: Option<serde_json::Value>,
pub tags: Option<Vec<String>>,
pub environment: Option<String>,
pub public: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct SpanBody {
pub id: String,
#[serde(rename = "traceId")]
pub trace_id: String,
#[serde(
rename = "parentObservationId",
skip_serializing_if = "Option::is_none"
)]
pub parent_observation_id: Option<String>,
pub name: Option<String>,
#[serde(rename = "startTime")]
pub start_time: Option<String>,
#[serde(rename = "endTime", skip_serializing_if = "Option::is_none")]
pub end_time: Option<String>,
pub level: Option<String>,
#[serde(rename = "statusMessage", skip_serializing_if = "Option::is_none")]
pub status_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct GenerationBody {
#[serde(flatten)]
pub span: SpanBody,
#[serde(
rename = "completionStartTime",
skip_serializing_if = "Option::is_none"
)]
pub completion_start_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(rename = "modelParameters", skip_serializing_if = "Option::is_none")]
pub model_parameters: Option<HashMap<String, serde_json::Value>>,
#[serde(rename = "usageDetails", skip_serializing_if = "Option::is_none")]
pub usage_details: Option<HashMap<String, u64>>,
#[serde(rename = "costDetails", skip_serializing_if = "Option::is_none")]
pub cost_details: Option<HashMap<String, f64>>,
#[serde(rename = "promptName", skip_serializing_if = "Option::is_none")]
pub prompt_name: Option<String>,
#[serde(rename = "promptVersion", skip_serializing_if = "Option::is_none")]
pub prompt_version: Option<u32>,
}
#[derive(Debug, Serialize)]
pub struct ScoreBody {
pub id: String,
#[serde(rename = "traceId", skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
#[serde(rename = "observationId", skip_serializing_if = "Option::is_none")]
pub observation_id: Option<String>,
#[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
pub name: String,
pub value: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct IngestionResponse {
#[serde(default)]
#[allow(dead_code)]
pub successes: Vec<IngestionSuccess>,
#[serde(default)]
pub errors: Vec<IngestionError>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)] pub struct IngestionSuccess {
pub id: String,
pub status: u16,
}
#[derive(Debug, Deserialize)]
pub struct IngestionError {
pub id: String,
pub status: u16,
pub message: Option<String>,
}
pub fn format_iso8601(t: std::time::SystemTime) -> String {
let dt = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = dt.as_secs() as i64;
let nanos = dt.subsec_nanos();
let (year, month, day, hour, minute, second) = secs_to_ymd_hms(secs);
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
year,
month,
day,
hour,
minute,
second,
nanos / 1_000_000
)
}
fn secs_to_ymd_hms(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
let mut s = secs;
let day_secs = 86_400;
let mut days = s / day_secs;
s -= days * day_secs;
if s < 0 {
days -= 1;
s += day_secs;
}
let hour = (s / 3600) as u32;
let minute = ((s / 60) % 60) as u32;
let second = (s % 60) as u32;
let mut year = 1970i32;
loop {
let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
let year_days = if leap { 366 } else { 365 };
if days >= year_days as i64 {
days -= year_days as i64;
year += 1;
} else {
break;
}
}
let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
let month_lens = if leap {
[31u32, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31u32, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u32;
for &m in &month_lens {
if (days as u32) >= m {
days -= m as i64;
month += 1;
} else {
break;
}
}
let day = (days as u32) + 1;
(year, month, day, hour, minute, second)
}
pub fn envelope_id() -> String {
Uuid::new_v4().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, SystemTime};
#[test]
fn format_iso8601_unix_epoch() {
let t = SystemTime::UNIX_EPOCH;
assert_eq!(format_iso8601(t), "1970-01-01T00:00:00.000Z");
}
#[test]
fn format_iso8601_2026_05_06() {
let secs: u64 = 1_778_025_600;
let t = SystemTime::UNIX_EPOCH + Duration::from_secs(secs);
assert_eq!(format_iso8601(t), "2026-05-06T00:00:00.000Z");
}
#[test]
fn trace_create_serializes_with_kebab_type() {
let ev = IngestionEvent::TraceCreate {
id: "e1".into(),
timestamp: "2026-05-06T00:00:00.000Z".into(),
body: TraceBody {
id: "t1".into(),
timestamp: "2026-05-06T00:00:00.000Z".into(),
name: Some("hello".into()),
user_id: None,
input: None,
output: None,
session_id: None,
release: None,
version: None,
metadata: None,
tags: None,
environment: None,
public: None,
},
};
let s = serde_json::to_string(&ev).unwrap();
assert!(s.contains("\"type\":\"trace-create\""));
}
}