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::Generate(_)) => Err(unknown(format!("step kind generate ({step})"))),
331            Some(StepBody::Extract(_)) => Err(unknown(format!("step kind extract ({step})"))),
332            Some(StepBody::AcceptModelDerived(_)) => {
333                Err(unknown(format!("step kind accept_model_derived ({step})")))
334            }
335            Some(StepBody::Exact(_)) => Ok("exact".into()),
336            Some(StepBody::Semantic(s)) => Ok(operation_name(s.operation).into()),
337            Some(StepBody::Corroborate(_)) => {
338                Err(unknown(format!("step kind corroborate ({step})")))
339            }
340            None => Err(unknown(format!(
341                "step {step} is not in the registered plan"
342            ))),
343        }
344    }
345
346    fn operation(&self, step: &str) -> Option<&'static str> {
347        match self.step_body(step)? {
348            StepBody::Semantic(s) => Some(operation_name(s.operation)),
349            _ => None,
350        }
351    }
352
353    /// The provenance classes of the judgment lineage's inputs; an input
354    /// with no recorded provenance counts as the most restrictive class.
355    fn lineage_inputs(&self) -> Vec<ProvenanceClass> {
356        let Some(ev) = self.evidence else {
357            return vec![];
358        };
359        ev.judgment
360            .lineage
361            .inputs
362            .iter()
363            .map(|field| {
364                ev.inputs
365                    .iter()
366                    .find(|i| &i.field == field)
367                    .and_then(|i| match &i.status {
368                        InputStatus::Resolved { provenance, .. } => Some(*provenance),
369                        InputStatus::Unresolved(_) => None,
370                    })
371                    .unwrap_or(ProvenanceClass::AttributedClaim)
372            })
373            .collect()
374    }
375}
376
377fn operation_name(op: Operation) -> &'static str {
378    match op {
379        Operation::Classify => "classify",
380        Operation::Proposition => "proposition",
381        Operation::Rubric => "rubric",
382        Operation::Rank => "rank",
383        Operation::Generate => "generate",
384        Operation::Extract => "extract",
385    }
386}
387
388/// The variant name, its fields or facts, and its detail (3.5.4).
389fn unresolved_parts(u: &Unresolved) -> (&'static str, Option<&[String]>, Option<&str>) {
390    let fields = match u {
391        Unresolved::MissingEvidence { fields }
392        | Unresolved::StaleEvidence { fields }
393        | Unresolved::InvalidInput { fields, .. }
394        | Unresolved::InsufficientGrade { fields } => Some(fields.as_slice()),
395        Unresolved::Conflict { facts } => Some(facts.as_slice()),
396        _ => None,
397    };
398    let detail = match u {
399        Unresolved::InvalidInput { detail, .. }
400        | Unresolved::BackendUnavailable { detail }
401        | Unresolved::InvalidBackendOutput { detail } => Some(detail.as_str()),
402        _ => None,
403    };
404    (u.kind().name(), fields, detail)
405}
406
407/// An outcome parameter as an attribute value, and whether it is a boolean
408/// or enum (3.3.2). Scalars keep their kind; a composite value is its
409/// record canonical JSON, never converted.
410fn param_value(v: &OutValue) -> (TraceValue, bool) {
411    match v {
412        OutValue::Bool(b) => (TraceValue::Bool(*b), true),
413        OutValue::Enum(s) => (TraceValue::Str(s.clone()), true),
414        OutValue::Integer(i) => (TraceValue::Int(*i), false),
415        OutValue::Text(s) => (TraceValue::Str(s.clone()), false),
416        OutValue::Decimal(d) => (TraceValue::Str(d.to_string()), false),
417        OutValue::Timestamp(t) => (TraceValue::Int(t.as_ms()), false),
418        OutValue::Duration(d) => (num(d.0), false),
419        other => (
420            TraceValue::Str(
421                rustev_contract::canonical::record_canonical_bytes(other)
422                    .ok()
423                    .and_then(|b| String::from_utf8(b).ok())
424                    .unwrap_or_else(|| "null".into()),
425            ),
426            false,
427        ),
428    }
429}
430
431/// Project one delivered run record (3.5). A record schema, judgment
432/// outcome or step kind this schema version does not know is
433/// [`ProjectionUnknown`].
434pub fn project(
435    input: &ProjectionInput<'_>,
436    redactor: &Redactor,
437    ids: &mut dyn IdSource,
438) -> Result<Projected, ProjectionUnknown> {
439    let rec = input.record;
440    if ![schema::RUN, schema::RUN_V2, schema::RUN_V3].contains(&rec.schema.as_str()) {
441        return Err(unknown(format!("run record {}", rec.schema)));
442    }
443    let evidence = match &rec.core {
444        CoreEvidence::Recorded(ev) => {
445            if ![schema::EVIDENCE, schema::EVIDENCE_V2].contains(&ev.schema.as_str())
446                || ![schema::JUDGMENT, schema::JUDGMENT_V2].contains(&ev.judgment.schema.as_str())
447            {
448                return Err(unknown(format!(
449                    "{} with {}",
450                    ev.schema, ev.judgment.schema
451                )));
452            }
453            Some(ev.as_ref())
454        }
455        CoreEvidence::NotProduced => None,
456    };
457    if let Some(ev) = evidence
458        && matches!(
459            ev.judgment.outcome,
460            Outcome::NeedsInput(_) | Outcome::UnresolvedAction { .. }
461        )
462    {
463        return Err(unknown("judgment outcome of a later spec"));
464    }
465    if let Some(p) = input.plan
466        && ![schema::PLAN, schema::PLAN_V3].contains(&p.schema.as_str())
467    {
468        return Err(unknown(format!("plan {}", p.schema)));
469    }
470
471    let a = input.anchor.unix_nanos;
472    let root_start = a.saturating_add(ms_to_nanos(rec.timing.queued_ms));
473    let root_end = a
474        .saturating_add(ms_to_nanos(rec.timing.elapsed_ms))
475        .max(root_start);
476    let ctx = Ctx {
477        evidence,
478        plan: input.plan,
479
480        anchor_kind: input.anchor.kind,
481    };
482    let mut facts = Facts {
483        termination: name_of(&rec.termination),
484        plan_id: rec.plan_id.to_string(),
485        elapsed_ms: rec.timing.elapsed_ms,
486        final_cost: name_of(&rec.cost.final_cost),
487        cost_units: rec.cost.observed.saturating_add(rec.cost.estimated),
488        ..Facts::default()
489    };
490
491    if let Some(entries) = evidence.and_then(|e| e.screening.as_ref()) {
492        use rustev_contract::screening::ScreenVerdict;
493        facts.screening = entries
494            .iter()
495            .map(|e| {
496                let verdict = match e.verdict {
497                    ScreenVerdict::Pass => "pass",
498                    ScreenVerdict::Blocked => "blocked",
499                    ScreenVerdict::Unavailable { .. } => "unavailable",
500                    ScreenVerdict::Empty => "empty",
501                };
502                (e.entry.clone(), verdict.into())
503            })
504            .collect();
505    }
506    if evidence.is_some_and(|e| e.screening.is_some())
507        || input.plan.is_some_and(|p| p.screening.is_some())
508        || rec.requests.iter().any(|r| r.screen.is_some())
509    {
510        facts.screening_units = Some(
511            rec.requests
512                .iter()
513                .filter_map(|r| r.screen.as_ref())
514                .fold(0u64, |sum, r| sum.saturating_add(r.units.charged)),
515        );
516    }
517
518    // The root.
519    let mut root = Draft {
520        name: span::DECISION,
521        span_id: input.root.span_id,
522        parent: input.root.parent_span_id,
523        kind: SpanKind::Internal,
524        start: root_start,
525        end: root_end,
526        status: SpanStatus::Unset,
527        b: SpanBuilder::new(redactor, input.value_bound),
528    };
529    root_attributes(&mut root.b, rec, evidence, &ctx, &mut facts, root_start);
530
531    // Steps: one per evidence entry, then any request with no entry (a
532    // decision whose evidence was not produced still ran its requests).
533    let mut drafts: Vec<Draft<'_>> = vec![root];
534    let mut used = vec![false; rec.requests.len()];
535    let mut order: Vec<(Option<&StepEvidence>, Option<&RequestRecord>)> = vec![];
536    for se in evidence.map(|e| e.steps.as_slice()).unwrap_or(&[]) {
537        let req = rec
538            .requests
539            .iter()
540            .enumerate()
541            .find(|(i, r)| !used[*i] && r.step == se.step && r.instance == se.instance);
542        if let Some((i, _)) = req {
543            used[i] = true;
544        }
545        order.push((Some(se), req.map(|(_, r)| r)));
546    }
547    for (i, r) in rec.requests.iter().enumerate() {
548        if !used[i] {
549            order.push((None, Some(r)));
550        }
551    }
552    for (se, req) in order {
553        let step_id = se.map(|s| s.step.as_str()).or(req.map(|r| r.step.as_str()));
554        let step_id = step_id.unwrap_or_default();
555        let kind = ctx.step_kind(step_id)?;
556        let step_span = ids.span_id();
557        let step_index = drafts.len();
558        let mut step = Draft {
559            name: span::STEP,
560            span_id: step_span,
561            parent: Some(input.root.span_id),
562            kind: SpanKind::Internal,
563            start: root_end,
564            end: root_end,
565            status: SpanStatus::Unset,
566            b: SpanBuilder::new(redactor, input.value_bound),
567        };
568        step_attributes(&mut step.b, se, req, &kind, &ctx, step_id, &mut facts);
569        drafts.push(step);
570        let mut window: Option<(u64, u64)> = None;
571        if let Some(r) = req {
572            for (n, att) in r.attempts.iter().enumerate() {
573                let start = a.saturating_add(ms_to_nanos(att.dispatched_ms));
574                let end = a.saturating_add(ms_to_nanos(att.ended_ms)).max(start);
575                window = Some(match window {
576                    None => (start, end),
577                    Some((s, e)) => (s.min(start), e.max(end)),
578                });
579                let retry_index = r.attempts[..n]
580                    .iter()
581                    .filter(|p| p.target == att.target)
582                    .count() as u64;
583                let mut d = Draft {
584                    name: span::ATTEMPT,
585                    span_id: ids.span_id(),
586                    parent: Some(step_span),
587                    kind: SpanKind::Internal,
588                    start,
589                    end,
590                    status: SpanStatus::Unset,
591                    b: SpanBuilder::new(redactor, input.value_bound),
592                };
593                let line = drafts.len();
594                let genai =
595                    attempt_attributes(&mut d, att, r, retry_index, ctx.operation(step_id), input);
596                facts.attempts.push(AttemptFacts {
597                    line,
598                    step_kind: kind.clone(),
599                    end: attempt_end_name(&att.end).to_string(),
600                    backend_id: att.backend_id.clone(),
601                    duration_ms: att.ended_ms.saturating_sub(att.dispatched_ms),
602                    genai,
603                });
604                drafts.push(d);
605            }
606            // Transitions on the step, at its start.
607            let at = window.map(|w| w.0).unwrap_or(root_end);
608            for t in &r.transitions {
609                transition_event(&mut drafts[step_index].b, t, at);
610                if matches!(t, Transition::Fallback { .. }) {
611                    facts.fallbacks += 1;
612                }
613            }
614        }
615        let s = &mut drafts[step_index];
616        match window {
617            Some((start, end)) => {
618                s.start = start;
619                s.end = end;
620            }
621            None => {
622                s.b.put("rustev.timing", FieldClass::Enum, "not_recorded".into())
623            }
624        }
625    }
626
627    // The policy span, last.
628    let mut policy = Draft {
629        name: span::POLICY,
630        span_id: ids.span_id(),
631        parent: Some(input.root.span_id),
632        kind: SpanKind::Internal,
633        start: root_end,
634        end: root_end,
635        status: SpanStatus::Unset,
636        b: SpanBuilder::new(redactor, input.value_bound),
637    };
638    policy
639        .b
640        .put("rustev.timing", FieldClass::Enum, "not_recorded".into());
641    if let Some(ev) = evidence {
642        policy_attributes(&mut policy.b, ev, &ctx, root_end);
643    }
644    drafts.push(policy);
645
646    // Lines.
647    let trace_id = hex(&input.root.trace_id);
648    let mut out = Projected {
649        lines: Vec::with_capacity(drafts.len()),
650        attributes_cut: 0,
651        events_cut: 0,
652        facts,
653    };
654    for (i, mut d) in drafts.into_iter().enumerate() {
655        if i == 0 {
656            d.b.put_fixed(
657                fixed::POLICY_ID,
658                TraceValue::Str(redactor.policy_id().to_string()),
659            );
660            d.b.put_fixed(
661                fixed::KEY_ID,
662                TraceValue::Str(redactor.key_id().to_string()),
663            );
664        }
665        let line = finish(d, &trace_id, i == 0, &mut out)?;
666        out.lines.push(line);
667    }
668    Ok(out)
669}
670
671/// Turn a draft into a line, cutting events (then long values) until the
672/// line fits 64 KiB; every cut is counted.
673fn finish(
674    mut d: Draft<'_>,
675    trace_id: &str,
676    is_root: bool,
677    out: &mut Projected,
678) -> Result<TraceLine, ProjectionUnknown> {
679    loop {
680        if d.b.events_cut > 0 {
681            d.b.put("rustev.events_cut", FieldClass::Number, num(d.b.events_cut));
682        }
683        let mut attributes = d.b.attributes.clone();
684        attributes.insert(
685            fixed::WITHHELD.to_string(),
686            TraceAttr {
687                v: TraceValue::StrArray(d.b.withheld.iter().cloned().collect()),
688                r: Mark::Allow,
689            },
690        );
691        let line = TraceLine {
692            schema: TRACE.to_string(),
693            schema_version: SPAN_SCHEMA_VERSION,
694            trace_id: trace_id.to_string(),
695            span_id: hex(&d.span_id),
696            parent_span_id: d.parent.map(|p| hex(&p)),
697            name: d.name.to_string(),
698            kind: d.kind,
699            start_unix_nanos: d.start,
700            end_unix_nanos: d.end,
701            status: d.status,
702            attributes,
703            events: d.b.events.clone(),
704            resource: is_root.then(resource),
705        };
706        match line.to_line() {
707            Ok(_) => {
708                out.attributes_cut += d.b.attributes_cut;
709                out.events_cut += d.b.events_cut;
710                return Ok(line);
711            }
712            Err(TraceLineError::TooLong { .. }) if !d.b.events.is_empty() => {
713                let drop = d.b.events.len().div_ceil(2);
714                d.b.events.truncate(d.b.events.len() - drop);
715                d.b.events_cut += drop as u64;
716            }
717            Err(TraceLineError::TooLong { .. }) => {
718                // Only attributes are left: cut every long string to 256
719                // bytes. Values are already bounded, so this terminates.
720                let mut changed = false;
721                for a in d.b.attributes.values_mut() {
722                    let (v, cut) = truncate_value(&a.v, 256);
723                    if cut {
724                        a.v = v;
725                        if a.r == Mark::Allow {
726                            a.r = Mark::Truncated;
727                        }
728                        d.b.attributes_cut += 1;
729                        changed = true;
730                    }
731                }
732                if !changed {
733                    return Err(unknown("a span that cannot fit one line"));
734                }
735            }
736            Err(e) => return Err(unknown(format!("span line: {e}"))),
737        }
738    }
739}
740
741fn resource() -> TraceResource {
742    let mut attributes = BTreeMap::new();
743    attributes.insert(
744        fixed::SCHEMA_VERSION.to_string(),
745        TraceAttr {
746            v: TraceValue::Int(i64::from(SPAN_SCHEMA_VERSION)),
747            r: Mark::Allow,
748        },
749    );
750    TraceResource {
751        attributes,
752        schema_url: SCHEMA_URL.to_string(),
753        scope: TraceScope {
754            name: SCOPE_NAME.to_string(),
755            version: env!("CARGO_PKG_VERSION").to_string(),
756            schema_url: SCHEMA_URL.to_string(),
757        },
758    }
759}
760
761fn root_attributes(
762    b: &mut SpanBuilder<'_>,
763    rec: &RunRecord,
764    evidence: Option<&EvidenceRecord>,
765    ctx: &Ctx<'_>,
766    facts: &mut Facts,
767    root_start: u64,
768) {
769    use FieldClass::*;
770    b.put("rustev.decision.id", Id, rec.decision_id.as_str().into());
771    b.put("rustev.plan.id", PlanMetadata, rec.plan_id.as_str().into());
772    b.put(
773        "rustev.execution.policy_id",
774        PlanMetadata,
775        match &rec.execution {
776            RecordedExecution::None => "none".into(),
777            RecordedExecution::Declared(id) => id.as_str().into(),
778        },
779    );
780    b.put("rustev.termination", Enum, facts.termination.clone().into());
781    match evidence {
782        None => {
783            b.put("rustev.outcome", Enum, "not_produced".into());
784            facts.outcome = "not_produced".into();
785        }
786        Some(ev) => {
787            b.put(
788                "rustev.definition.id",
789                PlanMetadata,
790                ev.definition_id.as_str().into(),
791            );
792            b.put(
793                "rustev.snapshot.id",
794                ContentDigest,
795                ev.snapshot_id.as_str().into(),
796            );
797            let j = &ev.judgment;
798            let outcome = match &j.outcome {
799                Outcome::Propose { action, params } => {
800                    b.put(
801                        "rustev.outcome.action",
802                        PlanMetadata,
803                        action.as_str().into(),
804                    );
805                    let inputs = ctx.lineage_inputs();
806                    for (name, v) in params {
807                        let (value, bool_or_enum) = param_value(v);
808                        let class = lineage_class(bool_or_enum, inputs.iter().copied());
809                        b.put(&format!("{PARAM_PREFIX}{name}"), class, value);
810                    }
811                    "propose"
812                }
813                Outcome::Escalate { reason } => {
814                    b.put(
815                        "rustev.outcome.escalate_reason",
816                        PlanMetadata,
817                        reason.as_str().into(),
818                    );
819                    "escalate"
820                }
821                Outcome::Unresolved(u) => {
822                    let (kind, fields, detail) = unresolved_parts(u);
823                    b.put("rustev.outcome.unresolved_reason", Enum, kind.into());
824                    facts.outcome_unresolved_reason = Some(kind.into());
825                    if let Some(f) = fields {
826                        b.put("rustev.outcome.unresolved_fields", PlanMetadata, strs(f));
827                    }
828                    if let Some(d) = detail {
829                        b.put("rustev.outcome.unresolved_detail", FreeTextDetail, d.into());
830                    }
831                    "unresolved"
832                }
833                Outcome::Refused { .. } => "refused",
834                // `project` returns before any span is built for
835                // `NeedsInput` and `UnresolvedAction` (the early check above),
836                // so this arm is never reached; `Refused` has its own arm.
837                Outcome::NeedsInput(_) | Outcome::UnresolvedAction { .. } => "unknown",
838            };
839            facts.outcome = outcome.into();
840            b.put("rustev.outcome", Enum, outcome.into());
841            b.put("rustev.derivation", Enum, name_of(&j.derivation).into());
842            b.put(
843                "rustev.lineage.inputs",
844                PlanMetadata,
845                strs(&j.lineage.inputs),
846            );
847            b.put("rustev.lineage.steps", PlanMetadata, strs(&j.lineage.steps));
848            for i in &ev.inputs {
849                let mut attrs = vec![("field", PlanMetadata, i.field.as_str().into())];
850                match &i.status {
851                    InputStatus::Resolved {
852                        provenance,
853                        as_of_ms,
854                        source,
855                    } => {
856                        attrs.push(("status", Enum, "resolved".into()));
857                        attrs.push(("provenance", Enum, name_of(provenance).into()));
858                        attrs.push(("as_of_ms", Number, TraceValue::Int(as_of_ms.as_ms())));
859                        attrs.push(("source", Id, source.as_str().into()));
860                    }
861                    InputStatus::Unresolved(u) => {
862                        attrs.push(("status", Enum, "unresolved".into()));
863                        attrs.push(("unresolved_reason", Enum, u.kind().name().into()));
864                    }
865                }
866                b.event(event::INPUT, root_start, attrs);
867            }
868            for n in &j.notices {
869                b.event(
870                    event::NOTICE,
871                    root_start,
872                    vec![
873                        ("kind", Enum, name_of(&n.kind).into()),
874                        ("subject", PlanMetadata, n.subject.as_str().into()),
875                        ("reason", PlanMetadata, n.reason.as_str().into()),
876                    ],
877                );
878            }
879        }
880    }
881    let c = &rec.cost;
882    b.put("rustev.cost.mode", Enum, name_of(&c.mode).into());
883    b.put("rustev.cost.final", Enum, name_of(&c.final_cost).into());
884    b.put("rustev.cost.limit", Number, num(c.limit));
885    b.put("rustev.cost.observed", Number, num(c.observed));
886    b.put("rustev.cost.estimated", Number, num(c.estimated));
887    b.put("rustev.cost.liability", Number, num(c.liability));
888    b.put(
889        "rustev.cost.bound_violations",
890        Number,
891        num(c.bound_violations),
892    );
893    b.put(
894        "rustev.cost.within_guaranteed_cap",
895        Enum,
896        c.within_guaranteed_cap.into(),
897    );
898    let t = &rec.timing;
899    b.put("rustev.timing.queued_ms", Number, num(t.queued_ms));
900    b.put("rustev.timing.elapsed_ms", Number, num(t.elapsed_ms));
901    b.put("rustev.timing.deadline_ms", Number, num(t.deadline_ms));
902    b.put(
903        "rustev.timing.deadline_expired",
904        Enum,
905        t.deadline_expired.into(),
906    );
907    b.put(
908        "rustev.deadline.remaining_ms",
909        Number,
910        num(t.deadline_ms.saturating_sub(t.elapsed_ms)),
911    );
912    b.put("rustev.time_anchor", Enum, ctx.anchor_kind.name().into());
913}
914
915fn step_attributes(
916    b: &mut SpanBuilder<'_>,
917    se: Option<&StepEvidence>,
918    req: Option<&RequestRecord>,
919    kind: &str,
920    ctx: &Ctx<'_>,
921    step_id: &str,
922    facts: &mut Facts,
923) {
924    use FieldClass::*;
925    b.put("rustev.step.id", PlanMetadata, step_id.into());
926    let instance = se.map(|s| &s.instance).or(req.map(|r| &r.instance));
927    if let Some(inst) = instance.filter(|i| !i.is_empty()) {
928        b.put("rustev.step.instance", Id, strs(inst));
929    }
930    b.put("rustev.step.kind", Enum, kind.into());
931    if let Some(p) = ctx.plan
932        && let Some(ps) = p.steps.iter().find(|s| s.id == step_id)
933    {
934        match &ps.detail {
935            PlanStepDetail::Exact { op, version } => b.put(
936                "rustev.step.operator",
937                PlanMetadata,
938                format!("{op}@{version}").into(),
939            ),
940            PlanStepDetail::Semantic(s) => b.put(
941                "rustev.step.backend_id",
942                PlanMetadata,
943                s.backend_id.as_str().into(),
944            ),
945            PlanStepDetail::Unsupported { .. }
946            | PlanStepDetail::Corroborate
947            | PlanStepDetail::Acquire
948            | PlanStepDetail::Widen
949            | PlanStepDetail::Generate(_) => {}
950        }
951    }
952    if let Some(s) = se {
953        match &s.status {
954            StepStatus::Skipped { .. } => b.put("rustev.step.status", Enum, "skipped".into()),
955            StepStatus::Resolved { kind, derivation } => {
956                b.put("rustev.step.status", Enum, "resolved".into());
957                b.put("rustev.step.value_kind", Enum, kind.as_str().into());
958                b.put("rustev.step.derivation", Enum, name_of(derivation).into());
959            }
960            StepStatus::Unresolved(u) => {
961                b.put("rustev.step.status", Enum, "unresolved".into());
962                let k = u.kind().name();
963                b.put("rustev.step.unresolved_reason", Enum, k.into());
964                facts.step_unresolved_reasons.push(k.into());
965            }
966        }
967    }
968    if let Some(r) = req {
969        match &r.result {
970            RequestResult::Output { target } => {
971                b.put("rustev.step.result", Enum, "output".into());
972                b.put("rustev.step.target", Number, num(u64::from(*target)));
973            }
974            RequestResult::Failed(_) => b.put("rustev.step.result", Enum, "failed".into()),
975            RequestResult::Withdrawn => {}
976            RequestResult::NotSupplied => b.put("rustev.step.result", Enum, "not_supplied".into()),
977        }
978        if let Some(o) = &r.optimization {
979            b.put(
980                "rustev.step.optimization.outcome",
981                Enum,
982                name_of(&o.outcome).into(),
983            );
984            b.put(
985                "rustev.step.optimization.mechanism",
986                Enum,
987                name_of(&o.mechanism).into(),
988            );
989        }
990        if let Some(from) = r.supplied_from()
991            && let Some(id) = from.reused_from
992        {
993            b.put("rustev.step.reused_from", Id, id.into());
994        }
995    }
996}
997
998fn transition_event(b: &mut SpanBuilder<'_>, t: &Transition, at: u64) {
999    use FieldClass::*;
1000    let attrs = match t {
1001        Transition::Retry {
1002            after,
1003            class,
1004            delay_ms,
1005        } => vec![
1006            ("kind", Enum, "retry".into()),
1007            ("class", Enum, class.name().into()),
1008            ("after", Number, num(u64::from(*after))),
1009            ("delay_ms", Number, num(*delay_ms)),
1010        ],
1011        Transition::Fallback { after, class, to } => vec![
1012            ("kind", Enum, "fallback".into()),
1013            ("class", Enum, class.name().into()),
1014            ("after", Number, num(u64::from(*after))),
1015            ("to", Number, num(u64::from(*to))),
1016        ],
1017        Transition::Stop { after, reason } => vec![
1018            ("kind", Enum, "stop".into()),
1019            ("after", Number, num(u64::from(*after))),
1020            ("reason", FreeTextDetail, reason.as_str().into()),
1021        ],
1022    };
1023    b.event(event::TRANSITION, at, attrs);
1024}
1025
1026fn attempt_end_name(e: &AttemptEnd) -> &'static str {
1027    match e {
1028        AttemptEnd::Output => "output",
1029        AttemptEnd::Failed { .. } => "failed",
1030        AttemptEnd::Deadline => "deadline",
1031        AttemptEnd::Cancelled => "cancelled",
1032    }
1033}
1034
1035/// The attempt's attributes; returns its GenAI facts when it has them.
1036fn attempt_attributes(
1037    d: &mut Draft<'_>,
1038    att: &AttemptRecord,
1039    req: &RequestRecord,
1040    retry_index: u64,
1041    operation: Option<&'static str>,
1042    input: &ProjectionInput<'_>,
1043) -> Option<GenAiFacts> {
1044    use FieldClass::*;
1045    let b = &mut d.b;
1046    b.put("rustev.attempt.id", Id, att.attempt_id.as_str().into());
1047    if let Some(n) = att
1048        .attempt_id
1049        .rsplit('/')
1050        .next()
1051        .and_then(|n| n.parse::<u64>().ok())
1052    {
1053        b.put("rustev.attempt.ordinal", Number, num(n));
1054    }
1055    b.put("rustev.attempt.retry_index", Number, num(retry_index));
1056    b.put("rustev.attempt.target", Number, num(u64::from(att.target)));
1057    b.put(
1058        "rustev.attempt.backend_id",
1059        PlanMetadata,
1060        att.backend_id.as_str().into(),
1061    );
1062    b.put(
1063        "rustev.attempt.artifact",
1064        PlanMetadata,
1065        att.artifact.as_str().into(),
1066    );
1067    b.put(
1068        "rustev.attempt.end",
1069        Enum,
1070        attempt_end_name(&att.end).into(),
1071    );
1072    match &att.end {
1073        AttemptEnd::Failed { class, detail } => {
1074            b.put("rustev.attempt.failure_class", Enum, class.name().into());
1075            b.put("error.type", Enum, class.name().into());
1076            b.put(
1077                "rustev.attempt.failure_detail",
1078                FreeTextDetail,
1079                detail.as_str().into(),
1080            );
1081            d.status = SpanStatus::Error;
1082        }
1083        AttemptEnd::Deadline => {
1084            b.put("error.type", Enum, "deadline".into());
1085            d.status = SpanStatus::Error;
1086        }
1087        AttemptEnd::Output | AttemptEnd::Cancelled => {}
1088    }
1089    b.put(
1090        "rustev.attempt.cancel",
1091        Enum,
1092        match att.cancellation {
1093            Cancellation::NotRequested => "not_requested".into(),
1094            Cancellation::Requested { answer } => name_of(&answer).into(),
1095        },
1096    );
1097    b.put(
1098        "rustev.attempt.remote_state",
1099        Enum,
1100        name_of(&att.remote).into(),
1101    );
1102    let (bound, _) = match att.cost.bound {
1103        CostBound::Bounded { max_units } => ("bounded", Some(max_units)),
1104        CostBound::Estimated { units } => ("estimated", Some(units)),
1105        CostBound::Unknown => ("unknown", None),
1106    };
1107    b.put("rustev.attempt.cost.bound", Enum, bound.into());
1108    b.put(
1109        "rustev.attempt.cost.reserved",
1110        Number,
1111        num(att.cost.reserved),
1112    );
1113    let (charge, units) = match att.cost.charge {
1114        Charge::Observed { units } => ("observed", Some(units)),
1115        Charge::Estimated { units } => ("estimated", Some(units)),
1116        Charge::Unknown => ("unknown", None),
1117    };
1118    b.put("rustev.attempt.charge_kind", Enum, charge.into());
1119    if let Some(u) = units {
1120        b.put("rustev.attempt.charge_units", Number, num(u));
1121    }
1122    if let Some(o) = &req.optimization {
1123        if let Some(id) = &o.batch_id {
1124            b.put("rustev.batch.id", Id, id.as_str().into());
1125        }
1126        if let Some(m) = o.batch_member {
1127            b.put("rustev.batch.member", Number, num(u64::from(m)));
1128        }
1129    }
1130
1131    // GenAI attributes (3.5.4): only for an attempt that joined an exchange
1132    // record or whose backend id the host mapped to a provider name.
1133    let exchange = input.exchanges.get(&att.attempt_id);
1134    let provider = input.bindings.providers.get(&att.backend_id);
1135    if exchange.is_none() && provider.is_none() {
1136        return None;
1137    }
1138    d.kind = SpanKind::Client;
1139    let provider = provider.cloned().unwrap_or_else(|| "unknown".into());
1140    b.put(
1141        "gen_ai.provider.name",
1142        ModelIdentity,
1143        provider.as_str().into(),
1144    );
1145    if let Some(op) = operation {
1146        b.put("gen_ai.operation.name", Enum, op.into());
1147    }
1148    let request_model = exchange
1149        .and_then(|x| x.requested_model.clone())
1150        .unwrap_or_else(|| att.artifact.to_string());
1151    b.put(
1152        "gen_ai.request.model",
1153        ModelIdentity,
1154        request_model.as_str().into(),
1155    );
1156    let mut facts = GenAiFacts {
1157        operation: operation.map(str::to_string),
1158        provider,
1159        request_model,
1160        input_tokens: None,
1161        output_tokens: None,
1162    };
1163    let Some(x) = exchange else {
1164        b.put("gen_ai.response.model", ModelIdentity, "unknown".into());
1165        b.put("rustev.served.disclosure", Enum, "unknown".into());
1166        return Some(facts);
1167    };
1168    let response_model = match (&x.served.disclosure, &x.served.identity) {
1169        (Disclosure::Pinned | Disclosure::Reported, Some(id)) => id.clone(),
1170        _ => "unknown".into(),
1171    };
1172    b.put(
1173        "gen_ai.response.model",
1174        ModelIdentity,
1175        response_model.into(),
1176    );
1177    b.put(
1178        "rustev.served.disclosure",
1179        Enum,
1180        name_of(&x.served.disclosure).into(),
1181    );
1182    if let Some(g) = &x.generation_id {
1183        b.put("gen_ai.response.id", Id, g.as_str().into());
1184    }
1185    if let Some(keys) = input.bindings.usage_keys.get(&att.backend_id) {
1186        // Absent, never 0, when not reported.
1187        if let Some(n) = keys.input.as_ref().and_then(|k| x.usage.get(k)) {
1188            b.put("gen_ai.usage.input_tokens", Number, num(*n));
1189            facts.input_tokens = Some(*n);
1190        }
1191        if let Some(n) = keys.output.as_ref().and_then(|k| x.usage.get(k)) {
1192            b.put("gen_ai.usage.output_tokens", Number, num(*n));
1193            facts.output_tokens = Some(*n);
1194        }
1195    }
1196    if let Some(dg) = &x.request_digest {
1197        b.put(
1198            "rustev.exchange.request_digest",
1199            ContentDigest,
1200            dg.as_str().into(),
1201        );
1202    }
1203    if let Some(dg) = &x.response_digest {
1204        b.put(
1205            "rustev.exchange.response_digest",
1206            ContentDigest,
1207            dg.as_str().into(),
1208        );
1209    }
1210    if let Some(s) = x.status {
1211        b.put(
1212            "rustev.exchange.status",
1213            Number,
1214            TraceValue::Int(i64::from(s)),
1215        );
1216    }
1217    b.put("rustev.exchange.bytes_sent", Enum, x.bytes_sent.into());
1218    b.put("rustev.exchange.late", Enum, x.late.into());
1219    b.put("rustev.exchange.truncated", Enum, x.truncated.into());
1220    if !x.route.is_empty() {
1221        b.put("rustev.exchange.route", ModelIdentity, strs(&x.route));
1222    }
1223    if let Some(c) = &x.reported_cost {
1224        b.put(
1225            "rustev.exchange.reported_cost",
1226            Number,
1227            c.amount.as_str().into(),
1228        );
1229        b.put(
1230            "rustev.exchange.reported_currency",
1231            Enum,
1232            c.currency.as_str().into(),
1233        );
1234    }
1235    if let Some(r) = &x.retry_after {
1236        b.put("rustev.exchange.retry_after", ThirdParty, r.as_str().into());
1237    }
1238    for (k, v) in &x.provider_extras {
1239        b.put(
1240            &format!("rustev.exchange.extra.{k}"),
1241            ThirdParty,
1242            v.as_str().into(),
1243        );
1244    }
1245    if let Some(m) = x.members.iter().find(|m| m.attempt_id == att.attempt_id)
1246        && let MemberOutcome::Failure { detail, .. } = &m.outcome
1247    {
1248        b.put(
1249            "rustev.exchange.member_detail",
1250            FreeTextDetail,
1251            detail.as_str().into(),
1252        );
1253    }
1254    Some(facts)
1255}
1256
1257fn policy_attributes(b: &mut SpanBuilder<'_>, ev: &EvidenceRecord, ctx: &Ctx<'_>, at: u64) {
1258    use FieldClass::*;
1259    let j = &ev.judgment;
1260    b.put("rustev.policy.rules_fired", PlanMetadata, strs(&j.trace));
1261    let Outcome::Propose { params, .. } = &j.outcome else {
1262        return;
1263    };
1264    let inputs = ctx.lineage_inputs();
1265    let mut excluded = None::<u64>;
1266    let mut truncated = None::<u64>;
1267    let mut excl = |b: &mut SpanBuilder<'_>, list: &[rustev_contract::judgment::Excluded]| {
1268        *excluded.get_or_insert(0) += list.len() as u64;
1269        for e in list {
1270            b.event(
1271                event::EXCLUDED,
1272                at,
1273                vec![
1274                    ("candidate", Id, e.id.as_str().into()),
1275                    ("reasons", PlanMetadata, strs(&e.reasons)),
1276                ],
1277            );
1278        }
1279    };
1280    for v in params.values() {
1281        match v {
1282            OutValue::Filtered(f) => excl(b, &f.excluded),
1283            OutValue::Shortlist(s) => {
1284                *truncated.get_or_insert(0) += s.truncated;
1285                excl(b, &s.excluded);
1286            }
1287            OutValue::Ranking(r) => {
1288                excl(b, &r.excluded);
1289                let class = lineage_class(false, inputs.iter().copied());
1290                for e in &r.entries {
1291                    b.event(
1292                        event::POSITION,
1293                        at,
1294                        vec![
1295                            ("position", Number, num(e.position)),
1296                            ("candidate", Id, e.candidate.as_str().into()),
1297                            ("score", class, TraceValue::Double(e.score)),
1298                        ],
1299                    );
1300                }
1301            }
1302            _ => {}
1303        }
1304    }
1305    if let Some(n) = excluded {
1306        b.put("rustev.policy.excluded_count", Number, num(n));
1307    }
1308    if let Some(n) = truncated {
1309        b.put("rustev.policy.truncated", Number, num(n));
1310    }
1311}