Files
honeyDueKMP/iosApp/PostHog-iOS-Integration-Guide.md
Trey t 2fc4a48fc9 Replace PostHog integration with AnalyticsManager architecture
Remove old PostHogAnalytics singleton and replace with guide-based
two-file architecture: AnalyticsManager (singleton wrapper with super
properties, session replay, opt-out, subscription funnel) and
AnalyticsEvent (type-safe enum with associated values).

Key changes:
- New API key, self-hosted analytics endpoint
- All 19 events ported to type-safe AnalyticsEvent enum
- Screen tracking via AnalyticsManager.Screen enum + SwiftUI modifier
- Remove all identify() calls — fully anonymous analytics
- Add lifecycle hooks: flush on background, update super properties on foreground
- Add privacy opt-out toggle in Settings
- Subscription funnel methods ready for IAP integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:48:49 -06:00

31 KiB

PostHog iOS Integration Guide

A drop-in reference for integrating PostHog analytics into any iOS/SwiftUI app. Derived from production implementations across multiple shipped apps.


Table of Contents

  1. Installation
  2. Architecture Overview
  3. AnalyticsManager Setup
  4. Event Definitions
  5. Screen Tracking
  6. App Lifecycle Integration
  7. Super Properties
  8. Subscription Funnel Tracking
  9. User Identification & Person Properties
  10. Session Recording
  11. Privacy & Opt-Out
  12. Feature Flags
  13. Debug Configuration
  14. Event Naming Conventions
  15. Checklist

1. Installation

Swift Package Manager (SPM)

In Xcode: File > Add Package Dependencies

https://github.com/PostHog/posthog-ios.git
  • Use Up to Next Major Version (e.g., 3.41.0)
  • Add PostHog framework to your iOS app target only (not widgets, watch, or extension targets)

Verify Installation

After adding, confirm the dependency appears in your Package.resolved:

{
  "identity": "posthog-ios",
  "kind": "remoteSourceControl",
  "location": "https://github.com/PostHog/posthog-ios.git",
  "state": {
    "version": "3.41.0"
  }
}

Import

import PostHog

2. Architecture Overview

The integration follows a three-file architecture:

YourApp/
  Core/
    Analytics/
      AnalyticsManager.swift   // Singleton wrapper around PostHog SDK
      AnalyticsEvent.swift     // Type-safe event enum

Design principles:

  • Single abstraction layer - All PostHog calls go through AnalyticsManager. No direct PostHogSDK calls scattered through the codebase.
  • Type-safe events - A Swift enum prevents string typos, provides autocomplete, and documents all tracked events in one place.
  • @MainActor singleton - Thread-safe by design.
  • Privacy-first - Opt-out support, text masking, anonymous by default.

3. AnalyticsManager Setup

Create AnalyticsManager.swift:

import Foundation
import PostHog
import UIKit

@MainActor
final class AnalyticsManager {

    // MARK: - Singleton

    static let shared = AnalyticsManager()
    private init() {}

    // MARK: - Configuration

    // Replace with your PostHog project API key
    private static let apiKey = "phc_YOUR_API_KEY"

    // PostHog Cloud: "https://us.i.posthog.com" or "https://eu.i.posthog.com"
    // Self-hosted: "https://your-posthog-instance.com"
    private static let host = "https://us.i.posthog.com"

    private static let optOutKey = "analyticsOptedOut"
    private static let sessionReplayKey = "analytics_session_replay_enabled"

    private var isConfigured = false

    // MARK: - ISO8601 Formatter (for date properties)

    private static let iso8601Formatter: ISO8601DateFormatter = {
        let f = ISO8601DateFormatter()
        f.formatOptions = [.withInternetDateTime]
        return f
    }()

    // MARK: - Setup

