Android Analytics SDK End-to-End: From Event Collection Design to a Reliable Data Reporting Pipeline

While building a data dashboard, I ran into a strange problem: the page-view (PV) count for one core page was 18% off from the backend API call volume. After digging in, the culprit turned out to be the analytics SDK’s offline cache being silently dropped—when a user’s network switched to a weak connection, the SDK failed to write to SQLite and simply discarded the event. That 18% gap meant we were losing nearly one-fifth of all user behavior data.

Looking back, an analytics system is far more than a single trackEvent() method call. It is a complete data pipeline from client-side collection to server-side persistence, and any broken link degrades data quality. This article walks through the core pieces—exposure collection, event serialization, offline caching, reporting strategy, and privacy compliance—and shares the practical thinking behind how I designed an analytics SDK.

Event Model Design: A Structure That Determines Pipeline Efficiency

The core data unit of an analytics SDK is the event. The event model’s design directly affects serialization size, transmission efficiency, and downstream consumption cost.

The general event structure I use:

data class TrackEvent(
    val eventId: String,         // UUID,全局唯一
    val eventName: String,       // 事件名,如 page_view
    val timestamp: Long,         // 客户端时间戳(毫秒)
    val sessionId: String,       // 会话 ID
    val userId: String?,         // 用户标识
    val properties: JSONObject,  // 自定义属性
    val context: EventContext    // 公共上下文
)

data class EventContext(
    val appVersion: String,
    val osVersion: String,
    val deviceModel: String,
    val networkType: String,
    val carrier: String
)

Two design decisions are worth elaborating on.

Use a UUID for the event ID instead of an auto-incrementing ID. An auto-incrementing ID cannot guarantee uniqueness across client multi-process scenarios or uninstall/reinstall cycles. A UUID costs 16 extra bytes but buys end-to-end idempotency—the server can deduplicate based on eventId and avoid data pollution from duplicate reports.

Separate the common context from business properties. Context holds the common fields carried by every event, filled in automatically by the SDK so business code doesn’t have to care. Once separated, Context can be dictionary-compressed during serialization, and size-sensitive fields (such as deviceModel) need only be sent once per batch.

Exposure Collection: From View Visibility to Deduplication Logic

Exposure events are the easiest part of analytics to spin out of control. Page scrolling, tab switching, and dialog overlays all trigger visibility changes; without controls, a single item can be exposed dozens of times.

My approach builds on ViewTreeObserver and a visibility-ratio threshold, combined with a deduplication set for accurate exposure collection.

class ExposureTracker(
    private val visibilityThreshold: Float = 0.5f,
    private val minDurationMs: Long = 500L
) {
    private val exposedIds = mutableSetOf<String>()
    private val pendingExposures = mutableMapOf<String, Long>()

    fun onScrollStateChanged(recyclerView: RecyclerView) {
        val layoutManager = recyclerView.layoutManager ?: return
        val visibleRect = Rect()
        recyclerView.getGlobalVisibleRect(visibleRect)

        for (i in 0 until layoutManager.childCount) {
            val child = layoutManager.getChildAt(i) ?: continue
            val itemId = recyclerView.getChildItemId(child).toString()
            if (itemId in exposedIds) continue

            if (getVisibleRatio(child, visibleRect) >= visibilityThreshold) {
                pendingExposures.putIfAbsent(itemId, SystemClock.uptimeMillis())
            }
        }
    }

    fun onDrawFinished() {
        val now = SystemClock.uptimeMillis()
        val iterator = pendingExposures.iterator()
        while (iterator.hasNext()) {
            val (id, startTime) = iterator.next()
            if (now - startTime >= minDurationMs) {
                exposedIds.add(id)
                reportExposure(id)
                iterator.remove()
            }
        }
    }
}

minDurationMs is the key. A visibility ratio alone is not enough: when a user scrolls quickly, items flash by and should not count as valid exposures. 500ms is an empirical value I’ve validated across multiple projects—it filters out pseudo-exposures during scrolling without making normal browsing feel delayed.

Memory management of the deduplication set also needs attention. In long-list scenarios, exposedIds can balloon to tens of thousands of entries. In real projects I cap it at 2000 using an LRU policy to keep memory bounded, at the cost of possibly a tiny number of duplicate exposures in extreme cases—a tradeoff the data team finds acceptable.

Event Persistence: SQLite Is Not a Silver Bullet

Events that aren’t persisted are all lost when the process is killed. The usual approach is to write to SQLite, but in production I found two fatal problems with SQLite:

  1. File bloat in WAL mode: under high-frequency writes the WAL file can grow to dozens of MB, triggering disk-space warnings on low-end devices
  2. Lock contention: reads on the UI thread and writes on a background thread occasionally trigger SQLiteDatabaseLockedException

My alternative is a ring memory buffer + mmap file dual-write approach:

class EventBuffer(capacity: Int = 500) {
    private val buffer = ArrayDeque<TrackEvent>(capacity)
    private val mmapFile: MappedByteBuffer
    private val lock = ReentrantLock()

    init {
        val file = File(context.filesDir, "events_cache")
        val channel = FileChannel.open(
            file.toPath(), CREATE, READ, WRITE
        )
        mmapFile = channel.map(READ_WRITE, 0, MAX_FILE_SIZE)
    }

