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 {
|
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 }
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue