cmux_daily_active deduplicates by UTC date, so PostHog hourly retention cohorts show 0s. Add a companion cmux_hourly_active event that fires at most once per UTC hour, deduped via UserDefaults. No flush() after hourly events (let them batch). The existing 30-minute timer provides adequate hour-boundary coverage without changes.
174 lines
5.9 KiB
Swift
174 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 {
|
|
#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
|
|
}
|
|
}
|