Flush PostHog hourly active events immediately (#934)

* PostHog: flush hourly active captures immediately

* PostHog: move focus-triggered active tracking off main queue

* Lighten PostHog app focus hot path

* Address review: dedupe PostHog event names
This commit is contained in:
Lawrence Chen 2026-03-05 04:00:04 -08:00 committed by GitHub
parent 3d6645cb18
commit 1408cbb68c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 145 additions and 37 deletions

View file

@ -1377,10 +1377,13 @@ func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) ->
final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation {
static var shared: AppDelegate? static var shared: AppDelegate?
private func isRunningUnderXCTest(_ env: [String: String]) -> Bool { private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment)
// On some macOS/Xcode setups, the app-under-test process doesn't get
// `XCTestConfigurationFilePath`. Use a broader set of signals so UI tests private var isRunningUnderXCTestCached: Bool {
// can reliably skip heavyweight startup work and bring up a window. Self.cachedIsRunningUnderXCTest
}
private static func detectRunningUnderXCTest(_ env: [String: String]) -> Bool {
if env["XCTestConfigurationFilePath"] != nil { return true } if env["XCTestConfigurationFilePath"] != nil { return true }
if env["XCTestBundlePath"] != nil { return true } if env["XCTestBundlePath"] != nil { return true }
if env["XCTestSessionIdentifier"] != nil { return true } if env["XCTestSessionIdentifier"] != nil { return true }
@ -1391,6 +1394,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return false return false
} }
private func isRunningUnderXCTest(_ env: [String: String]) -> Bool {
// On some macOS/Xcode setups, the app-under-test process doesn't get
// `XCTestConfigurationFilePath`. Use a broader set of signals so UI tests
// can reliably skip heavyweight startup work and bring up a window.
Self.detectRunningUnderXCTest(env)
}
private final class MainWindowContext { private final class MainWindowContext {
let windowId: UUID let windowId: UUID
let tabManager: TabManager let tabManager: TabManager
@ -1794,10 +1804,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
sentryBreadcrumb("app.didBecomeActive", category: "lifecycle", data: [ sentryBreadcrumb("app.didBecomeActive", category: "lifecycle", data: [
"tabCount": tabManager?.tabs.count ?? 0 "tabCount": tabManager?.tabs.count ?? 0
]) ])
let env = ProcessInfo.processInfo.environment if TelemetrySettings.enabledForCurrentLaunch && !isRunningUnderXCTestCached {
if TelemetrySettings.enabledForCurrentLaunch && !isRunningUnderXCTest(env) { PostHogAnalytics.shared.trackActive(reason: "didBecomeActive")
PostHogAnalytics.shared.trackDailyActive(reason: "didBecomeActive")
PostHogAnalytics.shared.trackHourlyActive(reason: "didBecomeActive")
} }
guard let notificationStore else { return } guard let notificationStore else { return }

View file

@ -2,7 +2,6 @@ import AppKit
import Foundation import Foundation
import PostHog import PostHog
@MainActor
final class PostHogAnalytics { final class PostHogAnalytics {
static let shared = PostHogAnalytics() static let shared = PostHogAnalytics()
@ -12,12 +11,27 @@ final class PostHogAnalytics {
// PostHog Cloud US default (matches other cmux properties). // PostHog Cloud US default (matches other cmux properties).
private let host = "https://us.i.posthog.com" private let host = "https://us.i.posthog.com"
private let dailyActiveEvent = "cmux_daily_active"
private let hourlyActiveEvent = "cmux_hourly_active"
private let lastActiveDayUTCKey = "posthog.lastActiveDayUTC" private let lastActiveDayUTCKey = "posthog.lastActiveDayUTC"
private let lastActiveHourUTCKey = "posthog.lastActiveHourUTC" private let lastActiveHourUTCKey = "posthog.lastActiveHourUTC"
private let workQueue: DispatchQueue
private let workQueueSpecificKey = DispatchSpecificKey<Void>()
private let utcHourFormatter: DateFormatter
private let utcDayFormatter: DateFormatter
private var didStart = false private var didStart = false
private var activeCheckTimer: Timer? private var activeCheckTimer: Timer?
private init() {
workQueue = DispatchQueue(label: "com.cmux.posthog.analytics", qos: .utility)
utcHourFormatter = Self.makeUTCFormatter("yyyy-MM-dd'T'HH")
utcDayFormatter = Self.makeUTCFormatter("yyyy-MM-dd")
workQueue.setSpecific(key: workQueueSpecificKey, value: ())
}
private var isEnabled: Bool { private var isEnabled: Bool {
guard TelemetrySettings.enabledForCurrentLaunch else { return false } guard TelemetrySettings.enabledForCurrentLaunch else { return false }
#if DEBUG #if DEBUG
@ -29,6 +43,44 @@ final class PostHogAnalytics {
} }
func startIfNeeded() { func startIfNeeded() {
dispatchAsyncOnWorkQueue { [weak self] in
self?.startIfNeededOnWorkQueue()
}
}
func trackActive(reason: String) {
dispatchAsyncOnWorkQueue { [weak self] in
guard let self else { return }
let didCaptureDaily = self.trackDailyActiveOnWorkQueue(reason: reason, flush: false)
let didCaptureHourly = self.trackHourlyActiveOnWorkQueue(reason: reason, flush: false)
if didCaptureDaily || didCaptureHourly {
// On app focus we can capture both events; flush once to reduce extra work.
PostHogSDK.shared.flush()
}
}
}
func trackDailyActive(reason: String) {
dispatchAsyncOnWorkQueue { [weak self] in
self?.trackDailyActiveOnWorkQueue(reason: reason, flush: true)
}
}
func trackHourlyActive(reason: String) {
dispatchAsyncOnWorkQueue { [weak self] in
self?.trackHourlyActiveOnWorkQueue(reason: reason, flush: true)
}
}
func flush() {
dispatchSyncOnWorkQueue {
guard didStart else { return }
PostHogSDK.shared.flush()
}
}
private func startIfNeededOnWorkQueue() {
guard !didStart else { return } guard !didStart else { return }
guard isEnabled else { return } guard isEnabled else { return }
@ -49,31 +101,40 @@ final class PostHogAnalytics {
didStart = true didStart = true
scheduleActiveCheckTimer()
}
private func scheduleActiveCheckTimer() {
// If the app stays in the foreground across midnight, `applicationDidBecomeActive` // If the app stays in the foreground across midnight, `applicationDidBecomeActive`
// won't fire again, so a periodic check avoids undercounting those users. // won't fire again, so a periodic check avoids undercounting those users.
activeCheckTimer?.invalidate() DispatchQueue.main.async { [weak self] in
activeCheckTimer = Timer.scheduledTimer(withTimeInterval: 30 * 60, repeats: true) { [weak self] _ in guard let self else { return }
self.activeCheckTimer?.invalidate()
self.activeCheckTimer = Timer.scheduledTimer(withTimeInterval: 30 * 60, repeats: true) { [weak self] _ in
guard let self else { return } guard let self else { return }
guard NSApp.isActive else { return } guard NSApp.isActive else { return }
self.trackDailyActive(reason: "activeTimer") self.trackActive(reason: "activeTimer")
self.trackHourlyActive(reason: "activeTimer") }
} }
} }
func trackDailyActive(reason: String) { @discardableResult
startIfNeeded() private func trackDailyActiveOnWorkQueue(reason: String, flush: Bool) -> Bool {
guard didStart else { return } startIfNeededOnWorkQueue()
guard didStart else { return false }
let today = utcDayString(Date()) let today = utcDayString(Date())
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
if defaults.string(forKey: lastActiveDayUTCKey) == today { if defaults.string(forKey: lastActiveDayUTCKey) == today {
return return false
} }
defaults.set(today, forKey: lastActiveDayUTCKey) defaults.set(today, forKey: lastActiveDayUTCKey)
let event = dailyActiveEvent
PostHogSDK.shared.capture( PostHogSDK.shared.capture(
"cmux_daily_active", event,
properties: Self.dailyActiveProperties( properties: Self.dailyActiveProperties(
dayUTC: today, dayUTC: today,
reason: reason, reason: reason,
@ -81,53 +142,77 @@ final class PostHogAnalytics {
) )
) )
// For DAU we care more about delivery than batching. if flush && Self.shouldFlushAfterCapture(event: event) {
// For active metrics we care more about delivery than batching.
PostHogSDK.shared.flush() PostHogSDK.shared.flush()
} }
func trackHourlyActive(reason: String) { return true
startIfNeeded() }
guard didStart else { return }
@discardableResult
private func trackHourlyActiveOnWorkQueue(reason: String, flush: Bool) -> Bool {
startIfNeededOnWorkQueue()
guard didStart else { return false }
let hour = utcHourString(Date()) let hour = utcHourString(Date())
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
if defaults.string(forKey: lastActiveHourUTCKey) == hour { if defaults.string(forKey: lastActiveHourUTCKey) == hour {
return return false
} }
defaults.set(hour, forKey: lastActiveHourUTCKey) defaults.set(hour, forKey: lastActiveHourUTCKey)
let event = hourlyActiveEvent
PostHogSDK.shared.capture( PostHogSDK.shared.capture(
"cmux_hourly_active", event,
properties: Self.hourlyActiveProperties( properties: Self.hourlyActiveProperties(
hourUTC: hour, hourUTC: hour,
reason: reason, reason: reason,
infoDictionary: Bundle.main.infoDictionary ?? [:] infoDictionary: Bundle.main.infoDictionary ?? [:]
) )
) )
}
func flush() { if flush && Self.shouldFlushAfterCapture(event: event) {
guard didStart else { return } // Keep hourly freshness and avoid losing a deduped hour on abrupt exits.
PostHogSDK.shared.flush() PostHogSDK.shared.flush()
} }
return true
}
private func dispatchAsyncOnWorkQueue(_ block: @escaping () -> Void) {
if DispatchQueue.getSpecific(key: workQueueSpecificKey) != nil {
block()
return
}
workQueue.async(execute: block)
}
private func dispatchSyncOnWorkQueue(_ block: () -> Void) {
if DispatchQueue.getSpecific(key: workQueueSpecificKey) != nil {
block()
return
}
workQueue.sync(execute: block)
}
private func utcHourString(_ date: Date) -> String { private func utcHourString(_ date: Date) -> String {
let formatter = DateFormatter() utcHourFormatter.string(from: date)
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 { private func utcDayString(_ date: Date) -> String {
utcDayFormatter.string(from: date)
}
private static func makeUTCFormatter(_ dateFormat: String) -> DateFormatter {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601) formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX") formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0) formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd" formatter.dateFormat = dateFormat
return formatter.string(from: date) return formatter
} }
nonisolated static func superProperties(infoDictionary: [String: Any]) -> [String: Any] { nonisolated static func superProperties(infoDictionary: [String: Any]) -> [String: Any] {
@ -162,6 +247,15 @@ final class PostHogAnalytics {
return properties return properties
} }
nonisolated static func shouldFlushAfterCapture(event: String) -> Bool {
switch event {
case "cmux_daily_active", "cmux_hourly_active":
return true
default:
return false
}
}
nonisolated private static func versionProperties(infoDictionary: [String: Any]) -> [String: Any] { nonisolated private static func versionProperties(infoDictionary: [String: Any]) -> [String: Any] {
var properties: [String: Any] = [:] var properties: [String: Any] = [:]
if let value = infoDictionary["CFBundleShortVersionString"] as? String, !value.isEmpty { if let value = infoDictionary["CFBundleShortVersionString"] as? String, !value.isEmpty {

View file

@ -1259,6 +1259,12 @@ final class PostHogAnalyticsPropertiesTests: XCTestCase {
XCTAssertNil(dailyProperties["app_version"]) XCTAssertNil(dailyProperties["app_version"])
XCTAssertNil(dailyProperties["app_build"]) XCTAssertNil(dailyProperties["app_build"])
} }
func testFlushPolicyIncludesDailyAndHourlyActiveEvents() {
XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_daily_active"))
XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_hourly_active"))
XCTAssertFalse(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_other_event"))
}
} }
final class GhosttyMouseFocusTests: XCTestCase { final class GhosttyMouseFocusTests: XCTestCase {