DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Analytics & Event Tracking Architecture (Production-Grade)

Analytics is where most SwiftUI apps quietly rot.

You start with:

analytics.log("button_tapped")
Enter fullscreen mode Exit fullscreen mode

Six months later you have:

  • duplicate events
  • inconsistent naming
  • missing context
  • analytics in views
  • analytics in ViewModels
  • analytics in services
  • no way to test anything

This post shows how to design clean, scalable analytics architecture in SwiftUI:

  • structured events
  • clear ownership
  • zero UI pollution
  • testable pipelines
  • future-proof design

This is how real products do it.


🧠 The Core Principle

Analytics is a domain concern, not a UI concern.

Views should never decide:

  • what an event is called
  • what properties it has
  • how it’s sent

They only express intent.


🧱 1. Define Events as Types (Not Strings)

Never do:

analytics.log("profile_viewed")
Enter fullscreen mode Exit fullscreen mode

Instead:

enum AnalyticsEvent {
    case profileViewed(userID: String)
    case searchPerformed(query: String)
    case purchaseCompleted(orderID: String, value: Double)
}
Enter fullscreen mode Exit fullscreen mode

This gives:

  • compile-time safety
  • discoverability
  • refactorability

📦 2. Analytics Protocol

protocol AnalyticsTracking {
    func track(_ event: AnalyticsEvent)
}
Enter fullscreen mode Exit fullscreen mode

Concrete implementation:

final class AnalyticsService: AnalyticsTracking {
    func track(_ event: AnalyticsEvent) {
        switch event {
        case .profileViewed(let id):
            send(name: "profile_viewed", props: ["id": id])
        case .searchPerformed(let query):
            send(name: "search_performed", props: ["query": query])
        case .purchaseCompleted(let orderID, let value):
            send(name: "purchase_completed", props: ["order_id": orderID, "value": value])
        }
    }

    private func send(name: String, props: [String: Any]) {
        // Firebase, Segment, custom backend, etc.
    }
}
Enter fullscreen mode Exit fullscreen mode

Mapping happens once, centrally.


🧩 3. Inject Analytics via Dependency Graph

From your AppContainer:

final class AppContainer {
    let analytics: AnalyticsTracking

    init() {
        self.analytics = AnalyticsService()
    }
}
Enter fullscreen mode Exit fullscreen mode

Injected into features:

ProfileViewModel(analytics: app.analytics)
Enter fullscreen mode Exit fullscreen mode

Never use globals.


🧠 4. ViewModels Emit Events, Views Don’t

Bad:

Button("Buy") {
    analytics.track(.purchaseCompleted(...))
}
Enter fullscreen mode Exit fullscreen mode

Good:

Button("Buy") {
    viewModel.buy()
}
Enter fullscreen mode Exit fullscreen mode

Inside ViewModel:

func buy() {
    analytics.track(.purchaseCompleted(orderID: id, value: price))
    // business logic
}
Enter fullscreen mode Exit fullscreen mode

This keeps:

  • UI pure
  • analytics consistent
  • logic centralized

🔄 5. Lifecycle Events

Track in lifecycle hooks:

.onAppear {
    viewModel.onAppear()
}
Enter fullscreen mode Exit fullscreen mode
func onAppear() {
    analytics.track(.profileViewed(userID: userID))
}
Enter fullscreen mode Exit fullscreen mode

Never embed analytics directly in the view.


🧭 6. Screen Tracking

Model screens as types:

enum Screen {
    case home
    case profile
    case settings
}
Enter fullscreen mode Exit fullscreen mode
func trackScreen(_ screen: Screen) {
    analytics.track(.screenViewed(screen))
}
Enter fullscreen mode Exit fullscreen mode

Call from:

  • navigation transitions
  • feature entry points
  • containers

Not from every view.


🧬 7. Context Injection

Your analytics service should enrich automatically:

func send(name: String, props: [String: Any]) {
    var enriched = props
    enriched["app_version"] = appVersion
    enriched["user_id"] = session.userID
    enriched["platform"] = platform
    // send enriched
}
Enter fullscreen mode Exit fullscreen mode

Views never care about context.


🧪 8. Testing Analytics

final class MockAnalytics: AnalyticsTracking {
    var events: [AnalyticsEvent] = []

    func track(_ event: AnalyticsEvent) {
        events.append(event)
    }
}
Enter fullscreen mode Exit fullscreen mode
func testProfileViewTracksEvent() {
    let analytics = MockAnalytics()
    let vm = ProfileViewModel(analytics: analytics)

    vm.onAppear()

    XCTAssertEqual(analytics.events.count, 1)
}
Enter fullscreen mode Exit fullscreen mode

No SDKs. No mocks. No hacks.


🔁 9. Avoiding Double-Fires

Never track in:

  • body
  • computed properties
  • initializers

Only track in:

  • user actions
  • lifecycle events
  • explicit intent points

SwiftUI re-renders constantly — analytics must not.


⚠️ 10. GDPR, Privacy & PII

Never send:

  • emails
  • names
  • phone numbers
  • raw IDs

Always:

  • hash identifiers
  • anonymize data
  • respect opt-out
  • centralize filtering in analytics layer

Architecture should enforce compliance.


❌ 11. Common Anti-Patterns

Avoid:

  • analytics in views
  • string-based events
  • direct SDK calls in UI
  • copy-paste event names
  • tracking in body
  • mixing analytics with business logic
  • “just log everything”

This creates noise, not insight.


🧠 Mental Model

Think in layers:

View (intent)
  ViewModel (meaning)
    Analytics Service (translation)
      SDK / Backend
Enter fullscreen mode Exit fullscreen mode

Each layer has one responsibility.


🚀 Final Thoughts

A real analytics architecture gives you:

  • clean data
  • consistent events
  • easy testing
  • safe refactors
  • reliable dashboards
  • trust in your metrics

Once analytics is clean, product decisions become easy.

Top comments (0)