Skip to content

feat(telemetry): add client telemetry through the shared Rust core - #1209

Open
pblazej wants to merge 8 commits into
mainfrom
blaze/telemetry
Open

pblazej wants to merge 8 commits into
mainfrom
blaze/telemetry

Conversation

@pblazej

@pblazej pblazej commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Client telemetry for Flutter, on top of the shared Rust core (livekit/rust-sdks#1396). On iOS, Android, macOS, Windows and Linux every Room reports its spans, RTC statistics, SDK warnings/errors and device state to its LiveKit Cloud project (only when the token carries the observability grant), for about 1,200 lines of Dart and no new public types; web stays a compiled no-op.

Carries the livekit_uniffi facade from #1160 (uniffi-rust-core) until that merges, so GitHub's diff against main also shows its lines. The counts below are this PR's own.

Public API

API Scope Notes
LiveKitClient.disableTelemetry() process in effect when the call returns: in the calling isolate no getStats() or stats submission begins afterwards; another live isolate stops at its next check of the core's process-wide flag (made right before each read and each submission), so at most one getStats() per Room that had already passed the check can still start there, and whatever it submits is refused by the core (nothing is uploaded); unsent data is deleted in the background; TODO: final shape pending the token/consent discussion
Room.emitTelemetryEvent(String name, {Map<String, String> attributes}) Room string name + string attributes; limits enforced by the core
Room.setTelemetryAttribute(String key, String? value) Room correlation ids for matching with app data; null removes

Nothing else is public. Configuration, instruments, transport and the UniFFI types stay internal (nothing new is exported from livekit_client.dart; Room.telemetry and Engine.telemetry are @internal fields, like the existing @internal Room.engine); every tuning value (60 s export, 60 s windows, stats poll interval) is the core's default.

Platform code

What this platform adds on top of Rust (everything else — destination, token handling, retries, cache, holds, stats mapping, span state — is in the core).

Four files, 1,030 lines; about 1,200 lines in total including the wiring in existing files.

Files and responsibilities
File LOC Responsibility
telemetry/telemetry_io.dart 813 installs the pipeline synchronously with the first Room; serves the core's pull queue from a root-zone timer polling tryNext() — every second while a Room is in a call or requests keep coming, else every 5 s (uniffi-dart callbacks are isolate-bound; the SDK leaves the core no callback and no pending Rust future into the isolate, so hot restart and recreated engines are safe) with package:http: the raw answer comes back, a redirect is never followed, an unanswered request is aborted after the export timeout; SDK log records (the live ambient span, else the Room, else the process); device and log instruments started and stopped from Dart (app lifecycle, memory pressure, connectivity, audio outputs → one change stream → DeviceState / events); per-Room session: token forwarding, lk.connect / lk.reconnect / lk.publish spans, remote track lifecycle for the core's lk.subscribe, one getStats() per peer connection every statsPollIntervalMs() → recordPeerStats; the on-disk cache location (the app's temporary directory on mobile, a per-user directory per app on desktop: an absolute $XDG_CACHE_HOME, else $HOME/.cache on Linux, memory only without either); one guard around the core calls made from SDK paths (connect, publish, subscribe, spans, teardown, opt-out, app events, log and device capture), so a core error is dropped and never fails a connect, publish, teardown or log call; a disposed Room leaves no listener on its signal client, and an ended span drops its native handle and its Room reference, so a zone that outlives the Room (the signal client's existing connectivity subscription) keeps only an empty span object; enum mappings
telemetry/telemetry.dart 95 the internal, platform-neutral surface the SDK calls (conditional import native/web), connect checkpoints, ambient span / Room zones
telemetry/telemetry_web.dart 25 web: no Room gets a session
support/sdk_logger.dart 97 the SDK's logger: every call goes to the livekit Logger unchanged when it would emit (telemetry reads its records); only a warning/error its level filters out is built once for telemetry, guarded, and never thrown to the caller
uniffi/uniffi_io.dart +56 re-exports the telemetry bindings: stays the SDK's single import site of livekit_uniffi
wiring in existing files (below) 127
Total 1213 (non-test, non-generated; added lines)

Changes in existing code

Wiring only: every existing public API and the SDK's logging behave as on main.

Changed files
Where Change Behaviour for existing apps
Room takes its session at construction; connect() delegates to a private _connect() (body unchanged) run inside lk.connect; its engine/signal listeners are set up in the Room's telemetry zone (re-indent only); the two public methods; the session follows room updates and a room move, and ends on dispose() without disconnect() unchanged
Engine connect checkpoints; one lk.reconnect span per reconnect cycle, attempts as checkpoints; the internal EngineClosingEvent carries the disconnect reason unchanged
LocalParticipant.publishAudioTrack / publishVideoTrack one lk.publish span per attempt, nested under the ambient span or the open connect span (pre-connect microphone); the sid is set on the span once known unchanged
RemoteTrackPublication.subscribe() a manual subscribe opens lk.subscribe unchanged
LocalTrack.createStream a getUserMedia / getDisplayMedia failure is reported as lk.device.capture.failed, then rethrown unchanged
LiveKitClient disableTelemetry() additive
logger.dart logger is the livekit Logger wrapped by SdkLogger; telemetry reads its warning/error records, and gets the ones its level filters out (disableLogging(), a level above WARNING) from the wrapper unchanged: the declared type, level, records, listeners, output and message evaluation as before; no Rust log forwarder is installed
.gitignore, AGENTS.md pubspec.yaml is untouched by this PR. A local rust-sdks build is wired per checkout with a git-ignored pubspec_overrides.yaml next to the package's and the example's pubspec.yaml, each path relative to its own file (overrides do not propagate from a dependency, hence both); AGENTS.md's local loop shows the two commands; without the files the published package resolves unchanged for consumers. Until a livekit-uniffi release carries the telemetry bindings and the livekit_uniffi constraint is bumped to it (a cross-platform release step), a build without the override fails to compile
build.yaml after the unchanged flutter test, one more step runs a checksum-verified otelcol-contrib and flutter test test/telemetry/ with LK_TELEMETRY_ENDPOINT; a collector that does not start fails the job adds one step
test/mock/peerconnection_mock.dart, e2e_container.dart the mock peer connection's getStats() reports its sent tracks and mock inbound tracks; addTransceiver / restartIce implemented; connectRoom(otherParticipants:) joins a room others are already in tests only

Events

9 of 19 SPEC signals fully covered, 5 partially, 5 skipped (no plugin exposes them to the SDK, or smoke-test only). error.type is the Dart exception's type name (TrackPublishException); in --obfuscate builds that name is obfuscated — how every platform names errors is a pending cross-platform decision.

Event coverage table
Signal (SPEC name) Status Source on this platform / why skipped
lk.connect span (+ checkpoints) ✅ Room.connect: ws_open · signal · join_recv · pc_created · offer_sent · answer_sent · engine · pc_connected · room_connected; the app's disconnect() meanwhile ends it as cancelled, a server close as failed
lk.reconnect span ✅ Engine reconnect cycle; attempt <n> quick|full per attempt
lk.publish span ✅ publishAudioTrack / publishVideoTrack
lk.subscribe span ✅ intent: autoSubscribe publish, the join for tracks already in the room, or subscribe(); subscribed / failed / unsubscribed / unpublished (cancelled before first media); first media seen by the core
lk.rtc.stats.sample ✅ one getStats() per peer connection (publisher + subscriber), paced by the core; track signals only bring the next poll forward, one poll at a time, each read bounded to 5 s
lk.room.disconnected ✅ RoomDisconnectedEvent reason
lk.telemetry.report ✅ core
custom.<name> ✅ Room.emitTelemetryEvent
log records (warn/error) ⚠️ partial the SDK's livekit logger, whatever its console level; the Rust core copies its own warnings/errors once a platform installs its log forwarder, which the Flutter SDK does not (no Rust log forwarding today); native WebRTC logs are not captured
lk.device.thermal.changed ❌ skipped not exposed without a platform plugin; reported as unknown (no event)
lk.device.low_power.changed ❌ skipped not exposed without a platform plugin; reported as unknown (no event)
lk.device.app_state.changed ✅ WidgetsBindingObserver lifecycle, starting from the binding's current state
lk.device.memory.changed ⚠️ partial didHaveMemoryPressure → warning; the OS never signals relief, so back to normal after a quiet minute; no critical level
lk.device.network.changed ⚠️ partial connectivity type (wifi / cell / wired / vpn / bluetooth / other / unavailable); expensive = cellular; constrained not exposed
lk.device.battery.changed ❌ skipped not exposed without a platform plugin
lk.device.audio_route.changed ⚠️ partial audio output list changes (Hardware.onDeviceChange), outputs from device labels; the platform gives no reason
lk.device.audio.interruption ❌ skipped not exposed by the audio plugins the SDK uses
lk.device.capture.failed ⚠️ partial getUserMedia / getDisplayMedia failures at track creation, reason from the error text; interruptions of a running capture are not reported
lk.ping ❌ skipped pipeline smoke test, never emitted in production paths

Local testing

Try it against a local OTel backend (LGTM) in a few minutes; the e2e test needs no server (signalling and media are mocked).

Commands
# 1. Local OTel backend (OTLP/HTTP on :4318, UI on :3000)
docker run -d --name lk-lgtm -p 3000:3000 -p 4318:4318 grafana/otel-lgtm

# 2. Local LiveKit server (for the example app only)
livekit-server --dev

# 3. Unreleased Rust bindings: build the package in a rust-sdks checkout next to this one, wire it in (see AGENTS.md)
(cd ../rust-sdks/livekit-uniffi && cargo make dart-package)
printf 'dependency_overrides:\n  livekit_uniffi:\n    path: ../rust-sdks/livekit-uniffi/packages/dart\n' > pubspec_overrides.yaml
printf 'dependency_overrides:\n  livekit_uniffi:\n    path: ../../rust-sdks/livekit-uniffi/packages/dart\n' > example/pubspec_overrides.yaml
flutter pub get

# 4. Point the core at the local backend and run the e2e test / example app
export LK_TELEMETRY_ENDPOINT=http://localhost:4318
flutter test test/telemetry/telemetry_e2e_test.dart   # prints: telemetry e2e: find app.call_id=<marker> in the backend
lk token create --api-key devkey --api-secret secret --join --room demo --identity flutter --valid-for 24h
(cd example && flutter run -d macos)                  # connect to ws://localhost:7880 with that token

Then open http://localhost:3000 → Explore: logs (Loki: {service_name="livekit-client-flutter"} | app_call_id="<marker>", otel_event_name) and traces (Tempo: the records' trace_id; lk.connect, lk.publish, lk.subscribe, …) for the session's trace id.

@pblazej pblazej changed the title Telemetry Client telemetry Sep 30, 2026
@pblazej pblazej changed the title Client telemetry Telemetry Sep 30, 2026
@pblazej pblazej changed the title Telemetry feat(telemetry): add client telemetry through the shared Rust core Oct 1, 2026
Exposes the telemetry core's public API through UniFFI.
Example app displays the Rust core build version as an FFI smoke test.
Process-wide export queue with device state monitoring and RTC stats collection.
Serves OTLP/HTTP pulled export with persistent storage and fail-open behavior.
Record Engine connect checkpoints on the lk.connect span that Room wiring starts, add reconnect spans, and propagate the mapped disconnect reason.
Track lk.publish spans for local participants, lk.subscribe for remote.
Capture getUserMedia failures with mapped reason.
Add LiveKitClient.disableTelemetry() to opt out process-wide.
Room.emitTelemetryEvent() sends custom.<name> events with attributes.
Room.setTelemetryAttribute() sets correlation attributes per session.
Unit tests for SDK logger, telemetry core errors, isolate safety, opt-out.
E2E test against local collector validates the full pipeline.
OTEL collector service provides HTTP endpoint for E2E validation.
Changesets for client telemetry and uniffi-rust-core dev wiring.
AGENTS.md documents UniFFI setup and local development: Native Assets, overrides and build hooks.
@pblazej
pblazej marked this pull request as ready for review October 1, 2026 14:37

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 3 potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

return result! as LocalTrackPublication<LocalAudioTrack>;
}

