Skip to content
Open
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
5 changes: 4 additions & 1 deletion desktop/macos/Desktop/Sources/AnalyticsManager.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import AppKit

Check warning on line 1 in desktop/macos/Desktop/Sources/AnalyticsManager.swift

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

desktop/macos/Desktop/Sources/AnalyticsManager.swift is 1009 lines; consider splitting files over 800 lines.
import Foundation
import Sentry

Expand Down Expand Up @@ -634,11 +634,14 @@
}

/// Track when the Claude agent bridge fails to start or errors
func chatAgentError(error: String, rawError: String? = nil) {
func chatAgentError(error: String, rawError: String? = nil, errorKind: String? = nil) {
var props: [String: Any] = ["error": error]
if let raw = rawError, raw != error {
props["raw_error"] = String(raw.prefix(500))
}
if let errorKind {
props["error_kind"] = errorKind
}
PostHogManager.shared.track("chat_agent_error", properties: props)
}

Expand Down
29 changes: 29 additions & 0 deletions desktop/macos/Desktop/Sources/Chat/AgentBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,35 @@ enum BridgeError: LocalizedError {
}
}

/// Stable, low-cardinality category for `chat_agent_error` telemetry so the
/// opaque "bridge failed to start" bucket can be split into missing-install /
/// launch-failure / crash / provider-config buckets in PostHog (issue #9342).
static func telemetryKind(for error: Error) -> String {
if let bridgeError = error as? BridgeError {
switch bridgeError {
case .nodeNotFound: return "bridge.node_not_found"
case .bridgeScriptNotFound: return "bridge.script_not_found"
case .notRunning: return "bridge.not_running"
case .encodingError: return "bridge.encoding_error"
case .timeout: return "bridge.timeout"
case .processExited: return "bridge.process_exited"
case .outOfMemory: return "bridge.out_of_memory"
case .stopped: return "bridge.stopped"
case .restarting: return "bridge.restarting"
case .requestAlreadyActive: return "bridge.request_already_active"
case .agentError: return "bridge.agent_error"
case .agentRuntimeFailure: return "bridge.agent_runtime_failure"
case .quotaExceeded: return "bridge.quota_exceeded"
case .authMissing: return "bridge.auth_missing"
}
}
// Non-BridgeError launch failures (e.g. Process spawn errors) carry an
// NSError domain+code — POSIX ENOENT distinguishes a missing executable
// (install problem) from other launch failures. Bounded cardinality.
let nsError = error as NSError
return "\(nsError.domain).\(nsError.code)"
}

private static func isSessionAuthenticationFailureMessage(_ message: String) -> Bool {
let normalized = message.lowercased()
if normalized.contains("invalid_token") || normalized.contains("please sign in") {
Expand Down
12 changes: 10 additions & 2 deletions desktop/macos/Desktop/Sources/Providers/ChatProvider.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import SwiftUI

Check warning on line 1 in desktop/macos/Desktop/Sources/Providers/ChatProvider.swift

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

desktop/macos/Desktop/Sources/Providers/ChatProvider.swift is 5410 lines; consider splitting files over 800 lines.
import Combine
import CoreGraphics
import GRDB
Expand Down Expand Up @@ -1384,7 +1384,11 @@
currentError = card
errorMessage = nil
} else {
AnalyticsManager.shared.chatAgentError(error: "AI not available: bridge failed to start", rawError: rawError)
AnalyticsManager.shared.chatAgentError(
error: "AI not available: bridge failed to start",
rawError: rawError,
errorKind: BridgeError.telemetryKind(for: error)
)
errorMessage = "AI not available: \(error.localizedDescription)"
}
return false
Expand Down Expand Up @@ -4342,7 +4346,11 @@
} else {
rawError = "\(error)"
}
AnalyticsManager.shared.chatAgentError(error: error.localizedDescription, rawError: rawError)
AnalyticsManager.shared.chatAgentError(
error: error.localizedDescription,
rawError: rawError,
errorKind: BridgeError.telemetryKind(for: error)
)

// Track onboarding errors with full context
if isOnboarding {
Expand Down
23 changes: 23 additions & 0 deletions desktop/macos/Desktop/Tests/ChatErrorStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,29 @@ final class ChatErrorStateMappingTests: XCTestCase {
BridgeError.quotaExceeded(plan: "free", unit: "msg", used: 100, limit: 100, resetAtUnix: nil)
))
}

/// `chat_agent_error` telemetry categorization (issue #9342): the generic
/// "bridge failed to start" bucket must be split into distinct, stable
/// `error_kind` values so PostHog can tell missing-install / launch-failure /
/// crash / provider-config failures apart. Distinct causes → distinct kinds.
func testTelemetryKindDistinguishesFailureModes() {
XCTAssertEqual(BridgeError.telemetryKind(for: BridgeError.nodeNotFound), "bridge.node_not_found")
XCTAssertEqual(BridgeError.telemetryKind(for: BridgeError.bridgeScriptNotFound), "bridge.script_not_found")
XCTAssertEqual(BridgeError.telemetryKind(for: BridgeError.processExited), "bridge.process_exited")
XCTAssertEqual(BridgeError.telemetryKind(for: BridgeError.outOfMemory), "bridge.out_of_memory")
XCTAssertEqual(BridgeError.telemetryKind(for: BridgeError.agentError("boom")), "bridge.agent_error")

// Non-BridgeError launch failures (e.g. Process spawn ENOENT = missing
// executable) surface the NSError domain+code, not the opaque bucket.
let spawnError = NSError(domain: NSPOSIXErrorDomain, code: 2)
XCTAssertEqual(BridgeError.telemetryKind(for: spawnError), "\(NSPOSIXErrorDomain).2")

// A missing binary and a crash must NOT collapse to the same kind.
XCTAssertNotEqual(
BridgeError.telemetryKind(for: BridgeError.bridgeScriptNotFound),
BridgeError.telemetryKind(for: BridgeError.processExited)
)
}
}

final class ChatErrorStateTests: XCTestCase {
Expand Down
Loading