iosNudge

Design an Analytics SDK

MediumSystem designPerformancePersistenceNetworkingSecurity 45 min read

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. track must 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().
PartJob
Public APITakes the call, adds a timestamp, puts it in a queue, returns.
EventPipelineOne serial place for all logic: add context, check consent, encode JSON.
EventStoreAppends each event to a file. Turns the file into a "batch" on flush.
UploaderSends 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:

  • Analytics is Sendable because its only stored property is a let of a Sendable type.
  • Take the timestamp in track, not later in the pipeline.
  • .utility priority: analytics work should never compete with the UI.
  • bufferingNewest(1_000) caps memory if an app calls track in 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.jsonl
actor 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, not Caches (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
    }
}
#endif

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

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 UUID per 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 email or phone from properties, with a debug warning.
  • Privacy manifest. Ship a PrivacyInfo.xcprivacy file. List the data types you collect. Declare "required reason" APIs you use, for example UserDefaults to 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, no try!. 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

DecisionOption AOption BUsual pick
StorageMemory buffer: fast, loses events on crashDisk per event: safe, more I/ODisk, append-only (cheap)
Flush sizeSmall batches: fresher dataBig batches: less battery20 events or 30 s
DeliveryAt-most-once (never duplicate)At-least-once + server dedupeAt-least-once
FormatJSON: easy to debugProtobuf: smallerJSON + compression
UploadForeground URLSessionBackground URLSessionForeground, plus background task on exit
4xx responseRetryDrop the batchDrop (it will never work)
DependenciesUse librariesFoundation onlyFoundation only

What interviewers look for

  • track is 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.