From 4fb4c992ee7a40f2ac1d4191706ae579b0f6ac4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:35:27 +0200 Subject: [PATCH 1/6] feat(telemetry): report device state from iOS and Android 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. --- .../livekit/reactnative/DeviceStateMonitor.kt | 115 ++++++++++++++++++ ios/LKDeviceState.swift | 94 ++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 android/src/main/java/com/livekit/reactnative/DeviceStateMonitor.kt create mode 100644 ios/LKDeviceState.swift diff --git a/android/src/main/java/com/livekit/reactnative/DeviceStateMonitor.kt b/android/src/main/java/com/livekit/reactnative/DeviceStateMonitor.kt new file mode 100644 index 0000000..dcb6639 --- /dev/null +++ b/android/src/main/java/com/livekit/reactnative/DeviceStateMonitor.kt @@ -0,0 +1,115 @@ +package com.livekit.reactnative + +import android.content.BroadcastReceiver +import android.content.ComponentCallbacks2 +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Configuration +import android.os.Build +import android.os.PowerManager +import androidx.annotation.RequiresApi + +/** + * Thermal state, power save mode and memory pressure: the three signals SPEC's cadence policy needs + * and no browser can answer. The pipeline never measures any of them itself — measuring CPU costs + * CPU — so each value here is the OS's own judgement, reported when it changes. + * + * Values are SPEC's names, not Android's, so that a record from Android and a record from iOS say + * the same thing. + */ +class DeviceStateMonitor( + private val context: Context, + private val onChange: (Map) -> Unit, +) { + companion object { + const val EVENT_NAME = "LK_DEVICE_STATE" + + /** `PowerManager.THERMAL_STATUS_*` collapsed onto SPEC's four levels. */ + fun thermalName(status: Int): String = when (status) { + PowerManager.THERMAL_STATUS_NONE -> "nominal" + PowerManager.THERMAL_STATUS_LIGHT -> "fair" + PowerManager.THERMAL_STATUS_MODERATE, PowerManager.THERMAL_STATUS_SEVERE -> "serious" + else -> "critical" + } + + /** + * `onTrimMemory` levels as the Android SDK maps them: COMPLETE and RUNNING_CRITICAL are + * critical, every other trim is a warning. UI_HIDDEN is not a memory signal (it fires on + * every backgrounding) and maps to null. The OS never says pressure is over; JavaScript + * counts it as normal again when the app returns to the foreground. + */ + fun memoryName(level: Int): String? = when (level) { + ComponentCallbacks2.TRIM_MEMORY_COMPLETE, + ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, + -> "critical" + ComponentCallbacks2.TRIM_MEMORY_MODERATE, + ComponentCallbacks2.TRIM_MEMORY_BACKGROUND, + ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW, + ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE, + -> "warning" + else -> null + } + } + + private val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + + // Lazy, and touched only behind the SDK_INT >= Q checks below: an eager initializer would + // load a class implementing an API 29 interface in the constructor, which fails to link on + // API 24-28 and takes the whole native module down with it. + @get:RequiresApi(Build.VERSION_CODES.Q) + private val thermalListener by lazy { + PowerManager.OnThermalStatusChangedListener { status -> + onChange(mapOf("thermal" to thermalName(status))) + } + } + + private val powerSaveReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + onChange(mapOf("lowPower" to powerManager.isPowerSaveMode)) + } + } + + private val memoryCallbacks = object : ComponentCallbacks2 { + override fun onTrimMemory(level: Int) { + memoryName(level)?.let { onChange(mapOf("memory" to it)) } + } + override fun onConfigurationChanged(newConfig: Configuration) = Unit + + @Deprecated("Required by ComponentCallbacks2 below API 34") + override fun onLowMemory() = onChange(mapOf("memory" to "critical")) + } + + private var started = false + + fun start() { + if (started) return + started = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + powerManager.addThermalStatusListener(thermalListener) + } + context.registerReceiver( + powerSaveReceiver, + IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED), + ) + context.applicationContext.registerComponentCallbacks(memoryCallbacks) + } + + fun stop() { + if (!started) return + started = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + powerManager.removeThermalStatusListener(thermalListener) + } + runCatching { context.unregisterReceiver(powerSaveReceiver) } + context.applicationContext.unregisterComponentCallbacks(memoryCallbacks) + } + + /** Everything that is a state rather than an edge, for the first report. */ + fun snapshot(): Map = buildMap { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put("thermal", thermalName(powerManager.currentThermalStatus)) + } + put("lowPower", powerManager.isPowerSaveMode) + } +} diff --git a/ios/LKDeviceState.swift b/ios/LKDeviceState.swift new file mode 100644 index 0000000..f2cfae3 --- /dev/null +++ b/ios/LKDeviceState.swift @@ -0,0 +1,94 @@ +import Foundation + +/** + * Thermal state, low power mode and memory pressure: the three signals SPEC's cadence policy needs + * and no browser can answer. The pipeline never measures any of them itself — measuring CPU costs + * CPU — so each value here is the OS's own judgement, reported when it changes. + * + * Values are SPEC's names, not Apple's, so that a record from iOS and a record from Android say the + * same thing. + */ +@objc(LKDeviceState) +public class LKDeviceState: NSObject { + /// One event carrying whatever changed; JS merges it into the state it already has. + @objc public static let eventName = "LK_DEVICE_STATE" + + private let onChange: ([String: Any]) -> Void + private var memorySource: DispatchSourceMemoryPressure? + + @objc public init(onChange: @escaping ([String: Any]) -> Void) { + self.onChange = onChange + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(thermalChanged), + name: ProcessInfo.thermalStateDidChangeNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(powerChanged), + name: Notification.Name.NSProcessInfoPowerStateDidChange, + object: nil + ) + + // DispatchSource reports all three levels, including the return to normal that + // `didReceiveMemoryWarning` never sends. + let source = DispatchSource.makeMemoryPressureSource( + eventMask: [.normal, .warning, .critical], + queue: .main + ) + source.setEventHandler { [weak self, weak source] in + guard let self, let event = source?.data else { return } + self.onChange(["memory": LKDeviceState.memoryName(event)]) + } + source.resume() + memorySource = source + } + + deinit { + stop() + } + + /// Stops observing. Idempotent: the module calls it when React Native invalidates the module, + /// and `deinit` repeats it as a backstop. + @objc public func stop() { + NotificationCenter.default.removeObserver(self) + memorySource?.cancel() + memorySource = nil + } + + /// Everything that is a state rather than an edge, for the first report. + @objc public func snapshot() -> [String: Any] { + let info = ProcessInfo.processInfo + return [ + "thermal": LKDeviceState.thermalName(info.thermalState), + "lowPower": info.isLowPowerModeEnabled, + ] + } + + @objc private func thermalChanged() { + onChange(["thermal": LKDeviceState.thermalName(ProcessInfo.processInfo.thermalState)]) + } + + @objc private func powerChanged() { + onChange(["lowPower": ProcessInfo.processInfo.isLowPowerModeEnabled]) + } + + private static func thermalName(_ state: ProcessInfo.ThermalState) -> String { + switch state { + case .nominal: return "nominal" + case .fair: return "fair" + case .serious: return "serious" + case .critical: return "critical" + @unknown default: return "nominal" + } + } + + private static func memoryName(_ event: DispatchSource.MemoryPressureEvent) -> String { + if event.contains(.critical) { return "critical" } + if event.contains(.warning) { return "warning" } + return "normal" + } +} From 280e387572df4b37343c0aa6bae8eac0eba0950e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:35:27 +0200 Subject: [PATCH 2/6] feat(telemetry): cache batches in a native file store 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. --- .../com/livekit/reactnative/BatchStore.kt | 97 ++++++++++++++++ ios/LKBatchStore.swift | 105 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 android/src/main/java/com/livekit/reactnative/BatchStore.kt create mode 100644 ios/LKBatchStore.swift diff --git a/android/src/main/java/com/livekit/reactnative/BatchStore.kt b/android/src/main/java/com/livekit/reactnative/BatchStore.kt new file mode 100644 index 0000000..a87491e --- /dev/null +++ b/android/src/main/java/com/livekit/reactnative/BatchStore.kt @@ -0,0 +1,97 @@ +package com.livekit.reactnative + +import android.content.Context +import java.io.File +import java.io.IOException + +/** + * A directory of batches, mirroring the Rust core's `FileCache` so that a React Native app keeps + * the caching semantics an iOS or Android app gets: a batch is written before the network is + * tried, survives the process, and is removed only once the collector has taken it. + * + * Batch ids sort oldest-first as plain strings, so pruning never has to stat or parse anything. + * Writes go to a temporary name and are renamed into place, so a crash never leaves half a batch + * readable. Eviction is oldest-first above the byte, count and age budgets — and one batch always + * survives, because a cache that prunes itself empty is worse than one that is slightly too big. + */ +class BatchStore( + context: Context, + private val maxBytes: Long, + private val maxBatches: Int, + private val maxAgeMillis: Long, +) { + private val directory = File(context.cacheDir, "livekit-telemetry").apply { mkdirs() } + + init { + directory.listFiles { file -> file.name.endsWith(".tmp") }?.forEach { it.delete() } + prune() + } + + /** + * Stores a batch and returns the ids evicted to stay inside the budgets. Throws when the batch + * could not be written, so a caller never believes a lost batch is stored. + */ + @Throws(IOException::class) + fun put(id: String, body: ByteArray): List { + val destination = file(id) ?: throw IOException("invalid batch id: $id") + // The system may clear cacheDir while the app is not running. + directory.mkdirs() + val temporary = File(directory, "$id.tmp") + try { + temporary.writeBytes(body) + if (!temporary.renameTo(destination)) { + throw IOException("could not move $temporary to $destination") + } + } catch (error: Exception) { + temporary.delete() + throw error + } + return prune() + } + + fun pending(): List = + directory.list()?.filterNot { it.endsWith(".tmp") }?.sorted() ?: emptyList() + + fun read(id: String): ByteArray? = file(id)?.takeIf { it.isFile }?.readBytes() + + fun remove(id: String) { + file(id)?.delete() + } + + /** The batch's file, or null for an id that would escape the directory. */ + private fun file(id: String): File? = + if (id.contains('/') || id.contains("..")) null else File(directory, id) + + fun clear() { + pending().forEach { remove(it) } + } + + /** Drops what is too old, then the oldest until the rest fits. Returns what it dropped. */ + private fun prune(): List { + val evicted = mutableListOf() + val cutoff = System.currentTimeMillis() - maxAgeMillis + val kept = mutableListOf>() + var total = 0L + + for (id in pending()) { + val file = File(directory, id) + if (file.lastModified() < cutoff) { + file.delete() + evicted.add(id) + continue + } + kept.add(id to file.length()) + total += file.length() + } + + var index = 0 + while ((total > maxBytes || kept.size - index > maxBatches) && kept.size - index > 1) { + val (id, bytes) = kept[index] + remove(id) + evicted.add(id) + total -= bytes + index += 1 + } + return evicted + } +} diff --git a/ios/LKBatchStore.swift b/ios/LKBatchStore.swift new file mode 100644 index 0000000..3de890e --- /dev/null +++ b/ios/LKBatchStore.swift @@ -0,0 +1,105 @@ +import Foundation + +/** + * A directory of batches, mirroring the Rust core's `FileCache` so that a React Native app keeps + * the caching semantics an iOS or Android app gets: a batch is written before the network is + * tried, survives the process, and is removed only once the collector has taken it. + * + * Batch ids sort oldest-first as plain strings, so pruning never has to stat or parse anything. + * Writes go to a temporary name and are renamed into place, so a crash never leaves half a batch + * readable. Eviction is oldest-first above the byte, count and age budgets — and one batch always + * survives, because a cache that prunes itself empty is worse than one that is slightly too big. + */ +@objc(LKBatchStore) +public class LKBatchStore: NSObject { + private let directory: URL + private let maxBytes: Int + private let maxBatches: Int + private let maxAge: TimeInterval + + @objc public init(maxBytes: Int, maxBatches: Int, maxAgeSeconds: Double) { + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + directory = caches.appendingPathComponent("livekit-telemetry", isDirectory: true) + self.maxBytes = maxBytes + self.maxBatches = maxBatches + maxAge = maxAgeSeconds + super.init() + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + for name in names() where name.hasSuffix(".tmp") { remove(id: name) } + _ = prune() + } + + /// Stores a batch and returns the ids evicted to stay inside the budgets. Throws when the + /// batch could not be written, so a caller never believes a lost batch is stored. + @objc public func put(id: String, body: Data) throws -> [String] { + guard let destination = url(id) else { throw CocoaError(.fileWriteInvalidFileName) } + // The system may purge Caches while the app is not running. + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let temporary = directory.appendingPathComponent("\(id).tmp") + do { + try body.write(to: temporary) + try FileManager.default.moveItem(at: temporary, to: destination) + } catch { + try? FileManager.default.removeItem(at: temporary) + throw error + } + return prune() + } + + @objc public func pending() -> [String] { + names().filter { !$0.hasSuffix(".tmp") }.sorted() + } + + private func names() -> [String] { + (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] + } + + @objc public func read(id: String) -> Data? { + url(id).flatMap { try? Data(contentsOf: $0) } + } + + @objc public func remove(id: String) { + if let url = url(id) { try? FileManager.default.removeItem(at: url) } + } + + /// The batch's file, or nil for an id that would escape the directory. + private func url(_ id: String) -> URL? { + id.contains("/") || id.contains("..") ? nil : directory.appendingPathComponent(id) + } + + @objc public func clear() { + for id in pending() { remove(id: id) } + } + + /// Drops what is too old, then the oldest until the rest fits. Returns what it dropped. + private func prune() -> [String] { + var evicted: [String] = [] + var sizes: [(id: String, bytes: Int)] = [] + var total = 0 + let cutoff = Date().addingTimeInterval(-maxAge) + + for id in pending() { + let path = directory.appendingPathComponent(id) + let attributes = try? FileManager.default.attributesOfItem(atPath: path.path) + let modified = attributes?[.modificationDate] as? Date ?? Date() + let bytes = (attributes?[.size] as? NSNumber)?.intValue ?? 0 + if modified < cutoff { + remove(id: id) + evicted.append(id) + continue + } + sizes.append((id, bytes)) + total += bytes + } + + var index = 0 + while (total > maxBytes || sizes.count - index > maxBatches) && sizes.count - index > 1 { + let oldest = sizes[index] + remove(id: oldest.id) + evicted.append(oldest.id) + total -= oldest.bytes + index += 1 + } + return evicted + } +} From 67a3e90d6c49cf794b036cae5c5d0dff8cc15d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:35:27 +0200 Subject: [PATCH 3/6] feat(telemetry): expose device state and the batch store to JavaScript 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. --- .../reactnative/LivekitReactNativeModule.kt | 89 +++++++++++++++++++ ios/LiveKitReactNativeModule.swift | 70 +++++++++++++++ ios/LivekitReactNativeModule.m | 10 +++ 3 files changed, 169 insertions(+) diff --git a/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt b/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt index ece4fcb..a60d2a7 100644 --- a/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt +++ b/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt @@ -1,6 +1,7 @@ package com.livekit.reactnative import android.media.AudioAttributes +import android.util.Base64 import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise @@ -8,6 +9,7 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableArray import com.facebook.react.modules.core.DeviceEventManagerModule import com.livekit.reactnative.audio.AudioDeviceKind import com.livekit.reactnative.audio.AudioManagerUtils @@ -31,6 +33,92 @@ class LivekitReactNativeModule(reactContext: ReactApplicationContext) : ReactCon val audioSinkManager = AudioSinkManager(reactContext) val audioManager = AudioSwitchManager(reactContext.applicationContext) + + /** + * Thermal state, power save mode and memory pressure in SPEC's names, for the cadence policy + * of `livekit-client`'s telemetry pipeline. Nothing is observed until `startDeviceStateUpdates` + * is called, and the observers stop when React Native invalidates the module. + */ + private val deviceStateMonitor = DeviceStateMonitor(reactContext.applicationContext) { change -> + // Delivered on the thermal or broadcast thread, so it can race invalidate(). + if (!reactContext.hasActiveReactInstance()) return@DeviceStateMonitor + val payload = Arguments.createMap() + change.forEach { (key, value) -> + when (value) { + is Boolean -> payload.putBoolean(key, value) + else -> payload.putString(key, value.toString()) + } + } + reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(DeviceStateMonitor.EVENT_NAME, payload) + } + + /** + * The write-ahead cache `livekit-client`'s pipeline stores batches in. Synchronous, because its + * queue path has no await in it; base64 because the bridge does not carry bytes. + * + * SPEC's budget: 4 MiB across at most 512 batches, nothing older than a day. + */ + private val batchStore by lazy { + BatchStore(reactContext.applicationContext, 4L * 1024 * 1024, 512, 24L * 60 * 60 * 1000) + } + + /** + * Answers null, never an empty list, when the batch could not be written; JavaScript turns that + * into a thrown error so the pipeline counts the loss. + */ + @ReactMethod(isBlockingSynchronousMethod = true) + fun batchStorePut(id: String, body: String): WritableArray? { + val evicted = try { + batchStore.put(id, Base64.decode(body, Base64.NO_WRAP)) + } catch (error: Exception) { + return null + } + val out = Arguments.createArray() + evicted.forEach { out.pushString(it) } + return out + } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun batchStorePending(): WritableArray { + val out = Arguments.createArray() + batchStore.pending().forEach { out.pushString(it) } + return out + } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun batchStoreRead(id: String): String? = + batchStore.read(id)?.let { Base64.encodeToString(it, Base64.NO_WRAP) } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun batchStoreRemove(id: String): Boolean { + batchStore.remove(id) + return true + } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun batchStoreClear(): Boolean { + batchStore.clear() + return true + } + + /** + * The first state comes back on the promise rather than as an event — an event sent from inside + * this call would race the listener JS is registering around it, and be dropped. + */ + @ReactMethod + fun startDeviceStateUpdates(promise: Promise) { + deviceStateMonitor.start() + val snapshot = Arguments.createMap() + deviceStateMonitor.snapshot().forEach { (key, value) -> + when (value) { + is Boolean -> snapshot.putBoolean(key, value) + else -> snapshot.putString(key, value.toString()) + } + } + promise.resolve(snapshot) + } + override fun getName(): String { return "LivekitReactNativeModule" } @@ -230,6 +318,7 @@ class LivekitReactNativeModule(reactContext: ReactApplicationContext) : ReactCon } override fun invalidate() { + deviceStateMonitor.stop() LiveKitReactNative.invalidate(reactApplicationContext) } } diff --git a/ios/LiveKitReactNativeModule.swift b/ios/LiveKitReactNativeModule.swift index 435b339..9715f72 100644 --- a/ios/LiveKitReactNativeModule.swift +++ b/ios/LiveKitReactNativeModule.swift @@ -7,11 +7,19 @@ struct LKEvents { static let kEventVolumeProcessed = "LK_VOLUME_PROCESSED"; static let kEventMultibandProcessed = "LK_MULTIBAND_PROCESSED"; static let kEventAudioData = "LK_AUDIO_DATA"; + static let kEventDeviceState = LKDeviceState.eventName; } @objc(LivekitReactNativeModule) public class LivekitReactNativeModule: RCTEventEmitter { + private var deviceState: LKDeviceState? = nil + + /// SPEC's cache budget: 4 MiB across at most 512 batches, nothing older than a day. + private lazy var batchStore = LKBatchStore(maxBytes: 4 * 1024 * 1024, + maxBatches: 512, + maxAgeSeconds: 24 * 60 * 60) + // This cannot be initialized in init as self.bridge is given afterwards. private var _audioRendererManager: AudioRendererManager? = nil public var audioRendererManager: AudioRendererManager { @@ -256,11 +264,73 @@ public class LivekitReactNativeModule: RCTEventEmitter { return nil } + /// Thermal state, low power mode and memory pressure in SPEC's names, for the cadence policy + /// of `livekit-client`'s telemetry pipeline. Nothing is observed until this is called, and the + /// observers stop when React Native invalidates the module. + /// + /// The first state comes back on the promise rather than as an event — an event sent from + /// inside this call would race the listener a caller registers around it, and be dropped. + @objc(startDeviceStateUpdates:withRejecter:) + public func startDeviceStateUpdates( + _ resolve: @escaping RCTPromiseResolveBlock, + withRejecter reject: @escaping RCTPromiseRejectBlock + ) { + if deviceState == nil { + deviceState = LKDeviceState { [weak self] change in + self?.sendEvent(withName: LKEvents.kEventDeviceState, body: change) + } + } + resolve(deviceState?.snapshot() ?? [:]) + } + + /// The write-ahead cache `livekit-client`'s pipeline stores batches in. Synchronous, because + /// its queue path has no await in it; base64 because the bridge does not carry bytes. + /// + /// `put` answers `nil`, never an empty list, when the batch could not be written; JavaScript + /// turns that into a thrown error so the pipeline counts the loss. A Swift `throws` cannot + /// cross the bridge. + @objc(batchStorePut:body:) + public func batchStorePut(_ id: String, body: String) -> [String]? { + guard let data = Data(base64Encoded: body) else { return nil } + return try? batchStore.put(id: id, body: data) + } + + @objc(batchStorePending) + public func batchStorePending() -> [String] { + batchStore.pending() + } + + @objc(batchStoreRead:) + public func batchStoreRead(_ id: String) -> String? { + batchStore.read(id: id)?.base64EncodedString() + } + + // A blocking synchronous method must return an *object*: the TurboModule interop retains + // whatever it gets back, so a `Bool` return is read as a pointer and segfaults the app. + @objc(batchStoreRemove:) + public func batchStoreRemove(_ id: String) -> Any? { + batchStore.remove(id: id) + return nil + } + + @objc(batchStoreClear) + public func batchStoreClear() -> Any? { + batchStore.clear() + return nil + } + + override public func invalidate() { + deviceState?.stop() + deviceState = nil + super.invalidate() + } + override public func supportedEvents() -> [String]! { return [ LKEvents.kEventVolumeProcessed, LKEvents.kEventMultibandProcessed, LKEvents.kEventAudioData, + LKEvents.kEventDeviceState, ] } } diff --git a/ios/LivekitReactNativeModule.m b/ios/LivekitReactNativeModule.m index dfe83d6..057e4f8 100644 --- a/ios/LivekitReactNativeModule.m +++ b/ios/LivekitReactNativeModule.m @@ -12,6 +12,16 @@ @interface RCT_EXTERN_MODULE(LivekitReactNativeModule, RCTEventEmitter) RCT_EXTERN_METHOD(setDefaultAudioTrackVolume:(nonnull NSNumber *) volume) +RCT_EXTERN_METHOD(startDeviceStateUpdates:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(batchStorePut:(nonnull NSString *)id + body:(nonnull NSString *)body) +RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(batchStorePending) +RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(batchStoreRead:(nonnull NSString *)id) +RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(batchStoreRemove:(nonnull NSString *)id) +RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(batchStoreClear) + RCT_EXTERN_METHOD(showAudioRoutePicker) RCT_EXTERN_METHOD(getAudioOutputsWithResolver:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) From e7de8f0ea3ef584eb5bba791b32101c2597f0bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:35:27 +0200 Subject: [PATCH 4/6] chore(telemetry): add a version constant and pin the jest runtime 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. --- package.json | 5 +- scripts/write-version.js | 8 +++ src/version.ts | 2 + tsconfig.build.json | 2 +- yarn.lock | 105 ++++++--------------------------------- 5 files changed, 28 insertions(+), 94 deletions(-) create mode 100644 scripts/write-version.js create mode 100644 src/version.ts diff --git a/package.json b/package.json index 33904f6..f6ef00f 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "prepare": "bob build", "example": "yarn --cwd example", "pods": "cd example && pod-install --verbose", - "ci:version": "changeset version && yarn build-docs", + "ci:version": "changeset version && node scripts/write-version.js && yarn build-docs", "ci:publish": "changeset publish", "bootstrap": "yarn example && yarn && yarn pods" }, @@ -148,6 +148,7 @@ ] }, "resolutions": { - "react-native-monorepo-config@^0.3.1": "patch:react-native-monorepo-config@npm%3A0.3.1#./.yarn/patches/react-native-monorepo-config-npm-0.3.1-2343b725c3.patch" + "react-native-monorepo-config@^0.3.1": "patch:react-native-monorepo-config@npm%3A0.3.1#./.yarn/patches/react-native-monorepo-config-npm-0.3.1-2343b725c3.patch", + "jest-environment-node": "30.4.1" } } diff --git a/scripts/write-version.js b/scripts/write-version.js new file mode 100644 index 0000000..da3119e --- /dev/null +++ b/scripts/write-version.js @@ -0,0 +1,8 @@ +// Keeps src/version.ts in step with package.json after `changeset version`. +const fs = require('fs'); +const path = require('path'); +const { version } = require('../package.json'); +fs.writeFileSync( + path.join(__dirname, '..', 'src', 'version.ts'), + `// Written by scripts/write-version.js after \`changeset version\`; keep in step with package.json.\nexport const version = '${version}';\n` +); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..8dc6a88 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,2 @@ +// Written by scripts/write-version.js after `changeset version`; keep in step with package.json. +export const version = '3.0.0'; diff --git a/tsconfig.build.json b/tsconfig.build.json index 9adfc50..7a1a017 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig", - "exclude": ["example", "ci"] + "exclude": ["example", "ci", "**/__tests__"] } diff --git a/yarn.lock b/yarn.lock index c91af92..7c0ca96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -41,17 +41,6 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/code-frame@npm:7.29.7" - dependencies: - "@babel/helper-validator-identifier": "npm:^7.29.7" - js-tokens: "npm:^4.0.0" - picocolors: "npm:^1.1.1" - checksum: 10/84da552e51a55795a50b3589116edb2f9e368a647d266380683775f18effd9acd4521b0246bebd0b049a7f32af1f87b1e8475d3bcb665f876bd04ade8da99697 - languageName: node - linkType: hard - "@babel/code-frame@npm:^7.27.1": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" @@ -74,6 +63,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/code-frame@npm:7.29.7" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.29.7" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10/84da552e51a55795a50b3589116edb2f9e368a647d266380683775f18effd9acd4521b0246bebd0b049a7f32af1f87b1e8475d3bcb665f876bd04ade8da99697 + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.24.7": version: 7.24.7 resolution: "@babel/compat-data@npm:7.24.7" @@ -3197,18 +3197,6 @@ __metadata: languageName: node linkType: hard -"@jest/environment@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/environment@npm:29.7.0" - dependencies: - "@jest/fake-timers": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - jest-mock: "npm:^29.7.0" - checksum: 10/90b5844a9a9d8097f2cf107b1b5e57007c552f64315da8c1f51217eeb0a9664889d3f145cdf8acf23a84f4d8309a6675e27d5b059659a004db0ea9546d1c81a8 - languageName: node - linkType: hard - "@jest/expect-utils@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect-utils@npm:30.4.1" @@ -3242,20 +3230,6 @@ __metadata: languageName: node linkType: hard -"@jest/fake-timers@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/fake-timers@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - "@sinonjs/fake-timers": "npm:^10.0.2" - "@types/node": "npm:*" - jest-message-util: "npm:^29.7.0" - jest-mock: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - checksum: 10/9b394e04ffc46f91725ecfdff34c4e043eb7a16e1d78964094c9db3fde0b1c8803e45943a980e8c740d0a3d45661906de1416ca5891a538b0660481a3a828c27 - languageName: node - linkType: hard - "@jest/get-type@npm:30.1.0": version: 30.1.0 resolution: "@jest/get-type@npm:30.1.0" @@ -4485,7 +4459,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^3.0.0, @sinonjs/commons@npm:^3.0.1": +"@sinonjs/commons@npm:^3.0.1": version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" dependencies: @@ -4494,15 +4468,6 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^10.0.2": - version: 10.3.0 - resolution: "@sinonjs/fake-timers@npm:10.3.0" - dependencies: - "@sinonjs/commons": "npm:^3.0.0" - checksum: 10/78155c7bd866a85df85e22028e046b8d46cf3e840f72260954f5e3ed5bd97d66c595524305a6841ffb3f681a08f6e5cef572a2cce5442a8a232dc29fb409b83e - languageName: node - linkType: hard - "@sinonjs/fake-timers@npm:^15.4.0": version: 15.4.0 resolution: "@sinonjs/fake-timers@npm:15.4.0" @@ -4669,7 +4634,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": +"@types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10/72576cc1522090fe497337c2b99d9838e320659ac57fa5560fcbdcbafcf5d0216c6b3a0a8a4ee4fdb3b1f5e3420aa4f6223ab57b82fef3578bec3206425c6cf5 @@ -8840,20 +8805,6 @@ __metadata: languageName: node linkType: hard -"jest-environment-node@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-environment-node@npm:29.7.0" - dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/fake-timers": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - jest-mock: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - checksum: 10/9cf7045adf2307cc93aed2f8488942e39388bff47ec1df149a997c6f714bfc66b2056768973770d3f8b1bf47396c19aa564877eb10ec978b952c6018ed1bd637 - languageName: node - linkType: hard - "jest-get-type@npm:^29.6.3": version: 29.6.3 resolution: "jest-get-type@npm:29.6.3" @@ -8946,23 +8897,6 @@ __metadata: languageName: node linkType: hard -"jest-message-util@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-message-util@npm:29.7.0" - dependencies: - "@babel/code-frame": "npm:^7.12.13" - "@jest/types": "npm:^29.6.3" - "@types/stack-utils": "npm:^2.0.0" - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - micromatch: "npm:^4.0.4" - pretty-format: "npm:^29.7.0" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.3" - checksum: 10/31d53c6ed22095d86bab9d14c0fa70c4a92c749ea6ceece82cf30c22c9c0e26407acdfbdb0231435dc85a98d6d65ca0d9cbcd25cd1abb377fe945e843fb770b9 - languageName: node - linkType: hard - "jest-mock@npm:30.4.1": version: 30.4.1 resolution: "jest-mock@npm:30.4.1" @@ -8974,17 +8908,6 @@ __metadata: languageName: node linkType: hard -"jest-mock@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-mock@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - jest-util: "npm:^29.7.0" - checksum: 10/ae51d1b4f898724be5e0e52b2268a68fcd876d9b20633c864a6dd6b1994cbc48d62402b0f40f3a1b669b30ebd648821f086c26c08ffde192ced951ff4670d51c - languageName: node - linkType: hard - "jest-pnp-resolver@npm:^1.2.3": version: 1.2.3 resolution: "jest-pnp-resolver@npm:1.2.3" @@ -12189,7 +12112,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": +"stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: From c2f82b7dad083d5fa4b43b12abe39603bb9b4c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:35:27 +0200 Subject: [PATCH 5/6] feat(telemetry): wire livekit-client's telemetry host from registerGlobals 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. --- .changeset/client-telemetry-native-store.md | 5 + src/index.tsx | 3 + src/logger.ts | 7 +- src/telemetry.ts | 149 ++++++++++++++++++++ src/telemetryStorage.ts | 61 ++++++++ 5 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 .changeset/client-telemetry-native-store.md create mode 100644 src/telemetry.ts create mode 100644 src/telemetryStorage.ts diff --git a/.changeset/client-telemetry-native-store.md b/.changeset/client-telemetry-native-store.md new file mode 100644 index 0000000..ed86085 --- /dev/null +++ b/.changeset/client-telemetry-native-store.md @@ -0,0 +1,5 @@ +--- +'@livekit/react-native': minor +--- + +Client telemetry: `registerGlobals` hands `livekit-client`'s telemetry pipeline this platform's resource names, a write-ahead cache backed by a directory of files (`LKBatchStore.swift`, `BatchStore.kt`) so a session that ends with the app being killed replays at the next launch, and the device state a phone can see — app foreground/background, thermal state, low power mode and memory pressure — in the names SPEC uses. `disableTelemetry` is re-exported from `livekit-client`. Requires the first `livekit-client` release that ships the telemetry host seam (`configureTelemetryHost`); the `livekit-client` peer and dev minimum is raised to that release before this ships, and an older `livekit-client` loads without telemetry. diff --git a/src/index.tsx b/src/index.tsx index 3d5ed53..2d9bc48 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -31,6 +31,7 @@ import type { LogLevel, SetLogLevelOptions } from './logger'; import RNE2EEManager from './e2ee/RNE2EEManager'; import RNKeyProvider, { type RNKeyProviderOptions } from './e2ee/RNKeyProvider'; import { setupNativeEvents } from './events/EventEmitter'; +import { registerTelemetry } from './telemetry'; import { ReadableStream, WritableStream, @@ -74,6 +75,7 @@ export function registerGlobals(options?: RegisterGlobalsOptions) { shimCryptoUuid(); shimWebstreams(); setupNativeEvents(); + registerTelemetry(); } function livekitRegisterGlobals() { @@ -168,6 +170,7 @@ export * from './logger'; export * from './audio/AudioManager'; export * from './audio/AudioManagerLegacy'; export * from './audio/MediaRecorder'; +export { disableTelemetry } from './telemetry'; export { AudioSession, diff --git a/src/logger.ts b/src/logger.ts index 9f6fa62..8dfb2c5 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,7 +1,8 @@ -import { setLogLevel as setClientSdkLogLevel } from 'livekit-client'; -import loglevel from 'loglevel'; +import { getLogger, setLogLevel as setClientSdkLogLevel } from 'livekit-client'; -export const log = loglevel.getLogger('lk-react-native'); +// Registered through `livekit-client`, so this package's warnings and errors reach client +// telemetry the way `livekit-client`'s own do, whatever the console level. +export const log = getLogger('lk-react-native'); log.setDefaultLevel('WARN'); export type LogLevel = Parameters[0]; diff --git a/src/telemetry.ts b/src/telemetry.ts new file mode 100644 index 0000000..11da29c --- /dev/null +++ b/src/telemetry.ts @@ -0,0 +1,149 @@ +// A namespace import read at call time: a `livekit-client` older than the host seam then loads +// without a missing-export error and simply has no telemetry. +import * as client from 'livekit-client'; +import type { TelemetryDeviceState, TelemetryHost } from 'livekit-client'; +import { + AppState, + type AppStateStatus, + NativeEventEmitter, + NativeModules, + Platform, +} from 'react-native'; +import { log } from './logger'; +import { version } from './version'; +import { nativeBatchStore } from './telemetryStorage'; + +/** + * The React Native half of client telemetry. + * + * `livekit-client` owns the pipeline and the instrumentation — when a connect span starts, what a + * stats window holds, how a subscribe ends at first media — and this package reuses it unchanged. + * What it adds is what a phone knows and a browser does not: a filesystem to cache batches in, and + * a device that goes to the background, gets hot, runs low on memory and asks for less work. + */ + +/** What the native side reports, already in SPEC's names; each event carries only what changed. */ +export interface NativeDeviceState { + thermal?: 'nominal' | 'fair' | 'serious' | 'critical'; + lowPower?: boolean; + memory?: 'normal' | 'warning' | 'critical'; +} + +const DEVICE_STATE_EVENT = 'LK_DEVICE_STATE'; + +/** + * Starts the native thermal, low power and memory pressure observers and forwards every change. + * The first report is the current state; the rest are deltas. Returns a function that stops the + * forwarding. An app on an older native build gets no reports, and must not crash. + */ +export function startDeviceState( + onChange: (change: NativeDeviceState) => void +): () => void { + const native = NativeModules.LivekitReactNativeModule; + if (!native?.startDeviceStateUpdates) { + return () => {}; + } + let active = true; + const subscription = new NativeEventEmitter(native).addListener( + DEVICE_STATE_EVENT, + onChange + ); + // The first state is the promise's answer, not an event: an event sent while this call is still + // in flight would arrive before the listener above is registered natively, and be dropped. + native + .startDeviceStateUpdates() + .then((state: NativeDeviceState) => { + if (active) { + onChange(state); + } + }) + .catch(() => {}); + return () => { + active = false; + subscription.remove(); + }; +} + +/** + * Everything this platform can tell the pipeline about the device: the app leaving and entering the + * foreground (the pipeline flushes on background itself), plus the native thermal, low power and + * memory state. iOS reports `inactive` for a pulled-down notification shade, which is not the + * background. Network, battery and audio route are not observed — nothing in this package's + * dependencies exposes them. + */ +function observeDevice( + report: (state: TelemetryDeviceState) => void +): () => void { + const appState = (status: AppStateStatus): TelemetryDeviceState => ({ + appState: status === 'background' ? 'background' : 'foreground', + // Android never says memory pressure is over; as the Android SDK does, it counts as normal + // again when the app returns to the foreground. iOS reports the return itself. + ...(Platform.OS === 'android' && status === 'active' + ? { memory: 'normal' as const } + : {}), + }); + const app = AppState.addEventListener('change', (status) => + report(appState(status)) + ); + const stopNative = startDeviceState(report); + report(appState(AppState.currentState)); + return () => { + app.remove(); + stopNative(); + }; +} + +let configured = false; + +/** + * Hands `livekit-client`'s telemetry pipeline this platform's resource names, file cache and device + * state. Called by {@link registerGlobals}; wired once, and a failure is logged once and dropped so + * telemetry never stops an app from starting. `endpoint` is for local runs against a collector + * (see the PR's Local testing) and re-applies the host. + */ +export function registerTelemetry(options: { endpoint?: string } = {}) { + if (configured && options.endpoint === undefined) { + return; + } + configured = true; + if (!client.configureTelemetryHost) { + return; + } + try { + const host: TelemetryHost = { + sdk: { + name: 'react-native', + version, + os: Platform.OS, + osVersion: String(Platform.Version), + // iOS has no model identifier in `Platform.constants`; left to the resource's defaults. + deviceModel: + Platform.OS === 'android' ? Platform.constants.Model : undefined, + }, + storage: nativeBatchStore(), + observeDevice, + endpoint: options.endpoint, + }; + client.configureTelemetryHost(host); + } catch (error) { + // Debug, as livekit-client reports its own telemetry failures: warn and error are captured + // into telemetry, and a telemetry failure must not report itself. A throwing log extension + // is swallowed too. + try { + log.debug('client telemetry is not configured on this platform', error); + } catch { + // never into the app + } + } +} + +/** + * Opts this process out of client telemetry: `livekit-client`'s `disableTelemetry`, a no-op on a + * `livekit-client` without it. In effect when the call returns; call it on every start before + * the first Room. + * + * TODO: final shape pending the token/consent discussion. + */ +export function disableTelemetry(): void { + client.disableTelemetry?.(); +} diff --git a/src/telemetryStorage.ts b/src/telemetryStorage.ts new file mode 100644 index 0000000..b146a75 --- /dev/null +++ b/src/telemetryStorage.ts @@ -0,0 +1,61 @@ +import { fromByteArray, toByteArray } from 'base64-js'; +import { NativeModules } from 'react-native'; + +/** + * The write-ahead cache `livekit-client`'s pipeline stores batches in: the shape the Rust core's + * `FileCache` has, so a React Native app keeps the caching semantics an iOS or Android app gets. + * A batch survives the process and is removed only once the collector has taken it. + * + * TODO(phase 2): replace with the type `livekit-client` exports once its storage hook lands. + */ +export interface TelemetryStorage { + /** + * Stores a batch and returns the ids evicted to stay inside the budgets. Throws when the batch + * could not be written, so the pipeline counts the loss instead of believing it stored. + */ + put(id: string, body: Uint8Array): string[]; + /** Ids of the stored batches, oldest first. */ + pending(): string[]; + read(id: string): Uint8Array | undefined; + remove(id: string): void; + clear(): void; +} + +/** + * The native file cache, or `undefined` on a native build that predates it — such an app simply + * keeps its batches in memory. + * + * The calls are synchronous blocking bridge calls because the pipeline's queue path has no await + * in it, and base64 because the bridge does not carry bytes. That is also why this is native code + * in this package rather than one of the filesystem packages on npm — all of those are async. + */ +export function nativeBatchStore(): TelemetryStorage | undefined { + const native = NativeModules.LivekitReactNativeModule; + if (!native?.batchStorePut) { + return undefined; + } + return { + put: (id, body) => { + // The native store answers null, never an empty list, when it could not write the batch. + const evicted: string[] | null = native.batchStorePut( + id, + fromByteArray(body) + ); + if (!evicted) { + throw new Error(`telemetry cache could not write batch ${id}`); + } + return evicted; + }, + pending: () => native.batchStorePending() ?? [], + read: (id) => { + const body = native.batchStoreRead(id); + return body ? toByteArray(body) : undefined; + }, + remove: (id) => { + native.batchStoreRemove(id); + }, + clear: () => { + native.batchStoreClear(); + }, + }; +} From 1c7053bd48f7f533706e3354eb3f8793f2af01c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:35:27 +0200 Subject: [PATCH 6/6] test(telemetry): cover the native bridge in the jest suite 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. --- src/__tests__/telemetry.test.ts | 249 ++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 src/__tests__/telemetry.test.ts diff --git a/src/__tests__/telemetry.test.ts b/src/__tests__/telemetry.test.ts new file mode 100644 index 0000000..430a8f9 --- /dev/null +++ b/src/__tests__/telemetry.test.ts @@ -0,0 +1,249 @@ +import type { TelemetryHost } from 'livekit-client'; + +/** What a `livekit-client` with the host seam exports, as far as this package reads it. */ +const currentClient = () => ({ + configureTelemetryHost: jest.fn(), + disableTelemetry: jest.fn(), + getLogger: jest.fn((name: string) => require('loglevel').getLogger(name)), + setLogLevel: jest.fn(), +}); + +/** A fresh module registry per test: `registerTelemetry` wires once per process. */ +function load(client: object = currentClient()) { + jest.resetModules(); + jest.doMock('livekit-client', () => client); + const { + AppState, + DeviceEventEmitter, + NativeModules, + } = require('react-native'); + const { Platform } = require('react-native'); + const { configureTelemetryHost, getLogger } = require('livekit-client'); + const { registerTelemetry, disableTelemetry } = require('../telemetry'); + const { log } = require('../logger'); + AppState.currentState = 'active'; + jest.spyOn(log, 'warn').mockImplementation(() => {}); + jest.spyOn(log, 'debug').mockImplementation(() => {}); + jest.spyOn(log, 'error').mockImplementation(() => {}); + return { + AppState, + DeviceEventEmitter, + NativeModules, + Platform, + configureTelemetryHost, + getLogger, + registerTelemetry, + disableTelemetry, + log, + }; +} + +describe('the package logger', () => { + it('is the instance livekit-client registers, and still takes every argument', () => { + const { log, getLogger } = load(); + expect(getLogger).toHaveBeenCalledWith('lk-react-native'); + expect(log).toBe(getLogger.mock.results[0].value); + expect(log.getLevel()).toBe(log.levels.WARN); + const warn = jest.spyOn(log, 'warn').mockImplementation(() => {}); + log.warn('audio session', { reason: 'busy' }, 3); + expect(warn).toHaveBeenCalledWith('audio session', { reason: 'busy' }, 3); + }); +}); + +/** The native module as the bridge presents it, with every answer under the test's control. */ +function mockNative(NativeModules: any) { + const native = { + addListener: jest.fn(), + removeListeners: jest.fn(), + startDeviceStateUpdates: jest.fn().mockResolvedValue({ + thermal: 'nominal', + lowPower: false, + }), + batchStorePut: jest.fn().mockReturnValue([]), + batchStorePending: jest.fn().mockReturnValue([]), + batchStoreRead: jest.fn().mockReturnValue(null), + batchStoreRemove: jest.fn(), + batchStoreClear: jest.fn(), + }; + NativeModules.LivekitReactNativeModule = native; + return native; +} + +const hostOf = (configureTelemetryHost: jest.Mock): TelemetryHost => + configureTelemetryHost.mock.calls[0][0]; + +describe('registerTelemetry', () => { + it('wires the host once, naming this platform', () => { + const { registerTelemetry, configureTelemetryHost, NativeModules } = load(); + mockNative(NativeModules); + registerTelemetry(); + registerTelemetry(); + expect(configureTelemetryHost).toHaveBeenCalledTimes(1); + const host = hostOf(configureTelemetryHost); + expect(host.sdk).toMatchObject({ + name: 'react-native', + version: require('../../package.json').version, + os: 'ios', + }); + expect(host.storage).toBeDefined(); + expect(host.observeDevice).toBeDefined(); + expect(host.endpoint).toBeUndefined(); + }); + + it('still configures without native modules: no cache, app state only', () => { + const { registerTelemetry, configureTelemetryHost, NativeModules } = load(); + delete NativeModules.LivekitReactNativeModule; + registerTelemetry(); + const host = hostOf(configureTelemetryHost); + expect(host.storage).toBeUndefined(); + const report = jest.fn(); + const stop = host.observeDevice!(report, jest.fn()); + expect(report).toHaveBeenCalledWith({ appState: 'foreground' }); + expect(() => stop?.()).not.toThrow(); + }); + + it('degrades to no telemetry on a livekit-client without the host seam', () => { + const { registerTelemetry, disableTelemetry, log } = load({ + getLogger: currentClient().getLogger, + setLogLevel: jest.fn(), + }); + expect(() => registerTelemetry()).not.toThrow(); + expect(() => disableTelemetry()).not.toThrow(); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('forwards disableTelemetry to livekit-client', () => { + const client = currentClient(); + load(client).disableTelemetry(); + expect(client.disableTelemetry).toHaveBeenCalledTimes(1); + }); + + it('reports a failing hook once at debug, outside the captured warn path', () => { + const { registerTelemetry, configureTelemetryHost, log } = load(); + configureTelemetryHost.mockImplementation(() => { + throw new Error('boom'); + }); + expect(() => registerTelemetry()).not.toThrow(); + registerTelemetry(); + expect(configureTelemetryHost).toHaveBeenCalledTimes(1); + expect(log.debug).toHaveBeenCalledTimes(1); + expect(log.warn).not.toHaveBeenCalled(); + expect(log.error).not.toHaveBeenCalled(); + }); +}); + +describe('the host storage', () => { + it('carries bytes as base64 and evicted ids back', () => { + const { registerTelemetry, configureTelemetryHost, NativeModules } = load(); + const native = mockNative(NativeModules); + native.batchStorePut.mockReturnValue(['000-old']); + native.batchStorePending.mockReturnValue(['000-old', '001-new']); + native.batchStoreRead.mockImplementation((id: string) => + id === '001-new' ? 'AQID' : null + ); + registerTelemetry(); + const store = hostOf(configureTelemetryHost).storage!; + + expect(store.put('001-new', new Uint8Array([1, 2, 3]))).toEqual([ + '000-old', + ]); + expect(native.batchStorePut).toHaveBeenCalledWith('001-new', 'AQID'); + expect(store.pending()).toEqual(['000-old', '001-new']); + expect(store.read('001-new')).toEqual(new Uint8Array([1, 2, 3])); + expect(store.read('nope')).toBeUndefined(); + store.remove('000-old'); + store.clear(); + expect(native.batchStoreRemove).toHaveBeenCalledWith('000-old'); + expect(native.batchStoreClear).toHaveBeenCalled(); + }); + + it('turns a native write failure into a throw, so the pipeline counts the loss', () => { + const { registerTelemetry, configureTelemetryHost, NativeModules } = load(); + mockNative(NativeModules).batchStorePut.mockReturnValue(null); + registerTelemetry(); + const store = hostOf(configureTelemetryHost).storage!; + // What the pipeline does: a put that throws is a counted loss; one that returns is stored. + let lost = 0; + try { + store.put('001', new Uint8Array([1])); + } catch { + lost += 1; + } + expect(lost).toBe(1); + }); +}); + +describe('the host device observer', () => { + it('reports app state, the native snapshot, then each change, until stopped', async () => { + const { + registerTelemetry, + configureTelemetryHost, + NativeModules, + AppState, + DeviceEventEmitter, + } = load(); + mockNative(NativeModules); + registerTelemetry(); + const report = jest.fn(); + const stop = hostOf(configureTelemetryHost).observeDevice!( + report, + jest.fn() + ); + await Promise.resolve(); + expect(report.mock.calls.map((c) => c[0])).toEqual([ + { appState: 'foreground' }, + { thermal: 'nominal', lowPower: false }, + ]); + + DeviceEventEmitter.emit('LK_DEVICE_STATE', { memory: 'critical' }); + const onAppState = AppState.addEventListener.mock.calls[0][1]; + onAppState('background'); + onAppState('inactive'); + expect(report.mock.calls.slice(2).map((c) => c[0])).toEqual([ + { memory: 'critical' }, + { appState: 'background' }, + { appState: 'foreground' }, + ]); + + stop?.(); + DeviceEventEmitter.emit('LK_DEVICE_STATE', { thermal: 'serious' }); + expect(report).toHaveBeenCalledTimes(5); + expect( + AppState.addEventListener.mock.results[0].value.remove + ).toHaveBeenCalled(); + }); + + it('counts Android memory as normal again when the app returns to the foreground', () => { + const { + registerTelemetry, + configureTelemetryHost, + NativeModules, + AppState, + Platform, + } = load(); + mockNative(NativeModules); + registerTelemetry(); + jest.replaceProperty(Platform, 'OS', 'android'); + const report = jest.fn(); + hostOf(configureTelemetryHost).observeDevice!(report, jest.fn()); + const onAppState = AppState.addEventListener.mock.calls[0][1]; + onAppState('background'); + expect(report).toHaveBeenLastCalledWith({ appState: 'background' }); + onAppState('active'); + expect(report).toHaveBeenLastCalledWith({ + appState: 'foreground', + memory: 'normal', + }); + }); + + it('drops a native snapshot that lands after stop', async () => { + const { registerTelemetry, configureTelemetryHost, NativeModules } = load(); + mockNative(NativeModules); + registerTelemetry(); + const report = jest.fn(); + hostOf(configureTelemetryHost).observeDevice!(report, jest.fn())?.(); + await Promise.resolve(); + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith({ appState: 'foreground' }); + }); +});