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:
parent
3d6645cb18
commit
1408cbb68c
3 changed files with 145 additions and 37 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<Void>()
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue