

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: AnalyticsHelper
Advanced Insights for Senior Engineers
Design Decisions
Interface-Based Architecture: By using the
AnalyticsHelper
interface, we decouple the logging logic from the rest of the app, adhering to the Dependency Inversion Principle of SOLID.Data Class for Events: The
AnalyticsEvent
data class ensures type safety and enforces a consistent structure for events.
Trade-offs and Challenges
Dynamic Flexibility vs. Type Safety: Representing
AnalyticsEvent.extras
as aList<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:
javascriptCoroutineScope(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
TestAnalyticsHelper
to verify that specific events are logged during tests:kotlinval 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.