Skip to content

Commit beab80d

Browse files
authored
Merge pull request #87 from zero-ide/feature/optin-telemetry
feat(diagnostics): add opt-in lightweight telemetry
2 parents 4296eab + 77eab9d commit beab80d

File tree

8 files changed

+271
-1
lines changed

8 files changed

+271
-1
lines changed

Sources/Zero/Constants.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ enum Constants {
2424

2525
enum Preferences {
2626
static let selectedOrgLogin = "com.zero.ide.last_selected_org_login"
27+
static let telemetryOptIn = "com.zero.ide.telemetry_opt_in"
2728
}
2829
}

Sources/Zero/Models/ZeroError.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,65 @@ enum ZeroError: Error, Equatable {
167167
return true
168168
}
169169
}
170+
171+
var telemetryCode: String {
172+
switch self {
173+
case .dockerNotInstalled:
174+
return "docker_not_installed"
175+
case .containerCreationFailed:
176+
return "container_creation_failed"
177+
case .containerExecutionFailed:
178+
return "container_execution_failed"
179+
case .containerNotFound:
180+
return "container_not_found"
181+
case .imageNotFound:
182+
return "image_not_found"
183+
case .gitNotInstalled:
184+
return "git_not_installed"
185+
case .gitCloneFailed:
186+
return "git_clone_failed"
187+
case .gitAuthenticationFailed:
188+
return "git_authentication_failed"
189+
case .invalidRepositoryURL:
190+
return "invalid_repository_url"
191+
case .githubAPIFailed:
192+
return "github_api_failed"
193+
case .githubAuthenticationFailed:
194+
return "github_authentication_failed"
195+
case .githubRateLimited:
196+
return "github_rate_limited"
197+
case .buildConfigurationFailed:
198+
return "build_configuration_failed"
199+
case .jdkNotFound:
200+
return "jdk_not_found"
201+
case .invalidJDKImage:
202+
return "invalid_jdk_image"
203+
case .sessionNotFound:
204+
return "session_not_found"
205+
case .sessionCreationFailed:
206+
return "session_creation_failed"
207+
case .sessionAlreadyExists:
208+
return "session_already_exists"
209+
case .fileNotFound:
210+
return "file_not_found"
211+
case .fileReadFailed:
212+
return "file_read_failed"
213+
case .fileWriteFailed:
214+
return "file_write_failed"
215+
case .keychainSaveFailed:
216+
return "keychain_save_failed"
217+
case .keychainLoadFailed:
218+
return "keychain_load_failed"
219+
case .keychainDeleteFailed:
220+
return "keychain_delete_failed"
221+
case .runtimeCommandFailed:
222+
return "runtime_command_failed"
223+
case .unknown:
224+
return "unknown"
225+
case .notImplemented:
226+
return "not_implemented"
227+
}
228+
}
170229
}
171230

172231
// MARK: - Result Extension

Sources/Zero/Services/ExecutionService.swift

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import Foundation
2+
3+
struct TelemetryErrorMetric: Equatable, Identifiable {
4+
let code: String
5+
let count: Int
6+
7+
var id: String { code }
8+
}
9+
10+
struct ExecutionTelemetrySummary: Equatable {
11+
let totalRuns: Int
12+
let successfulRuns: Int
13+
let failedRuns: Int
14+
let averageDurationSeconds: TimeInterval
15+
let topErrorCodes: [TelemetryErrorMetric]
16+
17+
static let empty = ExecutionTelemetrySummary(
18+
totalRuns: 0,
19+
successfulRuns: 0,
20+
failedRuns: 0,
21+
averageDurationSeconds: 0,
22+
topErrorCodes: []
23+
)
24+
25+
var successRate: Double {
26+
guard totalRuns > 0 else { return 0 }
27+
return Double(successfulRuns) / Double(totalRuns)
28+
}
29+
}

Sources/Zero/Views/AppState.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ class AppState: ObservableObject {
2222
persistSelectedOrgContextIfNeeded()
2323
}
2424
}
25+
26+
@Published var telemetryOptIn: Bool = false {
27+
didSet {
28+
persistTelemetryOptInIfNeeded()
29+
executionService.telemetryEnabled = telemetryOptIn
30+
}
31+
}
2532

