Skip to content
Shushant TiwariMobile engineer & maker
← All stories

Implementing Rate-Limited Instana Event Logging in Android: A Step-by-Step Guide with Kotlin Delegation

Kotlin delegation, queuing and persistence for event logging that respects Instana rate limits.

Shushant TiwariMobile engineer. Building tools that help us see.
Contents

Illustration accompanying Implementing Rate-Limited Instana Event Logging in Android: A Step-by-Step Guide with Kotlin Delegation

Why This Matters

In high-traffic or performance-sensitive applications, missing event logs can create gaps in data, impacting business insights and user experience improvements. This solution ensures that even if rate limits are hit, all events are eventually logged, supporting accurate analytics and decision-making.

Key Challenges in Event Logging

  1. Rate Limits: Instana restricts the number of logs that can be sent within specific time frames, making it challenging to capture all events in real-time.
  2. Reliability: Apps may need to retry logging events that exceed rate limits.
  3. Persistence: Ensuring events are stored and retried even if the app is backgrounded or killed.

Solution Overview

We’ll use Kotlin delegation and WorkManager to:

  • Log events reliably with Instana, adhering to rate limits.
  • Queue and retry events if they exceed rate limits.
  • Persist events so they are not lost if the app is closed.

Objectives

  1. Reliable logging of events under rate limits.
  2. Persistent queuing of events to prevent data loss.
  3. Modular, reusable code using Kotlin delegation.

Step 1: Define an Analytics Event Model

We’ll start by defining an **AnalyticsEvent** data class to keep track of each log's details and timestamp. This will store the properties and timestamp for accuracy.

data class AnalyticsEvent(
    val name: String,
    val properties: Map<String, String>? = null,
    val timestamp: Long = System.currentTimeMillis()
)

Step 2: Create the Analytics Handler Interface

Using Kotlin’s delegation, we’ll offload the logging logic to a RateLimitedLogger class. First, define an AnalyticsHandler interface with methods to log events and screen views.

interface AnalyticsHandler {
    fun logEvent(event: AnalyticsEvent)
    fun logScreenView(screenName: String)
}

Step 3: Build a RateLimitedLogger with Persistence

The RateLimitedLogger the class will manage rate limiting and store events that exceed Instana's limit. It queues these events and saves them using SharedPreferences (or Room, if preferred) for retry when limits reset.

RateLimitedLogger Implementation

Here’s the implementation with comments breaking down each section:

class RateLimitedLogger(context: Context) : AnalyticsHandler {

    private val sharedPreferences: SharedPreferences = 
        context.getSharedPreferences("analytics_events", Context.MODE_PRIVATE)
    private val gson = Gson()
    private val rateLimiter = RateLimiter(maxPerTenMinutes = 128, maxPerTenSeconds = 32)
    private val eventQueue = loadEventsFromPreferences()
    private val workManager = WorkManager.getInstance(context)

    // Log an event, checking if it can be sent immediately or queued for later
    override fun logEvent(event: AnalyticsEvent) {
        if (rateLimiter.canLogEvent()) {
            sendToInstana(event)
        } else {
            eventQueue.add(event)
            saveEventsToPreferences(eventQueue)
            scheduleRetryWork()
        }
    }

    // Load queued events from SharedPreferences
    private fun loadEventsFromPreferences(): ConcurrentLinkedQueue<AnalyticsEvent> {
        val eventsJson = sharedPreferences.getString("events", null)
        return if (eventsJson != null) {
            val events = gson.fromJson(eventsJson, Array<AnalyticsEvent>::class.java)
            ConcurrentLinkedQueue(events.toList())
        } else {
            ConcurrentLinkedQueue()
        }
    }

    // Save events to SharedPreferences for persistence
    private fun saveEventsToPreferences(queue: ConcurrentLinkedQueue<AnalyticsEvent>) {
        val eventsJson = gson.toJson(queue.toList())
        sharedPreferences.edit().putString("events", eventsJson).apply()
    }

