@@ -14,22 +14,33 @@ class ExecutionService: ObservableObject {
1414 let runProfileService : RunProfileService
1515 @Published var status : ExecutionStatus = . idle
1616 @Published var output : String = " "
17+ @Published private( set) var telemetrySummary : ExecutionTelemetrySummary = . empty
18+
19+ var telemetryEnabled = false
20+
1721 private var cancellationRequested = false
1822 private let installTimeoutSeconds : TimeInterval = 20
1923 private let installMaxAttempts = 3
2024 private let installRetryDelayNanoseconds : UInt64 = 300_000_000
25+ private let nowProvider : ( ) -> Date
26+ private var telemetryTotalDurationSeconds : TimeInterval = 0
27+ private var telemetryErrorCounts : [ String : Int ] = [ : ]
2128
2229 init (
2330 dockerService: DockerServiceProtocol ,
2431 buildConfigService: BuildConfigurationService = FileBasedBuildConfigurationService ( ) ,
25- runProfileService: RunProfileService = FileBasedRunProfileService ( )
32+ runProfileService: RunProfileService = FileBasedRunProfileService ( ) ,
33+ nowProvider: @escaping ( ) -> Date = Date . init
2634 ) {
2735 self . dockerService = dockerService
2836 self . buildConfigService = buildConfigService
2937 self . runProfileService = runProfileService
38+ self . nowProvider = nowProvider
3039 }
3140
3241 func run( container: String , command: String ) async {
42+ let startedAt = nowProvider ( )
43+
3344 await MainActor . run {
3445 self . status = . running
3546 self . cancellationRequested = false
@@ -52,19 +63,28 @@ class ExecutionService: ObservableObject {
5263 if self . cancellationRequested {
5364 self . status = . failed( " Execution cancelled " )
5465 self . output += " \n ⏹️ Execution cancelled by user "
66+ self . recordTelemetryIfEnabled ( success: false , errorCode: " execution_cancelled " , startedAt: startedAt, endedAt: self . nowProvider ( ) )
5567 } else {
5668 self . status = . success
69+ self . recordTelemetryIfEnabled ( success: true , errorCode: nil , startedAt: startedAt, endedAt: self . nowProvider ( ) )
5770 }
5871 }
5972 } catch {
6073 await MainActor . run {
6174 if self . cancellationRequested {
6275 self . status = . failed( " Execution cancelled " )
6376 self . output += " \n ⏹️ Execution cancelled by user "
77+ self . recordTelemetryIfEnabled ( success: false , errorCode: " execution_cancelled " , startedAt: startedAt, endedAt: self . nowProvider ( ) )
6478 } else {
6579 let userMessage = self . userMessage ( for: error)
6680 self . status = . failed( userMessage)
6781 self . output += " \n ❌ Error: \( userMessage) "
82+ self . recordTelemetryIfEnabled (
83+ success: false ,
84+ errorCode: self . telemetryErrorCode ( for: error) ,
85+ startedAt: startedAt,
86+ endedAt: self . nowProvider ( )
87+ )
6888 }
6989 }
7090 }
@@ -152,6 +172,58 @@ class ExecutionService: ObservableObject {
152172 . replacingOccurrences ( of: " \\ " , with: " \\ \\ " )
153173 . replacingOccurrences ( of: " \" " , with: " \\ \" " )
154174 }
175+
176+ private func recordTelemetryIfEnabled( success: Bool , errorCode: String ? , startedAt: Date , endedAt: Date ) {
177+ guard telemetryEnabled else { return }
178+
179+ let elapsed = max ( 0 , endedAt. timeIntervalSince ( startedAt) )
180+ telemetryTotalDurationSeconds += elapsed
181+
182+ let totalRuns = telemetrySummary. totalRuns + 1
183+ let successfulRuns = telemetrySummary. successfulRuns + ( success ? 1 : 0 )
184+ let failedRuns = telemetrySummary. failedRuns + ( success ? 0 : 1 )
185+
186+ if let errorCode {
187+ telemetryErrorCounts [ errorCode, default: 0 ] += 1
188+ }
189+
190+ let topErrorCodes = telemetryErrorCounts
191+ . map { TelemetryErrorMetric ( code: $0. key, count: $0. value) }
192+ . sorted { lhs, rhs in
193+ if lhs. count == rhs. count {
194+ return lhs. code < rhs. code
195+ }
196+ return lhs. count > rhs. count
197+ }
198+ . prefix ( 3 )
199+
200+ let averageDurationSeconds = telemetryTotalDurationSeconds / Double( totalRuns)
201+
202+ telemetrySummary = ExecutionTelemetrySummary (
203+ totalRuns: totalRuns,
204+ successfulRuns: successfulRuns,
205+ failedRuns: failedRuns,
206+ averageDurationSeconds: averageDurationSeconds,
207+ topErrorCodes: Array ( topErrorCodes)
208+ )
209+ }
210+
211+ private func telemetryErrorCode( for error: Error ) -> String {
212+ if let zeroError = error as? ZeroError {
213+ return zeroError. telemetryCode
214+ }
215+
216+ if let urlError = error as? URLError {
217+ return " url_error_ \( urlError. errorCode) "
218+ }
219+
220+ if case let CommandRunnerError . commandFailed( _, _, exitCode, _) = error {
221+ return " command_failed_ \( exitCode) "
222+ }
223+
224+ let nsError = error as NSError
225+ return " \( nsError. domain) # \( nsError. code) "
226+ }
155227
156228 func createJavaContainer( name: String ) async throws -> String {
157229 let config = try buildConfigService. load ( )
0 commit comments