Merge pull request #10310 from manaflow-ai/feat-conn-method-diagnostics
feat(ios): state connection method and live transport in diagnostics reports
This commit is contained in:
+18
-1
@@ -213,6 +213,14 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable name of a configured connection method.
|
||||
public func displayName(_ method: DiagnosticConnectionMethod) -> String {
|
||||
switch method {
|
||||
case .automatic: localized("diagnostics.connectionMethod.automatic", defaultValue: "Auto-Connect (Iroh)")
|
||||
case .tailscale: localized("diagnostics.connectionMethod.tailscale", defaultValue: "Tailscale Only")
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable name of a selected network path.
|
||||
public func displayName(_ kind: DiagnosticPathKind) -> String {
|
||||
switch kind {
|
||||
@@ -681,6 +689,10 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
return Field(key: "style", value: toastStyleName(raw))
|
||||
case .toastDismissed:
|
||||
return Field(key: "reason", value: toastDismissReasonName(raw))
|
||||
case .connectionMethodPreferenceChanged, .connectionMethodConfigured:
|
||||
return Field(key: "method", value: connectionMethodName(raw))
|
||||
case .foregroundTransportSelected:
|
||||
return Field(key: "transport", value: transportName(raw))
|
||||
default:
|
||||
if Self.appEventKindsWithValuePayload.contains(kind) {
|
||||
return Field(key: "value", value: String(raw))
|
||||
@@ -743,6 +755,11 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
?? unknownPayloadName(raw)
|
||||
}
|
||||
|
||||
private func connectionMethodName(_ raw: Int) -> String {
|
||||
DiagnosticConnectionMethod(rawValue: raw).map(displayName)
|
||||
?? unknownPayloadName(raw)
|
||||
}
|
||||
|
||||
private func unknownPayloadName(_ raw: Int) -> String {
|
||||
localized(
|
||||
"diagnostics.unknown.payload",
|
||||
@@ -762,7 +779,6 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
.displayWorkspacePreviewLinesChanged,
|
||||
.terminalScrollbackRowsChanged,
|
||||
.telemetrySharingChanged,
|
||||
.connectionMethodPreferenceChanged,
|
||||
.notificationPreferenceChanged,
|
||||
.terminalDraftStateChanged,
|
||||
]
|
||||
@@ -1388,6 +1404,7 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
case "active_sessions": localized("diagnostics.field.activeSessions", defaultValue: "Active sessions")
|
||||
case "count": localized("diagnostics.field.count", defaultValue: "Count")
|
||||
case "value": localized("diagnostics.field.value", defaultValue: "Value")
|
||||
case "method": localized("diagnostics.field.method", defaultValue: "Method")
|
||||
case "action": localized("diagnostics.field.action", defaultValue: "Action")
|
||||
case "tab": localized("diagnostics.field.tab", defaultValue: "Tab")
|
||||
case "scope": localized("diagnostics.field.scope", defaultValue: "Scope")
|
||||
|
||||
@@ -767,6 +767,7 @@ public enum DiagnosticAppEventKind: Int, Sendable, Codable, CaseIterable {
|
||||
case displayWorkspacePreviewLinesChanged = 528
|
||||
case terminalScrollbackRowsChanged = 529
|
||||
case telemetrySharingChanged = 530
|
||||
/// `c`: ``DiagnosticConnectionMethod`` the user switched to.
|
||||
case connectionMethodPreferenceChanged = 531
|
||||
/// Detail: ``DiagnosticAppEventDetail/toolbarConfigurationAction(_:)``.
|
||||
case customToolbarChanged = 532
|
||||
@@ -836,6 +837,22 @@ public enum DiagnosticAppEventKind: Int, Sendable, Codable, CaseIterable {
|
||||
|
||||
// MARK: Appended persistence events
|
||||
case pairedMacStoreWriteStarted = 660
|
||||
|
||||
// MARK: Appended connection reporting events
|
||||
/// The configured connection method, recorded at composition and on every
|
||||
/// foreground so any shared report window states it even after the ring
|
||||
/// rolls past app launch. `c`: ``DiagnosticConnectionMethod``.
|
||||
case connectionMethodConfigured = 661
|
||||
/// The transport that actually carries the foreground connection, recorded
|
||||
/// on connect and on every active-route change. `c`: ``DiagnosticTransportKind``.
|
||||
case foregroundTransportSelected = 662
|
||||
}
|
||||
|
||||
/// The user's configured connection method, mirrored from the settings picker
|
||||
/// without account, address, or grant details.
|
||||
public enum DiagnosticConnectionMethod: Int, Sendable, Codable, CaseIterable {
|
||||
case automatic = 0
|
||||
case tailscale = 1
|
||||
}
|
||||
|
||||
/// High-level lifecycle state for one phone-controlled Simulator stream.
|
||||
|
||||
@@ -103,6 +103,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.connectionMethod.automatic": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Auto-Connect (Iroh)"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "自動接続 (Iroh)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.connectionMethod.tailscale": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tailscale Only"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tailscaleのみ"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.count.bytes": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
@@ -5845,6 +5879,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.field.method": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Method"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "接続方法"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics.field.publicPaths": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
||||
+42
@@ -595,6 +595,48 @@ import Testing
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared report must state the configured connection method and the
|
||||
/// transport actually carrying the foreground connection in words, so a
|
||||
/// support thread never needs a Settings screenshot to interpret dials.
|
||||
@Test func describesConnectionMethodAndForegroundTransport() {
|
||||
let configured = englishPresentation.describe(DiagnosticEvent(
|
||||
code: .appFeatureAction,
|
||||
tNanos: 1,
|
||||
a: DiagnosticAppEventKind.connectionMethodConfigured.rawValue,
|
||||
c: DiagnosticConnectionMethod.tailscale.rawValue
|
||||
))
|
||||
#expect(configured.fields == [
|
||||
.init(key: "operation", value: "connectionMethodConfigured"),
|
||||
.init(key: "method", value: "Tailscale Only"),
|
||||
])
|
||||
#expect(englishPresentation.summary(configured)
|
||||
.contains("Method: Tailscale Only"))
|
||||
|
||||
let changed = englishPresentation.describe(DiagnosticEvent(
|
||||
code: .appFeatureAction,
|
||||
tNanos: 1,
|
||||
a: DiagnosticAppEventKind.connectionMethodPreferenceChanged.rawValue,
|
||||
c: DiagnosticConnectionMethod.automatic.rawValue
|
||||
))
|
||||
#expect(changed.fields == [
|
||||
.init(key: "operation", value: "connectionMethodPreferenceChanged"),
|
||||
.init(key: "method", value: "Auto-Connect (Iroh)"),
|
||||
])
|
||||
|
||||
let transport = englishPresentation.describe(DiagnosticEvent(
|
||||
code: .appFeatureAction,
|
||||
tNanos: 1,
|
||||
a: DiagnosticAppEventKind.foregroundTransportSelected.rawValue,
|
||||
c: DiagnosticTransportKind.tailscale.rawValue
|
||||
))
|
||||
#expect(transport.fields == [
|
||||
.init(key: "operation", value: "foregroundTransportSelected"),
|
||||
.init(key: "transport", value: "Tailscale"),
|
||||
])
|
||||
#expect(englishPresentation.summary(transport)
|
||||
.contains("Transport: Tailscale"))
|
||||
}
|
||||
|
||||
@Test func extractsFailureAndTransportKinds() {
|
||||
let event = DiagnosticEvent(
|
||||
code: .endpointFailed,
|
||||
|
||||
@@ -198,6 +198,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
correlationID: foregroundMacDeviceID,
|
||||
count: connectionState == .connected ? 1 : 0
|
||||
)
|
||||
recordForegroundTransportSelected()
|
||||
if connectionState == .connected {
|
||||
restartTerminalLanesForMountedSurfaces()
|
||||
browserStreamEvents?.setBrowserStreamConnectionStatus(.connected)
|
||||
@@ -279,9 +280,26 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
public internal(set) var activeRoute: CmxAttachRoute? {
|
||||
didSet {
|
||||
guard oldValue != activeRoute, connectionState == .connected else { return }
|
||||
recordForegroundTransportSelected()
|
||||
restartTerminalLanesForMountedSurfaces()
|
||||
}
|
||||
}
|
||||
|
||||
/// Records which transport actually carries the foreground connection, so
|
||||
/// a shared report states Iroh vs Tailscale usage explicitly instead of
|
||||
/// leaving it implied by whichever dial events survived the ring.
|
||||
///
|
||||
/// Hooked to both the connected transition and active-route changes: some
|
||||
/// connect flows pin the route before flipping the state and others after,
|
||||
/// and a mid-connection promotion swaps the route with no state change.
|
||||
private func recordForegroundTransportSelected() {
|
||||
guard connectionState == .connected, let route = activeRoute else { return }
|
||||
recordAppEvent(
|
||||
.foregroundTransportSelected,
|
||||
correlationID: foregroundMacDeviceID,
|
||||
count: DiagnosticTransportKind(route.kind).rawValue
|
||||
)
|
||||
}
|
||||
/// Authenticated Mac app-instance identity for the foreground connection.
|
||||
/// `nil` only for a fresh/legacy host that has not reported one.
|
||||
var activeMacInstanceTag: String?
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import CMUXMobileCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CmuxMobileShell
|
||||
|
||||
/// The diagnostics timeline must state which transport actually carries the
|
||||
/// foreground connection, both at connect and when the route is swapped
|
||||
/// mid-connection, so shared reports distinguish Iroh from Tailscale usage
|
||||
/// without inferring it from surviving dial events.
|
||||
@MainActor
|
||||
@Suite struct MobileForegroundTransportDiagnosticsTests {
|
||||
@Test func connectAndRouteChangeRecordSelectedTransport() async throws {
|
||||
let log = DiagnosticLog(capacity: 16, role: .mobileClient)
|
||||
let store = MobileShellComposite(
|
||||
isSignedIn: true,
|
||||
diagnosticLog: log
|
||||
)
|
||||
let tailscale = try CmxAttachRoute(
|
||||
id: "granted-tailscale",
|
||||
kind: .tailscale,
|
||||
endpoint: .hostPort(host: "100.64.0.42", port: 56_584)
|
||||
)
|
||||
let iroh = try CmxAttachRoute(
|
||||
id: "iroh-route",
|
||||
kind: .iroh,
|
||||
endpoint: .peer(
|
||||
identity: CmxIrohPeerIdentity(
|
||||
endpointID: String(repeating: "a", count: 64)
|
||||
),
|
||||
pathHints: []
|
||||
)
|
||||
)
|
||||
|
||||
store.connectionState = .connected
|
||||
store.activeRoute = tailscale
|
||||
store.activeRoute = iroh
|
||||
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .seconds(2))
|
||||
func selectedTransports() async -> [Int] {
|
||||
await log.snapshot().events
|
||||
.filter {
|
||||
$0.code == .appFeatureAction && $0.a
|
||||
== DiagnosticAppEventKind.foregroundTransportSelected.rawValue
|
||||
}
|
||||
.compactMap(\.c)
|
||||
}
|
||||
while await selectedTransports().count < 2, clock.now < deadline {
|
||||
await Task.yield()
|
||||
}
|
||||
#expect(await selectedTransports() == [
|
||||
DiagnosticTransportKind.tailscale.rawValue,
|
||||
DiagnosticTransportKind.iroh.rawValue,
|
||||
])
|
||||
}
|
||||
|
||||
@Test func disconnectedRouteChangesRecordNothing() async throws {
|
||||
let log = DiagnosticLog(capacity: 16, role: .mobileClient)
|
||||
let store = MobileShellComposite(
|
||||
isSignedIn: true,
|
||||
diagnosticLog: log
|
||||
)
|
||||
let tailscale = try CmxAttachRoute(
|
||||
id: "granted-tailscale",
|
||||
kind: .tailscale,
|
||||
endpoint: .hostPort(host: "100.64.0.42", port: 56_584)
|
||||
)
|
||||
|
||||
store.activeRoute = tailscale
|
||||
|
||||
// A directly recorded sentinel bounds the drain wait: once it has been
|
||||
// processed, any transport event recorded before it would be visible.
|
||||
log.recordAppEvent(.appLaunched)
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .seconds(1))
|
||||
while await log.processedCount() < 1, clock.now < deadline {
|
||||
await Task.yield()
|
||||
}
|
||||
let selected = await log.snapshot().events.filter {
|
||||
$0.code == .appFeatureAction && $0.a
|
||||
== DiagnosticAppEventKind.foregroundTransportSelected.rawValue
|
||||
}
|
||||
#expect(selected.isEmpty)
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -13,6 +13,17 @@ public enum MobileConnectionMethod: String, CaseIterable, Sendable {
|
||||
case tailscale
|
||||
}
|
||||
|
||||
extension MobileConnectionMethod {
|
||||
/// Exhaustive mapping into the diagnostics payload enum, so a future third
|
||||
/// method becomes a compile error here instead of silently misreporting.
|
||||
var diagnosticMethod: DiagnosticConnectionMethod {
|
||||
switch self {
|
||||
case .automatic: .automatic
|
||||
case .tailscale: .tailscale
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the user's connection-method choice.
|
||||
///
|
||||
/// The choice is exclusive: `automatic` uses the built-in encrypted transport,
|
||||
@@ -41,7 +52,7 @@ public final class MobileConnectionMethodStore {
|
||||
defaults.set(method.rawValue, forKey: Self.methodKey)
|
||||
diagnosticLog?.recordAppEvent(
|
||||
.connectionMethodPreferenceChanged,
|
||||
count: method == .automatic ? 0 : 1
|
||||
count: method.diagnosticMethod.rawValue
|
||||
)
|
||||
for continuation in continuations.values {
|
||||
continuation.yield(method)
|
||||
@@ -59,6 +70,20 @@ public final class MobileConnectionMethodStore {
|
||||
} else {
|
||||
self.method = .automatic
|
||||
}
|
||||
recordConfiguredMethodDiagnostic()
|
||||
}
|
||||
|
||||
/// Records the currently configured method into the diagnostics ring.
|
||||
///
|
||||
/// Called at composition and on every foreground so any shared report
|
||||
/// window states the configuration even after the bounded ring has rolled
|
||||
/// past app launch; `connectionMethodPreferenceChanged` alone only marks
|
||||
/// transitions.
|
||||
public func recordConfiguredMethodDiagnostic() {
|
||||
diagnosticLog?.recordAppEvent(
|
||||
.connectionMethodConfigured,
|
||||
count: method.diagnosticMethod.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
/// Observes connection-method changes, beginning with the current method.
|
||||
|
||||
+41
-5
@@ -48,12 +48,48 @@ import Testing
|
||||
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .seconds(1))
|
||||
while await log.processedCount() < 1, clock.now < deadline {
|
||||
while await log.processedCount() < 2, clock.now < deadline {
|
||||
await Task.yield()
|
||||
}
|
||||
#expect(await log.processedCount() >= 1)
|
||||
let event = await log.snapshot().events.first
|
||||
#expect(event?.a == DiagnosticAppEventKind.connectionMethodPreferenceChanged.rawValue)
|
||||
#expect(event?.c == 1)
|
||||
#expect(await log.processedCount() >= 2)
|
||||
let events = await log.snapshot().events
|
||||
#expect(events.first?.a
|
||||
== DiagnosticAppEventKind.connectionMethodConfigured.rawValue)
|
||||
#expect(events.first?.c == 0)
|
||||
let change = events.last
|
||||
#expect(change?.a
|
||||
== DiagnosticAppEventKind.connectionMethodPreferenceChanged.rawValue)
|
||||
#expect(change?.c == 1)
|
||||
}
|
||||
|
||||
/// A shared report window must state the configured method even when the
|
||||
/// bounded ring rolled past app launch, so the configured-method event is
|
||||
/// re-recordable on demand (the composition root calls it per foreground).
|
||||
@Test func recordsConfiguredMethodAtInitAndOnDemand() async {
|
||||
let defaults = makeDefaults()
|
||||
defaults.set(
|
||||
MobileConnectionMethod.tailscale.rawValue,
|
||||
forKey: MobileConnectionMethodStore.methodKey
|
||||
)
|
||||
let log = DiagnosticLog(capacity: 4)
|
||||
let store = MobileConnectionMethodStore(
|
||||
defaults: defaults,
|
||||
diagnosticLog: log
|
||||
)
|
||||
|
||||
store.recordConfiguredMethodDiagnostic()
|
||||
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .seconds(1))
|
||||
while await log.processedCount() < 2, clock.now < deadline {
|
||||
await Task.yield()
|
||||
}
|
||||
let events = await log.snapshot().events
|
||||
#expect(events.count == 2)
|
||||
for event in events {
|
||||
#expect(event.a
|
||||
== DiagnosticAppEventKind.connectionMethodConfigured.rawValue)
|
||||
#expect(event.c == 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +340,7 @@ final class AppCompositionRoot {
|
||||
switch phase {
|
||||
case .active:
|
||||
diagnosticLog.recordAppEvent(.appForegrounded)
|
||||
connectionMethodStore.recordConfiguredMethodDiagnostic()
|
||||
let isFullForegroundReturn = iroh.didBecomeActive()
|
||||
// A notification-permission prompt is itself a transient inactive
|
||||
// edge, so readiness still observes every active transition.
|
||||
|
||||
Reference in New Issue
Block a user