Skip to main content

livekit_telemetry/
otlp.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 prost::Message;
16
17use crate::span::SpanKind;
18use crate::{
19    event::now_unix_nanos,
20    proto::opentelemetry::proto::{
21        collector::{logs::v1::ExportLogsServiceRequest, trace::v1::ExportTraceServiceRequest},
22        common::v1::{any_value, AnyValue, InstrumentationScope, KeyValue},
23        logs::v1::{LogRecord, ResourceLogs, ScopeLogs, SeverityNumber},
24        resource::v1::Resource,
25        trace::v1::{span, status, ResourceSpans, ScopeSpans, Span, Status},
26    },
27    span::SpanRecord,
28    store::QueuedEvent,
29    Attribute, AttributeValue, Severity, SpanOutcome,
30};
31
32/// The `Content-Type` of an OTLP/HTTP protobuf request.
33pub(crate) const CONTENT_TYPE: &str = "application/x-protobuf";
34
35fn resource(attributes: &[Attribute]) -> Option<Resource> {
36    Some(Resource {
37        attributes: attributes.iter().map(KeyValue::from).collect(),
38        ..Default::default()
39    })
40}
41
42fn scope() -> Option<InstrumentationScope> {
43    Some(InstrumentationScope {
44        name: env!("CARGO_PKG_NAME").to_owned(),
45        version: env!("CARGO_PKG_VERSION").to_owned(),
46        ..Default::default()
47    })
48}
49
50/// Encode one batch as an OTLP `ExportLogsServiceRequest`: one resource, one instrumentation
51/// scope (this crate), one log record per event. Every record carries its session's trace id
52/// and the attributes it got at capture, with `global` filling in (see [`fill_in_global`]);
53/// records emitted inside a span carry its span id too.
54pub(crate) fn encode_logs(
55    resource_attributes: &[Attribute],
56    global: &[Attribute],
57    events: Vec<QueuedEvent>,
58) -> Vec<u8> {
59    ExportLogsServiceRequest {
60        resource_logs: vec![ResourceLogs {
61            resource: resource(resource_attributes),
62            scope_logs: vec![ScopeLogs {
63                scope: scope(),
64                log_records: events.into_iter().map(|e| log_record(e, global)).collect(),
65                ..Default::default()
66            }],
67            ..Default::default()
68        }],
69    }
70    .encode_to_vec()
71}
72
73/// Encode finished spans as an OTLP `ExportTraceServiceRequest`, each under its session's trace id.
74pub(crate) fn encode_spans(
75    resource_attributes: &[Attribute],
76    global: &[Attribute],
77    spans: Vec<SpanRecord>,
78) -> Vec<u8> {
79    ExportTraceServiceRequest {
80        resource_spans: vec![ResourceSpans {
81            resource: resource(resource_attributes),
82            scope_spans: vec![ScopeSpans {
83                scope: scope(),
84                spans: spans.into_iter().map(|s| otlp_span(s, global)).collect(),
85                ..Default::default()
86            }],
87            ..Default::default()
88        }],
89    }
90    .encode_to_vec()
91}
92
93/// An encoded batch cut in two halves (a 413 answer), each with its record count; `None` when it
94/// holds a single record or is not the one-resource, one-scope shape this crate encodes.
95pub(crate) type Halves = Option<[(Vec<u8>, u64); 2]>;
96
97pub(crate) fn split_logs(encoded: &[u8]) -> Halves {
98    let mut request = ExportLogsServiceRequest::decode(encoded).ok()?;
99    let [resource] = &mut request.resource_logs[..] else { return None };
100    let [scope] = &mut resource.scope_logs[..] else { return None };
101    let n = scope.log_records.len();
102    if n < 2 {
103        return None;
104    }
105    let second = scope.log_records.split_off(n / 2);
106    let first = request.encode_to_vec();
107    request.resource_logs[0].scope_logs[0].log_records = second;
108    Some([(first, (n / 2) as u64), (request.encode_to_vec(), (n - n / 2) as u64)])
109}
110
111pub(crate) fn split_spans(encoded: &[u8]) -> Halves {
112    let mut request = ExportTraceServiceRequest::decode(encoded).ok()?;
113    let [resource] = &mut request.resource_spans[..] else { return None };
114    let [scope] = &mut resource.scope_spans[..] else { return None };
115    let n = scope.spans.len();
116    if n < 2 {
117        return None;
118    }
119    let second = scope.spans.split_off(n / 2);
120    let first = request.encode_to_vec();
121    request.resource_spans[0].scope_spans[0].spans = second;
122    Some([(first, (n / 2) as u64), (request.encode_to_vec(), (n - n / 2) as u64)])
123}
124
125fn log_record(
126    QueuedEvent { mut event, session, .. }: QueuedEvent,
127    global: &[Attribute],
128) -> LogRecord {
129    fill_in_global(&mut event.attributes, global);
130    // `QueuedEvent::new` stamps capture time; this fallback covers only one built by hand.
131    let time_unix_nano = event.timestamp_ns.unwrap_or_else(now_unix_nanos);
132    // Events carry a display body (OTel: "a string display message of the event"); the name is
133    // the last resort so no event ever renders as an empty line. `otel.event.name` (semconv 1.39)
134    // duplicates `EventName` for backends that do not read the field yet (Loki); an app cannot
135    // set it (`otel.*` is reserved).
136    let body = event.body.or_else(|| (!event.name.is_empty()).then(|| event.name.clone()));
137    // The core's copy is the only one: a pipeline attribute of that name never ships.
138    event.attributes.retain(|a| a.key != "otel.event.name");
139    if !event.name.is_empty() {
140        event.attributes.push(Attribute::new("otel.event.name", event.name.clone()));
141    }
142    last_value_wins(&mut event.attributes);
143    LogRecord {
144        time_unix_nano,
145        // When the collecting side saw the record: for one recorded in process, OTel SDKs set
146        // it equal to the timestamp (9 bytes).
147        observed_time_unix_nano: time_unix_nano,
148        severity_number: SeverityNumber::from(event.severity) as i32,
149        // The text as well as the number, as OTel SDKs send them: a log view shows the text.
150        severity_text: event.severity.as_str().to_owned(),
151        body: body.map(|text| AnyValue { value: Some(any_value::Value::StringValue(text)) }),
152        attributes: event.attributes.iter().map(KeyValue::from).collect(),
153        event_name: event.name,
154        trace_id: session.trace_id.to_vec(),
155        span_id: event.span_id.map(|id| id.to_be_bytes().to_vec()).unwrap_or_default(),
156        ..Default::default()
157    }
158}
159
160fn otlp_span(mut record: SpanRecord, global: &[Attribute]) -> Span {
161    fill_in_global(&mut record.attributes, global);
162    // The outcome is the core's, as `session.id` is: a span's own copies never ship.
163    record.attributes.retain(|a| a.key != "lk.outcome" && a.key != "error.type");
164    last_value_wins(&mut record.attributes);
165    let mut attributes: Vec<KeyValue> = record.attributes.iter().map(KeyValue::from).collect();
166    attributes.extend(record.outcome_attributes().iter().map(KeyValue::from));
167    Span {
168        trace_id: record.session.trace_id.to_vec(),
169        span_id: record.span_id.to_be_bytes().to_vec(),
170        parent_span_id: record.parent_span_id.map(|p| p.to_be_bytes().to_vec()).unwrap_or_default(),
171        name: record.name,
172        kind: span::SpanKind::from(record.kind) as i32,
173        start_time_unix_nano: record.start_ns,
174        end_time_unix_nano: record.end_ns,
175        attributes,
176        dropped_attributes_count: record.dropped_attributes,
177        events: record
178            .events
179            .into_iter()
180            .map(|e| span::Event { time_unix_nano: e.time_ns, name: e.name, ..Default::default() })
181            .collect(),
182        dropped_events_count: record.dropped_events,
183        // OTel: instrumentation should not set `Ok`; success and cancellation stay `Unset` and
184        // are told apart by `lk.outcome`. No message: `error.type` already says what failed.
185        status: Some(Status {
186            code: match record.outcome {
187                SpanOutcome::Error => status::StatusCode::Error,
188                SpanOutcome::Ok | SpanOutcome::Cancelled => status::StatusCode::Unset,
189            } as i32,
190            ..Default::default()
191        }),
192        ..Default::default()
193    }
194}
195
196/// The pipeline-wide attributes fill in under a record's own and its session's, which it got at
197/// capture. Added at export because the queues never see them, so a size hint leaves them out.
198fn fill_in_global(own: &mut Vec<Attribute>, global: &[Attribute]) {
199    for attribute in global {
200        if !own.iter().any(|a| a.key == attribute.key) {
201            own.push(attribute.clone());
202        }
203    }
204}
205
206/// One attribute per key, as OTLP requires (a receiver's choice among repeats is unpredictable):
207/// the last value supplied wins, in the place of that last copy. Only a caller's own list can
208/// repeat a key, so the precedence above holds: the session's attributes, `session.id` and
209/// `otel.event.name` replaced every copy of their keys, and the rest only fill in missing keys.
210fn last_value_wins(attributes: &mut Vec<Attribute>) {
211    if attributes.len() > 64 {
212        let mut seen = std::collections::HashSet::<&str>::with_capacity(attributes.len());
213        let mut keep = vec![false; attributes.len()];
214        for (i, attribute) in attributes.iter().enumerate().rev() {
215            keep[i] = seen.insert(attribute.key.as_str());
216        }
217        // Release borrowed keys before moving or dropping attributes.
218        drop(seen);
219        let mut keep = keep.into_iter();
220        attributes.retain(|_| keep.next().expect("one flag per attribute"));
221        return;
222    }
223
224    // Small lists keep the allocation-free scan.
225
226    let mut i = 0;
227    while i < attributes.len() {
228        if attributes[i + 1..].iter().any(|a| a.key == attributes[i].key) {
229            attributes.remove(i);
230        } else {
231            i += 1;
232        }
233    }
234}
235
236impl From<SpanKind> for span::SpanKind {
237    /// The OTLP value of a span kind.
238    fn from(kind: SpanKind) -> Self {
239        match kind {
240            SpanKind::Internal => Self::Internal,
241            SpanKind::Client => Self::Client,
242        }
243    }
244}
245
246impl From<Severity> for SeverityNumber {
247    fn from(severity: Severity) -> Self {
248        match severity {
249            Severity::Trace => SeverityNumber::Trace,
250            Severity::Debug => SeverityNumber::Debug,
251            Severity::Info => SeverityNumber::Info,
252            Severity::Warn => SeverityNumber::Warn,
253            Severity::Error => SeverityNumber::Error,
254        }
255    }
256}
257
258impl From<&Attribute> for KeyValue {
259    fn from(attribute: &Attribute) -> Self {
260        let value = match &attribute.value {
261            AttributeValue::Str(s) => any_value::Value::StringValue(s.clone()),
262            AttributeValue::Int(i) => any_value::Value::IntValue(*i),
263            AttributeValue::Double(d) => any_value::Value::DoubleValue(*d),
264            AttributeValue::Bool(b) => any_value::Value::BoolValue(*b),
265        };
266        KeyValue {
267            key: attribute.key.clone(),
268            value: Some(AnyValue { value: Some(value) }),
269            ..Default::default()
270        }
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::TelemetryEvent;
278
279    /// A pipeline attribute named `otel.event.name` never ships a second copy of the key: the
280    /// core's own, from the event's name, is the only one.
281    #[test]
282    fn otel_event_name_is_sent_once() {
283        let session = crate::scope::ScopeState::new();
284        let global = [Attribute::new("otel.event.name", "sdk-meta")];
285        let queued = vec![QueuedEvent::new(TelemetryEvent::new("lk.ping"), session)];
286        let decoded = ExportLogsServiceRequest::decode(&encode_logs(&[], &global, queued)[..])
287            .expect("valid OTLP");
288        let record = &decoded.resource_logs[0].scope_logs[0].log_records[0];
289        let names: Vec<_> =
290            record.attributes.iter().filter(|kv| kv.key == "otel.event.name").collect();
291        assert_eq!(names.len(), 1, "{names:?}");
292        let value = names[0].value.as_ref().and_then(|v| v.value.as_ref());
293        assert_eq!(value, Some(&any_value::Value::StringValue("lk.ping".into())));
294    }
295
296    /// OTLP keys are unique: of a caller's repeated keys the last value ships, and a session key
297    /// the caller repeats still ships the session's value — on log records and spans alike.
298    #[test]
299    fn repeated_caller_keys_ship_once_with_the_last_value() {
300        let session = crate::scope::ScopeState::with_trace_id([7u8; 16]);
301        let custom = TelemetryEvent::custom(
302            "checkout",
303            vec![Attribute::new("app.step", "first"), Attribute::new("app.step", "second")],
304        );
305        let sdk = TelemetryEvent::new("lk.ping")
306            .with_attribute("session.id", "spoofed")
307            .with_attribute("session.id", "spoofed again");
308        let queued =
309            vec![QueuedEvent::new(custom, session.clone()), QueuedEvent::new(sdk, session)];
310        let decoded = ExportLogsServiceRequest::decode(&encode_logs(&[], &[], queued)[..])
311            .expect("valid OTLP");
312        let records = &decoded.resource_logs[0].scope_logs[0].log_records;
313        let values = |record: &LogRecord, key: &str| -> Vec<_> {
314            let values = record.attributes.iter().filter(|kv| kv.key == key);
315            values.filter_map(|kv| kv.value.clone()?.value).collect()
316        };
317        assert_eq!(
318            values(&records[0], "app.step"),
319            [any_value::Value::StringValue("second".into())]
320        );
321        let session_id = any_value::Value::StringValue("07".repeat(16));
322        assert_eq!(values(&records[1], "session.id"), [session_id]);
323        for record in records {
324            assert_unique_keys(&record.attributes);
325        }
326
327        let mut spans = crate::span::Spans::new(1);
328        let span = spans.begin("checkout", SpanKind::Internal, None);
329        let own = vec![Attribute::new("app.step", "first"), Attribute::new("app.step", "second")];
330        spans.end(span, SpanOutcome::Ok, None, own);
331        let bytes = encode_spans(&[], &[], spans.drain(1, usize::MAX));
332        let decoded = ExportTraceServiceRequest::decode(&bytes[..]).expect("valid OTLP");
333        let [span] = &decoded.resource_spans[0].scope_spans[0].spans[..] else {
334            panic!("one span")
335        };
336        assert_eq!(
337            self::values(&span.attributes, "app.step"),
338            [any_value::Value::StringValue("second".into())]
339        );
340        assert_unique_keys(&span.attributes);
341    }
342
343    /// Check last-occurrence order against an independent index oracle on both paths.
344    #[test]
345    fn dedup_keeps_last_occurrences_across_threshold() {
346        for len in [0, 1, 63, 64, 65, 66, 4096] {
347            // Distinct, identical, alternating, adjacent pairs, and separated repeats.
348            for pattern in 0..5 {
349                let key = |i: usize| match pattern {
350                    0 => i,
351                    1 => 0,
352                    2 => i % 2,
353                    3 => i / 2,
354                    _ => i % 17,
355                };
356                let mut attributes: Vec<_> =
357                    (0..len).map(|i| Attribute::new(format!("key-{}", key(i)), i as i64)).collect();
358                let expected: Vec<_> = (0..len)
359                    .filter(|&i| !(i + 1..len).any(|j| key(i) == key(j)))
360                    .map(|i| Attribute::new(format!("key-{}", key(i)), i as i64))
361                    .collect();
362                last_value_wins(&mut attributes);
363                assert_eq!(attributes, expected, "len={len}, pattern={pattern}");
364            }
365        }
366    }
367
368    /// Retention moves the original strings and preserves floating-point payloads.
369    #[test]
370    fn dedup_preserves_allocations_and_value_bits() {
371        for len in [64, 65] {
372            let mut attributes: Vec<_> =
373                (0..len - 6).map(|i| Attribute::new(format!("key-{i}"), i as i64)).collect();
374            attributes.push(Attribute::new("text", "discard"));
375            attributes.push(Attribute::new("nan", 1.0));
376            attributes.push(Attribute::new("zero", 1.0));
377            attributes.push(Attribute::new("text", "original allocation"));
378            let nan_bits = 0x7ff8_0000_0000_0042;
379            attributes.push(Attribute::new("nan", f64::from_bits(nan_bits)));
380            attributes.push(Attribute::new("zero", -0.0));
381            let key_ptr = attributes[len - 3].key.as_ptr();
382            let AttributeValue::Str(value) = &attributes[len - 3].value else {
383                panic!("string value")
384            };
385            let value_ptr = value.as_ptr();
386            last_value_wins(&mut attributes);
387            assert_eq!(attributes.len(), len - 3);
388            let tail = &attributes[attributes.len() - 3..];
389            assert_eq!(tail[0].key.as_ptr(), key_ptr);
390            let AttributeValue::Str(value) = &tail[0].value else { panic!("string value") };
391            assert_eq!(value.as_ptr(), value_ptr);
392            assert_eq!(value, "original allocation");
393            for (attribute, key, bits) in
394                [(&tail[1], "nan", nan_bits), (&tail[2], "zero", (-0.0f64).to_bits())]
395            {
396                assert_eq!(attribute.key, key);
397                let AttributeValue::Double(value) = attribute.value else { panic!("double value") };
398                assert_eq!(value.to_bits(), bits);
399            }
400        }
401    }
402
403    fn assert_unique_keys(attributes: &[KeyValue]) {
404        let mut keys: Vec<_> = attributes.iter().map(|kv| &kv.key).collect();
405        let count = keys.len();
406        keys.sort_unstable();
407        keys.dedup();
408        assert_eq!(keys.len(), count, "unique keys: {attributes:?}");
409    }
410
411    #[test]
412    fn encodes_events_as_otlp_log_records() {
413        let resource = [Attribute::new("service.name", "test")];
414        let event = TelemetryEvent::new("lk.ping")
415            .with_severity(Severity::Warn)
416            .with_body("hi")
417            .with_attribute("lk.ping.seq", 7i64);
418        let session = crate::scope::ScopeState::with_trace_id([7u8; 16]);
419        let bytes = encode_logs(&resource, &[], vec![QueuedEvent::new(event, session)]);
420
421        let decoded = ExportLogsServiceRequest::decode(&bytes[..]).expect("valid OTLP");
422        let resource_logs = &decoded.resource_logs[0];
423        let res_attr = &resource_logs.resource.as_ref().expect("resource").attributes[0];
424        assert_eq!(res_attr.key, "service.name");
425        let scope_logs = &resource_logs.scope_logs[0];
426        assert_eq!(scope_logs.scope.as_ref().expect("scope").name, "livekit-telemetry");
427        let record = &scope_logs.log_records[0];
428        assert_eq!(record.event_name, "lk.ping");
429        assert_eq!(record.trace_id, vec![7u8; 16]);
430        assert!(record.span_id.is_empty());
431        assert_eq!(record.severity_number, SeverityNumber::Warn as i32);
432        assert_eq!(record.severity_text, "WARN");
433        assert!(record.time_unix_nano > 0);
434        assert_eq!(record.attributes[0].key, "lk.ping.seq");
435        assert_eq!(
436            record.attributes[0].value.as_ref().and_then(|v| v.value.clone()),
437            Some(any_value::Value::IntValue(7))
438        );
439    }
440
441    #[test]
442    fn queued_records_carry_their_capture_time_not_their_export_time() {
443        const HOUR_NS: u64 = 3_600_000_000_000;
444        let store = crate::store::Store::new(10, usize::MAX, Default::default());
445        let session = crate::scope::ScopeState::new();
446        let captured = now_unix_nanos();
447        store.push(QueuedEvent::new(TelemetryEvent::new("unstamped"), session.clone()));
448        let own = TelemetryEvent { timestamp_ns: Some(7), ..TelemetryEvent::new("stamped") };
449        store.push(QueuedEvent::new(own, session));
450        // An hour in the queue (an outage) before the exporter encodes the batch.
451        crate::event::CLOCK_JUMP_NS.with(|jump| jump.set(HOUR_NS as i64));
452        let bytes = encode_logs(&[], &[], store.drain(10, usize::MAX));
453        let decoded = ExportLogsServiceRequest::decode(&bytes[..]).expect("valid OTLP");
454        let records = &decoded.resource_logs[0].scope_logs[0].log_records;
455        assert!((captured..captured + HOUR_NS).contains(&records[0].time_unix_nano));
456        assert_eq!(records[1].time_unix_nano, 7, "a caller's own timestamp is kept");
457    }
458
459    #[test]
460    fn spans_carry_the_cores_outcome_and_an_error_type_only_on_failure() {
461        let mut spans = crate::span::Spans::new(2);
462        let failed = spans.begin("lk.connect", SpanKind::Client, None);
463        let own = vec![Attribute::new("lk.outcome", "ok"), Attribute::new("error.type", "spoof")];
464        spans.end(failed, SpanOutcome::Error, Some("timeout".into()), own);
465        let cancelled = spans.begin("lk.connect", SpanKind::Client, None);
466        spans.end(cancelled, SpanOutcome::Cancelled, Some("CancellationError".into()), vec![]);
467        let bytes = encode_spans(&[], &[], spans.drain(2, usize::MAX));
468        let decoded = ExportTraceServiceRequest::decode(&bytes[..]).expect("valid OTLP");
469        let [failed, cancelled] = &decoded.resource_spans[0].scope_spans[0].spans[..] else {
470            panic!("two spans")
471        };
472        assert_eq!(failed.kind, span::SpanKind::Client as i32);
473        let values = |span: &Span, key: &str| values(&span.attributes, key);
474        let text = |s: &str| any_value::Value::StringValue(s.into());
475        assert_eq!(values(failed, "lk.outcome"), [text("error")]);
476        assert_eq!(values(failed, "error.type"), [text("timeout")]);
477        let status = failed.status.clone().expect("status");
478        assert_eq!(status.code, status::StatusCode::Error as i32);
479        assert_eq!(status.message, "", "error.type already says what failed");
480        assert_eq!(values(cancelled, "lk.outcome"), [text("cancelled")]);
481        assert!(values(cancelled, "error.type").is_empty(), "a cancellation is not a failure");
482        assert_eq!(
483            cancelled.status.clone().expect("status").code,
484            status::StatusCode::Unset as i32
485        );
486    }
487
488    /// The attributes `Scope::set_room` gives a session in a call.
489    fn in_room(session: &crate::scope::ScopeState) {
490        for (key, value) in [
491            ("lk.room.sid", "RM_UpE3vYzFmJp8"),
492            ("lk.room.name", "standup-2026-10-08"),
493            ("lk.participant.sid", "PA_kQ2Fv8sWbE3n"),
494            ("lk.participant.identity", "user-4f1c2a"),
495        ] {
496            session.set_attribute(key, Some(value.into()));
497        }
498    }
499
500    /// The values of `key` on an encoded record or span.
501    fn values(attributes: &[KeyValue], key: &str) -> Vec<any_value::Value> {
502        attributes
503            .iter()
504            .filter(|kv| kv.key == key)
505            .filter_map(|kv| kv.value.clone()?.value)
506            .collect()
507    }
508
509    #[test]
510    fn records_keep_the_session_attributes_they_were_captured_with() {
511        let session = crate::scope::ScopeState::new();
512        in_room(&session);
513        let spoofed =
514            TelemetryEvent::new("lk.room.disconnected").with_attribute("lk.room.sid", "x");
515        let queued = QueuedEvent::new(spoofed, session.clone());
516        // The platform clears the room right after the disconnect, before the next export.
517        session.set_attribute("lk.room.sid", None);
518        let global = [Attribute::new("lk.room.sid", "global"), Attribute::new("acme.tenant", "t1")];
519        let bytes = encode_logs(&[], &global, vec![queued]);
520        let decoded = ExportLogsServiceRequest::decode(&bytes[..]).expect("valid OTLP");
521        let record = &decoded.resource_logs[0].scope_logs[0].log_records[0];
522        let text = |s: &str| any_value::Value::StringValue(s.into());
523        assert_eq!(values(&record.attributes, "lk.room.sid"), [text("RM_UpE3vYzFmJp8")]);
524        assert_eq!(values(&record.attributes, "session.id"), [text(&session.hex())]);
525        assert_eq!(values(&record.attributes, "acme.tenant"), [text("t1")], "globals fill in");
526    }
527
528    #[test]
529    fn spans_keep_the_session_attributes_they_ended_with() {
530        let session = crate::scope::ScopeState::new();
531        in_room(&session);
532        let mut spans = crate::span::Spans::new(1);
533        let id = spans.begin_in("lk.publish", SpanKind::Internal, None, session.clone());
534        spans.end(id, SpanOutcome::Ok, None, vec![Attribute::new("lk.room.sid", "x")]);
535        session.set_attribute("lk.room.sid", None);
536        let bytes = encode_spans(&[], &[], spans.drain(1, usize::MAX));
537        let decoded = ExportTraceServiceRequest::decode(&bytes[..]).expect("valid OTLP");
538        let span = &decoded.resource_spans[0].scope_spans[0].spans[0];
539        let text = |s: &str| any_value::Value::StringValue(s.into());
540        assert_eq!(values(&span.attributes, "lk.room.sid"), [text("RM_UpE3vYzFmJp8")]);
541        assert_eq!(values(&span.attributes, "session.id"), [text(&session.hex())]);
542    }
543
544    #[test]
545    fn size_hints_track_the_encoded_size() {
546        let session = crate::scope::ScopeState::new();
547        in_room(&session);
548        // A record as queued (decorated), its hint, and the bytes it adds to a batch.
549        let measure = |event: &TelemetryEvent| {
550            let queued = QueuedEvent::new(event.clone(), session.clone());
551            let hint = queued.event.size_hint();
552            let encoded =
553                encode_logs(&[], &[], vec![queued]).len() - encode_logs(&[], &[], vec![]).len();
554            encoded as f64 / hint as f64
555        };
556        let log_line = TelemetryEvent::from(crate::LogRecord {
557            severity: Severity::Warn,
558            source: crate::LogSource::Sdk,
559            body: "x".repeat(200),
560            logger: Some("livekit::rtc_engine".into()),
561            function: Some("handle_signal".into()),
562            file: Some("src/rtc_engine/mod.rs".into()),
563            line: Some(412),
564            timestamp_ns: None,
565            span_id: Some(42),
566        });
567        let numeric = (0..20).fold(TelemetryEvent::new("lk.rtc.stats.sample"), |event, i| {
568            event.with_attribute(format!("lk.rtc.stat_{i}"), i as f64)
569        });
570        let custom = TelemetryEvent::custom(
571            "checkout",
572            (0..64).map(|i| Attribute::new(format!("acme.key_{i}"), "x".repeat(16))).collect(),
573        );
574        let mut ratios: Vec<_> = [
575            TelemetryEvent::new("lk.ping"),
576            TelemetryEvent::new("lk.ping").with_body("x".repeat(30)),
577            log_line,
578            numeric,
579            custom,
580        ]
581        .iter()
582        .map(|event| (event.name.clone(), measure(event)))
583        .collect();
584
585        let mut spans = crate::span::Spans::new(1);
586        let id = spans.begin_in("lk.connect", SpanKind::Client, None, session.clone());
587        for checkpoint in ["ws_open", "join_recv", "pc_connected"] {
588            spans.add_event(id, checkpoint);
589        }
590        let attempt = vec![Attribute::new("lk.connect.attempt", 1i64)];
591        spans.end(id, SpanOutcome::Error, Some("timeout".into()), attempt);
592        let span = spans.drain(1, usize::MAX).remove(0);
593        let hint = span.size_hint();
594        let encoded =
595            encode_spans(&[], &[], vec![span]).len() - encode_spans(&[], &[], vec![]).len();
596        ratios.push(("span".into(), encoded as f64 / hint as f64));
597
598        for (record, ratio) in ratios {
599            assert!((0.8..=1.25).contains(&ratio), "{record}: encoded/hint = {ratio:.2}");
600        }
601    }
602
603    #[test]
604    fn events_without_a_body_carry_their_name_as_body() {
605        let session = crate::scope::ScopeState::with_trace_id([7u8; 16]);
606        let event = TelemetryEvent::new("lk.rtc.stats.sample");
607        let bytes = encode_logs(&[], &[], vec![QueuedEvent::new(event, session)]);
608        let decoded = ExportLogsServiceRequest::decode(&bytes[..]).expect("valid OTLP");
609        let record = &decoded.resource_logs[0].scope_logs[0].log_records[0];
610        assert_eq!(record.event_name, "lk.rtc.stats.sample");
611        assert_eq!(
612            record.body.as_ref().and_then(|b| b.value.clone()),
613            Some(any_value::Value::StringValue("lk.rtc.stats.sample".into()))
614        );
615    }
616}