How to Implement Analytics in SwiftUI
Define a Codable event model, collect events in a Swift actor, then flush them in a batched URLSession POST — no third-party SDK required. Inject the client app-wide via a custom @Environment key.
// 1. Model
struct AnalyticsEvent: Codable {
let name: String
let timestamp: Date
var properties: [String: String] = [:]
}
// 2. Client (actor for thread safety)
actor AnalyticsClient {
func track(_ event: AnalyticsEvent) async { ... }
func flush() async throws { ... }
}
// 3. Use in a view
struct HomeView: View {
@Environment(\.analytics) private var analytics
var body: some View {
Color.clear.task {
await analytics.track(.init(name: "home_viewed", timestamp: .now))
}
}
}
Full implementation
The architecture below separates concerns cleanly: a Codable event model carries structured data, a Swift actor batches events in memory and flushes them over URLSession, and a custom EnvironmentValues extension makes the client injectable anywhere in the SwiftUI tree. Batching avoids hammering your endpoint on every tap, and the actor guarantees safe concurrent access without manual locking.
import SwiftUI
// MARK: - Event Model
struct AnalyticsEvent: Codable, Sendable {
let name: String
let timestamp: Date
var properties: [String: String]
init(name: String, timestamp: Date = .now, properties: [String: String] = [:]) {
self.name = name
self.timestamp = timestamp
self.properties = properties
}
}
// MARK: - Client
actor AnalyticsClient {
private var queue: [AnalyticsEvent] = []
private let endpoint: URL
private let session: URLSession
init(endpoint: URL, session: URLSession = .shared) {
self.endpoint = endpoint
self.session = session
}
func track(_ event: AnalyticsEvent) {
queue.append(event)
if queue.count >= 20 {
Task { try? await flush() }
}
}
func flush() async throws {
guard !queue.isEmpty else { return }
let batch = queue
queue.removeAll()
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let body = try encoder.encode(batch)
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = body
let (_, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
// Re-queue on failure so events aren't lost
queue.insert(contentsOf: batch, at: 0)
throw URLError(.badServerResponse)
}
}
}
// MARK: - Environment Key
private struct AnalyticsClientKey: EnvironmentKey {
static let defaultValue = AnalyticsClient(
endpoint: URL(string: "https://example.com/analytics")!
)
}
extension EnvironmentValues {
var analytics: AnalyticsClient {
get { self[AnalyticsClientKey.self] }
set { self[AnalyticsClientKey.self] = newValue }
}
}
// MARK: - App Entry
@main
struct AnalyticsDemoApp: App {
private let client = AnalyticsClient(
endpoint: URL(string: "https://api.myapp.com/v1/events")!
)
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.analytics, client)
}
}
}
// MARK: - Example View
struct ContentView: View {
@Environment(\.analytics) private var analytics
var body: some View {
NavigationStack {
List {
Button("Purchase") {
Task {
await analytics.track(.init(
name: "purchase_tapped",
properties: ["plan": "pro"]
))
}
}
NavigationLink("Settings", destination: SettingsView())
}
.navigationTitle("Home")
.task {
await analytics.track(.init(name: "home_screen_viewed"))
}
}
}
}
struct SettingsView: View {
@Environment(\.analytics) private var analytics
var body: some View {
Form { Text("Settings") }
.navigationTitle("Settings")
.task {
await analytics.track(.init(name: "settings_screen_viewed"))
}
}
}
// MARK: - Preview
#Preview {
ContentView()
.environment(
\.analytics,
AnalyticsClient(endpoint: URL(string: "https://example.com")!)
)
}
How it works
- Codable event model —
AnalyticsEventconforms to bothCodableandSendable, so it serialises cleanly withJSONEncoderand crosses actor boundaries without warnings. The.iso8601date strategy means timestamps are human-readable in your backend logs. - Actor-based batching —
AnalyticsClientis anactor, so thequeuearray is protected from concurrent writes automatically. When the queue hits 20 events it self-triggers aflush(), but you can also callflush()manually on app backgrounding. - URLSession POST —
flush()encodes the entire batch array as a JSON array and sends a single HTTP POST. On a non-2xx response the batch is re-inserted at the front of the queue so no events are silently dropped. - EnvironmentKey injection —
AnalyticsClientKeyfollows the iOS 17+EnvironmentKeypattern. One.environment(\.analytics, client)call at the root propagates the live client to every child view, while the default value in previews keeps them self-contained. - Tracking at the right moment — Screen views use
.task { }rather than.onAppear, because.taskis async-aware and its lifetime is tied to the view, automatically cancelling if the view disappears before the call resolves.
Variants
Flush on app background
// In your @main App struct, observe scene phase changes
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.analytics, client)
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
Task {
// Give URLSession time before the process suspends
try? await client.flush()
}
}
}
}
Offline persistence with SwiftData
If you need events to survive process kills, persist the queue array to a @Model class via SwiftData. On next launch, load undelivered events back into the actor's queue before accepting new ones. This pairs naturally with the re-queue-on-failure pattern already in flush(), giving you at-least-once delivery guarantees without an external library.
Common pitfalls
- iOS 16 actor isolation changes: The
actorkeyword andSendableenforcement used here require Swift 5.7+ / iOS 16+. On iOS 17+ strict concurrency warnings become errors — ensureAnalyticsEventis markedSendableor you'll hit a compile error when callingtrack()from a@MainActorview. - Calling track() without await: Forgetting
awaitin button actions causes a compile error — always wrap in aTask { await analytics.track(...) }when you're in a synchronous context like a button closure. - Flooding your endpoint in previews: The default
EnvironmentKeyvalue points to a real URL. Override it in#Previewwith a localhost or no-op URL — or subclassURLProtocolto intercept requests — so canvas renders don't pollute your production event stream.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement analytics in SwiftUI for iOS 17+. Use URLSession and Codable. Make it accessible (VoiceOver labels). Add a #Preview with realistic sample data.
Drop this prompt into the Soarias Build phase after scaffolding your screens — Claude Code will wire the AnalyticsClient actor and environment injection directly into your existing App entry point without disrupting navigation or SwiftData models already in place.
Related
FAQ
Does this work on iOS 16?
Yes, with minor adjustments. The actor keyword is available from Swift 5.5 / iOS 15+, and EnvironmentKey has existed since SwiftUI 1.0. The one iOS 17-specific piece is strict concurrency checking — on iOS 16 targets you can remove the Sendable conformance and it will compile cleanly, though you'll lose the compile-time data-race safety guarantees.
How do I add user identity (user ID, session ID) to every event?
Store a userId: String? and sessionId: String on the AnalyticsClient actor and merge them into every event's properties inside track() before appending to the queue. Because the actor protects its state, you can safely call await client.setUserId("abc123") at login without any extra synchronisation.
What is the UIKit equivalent?
In UIKit you'd typically store the client as a singleton (AnalyticsClient.shared) or inject it via initialiser into each UIViewController. The SwiftUI @Environment approach is cleaner because it avoids global state and makes preview isolation trivial — but the underlying actor, URLSession, and Codable logic is identical and could be shared across a SwiftUI + UIKit mixed codebase.
Last reviewed: 2026-05-11 by the Soarias team.