Skip to content

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

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

pblazej wants to merge 7 commits into
mainfrom
blaze/telemetry

Conversation

@pblazej

@pblazej pblazej commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Client telemetry for Swift, on top of the shared Rust core (livekit/rust-sdks#1396). 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.2k lines of Swift and no new public types.

Public API

API Scope Notes
LiveKitSDK.disableTelemetry() process TODO: final shape pending the token/consent discussion
Room.emitTelemetryEvent(_:attributes:) Room string name + string attributes; limits enforced by the core
Room.setTelemetryAttribute(_:value:) Room correlation ids for matching with app data; nil removes

Nothing else is public. Configuration, instruments, transport and the UniFFI types stay internal; 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).

Three files, 838 lines; about 1,210 lines in total including the wiring in existing files.

Files and responsibilities
File LOC Responsibility
Telemetry/Telemetry.swift 333 installs the pipeline synchronously with the first Room; opt-out (the core's synchronous disable, plus deleting a cache left by an earlier launch) and the lock that admits each stats request and submission; SDK log records (ambient span, else the Room); WebRTC error sink; URLSession transport that returns the raw answer and never follows a redirect to another origin; enum mappings
Telemetry/DeviceTelemetry.swift 305 actor on its own serial executor: thermal, low power, memory pressure, network path, battery, app state → DeviceState; audio route / interruption → device events; one AsyncStream for every OS callback
Telemetry/RTCTelemetry.swift 200 remote track lifecycle for the core's lk.subscribe (intents at join and after a full reconnect for tracks already in the Room); one getStats() per peer connection every statsPollIntervalMs() → recordPeerStats; report flattening
wiring in existing files (below) 370
Total 1208 (non-test, non-generated; 130 of them whitespace-only re-indentation in Room/Room+Engine)

Changes in existing code

Wiring only: every existing public API, the console logging and custom tracers behave as on main; one visible side effect, battery monitoring (below).

Changed files
Where Change Behaviour for existing apps
Room, Room+SignalClientDelegate takes its scope and RTC instrument at init; hands the core its URL and latest token on every token change (connect, refresh, room move); setRoom at join, full reconnect, move and room update; join-time subscribe intents; disconnected at clean-up, with the protocol reason of a server Leave; connect runs inside the telemetry span (re-indent only), the pre-connect microphone publish outside it unchanged
Room+Engine, Room+TransportDelegate one lk.reconnect span per reconnect cycle, attempts as checkpoints; subscribe intents re-reconciled after a full reconnect; a cycle that gives up reports reconnect_failed unchanged
LocalParticipant._publish one lk.publish span per attempt, parented to the ambient span only while it is open (its sid lets the core poll the new track sooner); a capture error during publish is reported as lk.device.capture.failed unchanged
RemoteTrackPublication.set(subscribed:) a manual subscribe opens lk.subscribe unchanged
Support/Tracing.swift Span optionally carries the core's span: record(_:) adds its checkpoint, end() ends it unchanged: Span, Tracing, LoggingTracer, setTracing, Room.connectSpan as on main; custom tracers (Benchmarks) work as before
Support/Logger.swift Loggable warnings/errors also go to the core unchanged: the app's Logger gets exactly what it got before
MulticastDelegate an internal observer slot: notified like the app's delegates, never listed or removed by removeAllDelegates() (the RTC instrument uses it) unchanged
Transport whole peer connection telemetryStatistics(), initiated on the RTC executor only while the opt-out lock admits it, bounded to 5 s (a late answer is dropped) new internal call; per-track statistics and their options untouched
LiveKit.swift disableTelemetry(): once it returns, no new Room gets a scope, the core accepts nothing more, and no Room starts another getStats() or submits a report (a request already in flight is dropped); OS observers and the WebRTC log sink are released shortly after; everything unsent, including a cache from an earlier launch, is deleted; not remembered across launches additive
LiveKit+DeviceHelpers.swift the extension-safe UIApplication lookup also returns the app state (initial lk.device.app_state) unchanged
device instrument (iOS/visionOS) sets UIDevice.isBatteryMonitoringEnabled = true and leaves it on for the process, also after opt-out; restoring it could switch off monitoring the app enabled meanwhile battery notifications enabled for the app too
DataTrackE2EE, test support, Benchmarks token newer bindings: Failed(reason:), VideoGrants.agent unchanged
Package.swift, Package@swift-6.2.swift, Benchmarks/Package.swift LIVEKIT_UNIFFI_PATH switches livekit-uniffi to a local build (cargo make swift-package-debug), else the released package unchanged for consumers. An env switch rather than a relative sibling path: worktree layouts differ, and nothing machine-specific lands in a manifest. Until a livekit-uniffi release carries the telemetry bindings, a build without the variable fails to compile
ci.yaml runs otelcol-contrib next to the dev server for the telemetry e2e test adds one step

Events

13 of 19 SPEC signals fully covered, 5 partially (not exposed by the OS on some platforms, or capture failures outside publish), 1 skipped (smoke-test only).

Event coverage table
Signal (SPEC name) Status Source on this platform / why skipped
lk.connect span (+ checkpoints) ✅ Room.connect, via the existing Span checkpoints: ws_open · signal · join_recv · pc_created · offer_sent · answer_sent · engine · pc_connected · room_connected (+ custom early_pc_created)
lk.reconnect span ✅ reconnect cycle; attempt <n> <mode> per attempt; reason publisher_failed / subscriber_failed for a failed peer connection, signal_disconnected, network_changed, else transport_failed
lk.publish span ✅ LocalParticipant._publish, nested under the ambient span while that span is open
lk.subscribe span ✅ intent: under autoSubscribe, a remote publish or, at join and after a full reconnect, every track already in the Room; or set(subscribed: true); subscribed / failed / unsubscribed / unpublished; first media seen by the core at its 1 s polls
lk.rtc.stats.sample ✅ one getStats() per peer connection (publisher + subscriber), paced by the core; a connection with no answer within 5 s is skipped
lk.room.disconnected ✅ Room clean-up: a server Leave keeps its protocol reason (the core's mapping), a reconnect that gives up is reconnect_failed, otherwise the LiveKitError
lk.telemetry.report ✅ core
custom.<name> ✅ Room.emitTelemetryEvent
log records (warn/error) ✅ SDK (Loggable) warnings/errors and WebRTC errors from Swift; the Rust core copies its own warnings and errors with the default logger (OSLogger with ffi: true, which starts the core's log forwarder); Swift never forwards those entries itself, so nothing is counted twice
lk.device.thermal.changed ✅ ProcessInfo.thermalStateDidChangeNotification
lk.device.low_power.changed ✅ NSProcessInfoPowerStateDidChange (macOS 12+)
lk.device.app_state.changed ✅ AppStateListener: background/foreground; sleep/wake on macOS
lk.device.memory.changed ✅ DispatchSource memory pressure
lk.device.network.changed ⚠️ partial NWPathMonitor: wifi / cell / wired / other / unavailable, expensive, constrained; VPN and Bluetooth tethering are not told apart by the path
lk.device.battery.changed ⚠️ partial UIDevice on iOS/visionOS; not exposed by the OS on macOS/tvOS
lk.device.audio_route.changed ⚠️ partial AVAudioSession on iOS/tvOS/visionOS; no audio session on macOS
lk.device.audio.interruption ⚠️ partial AVAudioSession on iOS/tvOS/visionOS; no audio session on macOS
lk.device.capture.failed ⚠️ partial capture errors during publish (permission denied, not found, format/engine); 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 runs a whole call and prints its Rooms' trace ids.

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 (separate terminal)
livekit-server --dev

# 3. Unreleased Rust bindings: build the local package (macOS + iOS slices) and switch the manifests to it
RUST_SDKS=~/path/to/rust-sdks
(cd "$RUST_SDKS/livekit-uniffi" && SPM_PLATFORMS="macos ios" cargo make swift-package-debug)
export LIVEKIT_UNIFFI_PATH="$RUST_SDKS/livekit-uniffi/packages/swift/LiveKitUniFFI"

# 4. Point the core at the local backend and run the e2e test
#    (xcodebuild hands the test process only TEST_RUNNER_-prefixed variables)
export LK_TELEMETRY_ENDPOINT=http://localhost:4318
TEST_RUNNER_LK_TELEMETRY_ENDPOINT=$LK_TELEMETRY_ENDPOINT xcodebuild test -scheme LiveKit \
  -destination 'platform=macOS' -only-testing:LiveKitCoreTests/TelemetryTests
# → telemetry e2e: publisher trace <id>, subscriber trace <id>, late joiner trace <id>, manual subscriber trace <id>

Then open http://localhost:3000 → Explore:

  • Tempo: search a printed trace id: lk.connect (with its checkpoints), lk.reconnect, lk.publish on the publisher; lk.connect, lk.subscribe (first_media) on the subscribers.
  • Loki: {service_name="livekit-client-swift"} | trace_id="<id>" for that Room's stats windows (lk.rtc.stats.sample), lk.room.disconnected, custom.e2e.checkpoint and SDK warnings/errors; {service_name="livekit-client-swift"} | otel_event_name=~"lk.device.+" for the device events.

Without LK_TELEMETRY_ENDPOINT the same test posts to the collector CI runs (otelcol-contrib --config Tests/LiveKitCoreTests/Telemetry/otelcol.yaml) and asserts on what it wrote.

@github-actions

Copy link
Copy Markdown

⚠️ This PR does not contain any files in the .changes directory.

@pblazej pblazej changed the title Telemetry feat(telemetry): add client telemetry through the shared Rust core Oct 1, 2026
Introduce telemetry infrastructure built on livekit-telemetry Rust crate. Add Telemetry singleton managing OTLP export pipeline, opt-out control, and tracing context helpers. Pipeline ships spans to configured collector; defaults to no-op when unconfigured. Rename UniFFI error field from message to reason to avoid Kotlin bindgen conflict. Forward SDK warnings and errors to telemetry core.
Add tracing spans for connect, publish, subscribe and reconnect, and record disconnect events with their mapped reason. Connect span wraps full connection setup; publish and subscribe spans track track operations; reconnect spans measure recovery time. Add SDK-internal observer support to MulticastDelegate.
Report device thermal state, power state, memory warnings, network reachability changes, battery level and state, audio route changes, and app foreground/background transitions as telemetry events. Bounded stream prevents unbounded memory growth from rapid device changes.
Poll getStats() on each peer connection; submit codecs, inbound/outbound RTP streams, ICE candidates, and connection quality metrics. Respects opt-out; keeps an idle timer while disconnected, without reading stats.
Add disableTelemetry() for opt-out, emitTelemetryEvent() for custom events, and setTelemetryAttribute() for context propagation. Opt-out stops collection when the call returns.
Cover connect, publish, subscribe, reconnect, late join, device state, RTC stats, and opt-out. Include local OTLP collector config; tests skip when collector unavailable.
Update CI workflow for telemetry tests and benchmark support.
@pblazej
pblazej marked this pull request as ready for review October 1, 2026 14:36

@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 5 potential issues.

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

Devin Review

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.

🟡 Room moves lose subscription start times

When a room moves with existing remote tracks, joined never reports their subscription intent. The move rebuilds participants without publishing callbacks, so their subscribe spans start only when media arrives.

(Refers to this code)

Devin Review


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

Comment on lines +95 to +96
let next = DispatchTime.now().uptimeNanoseconds + wait
self?.polling.mutate { if !Task.isCancelled { $0.next = next } }

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.

🟡 Track polls restart on stale deadlines

When poll replaces a sleeping loop, that loop can overwrite polling.next after its stats request completes. A new subscription can then inherit an incorrect deadline and delay its first-media reading.

Devin Review


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

Comment on lines +56 to +58
for case let publication as RemoteTrackPublication in participant.trackPublications.values where publication.track == nil {
guard let track = SpanTrack(publication, remoteIdentity: participant.identity?.stringValue) else { continue }
scope.subscribeStarted(track: track)

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.

🟡 Unwanted tracks open subscribe spans

With auto-subscribe enabled, joined treats every unattached track as wanted, including tracks explicitly unsubscribed through set(subscribed: false). A full reconnect then records a subscribe wait for media the client never requested.

Devin Review


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

Comment on lines +239 to +243
if level >= .warning {
Telemetry.log(LogRecord(severity: level.severity, source: .sdk, body: message?.description ?? "",
logger: String(describing: Self.self), function: "\(function)", file: "\(file)",
line: UInt32(clamping: line)),
scope: scope)

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.

🟡 Warning messages are evaluated twice

For warning and error logs, log evaluates message for both the app logger and telemetry. Side-effecting or changing descriptions produce inconsistent records and duplicate work.

Devin Review


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

Comment on lines +169 to +176
guard let url = URL(string: request.url) else {
throw ExportError.Rejected(reason: "invalid url \(request.url)")
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = request.body
for (name, value) in request.headers {
urlRequest.setValue(value, forHTTPHeaderField: name)

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.

🟨 Cleartext telemetry can expose participant tokens

When the supplied server URL uses HTTP, telemetry can send its bearer token over cleartext HTTP. The redirect guard checks same-origin redirects but never requires an encrypted transport.

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