Design an Analytics SDK
Requirements
You build an SDK (a library other teams add to their apps) that sends usage events to a server. Think Firebase Analytics, Segment or Mixpanel. Many different apps will use it, so it must be safe, small and polite: it must never hurt the host app.
Functional
track("checkout_started", properties: ["price": 9.99]).screen("Settings"),identify(userID:),reset()on logout.- Add common data to every event: app version, OS, device model, session id.
- Send events in batches. Allow a manual
flush(). - Consent: the app can turn collection off and on.
Non-functional
- Never crash and never block the main thread.
trackmust return in microseconds. - No data loss if the app is killed. Events are saved to disk.
- Low battery and data: batch events, compress them, and do not wake the radio often.
- Tiny footprint: no third-party dependencies, small binary, small memory.
- Privacy: no personal data by default, a privacy manifest, easy opt-out.
- Thread-safe. Works in app extensions too.
Out of scope
- The backend pipeline and dashboards.
- A/B testing, crash reporting, session replay.
Questions to ask the interviewer
- How many events per user per day? Do we need them in real time, or is a few minutes OK?
- Which laws apply (GDPR, CCPA)? Is there a consent screen?
- Which platforms: iOS only, or also watchOS and app extensions?
- How do apps install it: Swift Package Manager, binary framework?
- Can we use the advertising id (IDFA)? (Usually no.)
High-level architecture
Host app, any thread
|
| Analytics.shared.track("checkout_started", properties: ["price": 9.99])
v
+-------------------+ yield (sync, O(1)) +----------------------+
| Public API | -------------------> | EventPipeline | (actor)
| final class | AsyncStream | enrich, filter, |
| (non-blocking) | | consent, encode |
+-------------------+ +----------+-----------+
| append one JSON line
v
+----------------------+
| EventStore | current.jsonl
| (files on disk) | batch-*.jsonl
+----------+-----------+
| rotate on flush
v
+----------------------+
| Uploader | compress, POST,
| | retry + backoff
+----------+-----------+
| HTTPS
v
+----------------------+
| Ingest server |
+----------------------+
Flush when: 20 events queued, or every 30 s, or the app goes to background, or flush().| Part | Job |
|---|---|
| Public API | Takes the call, adds a timestamp, puts it in a queue, returns. |
EventPipeline | One serial place for all logic: add context, check consent, encode JSON. |
EventStore | Appends each event to a file. Turns the file into a "batch" on flush. |
Uploader | Sends batch files. Deletes a file only after the server says OK. |
Data model
public enum AnalyticsValue: Sendable, Equatable, Encodable {
case string(String), int(Int), double(Double), bool(Bool)
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let v): try container.encode(v)
case .int(let v): try container.encode(v)
case .double(let v): try container.encode(v)
case .bool(let v): try container.encode(v)
}
}
}
// Lets callers write ["price": 9.99, "plan": "pro"] with no wrapping.
extension AnalyticsValue: ExpressibleByStringLiteral, ExpressibleByIntegerLiteral,
ExpressibleByFloatLiteral, ExpressibleByBooleanLiteral {
public init(stringLiteral value: String) { self = .string(value) }
public init(integerLiteral value: Int) { self = .int(value) }
public init(floatLiteral value: Double) { self = .double(value) }
public init(booleanLiteral value: Bool) { self = .bool(value) }
}
struct Event: Encodable, Sendable {
let id: UUID // the server drops duplicates by id
let name: String
let properties: [String: AnalyticsValue]
let timestamp: Date // when track() was called, not when it was sent
let sessionID: UUID
let anonymousID: UUID // random, per install; reset on logout
let userID: String?
let context: Context
}
struct Context: Encodable, Sendable {
let appVersion: String
let osVersion: String
let deviceModel: String
let locale: String
let sdkVersion: String
}Why not [String: Any]? Any is not Sendable and not Encodable. A small enum is type-safe
and easy to encode. It also blocks people from sending big objects by mistake.
API / interfaces
Server endpoint
POST /v1/batch
Headers: Authorization: Bearer <writeKey>, Content-Type: application/json, Content-Encoding: gzip
Body: { "sentAt": "2026-10-16T10:00:00Z", "events": [ ... ] }
2xx -> delete the batch
408, 429, 5xx -> retry later, respect Retry-After
other 4xx -> bad batch: drop it (it will never succeed)sentAt fixes wrong phone clocks. The server computes serverTime - sentAt and shifts every
event timestamp by that amount.
Swift interfaces
public enum Consent: Sendable { case granted, denied }
public struct Configuration: Sendable {
public var writeKey: String
public var endpoint: URL
public var flushAt = 20 // events
public var flushInterval: Duration = .seconds(30)
public var maxStoredBytes = 5 * 1024 * 1024
}
public protocol AnalyticsClient: Sendable {
func track(_ name: String, properties: [String: AnalyticsValue])
func screen(_ name: String)
func identify(userID: String)
func reset()
func flush()
func setConsent(_ consent: Consent)
}
// Internal seams for tests:
protocol EventUploading: Sendable {
func upload(_ body: Data) async throws -> Int // returns the HTTP status
}
protocol Clock: Sendable {
var now: Date { get }
}All public methods are sync and return at once. The caller never needs await or try.
Deep dives
1. A non-blocking, ordered public API
The simple idea is Task { await pipeline.track(...) }. But many separate Tasks do not keep
their order. Event B can be stored before event A.
Better: put events into an AsyncStream. yield is synchronous and keeps the order. One loop
reads the stream and hands events to the actor, one by one.
struct RawEvent: Sendable {
enum Kind: Sendable { case track(String), screen(String), identify(String), reset, flush }
let kind: Kind
let properties: [String: AnalyticsValue]
let timestamp: Date
}
public final class Analytics: AnalyticsClient {
private let input: AsyncStream<RawEvent>.Continuation
public init(configuration: Configuration) {
let (stream, continuation) = AsyncStream.makeStream(
of: RawEvent.self,
bufferingPolicy: .bufferingNewest(1_000) // flood guard: drop the oldest
)
input = continuation
let pipeline = EventPipeline(configuration: configuration)
Task.detached(priority: .utility) {
for await raw in stream {
await pipeline.process(raw)
}
}
}
public func track(_ name: String, properties: [String: AnalyticsValue] = [:]) {
input.yield(RawEvent(kind: .track(name), properties: properties, timestamp: Date()))
}
public func flush() {
input.yield(RawEvent(kind: .flush, properties: [:], timestamp: Date()))
}
// screen, identify, reset and setConsent follow the same pattern.
}Notes:
AnalyticsisSendablebecause its only stored property is aletof aSendabletype.- Take the timestamp in
track, not later in the pipeline. .utilitypriority: analytics work should never compete with the UI.bufferingNewest(1_000)caps memory if an app callstrackin a tight loop.
2. Persistence: crash-safe files
Events must survive a crash or a kill. Keep them in memory only for a moment. A simple and robust design: append-only JSON lines (one event per line).
Application Support/Analytics/
current.jsonl <- new events are appended here
batch-1729072800-A1B2.jsonl <- frozen, waiting for upload
batch-1729072830-C3D4.jsonlactor EventStore {
private let directory: URL
private var currentURL: URL { directory.appending(path: "current.jsonl") }
private var handle: FileHandle?
init(directory: URL) { self.directory = directory }
func append(_ json: Data) throws {
if handle == nil {
if !FileManager.default.fileExists(atPath: currentURL.path) {
FileManager.default.createFile(atPath: currentURL.path, contents: nil)
}
handle = try FileHandle(forWritingTo: currentURL)
try handle?.seekToEnd()
}
try handle?.write(contentsOf: json + Data("\n".utf8))
}
/// Freezes current events into a batch file. New events go to a fresh file.
func rotate() throws -> URL? {
try handle?.close()
handle = nil
guard FileManager.default.fileExists(atPath: currentURL.path) else { return nil }
let stamp = Int(Date().timeIntervalSince1970)
let batch = directory.appending(path: "batch-\(stamp)-\(UUID().uuidString.prefix(4)).jsonl")
try FileManager.default.moveItem(at: currentURL, to: batch)
return batch
}
func pendingBatches() -> [URL] {
let files = (try? FileManager.default.contentsOfDirectory(at: directory,
includingPropertiesForKeys: nil)) ?? []
return files.filter { $0.lastPathComponent.hasPrefix("batch-") }
.sorted { $0.lastPathComponent < $1.lastPathComponent } // oldest first
}
}Why this design is good:
- A crash loses at most the line being written. A broken last line is skipped on upload.
- No decode step: the uploader joins lines into a JSON array.
- Folder:
Application Support, notCaches(the system can delete Caches). Mark it "excluded from backup". No need to back up analytics to iCloud. - Enforce
maxStoredBytes. If the user is offline for weeks, delete the oldest batches.
func makeBody(batch file: URL, sentAt: Date) throws -> Data {
let lines = try Data(contentsOf: file).split(separator: UInt8(ascii: "\n"))
var body = Data(#"{"sentAt":""#.utf8)
body.append(Data(ISO8601DateFormatter().string(from: sentAt).utf8))
body.append(Data(#"","events":["#.utf8))
body.append(Data(lines.joined(separator: Data(",".utf8))))
body.append(Data("]}".utf8))
return body
}3. Batching, retry and backoff
Sending one request per event wastes battery. Each request wakes the cell radio, and the radio stays on for several seconds after. So we batch.
Flush when any of these is true:
- 20 events are stored.
- 30 seconds passed since the last flush.
- The app goes to the background.
- The app calls
flush().
Decide what to do from the status code:
enum UploadOutcome { case success, retryLater, drop }
func outcome(for status: Int) -> UploadOutcome {
switch status {
case 200..<300: .success
case 408, 429, 500..<600: .retryLater
case 400..<500: .drop // bad data never succeeds: do not loop forever
default: .retryLater
}
}Retry with exponential backoff (wait 1 s, 2 s, 4 s ... up to 10 minutes) plus a random
jitter, so millions of phones do not retry at the same second after a server outage.
Use the Retry-After header when the server sends it. Stop retrying while offline.
NWPathMonitor tells you when the network is back.
The uploader sends one batch at a time, oldest first, and deletes the file only after a 2xx.
If the app is killed during upload, the file is still there. It is sent again on the next launch.
The server dedupes by event id, so a double send is harmless. This gives at-least-once
delivery (every event arrives one or more times, never zero).
Background. When the app goes to the background, flush with a little extra time:
#if canImport(UIKit) && !os(watchOS)
@MainActor
final class BackgroundFlusher {
private var taskID = UIBackgroundTaskIdentifier.invalid
func flush(using uploader: Uploader) {
let app = UIApplication.shared // NOT available in app extensions
taskID = app.beginBackgroundTask { [weak self] in self?.end() } // time is up
Task {
await uploader.uploadPending()
self.end()
}
}
private func end() {
guard taskID != .invalid else { return }
UIApplication.shared.endBackgroundTask(taskID)
taskID = .invalid
}
}
#endifIn app extensions, UIApplication.shared does not exist. Compile it out for extensions, or let
the host app call flush() itself. Another option is a background URLSession upload task,
which the system finishes even after the app is suspended.
4. Privacy and consent
Privacy is part of the design, not an extra.
- Consent. When
denied, drop new events and delete stored batches. Do not send anything. - Anonymous id. A random
UUIDper install. Do not use the IDFA unless the app has App Tracking Transparency permission and passes the id in.reset()makes a new anonymous id. - No personal data by default. Do not collect email, name or location. Add a denylist that
removes keys like
emailorphonefrom properties, with a debug warning. - Privacy manifest. Ship a
PrivacyInfo.xcprivacyfile. List the data types you collect. Declare "required reason" APIs you use, for exampleUserDefaultsto store the anonymous id. - HTTPS only. Never log event payloads in release builds.
extension EventPipeline {
func apply(_ consent: Consent) async {
self.consent = consent
if consent == .denied {
await store.deleteAll() // respect the user's choice right away
}
}
}5. A tiny, polite footprint
Your SDK lives inside someone else's app. Rules:
- No dependencies. If you ship Alamofire version X and the app uses version Y, the app breaks.
- Foundation only in the core. Put UIKit code behind
#if canImport(UIKit). - Never crash. No force unwraps, no
fatalError, notry!. Catch every error and ignore it. A broken analytics call must cost nothing. - Small memory: bounded buffer, no big caches.
- Battery: longer flush interval in Low Power Mode
(
ProcessInfo.processInfo.isLowPowerModeEnabled) and on Low Data Mode (NWPath.isConstrained). - Remote kill switch. A small config endpoint can turn the SDK off or sample events, without an app update.
Sessions. A new session starts after 30 minutes without activity:
struct SessionTracker {
private(set) var id = UUID()
private var lastActivity: Date
let timeout: TimeInterval = 30 * 60
init(now: Date) { lastActivity = now }
mutating func touch(now: Date) -> UUID {
if now.timeIntervalSince(lastActivity) > timeout { id = UUID() }
lastActivity = now
return id
}
}Pass now in. Tests can then move time without waiting 30 minutes.
Trade-offs
| Decision | Option A | Option B | Usual pick |
|---|---|---|---|
| Storage | Memory buffer: fast, loses events on crash | Disk per event: safe, more I/O | Disk, append-only (cheap) |
| Flush size | Small batches: fresher data | Big batches: less battery | 20 events or 30 s |
| Delivery | At-most-once (never duplicate) | At-least-once + server dedupe | At-least-once |
| Format | JSON: easy to debug | Protobuf: smaller | JSON + compression |
| Upload | Foreground URLSession | Background URLSession | Foreground, plus background task on exit |
| 4xx response | Retry | Drop the batch | Drop (it will never work) |
| Dependencies | Use libraries | Foundation only | Foundation only |
What interviewers look for
trackis non-blocking, keeps order, and never throws or crashes.- Events are persisted to disk and deleted only after a 2xx.
- Batching with clear flush triggers, and you can explain why (radio and battery).
- Retry with exponential backoff and jitter; drop on 4xx; respect
Retry-After. - At-least-once delivery with event ids for server-side dedupe.
- Clock skew fix with
sentAt. - Bounded memory and disk; oldest events dropped first.
- Privacy: consent, no PII by default, privacy manifest, anonymous id reset.
- SDK manners: no dependencies, works in extensions, kill switch.
- Testable design: injected clock, uploader and directory.