///|
/// Canonical SDK identifiers preserve trace identity across merged exports.
fn span_identifier(
context : Map[String, Json],
key : String,
width : Int32,
) -> String raise DecodeError {
let raw = required_string(context, key).to_lower()
let value = raw.strip_prefix("0x").unwrap_or(raw.view())
if value.length() != width {
raise DecodeError("invalid span identifier: " + key)
}
let mut nonzero = false
for c in value {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
raise DecodeError("invalid span identifier: " + key)
}
if c != '0' {
nonzero = true
}
}
if !nonzero {
raise DecodeError("zero span identifier: " + key)
}
value.to_owned()
}
///|
/// Both semantic families may coexist, but disagreement is not silently resolved.
fn span_tokens(
attrs : Map[String, Json],
genai : String,
inference : String,
) -> Int64? raise DecodeError {
let a = optional_tokens(attrs, genai)
let b = optional_tokens(attrs, inference)
match (a, b) {
(Some(x), Some(y)) if x != y =>
raise DecodeError("conflicting semantic token attributes")
(Some(x), _) => Some(x)
(_, value) => value
}
}
///|
/// Bounded flat SDK-span profile, not raw OTLP/protobuf or a backend API export.
/// Require explicit model-call kind, terminal status and caller-supplied task metadata.
pub fn decode_span(value : Json) -> Call raise DecodeError {
guard value is Object(obj) else {
raise DecodeError("expected flat SDK span object")
}
guard obj.get("attributes") is Some(Object(attrs)) else {
raise DecodeError("expected span attributes object")
}
guard obj.get("context") is Some(Object(context)) else {
raise DecodeError("expected span context object")
}
let _ = required_string(obj, "end_time")
let mut model_kind = false
match attrs.get("openinference.span.kind") {
Some(String("LLM")) => model_kind = true
None => ()
_ => raise DecodeError("only explicit LLM spans are accepted")
}
match attrs.get("gen_ai.operation.name") {
Some(String("chat" | "text_completion" | "generate_content")) =>
model_kind = true
None => ()
_ =>
raise DecodeError(
"unsupported GenAI operation; exclude parent and tool spans",
)
}
if !model_kind {
raise DecodeError("missing explicit model-call span kind")
}
guard obj.get("status") is Some(Object(status)) else {
raise DecodeError("missing explicit span status")
}
let state = match status.get("status_code") {
Some(String("OK")) => "ok"
Some(String("ERROR")) => "error"
_ =>
raise DecodeError(
"span status must explicitly be OK or ERROR; UNSET is unknown",
)
}
let model = match attrs.get("gen_ai.response.model") {
Some(String(model)) if !model.trim().is_empty() => model
None =>
match attrs.get("gen_ai.request.model") {
Some(String(model)) if !model.trim().is_empty() => model
None => required_string(attrs, "llm.model_name")
_ => raise DecodeError("invalid requested model")
}
_ => raise DecodeError("invalid response model")
}
match attrs.get("llm.model_name") {
Some(String(other)) if other != model =>
raise DecodeError("conflicting semantic model attributes")
Some(String(_)) | None => ()
_ => raise DecodeError("invalid OpenInference model")
}
{
run_id: required_string(attrs, "runledger.run_id"),
task_id: required_string(attrs, "runledger.task_id"),
stage: required_string(attrs, "runledger.stage"),
call_id: span_identifier(context, "trace_id", 32) +
"/" +
span_identifier(context, "span_id", 16),
model,
status: state,
input_tokens: span_tokens(
attrs, "gen_ai.usage.input_tokens", "llm.token_count.prompt",
),
output_tokens: span_tokens(
attrs, "gen_ai.usage.output_tokens", "llm.token_count.completion",
),
}
}
///|
/// Every nonblank input line must be one selected model-call span; no silent filtering.
pub fn import_spans(text : String) -> Parsed {
let records : Array[Record] = []
let findings : Array[Finding] = []
for index, line in text.split("\n").to_array() {
if line.trim().is_empty() {
continue
}
let value = @json.parse(
line,
reject_duplicate_keys=true,
preserve_number_repr=true,
) catch {
_ => {
findings.push({
line: index + 1,
code: "invalid_json",
message: "malformed span JSON or duplicate keys",
})
continue
}
}
let call = decode_span(value) catch {
DecodeError(message) => {
findings.push({ line: index + 1, code: "invalid_span", message, })
continue
}
}
records.push({ line: index + 1, call, })
}
{ records, findings, }
}