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>
This commit is contained in:
Trey t
2026-02-10 15:12:33 -06:00
parent a08d0d33c0
commit e0330dbc8d
38 changed files with 1048 additions and 202 deletions

View File

@@ -15,9 +15,12 @@ import os.log
enum SubscriptionState: Equatable {
case unknown
case subscribed(expirationDate: Date?, willAutoRenew: Bool)
case billingRetry(expirationDate: Date?, willAutoRenew: Bool)
case gracePeriod(expirationDate: Date?, willAutoRenew: Bool)
case inTrial(daysRemaining: Int)
case trialExpired
case expired
case revoked
}
// MARK: - IAPManager
@@ -84,16 +87,20 @@ class IAPManager: ObservableObject {
// MARK: - Computed Properties
var isSubscribed: Bool {
if case .subscribed = state { return true }
return false
switch state {
case .subscribed, .billingRetry, .gracePeriod:
return true
default:
return false
}
}
var hasFullAccess: Bool {
if Self.bypassSubscription { return true }
switch state {
case .subscribed, .inTrial:
case .subscribed, .billingRetry, .gracePeriod, .inTrial:
return true
case .unknown, .trialExpired, .expired:
case .unknown, .trialExpired, .expired, .revoked:
return false
}
}
@@ -101,9 +108,9 @@ class IAPManager: ObservableObject {
var shouldShowPaywall: Bool {
if Self.bypassSubscription { return false }
switch state {
case .trialExpired, .expired:
case .trialExpired, .expired, .revoked:
return true
case .unknown, .subscribed, .inTrial:
case .unknown, .subscribed, .billingRetry, .gracePeriod, .inTrial:
return false
}
}
@@ -169,10 +176,28 @@ class IAPManager: ObservableObject {
if hasActiveSubscription {
// State already set in checkForActiveSubscription cache it
if case .subscribed(let expiration, _) = state {
switch state {
case .subscribed(let expiration, _),
.billingRetry(let expiration, _),
.gracePeriod(let expiration, _):
cacheSubscriptionExpiration(expiration)
default:
break
}
syncSubscriptionStatusToUserDefaults()
trackSubscriptionAnalytics(source: "status_check_active")
return
}
// Preserve terminal StoreKit states (expired/revoked) instead of overriding with trial fallback.
if case .expired = state {
syncSubscriptionStatusToUserDefaults()
trackSubscriptionAnalytics(source: "status_check_terminal")
return
}
if case .revoked = state {
syncSubscriptionStatusToUserDefaults()
trackSubscriptionAnalytics(source: "status_check_terminal")
return
}
@@ -182,12 +207,14 @@ class IAPManager: ObservableObject {
if let expiration = cachedExpiration, expiration > Date() {
state = .subscribed(expirationDate: expiration, willAutoRenew: false)
syncSubscriptionStatusToUserDefaults()
trackSubscriptionAnalytics(source: "status_check_cached")
return
}
// Subscription genuinely gone clear cache and fall back to trial
cacheSubscriptionExpiration(nil)
updateTrialState()
trackSubscriptionAnalytics(source: "status_check_fallback")
}
/// Sync subscription status to UserDefaults for widget access
@@ -218,11 +245,14 @@ class IAPManager: ObservableObject {
}
/// Restore purchases
func restore() async {
func restore(source: String = "settings") async {
do {
try await AppStore.sync()
await checkSubscriptionStatus()
AnalyticsManager.shared.trackPurchaseRestored(source: source)
trackSubscriptionAnalytics(source: "restore")
} catch {
AnalyticsManager.shared.trackPurchaseFailed(productId: nil, source: source, error: error.localizedDescription)
AppLogger.iap.error("Failed to restore purchases: \(error.localizedDescription)")
}
}
@@ -239,6 +269,8 @@ class IAPManager: ObservableObject {
}
private func checkForActiveSubscription() async -> Bool {
var nonActiveState: SubscriptionState?
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
@@ -255,23 +287,46 @@ class IAPManager: ObservableObject {
if let product = currentProduct,
let subscription = product.subscription,
let statuses = try? await subscription.status {
var hadVerifiedStatus = false
for status in statuses {
guard case .verified(let renewalInfo) = status.renewalInfo else { continue }
hadVerifiedStatus = true
switch status.state {
case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
case .subscribed:
state = .subscribed(
expirationDate: transaction.expirationDate,
willAutoRenew: renewalInfo.willAutoRenew
)
return true
case .expired, .revoked:
case .inBillingRetryPeriod:
state = .billingRetry(
expirationDate: transaction.expirationDate,
willAutoRenew: renewalInfo.willAutoRenew
)
return true
case .inGracePeriod:
state = .gracePeriod(
expirationDate: transaction.expirationDate,
willAutoRenew: renewalInfo.willAutoRenew
)
return true
case .expired:
nonActiveState = .expired
continue
case .revoked:
nonActiveState = .revoked
continue
default:
continue
}
}
// We had detailed status and none were active, so do not fallback to subscribed.
if hadVerifiedStatus {
continue
}
}
// Fallback if we couldn't get detailed status
@@ -280,7 +335,11 @@ class IAPManager: ObservableObject {
}
// No active subscription found
currentProduct = nil
if let nonActiveState {
state = nonActiveState
} else {
currentProduct = nil
}
return false
}
@@ -297,6 +356,112 @@ class IAPManager: ObservableObject {
syncSubscriptionStatusToUserDefaults()
}
// MARK: - Analytics
func trackSubscriptionAnalytics(source: String) {
let status: String
let isSubscribed: Bool
let hasFullAccess: Bool
let productId = currentProduct?.id
let type = subscriptionType(for: productId)
let willAutoRenew: Bool?
let isInGracePeriod: Bool?
let trialDaysRemaining: Int?
let expirationDate: Date?
switch state {
case .unknown:
status = "unknown"
isSubscribed = false
hasFullAccess = Self.bypassSubscription
willAutoRenew = nil
isInGracePeriod = nil
trialDaysRemaining = nil
expirationDate = nil
case .subscribed(let expiration, let autoRenew):
status = "subscribed"
isSubscribed = true
hasFullAccess = true
willAutoRenew = autoRenew
isInGracePeriod = false
trialDaysRemaining = nil
expirationDate = expiration
case .billingRetry(let expiration, let autoRenew):
status = "billing_retry"
isSubscribed = true
hasFullAccess = true
willAutoRenew = autoRenew
isInGracePeriod = true
trialDaysRemaining = nil
expirationDate = expiration
case .gracePeriod(let expiration, let autoRenew):
status = "grace_period"
isSubscribed = true
hasFullAccess = true
willAutoRenew = autoRenew
isInGracePeriod = true
trialDaysRemaining = nil
expirationDate = expiration
case .inTrial(let daysRemaining):
status = "trial"
isSubscribed = false
hasFullAccess = true
willAutoRenew = nil
isInGracePeriod = nil
trialDaysRemaining = daysRemaining
expirationDate = nil
case .trialExpired:
status = "trial_expired"
isSubscribed = false
hasFullAccess = false
willAutoRenew = nil
isInGracePeriod = nil
trialDaysRemaining = nil
expirationDate = nil
case .expired:
status = "expired"
isSubscribed = false
hasFullAccess = false
willAutoRenew = nil
isInGracePeriod = nil
trialDaysRemaining = nil
expirationDate = nil
case .revoked:
status = "revoked"
isSubscribed = false
hasFullAccess = false
willAutoRenew = nil
isInGracePeriod = nil
trialDaysRemaining = nil
expirationDate = nil
}
AnalyticsManager.shared.trackSubscriptionStatusObserved(
status: status,
type: type,
source: source,
isSubscribed: isSubscribed,
hasFullAccess: hasFullAccess,
productId: productId,
willAutoRenew: willAutoRenew,
isInGracePeriod: isInGracePeriod,
trialDaysRemaining: trialDaysRemaining,
expirationDate: expirationDate
)
}
private func subscriptionType(for productID: String?) -> String {
guard let productID else { return "none" }
let id = productID.lowercased()
if id.contains("annual") || id.contains("year") {
return "yearly"
}
if id.contains("month") {
return "monthly"
}
return "unknown"
}
private func listenForTransactions() -> Task<Void, Error> {
Task.detached { [weak self] in
for await result in Transaction.updates {