    func configure() {
        guard !isConfigured else { return }

        let config = PostHogConfig(apiKey: Self.apiKey, host: Self.host)

        // Auto-capture
        config.captureElementInteractions = true
        config.captureApplicationLifecycleEvents = true
        config.captureScreenViews = true

        // Session replay
        config.sessionReplay = sessionReplayEnabled
        config.sessionReplayConfig.maskAllTextInputs = true
        config.sessionReplayConfig.maskAllImages = false
        config.sessionReplayConfig.captureNetworkTelemetry = true
        config.sessionReplayConfig.screenshotMode = true // Required for SwiftUI

        // Respect user opt-out preference
        if isOptedOut {
            config.optOut = true
        }

        // Debug configuration
        #if DEBUG
        config.debug = true
        config.flushAt = 1 // Flush every event immediately in debug
        #endif

        PostHogSDK.shared.setup(config)
        isConfigured = true

        // Register initial super properties
        updateSuperProperties()
    }

    // MARK: - Event Tracking

    /// Track a type-safe analytics event
    func track(_ event: AnalyticsEvent) {
        guard isConfigured else { return }
        let (name, properties) = event.payload
        #if DEBUG
        print("[Analytics] \(name)", properties ?? [:])
        #endif
        PostHogSDK.shared.capture(name, properties: properties)
    }

    // MARK: - Screen Tracking

    /// Track a screen view using the Screen enum
    func trackScreen(_ screen: Screen, properties: [String: Any]? = nil) {
        guard isConfigured else { return }
        var props: [String: Any] = ["screen_name": screen.rawValue]
        if let properties { props.merge(properties) { _, new in new } }
        #if DEBUG
        print("[Analytics] screen_viewed: \(screen.rawValue)")
        #endif
        PostHogSDK.shared.capture("screen_viewed", properties: props)
    }

    // MARK: - Flush & Reset

    /// Force flush pending events (call on app background)
    func flush() {
        guard isConfigured else { return }
        PostHogSDK.shared.flush()
    }

    /// Reset device identity and cached properties
    func reset() {
        guard isConfigured else { return }
        PostHogSDK.shared.reset()
    }

    // MARK: - Super Properties

    /// Register properties attached to every subsequent event.
    /// Call on configure() and on every app foreground.
    func updateSuperProperties() {
        let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
        let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
        let device = UIDevice.current.model
        let osVersion = UIDevice.current.systemVersion

        // Add your app-specific properties here
        var props: [String: Any] = [
            "app_version": version,
            "build_number": build,
            "device_model": device,
            "os_version": osVersion,
            "is_pro": false, // Replace with your subscription check
            "animations_enabled": !UIAccessibility.isReduceMotionEnabled,
        ]

        // Example: add user preferences from UserDefaults
        // props["theme"] = UserDefaults.standard.string(forKey: "theme") ?? "default"

        PostHogSDK.shared.register(props)
    }

    // MARK: - Subscription Funnel

    func trackPaywallViewed(source: String) {
        guard isConfigured else { return }
        PostHogSDK.shared.capture("paywall_viewed", properties: ["source": source])
    }

    func trackPurchaseStarted(productId: String, source: String) {
        guard isConfigured else { return }
        PostHogSDK.shared.capture("purchase_started", properties: [
            "product_id": productId,
            "source": source,
        ])
    }

    func trackPurchaseCompleted(productId: String, source: String) {
        guard isConfigured else { return }
        PostHogSDK.shared.capture("purchase_completed", properties: [
            "product_id": productId,
            "source": source,
        ])
    }

    func trackPurchaseFailed(productId: String?, source: String, error: String) {
        guard isConfigured else { return }
        var props: [String: Any] = ["source": source, "error": error]
        if let productId { props["product_id"] = productId }
        PostHogSDK.shared.capture("purchase_failed", properties: props)
    }

    func trackPurchaseRestored(source: String) {
        guard isConfigured else { return }
        PostHogSDK.shared.capture("purchase_restored", properties: ["source": source])
    }

    // MARK: - Subscription Status (Person Properties)