    fun write(event: TrackEvent) {
        lock.withLock {
            if (buffer.size >= capacity) {
                flushBatch()
            }
            buffer.addLast(event)
        }
    }

    private fun flushBatch() {
        val batch = buffer.toList()
        val json = JSONArray(batch.map { it.toJson() })
        val bytes = json.toString().toByteArray(Charsets.UTF_8)
        mmapFile.position(0)
        mmapFile.put(bytes, 0, bytes.size)
        buffer.clear()
    }
}

mmap’s core advantage is that writes bypass the Java Heap and go straight to the Page Cache. When the process crashes, the kernel guarantees dirty pages are flushed to disk—lower-level and more reliable than SQLite’s transactional guarantees. The downside is no structured queries, but an analytics use case doesn’t need queries anyway: an event has a single lifecycle of “write → batch read → delete.”

Measured comparison: under high-frequency writes (50 events per second), the mmap approach uses 60% less memory than SQLite and shows smaller write-latency variance (P99 dropped from 42ms to 8ms).

Reporting Strategy: Three Weapons Against Weak Networks

Events accumulate locally and eventually must reach the server. The unpredictability of mobile networks is the biggest enemy of the reporting pipeline.

Batch Merging and Compression

The problem with single-event reporting is that HTTP header overhead is proportionally too high. An event body is 500 bytes, yet the header takes up 800 bytes. Merging 20 events into one report and applying gzip compression reduces actual transfer size to roughly 15% of the original.

fun buildReportPayload(events: List<TrackEvent>): ByteArray {
    val body = JSONArray(events.map { it.toJson() }).toString()
    val baos = ByteArrayOutputStream()
    GZIPOutputStream(baos).use { gzip ->
        gzip.write(body.toByteArray(Charsets.UTF_8))
        gzip.finish()
    }
    return baos.toByteArray()
}

Exponential Backoff Retry

Retrying immediately after a network failure will most likely fail again. I use exponential backoff combined with random jitter:

fun scheduleRetry(attemptCount: Int) {
    val baseDelay = minOf(30_000L * (1 shl attemptCount), 300_000L)
    val jitter = (baseDelay * 0.2 * Random.nextDouble()).toLong()
    handler.postDelayed({ doReport() }, baseDelay + jitter)
}

The backoff cap is set at 5 minutes to avoid events piling up locally for too long. Events that still fail after 5 retries are logged and marked as dropped—data integrity matters, but it must not come at the cost of filling up the user’s disk.

Network-Aware Scheduling

ConnectivityManager on Android 6.0+ can precisely distinguish network types. On WiFi it reports without restriction; on cellular it defers batch reporting; on metered networks it only reports critical events and waits for WiFi to resume for the rest. This strategy helped us lift reporting success rate from 72% to 94% in India’s low-end device market.

Privacy Compliance: Where Are the Boundaries of Data Collection?

An analytics SDK is inherently at the center of privacy-compliance scrutiny. Compliance isn’t a patch applied to the SDK after the fact—it’s about building data-minimization principles into the design from the start.

Handling device identifiers. After Android 10, ordinary apps can no longer get the IMEI, and ANDROID_ID resets after a factory reset. My approach uses Advertising ID as the primary identifier; users can reset it or limit tracking in system settings, respecting user control.

Sensitive-data masking. Business code may accidentally stuff phone numbers or national IDs into properties. Regex pre-checks on the SDK side aren’t practical—the performance overhead is too high and false positives are likely. The actual approach is to enforce type constraints at the properties setter level, accepting only String, Number, Boolean, and List, and forbidding nested objects. Finer-grained masking is left to the server-side data-cleaning pipeline.

Data-collection switch. Both GDPR and China’s Personal Information Protection Law require giving users the right to opt out. The SDK must provide a global switch that, once turned off, immediately stops all collection and reporting and also clears already-cached events.

object TrackConfig {
    var isEnabled: Boolean = true
        set(value) {
            field = value
            if (!value) {
                eventBuffer.clear()
                cancelAllPendingReports()
            }
        }
}

The switch’s effect must be synchronous and irreversible—from the moment the user turns it off, no more data may flow out of the SDK.

Production Monitoring: Health Metrics for the Data Pipeline

Once the SDK ships, you can’t judge whether it’s working by “feel.” I built a set of self-monitoring metrics inside the SDK:

  • Event loss rate: generated events / successfully reported events; alert when it exceeds 5%
  • Cache file size: exceeding 10MB indicates a blocked reporting channel
  • Reporting latency P99: the time from event generation to server receipt; investigate if it exceeds 30s
  • Retry rate: too high suggests the network detection or backoff strategy needs tuning

These metrics are themselves events and go through a separate reporting channel to avoid interfering with business events.


An analytics SDK is a high-reliability edge data collector. The environment it faces is far harsher than the server side: processes can be killed at any time, the network is intermittent, and disk space is uncontrollable. On this data pipeline, every design decision is a tradeoff between completeness and resource consumption.

If you’re designing or refactoring an analytics system, my advice is to start from three points: first replace SQLite with mmap to solve write-reliability issues, then use batch compression + exponential backoff to push reporting success above 90%, and finally make the privacy switch irreversible—the first two determine data quality, and the last one determines whether your SDK passes review.