refactor(analytics): reduce tracking footprint (#4649)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-25 20:44:13 -06:00 committed by GitHub
parent ceeb28945d
commit 85c6ed61bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 109 additions and 163 deletions

View file

@ -30,7 +30,6 @@ 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
@ -46,6 +45,8 @@ import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailabilityLight
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics.ConsentStatus
import com.google.firebase.analytics.FirebaseAnalytics.ConsentType
import com.google.firebase.analytics.analytics
import com.google.firebase.crashlytics.crashlytics
import com.google.firebase.crashlytics.setCustomKeys
@ -64,16 +65,22 @@ import co.touchlab.kermit.Logger as KermitLogger
/**
* Google Play Services specific implementation of [PlatformAnalytics]. This helper initializes and manages Firebase and
* Datadog services, and subscribes to analytics preference changes to update consent accordingly.
*
* This implementation delays initialization of SDKs until user consent is granted to reduce tracking "noise" and
* respect privacy-focused environments.
*/
class GooglePlatformAnalytics
@Inject
constructor(
@ApplicationContext private val context: Context,
analyticsPrefs: AnalyticsPrefs,
private val analyticsPrefs: AnalyticsPrefs,
) : PlatformAnalytics {
private val sampleRate = 100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate
private var datadogLogger: Logger? = null
private var isFirebaseInitialized = false
private val isInTestLab: Boolean
get() {
val testLabSetting = Settings.System.getString(context.contentResolver, "firebase.test.lab")
@ -83,22 +90,16 @@ constructor(
companion object {
private const val TAG = "GooglePlatformAnalytics"
private const val SERVICE_NAME = "org.meshtastic"
private const val KEY_PRIORITY = "priority"
private const val KEY_TAG = "tag"
private const val KEY_MESSAGE = "message"
}
init {
initDatadog(context as Application)
initCrashlytics(context as Application)
val datadogLogger =
Logger.Builder()
.setService(SERVICE_NAME)
.setNetworkInfoEnabled(false) // Disable to avoid collecting Local IP/SSID
.setRemoteSampleRate(sampleRate)
.setBundleWithTraceEnabled(true)
.setBundleWithRumEnabled(true)
.build()
// Setup Kermit log writers immediately, they will handle delayed SDK initialization gracefully.
val writers = buildList {
add(DatadogLogWriter(datadogLogger))
add(DatadogLogWriter())
add(CrashlyticsLogWriter())
if (BuildConfig.DEBUG) {
add(co.touchlab.kermit.LogcatWriter())
@ -117,6 +118,30 @@ constructor(
.launchIn(ProcessLifecycleOwner.get().lifecycleScope)
}
/**
* Ensures that Datadog and Firebase SDKs are initialized if allowed. This is called lazily when consent is granted.
*/
private fun ensureInitialized() {
if (!analyticsPrefs.analyticsAllowed || isInTestLab) return
if (!Datadog.isInitialized()) {
initDatadog(context as Application)
datadogLogger =
Logger.Builder()
.setService(SERVICE_NAME)
.setNetworkInfoEnabled(false) // Disable to avoid collecting Local IP/SSID
.setRemoteSampleRate(sampleRate)
.setBundleWithTraceEnabled(true)
.setBundleWithRumEnabled(true)
.build()
}
if (!isFirebaseInitialized) {
initCrashlytics(context as Application)
isFirebaseInitialized = true
}
}
private fun initDatadog(application: Application) {
val configuration =
Configuration.Builder(
@ -135,13 +160,11 @@ constructor(
val rumConfiguration =
RumConfiguration.Builder(BuildConfig.datadogApplicationId)
.trackAnonymousUser(true)
.trackBackgroundEvents(true)
.trackFrustrations(true)
.trackBackgroundEvents(false) // Disable background noise
.trackFrustrations(false) // Disable click-tracking based frustration detection
.trackLongTasks()
.trackNonFatalAnrs(true)
.trackUserInteractions()
.setSessionSampleRate(sampleRate)
.enableComposeActionTracking()
.build()
Rum.enable(rumConfiguration)
@ -153,12 +176,24 @@ constructor(
GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME))
// Session Replay disabled to reduce PII collection as requested
// Session Replay disabled to reduce PII collection
}
private fun initCrashlytics(application: Application) {
Firebase.initialize(application)
// User ID tracking disabled to avoid collecting Unique Identifier PII
// Deny all ad-related consent types by default to minimize tracking noise
Firebase.analytics.setConsent(
mapOf(
ConsentType.AD_STORAGE to ConsentStatus.DENIED,
ConsentType.AD_USER_DATA to ConsentStatus.DENIED,
ConsentType.AD_PERSONALIZATION to ConsentStatus.DENIED,
ConsentType.ANALYTICS_STORAGE to ConsentStatus.DENIED,
),
)
// Explicitly disable analytics collection until we confirm user consent
Firebase.analytics.setAnalyticsCollectionEnabled(false)
}
/**
@ -167,18 +202,39 @@ constructor(
* @param allowed True if analytics are allowed, false otherwise.
*/
fun updateAnalyticsConsent(allowed: Boolean) {
if (!isPlatformServicesAvailable || isInTestLab) {
KermitLogger.i { "Analytics not available or in test lab, consent update skipped." }
return
}
KermitLogger.i { if (allowed) "Analytics enabled" else "Analytics disabled" }
Datadog.setTrackingConsent(if (allowed) TrackingConsent.GRANTED else TrackingConsent.NOT_GRANTED)
Firebase.crashlytics.isCrashlyticsCollectionEnabled = allowed
Firebase.analytics.setAnalyticsCollectionEnabled(allowed)
if (isInTestLab) return
if (allowed) {
Firebase.crashlytics.sendUnsentReports()
ensureInitialized()
}
KermitLogger.i { if (allowed) "Analytics enabled" else "Analytics disabled" }
if (Datadog.isInitialized()) {
Datadog.setTrackingConsent(if (allowed) TrackingConsent.GRANTED else TrackingConsent.NOT_GRANTED)
}
if (isFirebaseInitialized) {
Firebase.crashlytics.isCrashlyticsCollectionEnabled = allowed
Firebase.analytics.setAnalyticsCollectionEnabled(allowed)
if (allowed) {
Firebase.crashlytics.sendUnsentReports()
// Ensure ad-related PII collection remains disabled even if analytics is allowed
Firebase.analytics.setUserProperty("allow_personalized_ads", "false")
}
// Manage Analytics Storage consent for Advanced Consent Mode
val consentStatus = if (allowed) ConsentStatus.GRANTED else ConsentStatus.DENIED
Firebase.analytics.setConsent(
mapOf(
ConsentType.ANALYTICS_STORAGE to consentStatus,
// Keep ad-related types explicitly denied
ConsentType.AD_STORAGE to ConsentStatus.DENIED,
ConsentType.AD_USER_DATA to ConsentStatus.DENIED,
ConsentType.AD_PERSONALIZATION to ConsentStatus.DENIED,
),
)
}
}
@ -206,20 +262,12 @@ constructor(
it != ConnectionResult.SERVICE_MISSING && it != ConnectionResult.SERVICE_INVALID
}
private val isDatadogAvailable: Boolean
get() = Datadog.isInitialized()
override val isPlatformServicesAvailable: Boolean
get() = isGooglePlayAvailable && isDatadogAvailable
private class CrashlyticsLogWriter : LogWriter() {
companion object {
private const val KEY_PRIORITY = "priority"
private const val KEY_TAG = "tag"
private const val KEY_MESSAGE = "message"
}
get() = isGooglePlayAvailable
private inner class CrashlyticsLogWriter : LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
if (!isFirebaseInitialized) return
if (!Firebase.crashlytics.isCrashlyticsCollectionEnabled) return
// Add the log to the Crashlytics log buffer so it appears in reports
@ -244,8 +292,9 @@ constructor(
}
}
private class DatadogLogWriter(private val datadogLogger: Logger) : LogWriter() {
private inner class DatadogLogWriter : LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
val logger = datadogLogger ?: return
val datadogPriority =
when (severity) {
Severity.Verbose -> android.util.Log.VERBOSE
@ -255,7 +304,7 @@ constructor(
Severity.Error -> android.util.Log.ERROR
Severity.Assert -> android.util.Log.ASSERT
}
datadogLogger.log(datadogPriority, message, throwable, mapOf("tag" to tag))
logger.log(datadogPriority, message, throwable, mapOf("tag" to tag))
}
}
@ -266,6 +315,7 @@ constructor(
}
override fun track(event: String, vararg properties: DataPair) {
if (!isFirebaseInitialized) return
val bundle = Bundle()
properties.forEach {
when (it.value) {

View file

@ -60,7 +60,7 @@ constructor(
@AppSharedPreferences appPrefs: SharedPreferences,
) : AnalyticsPrefs {
override var analyticsAllowed: Boolean by
PrefDelegate(analyticsSharedPreferences, AnalyticsPrefs.KEY_ANALYTICS_ALLOWED, true)
PrefDelegate(analyticsSharedPreferences, AnalyticsPrefs.KEY_ANALYTICS_ALLOWED, false)
private var _installId: String? by NullableStringPrefDelegate(appPrefs, "appPrefs_install_id", null)