krz/octosentry

macOS menu bar app to monitor GitHub security alerts github macos menubar security

Commit 15887049f7

15887049f73d40c021bd4cf7670708f9b0a353f2

parent: e1de03985c

Unsigned

cmc <hello@cleberg.net> · 2026-08-22 21:15 UTC
committer: <noreply@github.com>

Add alert history and trends (#32)

Records two things per complete poll: a periodic snapshot of open
counts by source and severity, and a per-alert lifecycle carrying when
GitHub opened the alert, when it was last seen, and when it stopped
being reported. Surfaced in the dedicated window as open-now, new and
resolved over 30 days, mean time to resolution, and an open-count
chart.

Retention, which the issue left open: snapshots are taken at most every
six hours and kept 90 days, so the series is bounded at roughly 360
entries whatever the poll interval; resolved lifecycles are dropped
after 90 days while open ones are kept. A test simulates a year of
15-minute polling and asserts the bound holds.

Storage stays flat JSON rather than SwiftData. With retention the
history is tens of kilobytes, nothing queries it except the view
loading it whole, and it inherits PersistenceStore's existing
handling of an unreadable file.

Time to resolution measures from GitHub's created_at, not from when
octosentry first polled, so it doesn't restart at install day. Only
polls where every watched repo answered are recorded — a missing repo
is not a set of resolved alerts.

Closes #21

Layout: unified · split

octosentry/AlertHistory.swift added +143
@@ -0,0 +1,143 @@
1//
2// AlertHistory.swift
3// octosentry
4//
5// A record of what the feed looked like over time, so the app can answer
6// "are we getting better or worse?" and "how long do alerts sit open?"
7//
8// Two parts, because they answer different questions: periodic snapshots
9// give the open-count trend, and a per-alert lifecycle gives new-vs-resolved
10// and time to resolution.
11//
12// Both are bounded. Snapshots are taken at most every six hours and kept for
13// 90 days (≈360 entries); resolved lifecycles are dropped after 90 days.
14// Unbounded growth is the obvious failure mode for a per-poll time series,
15// and this lives in the same state.json as everything else.
16//
17
18import Foundation
19
20nonisolated struct AlertHistory: Codable, Equatable {
21 static let snapshotInterval: TimeInterval = 6 * 3600
22 static let retention: TimeInterval = 90 * 24 * 3600
23
24 var snapshots: [Snapshot] = []
25 var lifecycles: [String: Lifecycle] = [:]
26
27 nonisolated struct Snapshot: Codable, Equatable {
28 var recordedAt: Date
29 var openCount: Int
30 /// Keyed by SecurityEventSource.rawValue / SecurityEventSeverity
31 /// displayName so the file stays readable.
32 var countsBySource: [String: Int]
33 var countsBySeverity: [String: Int]
34 }
35
36 nonisolated struct Lifecycle: Codable, Equatable {
37 var repoFullName: String
38 var source: String
39 var severity: SecurityEventSeverity
40 /// When GitHub opened the alert, not when octosentry first saw it —
41 /// otherwise time-to-resolution would be measured from install day.
42 var openedAt: Date
43 var lastSeenAt: Date
44 /// First poll that no longer reported it. "No longer reported" is the
45 /// only resolution signal the API gives.
46 var resolvedAt: Date?
47 }
48
49 /// Folds one complete poll into the history.
50 ///
51 /// `events` must come from a poll where every watched repo answered:
52 /// an alert missing because its repo errored is not a resolved alert.
53 mutating func record(_ events: [SecurityEvent], at date: Date) {
54 let presentIDs = Set(events.map(\.id))
55
56 for event in events {
57 if var lifecycle = lifecycles[event.id] {
58 lifecycle.lastSeenAt = date
59 lifecycle.severity = event.severity
60 // Back from the dead: GitHub re-reported it.
61 lifecycle.resolvedAt = nil
62 lifecycles[event.id] = lifecycle
63 } else {
64 lifecycles[event.id] = Lifecycle(
65 repoFullName: event.repoFullName,
66 source: event.source.rawValue,
67 severity: event.severity,
68 openedAt: event.createdAt,
69 lastSeenAt: date,
70 resolvedAt: nil
71 )
72 }
73 }
74
75 for (id, var lifecycle) in lifecycles where !presentIDs.contains(id) && lifecycle.resolvedAt == nil {
76 lifecycle.resolvedAt = date
77 lifecycles[id] = lifecycle
78 }
79
80 appendSnapshot(for: events, at: date)
81 prune(now: date)
82 }
83
84 private mutating func appendSnapshot(for events: [SecurityEvent], at date: Date) {
85 if let last = snapshots.last, date.timeIntervalSince(last.recordedAt) < Self.snapshotInterval {
86 return
87 }
88
89 var countsBySource: [String: Int] = [:]
90 var countsBySeverity: [String: Int] = [:]
91 for event in events {
92 countsBySource[event.source.rawValue, default: 0] += 1
93 countsBySeverity[event.severity.displayName, default: 0] += 1
94 }
95
96 snapshots.append(
97 Snapshot(
98 recordedAt: date,
99 openCount: events.count,
100 countsBySource: countsBySource,
101 countsBySeverity: countsBySeverity
102 )
103 )
104 }
105
106 private mutating func prune(now: Date) {
107 let cutoff = now.addingTimeInterval(-Self.retention)
108 snapshots.removeAll { $0.recordedAt < cutoff }
109 lifecycles = lifecycles.filter { _, lifecycle in
110 guard let resolvedAt = lifecycle.resolvedAt else { return true }
111 return resolvedAt >= cutoff
112 }
113 }
114
115 // MARK: - Trends
116
117 var openCountOverTime: [Snapshot] {
118 snapshots.sorted { $0.recordedAt < $1.recordedAt }
119 }
120
121 func openedCount(since date: Date) -> Int {
122 lifecycles.values.filter { $0.openedAt >= date }.count
123 }
124
125 func resolvedCount(since date: Date) -> Int {
126 lifecycles.values.filter { ($0.resolvedAt ?? .distantFuture) >= date && $0.resolvedAt != nil }.count
127 }
128
129 var currentlyOpenCount: Int {
130 lifecycles.values.filter { $0.resolvedAt == nil }.count
131 }
132
133 /// Mean time from GitHub opening an alert to octosentry no longer seeing
134 /// it. nil when nothing has been resolved yet.
135 var meanTimeToResolution: TimeInterval? {
136 let durations = lifecycles.values.compactMap { lifecycle -> TimeInterval? in
137 guard let resolvedAt = lifecycle.resolvedAt else { return nil }
138 return resolvedAt.timeIntervalSince(lifecycle.openedAt)
139 }
140 guard !durations.isEmpty else { return nil }
141 return durations.reduce(0, +) / Double(durations.count)
142 }
143}
octosentry/AlertHistoryView.swift added +99
@@ -0,0 +1,99 @@
1//
2// AlertHistoryView.swift
3// octosentry
4//
5// Trends over time. Lives in the dedicated window (#10) rather than the
6// popover — the popover is 380pt wide and meant for at-a-glance triage.
7//
8
9import Charts
10import SwiftUI
11
12struct AlertHistoryView: View {
13 var store: SecurityEventStore
14
15 private static let windowLength: TimeInterval = 30 * 24 * 3600
16
17 private var since: Date {
18 Date().addingTimeInterval(-Self.windowLength)
19 }
20
21 var body: some View {
22 ScrollView {
23 VStack(alignment: .leading, spacing: 16) {
24 summary
25 Divider()
26 openOverTime
27 Divider()
28 resolutionNote
29 }
30 .padding(16)
31 .frame(maxWidth: .infinity, alignment: .leading)
32 }
33 }
34
35 private var summary: some View {
36 HStack(alignment: .top, spacing: 24) {
37 Statistic(label: "Open now", value: "\(store.history.currentlyOpenCount)")
38 Statistic(label: "New (30d)", value: "\(store.history.openedCount(since: since))")
39 Statistic(label: "Resolved (30d)", value: "\(store.history.resolvedCount(since: since))")
40 Statistic(label: "Mean time to resolution", value: meanTimeToResolutionText)
41 }
42 }
43
44 private var meanTimeToResolutionText: String {
45 guard let interval = store.history.meanTimeToResolution else { return "—" }
46 let days = interval / 86_400
47 return days >= 1
48 ? String(format: "%.1f d", days)
49 : String(format: "%.0f h", interval / 3600)
50 }
51
52 @ViewBuilder
53 private var openOverTime: some View {
54 VStack(alignment: .leading, spacing: 8) {
55 Text("Open alerts over time")
56 .font(.subheadline.weight(.semibold))
57
58 let snapshots = store.history.openCountOverTime
59 if snapshots.count < 2 {
60 Text("Not enough history yet — octosentry records a snapshot every few hours.")
61 .font(.callout)
62 .foregroundStyle(.secondary)
63 } else {
64 Chart(snapshots, id: \.recordedAt) { snapshot in
65 LineMark(
66 x: .value("Date", snapshot.recordedAt),
67 y: .value("Open", snapshot.openCount)
68 )
69 .interpolationMethod(.monotone)
70 }
71 .chartYScale(domain: .automatic(includesZero: true))
72 .frame(height: 180)
73 }
74 }
75 }
76
77 private var resolutionNote: some View {
78 Text("An alert counts as resolved once GitHub stops reporting it — the API gives no other signal, "
79 + "so a repo losing access or leaving the watch list can look the same. Only polls where every "
80 + "watched repo answered are recorded.")
81 .font(.caption)
82 .foregroundStyle(.secondary)
83 }
84}
85
86private struct Statistic: View {
87 let label: String
88 let value: String
89
90 var body: some View {
91 VStack(alignment: .leading, spacing: 2) {
92 Text(value)
93 .font(.title2.weight(.semibold))
94 Text(label)
95 .font(.caption)
96 .foregroundStyle(.secondary)
97 }
98 }
99}
octosentry/PersistedState.swift +9 −2
@@ -32,9 +32,13 @@ nonisolated struct PersistedState: Codable {
3232 /// What the user has hidden locally, and until when.
3333 var triage: AlertTriage
3434
35 /// Snapshots and per-alert lifecycles behind the trends view. Bounded by
36 /// AlertHistory's own retention rules.
37 var history: AlertHistory
38
3539 enum CodingKeys: String, CodingKey {
3640 case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder
37 case notifiedEventIDsByRepo, triage
41 case notifiedEventIDsByRepo, triage, history
3842 }
3943
4044 init(
@@ -45,7 +49,8 @@ nonisolated struct PersistedState: Codable {
4549 hasRepoScope: Bool = false,
4650 sortOrder: AlertSortOrder = .severity,
4751 notifiedEventIDsByRepo: [String: Set<String>]? = nil,
48 triage: AlertTriage = AlertTriage()
52 triage: AlertTriage = AlertTriage(),
53 history: AlertHistory = AlertHistory()
4954 ) {
5055 self.watchedRepos = watchedRepos
5156 self.seenEventIDs = seenEventIDs
@@ -55,6 +60,7 @@ nonisolated struct PersistedState: Codable {
5560 self.sortOrder = sortOrder
5661 self.notifiedEventIDsByRepo = notifiedEventIDsByRepo
5762 self.triage = triage
63 self.history = history
5864 }
5965
6066 // Custom decode so existing state.json files saved before hasRepoScope
@@ -72,6 +78,7 @@ nonisolated struct PersistedState: Codable {
7278 forKey: .notifiedEventIDsByRepo
7379 )
7480 triage = try container.decodeIfPresent(AlertTriage.self, forKey: .triage) ?? AlertTriage()
81 history = try container.decodeIfPresent(AlertHistory.self, forKey: .history) ?? AlertHistory()
7582 }
7683
7784 static let placeholder = PersistedState(
octosentry/SecurityEventListView.swift +13 −2
@@ -13,6 +13,7 @@ struct SecurityEventListView: View {
1313 var updateStore: UpdateStore
1414 var isStandaloneWindow: Bool = false
1515 @State private var showingRepoManager = false
16 @State private var showingHistory = false
1617 @State private var exportErrorMessage: String?
1718 @Environment(\.openWindow) private var openWindow
1819
@@ -27,6 +28,8 @@ struct SecurityEventListView: View {
2728 SignInView(authStore: authStore)
2829 } else if showingRepoManager {
2930 RepoManagerView(store: store, authStore: authStore)
31 } else if showingHistory {
32 AlertHistoryView(store: store)
3033 } else {
3134 filterBar
3235 Divider()
@@ -87,7 +90,7 @@ struct SecurityEventListView: View {
8790
8891 Spacer()
8992
90 if authStore.isSignedIn && !showingRepoManager {
93 if authStore.isSignedIn && !showingRepoManager && !showingHistory {
9194 Picker("Minimum severity", selection: Binding(
9295 get: { store.minimumSeverity },
9396 set: { newValue in Task { await store.setMinimumSeverity(newValue) } }
@@ -123,7 +126,15 @@ struct SecurityEventListView: View {
123126 }
124127
125128 if authStore.isSignedIn {
126 if !isStandaloneWindow {
129 if isStandaloneWindow {
130 Button {
131 showingHistory.toggle()
132 } label: {
133 Image(systemName: showingHistory ? "list.bullet" : "chart.xyaxis.line")
134 }
135 .buttonStyle(.plain)
136 .help(showingHistory ? "Back to alerts" : "Trends")
137 } else {
127138 Button {
128139 openWindow(id: SecurityEventWindow.id)
129140 } label: {
octosentry/SecurityEventStore.swift +12 −1
@@ -38,6 +38,10 @@ final class SecurityEventStore {
3838 /// so the feed can be re-derived without touching disk.
3939 private(set) var triage = AlertTriage()
4040
41 /// Trend data for the dedicated window (#10). Mirrored from
42 /// PersistedState so the view doesn't touch disk.
43 private(set) var history = AlertHistory()
44
4145 /// Repos represented in the current fetch, for the repo filter menu —
4246 /// the watch list can contain repos that returned nothing.
4347 var reposInFeed: [String] {
@@ -70,6 +74,7 @@ final class SecurityEventStore {
7074 sortOrder = state.sortOrder
7175 watchedRepos = state.watchedRepos
7276 triage = state.triage
77 history = state.history
7378
7479 guard let token = KeychainTokenStore.load() else {
7580 errorMessages = [stateLoadFailure, GitHubAPIError.missingToken.errorDescription ?? "Not signed in."]
@@ -138,12 +143,18 @@ final class SecurityEventStore {
138143 // Only prune against a complete picture: if a repo failed this round
139144 // its alerts are missing, and pruning would forget they were hidden.
140145 if fetchedEventsByRepo.count == state.watchedRepos.count {
146 let now = Date()
141147 state.triage = state.triage.pruned(
142148 presentEventIDs: Set(fetchedEvents.map(\.id)),
143 now: Date()
149 now: now
144150 )
145151 triage = state.triage
146152 applyFilters()
153
154 // Same completeness rule: an alert missing because its repo
155 // errored has not been resolved.
156 state.history.record(fetchedEvents, at: now)
157 history = state.history
147158 }
148159
149160 let newEvents = AlertDiff.newlyAppeared(
octosentryTests/AlertHistoryTests.swift added +217
@@ -0,0 +1,217 @@
1//
2// AlertHistoryTests.swift
3// octosentryTests
4//
5
6import Foundation
7import Testing
8@testable import octosentry
9
10struct AlertHistoryTests {
11
12 private let day0 = Date(timeIntervalSince1970: 1_785_000_000)
13 private func days(_ count: Double) -> TimeInterval { count * 86_400 }
14
15 private func event(_ id: String, severity: SecurityEventSeverity = .high, openedDaysAgo: Double = 0) -> SecurityEvent {
16 TestEvents.event(id: id, severity: severity, ageInHours: openedDaysAgo * 24)
17 }
18
19 // MARK: - Lifecycles
20
21 @Test func recordingCreatesALifecyclePerAlert() {
22 var history = AlertHistory()
23 history.record([event("a"), event("b")], at: day0)
24
25 #expect(Set(history.lifecycles.keys) == ["a", "b"])
26 #expect(history.currentlyOpenCount == 2)
27 #expect(history.lifecycles["a"]?.resolvedAt == nil)
28 }
29
30 // openedAt comes from GitHub, not from when octosentry first polled,
31 // otherwise time-to-resolution starts at install day.
32 @Test func lifecycleOpenedAtComesFromTheAlertNotThePoll() {
33 var history = AlertHistory()
34 let alert = event("a", openedDaysAgo: 10)
35 history.record([alert], at: day0)
36
37 #expect(history.lifecycles["a"]?.openedAt == alert.createdAt)
38 #expect(history.lifecycles["a"]?.openedAt != day0)
39 }
40
41 @Test func anAlertThatDisappearsIsMarkedResolved() {
42 var history = AlertHistory()
43 history.record([event("a"), event("b")], at: day0)
44
45 let later = day0.addingTimeInterval(days(1))
46 history.record([event("a")], at: later)
47
48 #expect(history.lifecycles["b"]?.resolvedAt == later)
49 #expect(history.lifecycles["a"]?.resolvedAt == nil)
50 #expect(history.currentlyOpenCount == 1)
51 }
52
53 @Test func resolutionTimeIsNotOverwrittenByLaterPolls() {
54 var history = AlertHistory()
55 history.record([event("a")], at: day0)
56 let resolvedAt = day0.addingTimeInterval(days(1))
57 history.record([], at: resolvedAt)
58 history.record([], at: day0.addingTimeInterval(days(2)))
59
60 #expect(history.lifecycles["a"]?.resolvedAt == resolvedAt)
61 }
62
63 @Test func aReReportedAlertBecomesOpenAgain() {
64 var history = AlertHistory()
65 history.record([event("a")], at: day0)
66 history.record([], at: day0.addingTimeInterval(days(1)))
67 history.record([event("a")], at: day0.addingTimeInterval(days(2)))
68
69 #expect(history.lifecycles["a"]?.resolvedAt == nil)
70 #expect(history.currentlyOpenCount == 1)
71 }
72
73 // MARK: - Snapshots
74
75 @Test func theFirstPollRecordsASnapshot() {
76 var history = AlertHistory()
77 history.record([event("a", severity: .critical), event("b", severity: .low)], at: day0)
78
79 #expect(history.snapshots.count == 1)
80 let snapshot = history.snapshots[0]
81 #expect(snapshot.openCount == 2)
82 #expect(snapshot.countsBySeverity["Critical"] == 1)
83 #expect(snapshot.countsBySeverity["Low"] == 1)
84 #expect(snapshot.countsBySource["dependabot"] == 2)
85 }
86
87 // Polls run every 15 minutes; a snapshot per poll would be 96 a day.
88 @Test func snapshotsAreRateLimited() {
89 var history = AlertHistory()
90 history.record([event("a")], at: day0)
91 history.record([event("a")], at: day0.addingTimeInterval(900))
92 history.record([event("a")], at: day0.addingTimeInterval(3600))
93
94 #expect(history.snapshots.count == 1)
95 }
96
97 @Test func aSnapshotIsTakenOnceTheIntervalHasPassed() {
98 var history = AlertHistory()
99 history.record([event("a")], at: day0)
100 history.record([event("a")], at: day0.addingTimeInterval(AlertHistory.snapshotInterval))
101
102 #expect(history.snapshots.count == 2)
103 }
104
105 @Test func openCountOverTimeIsChronological() {
106 var history = AlertHistory()
107 for step in 0..<4 {
108 history.record([event("a")], at: day0.addingTimeInterval(AlertHistory.snapshotInterval * Double(step)))
109 }
110
111 let dates = history.openCountOverTime.map(\.recordedAt)
112 #expect(dates == dates.sorted())
113 }
114
115 // MARK: - Retention
116
117 @Test func snapshotsOlderThanRetentionAreDropped() {
118 var history = AlertHistory()
119 history.record([event("a")], at: day0)
120 history.record([event("a")], at: day0.addingTimeInterval(AlertHistory.retention + days(1)))
121
122 #expect(history.snapshots.count == 1)
123 #expect(history.snapshots[0].recordedAt > day0)
124 }
125
126 @Test func resolvedLifecyclesAreDroppedAfterRetentionButOpenOnesAreKept() {
127 var history = AlertHistory()
128 history.record([event("old"), event("survivor")], at: day0)
129 history.record([event("survivor")], at: day0.addingTimeInterval(days(1)))
130
131 // Long enough that "old" resolved outside the retention window.
132 history.record([event("survivor")], at: day0.addingTimeInterval(AlertHistory.retention + days(2)))
133
134 #expect(history.lifecycles["old"] == nil)
135 #expect(history.lifecycles["survivor"] != nil)
136 }
137
138 // The growth question: a per-poll series must stay bounded.
139 @Test func aYearOfPollingStaysBounded() {
140 var history = AlertHistory()
141 // Every 15 minutes for 365 days.
142 let pollInterval: TimeInterval = 900
143 var date = day0
144 for _ in 0..<(365 * 96) {
145 history.record([event("a")], at: date)
146 date = date.addingTimeInterval(pollInterval)
147 }
148
149 let maximumSnapshots = Int(AlertHistory.retention / AlertHistory.snapshotInterval) + 2
150 #expect(history.snapshots.count <= maximumSnapshots)
151 #expect(history.snapshots.count > 0)
152 }
153
154 // MARK: - Trends
155
156 @Test func newAndResolvedCountsAreWindowed() {
157 var history = AlertHistory()
158 history.record([event("old", openedDaysAgo: 60), event("recent", openedDaysAgo: 1)], at: day0)
159
160 let since = day0.addingTimeInterval(-days(30))
161 #expect(history.openedCount(since: since) == 1)
162 #expect(history.resolvedCount(since: since) == 0)
163
164 history.record([event("old", openedDaysAgo: 60)], at: day0.addingTimeInterval(days(1)))
165 #expect(history.resolvedCount(since: since) == 1)
166 }
167
168 @Test func meanTimeToResolutionIsNilUntilSomethingResolves() {
169 var history = AlertHistory()
170 history.record([event("a")], at: day0)
171
172 #expect(history.meanTimeToResolution == nil)
173 }
174
175 @Test func meanTimeToResolutionAveragesOpenToResolved() {
176 var history = AlertHistory()
177 // "a" opened 2 days before day0, "b" opened 4 days before.
178 history.record([event("a", openedDaysAgo: 2), event("b", openedDaysAgo: 4)], at: day0)
179 history.record([], at: day0)
180
181 let mean = try? #require(history.meanTimeToResolution)
182 // Resolved at day0, so durations are 2 and 4 days; mean is 3.
183 #expect(mean != nil)
184 if let mean {
185 #expect(abs(mean - days(3)) < 1)
186 }
187 }
188
189 @Test func meanTimeToResolutionIgnoresStillOpenAlerts() {
190 var history = AlertHistory()
191 history.record([event("resolved", openedDaysAgo: 2), event("open", openedDaysAgo: 100)], at: day0)
192 history.record([event("open", openedDaysAgo: 100)], at: day0)
193
194 if let mean = history.meanTimeToResolution {
195 #expect(abs(mean - days(2)) < 1)
196 } else {
197 Issue.record("expected a mean time to resolution")
198 }
199 }
200
201 // MARK: - Persistence
202
203 @Test func roundTripsThroughCodable() throws {
204 var history = AlertHistory()
205 history.record([event("a", severity: .critical)], at: day0)
206 history.record([], at: day0.addingTimeInterval(AlertHistory.snapshotInterval))
207
208 let encoder = JSONEncoder()
209 encoder.dateEncodingStrategy = .iso8601
210 let decoder = JSONDecoder()
211 decoder.dateDecodingStrategy = .iso8601
212
213 let decoded = try decoder.decode(AlertHistory.self, from: try encoder.encode(history))
214
215 #expect(decoded == history)
216 }
217}
octosentryTests/PersistedStateTests.swift +2 −1
@@ -65,7 +65,7 @@ struct PersistedStateTests {
6565
6666 #expect(Set(object.keys) == [
6767 "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder",
68 "triage",
68 "triage", "history",
6969 ])
7070 // notifiedEventIDsByRepo is optional and nil on the placeholder, so it
7171 // encodes to nothing rather than a null.
@@ -92,6 +92,7 @@ struct PersistedStateTests {
9292 #expect(state.sortOrder == .severity)
9393 #expect(state.notifiedEventIDsByRepo == nil)
9494 #expect(state.triage == AlertTriage())
95 #expect(state.history == AlertHistory())
9596 }
9697
9798 @Test func rejectsStateMissingARequiredField() {