```html SwiftUI: How to Implement Analytics (iOS 17+, 2026)

How to Implement Analytics in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: URLSession, Codable Updated: May 11, 2026
TL;DR

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

  1. Codable event modelAnalyticsEvent conforms to both Codable and Sendable, so it serialises cleanly with JSONEncoder and crosses actor boundaries without warnings. The .iso8601 date strategy means timestamps are human-readable in your backend logs.
  2. Actor-based batchingAnalyticsClient is an actor, so the queue array is protected from concurrent writes automatically. When the queue hits 20 events it self-triggers a flush(), but you can also call flush() manually on app backgrounding.
  3. URLSession POSTflush() 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.
  4. EnvironmentKey injectionAnalyticsClientKey follows the iOS 17+ EnvironmentKey pattern. 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.
  5. Tracking at the right moment — Screen views use .task { } rather than .onAppear, because .task is 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

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.

```