# 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](#1-installation) 2. [Architecture Overview](#2-architecture-overview) 3. [AnalyticsManager Setup](#3-analyticsmanager-setup) 4. [Event Definitions](#4-event-definitions) 5. [Screen Tracking](#5-screen-tracking) 6. [App Lifecycle Integration](#6-app-lifecycle-integration) 7. [Super Properties](#7-super-properties) 8. [Subscription Funnel Tracking](#8-subscription-funnel-tracking) 9. [User Identification & Person Properties](#9-user-identification--person-properties) 10. [Session Recording](#10-session-recording) 11. [Privacy & Opt-Out](#11-privacy--opt-out) 12. [Feature Flags](#12-feature-flags) 13. [Debug Configuration](#13-debug-configuration) 14. [Event Naming Conventions](#14-event-naming-conventions) 15. [Checklist](#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`: ```json { "identity": "posthog-ios", "kind": "remoteSourceControl", "location": "https://github.com/PostHog/posthog-ios.git", "state": { "version": "3.41.0" } } ``` ### Import ```swift 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`: ```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. ```swift 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 ```swift // 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): ```swift 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`: ```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 ```swift 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: ```swift @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 ```swift // 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: ```swift // 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. ```swift // 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 ```swift // 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: ```swift 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()`: ```swift 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: ```swift // 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: ```swift 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: ```swift 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 ```swift // 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. ```swift // 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: ```swift PostHogSDK.shared.capture("$feature_flag_called", properties: [ "$feature_flag": "experiment-name", "$feature_flag_response": "variant-a", ]) ``` --- ## 13. Debug Configuration ### DEBUG Build Settings ```swift #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: ```swift #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: ```swift 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`