feat(analytics): report key user interactions as named RUM actions (#6654)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Fable 5 authored and GitHub committed 2026-08-12 19:11:44 +00:00
1 parent b4cc36043d
commit 9fa9f1e341
16 files changed
+74 -3

No files matched your search

@@ -275,6 +275,11 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
GlobalRumMonitor.get().addAction(RumActionType.CUSTOM, "connect", attributes)
}
override fun trackAction(name: String, attributes: Map<String, Any>) {
if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return
GlobalRumMonitor.get().addAction(RumActionType.CUSTOM, name, attributes)
}
override fun startScreenView(key: String, name: String) {
if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return
GlobalRumMonitor.get().startView(key = key, name = name)
@@ -52,6 +52,17 @@ interface PlatformAnalytics {
// Default no-op for platforms that don't support RUM (fdroid, desktop)
}
/**
* Tracks a key user interaction as a named custom RUM action. Auto-tracked tap targets are R8-obfuscated in release
* builds, so analytically important interactions must be reported explicitly with a stable name.
*
* @param name Stable snake_case action name (e.g. "message_send", "traceroute_request").
* @param attributes Optional structured attributes attached to the action.
*/
fun trackAction(name: String, attributes: Map<String, Any> = emptyMap()) {
// Default no-op for platforms that don't support RUM (fdroid, desktop)
}
/**
* Starts tracking a screen as a RUM view, aligned with the Meshtastic-Apple Datadog integration (which auto-tracks
* SwiftUI views) so per-screen RUM data lines up across platforms.
@@ -62,6 +62,13 @@ internal class MessagingControllerImpl(
dataHandler.value.rememberDataPacket(packet, myNodeNum, false)
val bytes = packet.bytes ?: ByteString.EMPTY
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", packet.dataType))
val actionName =
when (packet.dataType) {
PortNum.TEXT_MESSAGE_APP.value -> "message_send"
PortNum.WAYPOINT_APP.value -> "waypoint_send"
else -> "data_send"
}
analytics.trackAction(actionName, mapOf("port_num" to packet.dataType, "num_bytes" to bytes.size))
}
override suspend fun sendReaction(emoji: String, replyId: Int, contactKey: String) {
@@ -81,6 +88,7 @@ internal class MessagingControllerImpl(
)
.apply { from = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: NodeAddress.ID_LOCAL }
commandSender.sendData(dataPacket)
analytics.trackAction("reaction_send")
val user = nodeManager.nodeDBbyNodeNum[myNum]?.user ?: User(id = nodeManager.getMyId())
packetRepository.value.insertReaction(
Reaction(
+1
View File
@@ -38,6 +38,7 @@ kotlin {
implementation(projects.core.navigation)
implementation(projects.core.network)
implementation(projects.core.prefs)
implementation(projects.core.repository)
implementation(libs.meshtastic.protobufs)
implementation(projects.core.service)
implementation(projects.core.resources)
@@ -55,6 +55,7 @@ import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.isBle
@@ -113,6 +114,7 @@ class FirmwareUpdateViewModel(
private val fileHandler: FirmwareFileHandler,
private val applicationScope: ApplicationCoroutineScope,
private val hiddenFeaturesUnlock: HiddenFeaturesUnlock,
private val analytics: PlatformAnalytics,
) : ViewModel() {
private val _state = MutableStateFlow<FirmwareUpdateState>(FirmwareUpdateState.Idle)
@@ -321,6 +323,22 @@ class FirmwareUpdateViewModel(
fun startUpdate() {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
val release = currentState.release ?: return
// Explicit mapping instead of class-name reflection: FirmwareUpdateMethod is R8-obfuscated in release.
val updateMethod =
when (currentState.updateMethod) {
FirmwareUpdateMethod.Usb -> "usb"
FirmwareUpdateMethod.Ble -> "ble"
FirmwareUpdateMethod.Wifi -> "wifi"
FirmwareUpdateMethod.Unknown -> "unknown"
}
analytics.trackAction(
"firmware_update_start",
mapOf(
"update_method" to updateMethod,
"is_recovery" to currentState.isRecovery,
"release_version" to release.id,
),
)
if (currentState.isRecovery) {
startRecoveryUpdate(currentState, release)
} else {
@@ -115,6 +115,7 @@ class FirmwareUpdateIntegrationTest {
fileHandler,
TestApplicationCoroutineScope(testDispatcher),
HiddenFeaturesUnlock(),
mock(MockMode.autofill),
)
@Test
@@ -138,6 +138,7 @@ class FirmwareUpdateViewModelTest {
fileHandler,
TestApplicationCoroutineScope(testDispatcher),
hiddenFeaturesUnlock,
mock(MockMode.autofill),
)
@Test
@@ -137,6 +137,7 @@ class FirmwareUpdateViewModelFileTest {
fileHandler,
TestApplicationCoroutineScope(testDispatcher),
HiddenFeaturesUnlock(),
mock(MockMode.autofill),
)
private fun firmwareUri(fileName: String): CommonUri = CommonUri.parse("file:///downloads/$fileName")
@@ -25,6 +25,7 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
@@ -47,6 +48,7 @@ class CommonNodeRequestActions
constructor(
private val radioController: RadioController,
private val snackbarManager: SnackbarManager,
private val analytics: PlatformAnalytics,
) : NodeRequestActions {
private val _lastTracerouteTime = MutableStateFlow<Long?>(null)
@@ -62,6 +64,7 @@ constructor(
override suspend fun requestUserInfo(destNum: Int, longName: String) {
Logger.i { "Requesting UserInfo for '$destNum'" }
radioController.requestUserInfo(destNum)
analytics.trackAction("user_info_request")
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName))
}
@@ -76,6 +79,7 @@ constructor(
override suspend fun requestPosition(destNum: Int, longName: String, position: Position) {
Logger.i { "Requesting position for '$destNum'" }
radioController.requestPosition(destNum, position)
analytics.trackAction("position_request")
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.position, longName))
}
@@ -83,6 +87,7 @@ constructor(
Logger.i { "Requesting telemetry for '$destNum'" }
val packetId = radioController.generatePacketId()
radioController.requestTelemetry(packetId, destNum, type.ordinal)
analytics.trackAction("telemetry_request", mapOf("telemetry_type" to type.name))
val typeRes =
when (type) {
@@ -102,6 +107,7 @@ constructor(
Logger.i { "Requesting traceroute for '$destNum'" }
val packetId = radioController.generatePacketId()
radioController.requestTraceroute(packetId, destNum)
analytics.trackAction("traceroute_request")
_lastTracerouteTime.value = nowMillis
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName))
}
@@ -23,6 +23,7 @@ import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.favorite
@@ -46,6 +47,7 @@ constructor(
private val nodeRepository: NodeRepository,
private val radioController: RadioController,
private val alertManager: AlertManager,
private val analytics: PlatformAnalytics,
) {
open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) {
alertManager.showAlert(
@@ -114,6 +116,7 @@ constructor(
open suspend fun setFavorite(nodeNum: Int, favorite: Boolean) {
radioController.setFavorite(nodeNum, favorite)
analytics.trackAction("node_favorite", mapOf("favorite" to favorite))
}
open suspend fun setNodeNotes(nodeNum: Int, notes: String) {
@@ -45,6 +45,7 @@ class NodeManagementActionsTest {
nodeRepository = nodeRepository,
radioController = radioController,
alertManager = alertManager,
analytics = mock(MockMode.autofill),
)
@Test
@@ -76,6 +77,7 @@ class NodeManagementActionsTest {
nodeRepository = nodeRepository,
radioController = radioController,
alertManager = realAlertManager,
analytics = mock(MockMode.autofill),
)
val node = Node(num = 123, user = User(long_name = "Test Node"))
var afterRemoveCalled = false
@@ -84,8 +84,10 @@ class ChannelViewModel(
}
/** Set the radio config (also updates our saved copy in preferences). */
fun setChannels(channelSet: ChannelSet) =
safeLaunch(tag = "setChannels") { importChannelSet(channelSet, radioController, radioConfigRepository) }
fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") {
importChannelSet(channelSet, radioController, radioConfigRepository)
analytics.trackAction("channel_update", mapOf("num_channels" to channelSet.settings.size))
}
// Set the radio config (also updates our saved copy in preferences)
fun setConfig(config: Config) {
@@ -94,6 +96,7 @@ class ChannelViewModel(
fun trackShare() {
analytics.track("share", DataPair("content_type", "channel"))
analytics.trackAction("channel_share")
}
private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) {
@@ -70,6 +70,7 @@ import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NodeRestartTracker
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.SecurityKeyBackupStore
import org.meshtastic.core.repository.ServiceRepository
@@ -161,6 +162,7 @@ open class RadioConfigViewModel(
private val securityKeyBackupStore: SecurityKeyBackupStore,
private val snackbarManager: SnackbarManager,
private val nodeRestartTracker: NodeRestartTracker,
private val analytics: PlatformAnalytics,
) : ViewModel() {
val lockdownTokenInfo = serviceRepository.lockdownTokenInfo
@@ -590,8 +592,11 @@ open class RadioConfigViewModel(
private fun sendAdminRequest(destNum: Int) {
val route = radioConfigState.value.route
val isLocal = radioConfigState.value.isLocal
_radioConfigState.update { it.copy(route = "") } // setter (response is PortNum.ROUTING_APP)
analytics.trackAction("admin_action", mapOf("route" to route.lowercase(), "is_remote" to !isLocal))
val preserveFavorites = radioConfigState.value.nodeDbResetPreserveFavorites
when (route) {
@@ -257,7 +257,10 @@ fun ChannelScreen(
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.CHANNELS)
},
onClickShare = { showShareDialog = true },
onClickShare = {
viewModel.trackShare()
showShareDialog = true
},
)
}
item {
@@ -142,6 +142,7 @@ class ProfileRoundTripTest {
fileService = fileService,
mqttManager = mqttManager,
lockdownCoordinator = FakeLockdownCoordinator(),
analytics = mock(MockMode.autofill),
securityKeyBackupStore = securityKeyBackupStore,
snackbarManager = snackbarManager,
nodeRestartTracker = nodeRestartTracker,
@@ -184,6 +184,7 @@ class RadioConfigViewModelTest {
fileService = fileService,
mqttManager = mqttManager,
lockdownCoordinator = FakeLockdownCoordinator(),
analytics = mock(MockMode.autofill),
)
@Test
@@ -975,6 +976,7 @@ class RadioConfigViewModelTest {
fileService = fileService,
mqttManager = mqttManager,
lockdownCoordinator = FakeLockdownCoordinator(),
analytics = mock(MockMode.autofill),
)
assertEquals(456, viewModel.destNode.value?.num)
}