1use 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
32pub(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
50pub(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
73pub(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
93pub(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 let time_unix_nano = event.timestamp_ns.unwrap_or_else(now_unix_nanos);
132 let body = event.body.or_else(|| (!event.name.is_empty()).then(|| event.name.clone()));
137 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 observed_time_unix_nano: time_unix_nano,
148 severity_number: SeverityNumber::from(event.severity) as i32,
149 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 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 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
196fn 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
206fn 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 drop(seen);
219 let mut keep = keep.into_iter();
220 attributes.retain(|_| keep.next().expect("one flag per attribute"));
221 return;
222 }
223
224 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 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 #[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 #[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 #[test]
345 fn dedup_keeps_last_occurrences_across_threshold() {
346 for len in [0, 1, 63, 64, 65, 66, 4096] {
347 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 #[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 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 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 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 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 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}