Skip to content

feat(telemetry): add the pipeline, sessions, spans and exporter - #1483

Open
pblazej wants to merge 2 commits into
blaze/telemetry-stack/4-device-rtcfrom
blaze/telemetry-stack/5-pipeline
Open

pblazej wants to merge 2 commits into
blaze/telemetry-stack/4-device-rtcfrom
blaze/telemetry-stack/5-pipeline

Conversation

@pblazej

@pblazej pblazej commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The pipeline: the synchronous handle, one scope per Room, typed spans, and the exporter actor. The README becomes the crate docs (its usage example is a doc test) and SPEC.md is complete.

Changes

  • Telemetry: the synchronous handle — flood guard, flush / shutdown / purge, stats
  • Scope: one Room — server URL and token, attributes, RTC stats, the lk.subscribe lifecycle
  • Typed Spans
  • Exporter: the actor that encodes, caches and uploads under the upload policy
  • README as crate docs; SPEC.md complete

Why this stage is larger

The one stage over ~2k lines: Telemetry ⇄ Shared ⇄ Exporter and Scope ⇄ Span ⇄ Telemetry are one dependency cycle, so they land together. Their tests follow in #1484, together with the backend contract.

Temporary

#[allow(dead_code)] on mod telemetry (weak_commands serves global, #1485; test hooks serve #1484) and #[cfg_attr(test, allow(dead_code))] on mod span (open_count, #1485).

Verification

At c16912c3, 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 [], [net], [uniffi], [net,uniffi]
  • cargo test -p livekit-telemetry: 56 unit, 2 doc; --all-features: 57 unit, 2 doc

cargo doc -D warnings reports exactly what it reports on 6aba1b68 (private-item links, ExportError::from_response).

`Telemetry` is the synchronous handle (emit, log, device state, flush,
shutdown, purge, stats) behind a flood guard; `Scope` is one Room on it,
with its own trace id, server URL and token, attributes, RTC stats and the
`lk.subscribe` lifecycle; `Span` is one typed attempt (`SpanName`,
`SpanStep`). The `Exporter` actor drains the queue and finished spans,
encodes and gzips one batch per project into the cache before any network
is involved, then uploads oldest first: it acts on every collector answer,
pauses a destination with jittered backoff or for the delay the server
asked for, splits a 413, holds uploads while a Room connects or the device
asks for quiet, and meters requests while a Room is in a call.
@pblazej pblazej added the internal to tag changes that don't require changelog documentation label Oct 1, 2026
@pblazej
pblazej force-pushed the blaze/telemetry-stack/5-pipeline branch from 463a051 to c16912c 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:33
@pblazej
pblazej requested a review from ladvoc as a code owner October 1, 2026 14:33

@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.

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

Devin Review

Comment on lines +266 to +270
self.spans
.lock()
.unwrap_or_else(|e| e.into_inner())
.end(span, outcome, error_type, attributes);
}

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.

🟡 Finished connections leave uploads waiting

When a connect span ends, end_span does not wake the exporter to lift its soft hold. Cached batches wait until the hold cap or next tick despite signaling having finished.

Learn more

The exporter checks open connect and reconnect spans in hold_reason. Once it has entered a soft hold, its next scheduled wake is the hold cap or export tick, unless another change explicitly wakes it. Ending a span only updates the registry, so cached work remains held even though the reason ended.

Example: A connect finishes at second 3 with cached batches waiting. With the default cadence, the exporter stays asleep until its 60-second hold deadline rather than starting an upload at second 3.

Recommended fix: Wake the exporter when a sensitive span ends, after releasing the spans mutex. Ensure the release wake also covers the subscribe and generic span-end paths without holding registry locks during notification.

Devin Review


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

Comment on lines +743 to +746
if self.offline() {
self.log_hold(Some("offline"), backlog);
return (0, false);
}

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.

🟡 Offline time exhausts the upload hold

When the device goes offline during a soft hold, budget retains held_since throughout the hard hold. Reconnection immediately releases a batch even if the constrained network or low battery still requires quiet.

Learn more

A soft hold uses held_since as its 60-second timer. Going offline is a hard hold and stops requests indefinitely, but the early offline return leaves that timer running. On reconnection, the next pass sees a cap that has already elapsed and sends a batch despite the continuing soft-hold condition.

Example: Low Data Mode starts holding at 12:00:00, the phone goes offline at 12:00:05 and reconnects at 12:02:00. The first pass sends a batch immediately rather than waiting under the still-active Low Data Mode hold.

Recommended fix: Suspend or reset the soft-hold timer when the hard offline hold begins, and resume a fresh or remaining timer when it lifts. Check the same behavior for destination/token hard holds so waiting for credentials does not consume the soft-hold allowance.

Devin Review


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

