From ef75ce108f525bcf4fbad32a5ac45035b6815c60 Mon Sep 17 00:00:00 2001 From: yewreeka Date: Mon, 13 Jul 2026 15:00:31 -0700 Subject: [PATCH] Add device-pairing lifecycle events (started / completed / failed) The device pairing flow (QR + PIN + emoji handshake) currently emits no product analytics. Adds one started and one terminal event per role (initiator / joiner), with the failure step and reason as properties on device_pairing_failed rather than per-step events, matching the outcome-event style of the join funnels. Also ignores SwiftPM's .build scratch directory. Based on 8b5f741 (the revision convos-ios currently pins) rather than latest main so the companion convos-ios PR can consume this branch before the RevealMediaInfo removal is absorbed on the iOS side. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + .../Sources/ConvosMetrics/CoreActions.swift | 3 + .../Sources/ConvosMetrics/CoreEnums.swift | 54 +++++++++++++++ .../ConvosMetrics/MetricsCoreActions.swift | 27 ++++++++ README.md | 3 + .../metrics/descriptors/core/CoreActions.kt | 65 +++++++++++++++++++ 6 files changed, 153 insertions(+) diff --git a/.gitignore b/.gitignore index bc4299b..fa2b49c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build .gradle .idea .kotlin +.build diff --git a/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift b/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift index e8d2d7e..f61e386 100644 --- a/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift +++ b/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift @@ -14,4 +14,7 @@ public protocol CoreActions: AnyObject, Sendable { func purchaseCancelled(productId: String, source: PaywallSource) async func purchaseFailed(productId: String, source: PaywallSource, reason: PurchaseFailureReason) async func purchasesRestored(restoredCount: Int) async + func devicePairingStarted(role: DevicePairingRole) async + func devicePairingCompleted(role: DevicePairingRole, durationSecs: Float) async + func devicePairingFailed(role: DevicePairingRole, reason: DevicePairingFailureReason, step: DevicePairingStep, durationSecs: Float) async } diff --git a/ConvosMetrics/Sources/ConvosMetrics/CoreEnums.swift b/ConvosMetrics/Sources/ConvosMetrics/CoreEnums.swift index bbd7b3b..9f54fcf 100644 --- a/ConvosMetrics/Sources/ConvosMetrics/CoreEnums.swift +++ b/ConvosMetrics/Sources/ConvosMetrics/CoreEnums.swift @@ -131,3 +131,57 @@ extension PurchaseFailureReason { } } } + +public enum DevicePairingRole: Sendable { + case initiator + case joiner +} + +extension DevicePairingRole { + public var metricsString: String { + switch self { + case .initiator: return "initiator" + case .joiner: return "joiner" + } + } +} + +public enum DevicePairingFailureReason: Sendable { + case error + case expired + case cancelled +} + +extension DevicePairingFailureReason { + public var metricsString: String { + switch self { + case .error: return "error" + case .expired: return "expired" + case .cancelled: return "cancelled" + } + } +} + +public enum DevicePairingStep: Sendable { + case qrDisplayed + case joinRequested + case dataDeletion + case pinShown + case pinEntry + case emojiConfirmation + case syncing +} + +extension DevicePairingStep { + public var metricsString: String { + switch self { + case .qrDisplayed: return "qr_displayed" + case .joinRequested: return "join_requested" + case .dataDeletion: return "data_deletion" + case .pinShown: return "pin_shown" + case .pinEntry: return "pin_entry" + case .emojiConfirmation: return "emoji_confirmation" + case .syncing: return "syncing" + } + } +} diff --git a/ConvosMetrics/Sources/ConvosMetrics/MetricsCoreActions.swift b/ConvosMetrics/Sources/ConvosMetrics/MetricsCoreActions.swift index 7bff0a3..918d431 100644 --- a/ConvosMetrics/Sources/ConvosMetrics/MetricsCoreActions.swift +++ b/ConvosMetrics/Sources/ConvosMetrics/MetricsCoreActions.swift @@ -132,6 +132,28 @@ public final class MetricsCoreActions: CoreActions, @unchecked Sendable { ]) } + public func devicePairingStarted(role: DevicePairingRole) async { + delegate?.sendEvent(name: Self.eventDevicePairingStarted, properties: [ + Self.paramRole: role.metricsString, + ]) + } + + public func devicePairingCompleted(role: DevicePairingRole, durationSecs: Float) async { + delegate?.sendEvent(name: Self.eventDevicePairingCompleted, properties: [ + Self.paramRole: role.metricsString, + Self.paramDurationSecs: durationSecs, + ]) + } + + public func devicePairingFailed(role: DevicePairingRole, reason: DevicePairingFailureReason, step: DevicePairingStep, durationSecs: Float) async { + delegate?.sendEvent(name: Self.eventDevicePairingFailed, properties: [ + Self.paramRole: role.metricsString, + Self.paramReason: reason.metricsString, + Self.paramStep: step.metricsString, + Self.paramDurationSecs: durationSecs, + ]) + } + public static let eventStartedConversation: String = "started_conversation" public static let eventJoinedConversation: String = "joined_conversation" public static let eventInvitedToConversation: String = "invited_to_conversation" @@ -147,6 +169,9 @@ public final class MetricsCoreActions: CoreActions, @unchecked Sendable { public static let eventPurchaseCancelled: String = "purchase_cancelled" public static let eventPurchaseFailed: String = "purchase_failed" public static let eventPurchasesRestored: String = "purchases_restored" + public static let eventDevicePairingStarted: String = "device_pairing_started" + public static let eventDevicePairingCompleted: String = "device_pairing_completed" + public static let eventDevicePairingFailed: String = "device_pairing_failed" public static let paramVerificationDuration: String = "verification_duration" public static let paramMemberCount: String = "member_count" public static let paramHasAssistant: String = "has_assistant" @@ -176,4 +201,6 @@ public final class MetricsCoreActions: CoreActions, @unchecked Sendable { public static let paramDurationSecs: String = "duration_secs" public static let paramReason: String = "reason" public static let paramRestoredCount: String = "restored_count" + public static let paramRole: String = "role" + public static let paramStep: String = "step" } diff --git a/README.md b/README.md index 2c9003d..8ee475b 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ The section between the AUTOGEN markers below is regenerated by the build from t | `purchase_cancelled` | `purchaseCancelled` | `product_id`: String
`source`: PaywallSource { SETTINGS, LOW_BALANCE_BANNER, ONBOARDING, MEMBER_CARD, DEBUG } | | `purchase_failed` | `purchaseFailed` | `product_id`: String
`source`: PaywallSource { SETTINGS, LOW_BALANCE_BANNER, ONBOARDING, MEMBER_CARD, DEBUG }
`reason`: PurchaseFailureReason { PRODUCT_NOT_FOUND, PURCHASE_PENDING, PURCHASE_UNVERIFIED, BACKEND_VERIFY_UNAVAILABLE, BILLING_CLIENT_UNAVAILABLE, UNKNOWN } | | `purchases_restored` | `purchasesRestored` | `restored_count`: Int | +| `device_pairing_started` | `devicePairingStarted` | `role`: DevicePairingRole { INITIATOR, JOINER } | +| `device_pairing_completed` | `devicePairingCompleted` | `role`: DevicePairingRole { INITIATOR, JOINER }
`duration_secs`: Float | +| `device_pairing_failed` | `devicePairingFailed` | `role`: DevicePairingRole { INITIATOR, JOINER }
`reason`: DevicePairingFailureReason { ERROR, EXPIRED, CANCELLED }
`step`: DevicePairingStep { QR_DISPLAYED, JOIN_REQUESTED, DATA_DELETION, PIN_SHOWN, PIN_ENTRY, EMOJI_CONFIRMATION, SYNCING }
`duration_secs`: Float | ## User Properties diff --git a/metrics/descriptors/src/main/kotlin/org/convos/metrics/descriptors/core/CoreActions.kt b/metrics/descriptors/src/main/kotlin/org/convos/metrics/descriptors/core/CoreActions.kt index b7d1e9d..6481890 100644 --- a/metrics/descriptors/src/main/kotlin/org/convos/metrics/descriptors/core/CoreActions.kt +++ b/metrics/descriptors/src/main/kotlin/org/convos/metrics/descriptors/core/CoreActions.kt @@ -44,6 +44,45 @@ enum class PurchaseFailureReason { UNKNOWN, } +// Which side of a device-pairing handshake this device is: the signed-in +// device that displays the QR code and approves (INITIATOR), or the new +// device that scanned it and is being added to the inbox (JOINER). Each side +// emits its own started/completed/failed events, so a fully successful pair +// produces one completed event per role. +enum class DevicePairingRole { + INITIATOR, + JOINER, +} + +// The phase of the pairing handshake, used to locate where a failed attempt +// died. Ordered by flow progression; some steps only occur for one role. +enum class DevicePairingStep { + // Initiator: QR code displayed, waiting for the joiner to scan. + QR_DISPLAYED, + // Joiner: join request sent, waiting for the initiator to respond. + JOIN_REQUESTED, + // Joiner: blocked on the erase-existing-data confirmation. + DATA_DELETION, + // Initiator: PIN displayed, waiting for the joiner to type it. + PIN_SHOWN, + // Joiner: typing the PIN. + PIN_ENTRY, + // Both: comparing emoji fingerprints, waiting for initiator confirm. + EMOJI_CONFIRMATION, + // Both: identity share in flight / being adopted. + SYNCING, +} + +enum class DevicePairingFailureReason { + // The handshake errored (transport failure, wrong PIN exhausted, + // identity-share verification failure, ...). + ERROR, + // A phase timer expired before the other device responded. + EXPIRED, + // The user dismissed the flow. + CANCELLED, +} + // How the user initiated an assistant join - the stable entry-point intent, // captured at request time and carried through to the join moment. Defined by // intent (not by the UI surface it happens to render on) so the metric stays @@ -171,4 +210,30 @@ interface CoreActions { ) suspend fun purchasesRestored(restoredCount: Int) + + // Fired when a device-pairing attempt begins on this device: the + // initiator presenting the QR sheet, or the joiner accepting a pairing + // deep link. Pairs with exactly one devicePairingCompleted / + // devicePairingFailed terminal event per attempt. + suspend fun devicePairingStarted( + role: DevicePairingRole + ) + + // Terminal success event for a device-pairing attempt. `durationSecs` is + // the time since devicePairingStarted on the same device. + suspend fun devicePairingCompleted( + role: DevicePairingRole, + durationSecs: Float + ) + + // Terminal failure event for a device-pairing attempt. `reason` + // distinguishes breakage (ERROR) from timeout (EXPIRED) and user + // abandonment (CANCELLED); `step` is the phase the flow was in when it + // died, and `durationSecs` the time since devicePairingStarted. + suspend fun devicePairingFailed( + role: DevicePairingRole, + reason: DevicePairingFailureReason, + step: DevicePairingStep, + durationSecs: Float + ) }