    // Schedule retry using WorkManager if rate limits are exceeded
    private fun scheduleRetryWork() {
        val retryWorkRequest = OneTimeWorkRequestBuilder<RetryWorker>()
            .setInitialDelay(rateLimiter.getCooldownTime(), TimeUnit.MILLISECONDS)
            .build()
        workManager.enqueueUniqueWork("RetryInstanaLogging", ExistingWorkPolicy.KEEP, retryWorkRequest)
    }

    // Rate limiter for tracking logging frequency and enforcing limits within the current session
    inner class RateLimiter(
        private val maxPerTenMinutes: Int,
        private val maxPerTenSeconds: Int
    ) {
        private val tenMinuteWindow = ArrayDeque<Long>()
        private val tenSecondWindow = ArrayDeque<Long>()

        // Session-based rate limiting, reset on app restart
        init {
            tenMinuteWindow.clear()
            tenSecondWindow.clear()
        }

        fun canLogEvent(): Boolean {
            val currentTime = System.currentTimeMillis()
            tenMinuteWindow.removeIf { currentTime - it > 10 * 60 * 1000 }
            tenSecondWindow.removeIf { currentTime - it > 10 * 1000 }
            return if (tenMinuteWindow.size < maxPerTenMinutes && tenSecondWindow.size < maxPerTenSeconds) {
                tenMinuteWindow.add(currentTime)
                tenSecondWindow.add(currentTime)
                true
            } else {
                false
            }
        }

        fun getCooldownTime(): Long {
            val tenSecondCooldown = tenSecondWindow.peek()?.let { it + 10 * 1000 - System.currentTimeMillis() } ?: 0
            val tenMinuteCooldown = tenMinuteWindow.peek()?.let { it + 10 * 60 * 1000 - System.currentTimeMillis() } ?: 0
            return maxOf(tenSecondCooldown, tenMinuteCooldown, 1000)
        }
    }
}

Step 4: Implement a RetryWorker for Queued Events

RetryWorker It handles retry attempts for events in the queue, retrying when rate limits are reset.

class RetryWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) {
    private val rateLimitedLogger = RateLimitedLogger(appContext)

    override fun doWork(): Result {
        val eventQueue = rateLimitedLogger.loadEventsFromPreferences()
        while (eventQueue.isNotEmpty()) {
            val event = eventQueue.poll()
            if (!rateLimitedLogger.rateLimiter.canLogEvent()) {
                eventQueue.add(event)
                rateLimitedLogger.saveEventsToPreferences(eventQueue)
                rateLimitedLogger.scheduleRetryWork()
                return Result.retry()
            }
            rateLimitedLogger.sendToInstana(event)
        }
        rateLimitedLogger.saveEventsToPreferences(ConcurrentLinkedQueue())
        return Result.success()
    }
}

Step 5: Using Kotlin Delegation in Base Classes

To integrate this solution in multiple classes, delegate AnalyticsHandler in BaseFragment:

abstract class BaseFragment : Fragment(), AnalyticsHandler by RateLimitedLogger(requireContext()) {
    override fun onResume() {
        super.onResume()
        logScreenView(this::class.java.simpleName)
    }
}

Key Benefits of This Approach

  1. Reliable Event Logging: Queues events when rate limits are hit, so data isn’t lost.
  2. Persistence: Events persist if the app is killed or backgrounded.
  3. Modular Code with Kotlin Delegation: Clean and testable code by offloading logging logic.
  4. Reusability: This can be adapted for any event-logging framework.

Wrapping Up

This Kotlin-based solution ensures reliable Instana event logging in Android apps. With rate limits, persistence, and modularity in mind, this guide should help you confidently implement robust analytics. Try it out in your app to ensure no data is missed!

Originally published on Medium on 8 Nov 2024. Republished here by the author.

Examples and opinions reflect the original publication date.

Keep the conversation going.

Have a thought to add?Join the conversation on Medium.