import type { AssistantCloud } from "./AssistantCloud"; import type { AssistantCloudEvent, AssistantCloudEventKind, } from "./AssistantCloudEvents"; export type EngagementEventIds = Pick< AssistantCloudEvent, "thread_id" | "message_id" | "run_id" >; /** * Turns the ids an integration knows (its own thread and message ids) into the * ids the cloud stores. A send is the one event that may create the remote * thread, so it asks for `awaitThread`; every other event reads the ids that * already exist. Returning nothing declines the event: the thread is not one * the integration knows, so there is nothing to attribute it to. */ export type EngagementIdResolver = ( threadId: string, messageId: string | undefined, options: { awaitThread: boolean }, ) => EngagementEventIds | undefined | Promise; type EngagementEventInit = Pick; const MAX_REMEMBERED_THREADS = 256; function remember(map: Map, threadId: string, value: T): void { map.delete(threadId); map.set(threadId, value); if (map.size > MAX_REMEMBERED_THREADS) { map.delete(map.keys().next().value!); } } function mark(set: Set, threadId: string): void { set.delete(threadId); set.add(threadId); if (set.size > MAX_REMEMBERED_THREADS) { set.delete(set.values().next().value!); } } const passThroughIds: EngagementIdResolver = (threadId, messageId) => ({ thread_id: threadId, ...(messageId !== undefined ? { message_id: messageId } : undefined), }); /** * Derives engagement events from what a chat integration observes and keeps * the per thread state the events need: a run's start for the stop duration, * a run's end for the time to the next message, one error and one suggestion * list per run or thread. A started run is kept until it ends or stops; the * rest is kept for the 256 most recently touched threads, so a long session * does not grow it without bound. Delivery goes through the cloud's event * buffer, so a disabled telemetry setting drops everything here as well. */ export class CloudEngagementReporter { private readonly runStartedAt = new Map(); private readonly runEndedAt = new Map(); private readonly shownErrors = new Set(); private readonly shownSuggestions = new Set(); private readonly getCloud: () => AssistantCloud; private readonly resolveIds: EngagementIdResolver; constructor( cloud: AssistantCloud | (() => AssistantCloud), resolveIds: EngagementIdResolver = passThroughIds, ) { this.getCloud = typeof cloud === "function" ? cloud : () => cloud; this.resolveIds = resolveIds; } public runStarted(threadId: string): void { this.runStartedAt.set(threadId, Date.now()); this.shownErrors.delete(threadId); } public runEnded(threadId: string): void { this.runStartedAt.delete(threadId); remember(this.runEndedAt, threadId, Date.now()); } /** Reported once per started run, with the time the run had been going. */ public runStopped(threadId: string): void { const startedAt = this.runStartedAt.get(threadId); if (startedAt === undefined) return; const stoppedAt = Date.now(); this.runStartedAt.delete(threadId); // A stopped run is a run that ended, so the next send measures its idle // time from here rather than from the last run allowed to finish. remember(this.runEndedAt, threadId, stoppedAt); this.track("run_stopped", threadId, undefined, { value: Math.max(0, stoppedAt - startedAt), }); } /** Carries the time since the previous run of the thread ended, when known. */ public messageSent( threadId: string, init: { messageId?: string | undefined; chars: number; attachments: number; }, ): void { const previousRunEndedAt = this.runEndedAt.get(threadId); this.track( "message_sent", threadId, init.messageId, { props: { chars: init.chars, attachments: init.attachments }, ...(previousRunEndedAt !== undefined ? { value: Math.max(0, Date.now() - previousRunEndedAt) } : undefined), }, { awaitThread: true }, ); } public messageEdited( threadId: string, init: { messageId: string; chars: number }, ): void { this.track("message_edited", threadId, init.messageId, { props: { chars: init.chars }, }); } public messageRegenerated(threadId: string, messageId?: string): void { this.track("message_regenerated", threadId, messageId); } /** Reported once per run, so a retried render of the same error stays one event. */ public errorShown( threadId: string, init: { messageId?: string | undefined; reason: string }, ): void { if (this.shownErrors.has(threadId)) return; mark(this.shownErrors, threadId); this.track("error_shown", threadId, init.messageId, { props: { reason: init.reason }, }); } /** Reported once per thread, with the number of suggestions on offer. */ public suggestionsShown(threadId: string, count: number): void { if (this.shownSuggestions.has(threadId)) return; mark(this.shownSuggestions, threadId); this.track("suggestions_shown", threadId, undefined, { value: count }); } public suggestionClicked(threadId: string): void { this.track("suggestion_clicked", threadId); } public attachmentAdded( threadId: string, init: { messageId?: string | undefined; contentType?: string | undefined }, ): void { this.track("attachment_added", threadId, init.messageId, { ...(init.contentType ? { props: { type: init.contentType } } : undefined), }); } public attachmentFailed( threadId: string, init: { messageId?: string | undefined; contentType?: string | undefined }, ): void { this.track("attachment_failed", threadId, init.messageId, { ...(init.contentType ? { props: { type: init.contentType } } : undefined), }); } public voiceStarted(threadId: string): void { this.track("voice_started", threadId); } public speechStarted(threadId: string, messageId?: string): void { this.track("speech_started", threadId, messageId); } public branchSwitched(threadId: string, messageId?: string): void { this.track("branch_switched", threadId, messageId); } public messageCopied(threadId: string, messageId?: string): void { this.track("message_copied", threadId, messageId); } public toolApproved( threadId: string, messageId: string, toolCallId: string, toolName: string, ): void { this.track("tool_approved", threadId, messageId, { props: { toolCallId, toolName }, }); } public toolRejected( threadId: string, messageId: string, toolCallId: string, toolName: string, ): void { this.track("tool_rejected", threadId, messageId, { props: { toolCallId, toolName }, }); } /** Reported only for a thread the cloud already knows. */ public threadSwitched(threadId: string): void { this.track("thread_switched", threadId); } private track( kind: AssistantCloudEventKind, threadId: string, messageId?: string, init: EngagementEventInit = {}, options: { awaitThread: boolean } = { awaitThread: false }, ): void { void Promise.resolve() .then(() => this.resolveIds(threadId, messageId, options)) .then((ids) => { if (!ids) return; if (kind === "thread_switched" && !ids.thread_id) return; this.getCloud().events.track({ kind, ...init, ...ids }); }) .catch(() => {}); } }