160 lines
5.3 KiB
Swift
160 lines
5.3 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import PostHog
|
|
import Security
|
|
|
|
@MainActor
|
|
final class PostHogAnalytics {
|
|
static let shared = PostHogAnalytics()
|
|
|
|
// The PostHog project API key is intentionally embedded in the app (it's a public key).
|
|
// Replace with the real key for the cmux PostHog project.
|
|
private let apiKey = "phc_opOVu7oFzR9wD3I6ZahFGOV2h3mqGpl5EHyQvmHciDP"
|
|
|
|
// PostHog Cloud US default (matches other cmux properties).
|
|
private let host = "https://us.i.posthog.com"
|
|
|
|
private let lastActiveDayUTCKey = "posthog.lastActiveDayUTC"
|
|
|
|
private let keychainService = "com.cmuxterm.posthog"
|
|
private let keychainAccount = "distinct_id"
|
|
|
|
private var didStart = false
|
|
private var activeCheckTimer: Timer?
|
|
|
|
private var isEnabled: Bool {
|
|
#if DEBUG
|
|
// Avoid polluting production analytics while iterating locally.
|
|
return ProcessInfo.processInfo.environment["CMUX_POSTHOG_ENABLE"] == "1"
|
|
#else
|
|
return !apiKey.isEmpty && apiKey != "REPLACE_WITH_POSTHOG_PUBLIC_KEY"
|
|
#endif
|
|
}
|
|
|
|
func startIfNeeded() {
|
|
guard !didStart else { return }
|
|
guard isEnabled else { return }
|
|
|
|
let config = PostHogConfig(apiKey: apiKey, host: host)
|
|
config.captureApplicationLifecycleEvents = false
|
|
config.captureScreenViews = false
|
|
#if DEBUG
|
|
config.debug = ProcessInfo.processInfo.environment["CMUX_POSTHOG_DEBUG"] == "1"
|
|
#endif
|
|
|
|
PostHogSDK.shared.setup(config)
|
|
|
|
// Keep a stable distinct id so DAU is "unique installs active" and doesn't churn.
|
|
PostHogSDK.shared.identify(getOrCreateDistinctId())
|
|
|
|
didStart = true
|
|
|
|
// If the app stays in the foreground across midnight, `applicationDidBecomeActive`
|
|
// won't fire again, so a periodic check avoids undercounting those users.
|
|
activeCheckTimer?.invalidate()
|
|
activeCheckTimer = Timer.scheduledTimer(withTimeInterval: 30 * 60, repeats: true) { [weak self] _ in
|
|
guard let self else { return }
|
|
guard NSApp.isActive else { return }
|
|
self.trackDailyActive(reason: "activeTimer")
|
|
}
|
|
}
|
|
|
|
func trackDailyActive(reason: String) {
|
|
startIfNeeded()
|
|
guard didStart else { return }
|
|
|
|
let today = utcDayString(Date())
|
|
let defaults = UserDefaults.standard
|
|
if defaults.string(forKey: lastActiveDayUTCKey) == today {
|
|
return
|
|
}
|
|
|
|
defaults.set(today, forKey: lastActiveDayUTCKey)
|
|
|
|
PostHogSDK.shared.capture("cmux_daily_active", properties: [
|
|
"day_utc": today,
|
|
"reason": reason,
|
|
])
|
|
|
|
// For DAU we care more about delivery than batching.
|
|
PostHogSDK.shared.flush()
|
|
}
|
|
|
|
func flush() {
|
|
guard didStart else { return }
|
|
PostHogSDK.shared.flush()
|
|
}
|
|
|
|
// MARK: - Distinct Id
|
|
|
|
private func getOrCreateDistinctId() -> String {
|
|
if let existing = readKeychainString(service: keychainService, account: keychainAccount),
|
|
!existing.isEmpty {
|
|
return existing
|
|
}
|
|
|
|
let fresh = UUID().uuidString
|
|
if writeKeychainString(service: keychainService, account: keychainAccount, value: fresh) {
|
|
return fresh
|
|
}
|
|
|
|
// Keychain can fail in some environments; fall back to a per-install id in defaults.
|
|
let defaultsKey = "posthog.distinctId.fallback"
|
|
if let existing = UserDefaults.standard.string(forKey: defaultsKey), !existing.isEmpty {
|
|
return existing
|
|
}
|
|
UserDefaults.standard.set(fresh, forKey: defaultsKey)
|
|
return fresh
|
|
}
|
|
|
|
private func readKeychainString(service: String, account: String) -> String? {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
kSecReturnData as String: true,
|
|
]
|
|
|
|
var item: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
guard status == errSecSuccess, let data = item as? Data else { return nil }
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
private func writeKeychainString(service: String, account: String, value: String) -> Bool {
|
|
guard let data = value.data(using: .utf8) else { return false }
|
|
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
]
|
|
|
|
let attributes: [String: Any] = [
|
|
kSecValueData as String: data,
|
|
]
|
|
|
|
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
if status == errSecSuccess {
|
|
return true
|
|
}
|
|
|
|
if status != errSecItemNotFound {
|
|
return false
|
|
}
|
|
|
|
var addQuery = query
|
|
addQuery[kSecValueData as String] = data
|
|
return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess
|
|
}
|
|
|
|
private func utcDayString(_ date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.calendar = Calendar(identifier: .iso8601)
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
return formatter.string(from: date)
|
|
}
|
|
}
|