

This blog will walk you through the step-by-step process of building a scalable and testable analytics logging architecture in Android.
We'll explore its design, implementation, and best practices, empowering you to create your own robust solution.
A big thanks to NowInAndroid Repository for providing the code example used in this blog, which was taken from their open-source project.
Overview of the Architecture
The architecture centers around the AnalyticsHelper interface, which abstracts the actual logging mechanism. Multiple implementations of this interface handle different scenarios, such as:
- NoOpAnalyticsHelper: A no-operation implementation for testing and previews. 
- TestAnalyticsHelper: A mock implementation to verify logging behavior in unit tests. 
- StubAnalyticsHelper: A simple implementation for debugging in local environments. 
- FirebaseAnalyticsHelper: The actual implementation using Firebase Analytics for production. 
This setup adheres to the Dependency Inversion Principle (DIP) of SOLID, enabling modular, scalable, and testable code.
Step-by-Step Implementation
1. Define the AnalyticsHelper Interface
The AnalyticsHelper interface ensures that all implementations conform to a standard contract:
interface AnalyticsHelper {
    fun logEvent(event: AnalyticsEvent)
}This interface allows us to inject different implementations depending on the environment (e.g., production, testing).
2. Create the Implementations
a. NoOpAnalyticsHelper
This implementation does nothing and is useful for tests or when no logging is needed:
class NoOpAnalyticsHelper : AnalyticsHelper {
    override fun logEvent(event: AnalyticsEvent) = Unit
}b. TestAnalyticsHelper
Used in unit tests to verify if specific events are logged:
class TestAnalyticsHelper : AnalyticsHelper {
    private val events = mutableListOf<AnalyticsEvent>()
    override fun logEvent(event: AnalyticsEvent) {
        events.add(event)
    }
    fun hasLogged(event: AnalyticsEvent) = event in events
}c. StubAnalyticsHelper
Logs events to the console for debugging:
@Singleton
internal class StubAnalyticsHelper @Inject constructor() : AnalyticsHelper {
    override fun logEvent(event: AnalyticsEvent) {
        Log.d(TAG, "Received analytics event: $event")
    }
}d. FirebaseAnalyticsHelper
The production implementation that logs events to Firebase Analytics:
internal class FirebaseAnalyticsHelper @Inject constructor(
    private val firebaseAnalytics: FirebaseAnalytics,
) : AnalyticsHelper {
    override fun logEvent(event: AnalyticsEvent) {
        firebaseAnalytics.logEvent(event.type) {
            for (extra in event.extras) {
                param(
                    key = extra.key.take(40),
                    value = extra.value.take(100),
                )
            }
        }
    }
}3. Define the AnalyticsEvent Data Class
The AnalyticsEvent class represents each logged event:
data class AnalyticsEvent(
    val type: String,
    val extras: List<Param> = emptyList(),
) {
    data class Param(val key: String, val value: String)
}This structure ensures consistency and type safety in event logging.
4. Add a CompositionLocal for Dependency Injection
Use Jetpack Compose's CompositionLocal to provide a default AnalyticsHelper:
val LocalAnalyticsHelper = staticCompositionLocalOf<AnalyticsHelper> {
    NoOpAnalyticsHelper()
}This approach ensures that tests and previews do not require manual AnalyticsHelper injection.
How to use:
@Inject
lateinit var analyticsHelper: AnalyticsHelper
setContent {
    CompositionLocalProvider(
                LocalAnalyticsHelper provides analyticsHelper
) { NiaTheme() ... }
}5. Provide Dependency Injection with Hilt
Use Hilt to provide the appropriate implementation of AnalyticsHelper:
@Module
@InstallIn(SingletonComponent::class)
object AnalyticsModule {
    @Provides
    fun provideAnalyticsHelper(
        firebaseAnalytics: FirebaseAnalytics,
    ): AnalyticsHelper = FirebaseAnalyticsHelper(firebaseAnalytics)
}6. Create Extensions
Create extensions for your features  AnalyticsExtensions:
internal fun AnalyticsHelper.logNewsResourceBookmarkToggled(newsResourceId: String, isBookmarked: Boolean) {
    val eventType = if (isBookmarked) "news_resource_saved" else "news_resource_unsaved"
    val paramKey = if (isBookmarked) "saved_news_resource_id" else "unsaved_news_resource_id"
    logEvent(
        AnalyticsEvent(
            type = eventType,
            extras = listOf(
                Param(key = paramKey, value = newsResourceId),
            ),
        ),
    )
}and use it via analyticsHelper instance:
@Inject
lateinit var analyticsHelper: AnalyticsHelperAdvanced Insights for Senior Engineers
Design Decisions
- Interface-Based Architecture: By using the - AnalyticsHelperinterface, we decouple the logging logic from the rest of the app, adhering to the Dependency Inversion Principle of SOLID.
- Data Class for Events: The - AnalyticsEventdata class ensures type safety and enforces a consistent structure for events.
Trade-offs and Challenges
- Dynamic Flexibility vs. Type Safety: Representing - AnalyticsEvent.extrasas a- List<Param>provides flexibility but lacks the strong typing of a map.
- Data Integrity: Truncating keys and values for Firebase limits can cause discrepancies when comparing data across platforms. 
Optimization Strategies
- Performance: Logging events on the main thread can cause bottlenecks. Always offload logging to background threads using coroutines: javascript- CoroutineScope(Dispatchers.IO).launch { analyticsHelper.logEvent(event) }
- Batching Logs: In high-traffic apps, batching events can reduce network overhead and improve performance. 
Real-world Considerations
- Error Handling: Implement a retry mechanism for failed logs due to network issues. 
- Avoid Logging PII: Ensure no personally identifiable information (PII) like emails or phone numbers is logged. Use hashed or anonymized values if needed. 
Testing Strategies
- Unit Testing: Use - TestAnalyticsHelperto verify that specific events are logged during tests:kotlin- val testHelper = TestAnalyticsHelper() val event = AnalyticsEvent(type = "test_event") testHelper.logEvent(event) assertTrue(testHelper.hasLogged(event))
- Integration Testing: Use tools like Firebase Test Lab to inspect analytics logs in real-world scenarios. 
Production-Ready Enhancements
- Global Event Queue: Add a queue to handle retrying failed logs during intermittent network issues. 
- Custom Sampling Rates: Control the volume of logged events by introducing sampling rates for high-frequency events. 
Conclusion
With a clean, modular architecture like this, your analytics logging system becomes a powerful tool for understanding user behavior. Start small, keep your implementation testable, and iterate as your app grows. Remember, analytics is not just about logging eventsโit's about creating actionable insights that drive meaningful decisions.
By following the steps and best practices outlined in this guide, you can create a robust analytics logging system tailored to your app's needs.
Akshay Nandwana
Founder AndroidEngineers
You can connect with me on:
Book 1:1 Session here Click Here
Join our upcoming classes
https://www.androidengineers.in/courses
Get the latest Android development articles delivered to your inbox.