use amberfork_model::{
Edge, Outcome, Payload, Run, SchemaVersion, Step, StepKind, Warning, WarningCode,
};
use serde::Deserialize;
use serde::de::IgnoredAny;
use serde_json::{Map, Value};
use std::fmt;
use std::path::{Path, PathBuf};
const TRACE_FORMAT_URL: &str = concat!(
env!("CARGO_PKG_REPOSITORY"),
"/blob/main/docs/trace-format.md"
);
const CONVERSION_GUIDE_URL: &str = concat!(
env!("CARGO_PKG_REPOSITORY"),
"/blob/main/docs/run-on-your-own-agent.md"
);
pub mod genai;
pub mod hal;
pub mod openinference;
pub mod tape;
pub mod trail;
pub mod whowhen;
mod otlp;
mod oivocab;
mod genaivocab;
#[derive(Debug, Clone, PartialEq)]
pub struct Ingested {
pub run: Run,
pub warnings: Vec<Warning>,
}
#[derive(Debug)]
pub enum IngestError {
Io {
path: PathBuf,
source: std::io::Error,
},
Parse {
path: Option<PathBuf>,
source: serde_json::Error,
},
NotATrace {
path: Option<PathBuf>,
source: serde_json::Error,
},
JsonLines { path: Option<PathBuf> },
}
impl IngestError {
pub fn with_path(self, path: &Path) -> Self {
let path = Some(path.to_path_buf());
match self {
Self::Io { .. } => self,
Self::Parse { source, .. } => Self::Parse { path, source },
Self::NotATrace { source, .. } => Self::NotATrace { path, source },
Self::JsonLines { .. } => Self::JsonLines { path },
}
}
}
impl fmt::Display for IngestError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = |path: &Option<PathBuf>| {
path.as_ref()
.map_or_else(String::new, |p| format!("{}: ", p.display()))
};
match self {
Self::Io { path, source } => {
write!(f, "failed to read trace file {}: {source}", path.display())
}
Self::Parse { path, source } => {
write!(f, "{}failed to parse trace JSON: {source}", name(path))
}
Self::NotATrace { path, source } => write!(
f,
"{}valid JSON, but not a canonical trace: {source}\n \
a canonical trace is a single JSON object with `schema_version`, `id`, and `steps`\n \
format reference: {TRACE_FORMAT_URL}\n \
conversion guide: {CONVERSION_GUIDE_URL}",
name(path)
),
Self::JsonLines { path } => write!(
f,
"{}this looks like JSON-Lines (one JSON value per line) — amberfork takes a \
single JSON trace per file\n \
conversion guide: {CONVERSION_GUIDE_URL}",
name(path)
),
}
}
}
impl std::error::Error for IngestError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Parse { source, .. } | Self::NotATrace { source, .. } => Some(source),
Self::JsonLines { .. } => None,
}
}
}
pub fn from_json_str(s: &str) -> Result<Ingested, IngestError> {
let raw: RawRun =
serde_json::from_str(s).map_err(|source| classify_parse_failure(s, source))?;
let (run, warnings) = raw.into_run();
Ok(Ingested { run, warnings })
}
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_json_str(&text).map_err(|err| err.with_path(path))
}
fn classify_parse_failure(text: &str, source: serde_json::Error) -> IngestError {
if looks_like_json_lines(text) {
return IngestError::JsonLines { path: None };
}
if serde_json::from_str::<IgnoredAny>(text).is_ok() {
return IngestError::NotATrace { path: None, source };
}
IngestError::Parse { path: None, source }
}
fn looks_like_json_lines(text: &str) -> bool {
let mut lines = text.lines().filter(|line| !line.trim().is_empty());
let Some(first) = lines.next() else {
return false;
};
lines.next().is_some() && serde_json::from_str::<IgnoredAny>(first).is_ok()
}
#[derive(Deserialize)]
struct RawRun {
schema_version: SchemaVersion,
id: String,
#[serde(default)]
task: Option<String>,
#[serde(default)]
outcome: Option<Outcome>,
steps: Vec<RawStep>,
#[serde(default)]
edges: Option<Vec<Edge>>,
#[serde(flatten)]
extra: Map<String, Value>,
}
#[derive(Deserialize)]
struct RawStep {
idx: usize,
kind: StepKind,
name: String,
#[serde(default)]
inputs: Option<Payload>,
#[serde(default)]
outputs: Option<Payload>,
#[serde(default)]
attrs: Map<String, Value>,
#[serde(default)]
t_start: Option<String>,
#[serde(default)]
t_end: Option<String>,
#[serde(default)]
parent_idx: Option<usize>,
#[serde(flatten)]
extra: Map<String, Value>,
}
impl RawRun {
fn into_run(self) -> (Run, Vec<Warning>) {
let mut warnings = Vec::new();
if !self.schema_version.is_current() {
warnings.push(Warning {
code: WarningCode::SchemaVersionMismatch,
msg: format!(
"trace declares schema_version {:?}; this build is native to {} — fields may be read under the wrong contract",
self.schema_version.0,
SchemaVersion::CURRENT
),
});
}
if !self.extra.is_empty() {
warnings.push(Warning {
code: WarningCode::UnmappedAttributes,
msg: format!(
"run-level unmapped fields (no run-level attrs to hold them, dropped): {}",
sorted_keys(&self.extra)
),
});
}
let steps = self
.steps
.into_iter()
.map(|step| step.into_step(&mut warnings))
.collect();
let run = Run {
schema_version: self.schema_version,
id: self.id,
task: self.task,
outcome: self.outcome,
steps,
edges: self.edges,
};
(run, warnings)
}
}
impl RawStep {
fn into_step(self, warnings: &mut Vec<Warning>) -> Step {
let mut attrs = self.attrs;
if !self.extra.is_empty() {
warnings.push(Warning {
code: WarningCode::UnmappedAttributes,
msg: format!(
"step {} ({}): unmapped fields moved to attrs: {}",
self.idx,
self.name,
sorted_keys(&self.extra)
),
});
for (key, value) in self.extra {
attrs.insert(key, value);
}
}
if self.inputs.is_none() && self.outputs.is_none() {
warnings.push(Warning {
code: WarningCode::ContentAbsent,
msg: format!(
"step {} ({}): no input or output content captured",
self.idx, self.name
),
});
}
Step {
idx: self.idx,
kind: self.kind,
name: self.name,
inputs: self.inputs,
outputs: self.outputs,
attrs,
t_start: self.t_start,
t_end: self.t_end,
parent_idx: self.parent_idx,
}
}
}
fn sorted_keys(map: &Map<String, Value>) -> String {
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
keys.sort_unstable();
keys.join(", ")
}