Design an analytics/telemetry SDK that mobile apps embed
A senior blueprint for a batching, durable-queue telemetry SDK — public API, threading model, retry logic, and the privacy controls that keep it out of consent trouble.
LEARN BY DOINGPut your intuition to work3 steps
Design an analytics/telemetry SDK that mobile apps embed
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Clarifying the requirements
Before writing a single public method, I'd separate what this SDK is actually for. Is it product analytics (funnels, feature usage), crash and performance telemetry, or both sharing one pipeline? Does it need near-real-time delivery for dashboards, or is a delay of a few minutes acceptable — and almost every analytics use case tolerates far more delay than engineers assume, which matters a lot for the design. Does it need to support multiple destinations — an internal warehouse plus a third-party tool like Firebase or Amplitude — or just one? What's the acceptable binary size and dependency footprint, since this code ships inside every app that integrates it and a bloated SDK is a real cost to the host app's install size and startup time? And what consent model does it need to respect — GDPR/CCPA opt-out, Apple's App Tracking Transparency for anything tied to advertising identifiers? Those answers decide almost everything about the pipeline, especially the tension between delivering everything fast and being invisible in the host app's battery and network usage.
#A small, opinionated public API
The public surface should be deliberately boring — a handful of calls a host app's engineers can use without reading documentation, because the SDK's job is to disappear into the app, not to become a second thing to learn. I'd expose initialization, identity, event tracking, screen tracking, an explicit flush, and a consent toggle, and nothing else at the top level:
object Analytics {fun initialize(config: AnalyticsConfig)fun identify(userId: String, traits: Map<String, Any> = emptyMap())fun track(event: String, properties: Map<String, Any> = emptyMap())fun screen(name: String, properties: Map<String, Any> = emptyMap())/** Forces an immediate flush attempt; the SDK still batches on its own otherwise. */fun flush()/** Stops collection; any already-queued events are flushed once, then discarded. */fun setOptedOut(optedOut: Boolean)}data class AnalyticsConfig(val writeKey: String,val endpoint: String,val flushAtCount: Int = 20,val flushIntervalMs: Long = 30_000,val maxQueueSize: Int = 1_000,val maxRetries: Int = 5)
Every one of those calls has to return immediately and do essentially no work on the caller's thread, because host apps will call track from click handlers and scroll listeners without a second thought — if the SDK blocks, it becomes the host app's jank, and that's the fastest way to get ripped out of a codebase.
#Architecture: enqueue, persist, batch, flush
Underneath that thin API, the pipeline has four stages. First, every call to track or screen constructs an event with a client-generated UUID (for de-duplication downstream) and appends it to a durable write-ahead log — a small SQLite table or an append-only file, not an in-memory list — so that a process death between the call and the next flush doesn't silently drop data. Second, a batcher watches that log and decides when to flush: on reaching a configured event count, on a timer, or opportunistically the moment the app is backgrounded, since that's the last reliable moment to get bytes on the wire before the OS can suspend the process. Third, the flush step reads a bounded batch, compresses it, and posts it to the ingestion endpoint. Fourth, only a confirmed success removes those events from the durable log — anything else leaves them there for the next retry, so a crash mid-flush just means the same batch gets sent again, not that it disappears.
class EventQueue(private val config: AnalyticsConfig,private val storage: EventStorage, // durable WAL, backed by SQLiteprivate val transport: EventTransport, // thin HTTP client abstractionprivate val scope: CoroutineScope) {private val dispatcher = Dispatchers.IO.limitedParallelism(1) // serial, off the caller's threadprivate var timedFlush: Job? = nullfun enqueue(event: AnalyticsEvent) {scope.launch(dispatcher) {storage.append(event)scheduleTimedFlushIfNeeded()if (storage.pendingCount() >= config.flushAtCount) flushNow()}}private fun scheduleTimedFlushIfNeeded() {if (timedFlush?.isActive == true) returntimedFlush = scope.launch(dispatcher) {delay(config.flushIntervalMs)flushNow()}}suspend fun flushNow() {val batch = storage.peekBatch(limit = 100) ?: returnwhen (val result = transport.send(batch)) {is TransportResult.Success -> storage.remove(batch.ids)is TransportResult.Retry -> scheduleBackoff(batch.attempt)is TransportResult.RejectedPayload -> storage.moveToDeadLetter(batch.ids)}}private suspend fun scheduleBackoff(attempt: Int) {val backoffMs = min(30_000L, (1_000L * 2.0.pow(attempt)).toLong())delay(backoffMs + Random.nextLong(0, 500)) // jitter avoids a thundering herd on the ingestion endpoint}}
#Threading and performance footprint
Everything after the initial enqueue — the WAL write, the batching decision, compression, and the network call — runs on a single serial background dispatcher, never the caller's thread and never in parallel with itself, which keeps the implementation simple (no locking needed around the queue) while guaranteeing the public API never introduces jank. On top of that, I'd hold the SDK to a hard size and dependency budget — reusing the host app's own networking stack instead of bundling a second HTTP client is usually worth the small integration awkwardness, because SDK bloat is one of the most common reasons a growth or platform team gets asked to rip a tool back out.
#Failure handling and delivery guarantees
I'd be explicit in the design doc that this SDK targets at-least-once delivery, not exactly-once, because that's what a client-plus-retry system can actually promise — the client-generated UUID on each event exists so the backend can deduplicate on ingestion rather than the SDK pretending it can guarantee no duplicates ever reach the wire. Retries use exponential backoff with jitter so a service incident doesn't turn into a retry storm the moment it recovers, and a hard retry cap moves a batch to a local dead-letter store rather than retrying forever — a batch that fails five times in a row is far more likely to be malformed than transiently unlucky. I'd also cap the durable queue's total size and evict the oldest low-priority events first once it's full, while giving high-value event types (crashes, purchases) a priority tier so a chatty screen-view stream can't push a purchase event out of the queue during an extended offline period.
#Privacy and consent
A telemetry SDK is also a compliance surface, so setOptedOut has to be more than a suggestion: it should flush whatever's already queued once, then stop accepting new events entirely and clear anything still on disk, rather than quietly continuing to buffer for later. I'd validate outgoing event properties against an explicit allow-list schema rather than accepting arbitrary keys, so a well-meaning engineer three years from now can't accidentally pass a user's email address as an event property and turn an analytics event into a PII leak. Any identifier used for advertising attribution needs to be kept separate from the internal analytics ID and gated behind the platform's own consent primitive — App Tracking Transparency on iOS, the advertising ID reset on Android — because conflating the product's own analytics ID with the advertising identifier is exactly the kind of thing that turns a routine app update into a Play Store or App Store policy violation.
#Tradeoffs I'd name
Batching over HTTP is the right default over a persistent WebSocket for almost every analytics use case, because near-real-time delivery is rarely the actual requirement and a live connection costs meaningfully more battery and data for a benefit most dashboards don't need. Writing every single event to disk synchronously is the safest option but the slowest; I'd batch writes to the WAL in small groups instead of fsyncing per event, accepting a tiny window of possible data loss on a hard crash in exchange for meaningfully better throughput. And I'd treat the event schema itself as a versioned contract — old app versions stay in the field for months after a schema change ships, so the ingestion endpoint has to keep accepting the previous shape rather than assuming every client has updated, which is a constraint that has nothing to do with the SDK's internals but shapes its public API from day one.