Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/Zero/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ enum Constants {

enum Preferences {
static let selectedOrgLogin = "com.zero.ide.last_selected_org_login"
static let telemetryOptIn = "com.zero.ide.telemetry_opt_in"
}
}
59 changes: 59 additions & 0 deletions Sources/Zero/Models/ZeroError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,65 @@ enum ZeroError: Error, Equatable {
return true
}
}

var telemetryCode: String {
switch self {
case .dockerNotInstalled:
return "docker_not_installed"
case .containerCreationFailed:
return "container_creation_failed"
case .containerExecutionFailed:
return "container_execution_failed"
case .containerNotFound:
return "container_not_found"
case .imageNotFound:
return "image_not_found"
case .gitNotInstalled:
return "git_not_installed"
case .gitCloneFailed:
return "git_clone_failed"
case .gitAuthenticationFailed:
return "git_authentication_failed"
case .invalidRepositoryURL:
return "invalid_repository_url"
case .githubAPIFailed:
return "github_api_failed"
case .githubAuthenticationFailed:
return "github_authentication_failed"
case .githubRateLimited:
return "github_rate_limited"
case .buildConfigurationFailed:
return "build_configuration_failed"
case .jdkNotFound:
return "jdk_not_found"
case .invalidJDKImage:
return "invalid_jdk_image"
case .sessionNotFound:
return "session_not_found"
case .sessionCreationFailed:
return "session_creation_failed"
case .sessionAlreadyExists:
return "session_already_exists"
case .fileNotFound:
return "file_not_found"
case .fileReadFailed:
return "file_read_failed"
case .fileWriteFailed:
return "file_write_failed"
case .keychainSaveFailed:
return "keychain_save_failed"
case .keychainLoadFailed:
return "keychain_load_failed"
case .keychainDeleteFailed:
return "keychain_delete_failed"
case .runtimeCommandFailed:
return "runtime_command_failed"
case .unknown:
return "unknown"
case .notImplemented:
return "not_implemented"
}
}
}

