Skip to main content

livekit_telemetry/
span.rs

1// Copyright 2026 LiveKit, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::{HashMap, VecDeque};
16use std::sync::Arc;
17
18use crate::{
19    event::{attributes_size_hint, now_unix_nanos},
20    scope::ScopeState,
21    Attribute, AttributeValue,
22};
23
24/// OTel span kind, restricted to what client operations need; implied by [`crate::SpanName`].
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub(crate) enum SpanKind {
27    /// An operation inside the SDK (publish, subscribe).
28    Internal,
29    /// A call to the SFU that waits for its answer (connect, reconnect).
30    Client,
31}
32
33/// How an attempt ended. OTel status knows only `Unset`/`Ok`/`Error`, so `Cancelled` travels as
34/// `status = Unset` plus the `lk.outcome` attribute — every span carries `lk.outcome` so rollups
35/// never have to infer it (a user hanging up mid-connect is not a failure).
36#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum SpanOutcome {
39    /// It did what it set out to do (OTel status `Ok`).
40    Ok,
41    /// It failed; `error.type` says how (OTel status `Error`).
42    Error,
43    /// It was called off before it finished, which is not a failure (OTel status `Unset`).
44    Cancelled,
45}
46
47impl SpanOutcome {
48    pub(crate) fn as_str(self) -> &'static str {
49        match self {
50            SpanOutcome::Ok => "ok",
51            SpanOutcome::Error => "error",
52            SpanOutcome::Cancelled => "cancelled",
53        }
54    }
55}
56
57/// A checkpoint inside a span (OTLP span event). Structural to one attempt — the connect
58/// sequence's `ws_open → join_recv → pc_connected → …` — hence in the span's own envelope rather
59/// than a standalone log record (OTEP 4430 keeps that legal).
60#[derive(Debug, Clone, PartialEq)]
61pub(crate) struct SpanEvent {
62    pub name: String,
63    pub time_ns: u64,
64}
65
66/// One attempt at an operation, from `begin_span` to `end_span`.
67#[derive(Debug, Clone, PartialEq)]
68pub(crate) struct SpanRecord {
69    pub span_id: u64,
70    pub parent_span_id: Option<u64>,
71    pub name: String,
72    pub kind: SpanKind,
73    pub start_ns: u64,
74    pub end_ns: u64,
75    pub outcome: SpanOutcome,
76    pub error_type: Option<String>,
77    pub attributes: Vec<Attribute>,
78    /// Attributes cut by [`MAX_ATTRIBUTES_PER_SPAN`] (OTLP `dropped_attributes_count`).
79    pub dropped_attributes: u32,
80    pub events: Vec<SpanEvent>,
81    /// Checkpoints past [`MAX_EVENTS_PER_SPAN`] (OTLP `dropped_events_count`).
82    pub dropped_events: u32,
83    /// The session (trace) the span belongs to.
84    pub session: Arc<ScopeState>,
85    /// The session's project when the span ended (see `QueuedEvent::route`).
86    pub route: Option<String>,
87}
88
89/// Encoded bytes of a span beyond its strings: ids, timestamps, kind, status and the
90/// `lk.outcome` and `error.type` attributes (calibrated like `TelemetryEvent`'s).
91const SPAN_OVERHEAD_BYTES: usize = 94;
92/// Encoded bytes of a span event beyond its name: its timestamp and framing.
93const SPAN_EVENT_OVERHEAD_BYTES: usize = 13;
94
95/// Checkpoints one span keeps: OTel's default span limit.
96const MAX_EVENTS_PER_SPAN: usize = 128;
97/// Attributes one span keeps: OTel's default span limit.
98const MAX_ATTRIBUTES_PER_SPAN: usize = 128;
99/// Spans a host can leave open before the oldest is abandoned (counted as dropped).
100const MAX_OPEN_SPANS: usize = 256;
101
102/// Open spans by handle, plus the finished ones waiting for the exporter.
103///
104/// Handles are opaque `u64`s minted here; the host keeps them (a Swift `Span` object, a Kotlin
105/// value) and never sees ambient context — that is the platform's job (task-locals, coroutine
106/// context, zones), not the FFI's.
107pub(crate) struct Spans {
108    open: HashMap<u64, SpanRecord>,
109    /// Insertion order of `open`, to abandon the oldest when the cap is hit.
110    open_order: VecDeque<u64>,
111    finished: VecDeque<SpanRecord>,
112    finished_capacity: usize,
113    /// Which session every recent span belongs to — open, finished or already exported — so a
114    /// log record that arrives after its span ended (a warning logged right before a failing
115    /// publish ends, delivered a hop later) is still filed under the right session.
116    sessions: HashMap<u64, Arc<ScopeState>>,
117    /// Spans that ended or were abandoned, oldest first. Only these age out of `sessions`: an
118    /// open span keeps its session however many spans start after it.
119    session_order: VecDeque<u64>,
120    next_id: u64,
121    dropped: u64,
122    warned: Warned,
123    /// The opt-out, checked under the lock that guards the registry (see `Store`).
124    revoked: Arc<std::sync::atomic::AtomicBool>,
125}
126
127/// Which limits were hit since the last drain: each one's first hit logs a warning, the rest are
128/// only counted. One flag per limit, so hitting one never silences another.
129#[derive(Default)]
130struct Warned {
131    /// A full buffer (open or finished spans).
132    full: bool,
133    attributes: bool,
134    events: bool,
135}
136
137/// Log `warning` unless this limit's flag says it already did.
138fn warn_once(warned: &mut bool, warning: std::fmt::Arguments<'_>) {
139    if !*warned {
140        *warned = true;
141        log::warn!("{warning}");
142    }
143}
144
145/// Ended spans whose session stays resolvable.
146// ponytail: a fixed ring; a time-based expiry if a long session ever ends more spans than this
147// between a log and its export.
148const REMEMBERED_SPANS: usize = 1024;
149
150impl Spans {
151    /// A registry keeping at most `finished_capacity` finished spans for the exporter (at least
152    /// one: zero is treated as one).
153    pub fn new(finished_capacity: usize) -> Self {
154        Self {
155            open: HashMap::new(),
156            open_order: VecDeque::new(),
157            finished: VecDeque::new(),
158            finished_capacity: finished_capacity.max(1),
159            sessions: HashMap::new(),
160            session_order: VecDeque::new(),
161            // A random odd 63-bit start: never zero (OTLP treats all-zero as absent), and two
162            // pipelines in one process are very unlikely to overlap. 63 bits: a platform whose
163            // integers are signed (Dart) must be able to hand an id back.
164            next_id: (rand::random::<u64>() >> 1) | 1,
165            dropped: 0,
166            warned: Warned::default(),
167            revoked: Arc::default(),
168        }
169    }
170
171    /// Tie the registry to a pipeline's opt-out.
172    pub fn with_consent(mut self, revoked: Arc<std::sync::atomic::AtomicBool>) -> Self {
173        self.revoked = revoked;
174        self
175    }
176
177    fn revoked(&self) -> bool {
178        self.revoked.load(std::sync::atomic::Ordering::SeqCst)
179    }
180
181    /// Open a span in `session`'s trace. After the opt-out nothing opens: returns 0, which is
182    /// never a span id.
183    pub fn begin_in(
184        &mut self,
185        name: &str,
186        kind: SpanKind,
187        parent: Option<u64>,
188        session: Arc<ScopeState>,
189    ) -> u64 {
190        if self.revoked() {
191            return 0;
192        }
193        let id = self.next_id;
194        self.next_id = ((self.next_id + 1) & (u64::MAX >> 1)).max(1);
195        if self.open.len() >= MAX_OPEN_SPANS {
196            if let Some(oldest) = self.open_order.pop_front() {
197                self.open.remove(&oldest);
198                self.retire(oldest);
199                self.count_drop("open", MAX_OPEN_SPANS);
200            }
201        }
202        self.sessions.insert(id, session.clone());
203        let record = SpanRecord {
204            span_id: id,
205            parent_span_id: parent.filter(|p| *p != 0),
206            name: name.to_owned(),
207            kind,
208            start_ns: now_unix_nanos(),
209            end_ns: 0,
210            outcome: SpanOutcome::Ok,
211            error_type: None,
212            attributes: Vec::new(),
213            dropped_attributes: 0,
214            events: Vec::new(),
215            dropped_events: 0,
216            session,
217            route: None,
218        };
219        self.open.insert(id, record);
220        self.open_order.push_back(id);
221        id
222    }
223
224    /// The session a recent span belongs to — open, ended or exported (log records emitted inside
225    /// a span are filed there, and they may arrive after the span ended).
226    pub fn scope_of(&self, id: u64) -> Option<Arc<ScopeState>> {
227        self.sessions.get(&id).cloned()
228    }
229
230    #[cfg(test)]
231    pub fn begin(&mut self, name: &str, kind: SpanKind, parent: Option<u64>) -> u64 {
232        self.begin_in(name, kind, parent, ScopeState::new())
233    }
234
235    #[cfg(test)]
236    pub fn open_count(&self) -> usize {
237        self.open.len()
238    }
239
240    /// Whether a span with one of these names is still open (the exporter holds uploads while
241    /// `lk.connect` / `lk.reconnect` are).
242    pub fn any_open(&self, names: &[&str]) -> bool {
243        self.open.values().any(|span| names.contains(&span.name.as_str()))
244    }
245
246    /// Record a checkpoint on an open span. Ignored once the span ended, as OTel ignores any call
247    /// on an ended span; a log record carrying its id still lands in its session. Past
248    /// [`MAX_EVENTS_PER_SPAN`] it is dropped, counted in [`SpanRecord::dropped_events`] and
249    /// warned about once per drain.
250    pub fn add_event(&mut self, id: u64, name: &str) {
251        if self.revoked() {
252            return;
253        }
254        let Some(span) = self.open.get_mut(&id) else { return };
255        if span.events.len() >= MAX_EVENTS_PER_SPAN {
256            span.dropped_events = span.dropped_events.saturating_add(1);
257            warn_once(
258                &mut self.warned.events,
259                format_args!(
260                    "{} over {MAX_EVENTS_PER_SPAN} checkpoints: dropping the rest",
261                    span.name
262                ),
263            );
264            return;
265        }
266        span.events.push(SpanEvent { name: name.to_owned(), time_ns: now_unix_nanos() });
267    }
268
269    /// Close a span; the finished record waits for the next export. Unknown ids are ignored
270    /// (double `end` is harmless, like OTel's).
271    ///
272    /// Its attributes are taken now, like a log record's when it is queued: `attributes`, then
273    /// the app's correlation attributes, cut to [`MAX_ATTRIBUTES_PER_SPAN`] — the app's go first,
274    /// then the tail of `attributes` — counted in [`SpanRecord::dropped_attributes`] and warned
275    /// about once per drain. The session's own (`lk.room.*`, `session.id`) and the outcome are
276    /// added past the cap and always ship.
277    pub fn end(
278        &mut self,
279        id: u64,
280        outcome: SpanOutcome,
281        error_type: Option<String>,
282        mut attributes: Vec<Attribute>,
283    ) {
284        if self.revoked() {
285            return;
286        }
287        let Some(mut span) = self.open.remove(&id) else { return };
288        self.open_order.retain(|open| *open != id);
289        span.end_ns = now_unix_nanos().max(span.start_ns);
290        span.outcome = outcome;
291        span.error_type = error_type;
292        span.session.snapshot_custom(&mut attributes);
293        span.route = span.session.route();
294        let excess = attributes.len().saturating_sub(MAX_ATTRIBUTES_PER_SPAN);
295        if excess > 0 {
296            attributes.truncate(MAX_ATTRIBUTES_PER_SPAN);
297            span.dropped_attributes = u32::try_from(excess).unwrap_or(u32::MAX);
298            warn_once(
299                &mut self.warned.attributes,
300                format_args!(
301                    "{} over {MAX_ATTRIBUTES_PER_SPAN} attributes: dropping {excess}",
302                    span.name
303                ),
304            );
305        }
306        span.session.decorate(&mut attributes);
307        span.attributes = attributes;
308        self.retire(id);
309        if self.finished.len() >= self.finished_capacity {
310            self.finished.pop_front();
311            self.count_drop("finished", self.finished_capacity);
312        }
313        self.finished.push_back(span);
314    }
315
316    /// A span ended or was abandoned: its session stays resolvable until [`REMEMBERED_SPANS`]
317    /// more have.
318    fn retire(&mut self, id: u64) {
319        self.session_order.push_back(id);
320        if self.session_order.len() > REMEMBERED_SPANS {
321            if let Some(old) = self.session_order.pop_front() {
322                self.sessions.remove(&old);
323            }
324        }
325    }
326
327    /// Count a span dropped from a full buffer.
328    fn count_drop(&mut self, buffer: &str, capacity: usize) {
329        self.dropped += 1;
330        warn_once(
331            &mut self.warned.full,
332            format_args!("{buffer} spans full ({capacity}): dropping the oldest"),
333        );
334    }
335
336    /// Take the finished spans, oldest first.
337    /// At most `max` spans and about `max_bytes` (at least one when `max > 0`, so an oversized
338    /// span still ships).
339    pub fn drain(&mut self, max: usize, max_bytes: usize) -> Vec<SpanRecord> {
340        self.warned = Warned::default();
341        let (mut n, mut bytes) = (0, 0);
342        for span in self.finished.iter().take(max) {
343            let size = span.size_hint();
344            if n > 0 && bytes + size > max_bytes {
345                break;
346            }
347            bytes += size;
348            n += 1;
349        }
350        self.finished.drain(..n).collect()
351    }
352
353    /// Forget every open and finished span (opt-out); returns how many went.
354    pub fn clear(&mut self) -> u64 {
355        let n = self.open.len() + self.finished.len();
356        self.open.clear();
357        self.open_order.clear();
358        self.finished.clear();
359        self.sessions.clear();
360        self.session_order.clear();
361        self.warned = Warned::default();
362        n as u64
363    }
364
365    pub fn take_dropped(&mut self) -> u64 {
366        std::mem::take(&mut self.dropped)
367    }
368}
369
370impl SpanRecord {
371    /// Rough encoded size, like [`TelemetryEvent::size_hint`](crate::TelemetryEvent::size_hint).
372    pub(crate) fn size_hint(&self) -> usize {
373        SPAN_OVERHEAD_BYTES
374            + self.name.len()
375            + self.error_type.as_ref().map_or(0, String::len)
376            + attributes_size_hint(&self.attributes)
377            + self.events.iter().map(|e| SPAN_EVENT_OVERHEAD_BYTES + e.name.len()).sum::<usize>()
378    }
379
380    /// `lk.outcome`, plus `error.type` when the span failed: the attributes every span carries
381    /// beyond the caller's.
382    pub(crate) fn outcome_attributes(&self) -> Vec<Attribute> {
383        let mut attributes = vec![Attribute::new("lk.outcome", self.outcome.as_str())];
384        if let (SpanOutcome::Error, Some(error_type)) = (self.outcome, &self.error_type) {
385            attributes.push(Attribute::new("error.type", AttributeValue::Str(error_type.clone())));
386        }
387        attributes
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn spans_open_record_events_and_finish_in_order() {
397        let mut spans = Spans::new(8);
398        let parent = spans.begin("lk.connect", SpanKind::Client, None);
399        let child = spans.begin("lk.publish", SpanKind::Internal, Some(parent));
400        spans.add_event(parent, "ws_open");
401        spans.end(child, SpanOutcome::Cancelled, None, vec![]);
402        spans.end(
403            parent,
404            SpanOutcome::Error,
405            Some("timeout".into()),
406            vec![Attribute::new("lk.connect.attempt", 1i64)],
407        );
408        spans.end(parent, SpanOutcome::Ok, None, vec![]); // double end: ignored
409
410        let finished = spans.drain(10, usize::MAX);
411        assert_eq!(finished.len(), 2);
412        assert_eq!(finished[0].name, "lk.publish");
413        assert_eq!(finished[0].parent_span_id, Some(parent));
414        assert_eq!(finished[0].outcome, SpanOutcome::Cancelled);
415        assert_eq!(finished[1].events[0].name, "ws_open");
416        assert_eq!(finished[1].error_type.as_deref(), Some("timeout"));
417        assert!(finished[1].end_ns >= finished[1].start_ns);
418        assert_eq!(spans.take_dropped(), 0);
419    }
420
421    #[test]
422    fn open_spans_keep_their_session_however_many_spans_end_after_them() {
423        let mut spans = Spans::new(REMEMBERED_SPANS + 1);
424        let room = ScopeState::new();
425        let connect = spans.begin_in("lk.connect", SpanKind::Client, None, room.clone());
426        let first = spans.begin("lk.publish", SpanKind::Internal, None);
427        spans.end(first, SpanOutcome::Ok, None, vec![]);
428        for _ in 0..REMEMBERED_SPANS {
429            let id = spans.begin("lk.publish", SpanKind::Internal, None);
430            spans.end(id, SpanOutcome::Ok, None, vec![]);
431        }
432        assert!(spans.scope_of(first).is_none(), "ended spans age out");
433        assert_eq!(spans.scope_of(connect), Some(room.clone()));
434        spans.end(connect, SpanOutcome::Error, Some("timeout".into()), vec![]);
435        assert_eq!(spans.scope_of(connect), Some(room), "and stays resolvable once it ended");
436        assert_eq!(spans.sessions.len(), REMEMBERED_SPANS);
437    }
438
439    #[test]
440    fn abandoned_spans_are_counted_warned_once_and_age_out() {
441        crate::test_log::capture();
442        let mut spans = Spans::new(8);
443        let ids: Vec<_> = (0..MAX_OPEN_SPANS + REMEMBERED_SPANS + 1)
444            .map(|_| spans.begin("lk.publish", SpanKind::Internal, None))
445            .collect();
446        assert_eq!(spans.open_count(), MAX_OPEN_SPANS);
447        assert_eq!(spans.take_dropped(), REMEMBERED_SPANS as u64 + 1);
448        let warning = "open spans full (256): dropping the oldest";
449        assert_eq!(crate::test_log::take(), [warning], "the first drop logged, the rest counted");
450        assert!(spans.scope_of(ids[0]).is_none());
451        assert_eq!(spans.sessions.len(), MAX_OPEN_SPANS + REMEMBERED_SPANS);
452        spans.drain(10, usize::MAX);
453        spans.begin("lk.publish", SpanKind::Internal, None);
454        assert_eq!(crate::test_log::take(), [warning], "a drain starts a new episode");
455    }
456
457    #[test]
458    fn zero_finished_capacity_keeps_one_span_instead_of_panicking() {
459        let mut spans = Spans::new(0);
460        for _ in 0..2 {
461            let id = spans.begin("lk.publish", SpanKind::Internal, None);
462            spans.end(id, SpanOutcome::Ok, None, vec![]);
463        }
464        assert_eq!(spans.drain(10, usize::MAX).len(), 1);
465        assert_eq!(spans.take_dropped(), 1);
466    }
467
468    #[test]
469    fn clear_starts_a_new_warning_episode() {
470        let mut spans = Spans::new(1);
471        for _ in 0..2 {
472            let id = spans.begin("lk.publish", SpanKind::Internal, None);
473            spans.end(id, SpanOutcome::Ok, None, vec![]);
474        }
475        assert!(spans.warned.full);
476        assert_eq!(spans.clear(), 1);
477        assert!(!spans.warned.full);
478    }
479
480    /// The finished spans, drained and encoded as OTLP.
481    fn encoded(spans: &mut Spans) -> Vec<crate::proto::opentelemetry::proto::trace::v1::Span> {
482        use crate::proto::opentelemetry::proto::collector::trace::v1::ExportTraceServiceRequest;
483        use prost::Message;
484
485        let bytes = crate::otlp::encode_spans(&[], &[], spans.drain(10, usize::MAX));
486        let mut decoded = ExportTraceServiceRequest::decode(&bytes[..]).expect("valid OTLP");
487        std::mem::take(&mut decoded.resource_spans[0].scope_spans[0].spans)
488    }
489
490    #[test]
491    fn attributes_over_the_cap_are_counted_warned_once_and_spare_the_sessions() {
492        crate::test_log::capture();
493        let mut spans = Spans::new(8);
494        let session = ScopeState::new();
495        session.set_attribute("lk.room.sid", Some("RM_1".into()));
496        assert!(session.set_custom("app.call_id", Some("c1".into())));
497        let overflow = |spans: &mut Spans| {
498            let id = spans.begin_in("lk.publish", SpanKind::Internal, None, session.clone());
499            let own = (0..MAX_ATTRIBUTES_PER_SPAN)
500                .map(|i| Attribute::new(format!("lk.publish.k{i}"), i as i64))
501                .collect();
502            spans.end(id, SpanOutcome::Ok, None, own);
503        };
504        overflow(&mut spans);
505        overflow(&mut spans);
506        let warning = "lk.publish over 128 attributes: dropping 1";
507        assert_eq!(crate::test_log::take(), [warning], "two overflows, one warning");
508        for span in encoded(&mut spans) {
509            assert_eq!(span.dropped_attributes_count, 1, "the app's call id");
510            let keys: Vec<_> = span.attributes.iter().map(|kv| kv.key.as_str()).collect();
511            assert!(!keys.contains(&"app.call_id"), "the app's own go first");
512            for key in ["lk.publish.k127", "lk.room.sid", "session.id", "lk.outcome"] {
513                assert!(keys.contains(&key), "{key} survives the cap");
514            }
515        }
516        overflow(&mut spans);
517        assert_eq!(crate::test_log::take(), [warning], "a drain starts a new episode");
518        assert_eq!(spans.take_dropped(), 0, "no span was lost");
519    }
520
521    #[test]
522    fn an_attribute_overflow_does_not_silence_a_full_buffer() {
523        crate::test_log::capture();
524        let mut spans = Spans::new(1);
525        let first = spans.begin("lk.publish", SpanKind::Internal, None);
526        spans.end(first, SpanOutcome::Ok, None, vec![]);
527        let id = spans.begin("lk.connect", SpanKind::Client, None);
528        let own = (0..=MAX_ATTRIBUTES_PER_SPAN).map(|i| Attribute::new(format!("k{i}"), 1i64));
529        spans.end(id, SpanOutcome::Ok, None, own.collect());
530        assert_eq!(
531            crate::test_log::take(),
532            [
533                "lk.connect over 128 attributes: dropping 1",
534                "finished spans full (1): dropping the oldest"
535            ],
536            "one `end`, two limits, two warnings"
537        );
538    }
539
540    #[test]
541    fn checkpoints_over_the_cap_are_counted_and_warned_once() {
542        crate::test_log::capture();
543        let mut spans = Spans::new(8);
544        let overflow = |spans: &mut Spans| {
545            let id = spans.begin("lk.connect", SpanKind::Client, None);
546            for i in 0..=MAX_EVENTS_PER_SPAN {
547                spans.add_event(id, &format!("step {i}"));
548            }
549            spans.end(id, SpanOutcome::Ok, None, vec![]);
550        };
551        overflow(&mut spans);
552        overflow(&mut spans);
553        let warning = "lk.connect over 128 checkpoints: dropping the rest";
554        assert_eq!(crate::test_log::take(), [warning], "two overflows, one warning");
555        for span in encoded(&mut spans) {
556            assert_eq!(span.dropped_events_count, 1);
557            assert_eq!(span.events.len(), MAX_EVENTS_PER_SPAN);
558            assert_eq!(span.events.last().map(|e| e.name.as_str()), Some("step 127"));
559        }
560        overflow(&mut spans);
561        assert_eq!(crate::test_log::take(), [warning], "a drain starts a new episode");
562    }
563
564    #[test]
565    fn finished_spans_are_bounded() {
566        let mut spans = Spans::new(1);
567        for _ in 0..2 {
568            let id = spans.begin("lk.publish", SpanKind::Internal, None);
569            spans.end(id, SpanOutcome::Ok, None, vec![]);
570        }
571        assert_eq!(spans.drain(10, usize::MAX).len(), 1);
572        assert_eq!(spans.take_dropped(), 1);
573    }
574}