Analytics Logging System in NowInAndroid App
System Design
Analytics Logging System in NowInAndroid App
Akshay Nandwana
January 6, 2025
5 min read
24 views

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:

  1. NoOpAnalyticsHelper: A no-operation implementation for testing and previews.

  2. TestAnalyticsHelper: A mock implementation to verify logging behavior in unit tests.

  3. StubAnalyticsHelper: A simple implementation for debugging in local environments.

  4. 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:

kotlin
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:

kotlin
class NoOpAnalyticsHelper : AnalyticsHelper {
    override fun logEvent(event: AnalyticsEvent) = Unit
}

b. TestAnalyticsHelper

Used in unit tests to verify if specific events are logged:

kotlin
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:

kotlin
@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:

kotlin
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:

kotlin
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:

kotlin
val LocalAnalyticsHelper = staticCompositionLocalOf<AnalyticsHelper> {
    NoOpAnalyticsHelper()
}

This approach ensures that tests and previews do not require manual AnalyticsHelper injection.

How to use:

kotlin
@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:

kotlin
@Module
@InstallIn(SingletonComponent::class)
object AnalyticsModule {

    @Provides
    fun provideAnalyticsHelper(
        firebaseAnalytics: FirebaseAnalytics,
    ): AnalyticsHelper = FirebaseAnalyticsHelper(firebaseAnalytics)
}

6. Create Extensions

Create extensions for your features AnalyticsExtensions:

kotlin
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:

kotlin
@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

  1. Dynamic Flexibility vs. Type Safety: Representing AnalyticsEvent.extras as a List<Param> provides flexibility but lacks the strong typing of a map.

  2. Data Integrity: Truncating keys and values for Firebase limits can cause discrepancies when comparing data across platforms.

Optimization Strategies

  1. 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)
    }
  2. 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:

    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

  1. Global Event Queue: Add a queue to handle retrying failed logs during intermittent network issues.

  2. 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

Share This Article
Stay Updated

Get the latest Android development articles delivered to your inbox.