1use 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
40pub 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#[derive(Debug, Clone, PartialEq)]
65pub struct LogRecord {
66 pub time_unix_nanos: u64,
67 pub trace_id: String,
69 pub span_id: String,
71 pub severity_text: &'static str,
72 pub severity_number: u8,
74 pub body: String,
75 pub attributes: BTreeMap<String, TraceAttr>,
76}
77
78#[derive(Debug, Clone, PartialEq)]
81pub struct DecisionTelemetry {
82 pub lines: Vec<TraceLine>,
83 pub tracestate: Option<String>,
85 pub sampled: bool,
87 pub logs: Vec<LogRecord>,
88}
89
90#[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#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum ExportError {
102 Failed(String),
104 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
126pub trait TelemetryExporter: Send + Sync + 'static {
128 fn export<'a>(
130 &'a self,
131 batch: &'a [DecisionTelemetry],
132 ) -> BoxFuture<'a, Result<(), ExportError>>;
133
134 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 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 fn observe(&self, _measurement: &Measurement<'_>) {}
151
152 fn takes_logs(&self) -> bool {
155 false
156 }
157
158 fn takes_metrics(&self) -> bool {
160 false
161 }
162}
163
164impl 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
189pub trait SinkClock: Send + Sync {
192 fn now_unix_nanos(&self) -> u64;
193 fn monotonic_ms(&self) -> u64;
194}
195
196#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
225pub struct Flow {
226 pub accepted: u64,
227 pub exported: u64,
228 pub in_flight: u64,
229 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 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
252pub struct OtelStats {
253 pub spans: Flow,
254 pub logs: Flow,
255 pub metric_exports: Flow,
256 pub decisions_lost: BTreeMap<&'static str, u64>,
259 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#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct Begun {
279 pub trace_id: String,
281 pub root_span_id: String,
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub enum BeginError {
288 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
342pub 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 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 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 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 pub fn observe_runtime(&self, gauges: impl Fn() -> Gauges + Send + Sync + 'static) {
422 *lock(&self.shared.gauges) = Some(Arc::new(gauges));
423 }
424
425 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 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 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 pub fn stats(&self) -> OtelStats {
502 lock(&self.shared.stats).clone()
503 }
504
505 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 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 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 fn tee_failed(&self) {
624 lock(&self.shared.stats).lose_decision(loss::TEE_FAILED);
625 }
626
627 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 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 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 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 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#[derive(Debug)]
1085struct Failure {
1086 message: String,
1087 counted: bool,
1088}
1089
1090#[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
1116pub 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
1126fn 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
1143fn logs_of(p: &Projected) -> Vec<LogRecord> {
1145 let mut out = vec![];
1146 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 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
1196async 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
1222fn 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 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 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
1325pub struct TeeSink {
1329 pub primary: Arc<dyn EvidenceSink>,
1330 pub telemetry: Arc<OtelSink>,
1331 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 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 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}