iOS (Swift)
URLSession, install_id in the Keychain, an offline queue, and the session lifecycle for SwiftUI and UIKit.
A recipe for iOS 17+ and Swift 6 with no third-party dependencies. It consists of four files: the
event model, install_id in the Keychain, an offline queue that sends through URLSession, and a
session tracker that hooks into the app lifecycle. On macOS the code is the same, except for
platform: "macos", the macOS platform key, and NSApplication notifications. On Apple TV the
code is the same with the iOS key: device_type becomes tv and os_name becomes tvOS on their
own, and the queue file lives in Caches, which the system may clear when space runs low (see
TVs, watches and tablets).
Embed the iOS platform key from the Integration tab. If your app also ships on other platforms,
it's one app with several platforms: each has its own key, and a person with the same user_id on
iOS and Android is counted once — see Event platform.
Where to create the tracker, why the app rather than a screen owns the session, and why extensions and widgets don't send sessions — see Where to initialize analytics.
Model and sending
JSON keys are snake_case, and times are RFC 3339 with milliseconds. Every event carries its own
context: if an event sat in the queue until the app was updated, it goes out with the old version.
import Foundation
import UIKit
struct EventContext: Codable, Sendable {
var platform = "ios"
var osName: String
var osVersion: String
var appVersion: String
var appBuild: String
var deviceModel: String
var deviceType: String
var locale: String
var environment: String
}
struct Event: Codable, Sendable {
var insertId = UUID().uuidString.lowercased()
var name: String
var timestamp = Date.now
var installId: String
var sessionId: String
var userId: String?
var durationMs: Int?
var context: EventContext
}
struct IngestResult: Decodable, Sendable {
struct Rejection: Decodable, Sendable {
let index: Int
let insertId: String?
let code: String
let retryable: Bool
}
let accepted: Int
let rejected: [Rejection]
}
enum Analytics {
static let endpoint = URL(string: "https://i.analytics.example.com/v1/events")!
static let writeKey = "pk_…" // iOS platform key from the Integration tab
struct Response: Sendable {
let status: Int
let result: IngestResult?
let retryAfter: TimeInterval?
}
static func send(_ events: [Event]) async throws -> Response {
struct Batch: Encodable { let sentAt: Date; let events: [Event] }
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.dateEncodingStrategy = .custom { date, encoder in
var container = encoder.singleValueContainer()
try container.encode(date.formatted(.iso8601WithMilliseconds))
}
var request = URLRequest(url: endpoint, timeoutInterval: 15)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(writeKey, forHTTPHeaderField: "X-App-Key")
// sent_at is the moment of this attempt: the server uses it to correct a skewed device clock.
request.httpBody = try encoder.encode(Batch(sentAt: .now, events: events))
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) }
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return Response(
status: http.statusCode,
result: http.statusCode == 200 ? try? decoder.decode(IngestResult.self, from: data) : nil,
retryAfter: http.value(forHTTPHeaderField: "Retry-After").flatMap(TimeInterval.init)
)
}
@MainActor static func currentContext() -> EventContext {
let info = Bundle.main.infoDictionary ?? [:]
#if DEBUG
let environment = "development"
#else
let environment = "production"
#endif
let deviceType = switch UIDevice.current.userInterfaceIdiom {
case .pad: "tablet"
case .tv: "tv" // Apple TV: systemName is already "tvOS"
default: "phone"
}
return EventContext(
osName: UIDevice.current.systemName,
osVersion: UIDevice.current.systemVersion,
appVersion: info["CFBundleShortVersionString"] as? String ?? "",
appBuild: info["CFBundleVersion"] as? String ?? "",
deviceModel: hardwareModel(),
deviceType: deviceType,
locale: Locale.current.identifier.replacingOccurrences(of: "_", with: "-"),
environment: environment
)
}
private static func hardwareModel() -> String {
var systemInfo = utsname()
uname(&systemInfo)
return withUnsafeBytes(of: &systemInfo.machine) { raw in
String(decoding: raw.prefix(while: { $0 != 0 }), as: UTF8.self)
}
}
}
extension FormatStyle where Self == Date.ISO8601FormatStyle {
static var iso8601WithMilliseconds: Self { .init(includingFractionalSeconds: true) }
}deviceModel is a machine name like iPhone17,3, not the device name the user set: that name often
contains the person's own name.
install_id in the Keychain
install_id is created once and stored in the Keychain. Unlike UserDefaults, a Keychain item
survives deleting and reinstalling the app, so a reinstall doesn't create a new user.
ThisDeviceOnly keeps the identifier from moving to a new device via a backup: a new phone is a new
install, as in AppMetrica.
import Foundation
import Security
enum InstallID {
private static let service = "online.appmetrica.install-id"
static let current: String = load() ?? create()
private static func load() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let id = String(data: data, encoding: .utf8),
UUID(uuidString: id) != nil
else { return nil }
return id
}
private static func create() -> String {
let id = UUID().uuidString.lowercased()
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecValueData as String: Data(id.utf8),
]
SecItemAdd(attributes as CFDictionary, nil)
return id
}
}AfterFirstUnlock lets the identifier be read even when the app launches in the background while
the screen is locked.
Offline queue
OfflineQueue stores events in a file in Application Support and sends them in batches of up to
500. The action for each server response follows Errors and retries:
200— remove the sent events, except those rejected withretryable: true;400,413,415— drop the batch: retrying won't help;401,403— stop sending until the next launch: the key was revoked or the app is archived;429,503— retry afterRetry-After;- anything else and network errors — retry with a delay from 1 s to 300 s.
import Foundation
actor OfflineQueue {
static let shared = OfflineQueue()
private static let maxAge: TimeInterval = 7 * 24 * 3600
// tvOS gives apps no persistent storage besides the Keychain and UserDefaults.
#if os(tvOS)
private let fileURL = URL.cachesDirectory.appending(path: "appmetrica-queue.json")
#else
private let fileURL = URL.applicationSupportDirectory.appending(path: "appmetrica-queue.json")
#endif
private var events: [Event]
private var sending = false
private var stopped = false
private var retryAt = Date.distantPast
private var backoff: TimeInterval = 1
private init() {
let data = try? Data(contentsOf: fileURL)
events = data.flatMap { try? JSONDecoder().decode([Event].self, from: $0) } ?? []
}
func enqueue(_ event: Event) {
events.append(event)
save()
}
func flush() async {
events.removeAll { $0.timestamp < .now.addingTimeInterval(-Self.maxAge) }
guard !sending, !stopped, !events.isEmpty, Date.now >= retryAt else { return }
sending = true
defer { sending = false; save() }
let batch = Array(events.prefix(500))
let sent = Set(batch.map(\.insertId))
do {
let response = try await Analytics.send(batch)
switch response.status {
case 200:
let retry = (response.result?.rejected ?? []).filter(\.retryable).compactMap(\.insertId)
let done = sent.subtracting(retry)
events.removeAll { done.contains($0.insertId) }
backoff = 1
retryAt = retry.isEmpty ? .distantPast : .now.addingTimeInterval(60)
case 400, 413, 415:
events.removeAll { sent.contains($0.insertId) }
case 401, 403:
stopped = true
case 429, 503:
retryAt = .now.addingTimeInterval(response.retryAfter ?? 5)
default:
scheduleRetry()
}
} catch {
scheduleRetry()
}
}
private func scheduleRetry() {
retryAt = .now.addingTimeInterval(backoff * .random(in: 0.8...1.2))
backoff = min(backoff * 2, 300)
}
private func save() {
try? FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try? JSONEncoder().encode(events).write(to: fileURL, options: .atomic)
}
}The queue keeps timestamp as is, so an event sent three days later still lands in the report for
the day it happened (see Time and the offline queue).
Session lifecycle
SessionTracker opens a session when the app comes to the foreground and closes it when the app
goes to the background. Duration is measured with systemUptime — a monotonic clock that doesn't
change when the user adjusts the device time.
import Foundation
@MainActor
final class SessionTracker {
static let shared = SessionTracker()
/// Account ID after sign-in; nil if the user isn't signed in. Not an email or phone number.
var userId: String?
private var sessionId: String?
private var startedUptime: TimeInterval = 0
func start() {
guard sessionId == nil else { return }
sessionId = UUID().uuidString.lowercased()
startedUptime = ProcessInfo.processInfo.systemUptime
track("session_start", durationMs: nil)
}
func end() {
guard sessionId != nil else { return }
let elapsed = ProcessInfo.processInfo.systemUptime - startedUptime
track("session_end", durationMs: min(max(Int(elapsed * 1000), 0), 86_400_000))
sessionId = nil
}
private func track(_ name: String, durationMs: Int?) {
guard let sessionId else { return }
let event = Event(
name: name, installId: InstallID.current, sessionId: sessionId,
userId: userId, durationMs: durationMs, context: Analytics.currentContext()
)
Task {
await OfflineQueue.shared.enqueue(event)
await OfflineQueue.shared.flush()
}
}
}Hook the tracker into the app. A session starts in the "active" state and ends in "background":
moving to "inactive" (the notification shade, a system alert) doesn't interrupt the session, and
calling start() again within the same session does nothing.
import SwiftUI
@main
struct NotesApp: App {
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active: SessionTracker.shared.start()
case .background: SessionTracker.shared.end()
default: break
}
}
}
}In the background, the app has little time: if session_end doesn't get out in time, it stays in
the queue file and is sent on the next launch.
user_id after sign-in
After sign-in, set SessionTracker.shared.userId to the account ID from your backend; after
sign-out, set it to nil. The server retroactively attributes the anonymous events from before
sign-in to this user (see Identity).
func didSignIn(account: Account) {
SessionTracker.shared.userId = account.id // "a3f9c2d1-77b4-…", not an email
}Verification
- Run a debug build:
#if DEBUGsends events withenvironment: "development". - Send the app to the background and open it again.
session_startandsession_endappear on the Integration tab, and the session shows up on the Realtime tab. To see them in reports, turn on the development environment.