Adds a "Send anonymous telemetry" toggle in Settings that lets users disable Sentry crash reporting and PostHog analytics. The setting is frozen at launch so toggling mid-session shows a restart hint. The hint correctly clears if the user toggles back to the launch-time value.
175 lines
5.9 KiB
Swift
175 lines
5.9 KiB
Swift
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 {
|
|
guard TelemetrySettings.enabledForCurrentLaunch else { return false }
|
|
#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
|
|
}
|
|
}
|