Conversation
🦋 Changeset detectedLatest commit: 1c7053b The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
e0699e3 to
3747b20
Compare
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.
2a42a11 to
1c7053b
Compare
There was a problem hiding this comment.
Devin Review found 2 potential issues.
1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)
| return () => { | ||
| active = false; | ||
| subscription.remove(); | ||
| }; |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| native | ||
| .startDeviceStateUpdates() | ||
| .then((state: NativeDeviceState) => { | ||
| if (active) { | ||
| onChange(state); | ||
| } | ||
| }) |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
Client telemetry for React Native, on top of
livekit-client's TypeScript pipeline (livekit/client-sdk-js#2109; the contract islivekit-telemetry/SPEC.mdin 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, thelivekit-clientpeer 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 theirlivekit-uniffirelease. Until then an olderlivekit-clientloads without telemetry rather than failing to import.Public API
disableTelemetry()livekit-client's; a no-op on alivekit-clientwithout it, which the raised minimum rules out; TODO: final shape pending the token/consent discussionroom.emitTelemetryEvent(name, attributes = {})livekit-client'sRoom, used directly by React Native apps; string name + string attributes; limits enforced by the pipelineroom.setTelemetryAttribute(key, value)nullremovesNothing 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
src/telemetry.tsreact-native, package version, OS and version, Android model); the device observer —AppStateforeground/background plus the native thermal, low power and memory state, snapshot first, then deltas; wired once fromregisterGlobals(), logged once on failuresrc/telemetryStorage.tsnullbecomes a thrown error so the pipeline counts the batch asdropped.cache_errorinstead of believing it storedios/LKBatchStore.swift.tmpfiles dropped at start; a failed write or an id with a path separator throwsios/LKDeviceState.swiftProcessInfothermal state and Low Power Mode notifications, aDispatchSourcememory-pressure source (reports the return to normal too), mapped to SPEC's names; idempotentstop()android/…/BatchStore.ktcacheDir, with the same checks; a failed write or rename throwsandroid/…/DeviceStateMonitor.ktPowerManagerthermal 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; idempotentstart()/stop()src/version.ts,scripts/write-version.jspackage.jsonimport does not survivebob build), rewritten byci:versionmainChanges 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
src/index.tsxregisterGlobals()also callsregisterTelemetry();disableTelemetryexported, forwarding tolivekit-client'ssrc/logger.tslk-react-nativelogger comes fromlivekit-client'sgetLoggerinstead of a separate loglevel instance, so its warnings and errors reach telemetry likelivekit-client'ssetLogLevelbehaviourLiveKitReactNativeModule.swift,LivekitReactNativeModule.mstartDeviceStateUpdates()(promise with the snapshot, thenLK_DEVICE_STATEevents) and five blocking synchronousbatchStore*methods;invalidate()stops the observers;putanswersnilon a failed writeLivekitReactNativeModule.ktinvalidate()stops the monitor;putanswersnullon a failed write; emits are skipped once the React instance is gonepackage.jsonjest-environment-nodepinned to 30 underresolutions: react-native 0.83 asks for 29, which jest 30's runtime cannot drive, and no test had run here before;ci:versionalso rewritessrc/version.ts.livekit-clientpeer and dev minimum: raised to the first release with the host seam before merge (today^2.20.0, which the tolerant import survives)tsconfig.build.json**/__tests__excluded from the build.changeset/Events
12 of 19 SPEC signals fully covered, 2 partially, 5 skipped. The spans, stats, logs and app calls come from
livekit-clientunchanged; 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
lk.connectspan (+ checkpoints)livekit-client, unchangedlk.reconnectspanlivekit-client, unchangedlk.publishspanlivekit-client, unchangedlk.subscribespanlivekit-client, unchangedlk.rtc.stats.samplelivekit-client, onegetStats()per peer connection through@livekit/react-native-webrtclk.room.disconnectedlivekit-client, unchangedlk.telemetry.reportlivekit-client; the cache this package supplies makes the session's last report replay at the next launchcustom.<name>room.emitTelemetryEventlivekit-client's loggers and this package'slk-react-nativelogger (registered throughlivekit-client'sgetLogger), whatever the console levellk.device.thermal.changedProcessInfo.thermalState(nominal / fair / serious / critical); AndroidPowerManagerthermal 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.changedisPowerSaveMode)lk.device.app_state.changedAppState—backgroundis the background (the pipeline flushes on it);activeand iOS's transientinactiveare the foregroundlk.device.memory.changedonTrimMemoryas the Android SDK maps it (COMPLETEorRUNNING_CRITICAL→ critical; every other trim → warning;UI_HIDDENignored, it is not a memory signal) andonLowMemory→ 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 levelslk.device.network.changednavigator.connectiondoes not exist on Hermes); leftundefined, so the pipeline raises no network holdlk.device.battery.changedUIDevicebattery monitoring,BatteryManager) this PR does not addlk.device.audio_route.changedlk.device.audio.interruptionlk.device.capture.failedlivekit-client, from the error@livekit/react-native-webrtcraises at track creation (name → permission_denied / not_found / in_use / other)lk.pingLocal 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
Then open http://localhost:3000 → Explore:
service.name = livekit-client-react-native:lk.connect(with its checkpoints),lk.publish,lk.subscribe, andlk.reconnectafter toggling airplane mode.{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) andadb 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.