Skip to content

Commit 8a950c1

Browse files
Merge pull request #3070 from SwiftPackageIndex/alerting-command
Alerting command
2 parents 35338ea + 4902437 commit 8a950c1

File tree

7 files changed

+424
-6
lines changed

7 files changed

+424
-6
lines changed
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright Dave Verwer, Sven A. Schmidt, and other contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Fluent
16+
import SQLKit
17+
import Vapor
18+
import NIOCore
19+
20+
21+
enum Alerting {
22+
struct Command: AsyncCommand {
23+
var help: String { "Application level alerting" }
24+
25+
struct Signature: CommandSignature {
26+
@Option(name: "time-period", short: "t")
27+
var timePeriod: Int?
28+
29+
@Option(name: "limit", short: "l")
30+
var limit: Int?
31+
32+
static let defaultLimit = 2000
33+
static let defaultTimePeriod = 2
34+
35+
var duration: TimeAmount {
36+
.hours(Int64(timePeriod ?? Self.defaultTimePeriod))
37+
}
38+
}
39+
40+
func run(using context: CommandContext, signature: Signature) async throws {
41+
Current.setLogger(Logger(component: "alerting"))
42+
43+
Current.logger().info("Running alerting...")
44+
45+
let timePeriod = signature.duration
46+
let limit = signature.limit ?? Signature.defaultLimit
47+
48+
Current.logger().info("Validation time interval: \(timePeriod.hours)h, limit: \(limit)")
49+
50+
let builds = try await Alerting.fetchBuilds(on: context.application.db, timePeriod: timePeriod, limit: limit)
51+
try await Alerting.runChecks(for: builds)
52+
}
53+
}
54+
}
55+
56+
extension Alerting {
57+
struct BuildInfo {
58+
var createdAt: Date
59+
var updatedAt: Date
60+
var builderVersion: String?
61+
var platform: Build.Platform
62+
var runnerId: String?
63+
var status: Build.Status
64+
var swiftVersion: SwiftVersion
65+
66+
init(createdAt: Date, updatedAt: Date, builderVersion: String? = nil, platform: Build.Platform, runnerId: String? = nil, status: Build.Status, swiftVersion: SwiftVersion) {
67+
self.createdAt = createdAt
68+
self.updatedAt = updatedAt
69+
self.builderVersion = builderVersion
70+
self.platform = platform
71+
self.runnerId = runnerId
72+
self.status = status
73+
self.swiftVersion = swiftVersion
74+
}
75+
76+
init(_ build: Build) {
77+
self.createdAt = build.createdAt!
78+
self.updatedAt = build.updatedAt!
79+
self.builderVersion = build.builderVersion
80+
self.platform = build.platform
81+
self.runnerId = build.runnerId
82+
self.status = build.status
83+
self.swiftVersion = build.swiftVersion
84+
}
85+
}
86+
87+
static func runChecks(for builds: [BuildInfo]) async throws {
88+
// to do
89+
// - [ ] doc gen is configured but it failed
90+
91+
Current.logger().info("Build records selected: \(builds.count)")
92+
if let oldest = builds.last {
93+
Current.logger().info("Oldest selected: \(oldest.createdAt)")
94+
}
95+
if let mostRecent = builds.first {
96+
Current.logger().info("Most recent selected: \(mostRecent.createdAt)")
97+
}
98+
builds.validateBuildsPresent().log(check: "CHECK_BUILDS_PRESENT")
99+
builds.validatePlatformsPresent().log(check: "CHECK_BUILDS_PLATFORMS_PRESENT")
100+
builds.validateSwiftVersionsPresent().log(check: "CHECK_BUILDS_SWIFT_VERSIONS_PRESENT")
101+
builds.validatePlatformsSuccessful().log(check: "CHECK_BUILDS_PLATFORMS_SUCCESSFUL")
102+
builds.validateSwiftVersionsSuccessful().log(check: "CHECK_BUILDS_SWIFT_VERSIONS_SUCCESSFUL")
103+
builds.validateRunnerIdsPresent().log(check: "CHECK_BUILDS_RUNNER_IDS_PRESENT")
104+
builds.validateRunnerIdsSuccessful().log(check: "CHECK_BUILDS_RUNNER_IDS_SUCCESSFUL")
105+
if builds.count >= 1000 { // only run this test if we have a decent number of builds
106+
builds.validateSuccessRateInRange().log(check: "CHECK_BUILDS_SUCCESS_RATE_IN_RANGE")
107+
}
108+
}
109+
110+
static func fetchBuilds(on database: Database, timePeriod: TimeAmount, limit: Int) async throws -> [Alerting.BuildInfo] {
111+
let start = Date.now
112+
defer {
113+
Current.logger().debug("fetchBuilds elapsed: \(Date.now.timeIntervalSince(start).rounded(decimalPlaces: 2))s")
114+
}
115+
let cutoff = Current.date().addingTimeInterval(-timePeriod.timeInterval)
116+
let builds = try await Build.query(on: database)
117+
.field(\.$createdAt)
118+
.field(\.$updatedAt)
119+
.field(\.$builderVersion)
120+
.field(\.$platform)
121+
.field(\.$runnerId)
122+
.field(\.$status)
123+
.field(\.$swiftVersion)
124+
.filter(Build.self, \.$createdAt >= cutoff)
125+
.sort(\.$createdAt, .descending)
126+
.limit(limit)
127+
.all()
128+
.map(BuildInfo.init)
129+
return builds
130+
}
131+
}
132+
133+
134+
extension Alerting {
135+
enum Validation: Equatable {
136+
case ok
137+
case failed(reasons: [String])
138+
139+
func log(check: String) {
140+
switch self {
141+
case .ok:
142+
Current.logger().debug("\(check) passed")
143+
case .failed(let reasons):
144+
for reason in reasons {
145+
Current.logger().critical("\(check) failed: \(reason)")
146+
}
147+
}
148+
}
149+
}
150+
}
151+
152+
153+
extension [Alerting.BuildInfo] {
154+
func validateBuildsPresent() -> Alerting.Validation {
155+
isEmpty ? .failed(reasons: ["No builds"]) : .ok
156+
}
157+
158+
func validatePlatformsPresent() -> Alerting.Validation {
159+
var notSeen = Set(Build.Platform.allCases)
160+
for build in self {
161+
notSeen.remove(build.platform)
162+
if notSeen.isEmpty { return .ok }
163+
}
164+
return .failed(reasons: notSeen.sorted().map { "Missing platform: \($0)" })
165+
}
166+
167+
func validateSwiftVersionsPresent() -> Alerting.Validation {
168+
var notSeen = Set(SwiftVersion.allActive)
169+
for build in self {
170+
notSeen.remove(build.swiftVersion)
171+
if notSeen.isEmpty { return .ok }
172+
}
173+
return .failed(reasons: notSeen.sorted().map { "Missing Swift version: \($0)" })
174+
}
175+
176+
func validatePlatformsSuccessful() -> Alerting.Validation {
177+
var noSuccess = Set(Build.Platform.allCases)
178+
for build in self where build.status == .ok {
179+
noSuccess.remove(build.platform)
180+
if noSuccess.isEmpty { return .ok }
181+
}
182+
return .failed(reasons: noSuccess.sorted().map { "Platform without successful builds: \($0)" })
183+
}
184+
185+
func validateSwiftVersionsSuccessful() -> Alerting.Validation {
186+
var noSuccess = Set(SwiftVersion.allActive)
187+
for build in self where build.status == .ok {
188+
noSuccess.remove(build.swiftVersion)
189+
if noSuccess.isEmpty { return .ok }
190+
}
191+
return .failed(reasons: noSuccess.sorted().map { "Swift version without successful builds: \($0)" })
192+
}
193+
194+
func validateRunnerIdsPresent() -> Alerting.Validation {
195+
var notSeen = Set(Current.runnerIds())
196+
for build in self where build.runnerId != nil {
197+
notSeen.remove(build.runnerId!)
198+
if notSeen.isEmpty { return .ok }
199+
}
200+
return .failed(reasons: notSeen.sorted().map { "Missing runner id: \($0)" })
201+
}
202+
203+
func validateRunnerIdsSuccessful() -> Alerting.Validation {
204+
var noSuccess = Set(Current.runnerIds())
205+
for build in self where build.runnerId != nil && build.status == .ok {
206+
noSuccess.remove(build.runnerId!)
207+
if noSuccess.isEmpty { return .ok }
208+
}
209+
return .failed(reasons: noSuccess.sorted().map { "Runner id without successful builds: \($0)" })
210+
}
211+
212+
func validateSuccessRateInRange() -> Alerting.Validation {
213+
let successRate = Double(filter { $0.status == .ok }.count) / Double(count)
214+
// Success rate has been around 30% generally
215+
if 0.2 <= successRate && successRate <= 0.4 {
216+
return .ok
217+
} else {
218+
let percentSuccessRate = (successRate * 1000).rounded() / 10
219+
return .failed(reasons: ["Global success rate of \(percentSuccessRate)% out of bounds"])
220+
}
221+
}
222+
}
223+
224+
225+
private extension TimeAmount {
226+
var timeInterval: TimeInterval {
227+
Double(nanoseconds) * 1e-9
228+
}
229+
var hours: Int {
230+
Int(timeInterval / 3600.0 + 0.5)
231+
}
232+
}
233+
234+
235+
private extension TimeInterval {
236+
func rounded(decimalPlaces: Int) -> Self {
237+
let scale = (pow(10, decimalPlaces) as NSDecimalNumber).doubleValue
238+
return (self * scale).rounded(.toNearestOrAwayFromZero) / scale
239+
}
240+
}

