feat(analytics): expand DataDog RUM integration and align with iOS parity (#4970)

This commit is contained in:
James Rich
2026-04-01 15:27:28 -05:00
committed by GitHub
parent e249461e3c
commit 0167063497
10 changed files with 109 additions and 34 deletions

View File

@@ -12,7 +12,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Target SDK:** API 36. Min SDK: API 26 (Android 8.0).
- **Flavors:**
- `fdroid`: Open source only, no tracking/analytics.
- `google`: Includes Google Play Services (Maps) and DataDog analytics.
- `google`: Includes Google Play Services (Maps) and DataDog analytics (RUM, Session Replay, Compose action tracking, custom `connect` RUM action). 100% sampling, Apple-parity environments ("Local"/"Production").
- **Core Architecture:** Modern Android Development (MAD) with KMP core.
- **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all.
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.

View File

@@ -285,6 +285,8 @@ dependencies {
googleImplementation(libs.dd.sdk.android.compose)
googleImplementation(libs.dd.sdk.android.logs)
googleImplementation(libs.dd.sdk.android.rum)
googleImplementation(libs.dd.sdk.android.session.replay)
googleImplementation(libs.dd.sdk.android.session.replay.material)
googleImplementation(libs.dd.sdk.android.timber)
googleImplementation(libs.dd.sdk.android.trace)
googleImplementation(libs.dd.sdk.android.trace.otel)

View File

@@ -26,6 +26,7 @@ import co.touchlab.kermit.LogWriter
import co.touchlab.kermit.Severity
import com.datadog.android.Datadog
import com.datadog.android.DatadogSite
import com.datadog.android.compose.enableComposeActionTracking
import com.datadog.android.core.configuration.Configuration
import com.datadog.android.log.Logger
import com.datadog.android.log.Logs
@@ -33,7 +34,11 @@ import com.datadog.android.log.LogsConfiguration
import com.datadog.android.privacy.TrackingConsent
import com.datadog.android.rum.GlobalRumMonitor
import com.datadog.android.rum.Rum
import com.datadog.android.rum.RumActionType
import com.datadog.android.rum.RumConfiguration
import com.datadog.android.sessionreplay.SessionReplay
import com.datadog.android.sessionreplay.SessionReplayConfiguration
import com.datadog.android.sessionreplay.SessionReplayPrivacy
import com.datadog.android.trace.Trace
import com.datadog.android.trace.TraceConfiguration
import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry
@@ -68,7 +73,7 @@ import co.touchlab.kermit.Logger as KermitLogger
class GooglePlatformAnalytics(private val context: Context, private val analyticsPrefs: AnalyticsPrefs) :
PlatformAnalytics {
private val sampleRate = 100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate
private val sampleRate = 100f // Match Apple: 100% sampling for cross-platform DataDog comparison
private var datadogLogger: Logger? = null
private var isFirebaseInitialized = false
@@ -137,7 +142,7 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
val configuration =
Configuration.Builder(
clientToken = BuildConfig.datadogClientToken,
env = if (BuildConfig.DEBUG) "debug" else "release",
env = if (BuildConfig.DEBUG) "Local" else "Production",
variant = BuildConfig.FLAVOR,
)
.useSite(DatadogSite.US5)
@@ -151,10 +156,11 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
val rumConfiguration =
RumConfiguration.Builder(BuildConfig.datadogApplicationId)
.trackAnonymousUser(true)
.trackBackgroundEvents(false) // Disable background noise
.trackBackgroundEvents(true) // Match Apple: track background events for cross-platform parity
.trackFrustrations(false) // Disable click-tracking based frustration detection
.trackLongTasks()
.trackNonFatalAnrs(true)
.enableComposeActionTracking() // Required: activates runtime consumption of Compose semantics tags
.setSessionSampleRate(sampleRate)
.build()
Rum.enable(rumConfiguration)
@@ -162,9 +168,17 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
val logsConfig = LogsConfiguration.Builder().build()
Logs.enable(logsConfig)
val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(false).build()
val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(true).build()
Trace.enable(traceConfig)
// Session Replay for debug builds only, matching Apple's TestFlight-only gating.
// Masks all text inputs to protect message content.
if (BuildConfig.DEBUG) {
val sessionReplayConfig =
SessionReplayConfiguration.Builder(sampleRate).setPrivacy(SessionReplayPrivacy.MASK_USER_INPUT).build()
SessionReplay.enable(sessionReplayConfig)
}
GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME))
}
@@ -233,6 +247,24 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
GlobalRumMonitor.get().addAttribute("device_hardware", model)
}
override fun trackConnect(
firmwareVersion: String?,
transportType: String?,
hardwareModel: String?,
nodes: Int,
connectionRestored: Boolean,
) {
if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return
val attributes = buildMap {
firmwareVersion?.let { put("firmwareVersion", it) }
transportType?.let { put("transportType", it) }
hardwareModel?.let { put("hardwareModel", it) }
put("nodes", nodes)
if (connectionRestored) put("connectionRestored", true)
}
GlobalRumMonitor.get().addAction(RumActionType.CUSTOM, "connect", attributes)
}
private val isGooglePlayAvailable: Boolean
get() =
GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context).let {

View File

@@ -33,6 +33,7 @@ import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
@@ -88,6 +89,7 @@ class MeshConnectionManagerImpl(
private var locationRequestsJob: Job? = null
private var handshakeTimeout: Job? = null
private var connectTimeMsec = 0L
private var connectionRestored = false
@OptIn(FlowPreview::class)
override fun start(scope: CoroutineScope) {
@@ -169,6 +171,9 @@ class MeshConnectionManagerImpl(
}
private fun handleConnected() {
// Track whether this connection was restored from device sleep (vs. a fresh connect),
// matching Apple's "connectionRestored" attribute for cross-platform DataDog parity.
connectionRestored = serviceRepository.connectionState.value is ConnectionState.DeviceSleep
// The service state remains 'Connecting' until config is fully loaded
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
serviceRepository.setConnectionState(ConnectionState.Connecting)
@@ -181,22 +186,25 @@ class MeshConnectionManagerImpl(
private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) {
handshakeTimeout?.cancel()
handshakeTimeout = scope.handledLaunch {
delay(HANDSHAKE_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
// Attempt one retry. Note: the firmware silently drops identical consecutive
// writes (per-connection dedup). If the first want_config_id was received and
// the stall is on our side, the retry will be dropped and the reconnect below
// will trigger instead — which is the right recovery in that case.
Logger.w { "Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled." }
action()
delay(HANDSHAKE_RETRY_TIMEOUT)
handshakeTimeout =
scope.handledLaunch {
delay(HANDSHAKE_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
Logger.e { "Handshake still stalled after retry. Forcing reconnect." }
onConnectionChanged(ConnectionState.Disconnected)
// Attempt one retry. Note: the firmware silently drops identical consecutive
// writes (per-connection dedup). If the first want_config_id was received and
// the stall is on our side, the retry will be dropped and the reconnect below
// will trigger instead — which is the right recovery in that case.
Logger.w {
"Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled."
}
action()
delay(HANDSHAKE_RETRY_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
Logger.e { "Handshake still stalled after retry. Forcing reconnect." }
onConnectionChanged(ConnectionState.Disconnected)
}
}
}
}
}
private fun handleDeviceSleep() {
@@ -215,18 +223,19 @@ class MeshConnectionManagerImpl(
)
}
sleepTimeout = scope.handledLaunch {
try {
val localConfig = radioConfigRepository.localConfigFlow.first()
val timeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS
Logger.d { "Waiting for sleeping device, timeout=$timeout secs" }
delay(timeout.seconds)
Logger.w { "Device timeout out, setting disconnected" }
onConnectionChanged(ConnectionState.Disconnected)
} catch (_: CancellationException) {
Logger.d { "device sleep timeout cancelled" }
sleepTimeout =
scope.handledLaunch {
try {
val localConfig = radioConfigRepository.localConfigFlow.first()
val timeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS
Logger.d { "Waiting for sleeping device, timeout=$timeout secs" }
delay(timeout.seconds)
Logger.w { "Device timeout out, setting disconnected" }
onConnectionChanged(ConnectionState.Disconnected)
} catch (_: CancellationException) {
Logger.d { "device sleep timeout cancelled" }
}
}
}
serviceBroadcasts.broadcastConnection()
}
@@ -322,6 +331,16 @@ class MeshConnectionManagerImpl(
DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }),
radioModel,
)
// DataDog RUM custom action matching Apple's "connect" event for cross-platform analytics.
val transportType = radioInterfaceService.getDeviceAddress()?.let { DeviceType.fromAddress(it)?.name }
analytics.trackConnect(
firmwareVersion = myNode?.firmwareVersion,
transportType = transportType,
hardwareModel = myNode?.model,
nodes = nodeManager.nodeDBbyNodeNum.size,
connectionRestored = connectionRestored,
)
}
override fun updateTelemetry(t: Telemetry) {

View File

@@ -44,8 +44,8 @@ class AnalyticsPrefsImpl(
override val analyticsAllowed: StateFlow<Boolean> =
analyticsDataStore.data
.map { it[KEY_ANALYTICS_ALLOWED_PREF] ?: false }
.stateIn(scope, SharingStarted.Eagerly, false)
.map { it[KEY_ANALYTICS_ALLOWED_PREF] ?: true }
.stateIn(scope, SharingStarted.Eagerly, true)
override fun setAnalyticsAllowed(allowed: Boolean) {
scope.launch { analyticsDataStore.edit { prefs -> prefs[KEY_ANALYTICS_ALLOWED_PREF] = allowed } }

View File

@@ -32,6 +32,26 @@ interface PlatformAnalytics {
*/
fun setDeviceAttributes(firmwareVersion: String, model: String)
/**
* Tracks a successful device connection as a custom RUM action, aligned with the Meshtastic-Apple DataDog
* integration for cross-platform analytics comparison.
*
* @param firmwareVersion The firmware version of the connected device (major.minor).
* @param transportType The transport used for the connection (e.g., "BLE", "TCP", "USB").
* @param hardwareModel The hardware model name of the connected device.
* @param nodes The total number of nodes in the mesh network.
* @param connectionRestored True if this connection was restored from device sleep rather than a fresh connect.
*/
fun trackConnect(
firmwareVersion: String?,
transportType: String?,
hardwareModel: String?,
nodes: Int,
connectionRestored: Boolean,
) {
// Default no-op for platforms that don't support RUM (fdroid, desktop)
}
/**
* Indicates whether platform-specific services (like Google Play Services or Datadog) are available and
* initialized.

View File

@@ -31,7 +31,7 @@ import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.UiPrefs
class FakeAnalyticsPrefs : AnalyticsPrefs {
override val analyticsAllowed = MutableStateFlow(false)
override val analyticsAllowed = MutableStateFlow(true)
override fun setAnalyticsAllowed(allowed: Boolean) {
analyticsAllowed.value = allowed

View File

@@ -211,7 +211,7 @@ fun SettingsScreen(
if (state.isLocal) {
PrivacySection(
analyticsAvailable = state.analyticsAvailable,
analyticsEnabled = viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false).value,
analyticsEnabled = viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(true).value,
onToggleAnalytics = { viewModel.toggleAnalyticsAllowed() },
provideLocation = settingsViewModel.provideLocation.collectAsStateWithLifecycle().value,
onToggleLocation = { settingsViewModel.setProvideLocation(it) },

View File

@@ -98,7 +98,7 @@ data class RadioConfigState(
val fileManifest: List<FileInfo> = emptyList(),
val responseState: ResponseState<Boolean> = ResponseState.Empty,
val analyticsAvailable: Boolean = true,
val analyticsEnabled: Boolean = false,
val analyticsEnabled: Boolean = true,
val nodeDbResetPreserveFavorites: Boolean = false,
)

View File

@@ -215,6 +215,8 @@ coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
dd-sdk-android-compose = { module = "com.datadoghq:dd-sdk-android-compose", version.ref = "dd-sdk-android" }
dd-sdk-android-logs = { module = "com.datadoghq:dd-sdk-android-logs", version.ref = "dd-sdk-android" }
dd-sdk-android-rum = { module = "com.datadoghq:dd-sdk-android-rum", version.ref = "dd-sdk-android" }
dd-sdk-android-session-replay = { module = "com.datadoghq:dd-sdk-android-session-replay", version.ref = "dd-sdk-android" }
dd-sdk-android-session-replay-material = { module = "com.datadoghq:dd-sdk-android-session-replay-material", version.ref = "dd-sdk-android" }
dd-sdk-android-timber = { module = "com.datadoghq:dd-sdk-android-timber", version.ref = "dd-sdk-android" }
dd-sdk-android-trace = { module = "com.datadoghq:dd-sdk-android-trace", version.ref = "dd-sdk-android" }
dd-sdk-android-trace-otel = { module = "com.datadoghq:dd-sdk-android-trace-otel", version.ref = "dd-sdk-android" }