1use std::sync::OnceLock;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::{Duration, SystemTime};
17
18use opentelemetry::KeyValue;
19use opentelemetry::trace::{Event, SpanId, SpanKind, Status, TraceFlags, TraceId};
20use parking_lot::Mutex;
21
22use crate::{Isolation, LogLevel, LogLine, RequestDecision, ResponseDecision, RunError};
23
24static ID_SEED: OnceLock<u64> = OnceLock::new();
32static NEXT: AtomicU64 = AtomicU64::new(1);
33
34fn seed() -> u64 {
35 *ID_SEED.get_or_init(|| {
36 SystemTime::now()
37 .duration_since(SystemTime::UNIX_EPOCH)
38 .map(|d| d.as_nanos() as u64)
39 .unwrap_or(0x9E37_79B9_7F4A_7C15)
40 | 1
41 })
42}
43
44fn next_trace_id() -> TraceId {
45 let n = NEXT.fetch_add(1, Ordering::Relaxed);
46 let mut b = [0u8; 16];
47 b[..8].copy_from_slice(&seed().to_be_bytes());
48 b[8..].copy_from_slice(&n.to_be_bytes());
49 TraceId::from_bytes(b)
50}
51
52fn next_span_id() -> SpanId {
53 let n = NEXT.fetch_add(1, Ordering::Relaxed);
54 SpanId::from_bytes(n.to_be_bytes())
55}
56
57#[derive(Debug, Clone, Copy)]
67pub struct RequestTrace {
68 trace_id: TraceId,
69 parent_span_id: Option<SpanId>,
71 request_span_id: SpanId,
72 flags: TraceFlags,
73}
74
75impl RequestTrace {
76 pub fn root() -> Self {
78 Self {
79 trace_id: next_trace_id(),
80 parent_span_id: None,
81 request_span_id: next_span_id(),
82 flags: TraceFlags::SAMPLED,
83 }
84 }
85
86 pub fn from_traceparent(traceparent: &str) -> Option<Self> {
91 let mut parts = traceparent.split('-');
92 let (version, trace, span, flags) =
93 (parts.next()?, parts.next()?, parts.next()?, parts.next()?);
94 if parts.next().is_some() || version != "00" {
95 return None;
96 }
97 let tb: [u8; 16] = hex::decode(trace).ok()?.try_into().ok()?;
98 let sb: [u8; 8] = hex::decode(span).ok()?.try_into().ok()?;
99 let trace_id = TraceId::from_bytes(tb);
100 let inbound_span_id = SpanId::from_bytes(sb);
101 if trace_id == TraceId::INVALID || inbound_span_id == SpanId::INVALID {
102 return None;
103 }
104 let flag_byte = u8::from_str_radix(flags, 16).ok()?;
105 Some(Self {
106 trace_id,
107 parent_span_id: Some(inbound_span_id),
108 request_span_id: next_span_id(),
109 flags: TraceFlags::new(flag_byte),
110 })
111 }
112
113 pub fn to_traceparent(&self) -> String {
116 format!(
117 "00-{}-{}-{:02x}",
118 hex::encode(self.trace_id.to_bytes()),
119 hex::encode(self.request_span_id.to_bytes()),
120 if self.flags.is_sampled() { 1u8 } else { 0u8 },
121 )
122 }
123
124 pub fn trace_id(&self) -> TraceId {
125 self.trace_id
126 }
127
128 pub fn request_span_id(&self) -> SpanId {
130 self.request_span_id
131 }
132
133 pub fn parent_span_id(&self) -> Option<SpanId> {
135 self.parent_span_id
136 }
137
138 pub fn is_sampled(&self) -> bool {
139 self.flags.is_sampled()
140 }
141
142 pub(crate) fn new_child_span_id(&self) -> SpanId {
144 next_span_id()
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum SpanOutcome {
153 Continue,
154 Modified,
155 ShortCircuit,
156 Replace,
158 Deadline,
159 Trap,
160 InstantiateError,
161 Unavailable,
162 InvalidOutput,
165}
166
167impl SpanOutcome {
168 pub fn as_str(self) -> &'static str {
170 match self {
171 SpanOutcome::Continue => "continue",
172 SpanOutcome::Modified => "modified",
173 SpanOutcome::ShortCircuit => "short-circuit",
174 SpanOutcome::Replace => "replace",
175 SpanOutcome::Deadline => "deadline",
176 SpanOutcome::Trap => "trap",
177 SpanOutcome::InstantiateError => "instantiate-error",
178 SpanOutcome::Unavailable => "unavailable",
179 SpanOutcome::InvalidOutput => "invalid-output",
180 }
181 }
182
183 pub fn status(self) -> Status {
185 match self {
186 SpanOutcome::Continue
187 | SpanOutcome::Modified
188 | SpanOutcome::ShortCircuit
189 | SpanOutcome::Replace => Status::Ok,
190 SpanOutcome::Deadline
191 | SpanOutcome::Trap
192 | SpanOutcome::InstantiateError
193 | SpanOutcome::Unavailable
194 | SpanOutcome::InvalidOutput => Status::Error {
195 description: self.as_str().into(),
196 },
197 }
198 }
199}
200
201impl From<&RequestDecision> for SpanOutcome {
202 fn from(d: &RequestDecision) -> Self {
203 match d {
204 RequestDecision::Continue => SpanOutcome::Continue,
205 RequestDecision::Modified(_) => SpanOutcome::Modified,
206 RequestDecision::ShortCircuit(_) => SpanOutcome::ShortCircuit,
207 }
208 }
209}
210
211impl From<&ResponseDecision> for SpanOutcome {
212 fn from(d: &ResponseDecision) -> Self {
213 match d {
214 ResponseDecision::Continue => SpanOutcome::Continue,
215 ResponseDecision::Modified(_) => SpanOutcome::Modified,
216 ResponseDecision::Replace(_) => SpanOutcome::Replace,
217 }
218 }
219}
220
221impl From<&RunError> for SpanOutcome {
222 fn from(e: &RunError) -> Self {
223 match e {
224 RunError::Deadline => SpanOutcome::Deadline,
225 RunError::Trap(_) => SpanOutcome::Trap,
226 RunError::Instantiate(_) => SpanOutcome::InstantiateError,
227 RunError::Unavailable => SpanOutcome::Unavailable,
228 RunError::InvalidOutput => SpanOutcome::InvalidOutput,
229 }
230 }
231}
232
233#[derive(Debug, Clone)]
236pub struct FilterSpan {
237 pub trace_id: TraceId,
238 pub span_id: SpanId,
239 pub parent_span_id: SpanId,
240 pub name: String,
242 pub kind: SpanKind,
243 pub start_time: SystemTime,
244 pub duration: Duration,
245 pub outcome: SpanOutcome,
246 pub attributes: Vec<KeyValue>,
248 pub events: Vec<Event>,
250 pub sampled: bool,
253}
254
255impl FilterSpan {
256 pub fn status(&self) -> Status {
258 self.outcome.status()
259 }
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum Hook {
265 OnRequest,
266 OnRequestBody,
267 OnResponse,
268}
269
270impl Hook {
271 pub fn as_str(self) -> &'static str {
272 match self {
273 Hook::OnRequest => "on-request",
274 Hook::OnRequestBody => "on-request-body",
275 Hook::OnResponse => "on-response",
276 }
277 }
278}
279
280fn isolation_str(isolation: Isolation) -> &'static str {
281 match isolation {
282 Isolation::Trusted => "trusted",
283 Isolation::Untrusted => "untrusted",
284 }
285}
286
287fn level_str(level: LogLevel) -> &'static str {
288 match level {
289 LogLevel::Trace => "trace",
290 LogLevel::Debug => "debug",
291 LogLevel::Info => "info",
292 LogLevel::Warn => "warn",
293 LogLevel::Error => "error",
294 }
295}
296
297#[allow(clippy::too_many_arguments)]
300pub(crate) fn build_filter_span(
301 trace: &RequestTrace,
302 filter_id: &str,
303 isolation: Isolation,
304 hook: Hook,
305 outcome: SpanOutcome,
306 start_time: SystemTime,
307 duration: Duration,
308 logs: &[LogLine],
309) -> FilterSpan {
310 let events = logs
311 .iter()
312 .map(|line| {
313 Event::new(
314 line.message.clone(),
315 start_time,
316 vec![KeyValue::new("log.level", level_str(line.level))],
317 0,
318 )
319 })
320 .collect();
321 FilterSpan {
322 trace_id: trace.trace_id(),
323 span_id: trace.new_child_span_id(),
324 parent_span_id: trace.request_span_id(),
325 name: filter_id.to_string(),
326 kind: SpanKind::Internal,
327 start_time,
328 duration,
329 outcome,
330 attributes: vec![
331 KeyValue::new("filter.id", filter_id.to_string()),
332 KeyValue::new("plecto.isolation", isolation_str(isolation)),
333 KeyValue::new("plecto.outcome", outcome.as_str()),
334 KeyValue::new("plecto.hook", hook.as_str()),
335 ],
336 events,
337 sampled: trace.is_sampled(),
338 }
339}
340
341pub trait TelemetrySink: Send + Sync {
346 fn export(&self, span: &FilterSpan);
347
348 fn enabled(&self) -> bool {
353 true
354 }
355}
356
357#[derive(Debug, Default)]
359pub struct NoopSink;
360
361impl TelemetrySink for NoopSink {
362 fn export(&self, _span: &FilterSpan) {}
363
364 fn enabled(&self) -> bool {
365 false
366 }
367}
368
369const MAX_RETAINED_SPANS: usize = 10_000;
375
376#[derive(Debug, Default)]
378pub struct InMemorySink {
379 spans: Mutex<std::collections::VecDeque<FilterSpan>>,
380}
381
382impl InMemorySink {
383 pub fn new() -> Self {
384 Self::default()
385 }
386
387 pub fn spans(&self) -> Vec<FilterSpan> {
389 self.spans.lock().iter().cloned().collect()
390 }
391
392 pub fn len(&self) -> usize {
393 self.spans.lock().len()
394 }
395
396 pub fn is_empty(&self) -> bool {
397 self.spans.lock().is_empty()
398 }
399}
400
401impl TelemetrySink for InMemorySink {
402 fn export(&self, span: &FilterSpan) {
403 let mut spans = self.spans.lock();
404 if spans.len() >= MAX_RETAINED_SPANS {
405 spans.pop_front();
406 }
407 spans.push_back(span.clone());
408 }
409}
410
411#[derive(Debug, Default)]
416pub struct MetricsSink {
417 total: AtomicU64,
418 errors: AtomicU64,
419 short_circuits: AtomicU64,
420 duration_nanos: AtomicU64,
421}
422
423impl MetricsSink {
424 pub fn new() -> Self {
425 Self::default()
426 }
427
428 pub fn snapshot(&self) -> MetricsSnapshot {
429 MetricsSnapshot {
430 total: self.total.load(Ordering::Relaxed),
431 errors: self.errors.load(Ordering::Relaxed),
432 short_circuits: self.short_circuits.load(Ordering::Relaxed),
433 total_duration: Duration::from_nanos(self.duration_nanos.load(Ordering::Relaxed)),
434 }
435 }
436}
437
438impl TelemetrySink for MetricsSink {
439 fn export(&self, span: &FilterSpan) {
440 self.total.fetch_add(1, Ordering::Relaxed);
441 match span.outcome {
442 SpanOutcome::ShortCircuit | SpanOutcome::Replace => {
446 self.short_circuits.fetch_add(1, Ordering::Relaxed);
447 }
448 SpanOutcome::Deadline
449 | SpanOutcome::Trap
450 | SpanOutcome::InstantiateError
451 | SpanOutcome::Unavailable
452 | SpanOutcome::InvalidOutput => {
453 self.errors.fetch_add(1, Ordering::Relaxed);
454 }
455 SpanOutcome::Continue | SpanOutcome::Modified => {}
456 }
457 self.duration_nanos
458 .fetch_add(span.duration.as_nanos() as u64, Ordering::Relaxed);
459 }
460}
461
462#[derive(Debug, Clone, PartialEq, Eq)]
464pub struct MetricsSnapshot {
465 pub total: u64,
466 pub errors: u64,
467 pub short_circuits: u64,
468 pub total_duration: Duration,
469}
470
471pub struct FanOutSink {
474 sinks: Vec<std::sync::Arc<dyn TelemetrySink>>,
475}
476
477impl FanOutSink {
478 pub fn new(sinks: Vec<std::sync::Arc<dyn TelemetrySink>>) -> Self {
479 Self { sinks }
480 }
481}
482
483impl TelemetrySink for FanOutSink {
484 fn export(&self, span: &FilterSpan) {
485 for sink in &self.sinks {
486 sink.export(span);
487 }
488 }
489
490 fn enabled(&self) -> bool {
494 self.sinks.iter().any(|s| s.enabled())
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 fn span(outcome: SpanOutcome, dur_ns: u64) -> FilterSpan {
503 let trace = RequestTrace::root();
504 build_filter_span(
505 &trace,
506 "f",
507 Isolation::Untrusted,
508 Hook::OnRequest,
509 outcome,
510 SystemTime::now(),
511 Duration::from_nanos(dur_ns),
512 &[],
513 )
514 }
515
516 #[test]
517 fn traceparent_continuation_keeps_trace_and_flags_but_mints_a_local_span_id() {
518 let tp = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
519 let t = RequestTrace::from_traceparent(tp).expect("valid traceparent parses");
520 assert!(t.is_sampled());
521 let inbound = SpanId::from_bytes([0x00, 0xf0, 0x67, 0xaa, 0x0b, 0xa9, 0x02, 0xb7]);
522 assert_eq!(
523 t.parent_span_id(),
524 Some(inbound),
525 "the inbound span is kept as the REMOTE PARENT"
526 );
527 assert_ne!(
528 t.request_span_id(),
529 inbound,
530 "the request span id is minted locally, never the caller's (ADR 000040)"
531 );
532 let out = t.to_traceparent();
533 assert!(
534 out.starts_with("00-4bf92f3577b34da6a3ce929d0e0e4736-") && out.ends_with("-01"),
535 "trace id + flags are preserved downstream, span id is Plecto's own: {out}"
536 );
537 assert_eq!(
538 out.split('-').nth(2),
539 Some(hex::encode(t.request_span_id().to_bytes()).as_str()),
540 "the propagated span id is the request span's"
541 );
542 }
543
544 #[test]
545 fn root_trace_has_no_remote_parent() {
546 let t = RequestTrace::root();
547 assert_eq!(t.parent_span_id(), None);
548 assert!(t.is_sampled());
549 }
550
551 #[test]
552 fn malformed_traceparent_is_none_not_panic() {
553 for bad in [
554 "",
555 "garbage",
556 "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", "00-tooshort-00f067aa0ba902b7-01",
558 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra",
559 "00-00000000000000000000000000000000-00f067aa0ba902b7-01", ] {
561 assert!(
562 RequestTrace::from_traceparent(bad).is_none(),
563 "malformed {bad:?} must be None"
564 );
565 }
566 }
567
568 #[test]
569 fn root_trace_parents_filter_spans_within_one_request() {
570 let trace = RequestTrace::root();
571 let a = trace.new_child_span_id();
572 let b = trace.new_child_span_id();
573 assert_ne!(a, b, "each filter span gets a distinct id");
574 assert_eq!(
575 trace.request_span_id(),
576 trace.request_span_id(),
577 "the request (parent) span id is stable across the transaction"
578 );
579 }
580
581 #[test]
582 fn outcome_status_maps_decisions_ok_and_faults_error() {
583 assert_eq!(SpanOutcome::Continue.status(), Status::Ok);
584 assert_eq!(SpanOutcome::ShortCircuit.status(), Status::Ok); assert!(matches!(SpanOutcome::Trap.status(), Status::Error { .. }));
586 assert!(matches!(
587 SpanOutcome::Deadline.status(),
588 Status::Error { .. }
589 ));
590 }
591
592 #[test]
593 fn metrics_sink_tallies_outcomes_and_latency() {
594 let m = MetricsSink::new();
595 m.export(&span(SpanOutcome::Continue, 1000));
596 m.export(&span(SpanOutcome::ShortCircuit, 2000));
597 m.export(&span(SpanOutcome::Trap, 3000));
598 let s = m.snapshot();
599 assert_eq!(s.total, 3);
600 assert_eq!(s.short_circuits, 1);
601 assert_eq!(s.errors, 1, "only the trap is a fault");
602 assert_eq!(s.total_duration, Duration::from_nanos(6000));
603 }
604
605 #[test]
606 fn in_memory_sink_retains_spans_with_log_events() {
607 let trace = RequestTrace::root();
608 let logs = vec![LogLine {
609 level: LogLevel::Info,
610 message: "hello".to_string(),
611 }];
612 let sp = build_filter_span(
613 &trace,
614 "auth",
615 Isolation::Trusted,
616 Hook::OnRequest,
617 SpanOutcome::Continue,
618 SystemTime::now(),
619 Duration::from_micros(10),
620 &logs,
621 );
622 let sink = InMemorySink::new();
623 sink.export(&sp);
624
625 let got = sink.spans();
626 assert_eq!(got.len(), 1);
627 assert_eq!(got[0].name, "auth");
628 assert_eq!(got[0].parent_span_id, trace.request_span_id());
629 assert_eq!(got[0].trace_id, trace.trace_id());
630 assert_eq!(
631 got[0].events.len(),
632 1,
633 "the host-log line became a span event"
634 );
635 }
636
637 #[test]
638 fn in_memory_sink_drops_oldest_past_the_retention_cap() {
639 let trace = RequestTrace::root();
643 let span_named = |name: &str| {
644 build_filter_span(
645 &trace,
646 name,
647 Isolation::Trusted,
648 Hook::OnRequest,
649 SpanOutcome::Continue,
650 SystemTime::now(),
651 Duration::from_micros(1),
652 &[],
653 )
654 };
655 let sink = InMemorySink::new();
656 for i in 0..(MAX_RETAINED_SPANS + 10) {
657 sink.export(&span_named(&i.to_string()));
658 }
659 assert_eq!(sink.len(), MAX_RETAINED_SPANS, "retention is capped");
660 let got = sink.spans();
661 assert_eq!(
662 got.first().unwrap().name,
663 "10",
664 "the oldest 10 spans were evicted, FIFO"
665 );
666 assert_eq!(
667 got.last().unwrap().name,
668 (MAX_RETAINED_SPANS + 9).to_string(),
669 "the newest span is retained"
670 );
671 }
672}