diff --git a/Convos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Convos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4c80d7b9a..c72573f07 100644 --- a/Convos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Convos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/xmtplabs/convos-shared.git", "state" : { - "branch" : "main", - "revision" : "8b5f741bb0368c1a4edea87f00c1ec0463b181bb" + "branch" : "jarod/device-pairing-metrics", + "revision" : "ef75ce108f525bcf4fbad32a5ac45035b6815c60" } }, { diff --git a/Convos/App Settings/AppSettingsView.swift b/Convos/App Settings/AppSettingsView.swift index 22ed3d186..d62ef3272 100644 --- a/Convos/App Settings/AppSettingsView.swift +++ b/Convos/App Settings/AppSettingsView.swift @@ -177,7 +177,8 @@ struct AppSettingsView: View { DeferredInitiatorPairingService(session: session) }, session: session, - appGroupIdentifier: ConfigManager.shared.currentEnvironment.appGroupIdentifier + appGroupIdentifier: ConfigManager.shared.currentEnvironment.appGroupIdentifier, + coreActions: coreActions ) ) .onAppear { navigator?.navigateTo(devices: DevicesNavigatorArgs()) } diff --git a/Convos/Conversations List/ConversationsViewModel.swift b/Convos/Conversations List/ConversationsViewModel.swift index b862422ca..365d4e2ec 100644 --- a/Convos/Conversations List/ConversationsViewModel.swift +++ b/Convos/Conversations List/ConversationsViewModel.swift @@ -823,6 +823,7 @@ extension ConversationsViewModel { connectingMessage: connectingMessage, resendJoinRequestInterval: resendJoinRequestInterval, pairingService: session.joinerPairingService(), + coreActions: coreActions, onPairingAdopted: { [weak self] in await self?.session.refreshAfterPairingCompleted() }, @@ -1187,7 +1188,8 @@ extension ConversationsViewModel { incomingPairingRequest = PairingSheetViewModel( pairingService: DeferredInitiatorPairingService(session: session), mode: .respondToJoinRequest(joinerInboxId: joinerInboxId, deviceName: deviceName), - appGroupIdentifier: ConfigManager.shared.currentEnvironment.appGroupIdentifier + appGroupIdentifier: ConfigManager.shared.currentEnvironment.appGroupIdentifier, + coreActions: coreActions ) } diff --git a/Convos/Devices/DevicePairingMetricsTracker.swift b/Convos/Devices/DevicePairingMetricsTracker.swift new file mode 100644 index 000000000..760f7bcc5 --- /dev/null +++ b/Convos/Devices/DevicePairingMetricsTracker.swift @@ -0,0 +1,78 @@ +import ConvosMetrics +import Foundation + +/// Funnels one device-pairing attempt into the device-pairing metrics +/// events: at most one `devicePairingStarted` plus exactly one terminal +/// `devicePairingCompleted` / `devicePairingFailed`, no matter how many +/// code paths can reach each flow state (stream redelivery, resend loops, +/// cancel-after-terminal dismissals). +/// +/// Both pairing view models drive it from their `flowState` `didSet`, so +/// step bookkeeping stays correct even for transitions added later; the +/// explicit calls are only `started()` (flow kickoff) and `cancelled()` +/// (user dismissal, which no-ops once a terminal event has fired). +@MainActor +final class DevicePairingMetricsTracker { + private let role: DevicePairingRole + private let coreActions: any CoreActions + private var startedAt: Date? + private var lastStep: DevicePairingStep + private var didFinish: Bool = false + + init(role: DevicePairingRole, coreActions: any CoreActions) { + self.role = role + self.coreActions = coreActions + // The step a flow is in before its first tracked transition; only + // reported if the attempt dies that early. + self.lastStep = role == .initiator ? .qrDisplayed : .joinRequested + } + + func started() { + guard startedAt == nil else { return } + startedAt = Date() + let actions = coreActions + let role = role + Task { await actions.devicePairingStarted(role: role) } + } + + func reached(_ step: DevicePairingStep) { + guard !didFinish else { return } + lastStep = step + } + + func completed() { + guard finishOnce() else { return } + let actions = coreActions + let role = role + let duration = durationSecs + Task { await actions.devicePairingCompleted(role: role, durationSecs: duration) } + } + + func failed(_ reason: DevicePairingFailureReason) { + guard finishOnce() else { return } + let actions = coreActions + let role = role + let step = lastStep + let duration = durationSecs + Task { await actions.devicePairingFailed(role: role, reason: reason, step: step, durationSecs: duration) } + } + + /// User dismissal. Safe to call unconditionally on teardown: it only + /// emits when the attempt started and hasn't already ended. + func cancelled() { + failed(.cancelled) + } + + /// False when the attempt never started or already emitted its + /// terminal event. + private func finishOnce() -> Bool { + guard !didFinish, startedAt != nil else { return false } + didFinish = true + return true + } + + private var durationSecs: Float { + guard let startedAt else { return 0 } + return Float(Date().timeIntervalSince(startedAt)) + } +} diff --git a/Convos/Devices/DevicesViewModel.swift b/Convos/Devices/DevicesViewModel.swift index 74159988d..f92749a49 100644 --- a/Convos/Devices/DevicesViewModel.swift +++ b/Convos/Devices/DevicesViewModel.swift @@ -1,4 +1,5 @@ import ConvosCore +import ConvosMetrics import Observation import SwiftUI @@ -60,6 +61,7 @@ final class DevicesViewModel { private let pairingServiceFactory: @MainActor () -> any PairingServiceProtocol private let session: (any SessionManagerProtocol)? private let appGroupIdentifier: String? + private let coreActions: any CoreActions @ObservationIgnored private let observers: PairingNotificationObservers = .init() @ObservationIgnored @@ -77,11 +79,13 @@ final class DevicesViewModel { init( pairingServiceFactory: @escaping @MainActor () -> any PairingServiceProtocol, session: (any SessionManagerProtocol)? = nil, - appGroupIdentifier: String? = nil + appGroupIdentifier: String? = nil, + coreActions: any CoreActions = NoOpCoreActions() ) { self.pairingServiceFactory = pairingServiceFactory self.session = session self.appGroupIdentifier = appGroupIdentifier + self.coreActions = coreActions self.devices = [ PairedDevice( id: "self", @@ -159,7 +163,8 @@ final class DevicesViewModel { let vm = PairingSheetViewModel( pairingService: service, appGroupIdentifier: appGroupIdentifier, - targetDeviceName: backup.deviceName ?? Self.shortICloudDeviceName(inboxId: backup.inboxId) + targetDeviceName: backup.deviceName ?? Self.shortICloudDeviceName(inboxId: backup.inboxId), + coreActions: coreActions ) pairingViewModel = vm showPairingSheet = true @@ -350,7 +355,8 @@ final class DevicesViewModel { let service = pairingServiceFactory() let vm = PairingSheetViewModel( pairingService: service, - appGroupIdentifier: appGroupIdentifier + appGroupIdentifier: appGroupIdentifier, + coreActions: coreActions ) pairingViewModel = vm showPairingSheet = true diff --git a/Convos/Devices/JoinerPairingSheetViewModel.swift b/Convos/Devices/JoinerPairingSheetViewModel.swift index 1a9681032..0d2d12784 100644 --- a/Convos/Devices/JoinerPairingSheetViewModel.swift +++ b/Convos/Devices/JoinerPairingSheetViewModel.swift @@ -1,4 +1,5 @@ import ConvosCore +import ConvosMetrics import CryptoKit import Observation import SwiftUI @@ -19,7 +20,9 @@ enum JoinerPairingFlowState: Equatable { @MainActor final class JoinerPairingSheetViewModel: Identifiable { nonisolated let id: String - var flowState: JoinerPairingFlowState + var flowState: JoinerPairingFlowState { + didSet { trackFlowState() } + } var title: String = "Request to pair" var canDismiss: Bool = true var secondsRemaining: Int @@ -60,6 +63,9 @@ final class JoinerPairingSheetViewModel: Identifiable { /// most once. private var didComplete: Bool = false + @ObservationIgnored + private let metrics: DevicePairingMetricsTracker + init( pairingId: String, expiresAt: Date? = nil, @@ -68,6 +74,7 @@ final class JoinerPairingSheetViewModel: Identifiable { connectingMessage: String? = nil, resendJoinRequestInterval: TimeInterval? = nil, pairingService: any PairingServiceProtocol, + coreActions: any CoreActions = NoOpCoreActions(), onPairingAdopted: (@MainActor () async -> Void)? = nil, onApplyAdoptedProfile: (@MainActor (_ displayName: String?, _ imageAssetIdentifier: String?) async -> Void)? = nil, onDeleteExistingData: (@MainActor () async throws -> Void)? = nil, @@ -80,6 +87,7 @@ final class JoinerPairingSheetViewModel: Identifiable { self.connectingMessage = connectingMessage self.resendJoinRequestInterval = resendJoinRequestInterval self.pairingService = pairingService + self.metrics = DevicePairingMetricsTracker(role: .joiner, coreActions: coreActions) self.onPairingAdopted = onPairingAdopted self.onApplyAdoptedProfile = onApplyAdoptedProfile self.onDeleteExistingData = onDeleteExistingData @@ -142,7 +150,25 @@ final class JoinerPairingSheetViewModel: Identifiable { enteredPin.count == 6 } + /// Routes every `flowState` transition into the metrics tracker so all + /// paths to a terminal state (stream errors, send failures, countdown + /// expiry, identity-share completion) are counted without per-site + /// tracking calls. + private func trackFlowState() { + switch flowState { + case .connecting: metrics.reached(.joinRequested) + case .needsDataDeletion, .deletingData: metrics.reached(.dataDeletion) + case .pinEntry: metrics.reached(.pinEntry) + case .waitingForEmoji: metrics.reached(.emojiConfirmation) + case .syncing: metrics.reached(.syncing) + case .completed: metrics.completed() + case .failed: metrics.failed(.error) + case .expired: metrics.failed(.expired) + } + } + func sendJoinRequest() async { + metrics.started() // Check first whether the device has any real conversation data — // i.e. anything the user would actually lose. A placeholder // identity + pre-warmed unused convo cache from silent identity @@ -294,6 +320,7 @@ final class JoinerPairingSheetViewModel: Identifiable { } func cancel() { + metrics.cancelled() countdownTask?.cancel() resendTask?.cancel() Task { diff --git a/Convos/Devices/PairingSheetViewModel.swift b/Convos/Devices/PairingSheetViewModel.swift index 6de83a08c..5f1edb1a5 100644 --- a/Convos/Devices/PairingSheetViewModel.swift +++ b/Convos/Devices/PairingSheetViewModel.swift @@ -1,4 +1,5 @@ import ConvosCore +import ConvosMetrics import Observation import SwiftUI @@ -57,7 +58,9 @@ final class PairingSheetViewModel: Identifiable { return false } - var flowState: PairingFlowState = .qrCode(url: "") + var flowState: PairingFlowState = .qrCode(url: "") { + didSet { trackFlowState() } + } var canDismiss: Bool = true var title: String = "Pair new device" var secondsRemaining: Int = 60 @@ -77,19 +80,23 @@ final class PairingSheetViewModel: Identifiable { private var countdownTask: Task? @ObservationIgnored private let observers: PairingNotificationObservers + @ObservationIgnored + private let metrics: DevicePairingMetricsTracker init( pairingService: any PairingServiceProtocol, timeoutInterval: TimeInterval = 120, mode: PairingSheetMode = .createInvite, appGroupIdentifier: String? = nil, - targetDeviceName: String? = nil + targetDeviceName: String? = nil, + coreActions: any CoreActions = NoOpCoreActions() ) { self.pairingService = pairingService self.appGroupIdentifier = appGroupIdentifier self.timeoutInterval = timeoutInterval self.mode = mode self.targetDeviceName = targetDeviceName + self.metrics = DevicePairingMetricsTracker(role: .initiator, coreActions: coreActions) self.secondsRemaining = Int(timeoutInterval) self.observers = PairingNotificationObservers() if case .respondToJoinRequest = mode { @@ -133,8 +140,24 @@ final class PairingSheetViewModel: Identifiable { } } + /// Routes every `flowState` transition into the metrics tracker so all + /// paths to a terminal state (stream errors, coordinator failures, + /// countdown expiry) are counted without per-site tracking calls. + private func trackFlowState() { + switch flowState { + case .qrCode: metrics.reached(.qrDisplayed) + case .showingPin: metrics.reached(.pinShown) + case .emojiConfirmation: metrics.reached(.emojiConfirmation) + case .syncing: metrics.reached(.syncing) + case .completed: metrics.completed() + case .failed: metrics.failed(.error) + case .expired: metrics.failed(.expired) + } + } + func startPairing() async { Self.active = self + metrics.started() switch mode { case .createInvite: await startInviteFlow() @@ -342,6 +365,7 @@ final class PairingSheetViewModel: Identifiable { } func cancel() async { + metrics.cancelled() countdownTask?.cancel() if let coordinator, let joinerInboxId { let state = await coordinator.currentState diff --git a/ConvosCore/Package.swift b/ConvosCore/Package.swift index d6fb2a4ec..59dd7f93b 100644 --- a/ConvosCore/Package.swift +++ b/ConvosCore/Package.swift @@ -34,7 +34,7 @@ let package = Package( .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "12.1.0"), .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.1"), .package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "8.57.1"), - .package(url: "https://github.com/xmtplabs/convos-shared.git", branch: "main"), + .package(url: "https://github.com/xmtplabs/convos-shared.git", branch: "jarod/device-pairing-metrics"), .package(path: "../ConvosLogging"), .package(path: "../ConvosInvites"), .package(path: "../ConvosAppData"), diff --git a/ConvosCore/Sources/ConvosCore/Metrics/NoOpCoreActions.swift b/ConvosCore/Sources/ConvosCore/Metrics/NoOpCoreActions.swift index 329391b6e..94b5bbb9c 100644 --- a/ConvosCore/Sources/ConvosCore/Metrics/NoOpCoreActions.swift +++ b/ConvosCore/Sources/ConvosCore/Metrics/NoOpCoreActions.swift @@ -89,4 +89,15 @@ public final class NoOpCoreActions: CoreActions, @unchecked Sendable { ) async {} public func purchasesRestored(restoredCount: Int) async {} + + public func devicePairingStarted(role: DevicePairingRole) async {} + + public func devicePairingCompleted(role: DevicePairingRole, durationSecs: Float) async {} + + public func devicePairingFailed( + role: DevicePairingRole, + reason: DevicePairingFailureReason, + step: DevicePairingStep, + durationSecs: Float + ) async {} }