    func trackSubscriptionStatusObserved(
        status: String,
        type: String,
        source: String,
        isSubscribed: Bool,
        hasFullAccess: Bool,
        productId: String?,
        willAutoRenew: Bool?,
        isInGracePeriod: Bool?,
        trialDaysRemaining: Int?,
        expirationDate: Date?
    ) {
        guard isConfigured else { return }

        var props: [String: Any] = [
            "status": status,
            "type": type,
            "source": source,
            "is_subscribed": isSubscribed,
            "has_full_access": hasFullAccess,
        ]

        if let productId { props["product_id"] = productId }
        if let willAutoRenew { props["will_auto_renew"] = willAutoRenew }
        if let isInGracePeriod { props["is_in_grace_period"] = isInGracePeriod }
        if let trialDaysRemaining { props["trial_days_remaining"] = trialDaysRemaining }
        if let expirationDate { props["expiration_date"] = Self.iso8601Formatter.string(from: expirationDate) }

        PostHogSDK.shared.capture("subscription_status_changed", properties: props)
        updateSubscriptionPersonProperties(status, type: type)
    }

    /// Set person-level subscription properties (persist across sessions)
    private func updateSubscriptionPersonProperties(_ status: String, type: String) {
        guard isConfigured else { return }
        PostHogSDK.shared.capture("$set", properties: [
            "$set": [
                "subscription_status": status,
                "subscription_type": type,
            ],
        ])
    }

    // MARK: - Opt-Out / Opt-In

    var isOptedOut: Bool {
        UserDefaults.standard.bool(forKey: Self.optOutKey)
    }

    func optIn() {
        UserDefaults.standard.set(false, forKey: Self.optOutKey)
        if isConfigured {
            PostHogSDK.shared.optIn()
            PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": true])
        }
    }

    func optOut() {
        if isConfigured {
            PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": false])
        }
        UserDefaults.standard.set(true, forKey: Self.optOutKey)
        if isConfigured {
            PostHogSDK.shared.optOut()
        }
    }

    // MARK: - Session Replay Control

    var sessionReplayEnabled: Bool {
        get {
            if UserDefaults.standard.object(forKey: Self.sessionReplayKey) == nil {
                return true // Enabled by default
            }
            return UserDefaults.standard.bool(forKey: Self.sessionReplayKey)
        }
        set {
            UserDefaults.standard.set(newValue, forKey: Self.sessionReplayKey)
            if newValue {
                PostHogSDK.shared.startSessionRecording()
            } else {
                PostHogSDK.shared.stopSessionRecording()
            }
        }
    }
}

4. Event Definitions

Create AnalyticsEvent.swift as a separate file. This keeps event definitions discoverable, provides autocomplete, and prevents event name typos.

import Foundation

enum AnalyticsEvent {

    // MARK: - Onboarding
    case onboardingCompleted
    case onboardingSkipped

    // MARK: - Navigation
    case tabSwitched(tab: String, previousTab: String?)

    // MARK: - Core Feature (replace with your domain events)
    // case itemCreated(itemId: String, type: String)
    // case itemDeleted(itemId: String)
    // case itemUpdated(itemId: String, field: String)

    // MARK: - Settings
    case themeChanged(from: String, to: String)
    case animationsToggled(enabled: Bool)
    case settingsReset
    case analyticsToggled(enabled: Bool)

    // MARK: - IAP (tracked via AnalyticsManager methods, not this enum)
    // paywall_viewed, purchase_started, purchase_completed, etc.

    // MARK: - Errors
    case errorOccurred(domain: String, message: String, screen: String?)

    // MARK: - Payload

    /// Returns (eventName, properties) tuple for PostHog capture
    var payload: (String, [String: Any]?) {
        switch self {

        // Onboarding
        case .onboardingCompleted:
            return ("onboarding_completed", nil)
        case .onboardingSkipped:
            return ("onboarding_skipped", nil)

        // Navigation
        case .tabSwitched(let tab, let previousTab):
            var props: [String: Any] = ["tab": tab]
            if let previousTab { props["previous_tab"] = previousTab }
            return ("tab_switched", props)

        // Settings
        case .themeChanged(let from, let to):
            return ("theme_changed", ["from": from, "to": to])
        case .animationsToggled(let enabled):
            return ("animations_toggled", ["enabled": enabled])
        case .settingsReset:
            return ("settings_reset", nil)
        case .analyticsToggled(let enabled):
            return ("analytics_toggled", ["enabled": enabled])

        // Errors
        case .errorOccurred(let domain, let message, let screen):
            var props: [String: Any] = ["domain": domain, "message": message]
            if let screen { props["screen"] = screen }
            return ("error_occurred", props)
        }
    }
}

