From 6e20e5985fd42088a15184c9420f713c3aa5c600 Mon Sep 17 00:00:00 2001 From: yewreeka Date: Fri, 5 Jun 2026 12:49:58 -0700 Subject: [PATCH 1/2] Add assistant join wait/timeout/poll-rescue metrics Instruments the gap after agents/join where the user watches the assistant joining/verifying state: - assistant_joined: a verified assistant appeared in the member list; wait_duration is the seconds spent watching, surface is where the user was watching (status message bubble, contact card, or agent builder placeholder). - assistant_join_timed_out: no verified assistant arrived within the client wait window (the assistant backend gives up after ~2 minutes). - assistant_join_rescued_by_polling: the iOS client's temporary join-request polling fallback processed a join request the realtime message stream missed - direct evidence of a silently dead stream, with stream_age_secs reporting how stale the stream was. Descriptor change + regenerated Swift package and README catalog (./gradlew build in metrics/). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/ConvosMetrics/CoreActions.swift | 3 ++ .../Sources/ConvosMetrics/CoreEnums.swift | 16 +++++++++ .../ConvosMetrics/MetricsCoreActions.swift | 29 ++++++++++++++++ README.md | 3 ++ .../metrics/descriptors/core/CoreActions.kt | 34 +++++++++++++++++++ 5 files changed, 85 insertions(+) diff --git a/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift b/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift index 99f905e..4e84593 100644 --- a/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift +++ b/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift @@ -3,6 +3,9 @@ public protocol CoreActions: AnyObject, Sendable { func joinedConversation(verificationDuration: Float, memberCount: Int, hasAssistant: Bool, 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..c97d359 100644 --- a/ConvosMetrics/Sources/ConvosMetrics/MetricsCoreActions.swift +++ b/ConvosMetrics/Sources/ConvosMetrics/MetricsCoreActions.swift @@ -31,6 +31,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, @@ -111,6 +133,9 @@ public final class MetricsCoreActions: CoreActions, @unchecked Sendable { public static let eventJoinedConversation: String = "joined_conversation" 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 +148,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..043eae4 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ The section between the AUTOGEN markers below is regenerated by the build from t | `joined_conversation` | `joinedConversation` | `verification_duration`: Float
`member_count`: Int
`has_assistant`: Boolean
`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..0e80fc8 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() @@ -64,6 +73,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, From db0dde49c87c670758ca7a14488d863701a9be1d Mon Sep 17 00:00:00 2001 From: yewreeka Date: Fri, 5 Jun 2026 16:11:41 -0700 Subject: [PATCH 2/2] Add conversation_join_timed_out for invite joins stuck in Verifying joinedConversation's verification_duration only samples successful joins. When the conversation creator's device never approves the join request (offline, or its message stream died), the joiner sits in the "Verifying" state with no telemetry at all. conversation_join_timed_out reports that wait: emitted by the client when the join wait window elapses without the conversation becoming ready, with the same source breakdown (url / scan / paste) as joined_conversation. Co-Authored-By: Claude Opus 4.8 (1M context) --- ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift | 1 + .../Sources/ConvosMetrics/MetricsCoreActions.swift | 8 ++++++++ README.md | 1 + .../org/convos/metrics/descriptors/core/CoreActions.kt | 10 ++++++++++ 4 files changed, 20 insertions(+) diff --git a/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift b/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift index 4e84593..3fb109b 100644 --- a/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift +++ b/ConvosMetrics/Sources/ConvosMetrics/CoreActions.swift @@ -1,6 +1,7 @@ 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 diff --git a/ConvosMetrics/Sources/ConvosMetrics/MetricsCoreActions.swift b/ConvosMetrics/Sources/ConvosMetrics/MetricsCoreActions.swift index c97d359..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, @@ -131,6 +138,7 @@ 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" diff --git a/README.md b/README.md index 043eae4..5375fd1 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ 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 | 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 0e80fc8..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 @@ -64,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