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
+ )
}