Files
Reflect/Shared/Analytics.swift
Trey t e0330dbc8d Replace EventLogger with typed AnalyticsManager using PostHog
Complete analytics overhaul: delete EventLogger.swift, create Analytics.swift
with typed event enum (~45 events), screen tracking, super properties
(theme, icon pack, voting layout, etc.), session replay with kill switch,
autocapture, and network telemetry. Replace all 99 call sites across 38 files
with compiler-enforced typed events in object_action naming convention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:12:33 -06:00

626 lines
21 KiB
Swift

//
// Analytics.swift
// Feels
//
// Singleton analytics manager wrapping PostHog SDK.
// All analytics events flow through this single manager.
//
import Foundation
import PostHog
import UIKit
// MARK: - Analytics Manager
@MainActor
final class AnalyticsManager {
// MARK: - Singleton
static let shared = AnalyticsManager()
// MARK: - Configuration
private static let apiKey = "phc_3GsB3oqNft8Ykg2bJfE9MaJktzLAwr2EPMXQgwEFzAs"
private static let host = "https://analytics.88oakapps.com"
private static let optOutKey = "analyticsOptedOut"
private static let sessionReplayKey = "analytics_session_replay_enabled"
private static let iso8601Formatter = ISO8601DateFormatter()
// MARK: - State
var isOptedOut: Bool {
UserDefaults.standard.bool(forKey: Self.optOutKey)
}
var sessionReplayEnabled: Bool {
get {
if UserDefaults.standard.object(forKey: Self.sessionReplayKey) == nil {
return true
}
return UserDefaults.standard.bool(forKey: Self.sessionReplayKey)
}
set {
UserDefaults.standard.set(newValue, forKey: Self.sessionReplayKey)
if newValue {
PostHogSDK.shared.startSessionRecording()
} else {
PostHogSDK.shared.stopSessionRecording()
}
}
}
private var isConfigured = false
// MARK: - Initialization
private init() {}
// 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
}
#if DEBUG
config.debug = true
config.flushAt = 1
#endif
PostHogSDK.shared.setup(config)
isConfigured = true
// Register super properties
updateSuperProperties()
}
// MARK: - Super Properties (attached to every event)
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
let defaults = GroupUserDefaults.groupDefaults
var props: [String: Any] = [
"app_version": version,
"build_number": build,
"device_model": device,
"os_version": osVersion,
"is_pro": IAPManager.shared.isSubscribed,
"animations_enabled": !UIAccessibility.isReduceMotionEnabled,
"selected_sports": [String](),
"theme": "n/a",
"icon_pack": "n/a",
"voting_layout": "n/a",
"day_view_style": "n/a",
"mood_shape": "n/a",
"personality_pack": "n/a",
"privacy_lock_enabled": false,
"healthkit_enabled": false,
"days_filter_count": 0,
"days_filter_all": false,
]
// Theme
if let themeRaw = defaults.string(forKey: UserDefaultsStore.Keys.theme.rawValue) {
props["theme"] = themeRaw
}
// Icon pack
if let iconPackRaw = defaults.object(forKey: UserDefaultsStore.Keys.moodImages.rawValue) as? Int,
let iconPack = MoodImages(rawValue: iconPackRaw) {
props["icon_pack"] = String(describing: iconPack)
}
// Voting layout
if let layoutRaw = defaults.object(forKey: UserDefaultsStore.Keys.votingLayoutStyle.rawValue) as? Int,
let layout = VotingLayoutStyle(rawValue: layoutRaw) {
props["voting_layout"] = layout.displayName
}
// Day view style
if let styleRaw = defaults.object(forKey: UserDefaultsStore.Keys.dayViewStyle.rawValue) as? Int,
let style = DayViewStyle(rawValue: styleRaw) {
props["day_view_style"] = style.displayName
}
// Shape
if let shapeRaw = defaults.object(forKey: UserDefaultsStore.Keys.shape.rawValue) as? Int,
let shape = BGShape(rawValue: shapeRaw) {
props["mood_shape"] = String(describing: shape)
}
// Personality pack
if let packRaw = defaults.object(forKey: UserDefaultsStore.Keys.personalityPack.rawValue) as? Int,
let pack = PersonalityPack(rawValue: packRaw) {
props["personality_pack"] = pack.title()
}
// Privacy lock
props["privacy_lock_enabled"] = defaults.bool(forKey: UserDefaultsStore.Keys.privacyLockEnabled.rawValue)
// HealthKit
props["healthkit_enabled"] = defaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
// Days filter
let daysFilter = UserDefaultsStore.getDaysFilter()
props["days_filter_count"] = daysFilter.count
props["days_filter_all"] = daysFilter.count == 7
PostHogSDK.shared.register(props)
}
func registerSuperProperties() {
updateSuperProperties()
}
// MARK: - Event Tracking
func track(_ event: Event) {
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 (manual supplement to auto-capture)
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: - 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: - Opt In / Opt Out
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: - Person Properties (subscription segmentation)
func updateSubscriptionStatus(_ status: String, type: String) {
guard isConfigured else { return }
PostHogSDK.shared.capture("$set", properties: [
"$set": [
"subscription_status": status,
"subscription_type": type
]
])
}
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)
updateSubscriptionStatus(status, type: type)
}
// MARK: - Lifecycle
func flush() {
guard isConfigured else { return }
PostHogSDK.shared.flush()
}
func reset() {
guard isConfigured else { return }
PostHogSDK.shared.reset()
}
}
// MARK: - Screen Names
extension AnalyticsManager {
enum Screen: String {
case day = "day"
case month = "month"
case monthDetail = "month_detail"
case year = "year"
case insights = "insights"
case settings = "settings"
case customize = "customize"
case onboarding = "onboarding"
case onboardingSubscription = "onboarding_subscription"
case paywall = "paywall"
case entryDetail = "entry_detail"
case noteEditor = "note_editor"
case lockScreen = "lock_screen"
case sharing = "sharing"
case themePicker = "theme_picker"
case iconPackPicker = "icon_pack_picker"
case votingLayoutPicker = "voting_layout_picker"
case dayStylePicker = "day_style_picker"
case shapePicker = "shape_picker"
case personalityPackPicker = "personality_pack_picker"
case appThemePicker = "app_theme_picker"
case dayFilterPicker = "day_filter_picker"
case iconPicker = "icon_picker"
case widgetCreator = "widget_creator"
case widgetPicker = "widget_picker"
case privacy = "privacy"
case eula = "eula"
case specialThanks = "special_thanks"
case reminderTimePicker = "reminder_time_picker"
case exportView = "export_view"
}
}
// MARK: - Event Definitions
extension AnalyticsManager {
enum Event {
// MARK: Mood
case moodLogged(mood: Int, entryType: String)
case moodUpdated(mood: Int)
case noteUpdated(characterCount: Int)
case photoAdded
case photoDeleted
case missingEntriesFilled(count: Int)
// MARK: Customization
case themeChanged(themeId: String)
case themeApplied(themeName: String)
case iconPackChanged(packId: Int)
case votingLayoutChanged(layout: String)
case dayViewStyleChanged(style: String)
case moodShapeChanged(shapeId: Int)
case personalityPackChanged(packTitle: String)
case appIconChanged(iconTitle: String)
// MARK: Widget
case widgetViewed
case widgetCreateTapped
case widgetCreated
case widgetUsed
case widgetDeleted
case widgetShuffled
case widgetRandomized
case widgetEyeUpdated(style: String)
case widgetMouthUpdated(style: String)
case widgetBackgroundUpdated(style: String)
case widgetColorUpdated(part: String)
// MARK: Notifications
case notificationEnabled
case notificationDisabled
case reminderTimeUpdated
case reminderTimeTapped
// MARK: Onboarding
case onboardingCompleted(dayId: String?)
case onboardingSubscribeTapped
case onboardingSkipped
// MARK: Subscription
case paywallSubscribeTapped(source: String)
// MARK: Settings
case settingsClosed
case deleteToggleChanged(enabled: Bool)
case exportTapped
case dataExported(format: String, count: Int)
case importTapped
case importSucceeded
case importFailed(error: String?)
case onboardingReshown
// MARK: Privacy & Security
case privacyLockEnabled
case privacyLockDisabled
case privacyLockEnableFailed
case appLocked
case biometricUnlockSuccess
case biometricUnlockFailed(error: String)
// MARK: HealthKit
case healthKitAuthorized
case healthKitAuthFailed(error: String)
case healthKitEnableFailed
case healthKitDisabled
case healthKitNotAuthorized
case healthKitSyncCompleted(total: Int, success: Int, failed: Int)
// MARK: Navigation
case tabSwitched(tab: String)
case viewHeaderChanged(header: String)
// MARK: Sharing
case shareTemplateViewed(template: String)
// MARK: Legal
case eulaViewed
case privacyPolicyViewed
case specialThanksViewed
var payload: (name: String, properties: [String: Any]?) {
switch self {
// Mood
case .moodLogged(let mood, let entryType):
return ("mood_logged", ["mood_value": mood, "entry_type": entryType])
case .moodUpdated(let mood):
return ("mood_updated", ["mood_value": mood])
case .noteUpdated(let count):
return ("note_updated", ["character_count": count])
case .photoAdded:
return ("photo_added", nil)
case .photoDeleted:
return ("photo_deleted", nil)
case .missingEntriesFilled(let count):
return ("missing_entries_filled", ["count": count])
// Customization
case .themeChanged(let id):
return ("theme_changed", ["theme_id": id])
case .themeApplied(let name):
return ("theme_applied", ["theme_name": name])
case .iconPackChanged(let id):
return ("icon_pack_changed", ["pack_id": id])
case .votingLayoutChanged(let layout):
return ("voting_layout_changed", ["layout": layout])
case .dayViewStyleChanged(let style):
return ("day_view_style_changed", ["style": style])
case .moodShapeChanged(let id):
return ("mood_shape_changed", ["shape_id": id])
case .personalityPackChanged(let title):
return ("personality_pack_changed", ["pack_title": title])
case .appIconChanged(let title):
return ("app_icon_changed", ["icon_title": title])
// Widget
case .widgetViewed:
return ("widget_viewed", nil)
case .widgetCreateTapped:
return ("widget_create_tapped", nil)
case .widgetCreated:
return ("widget_created", nil)
case .widgetUsed:
return ("widget_used", nil)
case .widgetDeleted:
return ("widget_deleted", nil)
case .widgetShuffled:
return ("widget_shuffled", nil)
case .widgetRandomized:
return ("widget_randomized", nil)
case .widgetEyeUpdated(let style):
return ("widget_eye_updated", ["style": style])
case .widgetMouthUpdated(let style):
return ("widget_mouth_updated", ["style": style])
case .widgetBackgroundUpdated(let style):
return ("widget_background_updated", ["style": style])
case .widgetColorUpdated(let part):
return ("widget_color_updated", ["part": part])
// Notifications
case .notificationEnabled:
return ("notification_enabled", nil)
case .notificationDisabled:
return ("notification_disabled", nil)
case .reminderTimeUpdated:
return ("reminder_time_updated", nil)
case .reminderTimeTapped:
return ("reminder_time_tapped", nil)
// Onboarding
case .onboardingCompleted(let dayId):
var props: [String: Any] = [:]
if let dayId { props["day_id"] = dayId }
return ("onboarding_completed", props.isEmpty ? nil : props)
case .onboardingSubscribeTapped:
return ("onboarding_subscribe_tapped", nil)
case .onboardingSkipped:
return ("onboarding_skipped", nil)
// Subscription
case .paywallSubscribeTapped(let source):
return ("paywall_subscribe_tapped", ["source": source])
// Settings
case .settingsClosed:
return ("settings_closed", nil)
case .deleteToggleChanged(let enabled):
return ("delete_toggle_changed", ["enabled": enabled])
case .exportTapped:
return ("export_tapped", nil)
case .dataExported(let format, let count):
return ("data_exported", ["format": format, "count": count])
case .importTapped:
return ("import_tapped", nil)
case .importSucceeded:
return ("import_succeeded", nil)
case .importFailed(let error):
return ("import_failed", error != nil ? ["error": error!] : nil)
case .onboardingReshown:
return ("onboarding_reshown", nil)
// Privacy & Security
case .privacyLockEnabled:
return ("privacy_lock_enabled", nil)
case .privacyLockDisabled:
return ("privacy_lock_disabled", nil)
case .privacyLockEnableFailed:
return ("privacy_lock_enable_failed", nil)
case .appLocked:
return ("app_locked", nil)
case .biometricUnlockSuccess:
return ("biometric_unlock_success", nil)
case .biometricUnlockFailed(let error):
return ("biometric_unlock_failed", ["error": error])
// HealthKit
case .healthKitAuthorized:
return ("healthkit_authorized", nil)
case .healthKitAuthFailed(let error):
return ("healthkit_auth_failed", ["error": error])
case .healthKitEnableFailed:
return ("healthkit_enable_failed", nil)
case .healthKitDisabled:
return ("healthkit_disabled", nil)
case .healthKitNotAuthorized:
return ("healthkit_not_authorized", nil)
case .healthKitSyncCompleted(let total, let success, let failed):
return ("healthkit_sync_completed", ["total": total, "success": success, "failed": failed])
// Navigation
case .tabSwitched(let tab):
return ("tab_switched", ["tab": tab])
case .viewHeaderChanged(let header):
return ("view_header_changed", ["header": header])
// Sharing
case .shareTemplateViewed(let template):
return ("share_template_viewed", ["template": template])
// Legal
case .eulaViewed:
return ("eula_viewed", nil)
case .privacyPolicyViewed:
return ("privacy_policy_viewed", nil)
case .specialThanksViewed:
return ("special_thanks_viewed", nil)
}
}
}
}
// MARK: - SwiftUI Screen Tracking Modifier
import SwiftUI
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 {
func trackScreen(_ screen: AnalyticsManager.Screen, properties: [String: Any]? = nil) -> some View {
modifier(ScreenTrackingModifier(screen: screen, properties: properties))
}
}