Usage

// Type-safe, autocomplete-friendly
AnalyticsManager.shared.track(.tabSwitched(tab: "home", previousTab: "settings"))
AnalyticsManager.shared.track(.themeChanged(from: "light", to: "dark"))
AnalyticsManager.shared.track(.errorOccurred(domain: "network", message: "timeout", screen: "home"))

Adding New Events

  1. Add a case to AnalyticsEvent
  2. Add the payload mapping in the switch
  3. Call AnalyticsManager.shared.track(.yourEvent(...))

That's it. No strings to get wrong.


5. Screen Tracking

Screen Enum

Add to AnalyticsManager.swift (or a separate file):

extension AnalyticsManager {
    enum Screen: String {
        case home = "home"
        case settings = "settings"
        case onboarding = "onboarding"
        case paywall = "paywall"
        case detail = "detail"
        // Add your screens here
    }
}

SwiftUI ViewModifier

Add to AnalyticsManager.swift:

// MARK: - SwiftUI Screen Tracking Modifier

struct ScreenTrackingModifier: ViewModifier {
    let screen: AnalyticsManager.Screen
    let properties: [String: Any]?

    func body(content: Content) -> some View {
        content.onAppear {
            AnalyticsManager.shared.trackScreen(screen, properties: properties)
        }
    }
}

extension View {
    /// Track a screen view when this view appears
    func trackScreen(_ screen: AnalyticsManager.Screen, properties: [String: Any]? = nil) -> some View {
        modifier(ScreenTrackingModifier(screen: screen, properties: properties))
    }
}

Usage

struct SettingsView: View {
    var body: some View {
        List {
            // ...
        }
        .trackScreen(.settings)
    }
}

// With extra properties
struct PaywallView: View {
    let source: String

    var body: some View {
        VStack {
            // ...
        }
        .trackScreen(.paywall, properties: ["source": source])
    }
}

Note on Auto-Capture

config.captureScreenViews = true auto-captures SwiftUI view transitions as $screen events. The manual .trackScreen() modifier gives you named, queryable screen events (screen_viewed with screen_name). Use both - auto-capture catches everything, manual tracking gives you clean data.


6. App Lifecycle Integration

Wire analytics into your @main App struct:

@main
struct YourApp: App {
    @Environment(\.scenePhase) private var scenePhase

    init() {
        // Configure analytics early in app launch
        AnalyticsManager.shared.configure()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .onChange(of: scenePhase) { _, newPhase in
            switch newPhase {
            case .active:
                // Refresh super properties (subscription status, settings may have changed)
                AnalyticsManager.shared.updateSuperProperties()

            case .background:
                // Flush pending events before app suspends
                AnalyticsManager.shared.flush()

            case .inactive:
                break

            @unknown default:
                break
            }
        }
    }
}

Why this matters:

Lifecycle Event Action Reason
init() configure() Analytics ready before first view appears
.active updateSuperProperties() Subscription status or settings may have changed while backgrounded
.background flush() Ensure pending events are sent before the OS suspends the app

7. Super Properties

Super properties are key-value pairs attached to every event automatically. Use them for segmentation dimensions you always want available.

What to Include

Property Source Why
app_version Bundle.main Correlate issues to releases
build_number Bundle.main Distinguish TestFlight builds
device_model UIDevice.current.model Device-specific issues
os_version UIDevice.current.systemVersion OS-specific behavior
is_pro Your IAP manager Segment free vs paid users
animations_enabled UIAccessibility.isReduceMotionEnabled Accessibility insights
App-specific settings UserDefaults Understand user preferences

