native macOS codings agent orchestrator
5
fork

Configure Feed

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

Tag every PostHog event with app/OS/device context

Register a stable set of super properties on PostHog setup so every
captured event carries app_version, build_number, os_version (+ major/
minor split for easy aggregation), device_model (sysctlbyname hw.model),
cpu_arch (Apple Silicon vs Intel), and locale. Without these, the
existing 16 events are essentially uncuttable in the dashboard.

Sentry environment now follows updateChannel — "tip" channel users get
their own bucket separate from "production" so a regression on the tip
release doesn't pollute stable's crash rate.

Add session_duration_seconds to the app_quit event so we can see how
long users actually keep Prowl running per session, which is the natural
denominator for the long-running memory growth investigation in P2.

script_run / terminal_tab_closed enrichment moved to P2 — both need
TerminalClient.Event to grow scriptFinished / tabClosed payloads, which
is the same terminal-layer surgery P2 needs for memory probing anyway.

onevcat f2c47941 2ed859e5

+131 -3
+3 -1
supacode/App/supacodeApp.swift
··· 128 128 #if !DEBUG 129 129 let infoDictionary = Bundle.main.infoDictionary ?? [:] 130 130 let releaseName = (infoDictionary["CFBundleShortVersionString"] as? String).map { "prowl@\($0)" } 131 + let environment = initialSettings.updateChannel == .tip ? "tip" : "production" 131 132 132 133 if initialSettings.crashReportsEnabled, let dsn = Self.infoPlistSecret(infoDictionary, key: "ProwlSentryDSN") { 133 134 SentrySDK.start { options in 134 135 options.dsn = dsn 135 - options.environment = "production" 136 + options.environment = environment 136 137 if let releaseName { options.releaseName = releaseName } 137 138 options.tracesSampleRate = 0.05 138 139 options.enableAppHangTracking = true ··· 145 146 let config = PostHogConfig(apiKey: apiKey, host: host) 146 147 config.enableSwizzling = false 147 148 PostHogSDK.shared.setup(config) 149 + PostHogSDK.shared.register(AnalyticsContext.superProperties) 148 150 PostHogSDK.shared.identify(InstallIdentifier.current) 149 151 } 150 152 #endif
+15 -2
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 156 169 return .merge( 157 170 .send(.repositories(.task)), ··· 561 574 case .requestQuit: 562 575 #if !DEBUG 563 576 guard state.settings.confirmBeforeQuit else { 564 - analyticsClient.capture("app_quit", nil) 577 + analyticsClient.capture("app_quit", appQuitProperties(launchedAt: state.launchedAt)) 565 578 return .run { @MainActor _ in 566 579 NSApplication.shared.terminate(nil) 567 580 } ··· 824 837 return .none 825 838 826 839 case .alert(.presented(.confirmQuit)): 827 - analyticsClient.capture("app_quit", nil) 840 + analyticsClient.capture("app_quit", appQuitProperties(launchedAt: state.launchedAt)) 828 841 state.alert = nil 829 842 return .run { @MainActor _ in 830 843 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 + }