Commit 15887049f7
Unsigned
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 | ||
| 18 | import Foundation | |
| 19 | ||
| 20 | nonisolated 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 | ||
| 9 | import Charts | |
| 10 | import SwiftUI | |
| 11 | ||
| 12 | struct 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 | ||
| 86 | private 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 { | ||
| 32 | 32 | /// What the user has hidden locally, and until when. |
| 33 | 33 | var triage: AlertTriage |
| 34 | 34 | |
| 35 | /// Snapshots and per-alert lifecycles behind the trends view. Bounded by | |
| 36 | /// AlertHistory's own retention rules. | |
| 37 | var history: AlertHistory | |
| 38 | ||
| 35 | 39 | enum CodingKeys: String, CodingKey { |
| 36 | 40 | case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder |
| 37 | case notifiedEventIDsByRepo, triage | |
| 41 | case notifiedEventIDsByRepo, triage, history | |
| 38 | 42 | } |
| 39 | 43 | |
| 40 | 44 | init( |
| @@ -45,7 +49,8 @@ nonisolated struct PersistedState: Codable { | ||
| 45 | 49 | hasRepoScope: Bool = false, |
| 46 | 50 | sortOrder: AlertSortOrder = .severity, |
| 47 | 51 | notifiedEventIDsByRepo: [String: Set<String>]? = nil, |
| 48 | triage: AlertTriage = AlertTriage() | |
| 52 | triage: AlertTriage = AlertTriage(), | |
| 53 | history: AlertHistory = AlertHistory() | |
| 49 | 54 | ) { |
| 50 | 55 | self.watchedRepos = watchedRepos |
| 51 | 56 | self.seenEventIDs = seenEventIDs |
| @@ -55,6 +60,7 @@ nonisolated struct PersistedState: Codable { | ||
| 55 | 60 | self.sortOrder = sortOrder |
| 56 | 61 | self.notifiedEventIDsByRepo = notifiedEventIDsByRepo |
| 57 | 62 | self.triage = triage |
| 63 | self.history = history | |
| 58 | 64 | } |
| 59 | 65 | |
| 60 | 66 | // Custom decode so existing state.json files saved before hasRepoScope |
| @@ -72,6 +78,7 @@ nonisolated struct PersistedState: Codable { | ||
| 72 | 78 | forKey: .notifiedEventIDsByRepo |
| 73 | 79 | ) |
| 74 | 80 | triage = try container.decodeIfPresent(AlertTriage.self, forKey: .triage) ?? AlertTriage() |
| 81 | history = try container.decodeIfPresent(AlertHistory.self, forKey: .history) ?? AlertHistory() | |
| 75 | 82 | } |
| 76 | 83 | |
| 77 | 84 | static let placeholder = PersistedState( |
octosentry/SecurityEventListView.swift +13 −2
| @@ -13,6 +13,7 @@ struct SecurityEventListView: View { | ||
| 13 | 13 | var updateStore: UpdateStore |
| 14 | 14 | var isStandaloneWindow: Bool = false |
| 15 | 15 | @State private var showingRepoManager = false |
| 16 | @State private var showingHistory = false | |
| 16 | 17 | @State private var exportErrorMessage: String? |
| 17 | 18 | @Environment(\.openWindow) private var openWindow |
| 18 | 19 | |
| @@ -27,6 +28,8 @@ struct SecurityEventListView: View { | ||
| 27 | 28 | SignInView(authStore: authStore) |
| 28 | 29 | } else if showingRepoManager { |
| 29 | 30 | RepoManagerView(store: store, authStore: authStore) |
| 31 | } else if showingHistory { | |
| 32 | AlertHistoryView(store: store) | |
| 30 | 33 | } else { |
| 31 | 34 | filterBar |
| 32 | 35 | Divider() |
| @@ -87,7 +90,7 @@ struct SecurityEventListView: View { | ||
| 87 | 90 | |
| 88 | 91 | Spacer() |
| 89 | 92 | |
| 90 | if authStore.isSignedIn && !showingRepoManager { | |
| 93 | if authStore.isSignedIn && !showingRepoManager && !showingHistory { | |
| 91 | 94 | Picker("Minimum severity", selection: Binding( |
| 92 | 95 | get: { store.minimumSeverity }, |
| 93 | 96 | set: { newValue in Task { await store.setMinimumSeverity(newValue) } } |
| @@ -123,7 +126,15 @@ struct SecurityEventListView: View { | ||
| 123 | 126 | } |
| 124 | 127 | |
| 125 | 128 | 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 { | |
| 127 | 138 | Button { |
| 128 | 139 | openWindow(id: SecurityEventWindow.id) |
| 129 | 140 | } label: { |
octosentry/SecurityEventStore.swift +12 −1
| @@ -38,6 +38,10 @@ final class SecurityEventStore { | ||
| 38 | 38 | /// so the feed can be re-derived without touching disk. |
| 39 | 39 | private(set) var triage = AlertTriage() |
| 40 | 40 | |
| 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 | ||
| 41 | 45 | /// Repos represented in the current fetch, for the repo filter menu — |
| 42 | 46 | /// the watch list can contain repos that returned nothing. |
| 43 | 47 | var reposInFeed: [String] { |
| @@ -70,6 +74,7 @@ final class SecurityEventStore { | ||
| 70 | 74 | sortOrder = state.sortOrder |
| 71 | 75 | watchedRepos = state.watchedRepos |
| 72 | 76 | triage = state.triage |
| 77 | history = state.history | |
| 73 | 78 | |
| 74 | 79 | guard let token = KeychainTokenStore.load() else { |
| 75 | 80 | errorMessages = [stateLoadFailure, GitHubAPIError.missingToken.errorDescription ?? "Not signed in."] |
| @@ -138,12 +143,18 @@ final class SecurityEventStore { | ||
| 138 | 143 | // Only prune against a complete picture: if a repo failed this round |
| 139 | 144 | // its alerts are missing, and pruning would forget they were hidden. |
| 140 | 145 | if fetchedEventsByRepo.count == state.watchedRepos.count { |
| 146 | let now = Date() | |
| 141 | 147 | state.triage = state.triage.pruned( |
| 142 | 148 | presentEventIDs: Set(fetchedEvents.map(\.id)), |
| 143 | now: Date() | |
| 149 | now: now | |
| 144 | 150 | ) |
| 145 | 151 | triage = state.triage |
| 146 | 152 | 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 | |
| 147 | 158 | } |
| 148 | 159 | |
| 149 | 160 | let newEvents = AlertDiff.newlyAppeared( |
octosentryTests/AlertHistoryTests.swift added +217
| @@ -0,0 +1,217 @@ | ||
| 1 | // | |
| 2 | // AlertHistoryTests.swift | |
| 3 | // octosentryTests | |
| 4 | // | |
| 5 | ||
| 6 | import Foundation | |
| 7 | import Testing | |
| 8 | @testable import octosentry | |
| 9 | ||
| 10 | struct 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 { | ||
| 65 | 65 | |
| 66 | 66 | #expect(Set(object.keys) == [ |
| 67 | 67 | "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder", |
| 68 | "triage", | |
| 68 | "triage", "history", | |
| 69 | 69 | ]) |
| 70 | 70 | // notifiedEventIDsByRepo is optional and nil on the placeholder, so it |
| 71 | 71 | // encodes to nothing rather than a null. |
| @@ -92,6 +92,7 @@ struct PersistedStateTests { | ||
| 92 | 92 | #expect(state.sortOrder == .severity) |
| 93 | 93 | #expect(state.notifiedEventIDsByRepo == nil) |
| 94 | 94 | #expect(state.triage == AlertTriage()) |
| 95 | #expect(state.history == AlertHistory()) | |
| 95 | 96 | } |
| 96 | 97 | |
| 97 | 98 | @Test func rejectsStateMissingARequiredField() { |