cmux/Sources/PostHogAnalytics.swift
Lawrence Chen fa6a18c753
Add telemetry opt-out setting (#610)
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.
2026-02-26 22:02:29 -08:00

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
}
}