From 1408cbb68c6450673684ccc88f03bdeaf3632924 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:00:04 -0800 Subject: [PATCH] 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 --- Sources/AppDelegate.swift | 24 +++-- Sources/PostHogAnalytics.swift | 152 +++++++++++++++++++++++------ cmuxTests/GhosttyConfigTests.swift | 6 ++ 3 files changed, 145 insertions(+), 37 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index bae418c0..07a7ba8b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1377,10 +1377,13 @@ func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { static var shared: AppDelegate? - 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. + private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) + + private var isRunningUnderXCTestCached: Bool { + Self.cachedIsRunningUnderXCTest + } + + private static func detectRunningUnderXCTest(_ env: [String: String]) -> Bool { if env["XCTestConfigurationFilePath"] != nil { return true } if env["XCTestBundlePath"] != nil { return true } if env["XCTestSessionIdentifier"] != nil { return true } @@ -1391,6 +1394,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 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 { let windowId: UUID let tabManager: TabManager @@ -1794,10 +1804,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sentryBreadcrumb("app.didBecomeActive", category: "lifecycle", data: [ "tabCount": tabManager?.tabs.count ?? 0 ]) - let env = ProcessInfo.processInfo.environment - if TelemetrySettings.enabledForCurrentLaunch && !isRunningUnderXCTest(env) { - PostHogAnalytics.shared.trackDailyActive(reason: "didBecomeActive") - PostHogAnalytics.shared.trackHourlyActive(reason: "didBecomeActive") + if TelemetrySettings.enabledForCurrentLaunch && !isRunningUnderXCTestCached { + PostHogAnalytics.shared.trackActive(reason: "didBecomeActive") } guard let notificationStore else { return } diff --git a/Sources/PostHogAnalytics.swift b/Sources/PostHogAnalytics.swift index 031533aa..90eb071f 100644 --- a/Sources/PostHogAnalytics.swift +++ b/Sources/PostHogAnalytics.swift @@ -2,7 +2,6 @@ import AppKit import Foundation import PostHog -@MainActor final class PostHogAnalytics { static let shared = PostHogAnalytics() @@ -12,12 +11,27 @@ final class PostHogAnalytics { // PostHog Cloud US default (matches other cmux properties). 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 lastActiveHourUTCKey = "posthog.lastActiveHourUTC" + private let workQueue: DispatchQueue + private let workQueueSpecificKey = DispatchSpecificKey() + private let utcHourFormatter: DateFormatter + private let utcDayFormatter: DateFormatter + private var didStart = false 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 { guard TelemetrySettings.enabledForCurrentLaunch else { return false } #if DEBUG @@ -29,6 +43,44 @@ final class PostHogAnalytics { } 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 isEnabled else { return } @@ -49,31 +101,40 @@ final class PostHogAnalytics { didStart = true + scheduleActiveCheckTimer() + } + + private func scheduleActiveCheckTimer() { // 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 + DispatchQueue.main.async { [weak self] in guard let self else { return } - guard NSApp.isActive else { return } - self.trackDailyActive(reason: "activeTimer") - self.trackHourlyActive(reason: "activeTimer") + self.activeCheckTimer?.invalidate() + self.activeCheckTimer = Timer.scheduledTimer(withTimeInterval: 30 * 60, repeats: true) { [weak self] _ in + guard let self else { return } + guard NSApp.isActive else { return } + self.trackActive(reason: "activeTimer") + } } } - func trackDailyActive(reason: String) { - startIfNeeded() - guard didStart else { return } + @discardableResult + private func trackDailyActiveOnWorkQueue(reason: String, flush: Bool) -> Bool { + startIfNeededOnWorkQueue() + guard didStart else { return false } let today = utcDayString(Date()) let defaults = UserDefaults.standard if defaults.string(forKey: lastActiveDayUTCKey) == today { - return + return false } defaults.set(today, forKey: lastActiveDayUTCKey) + let event = dailyActiveEvent + PostHogSDK.shared.capture( - "cmux_daily_active", + event, properties: Self.dailyActiveProperties( dayUTC: today, reason: reason, @@ -81,53 +142,77 @@ final class PostHogAnalytics { ) ) - // For DAU we care more about delivery than batching. - PostHogSDK.shared.flush() + if flush && Self.shouldFlushAfterCapture(event: event) { + // For active metrics we care more about delivery than batching. + PostHogSDK.shared.flush() + } + + return true } - func trackHourlyActive(reason: String) { - 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 defaults = UserDefaults.standard if defaults.string(forKey: lastActiveHourUTCKey) == hour { - return + return false } defaults.set(hour, forKey: lastActiveHourUTCKey) + let event = hourlyActiveEvent + PostHogSDK.shared.capture( - "cmux_hourly_active", + event, properties: Self.hourlyActiveProperties( hourUTC: hour, reason: reason, infoDictionary: Bundle.main.infoDictionary ?? [:] ) ) + + if flush && Self.shouldFlushAfterCapture(event: event) { + // Keep hourly freshness and avoid losing a deduped hour on abrupt exits. + PostHogSDK.shared.flush() + } + + return true } - func flush() { - guard didStart else { return } - PostHogSDK.shared.flush() + 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 { - 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) + utcHourFormatter.string(from: date) } private func utcDayString(_ date: Date) -> String { + utcDayFormatter.string(from: date) + } + + private static func makeUTCFormatter(_ dateFormat: String) -> DateFormatter { 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) + formatter.dateFormat = dateFormat + return formatter } nonisolated static func superProperties(infoDictionary: [String: Any]) -> [String: Any] { @@ -162,6 +247,15 @@ final class PostHogAnalytics { 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] { var properties: [String: Any] = [:] if let value = infoDictionary["CFBundleShortVersionString"] as? String, !value.isEmpty { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 98bff922..53f988aa 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1259,6 +1259,12 @@ final class PostHogAnalyticsPropertiesTests: XCTestCase { XCTAssertNil(dailyProperties["app_version"]) 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 {