Sources/App/Commands/Swift6Trigger.swift

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ struct Swift6TriggerCommand: AsyncCommand {
3333

3434
var help: String { "Trigger Swift 6 builds" }
3535

36-
func run(using context: CommandContext, signature: Signature) async throws { Current.setLogger(Logger(component: "swift-6-trigger"))
36+
func run(using context: CommandContext, signature: Signature) async throws {
37+
Current.setLogger(Logger(component: "swift-6-trigger"))
3738

3839
do {
3940
if signature.dryRun {
@@ -48,11 +49,6 @@ struct Swift6TriggerCommand: AsyncCommand {
4849
Current.logger().critical("\(error)")
4950
}
5051
}
51-
52-
func printUsage(using context: CommandContext) {
53-
var context = context
54-
outputHelp(using: &context)
55-
}
5652
}
5753

5854

Sources/App/Core/AppEnvironment.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ struct AppEnvironment {
6868
var plausibleBackendReportingSiteID: () -> String?
6969
var postPlausibleEvent: (Client, Plausible.Event.Kind, Plausible.Path, User?) async throws -> Void
7070
var random: (_ range: ClosedRange<Double>) -> Double
71+
var runnerIds: () -> [String]
7172
var setHTTPClient: (Client) -> Void
7273
var setLogger: (Logger) -> Void
7374
var shell: Shell
@@ -205,6 +206,12 @@ extension AppEnvironment {
205206
plausibleBackendReportingSiteID: { Environment.get("PLAUSIBLE_BACKEND_REPORTING_SITE_ID") },
206207
postPlausibleEvent: Plausible.postEvent,
207208
random: Double.random,
209+
runnerIds: {
210+
Environment.get("RUNNER_IDS")
211+
.map { Data($0.utf8) }
212+
.flatMap { try? JSONDecoder().decode([String].self, from: $0) }
213+
?? []
214+
},
208215
setHTTPClient: { client in Self.httpClient = client },
209216
setLogger: { logger in Self.logger = logger },
210217
shell: .live,

Sources/App/configure.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ public func configure(_ app: Application) async throws -> String {
339339
app.commands.use(ReconcileCommand(), as: "reconcile")
340340
app.commands.use(TriggerBuildsCommand(), as: "trigger-builds")
341341
app.commands.use(ReAnalyzeVersions.Command(), as: "re-analyze-versions")
342+
app.commands.use(Alerting.Command(), as: "alerting")
342343
if Current.environment() == .development {
343344
app.commands.use(Swift6TriggerCommand(), as: "swift-6-trigger")
344345
}

0 commit comments

Comments
 (0)