Skip to content

feat(telemetry): add the crate with its event model and OTLP encoding - #1479

Open
pblazej wants to merge 2 commits into
mainfrom
blaze/telemetry-stack/1-data-model
Open

pblazej wants to merge 2 commits into
mainfrom
blaze/telemetry-stack/1-data-model

Conversation

@pblazej

@pblazej pblazej commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds livekit-telemetry, the shared client-telemetry core, starting with its data model and the OTLP encoding.

Changes

  • Event and log records with attributes, and the bounded in-memory queue
  • Health counters and the lk.telemetry.report event
  • The span store
  • Session identity: ScopeState (trace id, SDK and app attributes, route)
  • OTLP/HTTP protobuf encoding on opentelemetry-proto types
  • SPEC.md (resource attributes, events, log records, spans) and the crate changeset — the only PR the changeset check sees

Temporary

Until #1483: a crate-level #![allow(dead_code)] for internals the pipeline consumes, and ScopeState without subscribe tracking (#1483 completes scope.rs).

Architecture
 platform ──emit / span / getStats report / device state──▶ Telemetry ─▶ Store ─▶ Exporter ─▶ BatchCache ─▶ TelemetryTransport
            (instruments, one Scope per Room)                (sync, never blocks)  (actor: passes, (write-ahead:    (host HTTP, livekit-net,
                                                                                    one request out) memory | disk)  or Dart's pull queue)
  • One pipeline per process, one scope per Room. A scope is a trace id plus the Room's attributes; a span is one attempt; checkpoints are span events; lk.outcome carries ok | error | cancelled.
  • The platform keeps only what it alone can do: OS signals and moving bytes. The core owns destination and credentials, batching, OTLP encoding, retry, holds, persistence, loss accounting, the subscribe state machine, stats mapping (one raw getStats() report per peer connection) and polling cadence.
  • Conservative by default: one export and one RTC window per minute, stretched up to 4× under device pressure; while a Room is in a call, at most 4 requests per interval (a backlog waits its turn; with no call nothing is metered) — wake-ups (subscribes, device changes, tokens) never add exports; entering the background uploads everything at once.
  • The core reports on itself: its own Rust warnings and errors reach the pipeline through the existing log forwarder once a platform installs it, any compact JWT (also inside punctuation) and bearer/token credential values (quoted or spaced) masked; the console output is unchanged.
  • Transport stays flexible: the TelemetryTransport foreign trait (Swift/Kotlin), a bounded pull queue (Dart, whose callbacks are isolate-bound: polled with try_next() from a timer, no instruments passed, so Rust never calls into Dart), or NetTransport over the unmodified livekit-net registry.
Verification

At fc0ef589, from a clean checkout (CI's test workflow runs only for PRs into main, so these were run locally; there is no clippy job in CI):

  • cargo fmt -- --check
  • cargo clippy -p livekit-telemetry --all-targets --all-features -- -D warnings
  • cargo check -p livekit-telemetry --all-targets --no-default-features with features [], [uniffi]
  • cargo test -p livekit-telemetry: 6 unit; --all-features: 6 unit

cargo doc -D warnings reports one unresolved link, to Telemetry::stats, which #1483 adds.

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Changeset ✓

This PR includes a changeset covering all affected packages:

Package Bump
livekit-telemetry minor

Add `livekit-telemetry`: the records SDKs push in (events, log records,
attributes), the bounded in-memory queue, health counters and the
`lk.telemetry.report` event, the span store, a session's identity (trace
id, SDK and app attributes, route), and the OTLP/HTTP protobuf encoding of
logs and traces on `opentelemetry-proto` message types.
@pblazej
pblazej force-pushed the blaze/telemetry-stack/1-data-model branch from ddc7bc2 to fc0ef58 Compare October 1, 2026 13:53
@pblazej
pblazej added this pull request to stack #1486 October 1, 2026 14:23
@pblazej
pblazej marked this pull request as ready for review October 1, 2026 14:32
@pblazej
pblazej requested a review from ladvoc as a code owner October 1, 2026 14:32

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 6 potential issues.

3 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

name = "livekit-telemetry"
description = "Client telemetry core for LiveKit: buffers events on-device and exports them as OTLP"
version = "0.1.0"
readme = "README.md"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Missing README blocks crate publishing

When packaging livekit-telemetry, Cargo cannot find the declared README.md. The crate cannot be published.

Learn more

Cargo uses the package's readme entry to include its README in the published archive. The new crate declares README.md, but its directory contains only CHANGELOG.md, SPEC.md, Cargo.toml, src, and uniffi.toml. Packaging therefore fails before a release can publish the crate.

Example: A release job packages livekit-telemetry at version 0.1.0. Cargo looks for livekit-telemetry/README.md, fails to find it, and does not produce a package.

Recommended fix: Add the declared README to the crate, or remove the explicit readme setting if the crate intentionally ships without one. Validate with cargo package -p livekit-telemetry.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


fn log_record(Queued { mut event, session, .. }: Queued, global: &[Attribute]) -> LogRecord {
session.decorate(&mut event.attributes, global);
let time_unix_nano = event.timestamp_ns.unwrap_or_else(now_unix_nanos);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Buffered events acquire upload timestamps

When timestamp_ns is absent, log_record timestamps the event during encoding, not capture. Buffered events appear at upload time instead of emission time.

Learn more

Events enter the bounded queue through Queued::new, which preserves an absent timestamp. log_record runs only when the queue is encoded for export. Any buffering interval therefore shifts event timestamps, even though TelemetryEvent::timestamp_ns promises an emit-time stamp for None.

Example: An event emitted at 12:00 remains queued during a network outage until 12:30. Its exported timestamp is 12:30, so it appears after operations that actually occurred later.

Recommended fix: Fill a missing timestamp_ns when capturing the event in Queued::new, while preserving caller-provided timestamps. Encoding must use that stored capture time.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +161 to +164
if self.session_order.len() > REMEMBERED_SPANS {
if let Some(old) = self.session_order.pop_front() {
self.sessions.remove(&old);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Long-lived spans lose session identity

After 1,024 newer spans begin, begin_in evicts an open span from sessions. A later scope_of lookup loses that span's room identity.

Learn more

The registry stores span handles in both open and sessions; scope_of reads only sessions to identify the owning room. The 1,024-entry FIFO discards the oldest session mapping without checking whether its span remains open. The open span still accepts checkpoints and can end normally, but subsequent logs can no longer resolve its session.

Example: Room A starts a long connect span. Other rooms collectively start 1,024 more spans before A logs a connect error. scope_of returns None for A's still-open span instead of Room A.

Recommended fix: Retain mappings for all open spans. Apply the fixed retention limit only to ended spans, removing open mappings after end only when they expire from the recent-span cache.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +107 to +115
pub fn set_custom(&self, key: &str, value: Option<AttributeValue>) -> bool {
if !self.accepts_custom(key, value.as_ref()) {
return false;
}
let mut custom = self.custom.lock().unwrap_or_else(|e| e.into_inner());
custom.retain(|a| a.key != key);
if let Some(value) = value {
custom.push(Attribute::new(key, value));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concurrent updates exceed custom attribute cap

When two threads add distinct keys at 63 attributes, set_custom checks capacity before taking its insertion lock. Both can succeed, leaving 65 attributes.

Learn more

accepts_custom takes and releases the custom-attribute mutex before set_custom takes it to insert. Two callers can both observe available space and each insert a different key. The limit is intended to bound attributes copied into every queued record.

Example: A scope holds 63 attributes. Threads A and B both pass accepts_custom for different new keys before either inserts; both return true, and the scope now holds 65 attributes.

Recommended fix: Validate the value and check for an existing key or remaining capacity while holding the same mutex used for the mutation.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +243 to +245
if self.finished.len() >= self.finished_capacity {
self.finished.remove(0);
self.dropped += 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Zero span capacity panics on completion

With finished_capacity set to zero, end calls remove(0) on an empty vector. The first completed span panics.

Learn more

Spans::new accepts any usize capacity. For zero, an empty finished vector already meets the >= guard, so remove(0) panics rather than discarding the completed span.

Example: Construct Spans::new(0), begin one span, then end it. The registry panics while trying to evict a nonexistent previous span.

Recommended fix: Handle zero capacity explicitly in end, counting the just-finished span as dropped without indexing the empty buffer, or reject zero capacity when constructing the registry.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +150 to +151
let mut attributes: Vec<KeyValue> = record.attributes.iter().map(KeyValue::from).collect();
attributes.extend(record.outcome_attributes().iter().map(KeyValue::from));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Duplicate span outcome corrupts rollups

When end receives an lk.outcome attribute, otlp_span exports that value alongside the computed outcome. Backends can read the wrong outcome.

Learn more

Spans::end accepts caller attributes without removing lk.outcome or error.type. otlp_span converts those attributes before adding the authoritative outcome attributes, creating duplicate OTLP keys. Backends that select the first value can treat a failed span as successful.

Example: End an error span with Attribute::new("lk.outcome", "ok"). The exported span contains both lk.outcome=ok and lk.outcome=error, so an outcome rollup can count it as successful.

Recommended fix: Remove caller-supplied reserved outcome keys before encoding, then append exactly one lk.outcome and, when present, one authoritative error.type.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant