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

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`