diff --git a/.changeset/client-telemetry.md b/.changeset/client-telemetry.md
new file mode 100644
index 00000000..59f665ea
--- /dev/null
+++ b/.changeset/client-telemetry.md
@@ -0,0 +1,5 @@
+---
+"client-sdk-android": minor
+---
+
+Client telemetry through the shared Rust core: each Room reports its connect, reconnect, publish and subscribe spans, RTC statistics, SDK warnings and errors and device state to its LiveKit Cloud project when the token carries the observability grant; apps can add `Room.emitTelemetryEvent(name, attributes)` and `Room.setTelemetryAttribute(key, value)`, and opt out with `LiveKit.disableTelemetry()`.
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 39aa20eb..b601bda6 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -58,6 +58,28 @@ jobs:
- name: Build and test with Gradle
run: ./gradlew assembleRelease livekit-android-test:testRelease
+ # The telemetry end-to-end test runs the real Rust core under Robolectric: a host build of
+ # livekit-uniffi at the release gradle/libs.versions.toml pins, posting OTLP to a collector
+ # that writes what the test reads back (livekit-android-test/src/test/resources/telemetry/otelcol.yaml).
+ # Only this step sets LK_TELEMETRY_ENDPOINT, and a missing collector or library fails the job
+ # instead of skipping the test. The telemetry package's platform tests that need the core run here too.
+ - name: Telemetry E2E test
+ run: |
+ version="$(sed -n 's/^livekit-uniffi = "\(.*\)"/\1/p' gradle/libs.versions.toml)"
+ git clone --depth 1 --branch "livekit-uniffi/v$version" https://github.com/livekit/rust-sdks.git "$RUNNER_TEMP/rust-sdks"
+ (cd "$RUNNER_TEMP/rust-sdks" && cargo build -p livekit-uniffi)
+ curl -sSfL -o "$RUNNER_TEMP/otelcol.tar.gz" "https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v0.162.0/otelcol-contrib_0.162.0_linux_amd64.tar.gz"
+ echo "fcc063749f730f8c21fe29f2d340ff174f5f1c5885bd3156fb6c985a3036fcc3 $RUNNER_TEMP/otelcol.tar.gz" | sha256sum -c -
+ tar -xzf "$RUNNER_TEMP/otelcol.tar.gz" -C "$RUNNER_TEMP" otelcol-contrib
+ nohup "$RUNNER_TEMP/otelcol-contrib" --config livekit-android-test/src/test/resources/telemetry/otelcol.yaml > "$RUNNER_TEMP/otelcol.log" 2>&1 &
+ timeout 30 bash -c 'until curl -s -o /dev/null http://127.0.0.1:4319; do sleep 1; done' || { cat "$RUNNER_TEMP/otelcol.log"; exit 1; }
+ ./gradlew livekit-android-test:testReleaseUnitTest --tests 'io.livekit.android.telemetry.*' --rerun \
+ -PlivekitUniffiLibraryPath="$RUNNER_TEMP/rust-sdks/target/debug"
+ grep -q 'skipped="0"' livekit-android-test/build/test-results/testReleaseUnitTest/TEST-io.livekit.android.telemetry.TelemetryMockE2ETest.xml \
+ || { echo "TelemetryMockE2ETest was skipped"; exit 1; }
+ env:
+ LK_TELEMETRY_ENDPOINT: http://127.0.0.1:4319
+
- name: Run Detekt
run: ./gradlew livekit-android-sdk:detektRelease
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e0e6c0fd..50822f6f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -33,6 +33,7 @@ lifecycleProcess = "2.8.7"
agp = "8.7.2"
kotlin = "1.9.25"
livekit-uniffi = "0.1.12"
+jna = "5.16.0"
[libraries]
livekit-uniffi = { module = "io.livekit:livekit-uniffi-android", version.ref = "livekit-uniffi" }
@@ -103,6 +104,8 @@ mockito-inline = { module = "org.mockito:mockito-inline", version = "4.11.0" }
byte-buddy = { module = "net.bytebuddy:byte-buddy", version = "1.14.3" }
robolectric = { module = "org.robolectric:robolectric", version = "4.14.1" }
+# JVM natives (libjnidispatch) for the Rust core under Robolectric; the AAR variant only ships Android ABIs.
+jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
turbine = { module = "app.cash.turbine:turbine", version = "1.0.0" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
diff --git a/livekit-android-sdk/detekt-baseline-release.xml b/livekit-android-sdk/detekt-baseline-release.xml
index 6bd3cb94..868b843c 100644
--- a/livekit-android-sdk/detekt-baseline-release.xml
+++ b/livekit-android-sdk/detekt-baseline-release.xml
@@ -21,7 +21,7 @@
CyclomaticComplexMethod:PreconnectAudioBuffer.kt$@Deprecated("Set AudioTrackPublishDefaults.preconnect = true on the RoomOptions instead.") suspend fun <T> Room.withPreconnectAudio( timeout: Duration = TIMEOUT, topic: String = DEFAULT_TOPIC, onError: ((e: Exception) -> Unit)? = null, operation: suspend () -> T, )
CyclomaticComplexMethod:PreconnectAudioBuffer.kt$internal suspend fun Room.startPreconnectAudioJob( roomScope: CoroutineScope, timeout: Duration = TIMEOUT, topic: String = DEFAULT_TOPIC ): () -> Unit
CyclomaticComplexMethod:RTCEngine.kt$RTCEngine$@CheckResult internal suspend fun sendData(dataPacket: LivekitModels.DataPacket): Result<Unit>
- CyclomaticComplexMethod:RTCEngine.kt$RTCEngine$@Synchronized @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) fun reconnect()
+ CyclomaticComplexMethod:RTCEngine.kt$RTCEngine$@Synchronized internal fun reconnect(reason: ReconnectReason)
CyclomaticComplexMethod:RTCEngine.kt$RTCEngine$fun onMessage(dataChannel: DataChannel, buffer: DataChannel.Buffer?)
CyclomaticComplexMethod:RTCEngine.kt$RTCEngine$private fun makeRTCConfig( serverResponse: Either<JoinResponse, ReconnectResponse>, connectOptions: ConnectOptions, ): RTCConfiguration
CyclomaticComplexMethod:Room.kt$Room$@Throws(Exception::class) suspend fun connect(url: String, token: String, options: ConnectOptions = ConnectOptions())
@@ -36,7 +36,7 @@
LargeClass:RTCEngine.kt$RTCEngine : Listener
LargeClass:Room.kt$Room : ListenerParticipantListenerRpcManagerIncomingDataStreamManager
LargeClass:SignalClient.kt$SignalClient : WebSocketListener
- LongMethod:RTCEngine.kt$RTCEngine$@Synchronized @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) fun reconnect()
+ LongMethod:RTCEngine.kt$RTCEngine$@Synchronized internal fun reconnect(reason: ReconnectReason)
LongMethod:Room.kt$Room$@Throws(Exception::class) suspend fun connect(url: String, token: String, options: ConnectOptions = ConnectOptions())
LongMethod:SignalClient.kt$SignalClient$private fun handleSignalResponseImpl(ws: WebSocket, response: LivekitRtc.SignalResponse, encoded: ByteArray)
LongParameterList:AudioBufferCallbackDispatcher.kt$AudioBufferCallback$(buffer: ByteBuffer, audioFormat: Int, channelCount: Int, sampleRate: Int, bytesRead: Int, captureTimeNs: Long)
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/LiveKit.kt b/livekit-android-sdk/src/main/java/io/livekit/android/LiveKit.kt
index c281b5aa..2a9a9076 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/LiveKit.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/LiveKit.kt
@@ -23,6 +23,7 @@ import io.livekit.android.dagger.DaggerLiveKitComponent
import io.livekit.android.dagger.RTCModule
import io.livekit.android.dagger.create
import io.livekit.android.room.Room
+import io.livekit.android.telemetry.Telemetry
import io.livekit.android.util.LKLog
import io.livekit.android.util.LoggingLevel
@@ -64,6 +65,17 @@ object LiveKit {
@JvmStatic
var enableWebRTCLogging: Boolean = false
+ /**
+ * Opts this process out of client telemetry, in effect when this returns. Collection stops,
+ * and everything not yet sent — queued, open or cached on disk — is deleted; Rooms created
+ * afterwards collect nothing, and the first of them deletes what a previous launch left cached.
+ * Call it at every launch, before creating a Room, to collect nothing at all.
+ *
+ * TODO: final shape pending the token/consent discussion.
+ */
+ @JvmStatic
+ fun disableTelemetry() = Telemetry.disable()
+
/**
* Certain WebRTC classes need to be initialized prior to use.
*
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt
index bf4a692d..4288ea6a 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt
@@ -38,6 +38,8 @@ import io.livekit.android.e2ee.DataPacketCryptorManagerImpl
import io.livekit.android.memory.CloseableManager
import io.livekit.android.room.datatrack.LocalDataTrackManagerFactory
import io.livekit.android.room.datatrack.RemoteDataTrackManagerFactory
+import io.livekit.android.telemetry.Telemetry
+import io.livekit.android.telemetry.telemetryMicrophoneFailed
import io.livekit.android.util.LKLog
import io.livekit.android.util.LoggingLevel
import io.livekit.android.webrtc.CustomAudioProcessingFactory
@@ -113,6 +115,9 @@ internal object RTCModule {
.setNativeLibraryName("lkjingle_peerconnection_so")
.setInjectableLogger(
{ s, severity, s2 ->
+ if (severity == Logging.Severity.LS_ERROR) {
+ Telemetry.logWebRtc(s2, s)
+ }
if (!LiveKit.enableWebRTCLogging) {
return@setInjectableLogger
}
@@ -125,7 +130,8 @@ internal object RTCModule {
else -> LoggingLevel.OFF
}
- LKLog.log(loggingLevel, null) { "$s2: $s" }
+ // The console only: telemetry already has WebRTC's errors, above.
+ if (loggingLevel >= LKLog.loggingLevel) LKLog.logger?.log(loggingLevel, null, "$s2: $s")
},
Logging.Severity.LS_VERBOSE,
)
@@ -182,6 +188,7 @@ internal object RTCModule {
val audioRecordErrorCallback = object : JavaAudioDeviceModule.AudioRecordErrorCallback {
override fun onWebRtcAudioRecordInitError(errorMessage: String?) {
LKLog.e { "onWebRtcAudioRecordInitError: $errorMessage" }
+ telemetryMicrophoneFailed()
}
override fun onWebRtcAudioRecordStartError(
@@ -189,10 +196,12 @@ internal object RTCModule {
errorMessage: String?,
) {
LKLog.e { "onWebRtcAudioRecordStartError: $errorCode. $errorMessage" }
+ telemetryMicrophoneFailed()
}
override fun onWebRtcAudioRecordError(errorMessage: String?) {
LKLog.e { "onWebRtcAudioRecordError: $errorMessage" }
+ telemetryMicrophoneFailed()
}
}
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt
index c041ac66..5cf7ab4b 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt
@@ -43,6 +43,9 @@ import io.livekit.android.room.util.MediaConstraintKeys
import io.livekit.android.room.util.createAnswer
import io.livekit.android.room.util.setLocalDescription
import io.livekit.android.room.util.waitUntilConnected
+import io.livekit.android.telemetry.RTCTelemetry
+import io.livekit.android.telemetry.Telemetry
+import io.livekit.android.telemetry.guarded
import io.livekit.android.util.CloseableCoroutineScope
import io.livekit.android.util.Either
import io.livekit.android.util.FlowObservable
@@ -65,9 +68,14 @@ import io.livekit.android.webrtc.peerconnection.RTCThreadToken
import io.livekit.android.webrtc.peerconnection.executeBlockingOnRTCThread
import io.livekit.android.webrtc.peerconnection.launchBlockingOnRTCThread
import io.livekit.android.webrtc.toProtoSessionDescription
+import io.livekit.uniffi.TelemetryScope
+import io.livekit.uniffi.TelemetrySpan
+import io.livekit.uniffi.telemetryDisconnectReason
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.asContextElement
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
@@ -100,6 +108,10 @@ import livekit.org.webrtc.RtpSender
import livekit.org.webrtc.RtpTransceiver
import livekit.org.webrtc.RtpTransceiver.RtpTransceiverInit
import livekit.org.webrtc.SessionDescription
+import uniffi.livekit_telemetry.ReconnectReason
+import uniffi.livekit_telemetry.SpanName
+import uniffi.livekit_telemetry.SpanOutcome
+import uniffi.livekit_telemetry.SpanStep
import java.nio.ByteBuffer
import javax.inject.Inject
import javax.inject.Named
@@ -109,6 +121,7 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
+import uniffi.livekit_telemetry.DisconnectReason as FfiDisconnectReason
/**
* @suppress
@@ -128,6 +141,37 @@ internal constructor(
) : SignalClient.Listener {
internal var listener: Listener? = null
+ /**
+ * The Room's telemetry scope; null when telemetry is off. Bound on the engine's and the signal
+ * client's coroutines, so the Room handlers they drive log under the Room's session.
+ */
+ internal var telemetryScope: TelemetryScope? = null
+ set(value) {
+ field = value
+ client.telemetryScope = value
+ }
+
+ /**
+ * The Room's open `lk.connect` span while the user-initiated connect runs; the checkpoints
+ * are stamped here and in [SignalClient].
+ */
+ internal var connectSpan: TelemetrySpan? = null
+ set(value) {
+ field = value
+ client.connectSpan = value
+ }
+
+ /** The Room's RTC instrument; the signal client hands it a manual subscribe's intent. */
+ internal var rtcTelemetry: RTCTelemetry?
+ get() = client.rtcTelemetry
+ set(value) {
+ client.rtcTelemetry = value
+ }
+
+ /** Why this session ended, for telemetry, when the SDK's enum says less: the server's Leave reason, or a reconnect that gave up. */
+ @Volatile
+ internal var disconnectReasonForTelemetry: FfiDisconnectReason? = null
+
/**
* When the current connection attempt began, taken at the top of [joinImpl]. Cleared once the
* primary transport connects, so the attempt is timed exactly once.
@@ -163,7 +207,7 @@ internal constructor(
ConnectionState.DISCONNECTED -> {
LKLog.d { "primary ICE disconnected" }
if (oldVal == ConnectionState.CONNECTED) {
- reconnect()
+ reconnect(if (isSubscriberPrimary) ReconnectReason.SUBSCRIBER_FAILED else ReconnectReason.PUBLISHER_FAILED)
}
}
@@ -267,9 +311,11 @@ internal constructor(
roomOptions: RoomOptions,
): JoinResponse {
coroutineScope.close()
- coroutineScope = CloseableCoroutineScope(SupervisorJob() + ioDispatcher)
+ coroutineScope = CloseableCoroutineScope(SupervisorJob() + ioDispatcher + Telemetry.currentScope.asContextElement(telemetryScope))
sessionUrl = url
sessionToken = token
+ disconnectReasonForTelemetry = null
+ updateTelemetryServer()
connectOptions = options
lastRoomOptions = roomOptions
return joinImpl(url, token, options, roomOptions)
@@ -303,6 +349,10 @@ internal constructor(
connectionState = ConnectionState.CONNECTING
}
val joinResponse = client.join(url, token, options, roomOptions)
+ guarded {
+ connectSpan?.step(SpanStep.Signal)
+ connectSpan?.step(SpanStep.JoinRecv)
+ }
ensureActive()
if (joinResponse.hasParticipant()) {
@@ -325,6 +375,7 @@ internal constructor(
isSubscriberPrimary = joinResponse.subscriberPrimary
configure(joinResponse, options)
+ guarded { connectSpan?.step(SpanStep.PcCreated) }
// The publisher created above needs the attempt's start time before its first offer, in
// case video is published before the primary transport connects.
publisher?.setConnectStartedAt(startedAtMs)
@@ -398,7 +449,7 @@ internal constructor(
// Also reconnect on publisher disconnect
publisherObserver.connectionChangeListener = { newState ->
if (newState.isDisconnected()) {
- reconnect()
+ reconnect(ReconnectReason.PUBLISHER_FAILED)
}
}
} else {
@@ -605,9 +656,12 @@ internal constructor(
/**
* reconnect Signal and PeerConnections
*/
- @Synchronized
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
- fun reconnect() {
+ fun reconnect() = reconnect(ReconnectReason.UNKNOWN)
+
+ /** One reconnect cycle = one `lk.reconnect` span; attempts are its checkpoints. */
+ @Synchronized
+ internal fun reconnect(reason: ReconnectReason) {
if (reconnectingJob?.isActive == true) {
LKLog.d { "Reconnection is already in progress" }
return
@@ -625,7 +679,8 @@ internal constructor(
val forceFullReconnect = fullReconnectOnNext
fullReconnectOnNext = false
endSignalSession()
- val job = coroutineScope.launch {
+ val reconnectSpan = guarded { telemetryScope?.start(SpanName.Reconnect(reason), null) }
+ val job = coroutineScope.launch(Telemetry.currentSpan.asContextElement(reconnectSpan)) {
var hasResumedOnce = false
var hasReconnectedOnce = false
@@ -672,6 +727,7 @@ internal constructor(
ReconnectType.FORCE_SOFT_RECONNECT -> false
ReconnectType.FORCE_FULL_RECONNECT -> true
}
+ guarded { reconnectSpan?.step(SpanStep.Attempt((retries + 1).toUInt(), isFullReconnect)) }
var lastMessageSeq: Int? = null
val connectOptions = connectOptions ?: ConnectOptions()
@@ -784,6 +840,7 @@ internal constructor(
outgoingDataTrackManager.republishTracks()
}
incomingDataTrackManager.resendSubscriptionUpdates()
+ guarded { reconnectSpan?.end(SpanOutcome.OK, null) }
listener?.onPostReconnect(isFullReconnect)
return@launch
}
@@ -795,12 +852,16 @@ internal constructor(
}
}
+ val gaveUp = !isClosed // else disconnect() won
+ guarded { if (gaveUp) reconnectSpan?.fail("ReconnectFailed") else reconnectSpan?.cancel() }
+ if (gaveUp) disconnectReasonForTelemetry = FfiDisconnectReason.RECONNECT_FAILED
close("Failed reconnecting")
listener?.onEngineDisconnected(DisconnectReason.UNKNOWN_REASON)
}
reconnectingJob = job
job.invokeOnCompletion {
+ guarded { reconnectSpan?.takeIf { !it.isEnded() }?.cancel() }
if (reconnectingJob == job) {
reconnectingJob = null
}
@@ -1219,6 +1280,9 @@ internal constructor(
internal const val TARGET_DATA_PACKET_SIZE = 15 * 1024 // 15 KB
+ /** A report WebRTC never delivers (its connection closed meanwhile) is skipped after this long. */
+ private const val STATS_TIMEOUT_MS = 5_000L
+
/**
* Corresponds to the max-message-size in SDP. Attempting to send packets
* over this size will cause the data channel to close, so this must be enforced
@@ -1370,7 +1434,7 @@ internal constructor(
LKLog.i { "received close event: $reason, code: $code" }
endSignalSession()
abortPendingPublishTracks()
- reconnect()
+ reconnect(ReconnectReason.SIGNAL_DISCONNECTED)
}
override fun onRemoteMuteChanged(trackSid: String, muted: Boolean) {
@@ -1412,6 +1476,7 @@ internal constructor(
else -> {
close()
+ disconnectReasonForTelemetry = guarded { telemetryDisconnectReason(leave.reason.number) }
val disconnectReason = leave.reason.convert()
listener?.onEngineDisconnected(disconnectReason)
}
@@ -1444,6 +1509,14 @@ internal constructor(
override fun onRefreshToken(token: String) {
sessionToken = token
regionUrlProvider?.token = token
+ updateTelemetryServer()
+ }
+
+ /** Telemetry uploads with the Room's latest token, at join and on every refresh, to the URL the app gave. */
+ private fun updateTelemetryServer() {
+ val url = regionUrlProvider?.serverUrl?.toString() ?: sessionUrl ?: return
+ val token = sessionToken ?: return
+ guarded { telemetryScope?.setServer(url, token) }
}
override fun onLocalTrackUnpublished(trackUnpublished: LivekitRtc.TrackUnpublishedResponse) {
@@ -1663,6 +1736,24 @@ internal constructor(
client.sendSyncState(syncState)
}
+ /** Runs [action] on the RTC thread, suspending rather than blocking the caller. */
+ internal suspend fun onRTCThread(action: () -> T): T? = launchBlockingOnRTCThread(rtcThreadToken) { action() }
+
+ /**
+ * Each peer connection's whole report, publisher first, suspending rather than blocking.
+ * [request] makes each getStats() call, or refuses it (false): then no further one is made.
+ */
+ internal suspend fun peerStats(request: (() -> Unit) -> Boolean): List {
+ val reports = mutableListOf()
+ for (transport in listOfNotNull(publisher, subscriber)) {
+ val report = CompletableDeferred()
+ val requested = transport.withPeerConnection { request { getStats { report.complete(it) } } } ?: continue
+ if (!requested) break
+ withTimeoutOrNull(STATS_TIMEOUT_MS) { report.await() }?.let(reports::add)
+ }
+ return reports
+ }
+
fun getPublisherRTCStats(callback: RTCStatsCollectorCallback) {
runBlocking {
publisher?.withPeerConnection { getStats(callback) }
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt
index 12e0121e..3dffcfb5 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt
@@ -82,6 +82,12 @@ import io.livekit.android.room.track.Track
import io.livekit.android.room.track.TrackPublication
import io.livekit.android.room.types.toSDKType
import io.livekit.android.room.util.ConnectionWarmer
+import io.livekit.android.telemetry.RTCTelemetry
+import io.livekit.android.telemetry.Telemetry
+import io.livekit.android.telemetry.end
+import io.livekit.android.telemetry.guarded
+import io.livekit.android.telemetry.observeForTelemetry
+import io.livekit.android.telemetry.telemetry
import io.livekit.android.util.FlowObservable
import io.livekit.android.util.LKLog
import io.livekit.android.util.flow
@@ -89,11 +95,14 @@ import io.livekit.android.util.flowDelegate
import io.livekit.android.util.invoke
import io.livekit.android.util.rethrowIfCancellationSignal
import io.livekit.android.webrtc.getFilteredStats
+import io.livekit.uniffi.TelemetryScope
+import io.livekit.uniffi.TelemetrySpan
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.asContextElement
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
@@ -115,6 +124,11 @@ import livekit.org.webrtc.RendererCommon
import livekit.org.webrtc.RtpReceiver
import livekit.org.webrtc.SurfaceViewRenderer
import livekit.org.webrtc.audio.AudioDeviceModule
+import uniffi.livekit_telemetry.ReconnectReason
+import uniffi.livekit_telemetry.RoomIdentity
+import uniffi.livekit_telemetry.SpanName
+import uniffi.livekit_telemetry.SpanOutcome
+import uniffi.livekit_telemetry.SpanStep
import java.net.URI
import java.util.Date
import javax.inject.Named
@@ -168,9 +182,58 @@ constructor(
private val eventBus = BroadcastEventBus()
val events = eventBus.readOnly()
+ /**
+ * This Room's session in the telemetry pipeline, one trace for the Room's lifetime; null after
+ * [LiveKit.disableTelemetry][io.livekit.android.LiveKit.disableTelemetry].
+ */
+ @VisibleForTesting
+ internal var telemetryScope: TelemetryScope? = Telemetry.scope(context)
+ private val rtcTelemetry = telemetryScope?.let { RTCTelemetry(this, it) }
+
+ /** Removes this Room's audio route and focus listeners: an app-supplied handler can outlive it. */
+ private val stopAudioTelemetry = telemetryScope?.let { audioSwitchHandler?.observeForTelemetry() }
+
+ /** The user-initiated connect, open from [connect] to [onEngineConnected]; the engine stamps its checkpoints. */
+ private var connectSpan: TelemetrySpan? = null
+ set(value) {
+ field = value
+ engine.connectSpan = value
+ }
+
+ /**
+ * Records an app event in this Room's telemetry, exported as `custom.` next to the SDK's
+ * own records, with this Room's correlation attributes.
+ *
+ * ```
+ * room.emitTelemetryEvent("checkout.started", mapOf("cart.items" to "3"))
+ * ```
+ *
+ * Names and keys up to 128 bytes, values up to 1024 bytes, at most 64 attributes and no `lk.`
+ * keys; anything else is dropped, never truncated.
+ */
+ @JvmOverloads
+ fun emitTelemetryEvent(name: String, attributes: Map = emptyMap()) {
+ guarded { telemetryScope?.emitCustom(name, attributes) }
+ }
+
+ /**
+ * Sets a correlation attribute on every telemetry record this Room captures from now on, to
+ * match them with your own data (an order id, a tenant); `null` removes it.
+ *
+ * ```
+ * room.setTelemetryAttribute("app.order_id", order.id)
+ * ```
+ *
+ * Same limits as [emitTelemetryEvent], at most 64 per Room.
+ */
+ fun setTelemetryAttribute(key: String, value: String?) {
+ guarded { telemetryScope?.setAttribute(key, value) }
+ }
+
init {
engine.listener = this
-
+ engine.telemetryScope = telemetryScope
+ engine.rtcTelemetry = rtcTelemetry
// Register SDK-internal text-stream handlers for the RPC v2 transport. These reserve
// the topics `lk.rpc_request` and `lk.rpc_response` from user-level handler registration.
incomingDataStreamManager.registerTextStreamHandler(RPC_REQUEST_DATA_STREAM_TOPIC) { receiver, fromIdentity ->
@@ -365,6 +428,7 @@ constructor(
*/
val localParticipant: LocalParticipant = localParticipantFactory.create(dynacast = false).apply {
internalListener = this@Room
+ telemetryScope = this@Room.telemetryScope
}
private var mutableRemoteParticipants by flowDelegate(emptyMap())
@@ -488,7 +552,14 @@ constructor(
state = State.CONNECTING
connectOptions = options
- coroutineScope = CoroutineScope(defaultDispatcher + SupervisorJob())
+ // The Room's destination and grant from the start, so an attempt failing before the join still uploads.
+ guarded { telemetryScope?.setServer(url, token) }
+ engine.disconnectReasonForTelemetry = null // this attempt's own, if it ends early
+ // One connect() = one attempt; reconnect cycles get their own spans.
+ connectSpan = guarded { telemetryScope?.start(SpanName.Connect, null) }
+
+ coroutineScope = CoroutineScope(defaultDispatcher + SupervisorJob() + Telemetry.currentScope.asContextElement(telemetryScope))
+ rtcTelemetry?.start(coroutineScope)
roomOptions = getCurrentRoomOptions()
@@ -513,7 +584,7 @@ constructor(
// rethrow all throwables from the connect job.
val emptyCoroutineExceptionHandler = CoroutineExceptionHandler { _, _ -> }
val connectJob = coroutineScope.launch(
- ioDispatcher + emptyCoroutineExceptionHandler,
+ ioDispatcher + emptyCoroutineExceptionHandler + Telemetry.currentSpan.asContextElement(connectSpan),
) {
if (audioProcessingController is AuthedAudioProcessingController) {
audioProcessingController.authenticate(url, token)
@@ -609,6 +680,7 @@ constructor(
connectJob.join()
error?.let {
+ guarded { connectSpan?.end(it) }
if (it !is CancellationException) {
handleDisconnect(DisconnectReason.JOIN_FAILURE)
}
@@ -670,6 +742,7 @@ constructor(
*/
fun release() {
disconnect()
+ stopAudioTelemetry?.invoke()
closeableManager.close()
}
@@ -702,6 +775,7 @@ constructor(
localParticipant.updateFromInfo(response.participant)
localParticipant.setEnabledPublishCodecs(response.enabledPublishCodecsList)
+ updateTelemetryRoom()
if (response.otherParticipantsList.isNotEmpty()) {
response.otherParticipantsList.forEach { info ->
@@ -710,6 +784,20 @@ constructor(
}
}
+ /** The room and local participant on every telemetry record of this session from now on. */
+ private fun updateTelemetryRoom() {
+ guarded {
+ telemetryScope?.setRoom(
+ RoomIdentity(
+ sid = sid?.sid?.takeIf { it.isNotEmpty() },
+ name = name,
+ participantSid = localParticipant.sid.value.takeIf { it.isNotEmpty() },
+ participantIdentity = localParticipant.identity?.value,
+ ),
+ )
+ }
+ }
+
private fun setupLocalParticipantEventHandling() {
coroutineScope.launch {
localParticipant.events.collect {
@@ -1053,7 +1141,7 @@ constructor(
if (state == State.RECONNECTING) {
return
}
- engine.reconnect()
+ engine.reconnect(ReconnectReason.NETWORK_CHANGED)
}
private fun handleDisconnect(reason: DisconnectReason) {
@@ -1069,6 +1157,12 @@ constructor(
hasLostConnectivity = false
state = State.DISCONNECTED
+ guarded {
+ connectSpan?.run { if (reason == DisconnectReason.CLIENT_INITIATED) cancel() else fail(reason.name) }
+ // Once per real session: never on a reconnect.
+ telemetryScope?.disconnected(engine.disconnectReasonForTelemetry ?: reason.telemetry)
+ }
+ connectSpan = null
cleanupRoom()
engine.close()
@@ -1234,6 +1328,15 @@ constructor(
*/
override fun onEngineConnected() {
state = State.CONNECTED
+ guarded {
+ connectSpan?.run {
+ step(SpanStep.Engine)
+ step(SpanStep.PcConnected)
+ step(SpanStep.RoomConnected)
+ end(SpanOutcome.OK, null)
+ }
+ }
+ connectSpan = null
eventBus.postEvent(RoomEvent.Connected(this), coroutineScope)
}
@@ -1345,6 +1448,7 @@ constructor(
override fun onRoomUpdate(update: LivekitModels.Room) {
if (update.sid != null) {
sid = Sid(update.sid)
+ updateTelemetryRoom()
}
val oldMetadata = metadata
metadata = update.metadata
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt
index 788ca4da..5d14d364 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt
@@ -27,6 +27,9 @@ import io.livekit.android.room.participant.ParticipantTrackPermission
import io.livekit.android.room.track.Track
import io.livekit.android.stats.NetworkInfo
import io.livekit.android.stats.getClientInfo
+import io.livekit.android.telemetry.RTCTelemetry
+import io.livekit.android.telemetry.Telemetry
+import io.livekit.android.telemetry.guarded
import io.livekit.android.util.CloseableCoroutineScope
import io.livekit.android.util.Either
import io.livekit.android.util.LKLog
@@ -36,12 +39,15 @@ import io.livekit.android.util.toHttpUrl
import io.livekit.android.util.toWebsocketUrl
import io.livekit.android.util.withDeadline
import io.livekit.android.webrtc.toProtoSessionDescription
+import io.livekit.uniffi.TelemetryScope
+import io.livekit.uniffi.TelemetrySpan
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.asContextElement
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@@ -64,6 +70,7 @@ import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import okio.ByteString.Companion.toByteString
+import uniffi.livekit_telemetry.SpanStep
import java.util.Date
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
@@ -108,6 +115,15 @@ constructor(
@Volatile
private var joinContinuation: CancellableContinuation? = null
+
+ /** The Room's open `lk.connect` span, for the signaling checkpoints; null outside the user-initiated connect. */
+ internal var connectSpan: TelemetrySpan? = null
+
+ /** The Room's telemetry scope: signal responses drive the Room's handlers, whose records are filed under it. */
+ internal var telemetryScope: TelemetryScope? = null
+
+ /** The Room's RTC instrument, for a manual subscribe's intent. */
+ internal var rtcTelemetry: RTCTelemetry? = null
private lateinit var coroutineScope: CloseableCoroutineScope
/**
@@ -197,7 +213,7 @@ constructor(
LKLog.i { "connecting to $wsUrlString" }
- coroutineScope = CloseableCoroutineScope(SupervisorJob() + ioDispatcher)
+ coroutineScope = CloseableCoroutineScope(SupervisorJob() + ioDispatcher + Telemetry.currentScope.asContextElement(telemetryScope))
lastUrl = wsUrlString
lastOptions = options
lastRoomOptions = roomOptions
@@ -312,6 +328,10 @@ constructor(
}
// --------------------------------- WebSocket Listener --------------------------------------//
+ override fun onOpen(webSocket: WebSocket, response: Response) {
+ guarded { connectSpan?.step(SpanStep.WsOpen) }
+ }
+
override fun onMessage(webSocket: WebSocket, text: String) {
if (webSocket != currentWs) {
// Possibly message from old websocket, discard.
@@ -439,6 +459,7 @@ constructor(
}
fun sendOffer(offer: SessionDescription, offerId: Int) {
+ guarded { connectSpan?.step(SpanStep.OfferSent) }
val sd = offer.toProtoSessionDescription(offerId)
val request = LivekitRtc.SignalRequest.newBuilder()
.setOffer(sd)
@@ -448,6 +469,7 @@ constructor(
}
fun sendAnswer(answer: SessionDescription, offerId: Int) {
+ guarded { connectSpan?.step(SpanStep.AnswerSent) }
val sd = answer.toProtoSessionDescription(offerId)
val request = LivekitRtc.SignalRequest.newBuilder()
.setAnswer(sd)
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt
index 9ec1d98f..ff5165d3 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt
@@ -65,16 +65,24 @@ import io.livekit.android.room.track.VideoPreset
import io.livekit.android.room.track.screencapture.ScreenCaptureParams
import io.livekit.android.room.util.EncodingUtils
import io.livekit.android.rpc.RpcError
+import io.livekit.android.telemetry.Telemetry
+import io.livekit.android.telemetry.end
+import io.livekit.android.telemetry.guarded
+import io.livekit.android.telemetry.spanTrack
import io.livekit.android.util.LKLog
import io.livekit.android.util.flow
import io.livekit.android.util.rethrowIfCancellationSignal
import io.livekit.android.webrtc.sortVideoCodecPreferences
+import io.livekit.uniffi.TelemetryScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.asContextElement
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -95,6 +103,8 @@ import livekit.org.webrtc.RtpTransceiver.RtpTransceiverInit
import livekit.org.webrtc.SurfaceTextureHelper
import livekit.org.webrtc.VideoCapturer
import livekit.org.webrtc.VideoProcessor
+import uniffi.livekit_telemetry.SpanName
+import uniffi.livekit_telemetry.SpanOutcome
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.util.Collections
@@ -152,6 +162,9 @@ internal constructor(
internal val enabledPublishVideoCodecs = Collections.synchronizedList(mutableListOf())
+ /** The Room's telemetry scope, for the `lk.publish` span; null when telemetry is off. */
+ internal var telemetryScope: TelemetryScope? = null
+
private var defaultAudioTrack: LocalAudioTrack? = null
private var defaultVideoTrack: LocalVideoTrack? = null
@@ -500,7 +513,7 @@ internal constructor(
)
var publication: LocalTrackPublication? = null
try {
- publication = publishTrackImpl(
+ publication = publishTrackSpanned(
track = track,
options = options,
requestConfig = {
@@ -595,7 +608,7 @@ internal constructor(
var publication: LocalTrackPublication? = null
try {
- publication = publishTrackImpl(
+ publication = publishTrackSpanned(
track = track,
options = options,
requestConfig = {
@@ -652,11 +665,15 @@ internal constructor(
}
/**
+ * One publish = one `lk.publish` span, under a still-running ambient span (the connect span
+ * for a pre-connect microphone), and ambient itself while [publishTrackImpl] runs, so the
+ * publish's own warnings point at it.
+ *
* @throws TrackException.PublishException thrown when the publish fails. see [TrackException.PublishException.message] for details.
* @return true if the track publish was successful.
*/
@Throws(TrackException.PublishException::class)
- private suspend fun publishTrackImpl(
+ private suspend fun publishTrackSpanned(
track: Track,
options: TrackPublishOptions,
requestConfig: AddTrackRequest.Builder.() -> Unit,
@@ -667,8 +684,30 @@ internal constructor(
LKLog.w { "Attempting to publish a disposed track, ignoring." }
return null
}
+ val parent = Telemetry.currentSpan.get()?.takeIf { guarded { !it.isEnded() } == true }
+ val span = guarded { telemetryScope?.start(SpanName.Publish, parent) }
+ try {
+ return withContext(Telemetry.currentSpan.asContextElement(span)) {
+ publishTrackImpl(track, options, requestConfig, encodings, publishListener)
+ }
+ } finally {
+ // A cancellation that wins before withContext runs the publish leaves the span to us.
+ val active = currentCoroutineContext().isActive
+ guarded { span?.takeIf { !it.isEnded() }?.run { if (active) fail("PublishException") else cancel() } }
+ }
+ }
+ @Throws(TrackException.PublishException::class)
+ private suspend fun publishTrackImpl(
+ track: Track,
+ options: TrackPublishOptions,
+ requestConfig: AddTrackRequest.Builder.() -> Unit,
+ encodings: List = emptyList(),
+ publishListener: PublishListener? = null,
+ ): LocalTrackPublication? {
+ val span = Telemetry.currentSpan.get() // this publish's own, from publishTrackSpanned
fun onPublishFailure(e: TrackException.PublishException, triggerEvent: Boolean = true) {
+ guarded { span?.end(e) }
publishListener?.onPublishFailure(e)
if (triggerEvent) {
eventBus.postEvent(ParticipantEvent.LocalTrackPublicationFailed(this, track, e), scope)
@@ -680,6 +719,7 @@ internal constructor(
}
val trackSource = Track.Source.fromProto(addTrackRequestBuilder.source ?: LivekitModels.TrackSource.UNRECOGNIZED)
+ guarded { spanTrack(track.kind, trackSource)?.let { span?.setTrack(it) } }
if (!hasPermissionsToPublish(trackSource)) {
val exception = TrackException.PublishException("Failed to publish track, insufficient permissions")
onPublishFailure(exception)
@@ -856,6 +896,10 @@ internal constructor(
participant = this,
options = options,
)
+ guarded {
+ spanTrack(track.kind, trackSource, publication.sid)?.let { span?.setTrack(it) }
+ span?.end(SpanOutcome.OK, null)
+ }
addTrackPublication(publication)
LKLog.v { "add track publication $publication" }
@@ -864,6 +908,8 @@ internal constructor(
eventBus.postEvent(ParticipantEvent.LocalTrackPublished(this, publication), scope)
}
} finally {
+ val active = currentCoroutineContext().isActive
+ guarded { span?.takeIf { !it.isEnded() }?.run { if (active) fail("PublishException") else cancel() } }
if (publication == null) {
// Negotiation can win the race against a failed or cancelled add track request.
// Without a publication there is no unpublish to stop the transceiver, so it
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/RemoteTrackPublication.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/RemoteTrackPublication.kt
index 0f04a588..0d572067 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/RemoteTrackPublication.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/RemoteTrackPublication.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2025 LiveKit, Inc.
+ * Copyright 2023-2026 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -20,6 +20,7 @@ import io.livekit.android.dagger.InjectionNames
import io.livekit.android.events.TrackEvent
import io.livekit.android.events.collect
import io.livekit.android.room.participant.RemoteParticipant
+import io.livekit.android.telemetry.guarded
import io.livekit.android.util.debounce
import io.livekit.android.util.invoke
import kotlinx.coroutines.CoroutineDispatcher
@@ -132,6 +133,7 @@ class RemoteTrackPublication(
build()
}
participant.signalClient.sendUpdateSubscription(isDesired, participantTracks)
+ if (subscribed) guarded { participant.signalClient.rtcTelemetry?.subscribeIntent(this, participant) }
}
/**
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/video/CameraCapturerUtils.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/video/CameraCapturerUtils.kt
index 2996e524..e88bdbb5 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/video/CameraCapturerUtils.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/video/CameraCapturerUtils.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2025 LiveKit, Inc.
+ * Copyright 2023-2026 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -22,6 +22,7 @@ import android.content.Context
import android.hardware.camera2.CameraManager
import io.livekit.android.room.track.CameraPosition
import io.livekit.android.room.track.LocalVideoTrackOptions
+import io.livekit.android.telemetry.TelemetryCameraEvents
import io.livekit.android.util.LKLog
import livekit.org.webrtc.Camera1Capturer
import livekit.org.webrtc.Camera1Enumerator
@@ -98,6 +99,7 @@ object CameraCapturerUtils {
): Pair? {
val cameraEnumerator = provider.provideEnumerator(context)
val cameraEventsDispatchHandler = CameraEventsDispatchHandler()
+ cameraEventsDispatchHandler.registerHandler(TelemetryCameraEvents)
val targetDevice = cameraEnumerator.findCamera(options.deviceId, options.position) ?: return null
val targetVideoCapturer = provider.provideCapturer(context, options, cameraEventsDispatchHandler)
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/telemetry/DeviceTelemetry.kt b/livekit-android-sdk/src/main/java/io/livekit/android/telemetry/DeviceTelemetry.kt
new file mode 100644
index 00000000..a3d4f72c
--- /dev/null
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/telemetry/DeviceTelemetry.kt
@@ -0,0 +1,337 @@
+/*
+ * Copyright 2026 LiveKit, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.livekit.android.telemetry
+
+import android.app.Activity
+import android.app.Application
+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.media.AudioManager
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.os.BatteryManager
+import android.os.Build
+import android.os.Bundle
+import android.os.PowerManager
+import androidx.core.content.ContextCompat
+import com.twilio.audioswitch.AudioDevice
+import com.twilio.audioswitch.AudioDeviceChangeListener
+import io.livekit.android.audio.AudioSwitchHandler
+import io.livekit.android.util.LKLog
+import io.livekit.uniffi.telemetrySetDeviceState
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.launch
+import livekit.org.webrtc.CameraVideoCapturer
+import uniffi.livekit_telemetry.AppState
+import uniffi.livekit_telemetry.AudioOutput
+import uniffi.livekit_telemetry.AudioRouteReason
+import uniffi.livekit_telemetry.CaptureDevice
+import uniffi.livekit_telemetry.CaptureFailure
+import uniffi.livekit_telemetry.DeviceEvent
+import uniffi.livekit_telemetry.DeviceState
+import uniffi.livekit_telemetry.MemoryPressure
+import uniffi.livekit_telemetry.NetworkType
+import uniffi.livekit_telemetry.TelemetryInstrument
+import uniffi.livekit_telemetry.ThermalState
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * The device instrument: thermal status, battery saver, memory pressure, network, battery and app
+ * lifecycle as [DeviceState]. Every OS callback sends its change into one channel, drained in
+ * order by one coroutine on this instrument's own serial dispatcher; the core turns the state into
+ * `lk.device.*` records and upload holds. Nothing polls.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+internal class DeviceTelemetry(context: Context) : TelemetryInstrument {
+ private val app = context.applicationContext
+ private val power = app.getSystemService(Context.POWER_SERVICE) as? PowerManager
+ private val connectivity = app.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
+
+ @Suppress("InjectDispatcher") // the instrument's own serial dispatcher, outside any Room
+ private val scope = CoroutineScope(
+ SupervisorJob() + Dispatchers.Default.limitedParallelism(1) + CoroutineExceptionHandler { _, e -> LKLog.w(e) { "Device telemetry stopped." } },
+ )
+
+ // ponytail: unbounded, OS callbacks are a handful per minute at most
+ private val changes = Channel Unit>(Channel.UNLIMITED)
+ private var thermalListener: PowerManager.OnThermalStatusChangedListener? = null
+
+ private fun post(change: DeviceState.() -> Unit = {}) {
+ changes.trySend(change)
+ }
+
+ // MARK: - Lifecycle
+
+ override fun start() {
+ scope.launch {
+ val state = DeviceState(
+ thermal = ThermalState.UNKNOWN,
+ lowPowerMode = null,
+ appState = AppState.FOREGROUND,
+ memory = MemoryPressure.NORMAL,
+ network = NetworkType.UNKNOWN,
+ )
+ for (change in changes) {
+ // One failing change must not end the stream.
+ runCatching {
+ state.change()
+ // Read fresh on every change: these have no payload of their own.
+ state.thermal = thermal()
+ state.lowPowerMode = power?.isPowerSaveMode // unknown without a power service
+ state.networkConstrained = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
+ connectivity?.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
+ telemetrySetDeviceState(state.copy())
+ }.onFailure { e -> LKLog.w(e) { "Device telemetry skipped a change." } }
+ }
+ }
+ val filter = IntentFilter().apply {
+ addAction(Intent.ACTION_BATTERY_CHANGED)
+ addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) addAction(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED)
+ }
+ // System broadcasts only; the return value is the sticky battery intent: the first push.
+ val sticky = ContextCompat.registerReceiver(app, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
+ post { sticky?.let { battery(it) } }
+ // ponytail: no default-network callback below API 24, so the network stays unknown there
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ connectivity?.registerDefaultNetworkCallback(networkCallback) // delivers the current network at once
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ thermalListener = PowerManager.OnThermalStatusChangedListener { post() }.also { power?.addThermalStatusListener(it) }
+ }
+ app.registerComponentCallbacks(memoryCallbacks)
+ (app as? Application)?.registerActivityLifecycleCallbacks(activityCallbacks)
+ }
+
+ /**
+ * Called by the core on the caller's thread (the opt-out's, often main) under its lifecycle
+ * lock: only unregisters and cancels, never waits, never calls back into telemetry, never throws.
+ */
+ override fun stop() {
+ runCatching { app.unregisterReceiver(receiver) }
+ runCatching { connectivity?.unregisterNetworkCallback(networkCallback) }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) runCatching { thermalListener?.let { power?.removeThermalStatusListener(it) } }
+ runCatching { app.unregisterComponentCallbacks(memoryCallbacks) }
+ runCatching { (app as? Application)?.unregisterActivityLifecycleCallbacks(activityCallbacks) }
+ scope.cancel()
+ }
+
+ // MARK: - Power, battery, Data Saver
+
+ private val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ post { if (intent.action == Intent.ACTION_BATTERY_CHANGED) battery(intent) }
+ }
+ }
+
+ private fun DeviceState.battery(intent: Intent) {
+ val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
+ val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
+ val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
+ batteryLevel = if (level >= 0 && scale > 0) (level * 100 / scale).toUInt() else null
+ batteryCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
+ }
+
+ private fun thermal(): ThermalState {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return ThermalState.UNKNOWN // no thermal API
+ return when (power?.currentThermalStatus ?: return ThermalState.UNKNOWN) {
+ PowerManager.THERMAL_STATUS_MODERATE -> ThermalState.FAIR
+ PowerManager.THERMAL_STATUS_SEVERE -> ThermalState.SERIOUS
+ PowerManager.THERMAL_STATUS_CRITICAL, PowerManager.THERMAL_STATUS_EMERGENCY, PowerManager.THERMAL_STATUS_SHUTDOWN -> ThermalState.CRITICAL
+ else -> ThermalState.NOMINAL // NONE, LIGHT
+ }
+ }
+
+ // MARK: - Network
+
+ private val networkCallback = object : ConnectivityManager.NetworkCallback() {
+ override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) = post {
+ this.network = capabilities.type
+ networkExpensive = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
+ }
+
+ override fun onLost(network: Network) = post {
+ this.network = NetworkType.UNAVAILABLE
+ networkExpensive = false
+ }
+ }
+
+ private val NetworkCapabilities.type: NetworkType
+ get() = when {
+ hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> NetworkType.VPN
+ hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> NetworkType.WIFI
+ hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> NetworkType.CELL
+ hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> NetworkType.WIRED
+ hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> NetworkType.BLUETOOTH
+ else -> NetworkType.OTHER
+ }
+
+ // MARK: - Memory and app state
+
+ /**
+ * The trim levels the OS sends; hiding every activity is the move to the background. The OS
+ * never says pressure is over: it counts as normal again when an activity starts.
+ */
+ private val memoryCallbacks = object : ComponentCallbacks2 {
+ override fun onTrimMemory(level: Int) = when (level) {
+ ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> post { appState = AppState.BACKGROUND }
+ ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> post { memory = MemoryPressure.CRITICAL }
+ ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE,
+ ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
+ ComponentCallbacks2.TRIM_MEMORY_BACKGROUND,
+ ComponentCallbacks2.TRIM_MEMORY_MODERATE,
+ -> post { memory = MemoryPressure.WARNING }
+
+ else -> {}
+ }
+
+ override fun onLowMemory() = post { memory = MemoryPressure.CRITICAL }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {}
+ }
+
+ private val activityCallbacks = object : Application.ActivityLifecycleCallbacks {
+ override fun onActivityStarted(activity: Activity) = post {
+ appState = AppState.FOREGROUND
+ memory = MemoryPressure.NORMAL
+ }
+
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
+
+ override fun onActivityResumed(activity: Activity) {}
+
+ override fun onActivityPaused(activity: Activity) {}
+
+ override fun onActivityStopped(activity: Activity) {}
+
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
+
+ override fun onActivityDestroyed(activity: Activity) {}
+ }
+}
+
+// MARK: - Device events
+
+/**
+ * Audio route changes and focus loss: events, not state, they explain audio glitches. Process
+ * events, so one listener pair per handler however many Rooms share it (an app-supplied handler
+ * can be); the last Room to release it removes them. The audio switch names no reason for a
+ * change; a focus loss is an interruption that ends on the matching gain. Returns this Room's
+ * release, which is idempotent.
+ */
+internal fun AudioSwitchHandler.observeForTelemetry(): () -> Unit {
+ val observers = synchronized(audioObservers) { audioObservers.getOrPut(this) { AudioObservers() }.also { it.rooms++ } }
+ reconcile(observers)
+ val released = AtomicBoolean(false)
+ return {
+ if (released.compareAndSet(false, true)) {
+ synchronized(audioObservers) { observers.rooms-- }
+ reconcile(observers)
+ }
+ }
+}
+
+/**
+ * Registers or unregisters [observers] until that matches whether any Room uses them. The
+ * handler is only called outside the registry lock: it dispatches under its own listener locks,
+ * and a callback may create or release a Room. One thread acts at a time; a thread that finds
+ * another acting leaves it to re-check when done.
+ */
+private fun AudioSwitchHandler.reconcile(observers: AudioObservers) {
+ while (true) {
+ val register = synchronized(audioObservers) {
+ val wanted = observers.rooms > 0
+ if (observers.busy) return
+ if (observers.registered == wanted) {
+ if (!wanted && audioObservers[this] === observers) audioObservers.remove(this)
+ return
+ }
+ observers.busy = true
+ wanted
+ }
+ try {
+ if (register) {
+ registerAudioDeviceChangeListener(observers.route)
+ registerOnAudioFocusChangeListener(observers.focus)
+ } else {
+ unregisterAudioDeviceChangeListener(observers.route)
+ unregisterOnAudioFocusChangeListener(observers.focus)
+ }
+ } finally {
+ synchronized(audioObservers) {
+ observers.registered = register
+ observers.busy = false
+ }
+ }
+ }
+}
+
+/** The listener pair of one handler and how many Rooms use it; holds no handler or Room. Guarded by the registry. */
+private class AudioObservers {
+ var rooms = 0
+ var registered = false
+ var busy = false
+ val route = object : AudioDeviceChangeListener {
+ override fun invoke(devices: List, selected: AudioDevice?) =
+ Telemetry.deviceEvent(DeviceEvent.AudioRouteChanged(listOfNotNull(selected?.output), AudioRouteReason.UNKNOWN))
+ }
+ val focus = AudioManager.OnAudioFocusChangeListener { change -> Telemetry.deviceEvent(DeviceEvent.AudioInterruption(began = change < 0)) }
+}
+
+// ponytail: strong keys; an entry lives exactly as long as some Room still holds its handler
+private val audioObservers = HashMap()
+
+private val AudioDevice.output: AudioOutput
+ get() = when (this) {
+ is AudioDevice.BluetoothHeadset -> AudioOutput.BLUETOOTH
+ is AudioDevice.WiredHeadset -> AudioOutput.WIRED_HEADSET
+ is AudioDevice.Earpiece -> AudioOutput.RECEIVER
+ is AudioDevice.Speakerphone -> AudioOutput.SPEAKER
+ else -> AudioOutput.OTHER
+ }
+
+/** Camera failures from WebRTC's capturer, on every camera track the SDK opens. */
+internal object TelemetryCameraEvents : CameraVideoCapturer.CameraEventsHandler {
+ override fun onCameraError(message: String?) =
+ Telemetry.deviceEvent(DeviceEvent.CaptureFailed(CaptureDevice.CAMERA, CaptureFailure.OTHER))
+
+ override fun onCameraDisconnected() =
+ Telemetry.deviceEvent(DeviceEvent.CaptureFailed(CaptureDevice.CAMERA, CaptureFailure.DISCONNECTED))
+
+ override fun onCameraFreezed(message: String?) {}
+
+ override fun onCameraOpening(cameraName: String?) {}
+
+ override fun onFirstFrameAvailable() {}
+
+ override fun onCameraClosed() {}
+}
+
+/** A microphone that failed to start, from WebRTC's audio device module. */
+internal fun telemetryMicrophoneFailed() =
+ Telemetry.deviceEvent(DeviceEvent.CaptureFailed(CaptureDevice.MICROPHONE, CaptureFailure.OTHER))
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/telemetry/RTCTelemetry.kt b/livekit-android-sdk/src/main/java/io/livekit/android/telemetry/RTCTelemetry.kt
new file mode 100644
index 00000000..6bb82b9b
--- /dev/null
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/telemetry/RTCTelemetry.kt
@@ -0,0 +1,200 @@
+/*
+ * Copyright 2026 LiveKit, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.livekit.android.telemetry
+
+import androidx.annotation.VisibleForTesting
+import io.livekit.android.events.RoomEvent
+import io.livekit.android.room.Room
+import io.livekit.android.room.participant.RemoteParticipant
+import io.livekit.android.room.track.RemoteTrackPublication
+import io.livekit.android.room.track.TrackPublication
+import io.livekit.android.util.LKLog
+import io.livekit.uniffi.TelemetryScope
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import livekit.org.webrtc.RTCStatsReport
+import uniffi.livekit_telemetry.AttributeValue
+import uniffi.livekit_telemetry.RtcStat
+import java.math.BigInteger
+import kotlin.coroutines.cancellation.CancellationException
+
+/**
+ * A Room's RTC instrument. Reports the remote tracks' lifecycle, from which the core runs the
+ * `lk.subscribe` span (intent → first media), and hands it one raw `getStats()` report per peer
+ * connection as often as it asks; the core maps every RTP stream to its track and windows it.
+ */
+internal class RTCTelemetry(private val room: Room, private val scope: TelemetryScope) {
+ /** Cuts the current wait short when a track appears: the core then asks for its 1 s pace. */
+ private val wake = Channel(Channel.CONFLATED)
+
+ /**
+ * Runs for one connection of the Room, in [connection]. Started before the Room joins, so the
+ * tracks published and subscribed during the join are seen too. Cancelled at the opt-out,
+ * which never waits for it; a getStats() request still on its way is refused by [Telemetry.ifCollecting].
+ */
+ fun start(connection: CoroutineScope) {
+ // Fail-open like the rest of telemetry: a failing core call is logged, never the app's crash.
+ val failOpen = CoroutineExceptionHandler { _, e -> LKLog.w(e) { "RTC telemetry stopped." } }
+ // Undispatched: subscribed to the Room's events before this returns.
+ val collector = connection.launch(failOpen, start = CoroutineStart.UNDISPATCHED) {
+ room.events.events.takeWhile { !Telemetry.disabled }.collect { event ->
+ runCatching { onEvent(event) }.onFailure { LKLog.w(it) { "RTC telemetry skipped ${event::class.simpleName}." } }
+ }
+ }
+ // On its own clock, not the Room's dispatcher: the core paces it.
+
+ @Suppress("InjectDispatcher")
+ val poller = connection.launch(Dispatchers.Default + failOpen) {
+ pollStats(interval = { scope.statsPollIntervalMs().toLong() }, wake = wake) {
+ try {
+ recordPeerStats()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ LKLog.w(e) { "RTC telemetry skipped a poll." }
+ }
+ }
+ }
+ connection.launch {
+ Telemetry.optedOut.first { it }
+ collector.cancel()
+ poller.cancel()
+ }
+ }
+
+ private fun onEvent(event: RoomEvent) {
+ when (event) {
+ // With autoSubscribe the intent exists the moment the track is known.
+ is RoomEvent.TrackPublished -> {
+ if ((event.publication as? RemoteTrackPublication)?.isDesired == true) {
+ spanTrack(event.publication, event.participant as RemoteParticipant)?.let(scope::subscribeStarted)
+ }
+ wake.trySend(Unit)
+ }
+
+ // A full reconnect announces the remote tracks again, in its own join.
+ is RoomEvent.Connected, is RoomEvent.Reconnected -> reconcileJoinedTracks()
+
+ is RoomEvent.TrackSubscribed -> {
+ spanTrack(event.publication, event.participant)?.let(scope::subscribed)
+ wake.trySend(Unit) // a manual subscribe starts its wait here
+ }
+
+ is RoomEvent.TrackSubscriptionFailed -> scope.subscribeFailed(event.sid, event.exception.errorType())
+ is RoomEvent.TrackUnsubscribed -> scope.trackEnded(event.publications.sid)
+ is RoomEvent.TrackUnpublished -> scope.trackEnded(event.publication.sid)
+ else -> {}
+ }
+ }
+
+ /** A manual subscribe: its intent opens `lk.subscribe`, and the poller takes up the core's faster pace at once. */
+ fun subscribeIntent(publication: RemoteTrackPublication, participant: RemoteParticipant) {
+ spanTrack(publication, participant)?.let(scope::subscribeStarted)
+ wake.trySend(Unit)
+ }
+
+ /** The join announces tracks without a TrackPublished event: their intent starts at connect. */
+ private fun reconcileJoinedTracks() {
+ for (participant in room.remoteParticipants.values) {
+ val pending = participant.trackPublications.values.filter { (it as? RemoteTrackPublication)?.isDesired == true && it.track == null }
+ pending.forEach { publication -> spanTrack(publication, participant)?.let(scope::subscribeStarted) }
+ }
+ wake.trySend(Unit)
+ }
+
+ /**
+ * One report per peer connection, with every track this Room sends or receives. Each getStats()
+ * call and each submit runs under the opt-out's lock, so none begins once [Telemetry.disable]
+ * has returned; waiting for an answer holds no lock.
+ */
+ @VisibleForTesting
+ internal suspend fun recordPeerStats() {
+ // One RTC thread hop for every id: each track's own read then runs in place.
+ val tracks = room.engine.onRTCThread