Skip to content
Draft
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Convos/App Settings/AppSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()) }
Expand Down
4 changes: 3 additions & 1 deletion Convos/Conversations List/ConversationsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,7 @@ extension ConversationsViewModel {
connectingMessage: connectingMessage,
resendJoinRequestInterval: resendJoinRequestInterval,
pairingService: session.joinerPairingService(),
coreActions: coreActions,
onPairingAdopted: { [weak self] in
await self?.session.refreshAfterPairingCompleted()
},
Expand Down Expand Up @@ -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
)
}

Expand Down
78 changes: 78 additions & 0 deletions Convos/Devices/DevicePairingMetricsTracker.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
12 changes: 9 additions & 3 deletions Convos/Devices/DevicesViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ConvosCore
import ConvosMetrics
import Observation
import SwiftUI

Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion Convos/Devices/JoinerPairingSheetViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ConvosCore
import ConvosMetrics
import CryptoKit
import Observation
import SwiftUI
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -294,6 +320,7 @@ final class JoinerPairingSheetViewModel: Identifiable {
}

func cancel() {
metrics.cancelled()
countdownTask?.cancel()
resendTask?.cancel()
Task {
Expand Down
28 changes: 26 additions & 2 deletions Convos/Devices/PairingSheetViewModel.swift

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Devices/PairingSheetViewModel.swift:102

In .respondToJoinRequest mode, if the flow fails before any flowState transition after init (e.g. pairingService.start() throws in startRespondFlow), the devicePairingFailed metric reports step as .qrDisplayed even though this mode never shows a QR. The DevicePairingMetricsTracker defaults lastStep to .qrDisplayed for .initiator, and self.flowState = .syncing in init doesn't fire didSet (Swift skips property observers during initialization), so the tracker never learns the flow started in .syncing. Consider calling metrics.reached(.syncing) in the .respondToJoinRequest branch of init.

        if case .respondToJoinRequest = mode {
            // Respond mode never shows a QR; start in the spinner state
            // so the sheet doesn't flash the empty QR layout while the
            // pairing service bootstraps toward `.showingPin`.
            self.flowState = .syncing
            metrics.reached(.syncing)
        }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @Convos/Devices/PairingSheetViewModel.swift around lines 102-107:

In `.respondToJoinRequest` mode, if the flow fails before any `flowState` transition after init (e.g. `pairingService.start()` throws in `startRespondFlow`), the `devicePairingFailed` metric reports `step` as `.qrDisplayed` even though this mode never shows a QR. The `DevicePairingMetricsTracker` defaults `lastStep` to `.qrDisplayed` for `.initiator`, and `self.flowState = .syncing` in `init` doesn't fire `didSet` (Swift skips property observers during initialization), so the tracker never learns the flow started in `.syncing`. Consider calling `metrics.reached(.syncing)` in the `.respondToJoinRequest` branch of `init`.

Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ConvosCore
import ConvosMetrics
import Observation
import SwiftUI

Expand Down Expand Up @@ -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
Expand All @@ -77,19 +80,23 @@ final class PairingSheetViewModel: Identifiable {
private var countdownTask: Task<Void, Never>?
@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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ConvosCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
11 changes: 11 additions & 0 deletions ConvosCore/Sources/ConvosCore/Metrics/NoOpCoreActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}
Loading