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}