use crate::{IngestError, Ingested, oivocab};
use amberfork_model::{Run, SchemaVersion, Step, Warning};
use serde::Deserialize;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::path::Path;
pub fn from_trace_json_str(s: &str) -> Result<Ingested, IngestError> {
let trace: RawTrace =
serde_json::from_str(s).map_err(|source| IngestError::Parse { path: None, source })?;
Ok(normalize(trace))
}
pub fn load_file(path: impl AsRef<Path>) -> Result<Ingested, IngestError> {
let path = path.as_ref();
let text = std::fs::read_to_string(path).map_err(|source| IngestError::Io {
path: path.to_path_buf(),
source,
})?;
from_trace_json_str(&text).map_err(|err| err.with_path(path))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrailMeta {
pub gaia_task_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConvertedTrail {
pub run: Run,
pub meta: TrailMeta,
pub warnings: Vec<Warning>,
}
pub fn convert_str(s: &str) -> Result<ConvertedTrail, IngestError> {
let trace: RawTrace =
serde_json::from_str(s).map_err(|source| IngestError::Parse { path: None, source })?;
let gaia_task_id = extract_gaia_task_id(&trace.spans);
let Ingested { run, warnings } = normalize(trace);
Ok(ConvertedTrail {
run,
meta: TrailMeta { gaia_task_id },
warnings,
})
}
pub fn convert_file(path: impl AsRef<Path>) -> Result<ConvertedTrail, IngestError> {
let path = path.as_ref();
let text = std::fs::read_to_string(path).map_err(|source| IngestError::Io {
path: path.to_path_buf(),
source,
})?;
convert_str(&text).map_err(|err| err.with_path(path))
}
fn extract_gaia_task_id(spans: &[RawTrailSpan]) -> Option<String> {
for span in spans {
if let Some(id) = span
.logs
.iter()
.find_map(|log| task_id_in_log_body(&log.body))
{
return Some(id);
}
if let Some(id) = extract_gaia_task_id(&span.child_spans) {
return Some(id);
}
}
None
}
fn task_id_in_log_body(body: &Value) -> Option<String> {
let obj = body.as_object()?;
let from_example = obj
.get("function.arguments")
.and_then(Value::as_object)
.and_then(|args| args.get("example"))
.and_then(Value::as_object)
.and_then(|example| example.get("task_id"))
.and_then(Value::as_str)
.and_then(nonempty_string);
if from_example.is_some() {
return from_example;
}
obj.get("function.output")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|row| row.get("task_id").and_then(Value::as_str))
.find_map(nonempty_string)
}
fn nonempty_string(s: &str) -> Option<String> {
(!s.is_empty()).then(|| s.to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Impact {
Low,
Medium,
High,
Other(String),
}
impl Impact {
fn from_raw(raw: &str) -> Self {
match raw.to_ascii_uppercase().as_str() {
"LOW" => Self::Low,
"MEDIUM" => Self::Medium,
"HIGH" => Self::High,
_ => Self::Other(raw.to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrailError {
pub category: String,
pub location: String,
pub impact: Impact,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrailGold {
pub trace_id: String,
pub errors: Vec<TrailError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GoldStep {
pub step: Option<usize>,
pub category: String,
pub location: String,
pub impact: Impact,
}
pub fn annotations_from_json_str(s: &str) -> Result<TrailGold, IngestError> {
let raw: RawAnnotations =
serde_json::from_str(s).map_err(|source| IngestError::Parse { path: None, source })?;
Ok(raw.into_gold())
}
pub fn load_annotations(path: impl AsRef<Path>) -> Result<TrailGold, IngestError> {
let path = path.as_ref();
let text = std::fs::read_to_string(path).map_err(|source| IngestError::Io {
path: path.to_path_buf(),
source,
})?;
annotations_from_json_str(&text).map_err(|err| err.with_path(path))
}
impl TrailGold {
#[must_use]
pub fn resolve(&self, run: &Run) -> Vec<GoldStep> {
let idx_of: HashMap<&str, usize> = run
.steps
.iter()
.filter_map(|step| {
step.attrs
.get("otel.span_id")
.and_then(Value::as_str)
.map(|span_id| (span_id, step.idx))
})
.collect();
self.errors
.iter()
.map(|error| GoldStep {
step: idx_of.get(error.location.as_str()).copied(),
category: error.category.clone(),
location: error.location.clone(),
impact: error.impact.clone(),
})
.collect()
}
}
struct FlatSpan {
idx: usize,
parent_idx: Option<usize>,
span_id: String,
span_name: String,
timestamp: Option<String>,
duration: Option<String>,
attrs: Map<String, Value>,
}
fn normalize(trace: RawTrace) -> Ingested {
let mut flat = Vec::new();
flatten(trace.spans, None, &mut flat);
let mut warnings = Vec::new();
let steps: Vec<Step> = flat
.into_iter()
.map(|span| span.into_step(&mut warnings))
.collect();
let id = if trace.trace_id.is_empty() {
"trace-unknown".to_string()
} else {
trace.trace_id
};
let run = Run {
schema_version: SchemaVersion::current(),
id,
task: None,
outcome: None,
steps,
edges: None,
};
Ingested { run, warnings }
}
fn flatten(spans: Vec<RawTrailSpan>, parent_idx: Option<usize>, out: &mut Vec<FlatSpan>) {
for span in spans {
let idx = out.len();
let RawTrailSpan {
span_id,
span_name,
timestamp,
duration,
span_attributes,
child_spans,
logs: _,
} = span;
out.push(FlatSpan {
idx,
parent_idx,
span_id,
span_name,
timestamp,
duration,
attrs: span_attributes,
});
flatten(child_spans, Some(idx), out);
}
}
impl FlatSpan {
fn into_step(self, warnings: &mut Vec<Warning>) -> Step {
let mut provenance = Vec::new();
if !self.span_id.is_empty() {
provenance.push(("otel.span_id", Value::from(self.span_id)));
}
if let Some(duration) = self.duration {
provenance.push(("otel.duration", Value::from(duration)));
}
oivocab::map_span_to_step(
self.idx,
self.parent_idx,
&self.span_name,
self.attrs,
provenance,
self.timestamp,
None,
warnings,
)
}
}
#[derive(Deserialize)]
struct RawTrace {
#[serde(default)]
trace_id: String,
#[serde(default)]
spans: Vec<RawTrailSpan>,
}
#[derive(Deserialize)]
struct RawTrailSpan {
#[serde(default)]
span_id: String,
#[serde(default)]
span_name: String,
#[serde(default)]
timestamp: Option<String>,
#[serde(default)]
duration: Option<String>,
#[serde(default)]
span_attributes: Map<String, Value>,
#[serde(default)]
child_spans: Vec<RawTrailSpan>,
#[serde(default)]
logs: Vec<RawLog>,
}
#[derive(Deserialize)]
struct RawLog {
#[serde(default)]
body: Value,
}
#[derive(Deserialize)]
struct RawAnnotations {
#[serde(default)]
trace_id: String,
#[serde(default)]
errors: Vec<RawError>,
}
impl RawAnnotations {
fn into_gold(self) -> TrailGold {
TrailGold {
trace_id: self.trace_id,
errors: self
.errors
.into_iter()
.map(|error| TrailError {
impact: Impact::from_raw(&error.impact),
category: error.category,
location: error.location,
})
.collect(),
}
}
}
#[derive(Deserialize)]
struct RawError {
#[serde(default)]
category: String,
#[serde(default)]
location: String,
#[serde(default)]
impact: String,
}