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}