refactor(analytics)!: modularize analytics - remove Logging (#3256)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-09-30 18:22:22 -05:00 committed by GitHub
parent 9aa0cf9335
commit cad88d277b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 1219 additions and 1426 deletions

View file

@ -1,32 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh
import com.geeksville.mesh.android.GeeksvilleApplication
import dagger.hilt.android.HiltAndroidApp
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
import javax.inject.Inject
@HiltAndroidApp
class MeshUtilApplication : GeeksvilleApplication() {
@Inject override lateinit var analyticsPrefs: AnalyticsPrefs
override fun onCreate() {
super.onCreate()
}
}

View file

@ -1,97 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.analytics
import android.os.Bundle
import com.geeksville.mesh.android.Logging
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics
import com.google.firebase.analytics.logEvent
class DataPair(val name: String, valueIn: Any?) {
val value = valueIn ?: "null"
// / An accumulating firebase event - only one allowed per event
constructor(d: Double) : this(FirebaseAnalytics.Param.VALUE, d)
constructor(d: Int) : this(FirebaseAnalytics.Param.VALUE, d)
}
/** Implement our analytics API using Firebase Analytics */
class FirebaseAnalytics(installId: String) :
AnalyticsProvider,
Logging {
val t = Firebase.analytics.apply { setUserId(installId) }
override fun setEnabled(on: Boolean) {
t.setAnalyticsCollectionEnabled(on)
}
override fun endSession() {
track("End Session")
// Mint.flush() // Send results now
}
override fun trackLowValue(event: String, vararg properties: DataPair) {
track(event, *properties)
}
override fun track(event: String, vararg properties: DataPair) {
debug("Analytics: track $event")
val bundle = Bundle()
properties.forEach {
when (it.value) {
is Double -> bundle.putDouble(it.name, it.value)
is Int -> bundle.putLong(it.name, it.value.toLong())
is Long -> bundle.putLong(it.name, it.value)
is Float -> bundle.putDouble(it.name, it.value.toDouble())
else -> bundle.putString(it.name, it.value.toString())
}
}
t.logEvent(event, bundle)
}
override fun startSession() {
debug("Analytics: start session")
// automatic with firebase
}
override fun setUserInfo(vararg p: DataPair) {
p.forEach { t.setUserProperty(it.name, it.value.toString()) }
}
override fun increment(name: String, amount: Double) {
// Mint.logEvent("$name increment")
}
/** Send a google analytics screen view event */
override fun sendScreenView(name: String) {
debug("Analytics: start screen $name")
t.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
param(FirebaseAnalytics.Param.SCREEN_NAME, name)
param(FirebaseAnalytics.Param.SCREEN_CLASS, "MainActivity")
}
}
override fun endScreenView() {
// debug("Analytics: end screen")
}
}

View file

