Skip to main content

livekit_telemetry/
event.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::time::{SystemTime, UNIX_EPOCH};
16
17/// A discrete telemetry event.
18///
19/// Exported as one OTLP log record whose `event_name` is [`name`](Self::name), following the
20/// OTel logs data model (events are log records with a top-level event name).
21///
22/// An unnamed `Info` record: a plain log record once it has a body (see `SPEC.md`).
23#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
24#[derive(Debug, Clone, PartialEq, Default)]
25pub struct TelemetryEvent {
26    /// Event name. LiveKit-defined events use the `lk.` prefix (e.g. `lk.ping`); see `SPEC.md`.
27    pub name: String,
28    /// How serious it is (OTLP severity number and text).
29    pub severity: Severity,
30    /// Optional human-readable message (the OTLP log record body).
31    #[cfg_attr(feature = "uniffi", uniffi(default))]
32    pub body: Option<String>,
33    /// The record's own attributes; the session's are added when it is captured.
34    pub attributes: Vec<Attribute>,
35    /// Wall-clock time in nanoseconds since the Unix epoch. `None` is stamped when the record is
36    /// queued, never at export.
37    #[cfg_attr(feature = "uniffi", uniffi(default))]
38    pub timestamp_ns: Option<u64>,
39    /// The in-flight span this record belongs to (a handle from `begin_span`), if any. The trace
40    /// id is always the session's and is attached by the core.
41    #[cfg_attr(feature = "uniffi", uniffi(default))]
42    pub span_id: Option<u64>,
43}
44
45impl TelemetryEvent {
46    /// An `Info` event without attributes, stamped when emitted.
47    pub fn new(name: impl Into<String>) -> Self {
48        Self { name: name.into(), ..Self::default() }
49    }
50
51    /// Link this record to an in-flight span.
52    pub fn in_span(mut self, span: u64) -> Self {
53        self.span_id = Some(span);
54        self
55    }
56
57    /// The same event with `severity`.
58    pub fn with_severity(mut self, severity: Severity) -> Self {
59        self.severity = severity;
60        self
61    }
62
63    /// The same event with a display body.
64    pub fn with_body(mut self, body: impl Into<String>) -> Self {
65        self.body = Some(body.into());
66        self
67    }
68
69    /// The same event with one more attribute.
70    pub fn with_attribute(
71        mut self,
72        key: impl Into<String>,
73        value: impl Into<AttributeValue>,
74    ) -> Self {
75        self.attributes.push(Attribute::new(key, value));
76        self
77    }
78
79    /// A consumer's own event. Always namespaced under `custom.` so it can never be mistaken for
80    /// a LiveKit-defined `lk.*` event, and the backend can filter or quota it separately;
81    /// attributes keep the caller's namespace (`acme.checkout.step`).
82    pub fn custom(name: &str, attributes: Vec<Attribute>) -> Self {
83        let name = format!("custom.{}", name.trim_start_matches("custom."));
84        Self { attributes, ..Self::new(name) }
85    }
86
87    /// Rough encoded size in bytes — strings plus a fixed overhead per record and per attribute.
88    /// Drives the byte bounds on queue flushing and request size; cheaper than encoding and close
89    /// enough for both. The name is counted twice (`event_name` and `otel.event.name`), and again
90    /// when it doubles as the body.
91    ///
92    /// Counts the attributes the record carries: once queued, that includes its session's
93    /// (`lk.room.*`, `lk.participant.*`, `session.id`) and the app's correlation attributes, all
94    /// taken at capture. Pipeline-wide attributes are added at export and are not counted.
95    pub fn size_hint(&self) -> usize {
96        RECORD_OVERHEAD_BYTES
97            + 2 * self.name.len()
98            + self.body.as_ref().map_or(self.name.len(), String::len)
99            + attributes_size_hint(&self.attributes)
100    }
101}
102
103/// Event severity, mapped onto the OTel severity numbers (`TRACE`=1 … `ERROR`=17).
104#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
105#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
106pub enum Severity {
107    /// OTel `TRACE` (1).
108    Trace,
109    /// OTel `DEBUG` (5).
110    Debug,
111    /// OTel `INFO` (9).
112    #[default]
113    Info,
114    /// OTel `WARN` (13).
115    Warn,
116    /// OTel `ERROR` (17).
117    Error,
118}
119
120impl Severity {
121    /// The OTel severity text (`WARN`).
122    pub(crate) fn as_str(self) -> &'static str {
123        match self {
124            Self::Trace => "TRACE",
125            Self::Debug => "DEBUG",
126            Self::Info => "INFO",
127            Self::Warn => "WARN",
128            Self::Error => "ERROR",
129        }
130    }
131}
132
133/// Where a log line came from. WebRTC is chatty at warn, so only its errors become records;
134/// the SDK and the core use the configured floor.
135#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum LogSource {
138    /// The platform SDK's own code (Swift, Kotlin, Dart, JS).
139    Sdk,
140    /// The Rust core, through the FFI.
141    Ffi,
142    /// libwebrtc.
143    WebRtc,
144}
145
146impl LogSource {
147    fn as_str(self) -> &'static str {
148        match self {
149            Self::Sdk => "sdk",
150            Self::Ffi => "ffi",
151            Self::WebRtc => "webrtc",
152        }
153    }
154}
155
156/// A log line as the platform captured it, where it happened. The core turns it into a record:
157/// semconv `code.*` attributes, `lk.log.source`, `lk.log.logger`, filed under the span's session.
158/// Stamp `timestamp_ns` at capture; the record may cross an executor hop before it gets here.
159#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
160#[derive(Debug, Clone, PartialEq)]
161pub struct LogRecord {
162    /// How serious the line is; the core applies the source's floor to it.
163    pub severity: Severity,
164    /// Who wrote the line: it picks the floor, and is exported as `lk.log.source`.
165    pub source: LogSource,
166    /// The line itself (the OTLP log body). Not `message`: AGENTS.md keeps that name off every
167    /// exported record.
168    pub body: String,
169    /// The logger: a type, module or file name (`Room`, `livekit::rtc_engine`, `sctp.cc`),
170    /// exported as `lk.log.logger` to group lines in the backend.
171    #[cfg_attr(feature = "uniffi", uniffi(default))]
172    pub logger: Option<String>,
173    /// The function that logged, exported as `code.function.name`.
174    #[cfg_attr(feature = "uniffi", uniffi(default))]
175    pub function: Option<String>,
176    /// The source file, exported as `code.file.path`.
177    #[cfg_attr(feature = "uniffi", uniffi(default))]
178    pub file: Option<String>,
179    /// The line in `file`, exported as `code.line.number` (0 is no line).
180    #[cfg_attr(feature = "uniffi", uniffi(default))]
181    pub line: Option<u32>,
182    /// When the line was logged, in nanoseconds since the Unix epoch; `None` is stamped when the
183    /// record is queued.
184    #[cfg_attr(feature = "uniffi", uniffi(default))]
185    pub timestamp_ns: Option<u64>,
186    /// The in-flight span this line was logged under, if any.
187    #[cfg_attr(feature = "uniffi", uniffi(default))]
188    pub span_id: Option<u64>,
189}
190
191impl From<LogRecord> for TelemetryEvent {
192    fn from(record: LogRecord) -> Self {
193        let mut event = TelemetryEvent::default()
194            .with_severity(record.severity)
195            .with_body(record.body)
196            .with_attribute("lk.log.source", record.source.as_str());
197        if let Some(logger) = record.logger.filter(|s| !s.is_empty()) {
198            event = event.with_attribute("lk.log.logger", logger);
199        }
200        if let Some(function) = record.function.filter(|s| !s.is_empty()) {
201            event = event.with_attribute("code.function.name", function);
202        }
203        if let Some(file) = record.file.filter(|s| !s.is_empty()) {
204            event = event.with_attribute("code.file.path", file);
205        }
206        if let Some(line) = record.line.filter(|l| *l > 0) {
207            event = event.with_attribute("code.line.number", i64::from(line));
208        }
209        event.timestamp_ns = record.timestamp_ns;
210        event.span_id = record.span_id;
211        event
212    }
213}
214
215/// A key/value attribute on an event or on the resource.
216#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
217#[derive(Debug, Clone, PartialEq)]
218pub struct Attribute {
219    /// The attribute's name (`lk.room.sid`, `acme.checkout.step`).
220    pub key: String,
221    /// Its value.
222    pub value: AttributeValue,
223}
224
225impl Attribute {
226    /// A key/value pair.
227    pub fn new(key: impl Into<String>, value: impl Into<AttributeValue>) -> Self {
228        Self { key: key.into(), value: value.into() }
229    }
230}
231
232/// Attribute value: the scalar subset of OTLP `AnyValue`.
233#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
234#[derive(Debug, Clone, PartialEq)]
235pub enum AttributeValue {
236    /// A string (OTLP `string_value`).
237    Str(String),
238    /// A 64-bit integer (OTLP `int_value`).
239    Int(i64),
240    /// A 64-bit float (OTLP `double_value`).
241    Double(f64),
242    /// A boolean (OTLP `bool_value`).
243    Bool(bool),
244}
245
246impl From<&str> for AttributeValue {
247    fn from(value: &str) -> Self {
248        Self::Str(value.to_owned())
249    }
250}
251
252impl From<String> for AttributeValue {
253    fn from(value: String) -> Self {
254        Self::Str(value)
255    }
256}
257
258impl From<i64> for AttributeValue {
259    fn from(value: i64) -> Self {
260        Self::Int(value)
261    }
262}
263
264impl From<f64> for AttributeValue {
265    fn from(value: f64) -> Self {
266        Self::Double(value)
267    }
268}
269
270impl From<bool> for AttributeValue {
271    fn from(value: bool) -> Self {
272        Self::Bool(value)
273    }
274}
275
276/// Current wall-clock time in nanoseconds since the Unix epoch (0 if the clock is before 1970).
277pub(crate) fn now_unix_nanos() -> u64 {
278    let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| {
279        // Past the year 2554 a u64 of nanoseconds runs out: saturate rather than wrap.
280        u64::try_from(d.as_nanos()).unwrap_or(u64::MAX)
281    });
282    #[cfg(test)]
283    let now = now.saturating_add_signed(CLOCK_JUMP_NS.with(|jump| jump.get()));
284    now
285}
286
287#[cfg(test)]
288thread_local! {
289    /// How far a test moved the wall clock, on this (current-thread runtime) thread: the
290    /// capture-time test (`queued_records_carry_their_capture_time_not_their_export_time`) moves
291    /// it between capture and export, which no real clock does on demand.
292    pub(crate) static CLOCK_JUMP_NS: std::cell::Cell<i64> = const { std::cell::Cell::new(0) };
293}
294
295/// Limits on what an app hands over: long enough for any real identifier, short enough that one
296/// app cannot bloat every record. Over-long input is rejected and counted, never truncated — a
297/// truncated id silently collides with another.
298#[expect(dead_code, reason = "enforced by the pipeline, not in place yet")]
299pub(crate) const MAX_NAME_BYTES: usize = 128;
300/// The longest attribute key an app may hand over, in bytes.
301pub(crate) const MAX_KEY_BYTES: usize = 128;
302/// The longest string attribute value an app may hand over, in bytes.
303pub(crate) const MAX_VALUE_BYTES: usize = 1024;
304/// Custom attributes per room, and per custom event.
305pub(crate) const MAX_CUSTOM_ATTRIBUTES: usize = 64;
306
307/// Keys the SDK owns: an app can neither set nor override them — `lk.*` (room, participant,
308/// track, outcome), `otel.*` (OTel's own, `otel.event.name` among them), `code.*` (where a log
309/// line was written), `session.id`, `error.type` and `network.connection.type` (the device's
310/// network path; other `network.*` keys stay the app's).
311pub(crate) fn is_reserved(key: &str) -> bool {
312    key.starts_with("lk.")
313        || key.starts_with("otel.")
314        || key.starts_with("code.")
315        || key == "session.id"
316        || key == "error.type"
317        || key == "network.connection.type"
318}
319
320/// Whether an app-provided attribute is within the limits and outside the SDK's namespace.
321pub(crate) fn valid_custom(key: &str, value: Option<&AttributeValue>) -> bool {
322    let value_ok = match value {
323        Some(AttributeValue::Str(s)) => s.len() <= MAX_VALUE_BYTES,
324        _ => true,
325    };
326    !key.is_empty() && key.len() <= MAX_KEY_BYTES && !is_reserved(key) && value_ok
327}
328
329/// Encoded bytes of a log record beyond its strings: timestamps, severity, trace id and the
330/// `otel.event.name` key. Calibrated against the encoder, like the other overheads (the
331/// `size_hints_track_the_encoded_size` test).
332const RECORD_OVERHEAD_BYTES: usize = 78;
333/// Encoded bytes of an attribute beyond its key and string value: tags, lengths and wrappers.
334const ATTRIBUTE_OVERHEAD_BYTES: usize = 8;
335/// Encoded bytes of a number or boolean value: a varint takes 1 to 10 and a double 8, so 8 is
336/// the round figure the encoder test calibrates.
337const NUMBER_VALUE_BYTES: usize = 8;
338
339/// Rough encoded size of `attributes`.
340pub(crate) fn attributes_size_hint(attributes: &[Attribute]) -> usize {
341    attributes.iter().map(|a| ATTRIBUTE_OVERHEAD_BYTES + a.key.len() + a.value.size_hint()).sum()
342}
343
344impl AttributeValue {
345    /// Rough encoded size of the value: a string's bytes, or a 64-bit number's.
346    fn size_hint(&self) -> usize {
347        match self {
348            AttributeValue::Str(s) => s.len(),
349            _ => NUMBER_VALUE_BYTES,
350        }
351    }
352}