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>
1055 lines
31 KiB
Markdown
1055 lines
31 KiB
Markdown
# 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`
|