Skip to content

feat(telemetry): add client telemetry - #458

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

pblazej wants to merge 6 commits into
mainfrom
blaze/telemetry

Conversation

@pblazej

@pblazej pblazej commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Client telemetry for React Native, on top of livekit-client's TypeScript pipeline (livekit/client-sdk-js#2109; the contract is livekit-telemetry/SPEC.md in livekit/rust-sdks#1396). Every Room on LiveKit Cloud reports its spans, RTC statistics, SDK warnings/errors and device state to its own project, as a browser does; this package adds what a phone knows and a browser does not — a file cache that survives the process and the device state behind the cadence policy — for about 700 lines across TypeScript, Swift and Kotlin, no new dependency and no new public types. Before this merges, the livekit-client peer and dev minimum is raised to the first release that ships the host seam (configureTelemetryHost, disableTelemetry), which does not exist yet (livekit/client-sdk-js#2109 is unmerged) — as the native PRs wait for their livekit-uniffi release. Until then an older livekit-client loads without telemetry rather than failing to import.

Public API

API Scope Notes
disableTelemetry() process forwards to livekit-client's; a no-op on a livekit-client without it, which the raised minimum rules out; TODO: final shape pending the token/consent discussion
room.emitTelemetryEvent(name, attributes = {}) Room livekit-client's Room, used directly by React Native apps; string name + string attributes; limits enforced by the pipeline
room.setTelemetryAttribute(key, value) Room correlation ids for matching with app data; null removes

Nothing else is public. registerTelemetry() (the host wiring), the cache bridge and the device observer stay internal; registerGlobals() calls the wiring once, and a failure there is logged once and dropped.

Platform code

What this platform adds on top of livekit-client (everything else — destination, token handling, retries, cache policy, holds, stats mapping, span state — is in the pipeline).

Eight new files, 572 lines; 732 in total including the wiring in existing files.

Files and responsibilities
File LOC Responsibility
src/telemetry.ts 141 the host: resource names (react-native, package version, OS and version, Android model); the device observer — AppState foreground/background plus the native thermal, low power and memory state, snapshot first, then deltas; wired once from registerGlobals(), logged once on failure
src/telemetryStorage.ts 59 the cache bridge: five synchronous calls, base64 bodies; a native null becomes a thrown error so the pipeline counts the batch as dropped.cache_error instead of believing it stored
ios/LKBatchStore.swift 93 a directory of batch files under Caches (recreated if the system purged it), written to a temporary name and moved into place; pruned oldest-first above 4 MiB / 512 batches / 24 h, one batch always kept; stale .tmp files dropped at start; a failed write or an id with a path separator throws
ios/LKDeviceState.swift 82 ProcessInfo thermal state and Low Power Mode notifications, a DispatchSource memory-pressure source (reports the return to normal too), mapped to SPEC's names; idempotent stop()
android/…/BatchStore.kt 85 the same cache on cacheDir, with the same checks; a failed write or rename throws
android/…/DeviceStateMonitor.kt 102 PowerManager thermal listener (API 29+, created lazily so the class links on API 24–28), ACTION_POWER_SAVE_MODE_CHANGED, onTrimMemory / onLowMemory, mapped to SPEC's names; idempotent start() / stop()
src/version.ts, scripts/write-version.js 10 the package version as a constant (a package.json import does not survive bob build), rewritten by ci:version
wiring in existing files (below) 160
Total 732 non-test, non-generated: non-blank lines of new files plus non-blank added lines in existing ones, against main

Changes in existing code

Wiring only: every existing public API, the console logging and the audio handling behave as on main; registerGlobals() adds one call and the package logger changes provider, not name or level.

Changed files
Where Change Behaviour for existing apps
src/index.tsx registerGlobals() also calls registerTelemetry(); disableTelemetry exported, forwarding to livekit-client's unchanged; additive export
src/logger.ts the lk-react-native logger comes from livekit-client's getLogger instead of a separate loglevel instance, so its warnings and errors reach telemetry like livekit-client's unchanged name, default level (warn) and setLogLevel behaviour
LiveKitReactNativeModule.swift, LivekitReactNativeModule.m startDeviceStateUpdates() (promise with the snapshot, then LK_DEVICE_STATE events) and five blocking synchronous batchStore* methods; invalidate() stops the observers; put answers nil on a failed write unchanged: nothing is observed until the pipeline asks
LivekitReactNativeModule.kt the same methods; invalidate() stops the monitor; put answers null on a failed write; emits are skipped once the React instance is gone unchanged
package.json jest-environment-node pinned to 30 under resolutions: react-native 0.83 asks for 29, which jest 30's runtime cannot drive, and no test had run here before; ci:version also rewrites src/version.ts. livekit-client peer and dev minimum: raised to the first release with the host seam before merge (today ^2.20.0, which the tolerant import survives) none (tooling)
tsconfig.build.json **/__tests__ excluded from the build none
.changeset/ minor bump release notes

