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
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,10 @@ public struct DiagnosticEventPresentation: Sendable {
fields.append(decodeA(a, code: event.code))
}
if let b = event.b {
fields.append(decodeB(b, code: event.code))
fields.append(decodeB(b, event: event))
}
if let ms = event.ms {
fields.append(decodeMilliseconds(ms, code: event.code))
fields.append(decodeMilliseconds(ms, event: event))
}
if let c = event.c {
fields.append(decodeC(c, event: event))
Expand Down Expand Up @@ -227,10 +227,18 @@ public struct DiagnosticEventPresentation: Sendable {

/// The failure kind carried in an event's `b` slot, when applicable.
public func failureKind(of event: DiagnosticEvent) -> DiagnosticFailureKind? {
guard Self.codesWithFailureB.contains(event.code), let b = event.b else { return nil }
guard Self.carriesFailureInB(event), let b = event.b else { return nil }
return DiagnosticFailureKind(rawValue: b)
}

private static func carriesFailureInB(_ event: DiagnosticEvent) -> Bool {
if event.code == .appFeatureAction,
event.a == DiagnosticAppEventKind.taskModelListResultObserved.rawValue {
return false
}
return codesWithFailureB.contains(event.code)
}

/// The transport kind carried in an event's `a` slot, when applicable.
public func transportKind(of event: DiagnosticEvent) -> DiagnosticTransportKind? {
guard Self.codesWithTransportA.contains(event.code), let a = event.a else { return nil }
Expand Down Expand Up @@ -480,11 +488,11 @@ public struct DiagnosticEventPresentation: Sendable {
}
}

private func decodeB(_ raw: Int, code: DiagnosticEventCode) -> Field {
if Self.codesWithFailureB.contains(code) {
private func decodeB(_ raw: Int, event: DiagnosticEvent) -> Field {
if Self.carriesFailureInB(event) {
return Field(key: "failure", value: failureName(raw))
}
switch code {
switch event.code {
case .recoveryStarted:
return Field(key: "trigger", value: recoveryTriggerName(raw))
case .transportSessionLifecycle:
Expand All @@ -507,6 +515,12 @@ public struct DiagnosticEventPresentation: Sendable {
return Field(key: "private_fallback_paths", value: String(raw))
case .discoverySucceeded:
return Field(key: "bindings", value: String(raw))
case .appFeatureAction:
if event.a == DiagnosticAppEventKind.taskModelListResultObserved.rawValue,
let provider = DiagnosticTaskModelProvider(rawValue: raw) {
return Field(key: "provider", value: taskModelProviderName(provider))
Comment thread
azooz2003-bit marked this conversation as resolved.
}
return Field(key: "detail_2", value: String(raw))
case .transportPrivateAddressJoin:
return Field(key: "configured_addresses", value: String(raw))
case .transportLANDiscovery:
Expand All @@ -532,9 +546,9 @@ public struct DiagnosticEventPresentation: Sendable {

private func decodeMilliseconds(
_ raw: UInt32,
code: DiagnosticEventCode
event: DiagnosticEvent
) -> Field {
switch code {
switch event.code {
case .renderGridLag:
return Field(key: "lag", value: duration(raw))
case .livenessResubscribe:
Expand All @@ -543,6 +557,11 @@ public struct DiagnosticEventPresentation: Sendable {
return Field(key: "retry_delay", value: duration(raw))
case .transportCloseAttribution:
return Field(key: "application_error_code", value: String(raw))
case .appFeatureAction:
if event.a == DiagnosticAppEventKind.taskModelListResultObserved.rawValue {
return Field(key: "effort_count", value: String(raw))
}
return Field(key: "duration", value: duration(raw))
case .composerActiveTransition, .composerKeyboardToggleWhilePresented:
return Field(key: "keyboard_height", value: pointCount(Int(raw)))
default:
Expand Down Expand Up @@ -604,6 +623,10 @@ public struct DiagnosticEventPresentation: Sendable {
return Field(key: "method", value: connectionMethodName(raw))
case .foregroundTransportSelected:
return Field(key: "transport", value: transportName(raw))
case .taskModelListResultObserved:
if let source = DiagnosticTaskModelSource(rawValue: raw) {
return Field(key: "source", value: taskModelSourceName(source))
}
default:
if Self.appEventKindsWithValuePayload.contains(kind) {
return Field(key: "value", value: String(raw))
Expand Down Expand Up @@ -681,6 +704,23 @@ public struct DiagnosticEventPresentation: Sendable {
?? unknownPayloadName(raw)
}

private func taskModelProviderName(_ provider: DiagnosticTaskModelProvider) -> String {
switch provider {
case .claude: "claude"
case .codex: "codex"
case .openCode: "opencode"
}
}

private func taskModelSourceName(_ source: DiagnosticTaskModelSource) -> String {
switch source {
case .discovered: "discovered"
case .backend: "backend"
case .augmented: "augmented"
case .fallback: "fallback"
}
}

private func unknownPayloadName(_ raw: Int) -> String {
localized(
"diagnostics.unknown.payload",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,27 @@ public final class DiagnosticLog: Sendable {
))
}

/// Records the source and effort metadata of one visible task model result.
///
/// The fixed integer slots avoid exporting provider names, model IDs, or
/// command output. The event's `b` slot is the provider, `c` is the source,
/// and `ms` is the total number of efforts exposed by the result.
public nonisolated func recordTaskModelResult(
correlationID: String?,
provider: DiagnosticTaskModelProvider,
source: DiagnosticTaskModelSource,
effortCount: Int
) {
record(DiagnosticEvent(
.appFeatureAction,
surface: correlation.handle(for: correlationID),
ms: UInt32(clamping: max(0, effortCount)),
a: DiagnosticAppEventKind.taskModelListResultObserved.rawValue,
b: provider.rawValue,
c: source.rawValue
))
}

/// Records one terminal-operation phase with a per-minute admission cap.
/// The ring and AppLog still provide their existing bounded retention.
public nonisolated func recordTerminalTrace(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,10 @@ public enum DiagnosticAppEventKind: Int, Sendable, Codable, CaseIterable {
case authBootstrapCompleted = 664
/// A DEBUG launch attach route was admitted by the startup coordinator.
case dogfoodAttachStarted = 665
/// A visible task model result records its provider, source, and total
/// effort count for root-cause queries. `b` is the provider,
/// `c` is the source, and `ms` is the effort count.
case taskModelListResultObserved = 666
}

/// The user's configured connection method, mirrored from the settings picker
Expand All @@ -882,6 +886,21 @@ public enum DiagnosticConnectionMethod: Int, Sendable, Codable, CaseIterable {
case direct = 2
}

/// Provider identifier carried by task model discovery diagnostics.
public enum DiagnosticTaskModelProvider: Int, Sendable, Codable, CaseIterable {
case claude = 1
case codex = 2
case openCode = 3
}

/// Source identifier carried by task model discovery diagnostics.
public enum DiagnosticTaskModelSource: Int, Sendable, Codable, CaseIterable {
case discovered = 1
case backend = 2
case augmented = 3
case fallback = 4
}

/// High-level lifecycle state for one phone-controlled Simulator stream.
///
/// Values intentionally omit panel UUIDs, device names, workspace titles, and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ import Testing
DiagnosticEventPresentation(locale: Locale(identifier: "en"))
}

@Test func taskModelResultDoesNotDecodeProviderAsFailure() {
let event = DiagnosticEvent(
.appFeatureAction,
ms: 6,
a: DiagnosticAppEventKind.taskModelListResultObserved.rawValue,
b: DiagnosticTaskModelProvider.codex.rawValue,
c: DiagnosticTaskModelSource.discovered.rawValue
)
let presentation = englishPresentation
let fields = presentation.describe(event).fields
#expect(presentation.failureKind(of: event) == nil)
#expect(fields.contains(.init(key: "provider", value: "codex")))
#expect(fields.contains(.init(key: "source", value: "discovered")))
#expect(fields.contains(.init(key: "effort_count", value: "6")))
#expect(!fields.contains { $0.key == "failure" })

let failed = DiagnosticEvent(
.appFeatureAction,
a: DiagnosticAppEventKind.taskModelListLoadFailed.rawValue,
b: DiagnosticFailureKind.timedOut.rawValue
)
#expect(presentation.failureKind(of: failed) == .timedOut)
}

/// Case names are shipped telemetry vocabulary (Sentry grouping keys), so a
/// rename is a breaking change this test makes visible.
@Test func pinsEventCodeNames() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public final class MobileNetworkOutcomeReporter: Sendable {
public static let eventName = "ios_connectivity_latency"
/// The Axiom event name for task model discovery diagnostics.
public static let taskModelEventName = "ios_task_model_discovery"
/// The Axiom event name for visible task model result metadata.
public static let taskModelResultEventName = "ios_task_model_result"

private enum Phase: String, Hashable, Sendable {
case endpointStart = "endpoint_start"
Expand Down Expand Up @@ -101,7 +103,10 @@ public final class MobileNetworkOutcomeReporter: Sendable {
if event.code == .appFeatureAction,
let kind = event.a.flatMap(DiagnosticAppEventKind.init(rawValue:)),
let properties = Self.taskModelProperties(for: kind, event: event) {
emitter.capture(Self.taskModelEventName, properties)
let eventName = kind == .taskModelListResultObserved
? Self.taskModelResultEventName
: Self.taskModelEventName
emitter.capture(eventName, properties)
return
}
guard Self.mayObserve(event.code) else { return }
Expand All @@ -116,12 +121,47 @@ public final class MobileNetworkOutcomeReporter: Sendable {
await emitter.flush()
}

private static func taskModelProviderName(_ provider: DiagnosticTaskModelProvider) -> String {
switch provider {
case .claude: "claude"
case .codex: "codex"
case .openCode: "opencode"
}
}

private static func taskModelSourceName(_ source: DiagnosticTaskModelSource) -> String {
switch source {
case .discovered: "discovered"
case .backend: "backend"
case .augmented: "augmented"
case .fallback: "fallback"
}
}

/// Builds the task model discovery payload for one discovery event kind,
/// or nil when the kind is not part of that group.
private static func taskModelProperties(
for kind: DiagnosticAppEventKind,
event: DiagnosticEvent
) -> [String: AnalyticsValue]? {
if kind == .taskModelListResultObserved {
guard let provider = event.b.flatMap(DiagnosticTaskModelProvider.init(rawValue:)),
let source = event.c.flatMap(DiagnosticTaskModelSource.init(rawValue:)) else {
return nil
}
var properties: [String: AnalyticsValue] = [
"operation": .string("model_list"),
"outcome": .string("observed"),
"duration_ms": .int(0),
"provider": .string(taskModelProviderName(provider)),
"source": .string(taskModelSourceName(source)),
"effort_count": .int(Int(event.ms ?? 0)),
]
if let surface = event.surface {
properties["correlation_id"] = .int(Int(surface))
}
return properties
}
let outcome: String
let phase: String?
switch kind {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,34 @@ private struct NetworkOutcomeTestConsent: AnalyticsConsentProviding {
#expect(event?.properties["failure"] == .string("hostUnreachable"))
}

@Test func taskModelResultEmitsProviderSourceAndEffortCount() async {
let uploader = RecordingAnalyticsUploader()
let emitter = AnalyticsEmitter(
uploader: uploader,
consent: NetworkOutcomeTestConsent(isTelemetryEnabled: true),
anonymousID: "local-install"
)
let reporter = MobileNetworkOutcomeReporter(emitter: emitter)

reporter.ingest(DiagnosticEvent(
.appFeatureAction,
surface: 42,
ms: 6,
a: DiagnosticAppEventKind.taskModelListResultObserved.rawValue,
b: DiagnosticTaskModelProvider.codex.rawValue,
c: DiagnosticTaskModelSource.discovered.rawValue
))
await reporter.flush()

let event = await uploader.uploadedEvents.first
#expect(event?.name == MobileNetworkOutcomeReporter.taskModelResultEventName)
#expect(event?.properties["operation"] == .string("model_list"))
#expect(event?.properties["provider"] == .string("codex"))
#expect(event?.properties["source"] == .string("discovered"))
#expect(event?.properties["effort_count"] == .int(6))
#expect(event?.properties["correlation_id"] == .int(42))
}

@Test func transportDialCompletionEmitsLatencyOnly() async {
let uploader = RecordingAnalyticsUploader()
let emitter = AnalyticsEmitter(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,40 @@
public import CMUXMobileCore
public import Foundation

public import CmuxMobileShellModel

extension MobileShellComposite {
/// Records the provider/source metadata for one result made visible to the
/// task composer. Counts are aggregate metadata only, never model IDs.
public func recordTaskModelResult(
provider: MobileTaskAgentProvider,
correlationID: String?,
result: MobileTaskModelListResult
) {
let diagnosticProvider: DiagnosticTaskModelProvider = switch provider {
case .claude: .claude
case .codex: .codex
case .openCode: .openCode
}
let diagnosticSource: DiagnosticTaskModelSource = switch result.source {
case .discovered: .discovered
case .backend: .backend
case .augmented: .augmented
case .fallback: .fallback
}
var seenModelIDs = Set<String>()
let effortCount = result.models.reduce(into: 0) { total, model in
guard seenModelIDs.insert(model.id).inserted else { return }
total += model.efforts.count
} + (result.defaultModel.flatMap { seenModelIDs.insert($0.id).inserted ? $0.efforts.count : nil } ?? 0)
diagnosticLog?.recordTaskModelResult(
correlationID: correlationID,
provider: diagnosticProvider,
source: diagnosticSource,
effortCount: effortCount
)
}

/// Emits one privacy-safe product event through the app-wide diagnostic spine.
///
/// `correlationID` is reduced to a process-local integer before admission.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,14 @@ extension MobileShellComposite {
)
}
},
didUpdate: didUpdate
didUpdate: { [weak self] result in
self?.recordTaskModelResult(
provider: provider,
correlationID: macDeviceID,
result: result
)
didUpdate?(result)
}
)
let result = discoveredTaskModelResult(
provider: provider,
Expand Down
Loading
Loading