Skip to main content

plecto_host/
observe.rs

1//! ADR 000009 host observability stage. The native side — never the WASM filter — owns trace
2//! state and emits one span per filter execution. We borrow the OpenTelemetry **data model**
3//! (the `opentelemetry` API crate's `trace` types: `TraceId` / `SpanId` / `SpanKind` /
4//! `Status` / `KeyValue` / `Event`) and define a **sync** [`TelemetrySink`] seam. The async
5//! SDK `SpanExporter`, OTLP network export, and the wasi-otel guest contract are all
6//! named-deferred — the proxy stays no-tokio for now, and the sink maps to them later (the
7//! `config version` of the observability stack).
8//!
9//! Trace context is host-propagated: a [`RequestTrace`] (created by the chain driver, ADR
10//! 000009 "host manages span state across the filter boundary") parents every filter span, so
11//! a filter never manages its own trace context — it just runs, and the host times + records
12//! it. W3C `traceparent` in/out lets the (future) fast-path server continue an inbound trace.
13
14use 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
24// --- id generation -------------------------------------------------------------------------
25//
26// Trace/span ids need only be unique within a run (W3C ids are correlation handles, not
27// secrets, so a counter + a once-per-process time seed is enough — a cryptographic
28// RandomIdGenerator is a refinement). Ids must be non-zero (OTel treats all-zero as invalid),
29// which the `1`-based counter and the time seed guarantee.
30
31static 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/// The host-owned trace context for one request transaction. Created by the chain driver (the
58/// [`ConfigSnapshot`](crate) / fast-path server), it parents every filter span so the whole
59/// chain — request side and response side — shares one trace. Cheap to copy.
60///
61/// `request_span_id` is ALWAYS locally minted — it names the request (SERVER) span Plecto itself
62/// owns. An inbound `traceparent`'s span id is kept separately as `parent_span_id` (the remote
63/// parent), never reused as our own: emitting a span under an id another process minted would
64/// collide with that process's exporter (ADR 000040). This is the standard proxy shape — the
65/// upstream and every filter span nest under Plecto's request span.
66#[derive(Debug, Clone, Copy)]
67pub struct RequestTrace {
68    trace_id: TraceId,
69    /// The inbound (remote) span this transaction continues under, `None` for a local root.
70    parent_span_id: Option<SpanId>,
71    request_span_id: SpanId,
72    flags: TraceFlags,
73}
74
75impl RequestTrace {
76    /// Start a fresh, sampled root trace for a request with no inbound context.
77    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    /// Continue an inbound trace from a W3C `traceparent` (`00-{trace}-{span}-{flags}`). The
87    /// inbound span becomes this request's REMOTE PARENT; the request span id itself is minted
88    /// locally. Returns `None` on any malformed field (fail-soft: a bad header just starts no
89    /// continuation, never a panic on untrusted input).
90    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    /// Format as a W3C `traceparent` for downstream propagation. Carries the (locally-minted)
114    /// request span id, so the upstream's spans nest under Plecto's request span.
115    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    /// The request (root) span id — the parent of every filter span in this transaction.
129    pub fn request_span_id(&self) -> SpanId {
130        self.request_span_id
131    }
132
133    /// The inbound (remote) span this transaction continues under, or `None` for a local root.
134    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    /// A fresh child span id for one filter execution under this request.
143    pub(crate) fn new_child_span_id(&self) -> SpanId {
144        next_span_id()
145    }
146}
147
148/// What a filter execution resulted in — the union of its intentional `decision` and the
149/// `RunError` failure modes, so a span records traps and deadlines as faithfully as a
150/// `continue`. Maps to an OTel [`Status`] (the decisions are `Ok`; the faults are `Error`).
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum SpanOutcome {
153    Continue,
154    Modified,
155    ShortCircuit,
156    /// A response filter replaced the upstream response with a synthesised one (ADR 000073).
157    Replace,
158    Deadline,
159    Trap,
160    InstantiateError,
161    Unavailable,
162    /// The guest returned cleanly but its output failed header validation (ADR 000071) —
163    /// distinct from `Trap` so a misbehaving-but-alive filter is tellable from a crashing one.
164    InvalidOutput,
165}
166
167impl SpanOutcome {
168    /// The stable attribute value (`plecto.outcome`).
169    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    /// A filter that ran and returned a decision is `Ok`; a `RunError` fault is `Error`.
184    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/// One filter execution, as a span in the OTel data model. The host builds and emits one of
234/// these per `on_request` / `on_response` call; a [`TelemetrySink`] receives it.
235#[derive(Debug, Clone)]
236pub struct FilterSpan {
237    pub trace_id: TraceId,
238    pub span_id: SpanId,
239    pub parent_span_id: SpanId,
240    /// The filter id (the span name).
241    pub name: String,
242    pub kind: SpanKind,
243    pub start_time: SystemTime,
244    pub duration: Duration,
245    pub outcome: SpanOutcome,
246    /// `filter.id`, `plecto.isolation`, `plecto.outcome`, `plecto.hook`.
247    pub attributes: Vec<KeyValue>,
248    /// The filter's host-log lines, as span events (this is where dropped logs now land).
249    pub events: Vec<Event>,
250    /// The transaction's W3C sampled flag. An exporting sink (OTLP, ADR 000040) skips unsampled
251    /// spans; the in-process tally sinks count them all (metrics are not sampled).
252    pub sampled: bool,
253}
254
255impl FilterSpan {
256    /// The OTel status derived from the outcome.
257    pub fn status(&self) -> Status {
258        self.outcome.status()
259    }
260}
261
262/// Which hook produced a span (an attribute + a help for sinks/tests).
263#[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/// Build one filter span (host-internal). `at` is the time the call started; `logs` are the
298/// lines the filter emitted via host-log, recorded as span events.
299#[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
341/// Where the host sends each [`FilterSpan`]. Deliberately **sync** (the OTel SDK's
342/// `SpanExporter` is async and would pull tokio — ADR 000009 keeps that named-deferred): a sink
343/// must not block the data plane. `NoopSink` is the default; an OTLP-mapping sink is added when
344/// network export lands.
345pub trait TelemetrySink: Send + Sync {
346    fn export(&self, span: &FilterSpan);
347
348    /// Whether this sink consumes spans at all. When `false`, the host skips span construction
349    /// entirely on the per-request path — `build_filter_span` allocates (name, attributes, one
350    /// event per host-log line), which would otherwise be paid per hook call just to be dropped
351    /// by `export`. Defaults to `true`; only a sink that discards everything should override.
352    fn enabled(&self) -> bool {
353        true
354    }
355}
356
357/// The default: observability off, zero cost.
358#[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
369/// Cap on spans `InMemorySink` retains (CWE-770). This is a reference/test sink, not a
370/// production choice, but nothing in the type system stops a caller from wiring it into a live
371/// host's `TelemetrySink` — an unbounded `Vec` here would grow without limit under sustained
372/// request volume until the process runs out of memory. At capacity the oldest span is dropped
373/// to admit the newest (FIFO), mirroring `OtlpBuffer`'s bounded-queue discipline.
374const MAX_RETAINED_SPANS: usize = 10_000;
375
376/// A reference / test sink that retains up to `MAX_RETAINED_SPANS` spans in memory.
377#[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    /// A snapshot of every span captured so far.
388    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/// Host-aggregated RED-style metrics (ADR 000009 "metrics are host-aggregated"), derived from
412/// the span stream in-process: a sink that tallies rather than retains. Errors are the
413/// `RunError` outcomes (trap / deadline / instantiate / unavailable); short-circuits are
414/// counted separately (a filter blocking is not a fault).
415#[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            // Both are the "filter answered instead of the upstream" terminal decisions —
443            // request-side short-circuit and response-side replace (ADR 000073) — so the
444            // coarse counter tallies them together; the per-span outcome keeps them distinct.
445            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/// A point-in-time read of [`MetricsSink`].
463#[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
471/// Send every span to several sinks (e.g. export + aggregate at once). The host holds one
472/// sink; this composes many behind it.
473pub 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    /// Enabled iff ANY child is: without this override the trait default (`true`) would make
491    /// composing onto a `NoopSink` pay per-hook span construction (name / attribute / event
492    /// allocations) for spans no child would ever export.
493    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", // wrong version
557            "00-tooshort-00f067aa0ba902b7-01",
558            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra",
559            "00-00000000000000000000000000000000-00f067aa0ba902b7-01", // zero trace id
560        ] {
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); // a block is not a fault
585        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        // Regression test (CWE-770): nothing in the type system stops a caller from wiring this
640        // reference sink into a live host, so it must not grow without bound under sustained
641        // export volume — it drops the oldest span, not the newest, to admit each new one.
642        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}