diff --git a/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift b/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift index 99f905e..3fb109b 100644 --- a/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift +++ b/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift @@ -1,8 +1,12 @@ public protocol CoreActions: AnyObject, Sendable { func startedConversation() async func joinedConversation(verificationDuration: Float, memberCount: Int, hasAssistant: Bool, source: ConversationSource) async + func conversationJoinTimedOut(waitDuration: Float, source: ConversationSource) async func invitedToConversation(memberCount: Int, hasAssistant: Bool) async func addedAssistant(memberCount: Int) async + func assistantJoined(waitDuration: Float, surface: AssistantJoinSurface, memberCount: Int) async + func assistantJoinTimedOut(waitDuration: Float, surface: AssistantJoinSurface) async + func assistantJoinRescuedByPolling(streamAgeSecs: Float, pollTick: Int) async func sentMessage(sendingTime: Float, memberCount: Int, attachmentTypes: [String], hasText: Bool, hasAssistant: Bool, isSuccess: Bool) async func sharedConversation(memberCount: Int, hasAssistant: Bool, shareTarget: ShareTarget, hasExpiration: Bool, expiresAfterUse: Bool, isSuccess: Bool) async func builtAgent(buildDuration: Float, instructionCharCount: Int, instructionWordCount: Int, attachmentTypes: [String], hasVoiceMemo: Bool, voiceMemoDuration: Float, connectionTypes: [String], entryMode: AgentBuilderEntryMode, isSuccess: Bool) async diff --git a/ConvosMetrics/Sources/ConvosMetrics/CoreEnums.swift b/ConvosMetrics/Sources/ConvosMetrics/CoreEnums.swift index efa074d..b90cc62 100644 --- a/ConvosMetrics/Sources/ConvosMetrics/CoreEnums.swift +++ b/ConvosMetrics/Sources/ConvosMetrics/CoreEnums.swift @@ -16,6 +16,22 @@ extension ConversationSource { } } +public enum AssistantJoinSurface { + case statusMessage + case contactCard + case builderPlaceholder +} + +extension AssistantJoinSurface { + public var metricsString: String { + switch self { + case .statusMessage: return "status_message" + case .contactCard: return "contact_card" + case .builderPlaceholder: return "builder_placeholder" + } + } +} + public enum ShareTarget { case messages case mail diff --git a/ConvosMetrics/Sources/ConvosMetrics/MetricsCoreActions.swift b/ConvosMetrics/Sources/ConvosMetrics/MetricsCoreActions.swift index 7fa9b8c..e350793 100644 --- a/ConvosMetrics/Sources/ConvosMetrics/MetricsCoreActions.swift +++ b/ConvosMetrics/Sources/ConvosMetrics/MetricsCoreActions.swift @@ -18,6 +18,13 @@ public final class MetricsCoreActions: CoreActions, @unchecked Sendable { ]) } + public func conversationJoinTimedOut(waitDuration: Float, source: ConversationSource) async { + delegate?.sendEvent(name: Self.eventConversationJoinTimedOut, properties: [ + Self.paramWaitDuration: waitDuration, + Self.paramSource: source.metricsString, + ]) + } + public func invitedToConversation(memberCount: Int, hasAssistant: Bool) async { delegate?.sendEvent(name: Self.eventInvitedToConversation, properties: [ Self.paramMemberCount: memberCount, @@ -31,6 +38,28 @@ public final class MetricsCoreActions: CoreActions, @unchecked Sendable { ]) } + public func assistantJoined(waitDuration: Float, surface: AssistantJoinSurface, memberCount: Int) async { + delegate?.sendEvent(name: Self.eventAssistantJoined, properties: [ + Self.paramWaitDuration: waitDuration, + Self.paramSurface: surface.metricsString, + Self.paramMemberCount: memberCount, + ]) + } + + public func assistantJoinTimedOut(waitDuration: Float, surface: AssistantJoinSurface) async { + delegate?.sendEvent(name: Self.eventAssistantJoinTimedOut, properties: [ + Self.paramWaitDuration: waitDuration, + Self.paramSurface: surface.metricsString, + ]) + } + + public func assistantJoinRescuedByPolling(streamAgeSecs: Float, pollTick: Int) async { + delegate?.sendEvent(name: Self.eventAssistantJoinRescuedByPolling, properties: [ + Self.paramStreamAgeSecs: streamAgeSecs, + Self.paramPollTick: pollTick, + ]) + } + public func sentMessage(sendingTime: Float, memberCount: Int, attachmentTypes: [String], hasText: Bool, hasAssistant: Bool, isSuccess: Bool) async { delegate?.sendEvent(name: Self.eventSentMessage, properties: [ Self.paramSendingTime: sendingTime, @@ -109,8 +138,12 @@ public final class MetricsCoreActions: CoreActions, @unchecked Sendable { public static let eventStartedConversation: String = "started_conversation" public static let eventJoinedConversation: String = "joined_conversation" + public static let eventConversationJoinTimedOut: String = "conversation_join_timed_out" public static let eventInvitedToConversation: String = "invited_to_conversation" public static let eventAddedAssistant: String = "added_assistant" + public static let eventAssistantJoined: String = "assistant_joined" + public static let eventAssistantJoinTimedOut: String = "assistant_join_timed_out" + public static let eventAssistantJoinRescuedByPolling: String = "assistant_join_rescued_by_polling" public static let eventSentMessage: String = "sent_message" public static let eventSharedConversation: String = "shared_conversation" public static let eventBuiltAgent: String = "built_agent" @@ -123,6 +156,10 @@ public final class MetricsCoreActions: CoreActions, @unchecked Sendable { public static let paramMemberCount: String = "member_count" public static let paramHasAssistant: String = "has_assistant" public static let paramSource: String = "source" + public static let paramWaitDuration: String = "wait_duration" + public static let paramSurface: String = "surface" + public static let paramStreamAgeSecs: String = "stream_age_secs" + public static let paramPollTick: String = "poll_tick" public static let paramSendingTime: String = "sending_time" public static let paramAttachmentTypes: String = "attachment_types" public static let paramHasText: String = "has_text" diff --git a/README.md b/README.md index 1f755f5..5375fd1 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,12 @@ The section between the AUTOGEN markers below is regenerated by the build from t |-------|----------|------------| | `started_conversation` | `startedConversation` | _none_ | | `joined_conversation` | `joinedConversation` | `verification_duration`: Float
`member_count`: Int
`has_assistant`: Boolean
`source`: ConversationSource { URL, Scan, Paste, Message } | +| `conversation_join_timed_out` | `conversationJoinTimedOut` | `wait_duration`: Float
`source`: ConversationSource { URL, Scan, Paste, Message } | | `invited_to_conversation` | `invitedToConversation` | `member_count`: Int
`has_assistant`: Boolean | | `added_assistant` | `addedAssistant` | `member_count`: Int | +| `assistant_joined` | `assistantJoined` | `wait_duration`: Float
`surface`: AssistantJoinSurface { STATUS_MESSAGE, CONTACT_CARD, BUILDER_PLACEHOLDER }
`member_count`: Int | +| `assistant_join_timed_out` | `assistantJoinTimedOut` | `wait_duration`: Float
`surface`: AssistantJoinSurface { STATUS_MESSAGE, CONTACT_CARD, BUILDER_PLACEHOLDER } | +| `assistant_join_rescued_by_polling` | `assistantJoinRescuedByPolling` | `stream_age_secs`: Float
`poll_tick`: Int | | `sent_message` | `sentMessage` | `sending_time`: Float
`member_count`: Int
`attachment_types`: List
`has_text`: Boolean
`has_assistant`: Boolean
`is_success`: Boolean | | `shared_conversation` | `sharedConversation` | `member_count`: Int
`has_assistant`: Boolean
`share_target`: ShareTarget { MESSAGES, MAIL, COPY, QR_CODE, AIRDROP, OTHER, CANCELLED }
`has_expiration`: Boolean
`expires_after_use`: Boolean
`is_success`: Boolean | | `built_agent` | `builtAgent` | `build_duration`: Float
`instruction_char_count`: Int
`instruction_word_count`: Int
`attachment_types`: List
`has_voice_memo`: Boolean
`voice_memo_duration`: Float
`connection_types`: List
`entry_mode`: AgentBuilderEntryMode { COMPOSER, VOICE_MEMO }
`is_success`: Boolean | 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 9f35b5d..1c4796e 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,15 @@ enum class PurchaseFailureReason { UNKNOWN, } +// The UI surface where the user watches an assistant join after requesting +// one: the in-chat pending status bubble (bare add), the agent contact card +// (template add / deep link), or the agent-builder placeholder (post-Make). +enum class AssistantJoinSurface { + STATUS_MESSAGE, + CONTACT_CARD, + BUILDER_PLACEHOLDER, +} + @CoreActionsTarget interface CoreActions { suspend fun startedConversation() @@ -55,6 +64,16 @@ interface CoreActions { source: ConversationSource ) + // Fired when an invite join is still unverified after the client wait + // window - the joiner watched the "Verifying" state without the + // conversation creator's device approving the join request (counterpart + // to joinedConversation's verificationDuration, which only samples + // successes). + suspend fun conversationJoinTimedOut( + waitDuration: Float, + source: ConversationSource + ) + suspend fun invitedToConversation( memberCount: Int, hasAssistant: Boolean @@ -64,6 +83,31 @@ interface CoreActions { memberCount: Int ) + // Fired when a verified assistant actually appears in the conversation's + // member list after a join was requested. `waitDuration` is the seconds + // the user spent watching the joining/verifying state. + suspend fun assistantJoined( + waitDuration: Float, + surface: AssistantJoinSurface, + memberCount: Int + ) + + // Fired when no verified assistant appeared within the join wait window + // (the assistant backend gives up after about two minutes). + suspend fun assistantJoinTimedOut( + waitDuration: Float, + surface: AssistantJoinSurface + ) + + // Fired when the client's join-request polling fallback processed an + // assistant join request that the realtime message stream had missed - + // direct evidence of a silently dead stream. `streamAgeSecs` is the time + // since the stream last delivered any message (-1 when it never did). + suspend fun assistantJoinRescuedByPolling( + streamAgeSecs: Float, + pollTick: Int + ) + suspend fun sentMessage( sendingTime: Float, memberCount: Int,