return room.telemetry?.publish(track, publish) ?? publish();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Device toggles omit publish spans

When apps enable a microphone, camera, or screen share, publishAudioTrack and publishVideoTrack are bypassed. setTrackEnabled calls the private publishers directly, so these publications have no lk.publish span.

Learn more

The public track publishers wrap their serialized work in telemetry, but the SDK's device-toggle path invokes _publishAudioTrack and _publishVideoTrack directly inside _publishRunner in setTrackEnabled. This affects microphone, camera, screen-share video, and captured screen-share audio. Those publishes still reach the server, but never reach the telemetry publish wrapper.

Example: Calling room.localParticipant!.setMicrophoneEnabled(true) captures and publishes the microphone through _publishAudioTrack, but records no lk.publish span. Publishing an already-created microphone via publishAudioTrack records one.

Recommended fix: Instrument the shared private publish operation or the serialized toggle path as well, without calling a public method from inside _publishRunner and deadlocking its queue. Preserve one span per published track, including screen-share audio.

Devin Review


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

Comment on lines +679 to +680
_stats = Timer(due - _clock.elapsed, () async {
if (_disabled || _room.connectionState == ConnectionState.disconnected) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 RTC statistics stop after reconnect

If the stats timer fires while signaling is disconnected, _poll exits without rearming. A successful resume emits RoomReconnectedEvent, not RoomConnectedEvent, so RTC statistics stay silent until another track event.

Learn more

A connected Room schedules _poll according to the core's interval. During a resume, SignalClient.connect temporarily calls cleanUp, which sets its state to disconnected, before opening the new socket. If the timer fires in that window, it returns without scheduling another timer. The existing RoomReconnectedEvent does not restart the telemetry timer, and a resume does not emit RoomConnectedEvent.

Example: A Room has a one-second poll due at 12:00:01. The socket drops at 12:00:00.5 and reconnects at 12:00:02. The scheduled poll sees disconnected at 12:00:01 and stops; a quiet call collects no further RTC stats.

Recommended fix: Resume _poll() on successful resume/full restart, or keep scheduling while reconnection is pending and stop only on a final RoomDisconnectedEvent or disposal.

Devin Review


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

if (_room.connectOptions.autoSubscribe) {
for (final participant in _room.remoteParticipants.values.toList()) {
for (final publication in participant.trackPublications.values.toList()) {
if (publication.track == null) subscribeStarted(publication);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Early media loses subscribe spans

If a pre-join track attaches before RoomConnectedEvent, subscribeStarted skips its publication because track is non-null. TrackSubscribedEvent only marks an existing span, so that subscription has no lk.subscribe span.

Learn more

Tracks received before their metadata are queued by EngineTrackAddedEvent. During join, _getOrCreateRemoteParticipant flushes that queue before the handler emits RoomConnectedEvent. When the track is attached in time, the new telemetry handler excludes its publication on RoomConnectedEvent, even though no TrackPublishedEvent was emitted during join. The TrackSubscribedEvent handler calls _scope.subscribed but never starts the subscribe intent.

Example: A remote audio track arrives while the Room is connecting. Joining creates the remote participant and flushes the queued audio before RoomConnectedEvent. The publication already has a track when the telemetry handler enumerates it, so its lk.subscribe span never starts.

Recommended fix: Register auto-subscribe intent for all eligible join publications, including ones already carrying media; account for an already subscribed track when signaling its completion to the core.

Devin Review


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

This branch has not been deployed

No deployments
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