Skip to main content

rustev_otel/
project.rs

1//! The projection of a delivered run record into one decision's span tree
2//! (spec 028, 3.5): `rustev.decision`, a `rustev.step` per step evidence
3//! entry, a `rustev.attempt` per attempt, and `rustev.policy` last.
4//!
5//! Every value leaves through [`SpanBuilder::put`], which applies the
6//! redaction policy and the per-attribute value bound; there is no other
7//! path from a record value to an exported one (3.4.7). The projection
8//! reads the record and never writes it.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::fmt;
12use std::sync::Arc;
13
14use rustev_contract::definition::{Operation, ProvenanceClass, StepBody};
15use rustev_contract::evidence::{EvidenceRecord, InputStatus, StepEvidence, StepStatus};
16use rustev_contract::judgment::{OutValue, Outcome, Unresolved};
17use rustev_contract::plan::{Plan, PlanStepDetail};
18use rustev_contract::redaction::{Applied, FieldClass, Redactor, lineage_class, truncate_value};
19use rustev_contract::remote::{Disclosure, ExchangeRecord, MemberOutcome};
20use rustev_contract::run::{
21    AttemptEnd, AttemptRecord, Cancellation, Charge, CoreEvidence, CostBound, RecordedExecution,
22    RequestRecord, RequestResult, RunRecord, Transition,
23};
24use rustev_contract::schema;
25use rustev_contract::trace::{
26    Mark, SpanKind, SpanStatus, TRACE, TraceAttr, TraceEvent, TraceLine, TraceLineError,
27    TraceResource, TraceScope, TraceValue,
28};
29use rustev_contract::trace_fields::{
30    PARAM_PREFIX, SCHEMA_URL, SPAN_SCHEMA_VERSION, event, fixed, span, withheld_name,
31};
32use serde::Serialize;
33
34use crate::ids::{IdSource, hex};
35
36/// Events kept per span (3.5.5); the excess is counted in `rustev.events_cut`.
37pub const MAX_EVENTS_PER_SPAN: usize = 128;
38/// The instrumentation scope name (3.2.4).
39pub const SCOPE_NAME: &str = "rustev-otel";
40
41/// Where the decision's wall-clock anchor was read (3.5.2).
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum AnchorKind {
44    /// At `begin`, when the host registered the decision.
45    Begin,
46    /// At delivery minus `elapsed_ms`.
47    Delivery,
48}
49
50impl AnchorKind {
51    fn name(self) -> &'static str {
52        match self {
53            AnchorKind::Begin => "begin",
54            AnchorKind::Delivery => "delivery",
55        }
56    }
57}
58
59/// The wall-clock time of submission, in Unix nanoseconds, and how it was
60/// read.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct Anchor {
63    pub unix_nanos: u64,
64    pub kind: AnchorKind,
65}
66
67/// The decision's trace and root ids, and the host span it is a child of.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct RootIds {
70    pub trace_id: [u8; 16],
71    pub span_id: [u8; 8],
72    pub parent_span_id: Option<[u8; 8]>,
73}
74
75/// Per backend id, which exchange `usage` keys are input and output tokens
76/// (3.7.2).
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct UsageKeys {
79    pub input: Option<String>,
80    pub output: Option<String>,
81}
82
83/// Host configuration the projection reads (3.7.2). Rustev names no
84/// vendor: provider names and usage keys come only from here or from the
85/// exchange record.
86#[derive(Debug, Clone, Default)]
87pub struct Bindings {
88    /// Backend id to `gen_ai.provider.name`.
89    pub providers: BTreeMap<String, String>,
90    /// Backend id to its usage keys.
91    pub usage_keys: BTreeMap<String, UsageKeys>,
92}
93
94/// Everything one projection reads.
95pub struct ProjectionInput<'a> {
96    pub record: &'a RunRecord,
97    /// The registered plan, when the host registered it (3.7.2).
98    pub plan: Option<&'a Plan>,
99    /// Joined exchange records, by attempt id (3.7.3).
100    pub exchanges: &'a BTreeMap<String, Arc<ExchangeRecord>>,
101    pub bindings: &'a Bindings,
102    pub anchor: Anchor,
103    pub root: RootIds,
104    /// The per-attribute value bound in bytes (3.8.5).
105    pub value_bound: usize,
106}
107
108/// One decision's span lines and what was cut on the way.
109#[derive(Debug, Clone, PartialEq)]
110pub struct Projected {
111    /// Root first, then each step followed by its attempts, policy last.
112    pub lines: Vec<TraceLine>,
113    /// Attribute values cut to the value bound (3.8.5).
114    pub attributes_cut: u64,
115    /// Events beyond 128 on a span, or removed to fit a line (3.5.5).
116    pub events_cut: u64,
117    /// Facts the sink's metrics and sampling read, in their closed
118    /// vocabularies: never caller text.
119    pub facts: Facts,
120}
121
122/// What the metrics, logs and sampling of 3.6 and 3.9.4 need from a
123/// decision, already reduced to enum and number values.
124#[derive(Debug, Clone, Default, PartialEq)]
125pub struct Facts {
126    pub outcome: String,
127    pub termination: String,
128    pub plan_id: String,
129    pub outcome_unresolved_reason: Option<String>,
130    /// `unresolved_reason` of every unresolved step.
131    pub step_unresolved_reasons: Vec<String>,
132    /// `fallback` transitions.
133    pub fallbacks: u64,
134    pub elapsed_ms: u64,
135    pub final_cost: String,
136    /// Observed plus estimated cost units.
137    pub cost_units: u64,
138    pub attempts: Vec<AttemptFacts>,
139    /// Entry and closed verdict name, never screened content or its digest.
140    pub screening: Vec<(String, String)>,
141    /// Total charged units, present for decisions with screening.
142    pub screening_units: Option<u64>,
143}
144
145/// One attempt, for metrics and logs.
146#[derive(Debug, Clone, PartialEq)]
147pub struct AttemptFacts {
148    /// The attempt span's index in `lines`.
149    pub line: usize,
150    pub step_kind: String,
151    pub end: String,
152    pub backend_id: String,
153    pub duration_ms: u64,
154    /// GenAI attributes, when the attempt has them.
155    pub genai: Option<GenAiFacts>,
156}
157
158/// GenAI facts of one attempt (3.6.1).
159#[derive(Debug, Clone, PartialEq)]
160pub struct GenAiFacts {
161    pub operation: Option<String>,
162    pub provider: String,
163    pub request_model: String,
164    pub input_tokens: Option<u64>,
165    pub output_tokens: Option<u64>,
166}
167
168/// A record the projection does not know (3.5.6): never a guessed span.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct ProjectionUnknown {
171    pub what: String,
172}
173
174impl fmt::Display for ProjectionUnknown {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        write!(f, "projection_unknown: {}", self.what)
177    }
178}
179
180impl std::error::Error for ProjectionUnknown {}
181
182fn unknown(what: impl Into<String>) -> ProjectionUnknown {
183    ProjectionUnknown { what: what.into() }
184}
185
186/// The wire name of a unit enum variant.
187fn name_of<T: Serialize>(v: &T) -> String {
188    match serde_json::to_value(v) {
189        Ok(serde_json::Value::String(s)) => s,
190        _ => "unknown".to_string(),
191    }
192}
193
194/// A count or duration as an attribute value: an integer, or, beyond
195/// `i64`, its exact decimal string (never converted to a float).
196fn num(n: u64) -> TraceValue {
197    match i64::try_from(n) {
198        Ok(i) => TraceValue::Int(i),
199        Err(_) => TraceValue::Str(n.to_string()),
200    }
201}
202
203fn strs(items: &[String]) -> TraceValue {
204    TraceValue::StrArray(items.to_vec())
205}
206
207fn ms_to_nanos(ms: u64) -> u64 {
208    ms.saturating_mul(1_000_000)
209}
210
211/// Collects one span's attributes and events through the policy.
212struct SpanBuilder<'r> {
213    redactor: &'r Redactor,
214    bound: usize,
215    attributes: BTreeMap<String, TraceAttr>,
216    events: Vec<TraceEvent>,
217    withheld: BTreeSet<String>,
218    attributes_cut: u64,
219    events_cut: u64,
220}
221
222impl<'r> SpanBuilder<'r> {
223    fn new(redactor: &'r Redactor, bound: usize) -> Self {
224        SpanBuilder {
225            redactor,
226            bound,
227            attributes: BTreeMap::new(),
228            events: Vec::new(),
229            withheld: BTreeSet::new(),
230            attributes_cut: 0,
231            events_cut: 0,
232        }
233    }
234
235    /// Apply the policy and the value bound to one value of `field`.
236    fn redact(&mut self, field: &str, class: FieldClass, value: &TraceValue) -> Option<TraceAttr> {
237        match self.redactor.apply(field, class, value) {
238            Applied::Dropped => {
239                self.withheld.insert(withheld_name(field).to_string());
240                None
241            }
242            Applied::Kept(mut a) => {
243                let (v, cut) = truncate_value(&a.v, self.bound);
244                if cut {
245                    self.attributes_cut += 1;
246                    a.v = v;
247                    if a.r == Mark::Allow {
248                        a.r = Mark::Truncated;
249                    }
250                }
251                Some(a)
252            }
253        }
254    }
255
256    fn put(&mut self, field: &str, class: FieldClass, value: TraceValue) {
257        if let Some(a) = self.redact(field, class, &value) {
258            self.attributes.insert(field.to_string(), a);
259        }
260    }
261
262    /// A fixed field (3.4.4): always exported as written.
263    fn put_fixed(&mut self, field: &str, value: TraceValue) {
264        self.attributes.insert(
265            field.to_string(),
266            TraceAttr {
267                v: value,
268                r: Mark::Allow,
269            },
270        );
271    }
272
273    /// One event; its attributes are catalogued as `<event>.<key>`.
274    fn event(&mut self, name: &str, time: u64, attrs: Vec<(&str, FieldClass, TraceValue)>) {
275        if self.events.len() >= MAX_EVENTS_PER_SPAN {
276            self.events_cut += 1;
277            return;
278        }
279        let mut out = BTreeMap::new();
280        for (key, class, value) in attrs {
281            let field = format!("{name}.{key}");
282            if let Some(a) = self.redact(&field, class, &value) {
283                out.insert(key.to_string(), a);
284            }
285        }
286        self.events.push(TraceEvent {
287            name: name.to_string(),
288            time_unix_nanos: time,
289            attributes: out,
290        });
291    }
292}
293
294/// The span being built, before it becomes a line.
295struct Draft<'r> {
296    name: &'static str,
297    span_id: [u8; 8],
298    parent: Option<[u8; 8]>,
299    kind: SpanKind,
300    start: u64,
301    end: u64,
302    status: SpanStatus,
303    b: SpanBuilder<'r>,
304}
305
306/// Decision-level context the steps read.
307struct Ctx<'a> {
308    evidence: Option<&'a EvidenceRecord>,
309    plan: Option<&'a Plan>,
310    anchor_kind: AnchorKind,
311}
312
313impl Ctx<'_> {
314    fn step_body(&self, step: &str) -> Option<&StepBody> {
315        self.plan?
316            .definition
317            .steps
318            .iter()
319            .find(|s| s.id == step)
320            .map(|s| &s.body)
321    }
322
323    fn step_kind(&self, step: &str) -> Result<String, ProjectionUnknown> {
324        if self.plan.is_none() {
325            return Ok("unknown".into());
326        }
327        match self.step_body(step) {
328            Some(StepBody::Acquire(_)) => Err(unknown(format!("step kind acquire ({step})"))),
329            Some(StepBody::Widen(_)) => Err(unknown(format!("step kind widen ({step})"))),
330            Some(StepBody::Rank(_)) => Err(unknown(format!("step kind rank ({step})"))),
331            Some(StepBody::Select(_)) => Err(unknown(format!("step kind select ({step})"))),
332            Some(StepBody::Generate(_)) => Err(unknown(format!("step kind generate ({step})"))),
333            Some(StepBody::Extract(_)) => Err(unknown(format!("step kind extract ({step})"))),
334            Some(StepBody::AcceptModelDerived(_)) => {
335                Err(unknown(format!("step kind accept_model_derived ({step})")))
336            }
337            Some(StepBody::Exact(_)) => Ok("exact".into()),
338            Some(StepBody::Semantic(s)) => Ok(operation_name(s.operation).into()),
339            Some(StepBody::Corroborate(_)) => {
340                Err(unknown(format!("step kind corroborate ({step})")))
341            }
342            None => Err(unknown(format!(
343                "step {step} is not in the registered plan"
344            ))),
345        }
346    }
347
348    fn operation(&self, step: &str) -> Option<&'static str> {
349        match self.step_body(step)? {
350            StepBody::Semantic(s) => Some(operation_name(s.operation)),
351            _ => None,
352        }
353    }
354
355    /// The provenance classes of the judgment lineage's inputs; an input
356    /// with no recorded provenance counts as the most restrictive class.
357    fn lineage_inputs(&self) -> Vec<ProvenanceClass> {
358        let Some(ev) = self.evidence else {
359            return vec![];
360        };
361        ev.judgment
362            .lineage
363            .inputs
364            .iter()
365            .map(|field| {
366                ev.inputs
367                    .iter()
368                    .find(|i| &i.field == field)
369                    .and_then(|i| match &i.status {
370                        InputStatus::Resolved { provenance, .. } => Some(*provenance),
371                        InputStatus::Unresolved(_) => None,
372                    })
373                    .unwrap_or(ProvenanceClass::AttributedClaim)
374            })
375            .collect()
376    }
377}
378
379fn operation_name(op: Operation) -> &'static str {
380    match op {
381        Operation::Classify => "classify",
382        Operation::Proposition => "proposition",
383        Operation::Rubric => "rubric",
384        Operation::Rank => "rank",
385        Operation::Generate => "generate",
386        Operation::Extract => "extract",
387    }
388}
389
390/// The variant name, its fields or facts, and its detail (3.5.4).
391fn unresolved_parts(u: &Unresolved) -> (&'static str, Option<&[String]>, Option<&str>) {
392    let fields = match u {
393        Unresolved::MissingEvidence { fields }
394        | Unresolved::StaleEvidence { fields }
395        | Unresolved::InvalidInput { fields, .. }
396        | Unresolved::InsufficientGrade { fields } => Some(fields.as_slice()),
397        Unresolved::Conflict { facts } => Some(facts.as_slice()),
398        _ => None,
399    };
400    let detail = match u {
401        Unresolved::InvalidInput { detail, .. }
402        | Unresolved::BackendUnavailable { detail }
403        | Unresolved::InvalidBackendOutput { detail } => Some(detail.as_str()),
404        _ => None,
405    };
406    (u.kind().name(), fields, detail)
407}
408
409/// An outcome parameter as an attribute value, and whether it is a boolean
410/// or enum (3.3.2). Scalars keep their kind; a composite value is its
411/// record canonical JSON, never converted.
412fn param_value(v: &OutValue) -> (TraceValue, bool) {
413    match v {
414        OutValue::Bool(b) => (TraceValue::Bool(*b), true),
415        OutValue::Enum(s) => (TraceValue::Str(s.clone()), true),
416        OutValue::Integer(i) => (TraceValue::Int(*i), false),
417        OutValue::Text(s) => (TraceValue::Str(s.clone()), false),
418        OutValue::Decimal(d) => (TraceValue::Str(d.to_string()), false),
419        OutValue::Timestamp(t) => (TraceValue::Int(t.as_ms()), false),
420        OutValue::Duration(d) => (num(d.0), false),
421        other => (
422            TraceValue::Str(
423                rustev_contract::canonical::record_canonical_bytes(other)
424                    .ok()
425                    .and_then(|b| String::from_utf8(b).ok())
426                    .unwrap_or_else(|| "null".into()),
427            ),
428            false,
429        ),
430    }
431}
432
433/// Project one delivered run record (3.5). A record schema, judgment
434/// outcome or step kind this schema version does not know is
435/// [`ProjectionUnknown`].
436pub fn project(
437    input: &ProjectionInput<'_>,
438    redactor: &Redactor,
439    ids: &mut dyn IdSource,
440) -> Result<Projected, ProjectionUnknown> {
441    let rec = input.record;
442    if ![schema::RUN, schema::RUN_V2, schema::RUN_V3].contains(&rec.schema.as_str()) {
443        return Err(unknown(format!("run record {}", rec.schema)));
444    }
445    let evidence = match &rec.core {
446        CoreEvidence::Recorded(ev) => {
447            if ![schema::EVIDENCE, schema::EVIDENCE_V2].contains(&ev.schema.as_str())
448                || ![schema::JUDGMENT, schema::JUDGMENT_V2].contains(&ev.judgment.schema.as_str())
449            {
450                return Err(unknown(format!(
451                    "{} with {}",
452                    ev.schema, ev.judgment.schema
453                )));
454            }
455            Some(ev.as_ref())
456        }
457        CoreEvidence::NotProduced => None,
458    };
459    if let Some(ev) = evidence
460        && matches!(
461            ev.judgment.outcome,
462            Outcome::NeedsInput(_) | Outcome::UnresolvedAction { .. }
463        )
464    {
465        return Err(unknown("judgment outcome of a later spec"));
466    }
467    if let Some(p) = input.plan
468        && ![schema::PLAN, schema::PLAN_V3].contains(&p.schema.as_str())
469    {
470        return Err(unknown(format!("plan {}", p.schema)));
471    }
472
473    let a = input.anchor.unix_nanos;
474    let root_start = a.saturating_add(ms_to_nanos(rec.timing.queued_ms));
475    let root_end = a
476        .saturating_add(ms_to_nanos(rec.timing.elapsed_ms))
477        .max(root_start);
478    let ctx = Ctx {
479        evidence,
480        plan: input.plan,
481
482        anchor_kind: input.anchor.kind,
483    };
484    let mut facts = Facts {
485        termination: name_of(&rec.termination),
486        plan_id: rec.plan_id.to_string(),
487        elapsed_ms: rec.timing.elapsed_ms,
488        final_cost: name_of(&rec.cost.final_cost),
489        cost_units: rec.cost.observed.saturating_add(rec.cost.estimated),
490        ..Facts::default()
491    };
492
493    if let Some(entries) = evidence.and_then(|e| e.screening.as_ref()) {
494        use rustev_contract::screening::ScreenVerdict;
495        facts.screening = entries
496            .iter()
497            .map(|e| {
498                let verdict = match e.verdict {
499                    ScreenVerdict::Pass => "pass",
500                    ScreenVerdict::Blocked => "blocked",
501                    ScreenVerdict::Unavailable { .. } => "unavailable",
502                    ScreenVerdict::Empty => "empty",
503                };
504                (e.entry.clone(), verdict.into())
505            })
506            .collect();
507    }
508    if evidence.is_some_and(|e| e.screening.is_some())
509        || input.plan.is_some_and(|p| p.screening.is_some())
510        || rec.requests.iter().any(|r| r.screen.is_some())
511    {
512        facts.screening_units = Some(
513            rec.requests
514                .iter()
515                .filter_map(|r| r.screen.as_ref())
516                .fold(0u64, |sum, r| sum.saturating_add(r.units.charged)),
517        );
518    }
519
520    // The root.
521    let mut root = Draft {
522        name: span::DECISION,
523        span_id: input.root.span_id,
524        parent: input.root.parent_span_id,
525        kind: SpanKind::Internal,
526        start: root_start,
527        end: root_end,
528        status: SpanStatus::Unset,
529        b: SpanBuilder::new(redactor, input.value_bound),
530    };
531    root_attributes(&mut root.b, rec, evidence, &ctx, &mut facts, root_start);
532
533    // Steps: one per evidence entry, then any request with no entry (a
534    // decision whose evidence was not produced still ran its requests).
535    let mut drafts: Vec<Draft<'_>> = vec![root];
536    let mut used = vec![false; rec.requests.len()];
537    let mut order: Vec<(Option<&StepEvidence>, Option<&RequestRecord>)> = vec![];
538    for se in evidence.map(|e| e.steps.as_slice()).unwrap_or(&[]) {
539        let req = rec
540            .requests
541            .iter()
542            .enumerate()
543            .find(|(i, r)| !used[*i] && r.step == se.step && r.instance == se.instance);
544        if let Some((i, _)) = req {
545            used[i] = true;
546        }
547        order.push((Some(se), req.map(|(_, r)| r)));
548    }
549    for (i, r) in rec.requests.iter().enumerate() {
550        if !used[i] {
551            order.push((None, Some(r)));
552        }
553    }
554    for (se, req) in order {
555        let step_id = se.map(|s| s.step.as_str()).or(req.map(|r| r.step.as_str()));
556        let step_id = step_id.unwrap_or_default();
557        let kind = ctx.step_kind(step_id)?;
558        let step_span = ids.span_id();
559        let step_index = drafts.len();
560        let mut step = Draft {
561            name: span::STEP,
562            span_id: step_span,
563            parent: Some(input.root.span_id),
564            kind: SpanKind::Internal,
565            start: root_end,
566            end: root_end,
567            status: SpanStatus::Unset,
568            b: SpanBuilder::new(redactor, input.value_bound),
569        };
570        step_attributes(&mut step.b, se, req, &kind, &ctx, step_id, &mut facts);
571        drafts.push(step);
572        let mut window: Option<(u64, u64)> = None;
573        if let Some(r) = req {
574            for (n, att) in r.attempts.iter().enumerate() {
575                let start = a.saturating_add(ms_to_nanos(att.dispatched_ms));
576                let end = a.saturating_add(ms_to_nanos(att.ended_ms)).max(start);
577                window = Some(match window {
578                    None => (start, end),
579                    Some((s, e)) => (s.min(start), e.max(end)),
580                });
581                let retry_index = r.attempts[..n]
582                    .iter()
583                    .filter(|p| p.target == att.target)
584                    .count() as u64;
585                let mut d = Draft {
586                    name: span::ATTEMPT,
587                    span_id: ids.span_id(),
588                    parent: Some(step_span),
589                    kind: SpanKind::Internal,
590                    start,
591                    end,
592                    status: SpanStatus::Unset,
593                    b: SpanBuilder::new(redactor, input.value_bound),
594                };
595                let line = drafts.len();
596                let genai =
597                    attempt_attributes(&mut d, att, r, retry_index, ctx.operation(step_id), input);
598                facts.attempts.push(AttemptFacts {
599                    line,
600                    step_kind: kind.clone(),
601                    end: attempt_end_name(&att.end).to_string(),
602                    backend_id: att.backend_id.clone(),
603                    duration_ms: att.ended_ms.saturating_sub(att.dispatched_ms),
604                    genai,
605                });
606                drafts.push(d);
607            }
608            // Transitions on the step, at its start.
609            let at = window.map(|w| w.0).unwrap_or(root_end);
610            for t in &r.transitions {
611                transition_event(&mut drafts[step_index].b, t, at);
612                if matches!(t, Transition::Fallback { .. }) {
613                    facts.fallbacks += 1;
614                }
615            }
616        }
617        let s = &mut drafts[step_index];
618        match window {
619            Some((start, end)) => {
620                s.start = start;
621                s.end = end;
622            }
623            None => {
624                s.b.put("rustev.timing", FieldClass::Enum, "not_recorded".into())
625            }
626        }
627    }
628
629    // The policy span, last.
630    let mut policy = Draft {
631        name: span::POLICY,
632        span_id: ids.span_id(),
633        parent: Some(input.root.span_id),
634        kind: SpanKind::Internal,
635        start: root_end,
636        end: root_end,
637        status: SpanStatus::Unset,
638        b: SpanBuilder::new(redactor, input.value_bound),
639    };
640    policy
641        .b
642        .put("rustev.timing", FieldClass::Enum, "not_recorded".into());
643    if let Some(ev) = evidence {
644        policy_attributes(&mut policy.b, ev, &ctx, root_end);
645    }
646    drafts.push(policy);
647
648    // Lines.
649    let trace_id = hex(&input.root.trace_id);
650    let mut out = Projected {
651        lines: Vec::with_capacity(drafts.len()),
652        attributes_cut: 0,
653        events_cut: 0,
654        facts,
655    };
656    for (i, mut d) in drafts.into_iter().enumerate() {
657        if i == 0 {
658            d.b.put_fixed(
659                fixed::POLICY_ID,
660                TraceValue::Str(redactor.policy_id().to_string()),
661            );
662            d.b.put_fixed(
663                fixed::KEY_ID,
664                TraceValue::Str(redactor.key_id().to_string()),
665            );
666        }
667        let line = finish(d, &trace_id, i == 0, &mut out)?;
668        out.lines.push(line);
669    }
670    Ok(out)
671}
672
673/// Turn a draft into a line, cutting events (then long values) until the
674/// line fits 64 KiB; every cut is counted.
675fn finish(
676    mut d: Draft<'_>,
677    trace_id: &str,
678    is_root: bool,
679    out: &mut Projected,
680) -> Result<TraceLine, ProjectionUnknown> {
681    loop {
682        if d.b.events_cut > 0 {
683            d.b.put("rustev.events_cut", FieldClass::Number, num(d.b.events_cut));
684        }
685        let mut attributes = d.b.attributes.clone();
686        attributes.insert(
687            fixed::WITHHELD.to_string(),
688            TraceAttr {
689                v: TraceValue::StrArray(d.b.withheld.iter().cloned().collect()),
690                r: Mark::Allow,
691            },
692        );
693        let line = TraceLine {
694            schema: TRACE.to_string(),
695            schema_version: SPAN_SCHEMA_VERSION,
696            trace_id: trace_id.to_string(),
697            span_id: hex(&d.span_id),
698            parent_span_id: d.parent.map(|p| hex(&p)),
699            name: d.name.to_string(),
700            kind: d.kind,
701            start_unix_nanos: d.start,
702            end_unix_nanos: d.end,
703            status: d.status,
704            attributes,
705            events: d.b.events.clone(),
706            resource: is_root.then(resource),
707        };
708        match line.to_line() {
709            Ok(_) => {
710                out.attributes_cut += d.b.attributes_cut;
711                out.events_cut += d.b.events_cut;
712                return Ok(line);
713            }
714            Err(TraceLineError::TooLong { .. }) if !d.b.events.is_empty() => {
715                let drop = d.b.events.len().div_ceil(2);
716                d.b.events.truncate(d.b.events.len() - drop);
717                d.b.events_cut += drop as u64;
718            }
719            Err(TraceLineError::TooLong { .. }) => {
720                // Only attributes are left: cut every long string to 256
721                // bytes. Values are already bounded, so this terminates.
722                let mut changed = false;
723                for a in d.b.attributes.values_mut() {
724                    let (v, cut) = truncate_value(&a.v, 256);
725                    if cut {
726                        a.v = v;
727                        if a.r == Mark::Allow {
728                            a.r = Mark::Truncated;
729                        }
730                        d.b.attributes_cut += 1;
731                        changed = true;
732                    }
733                }
734                if !changed {
735                    return Err(unknown("a span that cannot fit one line"));
736                }
737            }
738            Err(e) => return Err(unknown(format!("span line: {e}"))),
739        }
740    }
741}
742
743fn resource() -> TraceResource {
744    let mut attributes = BTreeMap::new();
745    attributes.insert(
746        fixed::SCHEMA_VERSION.to_string(),
747        TraceAttr {
748            v: TraceValue::Int(i64::from(SPAN_SCHEMA_VERSION)),
749            r: Mark::Allow,
750        },
751    );
752    TraceResource {
753        attributes,
754        schema_url: SCHEMA_URL.to_string(),
755        scope: TraceScope {
756            name: SCOPE_NAME.to_string(),
757            version: env!("CARGO_PKG_VERSION").to_string(),
758            schema_url: SCHEMA_URL.to_string(),
759        },
760    }
761}
762
763fn root_attributes(
764    b: &mut SpanBuilder<'_>,
765    rec: &RunRecord,
766    evidence: Option<&EvidenceRecord>,
767    ctx: &Ctx<'_>,
768    facts: &mut Facts,
769    root_start: u64,
770) {
771    use FieldClass::*;
772    b.put("rustev.decision.id", Id, rec.decision_id.as_str().into());
773    b.put("rustev.plan.id", PlanMetadata, rec.plan_id.as_str().into());
774    b.put(
775        "rustev.execution.policy_id",
776        PlanMetadata,
777        match &rec.execution {
778            RecordedExecution::None => "none".into(),
779            RecordedExecution::Declared(id) => id.as_str().into(),
780        },
781    );
782    b.put("rustev.termination", Enum, facts.termination.clone().into());
783    match evidence {
784        None => {
785            b.put("rustev.outcome", Enum, "not_produced".into());
786            facts.outcome = "not_produced".into();
787        }
788        Some(ev) => {
789            b.put(
790                "rustev.definition.id",
791                PlanMetadata,
792                ev.definition_id.as_str().into(),
793            );
794            b.put(
795                "rustev.snapshot.id",
796                ContentDigest,
797                ev.snapshot_id.as_str().into(),
798            );
799            let j = &ev.judgment;
800            let outcome = match &j.outcome {
801                Outcome::Propose { action, params } => {
802                    b.put(
803                        "rustev.outcome.action",
804                        PlanMetadata,
805                        action.as_str().into(),
806                    );
807                    let inputs = ctx.lineage_inputs();
808                    for (name, v) in params {
809                        let (value, bool_or_enum) = param_value(v);
810                        let class = lineage_class(bool_or_enum, inputs.iter().copied());
811                        b.put(&format!("{PARAM_PREFIX}{name}"), class, value);
812                    }
813                    "propose"
814                }
815                Outcome::Escalate { reason } => {
816                    b.put(
817                        "rustev.outcome.escalate_reason",
818                        PlanMetadata,
819                        reason.as_str().into(),
820                    );
821                    "escalate"
822                }
823                Outcome::Unresolved(u) => {
824                    let (kind, fields, detail) = unresolved_parts(u);
825                    b.put("rustev.outcome.unresolved_reason", Enum, kind.into());
826                    facts.outcome_unresolved_reason = Some(kind.into());
827                    if let Some(f) = fields {
828                        b.put("rustev.outcome.unresolved_fields", PlanMetadata, strs(f));
829                    }
830                    if let Some(d) = detail {
831                        b.put("rustev.outcome.unresolved_detail", FreeTextDetail, d.into());
832                    }
833                    "unresolved"
834                }
835                Outcome::Refused { .. } => "refused",
836                // `project` returns before any span is built for
837                // `NeedsInput` and `UnresolvedAction` (the early check above),
838                // so this arm is never reached; `Refused` has its own arm.
839                Outcome::NeedsInput(_) | Outcome::UnresolvedAction { .. } => "unknown",
840            };
841            facts.outcome = outcome.into();
842            b.put("rustev.outcome", Enum, outcome.into());
843            b.put("rustev.derivation", Enum, name_of(&j.derivation).into());
844            b.put(
845                "rustev.lineage.inputs",
846                PlanMetadata,
847                strs(&j.lineage.inputs),
848            );
849            b.put("rustev.lineage.steps", PlanMetadata, strs(&j.lineage.steps));
850            for i in &ev.inputs {
851                let mut attrs = vec![("field", PlanMetadata, i.field.as_str().into())];
852                match &i.status {
853                    InputStatus::Resolved {
854                        provenance,
855                        as_of_ms,
856                        source,
857                    } => {
858                        attrs.push(("status", Enum, "resolved".into()));
859                        attrs.push(("provenance", Enum, name_of(provenance).into()));
860                        attrs.push(("as_of_ms", Number, TraceValue::Int(as_of_ms.as_ms())));
861                        attrs.push(("source", Id, source.as_str().into()));
862                    }
863                    InputStatus::Unresolved(u) => {
864                        attrs.push(("status", Enum, "unresolved".into()));
865                        attrs.push(("unresolved_reason", Enum, u.kind().name().into()));
866                    }
867                }
868                b.event(event::INPUT, root_start, attrs);
869            }
870            for n in &j.notices {
871                b.event(
872                    event::NOTICE,
873                    root_start,
874                    vec![
875                        ("kind", Enum, name_of(&n.kind).into()),
876                        ("subject", PlanMetadata, n.subject.as_str().into()),
877                        ("reason", PlanMetadata, n.reason.as_str().into()),
878                    ],
879                );
880            }
881        }
882    }
883    let c = &rec.cost;
884    b.put("rustev.cost.mode", Enum, name_of(&c.mode).into());
885    b.put("rustev.cost.final", Enum, name_of(&c.final_cost).into());
886    b.put("rustev.cost.limit", Number, num(c.limit));
887    b.put("rustev.cost.observed", Number, num(c.observed));
888    b.put("rustev.cost.estimated", Number, num(c.estimated));
889    b.put("rustev.cost.liability", Number, num(c.liability));
890    b.put(
891        "rustev.cost.bound_violations",
892        Number,
893        num(c.bound_violations),
894    );
895    b.put(
896        "rustev.cost.within_guaranteed_cap",
897        Enum,
898        c.within_guaranteed_cap.into(),
899    );
900    let t = &rec.timing;
901    b.put("rustev.timing.queued_ms", Number, num(t.queued_ms));
902    b.put("rustev.timing.elapsed_ms", Number, num(t.elapsed_ms));
903    b.put("rustev.timing.deadline_ms", Number, num(t.deadline_ms));
904    b.put(
905        "rustev.timing.deadline_expired",
906        Enum,
907        t.deadline_expired.into(),
908    );
909    b.put(
910        "rustev.deadline.remaining_ms",
911        Number,
912        num(t.deadline_ms.saturating_sub(t.elapsed_ms)),
913    );
914    b.put("rustev.time_anchor", Enum, ctx.anchor_kind.name().into());
915}
916
917fn step_attributes(
918    b: &mut SpanBuilder<'_>,
919    se: Option<&StepEvidence>,
920    req: Option<&RequestRecord>,
921    kind: &str,
922    ctx: &Ctx<'_>,
923    step_id: &str,
924    facts: &mut Facts,
925) {
926    use FieldClass::*;
927    b.put("rustev.step.id", PlanMetadata, step_id.into());
928    let instance = se.map(|s| &s.instance).or(req.map(|r| &r.instance));
929    if let Some(inst) = instance.filter(|i| !i.is_empty()) {
930        b.put("rustev.step.instance", Id, strs(inst));
931    }
932    b.put("rustev.step.kind", Enum, kind.into());
933    if let Some(p) = ctx.plan
934        && let Some(ps) = p.steps.iter().find(|s| s.id == step_id)
935    {
936        match &ps.detail {
937            PlanStepDetail::Exact { op, version } => b.put(
938                "rustev.step.operator",
939                PlanMetadata,
940                format!("{op}@{version}").into(),
941            ),
942            PlanStepDetail::Semantic(s) => b.put(
943                "rustev.step.backend_id",
944                PlanMetadata,
945                s.backend_id.as_str().into(),
946            ),
947            PlanStepDetail::Unsupported { .. }
948            | PlanStepDetail::Corroborate
949            | PlanStepDetail::Acquire
950            | PlanStepDetail::Widen
951            | PlanStepDetail::Generate(_)
952            | PlanStepDetail::Extract(_)
953            | PlanStepDetail::AcceptModelDerived(_)
954            | PlanStepDetail::Rank(_)
955            | PlanStepDetail::Select(_) => {}
956        }
957    }
958    if let Some(s) = se {
959        match &s.status {
960            StepStatus::Skipped { .. } => b.put("rustev.step.status", Enum, "skipped".into()),
961            StepStatus::Resolved { kind, derivation } => {
962                b.put("rustev.step.status", Enum, "resolved".into());
963                b.put("rustev.step.value_kind", Enum, kind.as_str().into());
964                b.put("rustev.step.derivation", Enum, name_of(derivation).into());
965            }
966            StepStatus::Unresolved(u) => {
967                b.put("rustev.step.status", Enum, "unresolved".into());
968                let k = u.kind().name();
969                b.put("rustev.step.unresolved_reason", Enum, k.into());
970                facts.step_unresolved_reasons.push(k.into());
971            }
972        }
973    }
974    if let Some(r) = req {
975        match &r.result {
976            RequestResult::Output { target } => {
977                b.put("rustev.step.result", Enum, "output".into());
978                b.put("rustev.step.target", Number, num(u64::from(*target)));
979            }
980            RequestResult::Failed(_) => b.put("rustev.step.result", Enum, "failed".into()),
981            RequestResult::Withdrawn => {}
982            RequestResult::NotSupplied => b.put("rustev.step.result", Enum, "not_supplied".into()),
983        }
984        if let Some(o) = &r.optimization {
985            b.put(
986                "rustev.step.optimization.outcome",
987                Enum,
988                name_of(&o.outcome).into(),
989            );
990            b.put(
991                "rustev.step.optimization.mechanism",
992                Enum,
993                name_of(&o.mechanism).into(),
994            );
995        }
996        if let Some(from) = r.supplied_from()
997            && let Some(id) = from.reused_from
998        {
999            b.put("rustev.step.reused_from", Id, id.into());
1000        }
1001    }
1002}
1003
1004fn transition_event(b: &mut SpanBuilder<'_>, t: &Transition, at: u64) {
1005    use FieldClass::*;
1006    let attrs = match t {
1007        Transition::Retry {
1008            after,
1009            class,
1010            delay_ms,
1011        } => vec![
1012            ("kind", Enum, "retry".into()),
1013            ("class", Enum, class.name().into()),
1014            ("after", Number, num(u64::from(*after))),
1015            ("delay_ms", Number, num(*delay_ms)),
1016        ],
1017        Transition::Fallback { after, class, to } => vec![
1018            ("kind", Enum, "fallback".into()),
1019            ("class", Enum, class.name().into()),
1020            ("after", Number, num(u64::from(*after))),
1021            ("to", Number, num(u64::from(*to))),
1022        ],
1023        Transition::Stop { after, reason } => vec![
1024            ("kind", Enum, "stop".into()),
1025            ("after", Number, num(u64::from(*after))),
1026            ("reason", FreeTextDetail, reason.as_str().into()),
1027        ],
1028    };
1029    b.event(event::TRANSITION, at, attrs);
1030}
1031
1032fn attempt_end_name(e: &AttemptEnd) -> &'static str {
1033    match e {
1034        AttemptEnd::Output => "output",
1035        AttemptEnd::Failed { .. } => "failed",
1036        AttemptEnd::Deadline => "deadline",
1037        AttemptEnd::Cancelled => "cancelled",
1038    }
1039}
1040
1041/// The attempt's attributes; returns its GenAI facts when it has them.
1042fn attempt_attributes(
1043    d: &mut Draft<'_>,
1044    att: &AttemptRecord,
1045    req: &RequestRecord,
1046    retry_index: u64,
1047    operation: Option<&'static str>,
1048    input: &ProjectionInput<'_>,
1049) -> Option<GenAiFacts> {
1050    use FieldClass::*;
1051    let b = &mut d.b;
1052    b.put("rustev.attempt.id", Id, att.attempt_id.as_str().into());
1053    if let Some(n) = att
1054        .attempt_id
1055        .rsplit('/')
1056        .next()
1057        .and_then(|n| n.parse::<u64>().ok())
1058    {
1059        b.put("rustev.attempt.ordinal", Number, num(n));
1060    }
1061    b.put("rustev.attempt.retry_index", Number, num(retry_index));
1062    b.put("rustev.attempt.target", Number, num(u64::from(att.target)));
1063    b.put(
1064        "rustev.attempt.backend_id",
1065        PlanMetadata,
1066        att.backend_id.as_str().into(),
1067    );
1068    b.put(
1069        "rustev.attempt.artifact",
1070        PlanMetadata,
1071        att.artifact.as_str().into(),
1072    );
1073    b.put(
1074        "rustev.attempt.end",
1075        Enum,
1076        attempt_end_name(&att.end).into(),
1077    );
1078    match &att.end {
1079        AttemptEnd::Failed { class, detail } => {
1080            b.put("rustev.attempt.failure_class", Enum, class.name().into());
1081            b.put("error.type", Enum, class.name().into());
1082            b.put(
1083                "rustev.attempt.failure_detail",
1084                FreeTextDetail,
1085                detail.as_str().into(),
1086            );
1087            d.status = SpanStatus::Error;
1088        }
1089        AttemptEnd::Deadline => {
1090            b.put("error.type", Enum, "deadline".into());
1091            d.status = SpanStatus::Error;
1092        }
1093        AttemptEnd::Output | AttemptEnd::Cancelled => {}
1094    }
1095    b.put(
1096        "rustev.attempt.cancel",
1097        Enum,
1098        match att.cancellation {
1099            Cancellation::NotRequested => "not_requested".into(),
1100            Cancellation::Requested { answer } => name_of(&answer).into(),
1101        },
1102    );
1103    b.put(
1104        "rustev.attempt.remote_state",
1105        Enum,
1106        name_of(&att.remote).into(),
1107    );
1108    let (bound, _) = match att.cost.bound {
1109        CostBound::Bounded { max_units } => ("bounded", Some(max_units)),
1110        CostBound::Estimated { units } => ("estimated", Some(units)),
1111        CostBound::Unknown => ("unknown", None),
1112    };
1113    b.put("rustev.attempt.cost.bound", Enum, bound.into());
1114    b.put(
1115        "rustev.attempt.cost.reserved",
1116        Number,
1117        num(att.cost.reserved),
1118    );
1119    let (charge, units) = match att.cost.charge {
1120        Charge::Observed { units } => ("observed", Some(units)),
1121        Charge::Estimated { units } => ("estimated", Some(units)),
1122        Charge::Unknown => ("unknown", None),
1123    };
1124    b.put("rustev.attempt.charge_kind", Enum, charge.into());
1125    if let Some(u) = units {
1126        b.put("rustev.attempt.charge_units", Number, num(u));
1127    }
1128    if let Some(o) = &req.optimization {
1129        if let Some(id) = &o.batch_id {
1130            b.put("rustev.batch.id", Id, id.as_str().into());
1131        }
1132        if let Some(m) = o.batch_member {
1133            b.put("rustev.batch.member", Number, num(u64::from(m)));
1134        }
1135    }
1136
1137    // GenAI attributes (3.5.4): only for an attempt that joined an exchange
1138    // record or whose backend id the host mapped to a provider name.
1139    let exchange = input.exchanges.get(&att.attempt_id);
1140    let provider = input.bindings.providers.get(&att.backend_id);
1141    if exchange.is_none() && provider.is_none() {
1142        return None;
1143    }
1144    d.kind = SpanKind::Client;
1145    let provider = provider.cloned().unwrap_or_else(|| "unknown".into());
1146    b.put(
1147        "gen_ai.provider.name",
1148        ModelIdentity,
1149        provider.as_str().into(),
1150    );
1151    if let Some(op) = operation {
1152        b.put("gen_ai.operation.name", Enum, op.into());
1153    }
1154    let request_model = exchange
1155        .and_then(|x| x.requested_model.clone())
1156        .unwrap_or_else(|| att.artifact.to_string());
1157    b.put(
1158        "gen_ai.request.model",
1159        ModelIdentity,
1160        request_model.as_str().into(),
1161    );
1162    let mut facts = GenAiFacts {
1163        operation: operation.map(str::to_string),
1164        provider,
1165        request_model,
1166        input_tokens: None,
1167        output_tokens: None,
1168    };
1169    let Some(x) = exchange else {
1170        b.put("gen_ai.response.model", ModelIdentity, "unknown".into());
1171        b.put("rustev.served.disclosure", Enum, "unknown".into());
1172        return Some(facts);
1173    };
1174    let response_model = match (&x.served.disclosure, &x.served.identity) {
1175        (Disclosure::Pinned | Disclosure::Reported, Some(id)) => id.clone(),
1176        _ => "unknown".into(),
1177    };
1178    b.put(
1179        "gen_ai.response.model",
1180        ModelIdentity,
1181        response_model.into(),
1182    );
1183    b.put(
1184        "rustev.served.disclosure",
1185        Enum,
1186        name_of(&x.served.disclosure).into(),
1187    );
1188    if let Some(g) = &x.generation_id {
1189        b.put("gen_ai.response.id", Id, g.as_str().into());
1190    }
1191    if let Some(keys) = input.bindings.usage_keys.get(&att.backend_id) {
1192        // Absent, never 0, when not reported.
1193        if let Some(n) = keys.input.as_ref().and_then(|k| x.usage.get(k)) {
1194            b.put("gen_ai.usage.input_tokens", Number, num(*n));
1195            facts.input_tokens = Some(*n);
1196        }
1197        if let Some(n) = keys.output.as_ref().and_then(|k| x.usage.get(k)) {
1198            b.put("gen_ai.usage.output_tokens", Number, num(*n));
1199            facts.output_tokens = Some(*n);
1200        }
1201    }
1202    if let Some(dg) = &x.request_digest {
1203        b.put(
1204            "rustev.exchange.request_digest",
1205            ContentDigest,
1206            dg.as_str().into(),
1207        );
1208    }
1209    if let Some(dg) = &x.response_digest {
1210        b.put(
1211            "rustev.exchange.response_digest",
1212            ContentDigest,
1213            dg.as_str().into(),
1214        );
1215    }
1216    if let Some(s) = x.status {
1217        b.put(
1218            "rustev.exchange.status",
1219            Number,
1220            TraceValue::Int(i64::from(s)),
1221        );
1222    }
1223    b.put("rustev.exchange.bytes_sent", Enum, x.bytes_sent.into());
1224    b.put("rustev.exchange.late", Enum, x.late.into());
1225    b.put("rustev.exchange.truncated", Enum, x.truncated.into());
1226    if !x.route.is_empty() {
1227        b.put("rustev.exchange.route", ModelIdentity, strs(&x.route));
1228    }
1229    if let Some(c) = &x.reported_cost {
1230        b.put(
1231            "rustev.exchange.reported_cost",
1232            Number,
1233            c.amount.as_str().into(),
1234        );
1235        b.put(
1236            "rustev.exchange.reported_currency",
1237            Enum,
1238            c.currency.as_str().into(),
1239        );
1240    }
1241    if let Some(r) = &x.retry_after {
1242        b.put("rustev.exchange.retry_after", ThirdParty, r.as_str().into());
1243    }
1244    for (k, v) in &x.provider_extras {
1245        b.put(
1246            &format!("rustev.exchange.extra.{k}"),
1247            ThirdParty,
1248            v.as_str().into(),
1249        );
1250    }
1251    if let Some(m) = x.members.iter().find(|m| m.attempt_id == att.attempt_id)
1252        && let MemberOutcome::Failure { detail, .. } = &m.outcome
1253    {
1254        b.put(
1255            "rustev.exchange.member_detail",
1256            FreeTextDetail,
1257            detail.as_str().into(),
1258        );
1259    }
1260    Some(facts)
1261}
1262
1263fn policy_attributes(b: &mut SpanBuilder<'_>, ev: &EvidenceRecord, ctx: &Ctx<'_>, at: u64) {
1264    use FieldClass::*;
1265    let j = &ev.judgment;
1266    b.put("rustev.policy.rules_fired", PlanMetadata, strs(&j.trace));
1267    let Outcome::Propose { params, .. } = &j.outcome else {
1268        return;
1269    };
1270    let inputs = ctx.lineage_inputs();
1271    let mut excluded = None::<u64>;
1272    let mut truncated = None::<u64>;
1273    let mut excl = |b: &mut SpanBuilder<'_>, list: &[rustev_contract::judgment::Excluded]| {
1274        *excluded.get_or_insert(0) += list.len() as u64;
1275        for e in list {
1276            b.event(
1277                event::EXCLUDED,
1278                at,
1279                vec![
1280                    ("candidate", Id, e.id.as_str().into()),
1281                    ("reasons", PlanMetadata, strs(&e.reasons)),
1282                ],
1283            );
1284        }
1285    };
1286    for v in params.values() {
1287        match v {
1288            OutValue::Filtered(f) => excl(b, &f.excluded),
1289            OutValue::Shortlist(s) => {
1290                *truncated.get_or_insert(0) += s.truncated;
1291                excl(b, &s.excluded);
1292            }
1293            OutValue::Ranking(r) => {
1294                excl(b, &r.excluded);
1295                let class = lineage_class(false, inputs.iter().copied());
1296                for e in &r.entries {
1297                    b.event(
1298                        event::POSITION,
1299                        at,
1300                        vec![
1301                            ("position", Number, num(e.position)),
1302                            ("candidate", Id, e.candidate.as_str().into()),
1303                            ("score", class, TraceValue::Double(e.score)),
1304                        ],
1305                    );
1306                }
1307            }
1308            _ => {}
1309        }
1310    }
1311    if let Some(n) = excluded {
1312        b.put("rustev.policy.excluded_count", Number, num(n));
1313    }
1314    if let Some(n) = truncated {
1315        b.put("rustev.policy.truncated", Number, num(n));
1316    }
1317}