// MARK: - Result Extension
Expand Down
74 changes: 73 additions & 1 deletion Sources/Zero/Services/ExecutionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,33 @@ class ExecutionService: ObservableObject {
let runProfileService: RunProfileService
@Published var status: ExecutionStatus = .idle
@Published var output: String = ""
@Published private(set) var telemetrySummary: ExecutionTelemetrySummary = .empty

var telemetryEnabled = false

private var cancellationRequested = false
private let installTimeoutSeconds: TimeInterval = 20
private let installMaxAttempts = 3
private let installRetryDelayNanoseconds: UInt64 = 300_000_000
private let nowProvider: () -> Date
private var telemetryTotalDurationSeconds: TimeInterval = 0
private var telemetryErrorCounts: [String: Int] = [:]

init(
dockerService: DockerServiceProtocol,
buildConfigService: BuildConfigurationService = FileBasedBuildConfigurationService(),
runProfileService: RunProfileService = FileBasedRunProfileService()
runProfileService: RunProfileService = FileBasedRunProfileService(),
nowProvider: @escaping () -> Date = Date.init
) {
self.dockerService = dockerService
self.buildConfigService = buildConfigService
self.runProfileService = runProfileService
self.nowProvider = nowProvider
}

func run(container: String, command: String) async {
let startedAt = nowProvider()

await MainActor.run {
self.status = .running
self.cancellationRequested = false
Expand All @@ -52,19 +63,28 @@ class ExecutionService: ObservableObject {
if self.cancellationRequested {
self.status = .failed("Execution cancelled")
self.output += "\n⏹️ Execution cancelled by user"
self.recordTelemetryIfEnabled(success: false, errorCode: "execution_cancelled", startedAt: startedAt, endedAt: self.nowProvider())
} else {
self.status = .success
self.recordTelemetryIfEnabled(success: true, errorCode: nil, startedAt: startedAt, endedAt: self.nowProvider())
}
}
} catch {
await MainActor.run {
if self.cancellationRequested {
self.status = .failed("Execution cancelled")
self.output += "\n⏹️ Execution cancelled by user"
self.recordTelemetryIfEnabled(success: false, errorCode: "execution_cancelled", startedAt: startedAt, endedAt: self.nowProvider())
} else {
let userMessage = self.userMessage(for: error)
self.status = .failed(userMessage)
self.output += "\n❌ Error: \(userMessage)"
self.recordTelemetryIfEnabled(
success: false,
errorCode: self.telemetryErrorCode(for: error),
startedAt: startedAt,
endedAt: self.nowProvider()
)
}
}
}
Expand Down Expand Up @@ -152,6 +172,58 @@ class ExecutionService: ObservableObject {
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
}

private func recordTelemetryIfEnabled(success: Bool, errorCode: String?, startedAt: Date, endedAt: Date) {
guard telemetryEnabled else { return }

let elapsed = max(0, endedAt.timeIntervalSince(startedAt))
telemetryTotalDurationSeconds += elapsed

let totalRuns = telemetrySummary.totalRuns + 1
let successfulRuns = telemetrySummary.successfulRuns + (success ? 1 : 0)
let failedRuns = telemetrySummary.failedRuns + (success ? 0 : 1)

if let errorCode {
telemetryErrorCounts[errorCode, default: 0] += 1
}

let topErrorCodes = telemetryErrorCounts
.map { TelemetryErrorMetric(code: $0.key, count: $0.value) }
.sorted { lhs, rhs in
if lhs.count == rhs.count {
return lhs.code < rhs.code
}
return lhs.count > rhs.count
}
.prefix(3)

let averageDurationSeconds = telemetryTotalDurationSeconds / Double(totalRuns)

telemetrySummary = ExecutionTelemetrySummary(
totalRuns: totalRuns,
successfulRuns: successfulRuns,
failedRuns: failedRuns,
averageDurationSeconds: averageDurationSeconds,
topErrorCodes: Array(topErrorCodes)
)
}

private func telemetryErrorCode(for error: Error) -> String {
if let zeroError = error as? ZeroError {
return zeroError.telemetryCode
}

if let urlError = error as? URLError {
return "url_error_\(urlError.errorCode)"
}

if case let CommandRunnerError.commandFailed(_, _, exitCode, _) = error {
return "command_failed_\(exitCode)"
}

let nsError = error as NSError
return "\(nsError.domain)#\(nsError.code)"
}

func createJavaContainer(name: String) async throws -> String {
let config = try buildConfigService.load()
Expand Down
29 changes: 29 additions & 0 deletions Sources/Zero/Services/ExecutionTelemetry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation

struct TelemetryErrorMetric: Equatable, Identifiable {
let code: String
let count: Int

var id: String { code }
}

struct ExecutionTelemetrySummary: Equatable {
let totalRuns: Int
let successfulRuns: Int
let failedRuns: Int
let averageDurationSeconds: TimeInterval
let topErrorCodes: [TelemetryErrorMetric]

static let empty = ExecutionTelemetrySummary(
totalRuns: 0,
successfulRuns: 0,
failedRuns: 0,
averageDurationSeconds: 0,
topErrorCodes: []
)

var successRate: Double {
guard totalRuns > 0 else { return 0 }
return Double(successfulRuns) / Double(totalRuns)
}
}
19 changes: 19 additions & 0 deletions Sources/Zero/Views/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ class AppState: ObservableObject {
persistSelectedOrgContextIfNeeded()
}
}

@Published var telemetryOptIn: Bool = false {
didSet {
persistTelemetryOptInIfNeeded()
executionService.telemetryEnabled = telemetryOptIn
}
}

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

var pendingOAuthStateForTesting: String? {
pendingOAuthState
Expand All @@ -94,6 +102,12 @@ class AppState: ObservableObject {
self.persistedSessionDeleter = { session in
try manager.deleteSession(session)
}

let storedTelemetryOptIn = UserDefaults.standard.bool(forKey: Constants.Preferences.telemetryOptIn)
shouldPersistTelemetryOptIn = false
telemetryOptIn = storedTelemetryOptIn
shouldPersistTelemetryOptIn = true
executionService.telemetryEnabled = storedTelemetryOptIn

checkLoginStatus()
}
Expand Down Expand Up @@ -416,6 +430,11 @@ class AppState: ObservableObject {
shouldPersistSelectedOrgContext = true
}

private func persistTelemetryOptInIfNeeded() {
guard shouldPersistTelemetryOptIn else { return }
UserDefaults.standard.set(telemetryOptIn, forKey: Constants.Preferences.telemetryOptIn)
}

private func reconcileSelectedOrgContext() {
if let currentOrg = selectedOrg,
let matchingCurrentOrg = organizations.first(where: { $0.login == currentOrg.login }) {
Expand Down
35 changes: 35 additions & 0 deletions Sources/Zero/Views/DiagnosticsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,33 @@ struct DiagnosticsView: View {
.foregroundStyle(exportMessage.hasPrefix("Failed") ? Color.red : Color.secondary)
}

VStack(alignment: .leading, spacing: 10) {
Toggle("Enable lightweight telemetry", isOn: $appState.telemetryOptIn)

let summary = appState.executionService.telemetrySummary
Text("Success Rate: \(formatPercent(summary.successRate))")
.font(.caption)
.foregroundStyle(.secondary)
Text("Average Runtime: \(formatDuration(summary.averageDurationSeconds))")
.font(.caption)
.foregroundStyle(.secondary)

if summary.topErrorCodes.isEmpty {
Text("Top Error Codes: none")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(summary.topErrorCodes) { metric in
Text("Error: \(metric.code) (\(metric.count))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(12)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(8)

if let snapshot {
VStack(alignment: .leading, spacing: 10) {
Text("Docker")
Expand Down Expand Up @@ -178,6 +205,14 @@ struct DiagnosticsView: View {
}
}
}

private func formatPercent(_ value: Double) -> String {
String(format: "%.1f%%", value * 100)
}

private func formatDuration(_ value: TimeInterval) -> String {
String(format: "%.2fs", value)
}
}

private struct DiagnosticsStatusRow: View {
Expand Down
20 changes: 20 additions & 0 deletions Tests/ZeroTests/AppStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ class AppStateTests: XCTestCase {
// Clear keychain before test to ensure clean state
try? KeychainHelper.standard.delete(service: "com.zero.ide", account: "github_token")
UserDefaults.standard.removeObject(forKey: Constants.Preferences.selectedOrgLogin)
UserDefaults.standard.removeObject(forKey: Constants.Preferences.telemetryOptIn)
appState = AppState()
}

override func tearDown() {
UserDefaults.standard.removeObject(forKey: Constants.Preferences.selectedOrgLogin)
UserDefaults.standard.removeObject(forKey: Constants.Preferences.telemetryOptIn)
appState = nil
super.tearDown()
}
Expand Down Expand Up @@ -495,6 +497,24 @@ class AppStateTests: XCTestCase {
// Then
XCTAssertEqual(selectedName, "selected-repo")
}

func testTelemetryOptInPersistsAcrossAppStateInstances() {
// Given
appState.telemetryOptIn = true

// When
let reloadedState = AppState()

// Then
XCTAssertTrue(reloadedState.telemetryOptIn)

// When
reloadedState.telemetryOptIn = false
let reloadedAgain = AppState()

// Then
XCTAssertFalse(reloadedAgain.telemetryOptIn)
}
}

// MARK: - Mocks
Expand Down
Loading