import AppKit import Foundation import PostHog @MainActor final class PostHogAnalytics { static let shared = PostHogAnalytics() // The PostHog project API key is intentionally embedded in the app (it's a public key). 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 lastActiveHourUTCKey = "posthog.lastActiveHourUTC" 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) // Tag every event so PostHog can distinguish desktop from web and // break events down by released app version/build. PostHogSDK.shared.register(Self.superProperties(infoDictionary: Bundle.main.infoDictionary ?? [:])) // The SDK automatically generates and persists an anonymous distinct ID. 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") self.trackHourlyActive(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: Self.dailyActiveProperties( dayUTC: today, reason: reason, infoDictionary: Bundle.main.infoDictionary ?? [:] ) ) // For DAU we care more about delivery than batching. PostHogSDK.shared.flush() } func trackHourlyActive(reason: String) { startIfNeeded() guard didStart else { return } let hour = utcHourString(Date()) let defaults = UserDefaults.standard if defaults.string(forKey: lastActiveHourUTCKey) == hour { return } defaults.set(hour, forKey: lastActiveHourUTCKey) PostHogSDK.shared.capture( "cmux_hourly_active", properties: Self.hourlyActiveProperties( hourUTC: hour, reason: reason, infoDictionary: Bundle.main.infoDictionary ?? [:] ) ) } func flush() { guard didStart else { return } PostHogSDK.shared.flush() } private func utcHourString(_ 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'T'HH" return formatter.string(from: date) } 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) } nonisolated static func superProperties(infoDictionary: [String: Any]) -> [String: Any] { var properties: [String: Any] = ["platform": "cmuxterm"] properties.merge(versionProperties(infoDictionary: infoDictionary)) { _, new in new } return properties } nonisolated static func dailyActiveProperties( dayUTC: String, reason: String, infoDictionary: [String: Any] ) -> [String: Any] { var properties: [String: Any] = [ "day_utc": dayUTC, "reason": reason, ] properties.merge(versionProperties(infoDictionary: infoDictionary)) { _, new in new } return properties } nonisolated static func hourlyActiveProperties( hourUTC: String, reason: String, infoDictionary: [String: Any] ) -> [String: Any] { var properties: [String: Any] = [ "hour_utc": hourUTC, "reason": reason, ] properties.merge(versionProperties(infoDictionary: infoDictionary)) { _, new in new } return properties } nonisolated private static func versionProperties(infoDictionary: [String: Any]) -> [String: Any] { var properties: [String: Any] = [:] if let value = infoDictionary["CFBundleShortVersionString"] as? String, !value.isEmpty { properties["app_version"] = value } if let value = infoDictionary["CFBundleVersion"] as? String, !value.isEmpty { properties["app_build"] = value } return properties } }