Events

12 of 19 SPEC signals fully covered, 2 partially, 5 skipped. The spans, stats, logs and app calls come from livekit-client unchanged; this package supplies the four device signals a page cannot see. Network, battery and audio route are not observed — nothing in this package's dependencies exposes them (see table).

Event coverage table
Signal (SPEC name) Status Source on this platform / why skipped
lk.connect span (+ checkpoints) ✅ livekit-client, unchanged
lk.reconnect span ✅ livekit-client, unchanged
lk.publish span ✅ livekit-client, unchanged
lk.subscribe span ✅ livekit-client, unchanged
lk.rtc.stats.sample ✅ livekit-client, one getStats() per peer connection through @livekit/react-native-webrtc
lk.room.disconnected ✅ livekit-client, unchanged
lk.telemetry.report ✅ livekit-client; the cache this package supplies makes the session's last report replay at the next launch
custom.<name> ✅ room.emitTelemetryEvent
log records (warn/error) ✅ livekit-client's loggers and this package's lk-react-native logger (registered through livekit-client's getLogger), whatever the console level
lk.device.thermal.changed ⚠️ partial this package: iOS ProcessInfo.thermalState (nominal / fair / serious / critical); Android PowerManager thermal status on API 29+ (none → nominal, light → fair, moderate and severe → serious, above → critical); no source below API 29 (not exposed by the OS)
lk.device.low_power.changed ✅ this package: iOS Low Power Mode; Android Battery Saver (isPowerSaveMode)
lk.device.app_state.changed ✅ this package: AppState — background is the background (the pipeline flushes on it); active and iOS's transient inactive are the foreground
lk.device.memory.changed ⚠️ partial this package: iOS memory-pressure source with warning, critical and the return to normal; Android onTrimMemory as the Android SDK maps it (COMPLETE or RUNNING_CRITICAL → critical; every other trim → warning; UI_HIDDEN ignored, it is not a memory signal) and onLowMemory → critical — the OS never signals the end of pressure, so it counts as normal again when the app returns to the foreground; API 34+ deliver fewer trim levels
lk.device.network.changed ❌ skipped no dependency of this package exposes the network type or Data Saver (navigator.connection does not exist on Hermes); left undefined, so the pipeline raises no network hold
lk.device.battery.changed ❌ skipped not observed: needs native code (UIDevice battery monitoring, BatteryManager) this PR does not add
lk.device.audio_route.changed ❌ skipped the package's audio session management observes routes natively but does not forward them yet
lk.device.audio.interruption ❌ skipped as above: audio focus / session interruptions are handled natively, not forwarded
lk.device.capture.failed ✅ livekit-client, from the error @livekit/react-native-webrtc raises at track creation (name → permission_denied / not_found / in_use / other)
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 with the example app: registerTelemetry({ endpoint }) points the pipeline at the collector without Cloud rules or tokens.

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

# 2. Local LiveKit server (separate terminal), and a token for it
livekit-server --dev
lk token create --api-key devkey --api-secret secret --join --room test --identity phone

# 3. Unreleased livekit-client: build the branch of livekit/client-sdk-js#2109 and link it
pnpm --dir ../client-sdk-js build
rm -rf node_modules/livekit-client && ln -s ../../client-sdk-js node_modules/livekit-client

# 4. Point the pipeline at the local backend: in example/index.js, after registerGlobals()
#    (a device needs your machine's LAN address; the Android emulator reaches the host at 10.0.2.2)
#      import { registerTelemetry } from '../src/telemetry';
#      registerTelemetry({ endpoint: 'http://<your-ip>:4318' });
yarn && yarn example ios      # or: yarn example android
# connect to ws://<your-ip>:7880 with the token from step 2, publish camera/mic, then background the app

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

  • Tempo: search service.name = livekit-client-react-native: lk.connect (with its checkpoints), lk.publish, lk.subscribe, and lk.reconnect after toggling airplane mode.
  • Loki: {service_name="livekit-client-react-native"} | otel_event_name=~"lk.device.+" for the device events: background the app (app_state), toggle Low Power Mode / Battery Saver (low_power); on Android, adb shell cmd thermalservice override-status 3 (thermal) and adb shell am send-trim-memory <package> RUNNING_CRITICAL (memory); on an iOS device, Xcode's Debug ▸ Simulate Thermal State. Kill the app mid-call and relaunch it: the cached batches are uploaded first.

The jest suite (yarn test) covers the bridge: the host is wired once, a native write failure surfaces as a counted loss, device snapshots and deltas are forwarded as the pipeline's device state, and an app without the native module still configures telemetry with no cache and app state only.

@changeset-bot

changeset-bot Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1c7053b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@livekit/react-native Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pblazej pblazej changed the title Telemetry feat(telemetry): add client telemetry Oct 1, 2026
LKDeviceState and DeviceStateMonitor observe thermal state, low power mode and memory pressure and report them in the names SPEC uses, as a snapshot followed by deltas. Both start idempotently and stop deterministically; the Android thermal listener is created lazily behind the API 29 check so the class links on API 24-28, and trim levels follow the Android SDK's table: COMPLETE/RUNNING_CRITICAL critical, MODERATE/BACKGROUND/RUNNING_LOW/RUNNING_MODERATE warning, UI_HIDDEN ignored.
LKBatchStore and BatchStore keep one file per batch, written to a temporary name and renamed into place so a crash never leaves a half batch readable, pruned oldest-first above the byte, count and age budgets with one batch always kept. A failed write throws instead of answering an empty eviction list; put, read and remove share one guard that rejects ids with a path separator or '..'; the directory is recreated on every put because the system may purge it, and stale .tmp files are dropped at start.
The module gains startDeviceStateUpdates plus the LK_DEVICE_STATE event, and synchronous batchStorePut/Pending/Read/Remove/Clear with base64 bodies; a native write failure crosses the bridge as null. Observers stop from the module's existing invalidate on both platforms, and Android skips an emit once the React instance is gone since the callback arrives on the thermal or broadcast thread. Blocking synchronous methods return objects, never Bool, because the TurboModule interop retains a Bool as a pointer.
A package.json import survives bob build as ../package.json, which lib/ does not contain, so src/version.ts holds the version and ci:version rewrites it after changeset version. jest-environment-node is pinned to 30 because react-native 0.83 still asks for 29, which jest 30's runtime cannot drive; src/__tests__ is excluded from the build tsconfig.
…obals

registerGlobals hands the pipeline this platform's resource names, the native file cache and a device observer (AppState plus the native thermal, low power and memory state; a return to the foreground reports memory as normal on Android), once per process. The seam is read at call time so a livekit-client without it loads with no telemetry; disableTelemetry is re-exported through a wrapper that is a no-op without it, and nothing else telemetry-related is public. A native put that answers null throws so the pipeline counts the loss; a failing hook is reported at debug, outside the captured path. The package logger is created through livekit-client's getLogger so its warnings reach telemetry like the SDK's own. The peer minimum rises to the first livekit-client release shipping the seam before merge.
A failed native put surfaces as a throw the pipeline counts, device-state snapshots and events are forwarded until stopped, AppState transitions map to foreground and background with Android memory relief, a client without the seam degrades silently, the package logger is the getLogger instance, a configuration failure stays at debug, and an older native build degrades to no cache and no device state.
@pblazej
pblazej marked this pull request as ready for review October 2, 2026 12:50

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

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

Devin Review

Comment thread src/telemetry.ts
Comment on lines +61 to +64
return () => {
active = false;
subscription.remove();
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Native device observers outlive telemetry

When telemetry stops, stopNative removes only its JavaScript listener. The native thermal, power, and memory observers remain active until module invalidation, emitting unused events between Rooms.

Learn more

The host's device observer returns a cleanup function when telemetry no longer needs device state. That cleanup removes the JavaScript subscription, but the observers started by startDeviceStateUpdates and startDeviceStateUpdates stay installed. Both platforms only stop them when the module is invalidated, which normally happens on React-instance teardown, not between Rooms. Native notifications therefore keep firing after the telemetry pipeline stops observing them.

Example: A user ends a Room, leaving the app running. The pipeline calls the returned cleanup, but toggling Battery Saver still reaches the native emitter with no telemetry listener.

Recommended fix: Expose a native stop method on both platforms and call it when the last device-state subscription is removed. Allow a subsequent startDeviceStateUpdates to restart the observers and return a fresh snapshot.

Devin Review


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

Comment thread src/telemetry.ts
Comment on lines +53 to +59
native
.startDeviceStateUpdates()
.then((state: NativeDeviceState) => {
if (active) {
onChange(state);
}
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Late snapshot restores stale device state

If a native event arrives before startDeviceStateUpdates resolves, its snapshot can overwrite that newer state. Telemetry then tracks stale thermal or power state until another change arrives.

Learn more

The native methods capture a device snapshot and resolve a promise, while device notifications use a separate event emitter. Promise resolution and events can reach JavaScript in either order. Since the event listener is already attached, a newer notification can update the telemetry state before the older snapshot callback runs. Applying that snapshot last reverts the field until the OS emits another change.

Example: The start call snapshots lowPower: false. Battery Saver turns on and its lowPower: true event reaches JavaScript first. The queued promise then resolves with false, leaving telemetry believing Battery Saver is off.

Recommended fix: Buffer native deltas until the snapshot promise resolves, report the snapshot first, then replay buffered deltas in order. Discard both the buffer and any late snapshot after cleanup.

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