Comment on lines +395 to +398
fn in_call(&self) -> bool {
let scopes = self.shared.scopes.lock().unwrap_or_else(|e| e.into_inner());
scopes.iter().any(|s| s.upgrade().is_some_and(|s| s.in_call()))
}

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.

🟡 Dropped rooms keep backlog metered

After a room is dropped without disconnected, in_call still sees its server through the retained span session. Spans.sessions keeps that session alive, so backlog stays limited to four requests per tick.

Learn more

The exporter decides whether to meter backlog by inspecting live ScopeState values. The span registry remembers recent span-to-session associations with strong Arc<ScopeState> references, including finished spans, so a room scope can be gone while its session still upgrades here. Its server field remains set unless the caller invoked disconnected, making the exporter treat a vanished room as a call.

Example: A room sets its server, finishes one connect span, then its last Scope is dropped without calling disconnected. The process retains a 100-batch backlog; uploads stay at four per minute instead of draining freely after the room disappears.

Recommended fix: Track whether the actual Room scope remains alive independently of retained record/span associations, or make the span-to-session history weak and preserve needed queued data elsewhere. Clear the in-call state on room teardown without sacrificing the routing credentials required by cached batches.

Devin Review


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

Comment on lines +592 to +595
if !self.guard.lock().unwrap_or_else(|e| e.into_inner()).admit() {
Counters::add(&self.shared.counters.rate_limited, 1);
return;
}

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.

🟡 Event quota drops warning logs

When warning logs arrive after 300 discrete events, emit_in applies the same flood guard to their empty-name records. Diagnostic logs disappear until the ten-minute window resets.

Learn more

The flood guard caps discrete events, while plain logs are distinguished by an empty event name. log_event creates those records, and they reach this same capture path. Sharing the quota means an unrelated burst of events can prevent important warning and error lines from being exported.

Example: After 300 lk.ping events in ten minutes, an SDK error log arrives with an empty name. It is counted as rate-limited and never enters the queue.

Recommended fix: Apply FloodGuard::admit only to named, discrete events; keep the existing severity floor for plain logs.

Suggested change
if !self.guard.lock().unwrap_or_else(|e| e.into_inner()).admit() {
Counters::add(&self.shared.counters.rate_limited, 1);
return;
}
if !event.name.is_empty() && !self.guard.lock().unwrap_or_else(|e| e.into_inner()).admit() {
Counters::add(&self.shared.counters.rate_limited, 1);
return;
}

Devin Review


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

Comment on lines +335 to +338
let valid = !name.is_empty()
&& name.len() <= crate::event::MAX_NAME_BYTES
&& attributes.len() <= crate::event::MAX_CUSTOM_ATTRIBUTES
&& attributes.iter().all(|a| crate::event::valid_custom(&a.key, Some(&a.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.

🟡 Custom event prefix exceeds name limit

A 128-byte name passes emit_custom, but TelemetryEvent::custom prepends custom. afterward. The exported name exceeds the 128-byte limit instead of being rejected and counted.

Suggested change
let valid = !name.is_empty()
&& name.len() <= crate::event::MAX_NAME_BYTES
&& attributes.len() <= crate::event::MAX_CUSTOM_ATTRIBUTES
&& attributes.iter().all(|a| crate::event::valid_custom(&a.key, Some(&a.value)));
let valid = !name.is_empty()
&& name.trim_start_matches("custom.").len() + "custom.".len()
<= crate::event::MAX_NAME_BYTES
&& attributes.len() <= crate::event::MAX_CUSTOM_ATTRIBUTES
&& attributes.iter().all(|a| crate::event::valid_custom(&a.key, Some(&a.value)));

Devin Review


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

Comment on lines +355 to +363
pub fn disconnected(&self, reason: DisconnectReason) {
let open: Vec<_> =
self.state.subscribes.lock().unwrap_or_else(|e| e.into_inner()).drain().collect();
for (_, (span, since)) in open {
end_unfinished(&span, since);
}
self.telemetry.retire_stats(&self.state, None);
// Out of the call: uploads no longer yield to it.
self.state.server.lock().unwrap_or_else(|e| e.into_inner()).take();

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.

🟡 Repeated disconnects duplicate terminal events

Calling disconnected twice emits lk.room.disconnected twice because the method has no terminal-state check. A room's final disconnect is counted twice.

Learn more

The method clears pending subscribes and the server slot, then unconditionally emits a disconnect event. It never records whether it already emitted one for this scope. A repeated platform callback therefore produces duplicate final records rather than leaving the session terminal.

Example: The transport and Room cleanup paths both call disconnected(ClientInitiated) for the same scope. The queue receives two lk.room.disconnected records for one call.

Recommended fix: Add a per-scope terminal flag or equivalent one-time transition, set atomically before producing the event. Keep disconnect reporting available for failed connects that never had a server.

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

internal to tag changes that don't require changelog documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant