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,