2633
// 페이지 크기 (테스트 시 조정 가능)
2734
var pageSize: Int = Constants.GitHub.pageSize
@@ -70,6 +77,7 @@ class AppState: ObservableObject {
7077
private var pendingOAuthCodeVerifier: String?
7178
private var pendingOAuthRedirectURI: String?
7279
private var shouldPersistSelectedOrgContext = true
80+
private var shouldPersistTelemetryOptIn = true
7381

7482
var pendingOAuthStateForTesting: String? {
7583
pendingOAuthState
@@ -94,6 +102,12 @@ class AppState: ObservableObject {
94102
self.persistedSessionDeleter = { session in
95103
try manager.deleteSession(session)
96104
}
105+
106+
let storedTelemetryOptIn = UserDefaults.standard.bool(forKey: Constants.Preferences.telemetryOptIn)
107+
shouldPersistTelemetryOptIn = false
108+
telemetryOptIn = storedTelemetryOptIn
109+
shouldPersistTelemetryOptIn = true
110+
executionService.telemetryEnabled = storedTelemetryOptIn
97111

98112
checkLoginStatus()
99113
}
@@ -416,6 +430,11 @@ class AppState: ObservableObject {
416430
shouldPersistSelectedOrgContext = true
417431
}
418432

433+
private func persistTelemetryOptInIfNeeded() {
434+
guard shouldPersistTelemetryOptIn else { return }
435+
UserDefaults.standard.set(telemetryOptIn, forKey: Constants.Preferences.telemetryOptIn)
436+
}
437+
419438
private func reconcileSelectedOrgContext() {
420439
if let currentOrg = selectedOrg,
421440
let matchingCurrentOrg = organizations.first(where: { $0.login == currentOrg.login }) {

Sources/Zero/Views/DiagnosticsView.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,33 @@ struct DiagnosticsView: View {
5252
.foregroundStyle(exportMessage.hasPrefix("Failed") ? Color.red : Color.secondary)
5353
}
5454

55+
VStack(alignment: .leading, spacing: 10) {
56+
Toggle("Enable lightweight telemetry", isOn: $appState.telemetryOptIn)
57+
58+
let summary = appState.executionService.telemetrySummary
59+
Text("Success Rate: \(formatPercent(summary.successRate))")
60+
.font(.caption)
61+
.foregroundStyle(.secondary)
62+
Text("Average Runtime: \(formatDuration(summary.averageDurationSeconds))")
63+
.font(.caption)
64+
.foregroundStyle(.secondary)
65+
66+
if summary.topErrorCodes.isEmpty {
67+
Text("Top Error Codes: none")
68+
.font(.caption)
69+
.foregroundStyle(.secondary)
70+
} else {
71+
ForEach(summary.topErrorCodes) { metric in
72+
Text("Error: \(metric.code) (\(metric.count))")
73+
.font(.caption)
74+
.foregroundStyle(.secondary)
75+
}
76+
}
77+
}
78+
.padding(12)
79+
.background(Color(nsColor: .controlBackgroundColor))
80+
.cornerRadius(8)
81+
5582
if let snapshot {
5683
VStack(alignment: .leading, spacing: 10) {
5784
Text("Docker")
@@ -178,6 +205,14 @@ struct DiagnosticsView: View {
178205
}
179206
}
180207
}
208+
209+
private func formatPercent(_ value: Double) -> String {
210+
String(format: "%.1f%%", value * 100)
211+
}
212+
213+
private func formatDuration(_ value: TimeInterval) -> String {
214+
String(format: "%.2fs", value)
215+
}
181216
}
182217

183218
private struct DiagnosticsStatusRow: View {

Tests/ZeroTests/AppStateTests.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ class AppStateTests: XCTestCase {
1111
// Clear keychain before test to ensure clean state
1212
try? KeychainHelper.standard.delete(service: "com.zero.ide", account: "github_token")
1313
UserDefaults.standard.removeObject(forKey: Constants.Preferences.selectedOrgLogin)
14+
UserDefaults.standard.removeObject(forKey: Constants.Preferences.telemetryOptIn)
1415
appState = AppState()
1516
}
1617

1718
override func tearDown() {
1819
UserDefaults.standard.removeObject(forKey: Constants.Preferences.selectedOrgLogin)
20+
UserDefaults.standard.removeObject(forKey: Constants.Preferences.telemetryOptIn)
1921
appState = nil
2022
super.tearDown()
2123
}
@@ -495,6 +497,24 @@ class AppStateTests: XCTestCase {
495497
// Then
496498
XCTAssertEqual(selectedName, "selected-repo")
497499
}
500+
501+
func testTelemetryOptInPersistsAcrossAppStateInstances() {
502+
// Given
503+
appState.telemetryOptIn = true
504+
505+
// When
506+
let reloadedState = AppState()
507+
508+
// Then
509+
XCTAssertTrue(reloadedState.telemetryOptIn)
510+
511+
// When
512+
reloadedState.telemetryOptIn = false
513+
let reloadedAgain = AppState()
514+
515+
// Then
516+
XCTAssertFalse(reloadedAgain.telemetryOptIn)
517+
}
498518
}
499519

500520
// MARK: - Mocks

0 commit comments

Comments
 (0)