@ -1,267 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.android
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import android.provider.Settings
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import com.datadog.android.Datadog
import com.datadog.android.DatadogSite
import com.datadog.android.compose.ExperimentalTrackingApi
import com.datadog.android.compose.NavigationViewTrackingEffect
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
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.RumConfiguration
import com.datadog.android.rum.tracking.AcceptAllNavDestinations
import com.datadog.android.sessionreplay.SessionReplay
import com.datadog.android.sessionreplay.SessionReplayConfiguration
import com.datadog.android.sessionreplay.compose.ComposeExtensionSupport
import com.datadog.android.timber.DatadogTree
import com.datadog.android.trace.Trace
import com.datadog.android.trace.TraceConfiguration
import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.analytics.AnalyticsProvider
import com.geeksville.mesh.analytics.FirebaseAnalytics
import com.geeksville.mesh.util.exceptionReporter
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailabilityLight
import com.google.firebase.Firebase
import com.google.firebase.analytics.analytics
import com.google.firebase.crashlytics.crashlytics
import com.google.firebase.crashlytics.setCustomKeys
import com.google.firebase.initialize
import com.suddenh4x.ratingdialog.AppRating
import io.opentelemetry.api.GlobalOpenTelemetry
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
import timber.log.Timber
abstract class GeeksvilleApplication :
Application(),
Logging {
companion object {
lateinit var analytics: AnalyticsProvider
}
// / Are we running inside the testlab?
val isInTestLab: Boolean
get() {
val testLabSetting = Settings.System.getString(contentResolver, "firebase.test.lab")
if (testLabSetting != null) {
info("Testlab is $testLabSetting")
}
return "true" == testLabSetting
}
abstract val analyticsPrefs: AnalyticsPrefs
private val minimumLaunchTimes: Int = 10
private val minimumDays: Int = 10
private val minimumLaunchTimesToShowAgain: Int = 5
private val minimumDaysToShowAgain: Int = 14
/** Ask user to rate in play store */
@Suppress("MagicNumber")
fun askToRate(activity: AppCompatActivity) {
if (!isGooglePlayAvailable) return
@Suppress("MaxLineLength")
exceptionReporter {
// we don't want to crash our app because of bugs in this optional feature
AppRating.Builder(activity)
.setMinimumLaunchTimes(minimumLaunchTimes) // default is 5, 3 means app is launched 3 or more times
.setMinimumDays(
minimumDays,
) // default is 5, 0 means install day, 10 means app is launched 10 or more days
// later than installation
.setMinimumLaunchTimesToShowAgain(
minimumLaunchTimesToShowAgain,
) // default is 5, 1 means app is launched 1 or more times after neutral button
// clicked
.setMinimumDaysToShowAgain(
minimumDaysToShowAgain,
) // default is 14, 1 means app is launched 1 or more days after neutral button
// clicked
.showIfMeetsConditions()
}
}
lateinit var analyticsPrefsChangedListener: SharedPreferences.OnSharedPreferenceChangeListener
override fun onCreate() {
super.onCreate()
initDatadog()
initCrashlytics()
updateAnalyticsConsent()
// listen for changes to analytics prefs
analyticsPrefsChangedListener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "allowed") {
updateAnalyticsConsent()
}
}
getSharedPreferences("analytics-prefs", MODE_PRIVATE)
.registerOnSharedPreferenceChangeListener(analyticsPrefsChangedListener)
}
private val sampleRate = 100f
private fun initCrashlytics() {
analytics = FirebaseAnalytics(analyticsPrefs.installId)
Firebase.initialize(this)
Firebase.crashlytics.setUserId(analyticsPrefs.installId)
Timber.plant(CrashlyticsTree())
}
private fun updateAnalyticsConsent() {
if (!isAnalyticsAvailable || isInTestLab) {
info("Analytics not available")
return
}
val isAnalyticsAllowed = analyticsPrefs.analyticsAllowed
info(if (isAnalyticsAllowed) "Analytics enabled" else "Analytics disabled")
Datadog.setTrackingConsent(if (isAnalyticsAllowed) TrackingConsent.GRANTED else TrackingConsent.NOT_GRANTED)
analytics.setEnabled(isAnalyticsAllowed)
Firebase.crashlytics.isCrashlyticsCollectionEnabled = isAnalyticsAllowed
Firebase.analytics.setAnalyticsCollectionEnabled(isAnalyticsAllowed)
Firebase.crashlytics.sendUnsentReports()
}
private class CrashlyticsTree : Timber.Tree() {
companion object {
private const val KEY_PRIORITY = "priority"
private const val KEY_TAG = "tag"
private const val KEY_MESSAGE = "message"
}
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
Firebase.crashlytics.setCustomKeys {
key(KEY_PRIORITY, priority)
key(KEY_TAG, tag ?: "No Tag")
key(KEY_MESSAGE, message)
}
if (t == null) {
Firebase.crashlytics.recordException(Exception(message))
} else {
Firebase.crashlytics.recordException(t)
}
}
}
private fun initDatadog() {
val logger =
Logger.Builder()
.setNetworkInfoEnabled(true)
.setRemoteSampleRate(sampleRate)
.setBundleWithTraceEnabled(true)
.setBundleWithRumEnabled(true)
.build()
val configuration =
Configuration.Builder(
clientToken = BuildConfig.datadogClientToken,
env = if (BuildConfig.DEBUG) "debug" else "release",
variant = BuildConfig.FLAVOR,
)
.useSite(DatadogSite.US5)
.setCrashReportsEnabled(true)
.setUseDeveloperModeWhenDebuggable(true)
.build()
val consent = TrackingConsent.PENDING
Datadog.initialize(this, configuration, consent)
Datadog.setUserInfo(analyticsPrefs.installId)
val rumConfiguration =
RumConfiguration.Builder(BuildConfig.datadogApplicationId)
.trackAnonymousUser(true)
.trackBackgroundEvents(true)
.trackFrustrations(true)
.trackLongTasks()
.trackNonFatalAnrs(true)
.trackUserInteractions()
.enableComposeActionTracking()
.build()
Rum.enable(rumConfiguration)
val logsConfig = LogsConfiguration.Builder().build()
Logs.enable(logsConfig)
val traceConfig = TraceConfiguration.Builder().build()
Trace.enable(traceConfig)
GlobalOpenTelemetry.set(DatadogOpenTelemetry(BuildConfig.APPLICATION_ID))
val sessionReplayConfig =
SessionReplayConfiguration.Builder(sampleRate = 20.0f)
// in case you need Jetpack Compose support
.addExtensionSupport(ComposeExtensionSupport())
.build()
SessionReplay.enable(sessionReplayConfig)
Timber.plant(Timber.DebugTree(), DatadogTree(logger))
}
}
fun setAttributes(firmwareVersion: String, deviceHardware: DeviceHardware) {
GlobalRumMonitor.get().addAttribute("firmware_version", firmwareVersion.extractSemanticVersion())
GlobalRumMonitor.get().addAttribute("device_hardware", deviceHardware.hwModelSlug)
}
private val Context.isGooglePlayAvailable: Boolean
get() =
GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(this).let {
it != ConnectionResult.SERVICE_MISSING && it != ConnectionResult.SERVICE_INVALID
}
private val isDatadogAvailable: Boolean = Datadog.isInitialized()
val Context.isAnalyticsAvailable: Boolean
get() = isDatadogAvailable && isGooglePlayAvailable
@OptIn(ExperimentalTrackingApi::class)
@Composable
fun AddNavigationTracking(navController: NavHostController) {
NavigationViewTrackingEffect(
navController = navController,
trackArguments = true,
destinationPredicate = AcceptAllNavDestinations(),
)
}
fun String.extractSemanticVersion(): String {
// Regex to capture up to three numeric parts separated by dots
val regex = """^(\d+)(?:\.(\d+))?(?:\.(\d+))?""".toRegex()
val matchResult = regex.find(this)
return matchResult?.groupValues?.drop(1)?.filter { it.isNotEmpty() }?.joinToString(".")
?: this // Fallback to original if no match
}

