Skip to main content

rustev_otel/
sink.rs

1//! The OpenTelemetry evidence sink (spec 028, 3.6 to 3.9.4).
2//!
3//! `deliver` projects and redacts the record synchronously, updates the
4//! metrics (never sampled), applies sampling, and hands the whole tree to
5//! the bounded export queue as one unit. A worker exports batches through
6//! the host's exporter, each call bounded by the export timeout and run as
7//! its own task, so a failing, hanging or panicking exporter is counted
8//! under its reason. Accepted telemetry is exported, lost under exactly one
9//! reason, or in flight (I-2).
10//!
11//! The sink reads the record and never writes it; a receipt establishes
12//! only enqueueing or export, never anything about the decision.
13
14use std::collections::{BTreeMap, VecDeque};
15use std::fmt;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::{Arc, Mutex, MutexGuard};
18use std::time::{Instant, SystemTime, UNIX_EPOCH};
19
20use rustev_contract::Document;
21use rustev_contract::Identified;
22use rustev_contract::ids::PlanId;
23use rustev_contract::plan::Plan;
24use rustev_contract::redaction::{FieldClass, Redactor};
25use rustev_contract::remote::ExchangeRecord;
26use rustev_contract::run::RunRecord;
27use rustev_contract::trace::{TraceAttr, TraceLine};
28use rustev_core::seams::{BoxFuture, EvidenceSink};
29use rustev_runtime::Gauges;
30use tokio::sync::{Notify, oneshot};
31
32use crate::config::{
33    AckMode, AlwaysSampleOn, ConfigError, ParentSampling, RootSampling, SinkConfig,
34};
35use crate::context::{ContextTable, JoinBuffer, Joined, TraceParent, parse_traceparent};
36use crate::ids::{IdSource, RandomIds, hex};
37use crate::metrics::{AttrSet, Instruments, MetricsSnapshot, name};
38use crate::project::{Anchor, AnchorKind, Facts, Projected, ProjectionInput, RootIds, project};
39
40/// The loss reasons of 3.8.4.
41pub mod loss {
42    pub const QUEUE_FULL: &str = "queue_full";
43    pub const EXPORT_FAILED: &str = "export_failed";
44    pub const EXPORT_TIMEOUT: &str = "export_timeout";
45    pub const EXPORTER_PANICKED: &str = "exporter_panicked";
46    pub const SHUTDOWN_UNFLUSHED: &str = "shutdown_unflushed";
47    pub const TEE_FAILED: &str = "tee_failed";
48    pub const FILE_FULL: &str = "file_full";
49    pub const PROJECTION_UNKNOWN: &str = "projection_unknown";
50    pub const ALL: [&str; 8] = [
51        QUEUE_FULL,
52        EXPORT_FAILED,
53        EXPORT_TIMEOUT,
54        EXPORTER_PANICKED,
55        SHUTDOWN_UNFLUSHED,
56        TEE_FAILED,
57        FILE_FULL,
58        PROJECTION_UNKNOWN,
59    ];
60}
61
62/// A WARN log record (3.6.3): a fixed template body over enum values and
63/// the span's redacted attributes, correlated to its span.
64#[derive(Debug, Clone, PartialEq)]
65pub struct LogRecord {
66    pub time_unix_nanos: u64,
67    /// 32 lowercase hex digits.
68    pub trace_id: String,
69    /// 16 lowercase hex digits.
70    pub span_id: String,
71    pub severity_text: &'static str,
72    /// OpenTelemetry's WARN severity number.
73    pub severity_number: u8,
74    pub body: String,
75    pub attributes: BTreeMap<String, TraceAttr>,
76}
77
78/// One decision's telemetry: its whole span tree and its log records,
79/// queued and exported as one unit.
80#[derive(Debug, Clone, PartialEq)]
81pub struct DecisionTelemetry {
82    pub lines: Vec<TraceLine>,
83    /// The W3C `tracestate` of a registered valid parent, unchanged.
84    pub tracestate: Option<String>,
85    /// The sampled flag the spans carry.
86    pub sampled: bool,
87    pub logs: Vec<LogRecord>,
88}
89
90/// One histogram measurement (3.6.1), its attributes already through the
91/// policy.
92#[derive(Debug, Clone, Copy, PartialEq)]
93pub struct Measurement<'a> {
94    pub instrument: &'static str,
95    pub attrs: &'a [(String, String)],
96    pub value: f64,
97}
98
99/// Why an export did not succeed.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum ExportError {
102    /// Any failure, a redirect included; never carries a secret.
103    Failed(String),
104    /// The JSONL file reached its bound.
105    FileFull,
106}
107
108impl ExportError {
109    fn reason(&self) -> &'static str {
110        match self {
111            ExportError::Failed(_) => loss::EXPORT_FAILED,
112            ExportError::FileFull => loss::FILE_FULL,
113        }
114    }
115}
116
117impl fmt::Display for ExportError {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        match self {
120            ExportError::Failed(e) => write!(f, "export failed: {e}"),
121            ExportError::FileFull => f.write_str("trace file full"),
122        }
123    }
124}
125
126/// Where telemetry goes: compiled in, configured explicitly (3.13.7).
127pub trait TelemetryExporter: Send + Sync + 'static {
128    /// Export the spans of one batch of whole decision trees.
129    fn export<'a>(
130        &'a self,
131        batch: &'a [DecisionTelemetry],
132    ) -> BoxFuture<'a, Result<(), ExportError>>;
133
134    /// Export one metrics snapshot; only called when [`Self::takes_metrics`].
135    fn export_metrics<'a>(
136        &'a self,
137        _snapshot: &'a MetricsSnapshot,
138    ) -> BoxFuture<'a, Result<(), ExportError>> {
139        Box::pin(async { Err(ExportError::Failed("metrics are not exported".into())) })
140    }
141
142    /// Export the log records of one batch, after its spans; only called
143    /// when [`Self::takes_logs`].
144    fn export_logs<'a>(&'a self, _logs: &'a [LogRecord]) -> BoxFuture<'a, Result<(), ExportError>> {
145        Box::pin(async { Err(ExportError::Failed("logs are not exported".into())) })
146    }
147
148    /// One histogram measurement as the sink records it, for an exporter
149    /// that keeps its own instruments (3.6.2: every delivered record).
150    fn observe(&self, _measurement: &Measurement<'_>) {}
151
152    /// Whether log records are exported. A sink with logs on refuses an
153    /// exporter that takes none.
154    fn takes_logs(&self) -> bool {
155        false
156    }
157
158    /// Whether [`Self::export_metrics`] exports.
159    fn takes_metrics(&self) -> bool {
160        false
161    }
162}
163
164/// The JSONL exporter writes each decision's span lines in one write; it
165/// takes no logs and no metrics.
166impl TelemetryExporter for crate::jsonl::JsonlExporter {
167    fn export<'a>(
168        &'a self,
169        batch: &'a [DecisionTelemetry],
170    ) -> BoxFuture<'a, Result<(), ExportError>> {
171        let mut r = Ok(());
172        for d in batch {
173            match crate::jsonl::JsonlExporter::export(self, &d.lines) {
174                Ok(_) => {}
175                Err(crate::jsonl::JsonlError::FileFull) => {
176                    r = Err(ExportError::FileFull);
177                    break;
178                }
179                Err(e) => {
180                    r = Err(ExportError::Failed(e.to_string()));
181                    break;
182                }
183            }
184        }
185        Box::pin(async move { r })
186    }
187}
188
189/// The sink's clocks: the wall clock read once per decision as its anchor,
190/// and a monotonic clock for the tables' time to live.
191pub trait SinkClock: Send + Sync {
192    fn now_unix_nanos(&self) -> u64;
193    fn monotonic_ms(&self) -> u64;
194}
195
196/// The system clocks.
197#[derive(Debug)]
198pub struct SystemClock {
199    origin: Instant,
200}
201
202impl Default for SystemClock {
203    fn default() -> Self {
204        SystemClock {
205            origin: Instant::now(),
206        }
207    }
208}
209
210impl SinkClock for SystemClock {
211    fn now_unix_nanos(&self) -> u64 {
212        SystemTime::now()
213            .duration_since(UNIX_EPOCH)
214            .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
215            .unwrap_or(0)
216    }
217
218    fn monotonic_ms(&self) -> u64 {
219        u64::try_from(self.origin.elapsed().as_millis()).unwrap_or(u64::MAX)
220    }
221}
222
223/// Accepted, exported, lost and in-flight items of one signal (3.8.4).
224#[derive(Debug, Clone, Default, PartialEq, Eq)]
225pub struct Flow {
226    pub accepted: u64,
227    pub exported: u64,
228    pub in_flight: u64,
229    /// By loss reason.
230    pub lost: BTreeMap<&'static str, u64>,
231}
232
233impl Flow {
234    pub fn lost_total(&self) -> u64 {
235        self.lost.values().sum()
236    }
237
238    /// accepted = exported + lost + in flight.
239    pub fn conserved(&self) -> bool {
240        self.accepted == self.exported + self.lost_total() + self.in_flight
241    }
242
243    fn lose(&mut self, reason: &'static str, n: u64) {
244        if n > 0 {
245            *self.lost.entry(reason).or_default() += n;
246        }
247    }
248}
249
250/// In-memory counters (they reset with the process, spec 003 3.9.7).
251#[derive(Debug, Clone, Default, PartialEq, Eq)]
252pub struct OtelStats {
253    pub spans: Flow,
254    pub logs: Flow,
255    pub metric_exports: Flow,
256    /// Decisions whose telemetry was lost, by reason: the
257    /// `rustev.telemetry.lost` counter.
258    pub decisions_lost: BTreeMap<&'static str, u64>,
259    // Counted, not loss (3.8.4).
260    pub sampled_out: u64,
261    pub invalid_traceparent: u64,
262    pub table_full: u64,
263    pub context_expired: u64,
264    pub plan_unregistered: u64,
265    pub exchange_unjoined: u64,
266    pub attributes_cut: u64,
267    pub events_cut: u64,
268}
269
270impl OtelStats {
271    fn lose_decision(&mut self, reason: &'static str) {
272        *self.decisions_lost.entry(reason).or_default() += 1;
273    }
274}
275
276/// What `begin` returns: the ids a host links its own logs to (3.7.1).
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct Begun {
279    /// 32 lowercase hex digits.
280    pub trace_id: String,
281    /// The root span's id, 16 lowercase hex digits.
282    pub root_span_id: String,
283}
284
285/// Why `begin` registered nothing.
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub enum BeginError {
288    /// The table holds `max_pending_contexts` entries; the decision will be
289    /// exported as a new trace.
290    TableFull,
291}
292
293impl fmt::Display for BeginError {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        f.write_str("the trace context table is full")
296    }
297}
298
299impl std::error::Error for BeginError {}
300
301struct Queued {
302    tree: DecisionTelemetry,
303    spans: u64,
304    logs: u64,
305    waiter: Option<oneshot::Sender<Result<(), &'static str>>>,
306}
307
308struct Queue {
309    items: VecDeque<Queued>,
310    spans: usize,
311    closed: bool,
312    exporting: bool,
313}
314
315struct Tables {
316    contexts: ContextTable,
317    joins: JoinBuffer,
318    plans: BTreeMap<PlanId, Arc<Plan>>,
319    ids: Box<dyn IdSource>,
320}
321
322type GaugeFn = Arc<dyn Fn() -> Gauges + Send + Sync>;
323
324struct Shared {
325    config: SinkConfig,
326    redactor: Redactor,
327    exporter: Arc<dyn TelemetryExporter>,
328    clock: Arc<dyn SinkClock>,
329    queue: Mutex<Queue>,
330    wake: Notify,
331    idle: Notify,
332    stats: Mutex<OtelStats>,
333    metrics: Mutex<Instruments>,
334    tables: Mutex<Tables>,
335    gauges: Mutex<Option<GaugeFn>>,
336}
337
338fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
339    m.lock().unwrap_or_else(|e| e.into_inner())
340}
341
342/// The sink. Implements [`EvidenceSink`]; use it directly or inside a
343/// [`TeeSink`].
344pub struct OtelSink {
345    shared: Arc<Shared>,
346}
347
348impl fmt::Debug for OtelSink {
349    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
350        f.debug_struct("OtelSink").finish_non_exhaustive()
351    }
352}
353
354impl OtelSink {
355    /// Validate `config`, build the redactor, and start the export worker
356    /// on the current Tokio runtime.
357    pub fn new(
358        config: SinkConfig,
359        exporter: Arc<dyn TelemetryExporter>,
360    ) -> Result<OtelSink, ConfigError> {
361        Self::with_parts(
362            config,
363            exporter,
364            Arc::new(SystemClock::default()),
365            Box::new(RandomIds::default()),
366        )
367    }
368
369    /// [`OtelSink::new`] with an explicit clock and id source.
370    pub fn with_parts(
371        config: SinkConfig,
372        exporter: Arc<dyn TelemetryExporter>,
373        clock: Arc<dyn SinkClock>,
374        ids: Box<dyn IdSource>,
375    ) -> Result<OtelSink, ConfigError> {
376        config.validate()?;
377        let redactor = Redactor::new(config.policy.clone(), config.key.clone())
378            .map_err(ConfigError::Redaction)?;
379        if config.logs && !exporter.takes_logs() {
380            return Err(ConfigError::LogsUnsupported);
381        }
382        let handle = tokio::runtime::Handle::try_current().map_err(|_| ConfigError::NoRuntime)?;
383        let shared = Arc::new(Shared {
384            tables: Mutex::new(Tables {
385                contexts: ContextTable::new(config.max_pending_contexts, config.context_ttl_ms),
386                joins: JoinBuffer::new(config.join_buffer, config.context_ttl_ms),
387                plans: BTreeMap::new(),
388                ids,
389            }),
390            config,
391            redactor,
392            exporter,
393            clock,
394            queue: Mutex::new(Queue {
395                items: VecDeque::new(),
396                spans: 0,
397                closed: false,
398                exporting: false,
399            }),
400            wake: Notify::new(),
401            idle: Notify::new(),
402            stats: Mutex::new(OtelStats::default()),
403            metrics: Mutex::new(Instruments::default()),
404            gauges: Mutex::new(None),
405        });
406        handle.spawn(worker(shared.clone()));
407        Ok(OtelSink { shared })
408    }
409
410    /// Register a compiled plan the host runs (3.7.2).
411    pub fn register_plan(&self, plan: &Plan) {
412        if let Ok(id) = plan.id() {
413            lock(&self.shared.tables)
414                .plans
415                .insert(id, Arc::new(plan.clone()));
416        }
417    }
418
419    /// Register the runtime's gauges, read when metrics are read (3.6.1):
420    /// rejected decisions produce no run record.
421    pub fn observe_runtime(&self, gauges: impl Fn() -> Gauges + Send + Sync + 'static) {
422        *lock(&self.shared.gauges) = Some(Arc::new(gauges));
423    }
424
425    /// Register a decision before the host submits it (3.7.1). An invalid
426    /// `traceparent` is ignored and counted; the decision starts a new
427    /// trace.
428    pub fn begin(
429        &self,
430        decision_id: &str,
431        traceparent: Option<&str>,
432        tracestate: Option<&str>,
433    ) -> Result<Begun, BeginError> {
434        let s = &self.shared;
435        let parent = match traceparent {
436            None => None,
437            Some(t) => {
438                let p = parse_traceparent(t);
439                if p.is_none() {
440                    lock(&s.stats).invalid_traceparent += 1;
441                }
442                p
443            }
444        };
445        let now = s.clock.monotonic_ms();
446        let anchor = s.clock.now_unix_nanos();
447        let mut t = lock(&s.tables);
448        let expired = t.contexts.expire(now);
449        let trace_id = parent
450            .map(|p| p.trace_id)
451            .unwrap_or_else(|| t.ids.trace_id());
452        let root = t.ids.span_id();
453        let ok = t.contexts.insert(
454            decision_id,
455            trace_id,
456            root,
457            parent,
458            tracestate.map(str::to_string),
459            anchor,
460            now,
461        );
462        drop(t);
463        let mut st = lock(&s.stats);
464        st.context_expired += expired;
465        if !ok {
466            st.table_full += 1;
467            return Err(BeginError::TableFull);
468        }
469        Ok(Begun {
470            trace_id: hex(&trace_id),
471            root_span_id: hex(&root),
472        })
473    }
474
475    /// Remove a registered decision that will not be delivered (a rejected
476    /// one). True when it was registered.
477    pub fn abandon(&self, decision_id: &str) -> bool {
478        lock(&self.shared.tables)
479            .contexts
480            .take(decision_id)
481            .is_some()
482    }
483
484    /// Accept an exchange record forwarded from the host's own exchange
485    /// sink (3.7.3).
486    pub fn record_exchange(&self, record: &ExchangeRecord) {
487        let s = &self.shared;
488        let now = s.clock.monotonic_ms();
489        let mut t = lock(&s.tables);
490        let expired = t.joins.expire(now);
491        let joined = t.joins.insert(record.clone(), now);
492        drop(t);
493        let mut st = lock(&s.stats);
494        st.exchange_unjoined += expired;
495        if joined == Joined::Unjoined {
496            st.exchange_unjoined += 1;
497        }
498    }
499
500    /// The counters (3.8.4).
501    pub fn stats(&self) -> OtelStats {
502        lock(&self.shared.stats).clone()
503    }
504
505    /// The metrics now, including the loss and sampling counters and the
506    /// runtime's observable counters when registered.
507    pub fn metrics(&self) -> MetricsSnapshot {
508        let s = &self.shared;
509        let mut snap = lock(&s.metrics).snapshot();
510        let policy = s.redactor.policy();
511        let mut extra = Instruments::default();
512        let st = self.stats();
513        for reason in loss::ALL {
514            let n = st.decisions_lost.get(reason).copied().unwrap_or(0);
515            let a = AttrSet::new(policy)
516                .with(
517                    "reason",
518                    "rustev.telemetry.lost.reason",
519                    FieldClass::Enum,
520                    reason,
521                )
522                .done();
523            extra.add(name::TELEMETRY_LOST, a, n);
524        }
525        extra.add(name::SAMPLED_OUT, vec![], st.sampled_out);
526        let gauges = lock(&s.gauges).clone();
527        if let Some(g) = gauges {
528            let g = g();
529            extra.add(name::ADMISSION_REJECTED, vec![], g.rejected);
530            for (result, n) in [
531                ("acknowledged", g.sink.acknowledged),
532                ("failed", g.sink.failed),
533                ("timed_out", g.sink.timed_out),
534                ("dropped", g.sink.dropped),
535            ] {
536                let a = AttrSet::new(policy)
537                    .with(
538                        "result",
539                        "rustev.evidence.sink.result",
540                        FieldClass::Enum,
541                        result,
542                    )
543                    .done();
544                extra.add(name::EVIDENCE_SINK, a, n);
545            }
546        }
547        snap.instruments.extend(extra.snapshot().instruments);
548        snap
549    }
550
551    /// Export one metrics snapshot when the exporter takes metrics; counted
552    /// under `metric_exports` like any other telemetry.
553    pub async fn flush_metrics(&self) -> Result<(), &'static str> {
554        let s = &self.shared;
555        if !s.exporter.takes_metrics() {
556            return Ok(());
557        }
558        let snap = self.metrics();
559        {
560            let mut st = lock(&s.stats);
561            st.metric_exports.accepted += 1;
562            st.metric_exports.in_flight += 1;
563        }
564        let ex = s.exporter.clone();
565        let r = run_export(s.config.export_timeout_ms, async move {
566            ex.export_metrics(&snap).await
567        })
568        .await;
569        let mut st = lock(&s.stats);
570        st.metric_exports.in_flight -= 1;
571        match r {
572            Ok(()) => {
573                st.metric_exports.exported += 1;
574                Ok(())
575            }
576            Err(reason) => {
577                st.metric_exports.lose(reason, 1);
578                Err(reason)
579            }
580        }
581    }
582
583    /// Stop taking telemetry and wait up to `timeout_ms` for the queue to
584    /// drain; what is left is lost as `shutdown_unflushed`.
585    pub async fn shutdown(&self, timeout_ms: u64) {
586        let s = &self.shared;
587        lock(&s.queue).closed = true;
588        s.wake.notify_one();
589        let wait = async {
590            loop {
591                let notified = s.idle.notified();
592                {
593                    let q = lock(&s.queue);
594                    if q.items.is_empty() && !q.exporting {
595                        return;
596                    }
597                }
598                notified.await;
599            }
600        };
601        let _ = tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), wait).await;
602        let left: Vec<Queued> = {
603            let mut q = lock(&s.queue);
604            q.spans = 0;
605            q.items.drain(..).collect()
606        };
607        let mut st = lock(&s.stats);
608        for item in left {
609            st.spans.in_flight -= item.spans;
610            st.logs.in_flight -= item.logs;
611            st.spans.lose(loss::SHUTDOWN_UNFLUSHED, item.spans);
612            st.logs.lose(loss::SHUTDOWN_UNFLUSHED, item.logs);
613            st.lose_decision(loss::SHUTDOWN_UNFLUSHED);
614            if let Some(w) = item.waiter {
615                let _ = w.send(Err(loss::SHUTDOWN_UNFLUSHED));
616            }
617        }
618        s.wake.notify_one();
619    }
620
621    /// Count a telemetry failure inside a [`TeeSink`] that did not change
622    /// the decision and that no other reason counts.
623    fn tee_failed(&self) {
624        lock(&self.shared.stats).lose_decision(loss::TEE_FAILED);
625    }
626
627    /// Deliver; on failure, whether the failure was already counted under a
628    /// loss reason (so a tee never counts one decision twice). `enqueued`
629    /// is set once the tree is in the queue, where the worker counts it.
630    async fn deliver_record(
631        &self,
632        record: &RunRecord,
633        enqueued: &AtomicBool,
634    ) -> Result<String, Failure> {
635        let s = &self.shared;
636        let digest = record.record_digest().map_err(|e| Failure {
637            message: format!("otel: the run record has no digest: {e}"),
638            counted: false,
639        })?;
640        let now = s.clock.monotonic_ms();
641        let wall = s.clock.now_unix_nanos();
642
643        // Context, plan and exchanges, under one lock.
644        let (ctx, plan, exchanges, child_ids, expired) = {
645            let mut t = lock(&s.tables);
646            let expired = t.contexts.expire(now);
647            let expired_x = t.joins.expire(now);
648            let ctx = t.contexts.take(&record.decision_id);
649            let plan = t.plans.get(&record.plan_id).cloned();
650            let attempts: Vec<&str> = record
651                .requests
652                .iter()
653                .flat_map(|r| r.attempts.iter().map(|a| a.attempt_id.as_str()))
654                .collect();
655            let exchanges = t.joins.take(&attempts);
656            // The ids this decision needs, drawn up front.
657            let n = 2
658                + record.requests.len() * 2
659                + record
660                    .requests
661                    .iter()
662                    .map(|r| r.attempts.len())
663                    .sum::<usize>()
664                + match &record.core {
665                    rustev_contract::run::CoreEvidence::Recorded(e) => e.steps.len(),
666                    _ => 0,
667                };
668            let mut ids = PreDrawn::default();
669            ids.trace = Some(t.ids.trace_id());
670            for _ in 0..n {
671                ids.spans.push_back(t.ids.span_id());
672            }
673            (ctx, plan, exchanges, ids, (expired, expired_x))
674        };
675        {
676            let mut st = lock(&s.stats);
677            st.context_expired += expired.0;
678            st.exchange_unjoined += expired.1;
679            if plan.is_none() {
680                st.plan_unregistered += 1;
681            }
682        }
683        let mut ids = child_ids;
684        let (root, anchor, parent, tracestate) = match &ctx {
685            Some(c) => (
686                RootIds {
687                    trace_id: c.trace_id,
688                    span_id: c.root_span_id,
689                    parent_span_id: c.parent.map(|p| p.parent_id),
690                },
691                Anchor {
692                    unix_nanos: c.anchor_unix_nanos,
693                    kind: AnchorKind::Begin,
694                },
695                c.parent,
696                c.tracestate.clone(),
697            ),
698            None => (
699                RootIds {
700                    trace_id: ids.trace.take().unwrap_or([1; 16]),
701                    span_id: ids.span_id(),
702                    parent_span_id: None,
703                },
704                Anchor {
705                    unix_nanos: wall
706                        .saturating_sub(record.timing.elapsed_ms.saturating_mul(1_000_000)),
707                    kind: AnchorKind::Delivery,
708                },
709                None,
710                None,
711            ),
712        };
713        let projected = project(
714            &ProjectionInput {
715                record,
716                plan: plan.as_deref(),
717                exchanges: &exchanges,
718                bindings: &s.config.bindings,
719                anchor,
720                root,
721                value_bound: s.config.value_bound,
722            },
723            &s.redactor,
724            &mut ids,
725        );
726        let projected = match projected {
727            Ok(p) => p,
728            Err(e) => {
729                lock(&s.stats).lose_decision(loss::PROJECTION_UNKNOWN);
730                return Err(Failure {
731                    message: format!("otel: {e}"),
732                    counted: true,
733                });
734            }
735        };
736        {
737            let mut st = lock(&s.stats);
738            st.attributes_cut += projected.attributes_cut;
739            st.events_cut += projected.events_cut;
740        }
741        self.update_metrics(&projected.facts);
742
743        // Sampling (3.9.4).
744        let sampled = self.sampled(&root, parent.as_ref(), &projected.facts);
745        if !sampled {
746            lock(&s.stats).sampled_out += 1;
747            return Ok(format!("otel:sampled_out:{digest}"));
748        }
749        let logs = if s.config.logs {
750            logs_of(&projected)
751        } else {
752            vec![]
753        };
754        let tree = DecisionTelemetry {
755            lines: projected.lines,
756            tracestate,
757            sampled,
758            logs,
759        };
760        let spans = tree.lines.len() as u64;
761        let nlogs = tree.logs.len() as u64;
762        let (tx, rx) = match s.config.ack {
763            AckMode::Accepted => (None, None),
764            AckMode::Exported => {
765                let (tx, rx) = oneshot::channel();
766                (Some(tx), Some(rx))
767            }
768        };
769        {
770            let mut q = lock(&s.queue);
771            let mut st = lock(&s.stats);
772            st.spans.accepted += spans;
773            st.logs.accepted += nlogs;
774            if q.closed || q.spans + spans as usize > s.config.max_queued_spans {
775                let reason = if q.closed {
776                    loss::SHUTDOWN_UNFLUSHED
777                } else {
778                    loss::QUEUE_FULL
779                };
780                st.spans.lose(reason, spans);
781                st.logs.lose(reason, nlogs);
782                st.lose_decision(reason);
783                return Err(Failure {
784                    message: format!("otel: {reason}"),
785                    counted: true,
786                });
787            }
788            st.spans.in_flight += spans;
789            st.logs.in_flight += nlogs;
790            q.spans += spans as usize;
791            q.items.push_back(Queued {
792                tree,
793                spans,
794                logs: nlogs,
795                waiter: tx,
796            });
797            enqueued.store(true, Ordering::SeqCst);
798        }
799        s.wake.notify_one();
800        match rx {
801            None => Ok(format!("otel:accepted:{digest}")),
802            Some(rx) => match rx.await {
803                Ok(Ok(())) => Ok(format!("otel:exported:{digest}")),
804                Ok(Err(reason)) => Err(Failure {
805                    message: format!("otel: {reason}"),
806                    counted: true,
807                }),
808                Err(_) => Err(Failure {
809                    message: "otel: the export worker stopped".into(),
810                    counted: true,
811                }),
812            },
813        }
814    }
815
816    fn sampled(&self, root: &RootIds, parent: Option<&TraceParent>, facts: &Facts) -> bool {
817        let sampling = &self.shared.config.sampling;
818        let base = match (parent, sampling.parent) {
819            (Some(p), ParentSampling::Honor) => p.sampled,
820            _ => match sampling.root {
821                RootSampling::Always => true,
822                RootSampling::Never => false,
823                RootSampling::Ratio { per_million } => ratio_sampled(&root.trace_id, per_million),
824            },
825        };
826        if base {
827            return true;
828        }
829        sampling.always_sample_on.iter().any(|t| match t {
830            AlwaysSampleOn::Unresolved => {
831                facts.outcome == "unresolved" || !facts.step_unresolved_reasons.is_empty()
832            }
833            AlwaysSampleOn::Refusal => facts.outcome == "escalate",
834            AlwaysSampleOn::Fallback => facts.fallbacks > 0,
835            AlwaysSampleOn::Cancelled => facts.termination == "cancelled",
836        })
837    }
838
839    fn update_metrics(&self, f: &Facts) {
840        let s = &self.shared;
841        let p = s.redactor.policy();
842        let mut m = lock(&s.metrics);
843        m.add(
844            name::DECISIONS,
845            AttrSet::new(p)
846                .with(
847                    "rustev.outcome",
848                    "rustev.outcome",
849                    FieldClass::Enum,
850                    &f.outcome,
851                )
852                .with(
853                    "rustev.termination",
854                    "rustev.termination",
855                    FieldClass::Enum,
856                    &f.termination,
857                )
858                .with(
859                    "rustev.plan.id",
860                    "rustev.plan.id",
861                    FieldClass::PlanMetadata,
862                    &f.plan_id,
863                )
864                .done(),
865            1,
866        );
867        for (entry, verdict) in &f.screening {
868            m.add(
869                name::SCREENING,
870                AttrSet::new(p)
871                    .with(
872                        "rustev.screening.verdict",
873                        "rustev.screening.verdict",
874                        FieldClass::Enum,
875                        verdict,
876                    )
877                    .with(
878                        "rustev.screening.entry",
879                        "rustev.screening.entry",
880                        FieldClass::PlanMetadata,
881                        entry,
882                    )
883                    .with(
884                        "rustev.plan.id",
885                        "rustev.plan.id",
886                        FieldClass::PlanMetadata,
887                        &f.plan_id,
888                    )
889                    .done(),
890                1,
891            );
892        }
893        if let Some(units) = f.screening_units {
894            record(
895                &mut m,
896                &*s.exporter,
897                name::SCREENING_UNITS,
898                AttrSet::new(p)
899                    .with(
900                        "rustev.plan.id",
901                        "rustev.plan.id",
902                        FieldClass::PlanMetadata,
903                        &f.plan_id,
904                    )
905                    .done(),
906                units as f64,
907            );
908        }
909        if let Some(r) = &f.outcome_unresolved_reason {
910            m.add(
911                name::UNRESOLVED,
912                AttrSet::new(p)
913                    .with(
914                        "rustev.outcome.unresolved_reason",
915                        "rustev.outcome.unresolved_reason",
916                        FieldClass::Enum,
917                        r,
918                    )
919                    .with(
920                        "scope",
921                        "rustev.unresolved.scope",
922                        FieldClass::Enum,
923                        "outcome",
924                    )
925                    .with(
926                        "rustev.plan.id",
927                        "rustev.plan.id",
928                        FieldClass::PlanMetadata,
929                        &f.plan_id,
930                    )
931                    .done(),
932                1,
933            );
934        }
935        for r in &f.step_unresolved_reasons {
936            m.add(
937                name::UNRESOLVED,
938                AttrSet::new(p)
939                    .with(
940                        "rustev.step.unresolved_reason",
941                        "rustev.step.unresolved_reason",
942                        FieldClass::Enum,
943                        r,
944                    )
945                    .with("scope", "rustev.unresolved.scope", FieldClass::Enum, "step")
946                    .with(
947                        "rustev.plan.id",
948                        "rustev.plan.id",
949                        FieldClass::PlanMetadata,
950                        &f.plan_id,
951                    )
952                    .done(),
953                1,
954            );
955        }
956        if f.fallbacks > 0 {
957            m.add(
958                name::FALLBACKS,
959                AttrSet::new(p)
960                    .with(
961                        "kind",
962                        "rustev.fallbacks.kind",
963                        FieldClass::Enum,
964                        "runtime_fallback",
965                    )
966                    .done(),
967                f.fallbacks,
968            );
969        }
970        record(
971            &mut m,
972            &*s.exporter,
973            name::DECISION_DURATION,
974            vec![],
975            f.elapsed_ms as f64,
976        );
977        record(
978            &mut m,
979            &*s.exporter,
980            name::DECISION_COST,
981            AttrSet::new(p)
982                .with(
983                    "rustev.cost.final",
984                    "rustev.cost.final",
985                    FieldClass::Enum,
986                    &f.final_cost,
987                )
988                .done(),
989            f.cost_units as f64,
990        );
991        for a in &f.attempts {
992            record(
993                &mut m,
994                &*s.exporter,
995                name::ATTEMPT_DURATION,
996                AttrSet::new(p)
997                    .with(
998                        "rustev.step.kind",
999                        "rustev.step.kind",
1000                        FieldClass::Enum,
1001                        &a.step_kind,
1002                    )
1003                    .with(
1004                        "rustev.attempt.end",
1005                        "rustev.attempt.end",
1006                        FieldClass::Enum,
1007                        &a.end,
1008                    )
1009                    .with(
1010                        "rustev.attempt.backend_id",
1011                        "rustev.attempt.backend_id",
1012                        FieldClass::PlanMetadata,
1013                        &a.backend_id,
1014                    )
1015                    .done(),
1016                a.duration_ms as f64,
1017            );
1018            let Some(g) = &a.genai else { continue };
1019            let base = |kind: Option<&str>| {
1020                let mut b = AttrSet::new(p);
1021                if let Some(k) = kind {
1022                    b = b.with(
1023                        "gen_ai.token.type",
1024                        "gen_ai.token.type",
1025                        FieldClass::Enum,
1026                        k,
1027                    );
1028                }
1029                if let Some(op) = &g.operation {
1030                    b = b.with(
1031                        "gen_ai.operation.name",
1032                        "gen_ai.operation.name",
1033                        FieldClass::Enum,
1034                        op,
1035                    );
1036                }
1037                b.with(
1038                    "gen_ai.provider.name",
1039                    "gen_ai.provider.name",
1040                    FieldClass::ModelIdentity,
1041                    &g.provider,
1042                )
1043                .with(
1044                    "gen_ai.request.model",
1045                    "gen_ai.request.model",
1046                    FieldClass::ModelIdentity,
1047                    &g.request_model,
1048                )
1049                .done()
1050            };
1051            if g.input_tokens.is_some() || g.output_tokens.is_some() {
1052                if let Some(n) = g.input_tokens {
1053                    record(
1054                        &mut m,
1055                        &*s.exporter,
1056                        name::TOKEN_USAGE,
1057                        base(Some("input")),
1058                        n as f64,
1059                    );
1060                }
1061                if let Some(n) = g.output_tokens {
1062                    record(
1063                        &mut m,
1064                        &*s.exporter,
1065                        name::TOKEN_USAGE,
1066                        base(Some("output")),
1067                        n as f64,
1068                    );
1069                }
1070                // Seconds, as the GenAI conventions state.
1071                record(
1072                    &mut m,
1073                    &*s.exporter,
1074                    name::OPERATION_DURATION,
1075                    base(None),
1076                    a.duration_ms as f64 / 1000.0,
1077                );
1078            }
1079        }
1080    }
1081}
1082
1083/// A failed delivery, and whether a loss reason already counts it.
1084#[derive(Debug)]
1085struct Failure {
1086    message: String,
1087    counted: bool,
1088}
1089
1090/// Span ids drawn under the tables' lock before projecting.
1091#[derive(Default)]
1092struct PreDrawn {
1093    trace: Option<[u8; 16]>,
1094    spans: VecDeque<[u8; 8]>,
1095    fallback: Option<RandomIds>,
1096}
1097
1098impl IdSource for PreDrawn {
1099    fn trace_id(&mut self) -> [u8; 16] {
1100        self.trace.take().unwrap_or_else(|| {
1101            self.fallback
1102                .get_or_insert_with(RandomIds::default)
1103                .trace_id()
1104        })
1105    }
1106
1107    fn span_id(&mut self) -> [u8; 8] {
1108        self.spans.pop_front().unwrap_or_else(|| {
1109            self.fallback
1110                .get_or_insert_with(RandomIds::default)
1111                .span_id()
1112        })
1113    }
1114}
1115
1116/// OpenTelemetry's trace-id-ratio rule: the trace id's last eight bytes,
1117/// shifted right by one, below `ratio * 2^63`.
1118pub fn ratio_sampled(trace_id: &[u8; 16], per_million: u32) -> bool {
1119    let mut last = [0u8; 8];
1120    last.copy_from_slice(&trace_id[8..]);
1121    let rnd = u64::from_be_bytes(last) >> 1;
1122    let bound = ((u128::from(per_million) << 63) / 1_000_000) as u64;
1123    rnd < bound
1124}
1125
1126/// Record one histogram value and hand it to the exporter's own
1127/// instruments.
1128fn record(
1129    m: &mut Instruments,
1130    exporter: &dyn TelemetryExporter,
1131    instrument: &'static str,
1132    attrs: crate::metrics::Attrs,
1133    value: f64,
1134) {
1135    exporter.observe(&Measurement {
1136        instrument,
1137        attrs: &attrs,
1138        value,
1139    });
1140    m.record(instrument, attrs, value);
1141}
1142
1143/// The log records of a sampled decision (3.6.3).
1144fn logs_of(p: &Projected) -> Vec<LogRecord> {
1145    let mut out = vec![];
1146    // The projection always writes the root first; a projection without
1147    // lines has nothing to log.
1148    let Some(root) = p.lines.first() else {
1149        return out;
1150    };
1151    let f = &p.facts;
1152    let body = match (f.outcome.as_str(), f.termination.as_str()) {
1153        (_, "cancelled") => Some("decision cancelled".to_string()),
1154        ("unresolved", _) => Some(format!(
1155            "decision unresolved: {}",
1156            f.outcome_unresolved_reason.as_deref().unwrap_or("unknown")
1157        )),
1158        ("escalate", _) => Some("decision escalated".to_string()),
1159        _ => None,
1160    };
1161    let mk = |l: &TraceLine, body: String| LogRecord {
1162        time_unix_nanos: l.end_unix_nanos,
1163        trace_id: l.trace_id.clone(),
1164        span_id: l.span_id.clone(),
1165        severity_text: "WARN",
1166        severity_number: 13,
1167        body,
1168        attributes: l.attributes.clone(),
1169    };
1170    if let Some(b) = body {
1171        out.push(mk(root, b));
1172    }
1173    for a in &f.attempts {
1174        // An attempt names the index of its own line; one out of range is
1175        // skipped rather than allowed to panic the sink.
1176        let Some(l) = p.lines.get(a.line) else {
1177            continue;
1178        };
1179        let body = match a.end.as_str() {
1180            "failed" => {
1181                let class = l
1182                    .attributes
1183                    .get("rustev.attempt.failure_class")
1184                    .and_then(|v| v.v.as_str())
1185                    .unwrap_or("unknown");
1186                format!("attempt failed: {class}")
1187            }
1188            "deadline" => "attempt deadline".to_string(),
1189            _ => continue,
1190        };
1191        out.push(mk(l, body));
1192    }
1193    out
1194}
1195
1196/// Run one export as its own task, bounded by `timeout_ms`: a panic is
1197/// `exporter_panicked`, the timeout `export_timeout` (the task is aborted).
1198async fn run_export<F>(timeout_ms: u64, fut: F) -> Result<(), &'static str>
1199where
1200    F: std::future::Future<Output = Result<(), ExportError>> + Send + 'static,
1201{
1202    let task = tokio::spawn(fut);
1203    let abort = task.abort_handle();
1204    match tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), task).await {
1205        Ok(Ok(Ok(()))) => Ok(()),
1206        Ok(Ok(Err(e))) => Err(e.reason()),
1207        Ok(Err(join)) if join.is_panic() => Err(loss::EXPORTER_PANICKED),
1208        Ok(Err(_)) => Err(loss::EXPORT_FAILED),
1209        Err(_) => {
1210            abort.abort();
1211            Err(loss::EXPORT_TIMEOUT)
1212        }
1213    }
1214}
1215
1216enum Next {
1217    Exit,
1218    Wait,
1219    Batch(Vec<Queued>),
1220}
1221
1222/// Take the next batch: whole trees, at most `batch_size` spans unless one
1223/// tree alone is larger.
1224fn next_batch(s: &Shared) -> Next {
1225    let mut q = lock(&s.queue);
1226    if q.items.is_empty() {
1227        return if q.closed { Next::Exit } else { Next::Wait };
1228    }
1229    let mut batch = vec![];
1230    let mut spans = 0usize;
1231    while let Some(front) = q.items.front() {
1232        let n = front.spans as usize;
1233        if !batch.is_empty() && spans + n > s.config.batch_size {
1234            break;
1235        }
1236        spans += n;
1237        if let Some(item) = q.items.pop_front() {
1238            batch.push(item);
1239        }
1240    }
1241    q.spans -= spans;
1242    q.exporting = true;
1243    Next::Batch(batch)
1244}
1245
1246async fn worker(s: Arc<Shared>) {
1247    loop {
1248        let notified = s.wake.notified();
1249        let batch = match next_batch(&s) {
1250            Next::Exit => {
1251                s.idle.notify_waiters();
1252                return;
1253            }
1254            Next::Wait => {
1255                s.idle.notify_waiters();
1256                notified.await;
1257                continue;
1258            }
1259            Next::Batch(b) => b,
1260        };
1261        let trees: Arc<Vec<DecisionTelemetry>> =
1262            Arc::new(batch.iter().map(|q| q.tree.clone()).collect());
1263        let logs: Vec<LogRecord> = trees.iter().flat_map(|t| t.logs.iter().cloned()).collect();
1264        let ex = s.exporter.clone();
1265        let spans = run_export(s.config.export_timeout_ms, async move {
1266            ex.export(&trees).await
1267        })
1268        .await;
1269        // Log records go in their own export call, after the spans, so each
1270        // signal is counted by what happened to it.
1271        let logs = if logs.is_empty() {
1272            Ok(())
1273        } else {
1274            let ex = s.exporter.clone();
1275            run_export(s.config.export_timeout_ms, async move {
1276                ex.export_logs(&logs).await
1277            })
1278            .await
1279        };
1280        {
1281            let mut st = lock(&s.stats);
1282            for item in &batch {
1283                st.spans.in_flight -= item.spans;
1284                st.logs.in_flight -= item.logs;
1285                match spans {
1286                    Ok(()) => st.spans.exported += item.spans,
1287                    Err(reason) => st.spans.lose(reason, item.spans),
1288                }
1289                match logs {
1290                    Ok(()) => st.logs.exported += item.logs,
1291                    Err(reason) => st.logs.lose(reason, item.logs),
1292                }
1293                // A decision is lost under the first reason that lost any
1294                // of its telemetry, and only once.
1295                let first = spans
1296                    .err()
1297                    .or(if item.logs > 0 { logs.err() } else { None });
1298                if let Some(reason) = first {
1299                    st.lose_decision(reason);
1300                }
1301            }
1302        }
1303        for item in batch {
1304            if let Some(w) = item.waiter {
1305                let r = spans.and(if item.logs > 0 { logs } else { Ok(()) });
1306                let _ = w.send(r);
1307            }
1308        }
1309        lock(&s.queue).exporting = false;
1310        s.idle.notify_waiters();
1311    }
1312}
1313
1314impl EvidenceSink for OtelSink {
1315    fn deliver<'a>(&'a self, record: &'a RunRecord) -> BoxFuture<'a, Result<String, String>> {
1316        Box::pin(async move {
1317            let enqueued = AtomicBool::new(false);
1318            self.deliver_record(record, &enqueued)
1319                .await
1320                .map_err(|f| f.message)
1321        })
1322    }
1323}
1324
1325/// Keeps the host's evidence store as the sink of record (3.8.3): the
1326/// receipt is the primary's; telemetry is delivered after the primary
1327/// answers, within its own timeout.
1328pub struct TeeSink {
1329    pub primary: Arc<dyn EvidenceSink>,
1330    pub telemetry: Arc<OtelSink>,
1331    /// When false (the default) a telemetry failure never changes the
1332    /// decision and is counted as `tee_failed`; when true it fails the
1333    /// delivery like the primary's own failure.
1334    pub require_telemetry: bool,
1335    pub telemetry_timeout_ms: u64,
1336}
1337
1338impl EvidenceSink for TeeSink {
1339    fn deliver<'a>(&'a self, record: &'a RunRecord) -> BoxFuture<'a, Result<String, String>> {
1340        Box::pin(async move {
1341            let receipt = self.primary.deliver(record).await?;
1342            let enqueued = AtomicBool::new(false);
1343            let t = tokio::time::timeout(
1344                std::time::Duration::from_millis(self.telemetry_timeout_ms),
1345                self.telemetry.deliver_record(record, &enqueued),
1346            )
1347            .await;
1348            let failure = match t {
1349                Ok(Ok(_)) => None,
1350                Ok(Err(f)) => Some(f),
1351                // Once queued, the worker counts the tree under its own
1352                // reason; before that, nothing has.
1353                Err(_) => Some(Failure {
1354                    message: "otel: telemetry delivery timed out".into(),
1355                    counted: enqueued.load(Ordering::SeqCst),
1356                }),
1357            };
1358            match failure {
1359                None => Ok(receipt),
1360                Some(f) if self.require_telemetry => Err(f.message),
1361                Some(f) => {
1362                    // Each decision's telemetry is lost under exactly one
1363                    // reason: `tee_failed` only when no other counts it.
1364                    if !f.counted {
1365                        self.telemetry.tee_failed();
1366                    }
1367                    Ok(receipt)
1368                }
1369            }
1370        })
1371    }
1372
1373    fn accepts(&self, schema: &str) -> bool {
1374        self.primary.accepts(schema) && self.telemetry.accepts(schema)
1375    }
1376}