Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ build
.gradle
.idea
.kotlin
.build
3 changes: 3 additions & 0 deletions ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
54 changes: 54 additions & 0 deletions ConvosMetrics/Sources/ConvosMetrics/CoreEnums.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
27 changes: 27 additions & 0 deletions ConvosMetrics/Sources/ConvosMetrics/MetricsCoreActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"
}
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ The section between the AUTOGEN markers below is regenerated by the build from t
| `purchase_cancelled` | `purchaseCancelled` | `product_id`: String<br>`source`: PaywallSource { SETTINGS, LOW_BALANCE_BANNER, ONBOARDING, MEMBER_CARD, DEBUG } |
| `purchase_failed` | `purchaseFailed` | `product_id`: String<br>`source`: PaywallSource { SETTINGS, LOW_BALANCE_BANNER, ONBOARDING, MEMBER_CARD, DEBUG }<br>`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 }<br>`duration_secs`: Float |
| `device_pairing_failed` | `devicePairingFailed` | `role`: DevicePairingRole { INITIATOR, JOINER }<br>`reason`: DevicePairingFailureReason { ERROR, EXPIRED, CANCELLED }<br>`step`: DevicePairingStep { QR_DISPLAYED, JOIN_REQUESTED, DATA_DELETION, PIN_SHOWN, PIN_ENTRY, EMOJI_CONFIRMATION, SYNCING }<br>`duration_secs`: Float |

## User Properties

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}