View file

@ -32,12 +32,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.geeksville.mesh.android.BuildUtils.debug
import com.google.android.gms.common.api.ResolvableApiException
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.LocationSettingsRequest
import com.google.android.gms.location.Priority
import timber.log.Timber
private const val INTERVAL_MILLIS = 10000L
@ -66,11 +66,11 @@ fun LocationPermissionsHandler(onPermissionResult: (Boolean) -> Unit) {
val locationSettingsLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartIntentSenderForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
debug("Location settings changed by user.")
Timber.d("Location settings changed by user.")
// User has enabled location services or improved accuracy.
onPermissionResult(true) // Settings are now adequate, and permission was already granted.
} else {
debug("Location settings change cancelled by user.")
Timber.d("Location settings change cancelled by user.")
// User chose not to change settings. The permission itself is still granted,
// but the experience might be degraded. For the purpose of enabling map features,
// we consider this as success if the core permission is there.
@ -111,7 +111,7 @@ fun LocationPermissionsHandler(onPermissionResult: (Boolean) -> Unit) {
val task = client.checkLocationSettings(builder.build())
task.addOnSuccessListener {
debug("Location settings are satisfied.")
Timber.d("Location settings are satisfied.")
onPermissionResult(true) // Permission granted and settings are good
}
@ -122,11 +122,11 @@ fun LocationPermissionsHandler(onPermissionResult: (Boolean) -> Unit) {
locationSettingsLauncher.launch(intentSenderRequest)
// Result of this launch will be handled by locationSettingsLauncher's callback
} catch (sendEx: ActivityNotFoundException) {
debug("Error launching location settings resolution ${sendEx.message}.")
Timber.d("Error launching location settings resolution ${sendEx.message}.")
onPermissionResult(true) // Permission is granted, but settings dialog failed. Proceed.
}
} else {
debug("Location settings are not satisfiable.${exception.message}")
Timber.d("Location settings are not satisfiable.${exception.message}")
onPermissionResult(true) // Permission is granted, but settings not ideal. Proceed.
}
}

View file

@ -66,8 +66,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import com.geeksville.mesh.MeshProtos.Position
import com.geeksville.mesh.MeshProtos.Waypoint
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.BuildUtils.warn
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.map.components.ClusterItemsListDialog
import com.geeksville.mesh.ui.map.components.CustomMapLayersSheet
@ -205,7 +203,7 @@ fun MapView(
try {
cameraPositionState.animate(cameraUpdate)
} catch (e: IllegalStateException) {
debug("Error animating camera to location: ${e.message}")
Timber.d("Error animating camera to location: ${e.message}")
}
}
}
@ -224,14 +222,14 @@ fun MapView(
try {
fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null)
debug("Started location tracking")
Timber.d("Started location tracking")
} catch (e: SecurityException) {
debug("Location permission not available: ${e.message}")
Timber.d("Location permission not available: ${e.message}")
isLocationTrackingEnabled = false
}
} else {
fusedLocationClient.removeLocationUpdates(locationCallback)
debug("Stopped location tracking")
Timber.d("Stopped location tracking")
}
}
@ -374,7 +372,7 @@ fun MapView(
cameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(bounds, padding))
}
} catch (e: IllegalStateException) {
warn("MapView Could not animate to bounds: ${e.message}")
Timber.w("MapView Could not animate to bounds: ${e.message}")
}
}
},
@ -462,7 +460,7 @@ fun MapView(
CameraUpdateFactory.newLatLngBounds(bounds.build(), 100),
)
}
debug("Cluster clicked! $cluster")
Timber.d("Cluster clicked! $cluster")
}
true
},
@ -574,9 +572,9 @@ fun MapView(
val currentPosition = cameraPositionState.position
val newCameraPosition = CameraPosition.Builder(currentPosition).bearing(0f).build()
cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(newCameraPosition))
debug("Oriented map to north")
Timber.d("Oriented map to north")
} catch (e: IllegalStateException) {
debug("Error orienting map to north: ${e.message}")
Timber.d("Error orienting map to north: ${e.message}")
}
}
}

View file

@ -22,7 +22,6 @@ import android.net.Uri
import androidx.core.net.toFile
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.android.BuildUtils.debug
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider
@ -442,7 +441,7 @@ constructor(
try {
application.contentResolver.openInputStream(uriToLoad)
} catch (_: Exception) {
debug("MapViewModel: Error opening InputStream from URI: $uriToLoad")
Timber.d("MapViewModel: Error opening InputStream from URI: $uriToLoad")
null
}
}