Registration Pattern

// Called in configure() and on every foreground
func updateSuperProperties() {
    let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
    let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"

    PostHogSDK.shared.register([
        "app_version": version,
        "build_number": build,
        "device_model": UIDevice.current.model,
        "os_version": UIDevice.current.systemVersion,
        "is_pro": YourIAPManager.shared.isSubscribed,
        "animations_enabled": !UIAccessibility.isReduceMotionEnabled,
        // Add your app-specific properties:
        // "selected_theme": UserDefaults.standard.string(forKey: "theme") ?? "default",
    ])
}

Rules

  • Only include low-cardinality values (booleans, enums, short strings). Don't register user IDs or timestamps.
  • Call updateSuperProperties() on every foreground since values can change while backgrounded.
  • Use PostHogSDK.shared.register() (not registerOnce). You want fresh values every time.

8. Subscription Funnel Tracking

Track the full purchase funnel as discrete events. This gives you conversion rates at each step.

Funnel Events

paywall_viewed → purchase_started → purchase_completed
                                  → purchase_failed
                                  → (user cancelled)
purchase_restored (separate flow)

Implementation Pattern

In your paywall/store view:

// 1. When paywall appears
.onAppear {
    AnalyticsManager.shared.trackPaywallViewed(source: "settings")
}

// 2. When user taps subscribe
func subscribe(product: Product) async {
    AnalyticsManager.shared.trackPurchaseStarted(productId: product.id, source: "paywall")

    do {
        let result = try await product.purchase()
        switch result {
        case .success(let verification):
            // Verify transaction...
            AnalyticsManager.shared.trackPurchaseCompleted(productId: product.id, source: "paywall")
        case .userCancelled:
            AnalyticsManager.shared.trackPurchaseFailed(
                productId: product.id, source: "paywall", error: "user_cancelled"
            )
        case .pending:
            AnalyticsManager.shared.trackPurchaseFailed(
                productId: product.id, source: "paywall", error: "pending"
            )
        @unknown default:
            break
        }
    } catch {
        AnalyticsManager.shared.trackPurchaseFailed(
            productId: product.id, source: "paywall", error: error.localizedDescription
        )
    }
}

// 3. Restore purchases
func restore() async {
    // ... restore logic
    AnalyticsManager.shared.trackPurchaseRestored(source: "settings")
}

Subscription Status Tracking

Track detailed subscription state on every app foreground and after purchases. This lets you build subscription dashboards.

// In your IAP/Store manager, call on foreground and after purchase events:
func trackSubscriptionAnalytics(source: String) {
    AnalyticsManager.shared.trackSubscriptionStatusObserved(
        status: "subscribed",           // "subscribed", "free", "expired", "billing_retry", "grace_period", "revoked"
        type: "monthly",                // "monthly", "yearly", "none"
        source: source,                 // "app_foreground", "purchase_success", "restore"
        isSubscribed: true,
        hasFullAccess: true,
        productId: "com.app.monthly",
        willAutoRenew: true,
        isInGracePeriod: false,
        trialDaysRemaining: nil,
        expirationDate: Date().addingTimeInterval(30 * 24 * 60 * 60)
    )
}

Source values to use consistently:

Source When
"app_foreground" Status checked on foreground
"purchase_success" Just completed a purchase
"restore" User restored purchases
"entitlements_refresh" Background entitlement check

9. User Identification & Person Properties

Anonymous by Default

PostHog assigns a random distinctId per device. For apps without user accounts, this is sufficient. Do not call identify() unless you have authenticated users.

If You Have User Accounts

// After login - link anonymous events to this user
PostHogSDK.shared.identify(userId, userProperties: [
    "email": email,
    "name": name,
    "created_at": signUpDate.ISO8601Format(),
])

// After logout - generate new anonymous ID
AnalyticsManager.shared.reset()

Person Properties (Without Accounts)

Use $set to attach persistent properties to the anonymous user profile:

