mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-04 14:13:47 -04:00
feat(analytics): expand DataDog RUM integration and align with iOS parity (#4970)
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user