Skip to main content

livekit_telemetry/
stats.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::sync::atomic::{AtomicU64, Ordering};
16
17use crate::TelemetryEvent;
18
19/// Declares the pipeline's health counters once: the shared atomics ([`Counters`]), their
20/// point-in-time copy ([`Snapshot`]) and the delta between two copies.
21macro_rules! counters {
22    ($($(#[$doc:meta])* $name:ident,)*) => {
23        /// Pipeline health counters, shared by the store, the exporter and
24        /// [`Telemetry::stats`](crate::Telemetry::stats). Loss reasons follow the OpenTelemetry
25        /// SDK self-metrics conventions (`queue_full`, `rejected`, `timeout`) where one exists.
26        #[derive(Default)]
27        pub(crate) struct Counters {
28            $($(#[$doc])* pub $name: AtomicU64,)*
29        }
30
31        /// A point-in-time copy of [`Counters`].
32        #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33        pub(crate) struct Snapshot {
34            $(pub $name: u64,)*
35        }
36
37        impl Counters {
38            pub fn snapshot(&self) -> Snapshot {
39                Snapshot { $($name: self.$name.load(Ordering::Relaxed),)* }
40            }
41        }
42
43        impl Snapshot {
44            /// Counts accumulated since `earlier`.
45            pub fn since(&self, earlier: &Snapshot) -> Snapshot {
46                Snapshot { $($name: self.$name.saturating_sub(earlier.$name),)* }
47            }
48        }
49    };
50}
51
52counters! {
53    /// Records evicted from the in-memory queue (`max_queue_size`), and spans dropped from a full
54    /// span buffer (open spans abandoned, finished ones past its capacity).
55    queue_full,
56    /// Records lost because no cache, not even memory, could take their batch.
57    cache_error,
58    /// Records evicted from the cache by its size or file-count bound.
59    cache_full,
60    /// Records in cached batches past the 24 h age limit.
61    expired,
62    /// Records in cached batches that failed their integrity check (truncated, corrupt).
63    corrupt,
64    /// Records the core refused at the door: a custom event or attribute over the limits.
65    invalid,
66    /// Records the collector rejected: a final 4xx/5xx, or refused in a partial success.
67    rejected,
68    /// Single records larger than the collector accepts (413 down to one record).
69    oversized,
70    /// Records evicted from the cache while the collector held uploads off (`Retry-After`).
71    throttled,
72    /// Records for a project that receives nothing (self-hosted, no grant, disabled, 404).
73    disabled,
74    /// Discrete events dropped by the flood guard (`max_events_per_10min`).
75    rate_limited,
76    /// Records deleted by the opt-out.
77    purged,
78    /// Batches the collector accepted.
79    uploads_sent,
80    /// Compressed bytes the collector accepted — what telemetry actually cost the uplink.
81    upload_bytes,
82    /// Upload attempts that failed transiently before the timeout (a network error, 429, 5xx).
83    upload_failures,
84    /// Upload attempts that hit `export_timeout_ms` (a slow network, or a stalled collector).
85    upload_timeouts,
86    /// 401/403 answers: a token the collector refused (the batches wait for the next one).
87    auth_denied,
88    /// Soft holds that reached the cap and let one batch through: the policy was starving
89    /// telemetry, and data arrived late.
90    hold_cap_hits,
91    /// Batches the disk cache could not store (disk full, directory gone), kept in memory
92    /// instead: they survive a failed upload, not the process.
93    cache_write_errors,
94}
95
96impl Counters {
97    pub fn add(counter: &AtomicU64, n: u64) {
98        counter.fetch_add(n, Ordering::Relaxed);
99    }
100}
101
102impl Snapshot {
103    /// Everything that was lost, by any reason.
104    pub fn dropped(&self) -> u64 {
105        self.problem_drops() + self.disabled + self.purged
106    }
107
108    /// Upload attempts that did not get through: failures and timeouts together, the "failed"
109    /// of the report's body (the `lk.telemetry.uploads.failed` attribute counts failures only).
110    pub fn failed_attempts(&self) -> u64 {
111        self.upload_failures + self.upload_timeouts
112    }
113
114    /// Records lost other than by policy (a project that receives nothing, an opt-out).
115    pub fn problem_drops(&self) -> u64 {
116        self.queue_full
117            + self.cache_error
118            + self.cache_full
119            + self.expired
120            + self.corrupt
121            + self.invalid
122            + self.rejected
123            + self.oversized
124            + self.throttled
125            + self.rate_limited
126    }
127
128    /// Anything worth telling the backend about: data lost (other than by policy), uploads
129    /// failing or refused, holds starving uploads, the disk refusing writes.
130    pub fn has_problems(&self) -> bool {
131        self.problem_drops() > 0
132            || self.upload_failures > 0
133            || self.upload_timeouts > 0
134            || self.auth_denied > 0
135            || self.hold_cap_hits > 0
136            || self.cache_write_errors > 0
137    }
138
139    /// The `lk.telemetry.report` event: what this pipeline sent, dropped or failed to upload
140    /// since the previous report — deltas by reason, riding along with the next batch, never
141    /// persisted on its own, never an extra request — plus one at shutdown, so every session
142    /// leaves a summary the fleet's success rates can be computed from.
143    pub fn report(&self, cached_batches: u64) -> TelemetryEvent {
144        let mut event = TelemetryEvent::new("lk.telemetry.report")
145            .with_body(format!(
146                "telemetry: {} batches sent ({} B), {} failed, {} dropped, {} cached",
147                self.uploads_sent,
148                self.upload_bytes,
149                self.failed_attempts(),
150                self.problem_drops(),
151                cached_batches
152            ))
153            .with_attribute("lk.telemetry.uploads.sent", int(self.uploads_sent))
154            .with_attribute("lk.telemetry.uploads.bytes", int(self.upload_bytes))
155            .with_attribute("lk.telemetry.uploads.failed", int(self.upload_failures))
156            .with_attribute("lk.telemetry.cache.batches", int(cached_batches));
157        for (key, value) in [
158            ("lk.telemetry.uploads.timeouts", self.upload_timeouts),
159            ("lk.telemetry.uploads.unauthorized", self.auth_denied),
160            ("lk.telemetry.holds.capped", self.hold_cap_hits),
161            ("lk.telemetry.cache.write_errors", self.cache_write_errors),
162            ("lk.telemetry.dropped.queue_full", self.queue_full),
163            ("lk.telemetry.dropped.cache_error", self.cache_error),
164            ("lk.telemetry.dropped.cache_full", self.cache_full),
165            ("lk.telemetry.dropped.expired", self.expired),
166            ("lk.telemetry.dropped.corrupt", self.corrupt),
167            ("lk.telemetry.dropped.invalid", self.invalid),
168            ("lk.telemetry.dropped.rejected", self.rejected),
169            ("lk.telemetry.dropped.oversized", self.oversized),
170            ("lk.telemetry.dropped.throttled", self.throttled),
171            ("lk.telemetry.dropped.rate_limited", self.rate_limited),
172        ] {
173            if value > 0 {
174                event = event.with_attribute(key, int(value));
175            }
176        }
177        event
178    }
179}
180
181/// A count as an OTLP integer: past `i64::MAX` it saturates rather than wraps negative.
182fn int(count: u64) -> i64 {
183    i64::try_from(count).unwrap_or(i64::MAX)
184}
185
186/// What the upload policy is doing right now.
187#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum TelemetryStatus {
190    /// Uploading as data arrives.
191    Ok,
192    /// Uploads wait for a connect/reconnect to finish or for the device (Low Data Mode, low
193    /// battery); capped at 60 s.
194    Held,
195    /// The last upload failed; retrying after a jittered exponential backoff.
196    Paused,
197    /// The collector asked for a pause (`Retry-After`).
198    Throttled,
199    /// No destination or no usable token yet (no room connected, the token expired, lacks the
200    /// grant or was refused); everything waits in the cache for the next token.
201    Waiting,
202    /// No project receives telemetry: every server this process talked to is self-hosted, never
203    /// granted observability, has no ingest, or disabled data recording.
204    Off,
205}
206
207impl std::fmt::Display for TelemetryStatus {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        f.write_str(match self {
210            Self::Ok => "ok",
211            Self::Held => "held",
212            Self::Paused => "paused",
213            Self::Throttled => "throttled",
214            Self::Waiting => "waiting",
215            Self::Off => "off",
216        })
217    }
218}
219
220/// Pipeline health as seen by the SDK: [`Telemetry::stats`](crate::Telemetry::stats).
221#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct TelemetryStats {
224    /// What the upload policy is doing right now.
225    pub status: TelemetryStatus,
226    /// Records lost for any reason (sum of the `dropped_*` fields).
227    pub dropped: u64,
228    /// Records evicted from the in-memory queue, and spans dropped from a full span buffer.
229    pub dropped_queue_full: u64,
230    /// Records lost because no cache, not even memory, could take their batch.
231    pub dropped_cache_error: u64,
232    /// Records evicted from the cache by its size or file-count bound.
233    pub dropped_cache_full: u64,
234    /// Records in cached batches past the 24 h age limit.
235    pub dropped_expired: u64,
236    /// Records in cached batches that failed their integrity check.
237    pub dropped_corrupt: u64,
238    /// Custom events and attributes refused at the door, over the limits.
239    pub dropped_invalid: u64,
240    /// Records the collector rejected (a final 4xx/5xx, or a partial success).
241    pub dropped_rejected: u64,
242    /// Single records larger than the collector accepts.
243    pub dropped_oversized: u64,
244    /// Records evicted from the cache while the collector held uploads off.
245    pub dropped_throttled: u64,
246    /// Records for a project that receives nothing (self-hosted, no grant, disabled, 404).
247    pub dropped_disabled: u64,
248    /// Discrete events dropped by the flood guard.
249    pub dropped_rate_limited: u64,
250    /// Records deleted by the opt-out.
251    pub dropped_purged: u64,
252    /// Batches the collector accepted.
253    pub uploads_sent: u64,
254    /// Compressed bytes the collector accepted.
255    pub upload_bytes: u64,
256    /// Upload attempts that failed transiently before the timeout (a network error, 429, 5xx).
257    pub upload_failures: u64,
258    /// Upload attempts that got no answer within `export_timeout_ms`; not counted as failures.
259    pub upload_timeouts: u64,
260    /// Tokens the collector refused (401/403).
261    pub uploads_unauthorized: u64,
262    /// Soft holds that reached the cap.
263    pub holds_capped: u64,
264    /// Batches the disk cache could not store, kept in memory instead.
265    pub cache_write_errors: u64,
266    /// Batches currently waiting in the cache.
267    pub cached_batches: u64,
268}
269
270/// One line for a debug console: status, throughput, one backlog number, "failed" (failures and
271/// timeouts together, [`TelemetryStats::failed_attempts`]), one loss number, then the loss
272/// breakdown.
273impl std::fmt::Display for TelemetryStats {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        write!(
276            f,
277            "{}, sent {} ({} B), backlog {}, failed {}, lost {} (queue {}, cache {}, expired {}, \
278             corrupt {}, invalid {}, rejected {}, oversized {}, throttled {}, rate-limited {}, \
279             disabled {}, purged {}; unauthorized {}, holds capped {}, write errors {})",
280            self.status,
281            self.uploads_sent,
282            self.upload_bytes,
283            self.cached_batches,
284            self.failed_attempts(),
285            self.dropped,
286            self.dropped_queue_full,
287            self.dropped_cache_full + self.dropped_cache_error,
288            self.dropped_expired,
289            self.dropped_corrupt,
290            self.dropped_invalid,
291            self.dropped_rejected,
292            self.dropped_oversized,
293            self.dropped_throttled,
294            self.dropped_rate_limited,
295            self.dropped_disabled,
296            self.dropped_purged,
297            self.uploads_unauthorized,
298            self.holds_capped,
299            self.cache_write_errors,
300        )
301    }
302}
303
304impl TelemetryStats {
305    /// Upload attempts that did not get through: `upload_failures` and `upload_timeouts`
306    /// together, the "failed" of the `Display` line and of the report's body.
307    pub fn failed_attempts(&self) -> u64 {
308        self.upload_failures + self.upload_timeouts
309    }
310
311    pub(crate) fn new(snapshot: Snapshot, cached_batches: u64, status: TelemetryStatus) -> Self {
312        Self {
313            status,
314            dropped: snapshot.dropped(),
315            dropped_queue_full: snapshot.queue_full,
316            dropped_cache_error: snapshot.cache_error,
317            dropped_cache_full: snapshot.cache_full,
318            dropped_expired: snapshot.expired,
319            dropped_corrupt: snapshot.corrupt,
320            dropped_invalid: snapshot.invalid,
321            dropped_rejected: snapshot.rejected,
322            dropped_oversized: snapshot.oversized,
323            dropped_throttled: snapshot.throttled,
324            dropped_disabled: snapshot.disabled,
325            dropped_rate_limited: snapshot.rate_limited,
326            dropped_purged: snapshot.purged,
327            uploads_sent: snapshot.uploads_sent,
328            upload_bytes: snapshot.upload_bytes,
329            upload_failures: snapshot.upload_failures,
330            upload_timeouts: snapshot.upload_timeouts,
331            uploads_unauthorized: snapshot.auth_denied,
332            holds_capped: snapshot.hold_cap_hits,
333            cache_write_errors: snapshot.cache_write_errors,
334            cached_batches,
335        }
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::AttributeValue;
343
344    /// A count past `i64::MAX` reaches the report as `i64::MAX`, never as a negative number.
345    #[test]
346    fn report_counts_saturate_instead_of_wrapping() {
347        let snapshot = Snapshot { upload_failures: u64::MAX, ..Snapshot::default() };
348        let report = snapshot.report(u64::MAX);
349        let value =
350            |key: &str| report.attributes.iter().find(|a| a.key == key).map(|a| a.value.clone());
351        assert_eq!(value("lk.telemetry.uploads.failed"), Some(AttributeValue::Int(i64::MAX)));
352        assert_eq!(value("lk.telemetry.cache.batches"), Some(AttributeValue::Int(i64::MAX)));
353    }
354}