PostHogSDK.shared.capture("$set", properties: [
    "$set": [
        "subscription_status": "subscribed",
        "subscription_type": "yearly",
    ],
])

These persist across sessions and are visible in PostHog's person profiles.


10. Session Recording

Configuration

Session replay is configured during configure():

config.sessionReplay = sessionReplayEnabled            // User-controllable
config.sessionReplayConfig.maskAllTextInputs = true     // Privacy: mask passwords, emails, etc.
config.sessionReplayConfig.maskAllImages = false        // Show images for context
config.sessionReplayConfig.captureNetworkTelemetry = true // Debug network issues
config.sessionReplayConfig.screenshotMode = true        // REQUIRED for SwiftUI

Key Settings

Setting Recommended Why
maskAllTextInputs true Prevents capturing passwords, emails, sensitive text
maskAllImages false Images provide useful visual context without PII risk
captureNetworkTelemetry true Correlate UI issues with network failures
screenshotMode true Required for SwiftUI. Without this, session replay won't work.

Runtime Control

Let users toggle session recording:

// Start recording
PostHogSDK.shared.startSessionRecording()

// Stop recording
PostHogSDK.shared.stopSessionRecording()

The AnalyticsManager.sessionReplayEnabled property handles this with UserDefaults persistence.


11. Privacy & Opt-Out

Settings UI

Add a toggle in your Settings view:

