native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #208 from onevcat/feature/observability-p1

Tag every PostHog event with app/OS/device context

authored by

Wei Wang and committed by
GitHub
4b62bdde 8075c61d

+150 -16
+21 -13
supacode/App/supacodeApp.swift
··· 116 116 return value 117 117 } 118 118 119 - @MainActor init() { 120 - NSWindow.allowsAutomaticWindowTabbing = false 121 - UserDefaults.standard.set(200, forKey: "NSInitialToolTipDelay") 122 - @Shared(.settingsFile) var settingsFile 123 - let initialSettings = settingsFile.global 124 - let initialResolvedKeybindings = KeybindingResolver.resolve( 125 - schema: .appResolverSchema(), 126 - userOverrides: initialSettings.keybindingUserOverrides 127 - ) 119 + private static func bootstrapTelemetry(initialSettings: GlobalSettings) { 128 120 #if !DEBUG 129 121 let infoDictionary = Bundle.main.infoDictionary ?? [:] 130 122 let releaseName = (infoDictionary["CFBundleShortVersionString"] as? String).map { "prowl@\($0)" } 123 + let environment = initialSettings.updateChannel == .tip ? "tip" : "production" 131 124 132 - if initialSettings.crashReportsEnabled, let dsn = Self.infoPlistSecret(infoDictionary, key: "ProwlSentryDSN") { 125 + if initialSettings.crashReportsEnabled, let dsn = infoPlistSecret(infoDictionary, key: "ProwlSentryDSN") { 133 126 SentrySDK.start { options in 134 127 options.dsn = dsn 135 - options.environment = "production" 128 + options.environment = environment 136 129 if let releaseName { options.releaseName = releaseName } 137 130 options.tracesSampleRate = 0.05 138 131 options.enableAppHangTracking = true 139 132 } 140 133 } 141 134 if initialSettings.analyticsEnabled, 142 - let apiKey = Self.infoPlistSecret(infoDictionary, key: "ProwlPostHogAPIKey"), 143 - let host = Self.infoPlistSecret(infoDictionary, key: "ProwlPostHogHost") 135 + let apiKey = infoPlistSecret(infoDictionary, key: "ProwlPostHogAPIKey"), 136 + let host = infoPlistSecret(infoDictionary, key: "ProwlPostHogHost") 144 137 { 145 138 let config = PostHogConfig(apiKey: apiKey, host: host) 146 139 config.enableSwizzling = false 140 + config.captureApplicationLifecycleEvents = false 141 + config.captureScreenViews = false 147 142 PostHogSDK.shared.setup(config) 143 + PostHogSDK.shared.register(AnalyticsContext.superProperties) 148 144 PostHogSDK.shared.identify(InstallIdentifier.current) 149 145 } 150 146 #endif 147 + } 148 + 149 + @MainActor init() { 150 + NSWindow.allowsAutomaticWindowTabbing = false 151 + UserDefaults.standard.set(200, forKey: "NSInitialToolTipDelay") 152 + @Shared(.settingsFile) var settingsFile 153 + let initialSettings = settingsFile.global 154 + let initialResolvedKeybindings = KeybindingResolver.resolve( 155 + schema: .appResolverSchema(), 156 + userOverrides: initialSettings.keybindingUserOverrides 157 + ) 158 + Self.bootstrapTelemetry(initialSettings: initialSettings) 151 159 if let resourceURL = Bundle.main.resourceURL?.appendingPathComponent("ghostty") { 152 160 setenv("GHOSTTY_RESOURCES_DIR", resourceURL.path, 1) 153 161 }
+16 -3
supacode/Features/App/Reducer/AppFeature.swift
··· 52 52 var lastKnownSystemNotificationsEnabled: Bool 53 53 var launchRestoreMode: LaunchRestoreMode 54 54 var suppressLayoutSaveUntilRelaunch = false 55 + var launchedAt: Date? 55 56 @Presents var alert: AlertState<Alert>? 56 57 57 58 init( ··· 104 105 } 105 106 106 107 @Dependency(AnalyticsClient.self) private var analyticsClient 108 + @Dependency(\.date.now) private var now 107 109 @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence 108 110 @Dependency(WorkspaceClient.self) private var workspaceClient 109 111 @Dependency(SettingsWindowClient.self) private var settingsWindowClient ··· 113 115 @Dependency(WorktreeInfoWatcherClient.self) private var worktreeInfoWatcher 114 116 @Dependency(CustomShortcutRegistryClient.self) private var customShortcutRegistryClient 115 117 118 + private func appQuitProperties(launchedAt: Date?) -> [String: Any]? { 119 + guard let seconds = Self.sessionDurationSeconds(launchedAt: launchedAt, now: now) else { return nil } 120 + return ["session_duration_seconds": seconds] 121 + } 122 + 123 + static func sessionDurationSeconds(launchedAt: Date?, now: Date) -> Int? { 124 + guard let launchedAt else { return nil } 125 + return max(0, Int(now.timeIntervalSince(launchedAt))) 126 + } 127 + 116 128 private func resolvedKeybindings( 117 129 settings: SettingsFeature.State, 118 130 customCommands: [UserCustomCommand] ··· 152 164 case .appLaunched: 153 165 try? SupacodePaths.migrateLegacyCacheFilesIfNeeded() 154 166 appLogger.info("[LayoutRestore] appLaunched: launchRestoreMode=\(String(describing: state.launchRestoreMode))") 167 + state.launchedAt = now 155 168 state.repositories.launchRestoreMode = state.launchRestoreMode 169 + analyticsClient.capture("app_activated", nil) 156 170 return .merge( 157 171 .send(.repositories(.task)), 158 172 .send(.settings(.task)), ··· 177 191 case .scenePhaseChanged(let phase): 178 192 switch phase { 179 193 case .active: 180 - analyticsClient.capture("app_activated", nil) 181 194 return .merge( 182 195 .send(.repositories(.refreshWorktrees)), 183 196 .run { send in ··· 562 575 case .requestQuit: 563 576 #if !DEBUG 564 577 guard state.settings.confirmBeforeQuit else { 565 - analyticsClient.capture("app_quit", nil) 578 + analyticsClient.capture("app_quit", appQuitProperties(launchedAt: state.launchedAt)) 566 579 return .run { @MainActor _ in 567 580 NSApplication.shared.terminate(nil) 568 581 } ··· 825 838 return .none 826 839 827 840 case .alert(.presented(.confirmQuit)): 828 - analyticsClient.capture("app_quit", nil) 841 + analyticsClient.capture("app_quit", appQuitProperties(launchedAt: state.launchedAt)) 829 842 state.alert = nil 830 843 return .run { @MainActor _ in 831 844 NSApplication.shared.terminate(nil)
+48
supacode/Support/AnalyticsContext.swift
··· 1 + import Darwin 2 + import Foundation 3 + 4 + /// Static metadata about the app and host machine. Registered once with PostHog 5 + /// at startup so every event ships with these dimensions and dashboards can 6 + /// slice by app version, OS, hardware, locale. 7 + nonisolated enum AnalyticsContext { 8 + static var superProperties: [String: String] { 9 + let bundle = Bundle.main 10 + let osVersion = ProcessInfo.processInfo.operatingSystemVersion 11 + let appVersion = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" 12 + let buildNumber = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown" 13 + 14 + return [ 15 + "app_version": appVersion, 16 + "build_number": buildNumber, 17 + "os_version": "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)", 18 + "os_major": "\(osVersion.majorVersion)", 19 + "os_minor": "\(osVersion.minorVersion)", 20 + "device_model": deviceModel, 21 + "cpu_arch": cpuArch, 22 + "locale": Locale.current.identifier, 23 + ] 24 + } 25 + 26 + private static var deviceModel: String { 27 + sysctlString("hw.model") ?? "unknown" 28 + } 29 + 30 + private static var cpuArch: String { 31 + #if arch(arm64) 32 + return "arm64" 33 + #elseif arch(x86_64) 34 + return "x86_64" 35 + #else 36 + return "unknown" 37 + #endif 38 + } 39 + 40 + private static func sysctlString(_ name: String) -> String? { 41 + var size = 0 42 + guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil } 43 + var bytes = [UInt8](repeating: 0, count: size) 44 + guard sysctlbyname(name, &bytes, &size, nil, 0) == 0 else { return nil } 45 + let payload = bytes.prefix(while: { $0 != 0 }) 46 + return String(bytes: payload, encoding: .utf8) 47 + } 48 + }
+37
supacodeTests/AnalyticsContextTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct AnalyticsContextTests { 7 + @Test func superPropertiesContainAllRequiredKeys() { 8 + let props = AnalyticsContext.superProperties 9 + 10 + let requiredKeys = [ 11 + "app_version", 12 + "build_number", 13 + "os_version", 14 + "os_major", 15 + "os_minor", 16 + "device_model", 17 + "cpu_arch", 18 + "locale", 19 + ] 20 + 21 + for key in requiredKeys { 22 + #expect(props[key] != nil, "missing key: \(key)") 23 + #expect(!(props[key]?.isEmpty ?? true), "empty value for: \(key)") 24 + } 25 + } 26 + 27 + @Test func cpuArchHasExpectedValue() { 28 + let arch = AnalyticsContext.superProperties["cpu_arch"] 29 + #expect(["arm64", "x86_64", "unknown"].contains(arch)) 30 + } 31 + 32 + @Test func osMajorAndMinorAreNumeric() { 33 + let props = AnalyticsContext.superProperties 34 + #expect(Int(props["os_major"] ?? "") != nil) 35 + #expect(Int(props["os_minor"] ?? "") != nil) 36 + } 37 + }
+28
supacodeTests/AppFeatureSessionDurationTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct AppFeatureSessionDurationTests { 7 + @Test func returnsNilWhenLaunchedAtIsNil() { 8 + #expect(AppFeature.sessionDurationSeconds(launchedAt: nil, now: Date()) == nil) 9 + } 10 + 11 + @Test func returnsElapsedSecondsAsInteger() { 12 + let start = Date(timeIntervalSince1970: 1_000) 13 + let later = Date(timeIntervalSince1970: 1_042) 14 + #expect(AppFeature.sessionDurationSeconds(launchedAt: start, now: later) == 42) 15 + } 16 + 17 + @Test func clampsNegativeDurationsToZero() { 18 + let future = Date(timeIntervalSince1970: 2_000) 19 + let past = Date(timeIntervalSince1970: 1_000) 20 + #expect(AppFeature.sessionDurationSeconds(launchedAt: future, now: past) == 0) 21 + } 22 + 23 + @Test func truncatesFractionalSeconds() { 24 + let start = Date(timeIntervalSince1970: 1_000) 25 + let later = Date(timeIntervalSince1970: 1_000.999) 26 + #expect(AppFeature.sessionDurationSeconds(launchedAt: start, now: later) == 0) 27 + } 28 + }