private var analyticsToggle: some View {
    Section {
        Toggle(isOn: Binding(
            get: { !AnalyticsManager.shared.isOptedOut },
            set: { enabled in
                if enabled {
                    AnalyticsManager.shared.optIn()
                } else {
                    AnalyticsManager.shared.optOut()
                }
            }
        )) {
            Label {
                VStack(alignment: .leading, spacing: 2) {
                    Text("Share Analytics")
                        .font(.body)
                        .foregroundStyle(.primary)
                    Text("Help improve \(appName) by sharing anonymous usage data")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            } icon: {
                Image(systemName: "chart.bar.xaxis")
            }
        }
    } header: {
        Text("Privacy")
    } footer: {
        Text("No personal data is collected. Analytics are fully anonymous.")
    }
}

Opt-Out Behavior

The opt-out flow is intentionally ordered:

func optOut() {
    // 1. Capture the toggle event BEFORE opting out (so we know they left)
    if isConfigured {
        PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": false])
    }
    // 2. Persist preference
    UserDefaults.standard.set(true, forKey: Self.optOutKey)
    // 3. Tell SDK to stop
    if isConfigured {
        PostHogSDK.shared.optOut()
    }
}

func optIn() {
    // 1. Persist preference
    UserDefaults.standard.set(false, forKey: Self.optOutKey)
    // 2. Tell SDK to resume
    if isConfigured {
        PostHogSDK.shared.optIn()
        // 3. Capture the toggle event AFTER opting in (so we know they returned)
        PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": true])
    }
}

Privacy Checklist

  • Analytics opt-out toggle in Settings
  • Text inputs masked in session replay
  • No PII in event properties (no emails, names, phone numbers)
  • No PII in super properties
  • Privacy policy link in Settings
  • EULA link in Settings
  • Opt-out preference respected on app restart (checked in configure())

12. Feature Flags

PostHog supports feature flags for gradual rollouts and A/B testing. While the reference implementations don't use them yet, here's how to add them:

Basic Usage

// Check a boolean flag
if PostHogSDK.shared.isFeatureEnabled("new-onboarding") {
    showNewOnboarding()
} else {
    showLegacyOnboarding()
}

// Get a flag value (for multivariate flags)
let variant = PostHogSDK.shared.getFeatureFlag("checkout-button-color") as? String

With Loading State

Feature flags are fetched from the server. They may not be available immediately on cold start.

// Reload flags explicitly
PostHogSDK.shared.reloadFeatureFlags {
    let enabled = PostHogSDK.shared.isFeatureEnabled("new-feature")
    // Update UI
}

Tracking Experiments

PostHog auto-tracks $feature_flag_called when you check a flag. For explicit experiment tracking:

PostHogSDK.shared.capture("$feature_flag_called", properties: [
    "$feature_flag": "experiment-name",
    "$feature_flag_response": "variant-a",
])

13. Debug Configuration

DEBUG Build Settings

#if DEBUG
config.debug = true    // Prints all events to Xcode console
config.flushAt = 1     // Sends every event immediately (no batching)
#endif

Console Output

With debug = true, you'll see PostHog SDK logs in the Xcode console:

[PostHog] Captured event: tab_switched {tab: "settings", previous_tab: "home"}

The custom #if DEBUG print statements in the track() method add your own formatted output:

[Analytics] tab_switched ["tab": "settings", "previous_tab": "home"]

Production Behavior

In release builds:

  • debug defaults to false (no console logging)
  • flushAt uses the SDK default (batched, typically 20 events or 30 seconds)
  • Events are queued and sent efficiently

Tip: Separate PostHog Projects

For production apps, use separate PostHog projects for development and production:

#if DEBUG
private static let apiKey = "phc_DEV_KEY"
#else
private static let apiKey = "phc_PROD_KEY"
#endif

This keeps test data out of your production dashboards.


14. Event Naming Conventions

Follow these conventions for consistency across all your apps.

Event Names

Rule Example Bad Example
snake_case trip_planned tripPlanned, Trip Planned
past tense for completed actions mood_logged log_mood
_tapped for UI interactions subscribe_tapped click_subscribe
_viewed for screen/content views paywall_viewed show_paywall
_toggled / _changed for settings theme_changed set_theme
_enabled / _disabled for booleans notification_enabled toggle_notification
_started / _completed / _failed for flows purchase_started begin_purchase

Property Names

Rule Example
snake_case product_id, screen_name
Boolean properties describe the state enabled: true, is_pro: false
IDs end with _id trip_id, stadium_id
Counts end with _count stop_count, character_count
Use source for origin tracking source: "settings", source: "paywall"
Dates use ISO8601 strings expiration_date: "2025-01-15T00:00:00Z"

Event Categories

Group events by domain:

enum AnalyticsEvent {
    // MARK: - Onboarding
    // MARK: - Navigation
    // MARK: - Core Feature
    // MARK: - Settings
    // MARK: - Errors
}

15. Checklist

Initial Setup

  • Add posthog-ios via SPM
  • Create AnalyticsManager.swift with your API key and host
  • Create AnalyticsEvent.swift with initial events
  • Add Screen enum with your app's screens
  • Add ScreenTrackingModifier SwiftUI extension
  • Call AnalyticsManager.shared.configure() in App.init()
  • Add flush() on .background scene phase
  • Add updateSuperProperties() on .active scene phase

Privacy

  • Analytics opt-out toggle in Settings
  • Session replay text masking enabled (maskAllTextInputs = true)
  • screenshotMode = true for SwiftUI
  • Privacy policy and EULA links in Settings
  • No PII in events or super properties
  • Opt-out preference checked during configure()

Events

  • Define all events as AnalyticsEvent enum cases
  • Follow snake_case naming convention
  • Include source property where applicable
  • Track error events with domain, message, and screen
  • Add .trackScreen() modifier to all major views

Subscription Tracking (if applicable)

  • paywall_viewed with source
  • purchase_started with product ID and source
  • purchase_completed with product ID and source
  • purchase_failed with product ID, source, and error
  • purchase_restored with source
  • subscription_status_changed on foreground and after purchase
  • Person properties set for subscription_status and subscription_type

Super Properties

  • app_version and build_number
  • device_model and os_version
  • is_pro (subscription status)
  • App-specific user preferences
  • Updated on every foreground

Debug

  • config.debug = true in DEBUG builds
  • config.flushAt = 1 in DEBUG builds
  • Console logging in track() method with #if DEBUG
  • Consider separate API keys for dev/prod

Auto-Capture

  • captureElementInteractions = true
  • captureApplicationLifecycleEvents = true
  • captureScreenViews = true