@sendbird/ai-agent-messenger-react 1.35.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/dist/index.cjs +177 -177
  2. package/dist/index.d.ts +1596 -902
  3. package/dist/index.js +5184 -5014
  4. package/package.json +5 -5
package/dist/index.d.ts CHANGED
@@ -4,7 +4,6 @@ import type { AIAgentModule } from '@sendbird/chat/aiAgent';
4
4
  import { AriaRole } from 'react';
5
5
  import type { BaseChannelCollectionEventHandler } from '@sendbird/chat/groupChannel';
6
6
  import type { BaseMessage } from '@sendbird/chat/message';
7
- import type { CollectionEventSource } from '@sendbird/chat';
8
7
  import { ComponentType } from 'react';
9
8
  import { Context } from 'react';
10
9
  import type { Conversation as Conversation_2 } from '@sendbird/chat/aiAgent';
@@ -292,29 +291,28 @@ declare abstract class AIAgentBaseStats {
292
291
  protected committed: boolean = false;
293
292
  protected commitCallback: StatsAppendCallback | null = null;
294
293
  protected conversationId: number | null = null;
295
- protected channelUrl: string | null = null;
296
294
  protected errorCode: number | null = null;
297
295
  protected errorDescription: string | null = null;
298
296
  protected extraData: Record<string, unknown> = {};
299
297
 
298
+ protected abstract buildPayload(): AIAgentStatPayload | null;
299
+ protected abstract getMetricKey(): string;
300
+
300
301
  setCommitCallback(callback: StatsAppendCallback): this {
301
302
  this.commitCallback = callback;
302
303
  return this;
303
304
  }
304
305
 
305
- setConversationId(id: number): this {
306
- this.conversationId = id;
306
+ setConversationId(id?: number): this {
307
+ if (id) this.conversationId = id;
307
308
  return this;
308
309
  }
309
310
 
310
- setChannelUrl(url: string): this {
311
- this.setExtra('channel_url', url);
312
- return this;
313
- }
311
+ setError(error: unknown | null | undefined): this {
312
+ if (error === null || error === undefined) return this;
314
313
 
315
- setError(code: number | undefined, description: string): this {
316
- this.errorCode = code ?? DEFAULT_ERROR_CODE;
317
- this.errorDescription = description;
314
+ this.errorCode = getErrorCode(error) ?? DEFAULT_ERROR_CODE;
315
+ this.errorDescription = getErrorDescription(error);
318
316
  return this;
319
317
  }
320
318
 
@@ -331,7 +329,7 @@ declare abstract class AIAgentBaseStats {
331
329
  const existing = this.timers.get(key);
332
330
  if (existing?.startTime !== null && existing?.startTime !== undefined) return this;
333
331
 
334
- this.timers.set(key, { startTime: defaultStatsClock.now(), endTime: null });
332
+ this.timers.set(key, { startTime: performance.now(), endTime: null });
335
333
  return this;
336
334
  }
337
335
 
@@ -341,22 +339,51 @@ declare abstract class AIAgentBaseStats {
341
339
  const existing = this.timers.get(key);
342
340
  if (!existing || existing.startTime === null || existing.endTime !== null) return this;
343
341
 
344
- this.timers.set(key, { ...existing, endTime: defaultStatsClock.now() });
342
+ this.timers.set(key, { ...existing, endTime: performance.now() });
345
343
  return this;
346
344
  }
347
345
 
348
- protected getDuration(key: string): number | null {
346
+ getTimer(key: string): TimerReading {
349
347
  const timer = this.timers.get(key);
350
- if (!timer || timer.startTime === null || timer.endTime === null) return null;
351
- return timer.endTime - timer.startTime;
348
+ if (!timer || timer.startTime === null) return { startAt: null, endedAt: null, duration: null };
349
+ return {
350
+ startAt: timer.startTime,
351
+ endedAt: timer.endTime,
352
+ duration: timer.endTime === null ? null : timer.endTime - timer.startTime,
353
+ };
354
+ }
355
+
356
+ protected createPayload(value: string | number, params?: StatsPayloadParams): AIAgentStatPayload {
357
+ const payload: AIAgentStatPayload = {
358
+ key: this.getMetricKey(),
359
+ value: String(value),
360
+ };
361
+
362
+ const conversationId = params?.conversationId ?? this.conversationId ?? undefined;
363
+ if (conversationId !== undefined) payload.conversation_id = conversationId;
364
+
365
+ if (this.errorCode !== null) {
366
+ payload.error_code = this.errorCode;
367
+ payload.error_description = this.errorDescription ?? '';
368
+ }
369
+
370
+ const extra = dropNullish({
371
+ ...this.extraData,
372
+ ...params?.extra,
373
+ });
374
+ if (Object.keys(extra).length > 0) payload.extra = extra;
375
+
376
+ return payload;
352
377
  }
353
378
 
354
379
  isCommitted(): boolean {
355
380
  return this.committed;
356
381
  }
357
382
 
358
- protected abstract buildPayload(): AIAgentStatPayload | null;
359
- protected abstract getMetricKey(): string;
383
+ /** Consume without emitting: the metric counts as committed for once-per-session gating. */
384
+ discard(): void {
385
+ this.committed = true;
386
+ }
360
387
 
361
388
  commit(): boolean {
362
389
  if (this.committed || !this.commitCallback) {
@@ -364,14 +391,10 @@ declare abstract class AIAgentBaseStats {
364
391
  }
365
392
 
366
393
  const payload = this.buildPayload();
367
-
368
- if (!payload) {
369
- return false;
370
- }
394
+ if (!payload) return false;
371
395
 
372
396
  this.committed = true;
373
- const result = this.commitCallback(AI_AGENT_STAT_TYPE, payload);
374
- return result;
397
+ return this.commitCallback(AI_AGENT_STAT_TYPE, payload);
375
398
  }
376
399
  }
377
400
 
@@ -587,7 +610,6 @@ declare interface AIAgentConversationContextValue {
587
610
  messages: ChatSDKBaseMessage[];
588
611
  newMessages: ChatSDKBaseMessage[];
589
612
  initialized: boolean;
590
- subscribeMessageEvents: (listener: (event: ConversationMessageEvent) => void) => () => void;
591
613
  loadPrevious: () => Promise<void>;
592
614
  loadNext: () => Promise<void>;
593
615
  sendUserMessage: (params: ChatSDKUserMessageCreateParams) => Promise<ChatSDKUserMessage>;
@@ -608,6 +630,8 @@ declare interface AIAgentConversationContextValue {
608
630
  };
609
631
  input: InputState;
610
632
  };
633
+
634
+ _statsEnabled: boolean;
611
635
  }
612
636
 
613
637
  declare interface AIAgentConversationListContextValue {
@@ -728,6 +752,9 @@ declare interface AIAgentInterface {
728
752
  /** @internal Schedules metrics work that should only start while the app is foregrounded. */
729
753
  readonly scheduleForegroundTask?: ForegroundTaskScheduler;
730
754
 
755
+ /** @internal Watches whether the app was backgrounded during a stats measurement window. */
756
+ readonly watchBackgrounded?: BackgroundedWatcher;
757
+
731
758
  /** @internal Stats trackers owned by this SDK instance. */
732
759
  readonly statsTrackers: AIAgentStatsTrackers;
733
760
 
@@ -1278,23 +1305,40 @@ declare interface AIAgentStringSet {
1278
1305
  };
1279
1306
  }
1280
1307
 
1281
- declare class AIEVAuthExpiredUnhandledStatsTracker {
1282
- private readonly callback: StatsAppendCallback;
1283
-
1284
- constructor(callback: StatsAppendCallback) {
1285
- this.callback = callback;
1286
- }
1287
-
1308
+ /**
1309
+ * Spec: aiev_auth_expired_unhandled
1310
+ *
1311
+ * Records a server EXTERNAL_AUTH_TOKEN_EXPIRED event that the SDK cannot
1312
+ * deliver to the host app.
1313
+ *
1314
+ * Timeline:
1315
+ * - Trigger when the SDK receives the external-token-expired AIEV payload.
1316
+ * - Commit immediately if payload parsing fails or no host callback/delegate
1317
+ * can receive the event.
1318
+ *
1319
+ * Payload:
1320
+ * - value: failure_reason = payload_parse_failed | callback_not_found.
1321
+ * - extra.agent_id: AI Agent id when available.
1322
+ *
1323
+ * Policy:
1324
+ * - Fire-and-forget; one failed event produces one stat.
1325
+ * - Successful delivery to the host app is not recorded by this metric.
1326
+ */
1327
+ declare class AIEVAuthExpiredUnhandledStatsTracker extends StatsTrackerBase {
1288
1328
  onPayloadParseFailed(params: { aiAgentId: string }): void {
1289
- append(this.callback, METRIC_KEY_AIEV_AUTH_EXPIRED_UNHANDLED, 'payload_parse_failed', {
1290
- extra: { agent_id: params.aiAgentId },
1291
- });
1329
+ new AIEVAuthExpiredUnhandledStats()
1330
+ .setReason('payload_parse_failed')
1331
+ .setAIAgentId(params.aiAgentId)
1332
+ .setCommitCallback(this.commitCallback)
1333
+ .commit();
1292
1334
  }
1293
1335
 
1294
1336
  onCallbackNotFound(params: { aiAgentId: string }): void {
1295
- append(this.callback, METRIC_KEY_AIEV_AUTH_EXPIRED_UNHANDLED, 'callback_not_found', {
1296
- extra: { agent_id: params.aiAgentId },
1297
- });
1337
+ new AIEVAuthExpiredUnhandledStats()
1338
+ .setReason('callback_not_found')
1339
+ .setAIAgentId(params.aiAgentId)
1340
+ .setCommitCallback(this.commitCallback)
1341
+ .commit();
1298
1342
  }
1299
1343
 
1300
1344
  clear(): void {}
@@ -1344,8 +1388,15 @@ declare interface AttachmentRules {
1344
1388
  getUploadSizeLimitInMB: (mimeType: string) => number;
1345
1389
  }
1346
1390
 
1391
+ declare type AuthState = 'idle' | 'in_progress' | 'completed' | 'failed';
1392
+
1393
+ declare type AuthState_2 = 'idle' | 'in_progress' | 'completed' | 'failed';
1394
+
1347
1395
  declare interface AuthTokenRefreshedParams {}
1348
1396
 
1397
+ /** Starts watching foreground state; the returned function stops watching and reports whether the app was backgrounded while watched. */
1398
+ declare type BackgroundedWatcher = () => () => boolean;
1399
+
1349
1400
  declare abstract class BaseCommand {
1350
1401
  abstract type: CommandType;
1351
1402
  payload: any;
@@ -1389,7 +1440,7 @@ declare type CachedMessengerSettings = Omit<MessengerSettingsResponse, 'memory'>
1389
1440
  export declare type ChallengeActionParams = {
1390
1441
  key: string;
1391
1442
  requestId: string;
1392
- action: 'submit' | 'cancel';
1443
+ action: 'submit' | 'cancel' | 'fail';
1393
1444
  data?: Record<string, unknown>;
1394
1445
  };
1395
1446
 
@@ -1407,8 +1458,6 @@ declare type ChatSDKBaseChannelCollectionEventHandler = BaseChannelCollectionEve
1407
1458
 
1408
1459
  declare type ChatSDKBaseMessage = BaseMessage;
1409
1460
 
1410
- declare type ChatSDKCollectionEventSource = CollectionEventSource;
1411
-
1412
1461
  declare type ChatSDKConversation = Conversation_2;
1413
1462
 
1414
1463
  declare type ChatSDKConversationStatus = ConversationStatus;
@@ -1590,7 +1639,7 @@ declare interface ContextObject {
1590
1639
  context: Record<string, string>;
1591
1640
  }
1592
1641
 
1593
- export declare const Conversation: ({ children, onNavigateToConversationList, channelUrl, onClearChannelUrl, shouldMarkAsRead, announcementsEnabled, conversationStatsEnabled, initialFocusTarget, style, closedChannelUrl, onClearClosedChannelUrl, }: ConversationProps) => JSX.Element;
1642
+ export declare const Conversation: ({ children, onNavigateToConversationList, channelUrl, onClearChannelUrl, shouldMarkAsRead, shouldInitializeConversation, announcementsEnabled, conversationStatsEnabled, initialFocusTarget, style, closedChannelUrl, onClearClosedChannelUrl, }: ConversationProps) => JSX.Element;
1594
1643
 
1595
1644
  export declare const ConversationContext: Context<ConversationContextValue | null>;
1596
1645
 
@@ -1600,12 +1649,13 @@ declare interface ConversationContextProps {
1600
1649
  onClearChannelUrl?: () => void;
1601
1650
  shouldMarkAsRead?: boolean;
1602
1651
  conversationStatsEnabled?: boolean;
1652
+ /** @internal */
1653
+ shouldInitializeConversation?: boolean;
1603
1654
  }
1604
1655
 
1605
1656
  export declare const ConversationContextProvider: ({ children, ...props }: PropsWithChildren<ConversationContextProps>) => JSX.Element;
1606
1657
 
1607
1658
  declare interface ConversationContextValue extends AIAgentConversationContextValue {
1608
- conversationStatsEnabled: boolean;
1609
1659
  scrollSource: ConversationScrollContextValue;
1610
1660
  goToActiveConversation: () => Promise<void>;
1611
1661
  onNavigateToConversationList?: () => void;
@@ -1702,234 +1752,326 @@ declare interface ConversationHeaderTemplateProps {
1702
1752
 
1703
1753
  declare type ConversationInitialFocusTarget = 'messageInput';
1704
1754
 
1755
+ declare type ConversationInitialRenderMeasure = {
1756
+ channelUrl: string;
1757
+ pageRenderState: PageRenderState;
1758
+ };
1759
+
1705
1760
  declare class ConversationInitialRenderStats extends AIAgentBaseStats {
1761
+ private channelUrl: string | null = null;
1706
1762
  private presentMethod: PresentMethod = 'direct_present';
1707
1763
 
1764
+ setChannelUrl(url: string): this {
1765
+ this.channelUrl = url;
1766
+ return this;
1767
+ }
1768
+
1708
1769
  setPresentMethod(method: PresentMethod): this {
1709
1770
  this.presentMethod = method;
1710
1771
  return this;
1711
1772
  }
1712
1773
 
1774
+ resetPageRenderSpan(): this {
1775
+ this.timers.delete(DurationKey.PAGE_RENDER);
1776
+ this.timers.delete(DurationKey.PAGE_RENDER_FROM_CACHE);
1777
+ this.timers.delete(DurationKey.GET_CHANNEL);
1778
+ this.timers.delete(DurationKey.GET_MESSAGES);
1779
+ this.conversationId = null;
1780
+ this.channelUrl = null;
1781
+ this.errorCode = null;
1782
+ this.errorDescription = null;
1783
+ return this;
1784
+ }
1785
+
1713
1786
  protected getMetricKey(): string {
1714
1787
  return METRIC_KEY_CONVERSATION_INITIAL_RENDER;
1715
1788
  }
1716
1789
 
1717
1790
  protected buildPayload(): AIAgentStatPayload | null {
1718
- const authDuration = this.getDuration(DurationKey.AUTH);
1719
- const getChannelDuration = this.getDuration(DurationKey.GET_CHANNEL);
1720
- const getMessagesDuration = this.getDuration(DurationKey.GET_MESSAGES);
1721
- const totalDurationFromCache = this.getDuration(DurationKey.TOTAL_DURATION_FROM_CACHE);
1722
- const totalDuration = this.getDuration(DurationKey.TOTAL_DURATION);
1791
+ const auth = this.getTimer(DurationKey.AUTH);
1792
+ const pageRender = this.getTimer(DurationKey.PAGE_RENDER);
1793
+ const fromCache = this.getTimer(DurationKey.PAGE_RENDER_FROM_CACHE);
1723
1794
 
1724
- const extra: Record<string, unknown> = {
1725
- ...this.extraData,
1726
- present_method: this.presentMethod,
1727
- ...(authDuration !== null && { [DurationKey.AUTH]: authDuration }),
1728
- ...(getChannelDuration !== null && { [DurationKey.GET_CHANNEL]: getChannelDuration }),
1729
- ...(getMessagesDuration !== null && { [DurationKey.GET_MESSAGES]: getMessagesDuration }),
1730
- ...(totalDurationFromCache !== null && { [DurationKey.TOTAL_DURATION_FROM_CACHE]: totalDurationFromCache }),
1731
- };
1795
+ // Only the auth-error early commit legitimately lacks a page render duration;
1796
+ // any other commit without it is an incomplete measurement and must not emit.
1797
+ if (pageRender.duration === null && (this.errorCode === null || auth.duration === null)) return null;
1732
1798
 
1733
- return createStatsPayload(this.getMetricKey(), totalDuration ?? 0, {
1734
- conversationId: this.conversationId ?? undefined,
1735
- error: this.errorCode !== null ? { code: this.errorCode, message: this.errorDescription ?? '' } : undefined,
1736
- extra,
1799
+ // The timers race freely; subtracting the overlap counts the auth wait exactly once.
1800
+ const pageRenderDuration = pageRender.duration === null ? 0 : pageRender.duration - overlapMs(auth, pageRender);
1801
+ const fromCacheDuration = fromCache.duration === null ? null : fromCache.duration - overlapMs(auth, fromCache);
1802
+
1803
+ return this.createPayload((auth.duration ?? 0) + pageRenderDuration, {
1804
+ extra: {
1805
+ channel_url: this.channelUrl,
1806
+ present_method: this.presentMethod,
1807
+ [DurationKey.AUTH]: auth.duration,
1808
+ [DurationKey.GET_CHANNEL]: this.getTimer(DurationKey.GET_CHANNEL).duration,
1809
+ [DurationKey.GET_MESSAGES]: this.getTimer(DurationKey.GET_MESSAGES).duration,
1810
+ [DurationKey.PAGE_RENDER_FROM_CACHE]: fromCacheDuration,
1811
+ },
1737
1812
  });
1738
1813
  }
1739
1814
  }
1740
1815
 
1741
1816
  /**
1742
- * Manages ConversationInitialRenderStats lifecycle and encapsulates tracking logic.
1817
+ * One Conversation entry attempt. Owns the page render phase machine; the tracker
1818
+ * owns the cross-attempt policy (auth span, supersession, one commit per entry).
1819
+ *
1820
+ * Async-boundary events carry the channel identity so a result delivered to the
1821
+ * wrong span is rejected instead of consuming a phase transition.
1822
+ *
1823
+ * Timers record their own event boundaries and race freely against auth; the
1824
+ * metric subtracts the overlap at payload time, so event ordering needs no
1825
+ * orchestration here.
1743
1826
  */
1744
- declare class ConversationInitialRenderStatsTracker {
1745
- private readonly commitCallback: StatsAppendCallback;
1746
- private apiResultReceived = false;
1747
- private channelUrl?: string;
1748
- private consumed = false;
1749
- private consumedByAuthError = false;
1750
- private presentMethod: PresentMethod = 'direct_present';
1751
- private renderedAfterApiResult = false;
1752
- private stats: ConversationInitialRenderStats | null = null;
1827
+ declare class ConversationInitialRenderStatsSpan extends StatsSpanBase<
1828
+ ConversationInitialRenderStats,
1829
+ ConversationInitialRenderMeasure
1830
+ > {
1831
+ protected measure: ConversationInitialRenderMeasure;
1753
1832
 
1754
- constructor(commitCallback: StatsAppendCallback) {
1755
- this.commitCallback = commitCallback;
1833
+ constructor(stats: ConversationInitialRenderStats | null, channelUrl: string, visible: boolean) {
1834
+ super(stats, visible);
1835
+ this.measure = { channelUrl, pageRenderState: 'initialized' };
1836
+ this.stats
1837
+ ?.setChannelUrl(channelUrl)
1838
+ .startTimer(DurationKey.PAGE_RENDER)
1839
+ .startTimer(DurationKey.PAGE_RENDER_FROM_CACHE);
1756
1840
  }
1757
1841
 
1758
- private getOrCreateStats(): ConversationInitialRenderStats | null {
1759
- if (this.consumed) return null;
1760
- if (this.stats?.isCommitted()) {
1761
- this.consumed = true;
1762
- return null;
1763
- }
1764
- if (!this.stats) {
1765
- this.stats = new ConversationInitialRenderStats()
1766
- .setCommitCallback(this.commitCallback)
1767
- .setPresentMethod(this.presentMethod);
1768
- this.apiResultReceived = false;
1769
- this.renderedAfterApiResult = false;
1770
- }
1771
- return this.stats;
1842
+ onGetChannelStart(channelUrl: string): void {
1843
+ if (!this.stats || channelUrl !== this.measure.channelUrl || this.measure.pageRenderState !== 'initialized') return;
1844
+
1845
+ this.stats.startTimer(DurationKey.GET_CHANNEL);
1846
+ this.measure.pageRenderState = 'channel_fetching';
1772
1847
  }
1773
1848
 
1774
- private getActiveStats(): ConversationInitialRenderStats | null {
1775
- if (this.consumed || !this.stats || this.stats.isCommitted()) return null;
1776
- return this.stats;
1849
+ onGetChannelComplete(channelUrl: string, conversationId?: number): void {
1850
+ if (!this.stats || channelUrl !== this.measure.channelUrl || this.measure.pageRenderState !== 'channel_fetching')
1851
+ return;
1852
+
1853
+ this.stats.stopTimer(DurationKey.GET_CHANNEL).setConversationId(conversationId);
1854
+ this.measure.pageRenderState = 'channel_fetched';
1777
1855
  }
1778
1856
 
1779
- private matchesChannelUrl(channelUrl?: string): boolean {
1780
- return channelUrl === undefined || this.channelUrl === undefined || channelUrl === this.channelUrl;
1857
+ onGetChannelError(channelUrl: string, error: Error): void {
1858
+ if (!this.stats || channelUrl !== this.measure.channelUrl || this.measure.pageRenderState !== 'channel_fetching')
1859
+ return;
1860
+
1861
+ this.stats.stopTimer(DurationKey.GET_CHANNEL).setError(error).stopTimer(DurationKey.PAGE_RENDER);
1862
+ this.measure.pageRenderState = 'channel_failed';
1863
+ this.tryFinalize();
1781
1864
  }
1782
1865
 
1783
- private pinChannelUrl(channelUrl?: string): boolean {
1784
- if (channelUrl === undefined) return true;
1785
- if (!this.matchesChannelUrl(channelUrl)) return false;
1866
+ onGetMessagesStart(): void {
1867
+ if (!this.stats || this.measure.pageRenderState !== 'channel_fetched') return;
1786
1868
 
1787
- this.channelUrl = channelUrl;
1788
- this.stats?.setChannelUrl(channelUrl);
1789
- return true;
1869
+ this.stats.startTimer(DurationKey.GET_MESSAGES);
1870
+ this.measure.pageRenderState = 'messages_fetching';
1790
1871
  }
1791
1872
 
1792
- private consume(commitResult: boolean): boolean {
1793
- if (commitResult) {
1794
- this.consumed = true;
1873
+ onGetMessagesCacheComplete(channelUrl: string, error: Error | null): void {
1874
+ if (
1875
+ !this.stats ||
1876
+ channelUrl !== this.measure.channelUrl ||
1877
+ error ||
1878
+ this.measure.pageRenderState !== 'messages_fetching'
1879
+ ) {
1880
+ return;
1795
1881
  }
1796
- return commitResult;
1797
- }
1798
1882
 
1799
- private commitIfReady(channelUrl?: string): boolean {
1800
- if (this.consumed || !this.stats || this.stats.isCommitted()) return true;
1801
- if (!this.matchesChannelUrl(channelUrl)) return false;
1802
- if (!this.apiResultReceived || !this.renderedAfterApiResult) return false;
1803
- return this.consume(this.stats.stopTimer(DurationKey.TOTAL_DURATION).commit());
1883
+ this.stats.stopTimer(DurationKey.PAGE_RENDER_FROM_CACHE);
1804
1884
  }
1805
1885
 
1806
- start(channelUrl?: string): void {
1807
- const stats = this.getOrCreateStats();
1808
- if (!stats || !this.pinChannelUrl(channelUrl)) return;
1886
+ onGetMessagesComplete(channelUrl: string, error: Error | null): void {
1887
+ if (!this.stats || channelUrl !== this.measure.channelUrl || this.measure.pageRenderState !== 'messages_fetching')
1888
+ return;
1809
1889
 
1810
- stats.startTimer(DurationKey.TOTAL_DURATION).startTimer(DurationKey.TOTAL_DURATION_FROM_CACHE);
1890
+ this.stats.stopTimer(DurationKey.GET_MESSAGES).setError(error);
1891
+ this.measure.pageRenderState = 'messages_fetching_completed';
1811
1892
  }
1812
1893
 
1813
- onAuthStart(): void {
1814
- if (this.consumedByAuthError) {
1815
- this.clear();
1816
- }
1817
- this.getOrCreateStats()?.startTimer(DurationKey.AUTH);
1894
+ onRendered(): void {
1895
+ if (!this.stats || this.measure.pageRenderState !== 'messages_fetching_completed') return;
1896
+
1897
+ this.stats.stopTimer(DurationKey.PAGE_RENDER);
1898
+ this.tryFinalize();
1818
1899
  }
1819
1900
 
1820
- onAuthComplete(): void {
1821
- this.getActiveStats()?.stopTimer(DurationKey.AUTH);
1901
+ onRenderDiscarded(): void {
1902
+ if (!this.stats || this.measure.pageRenderState !== 'messages_fetching_completed') return;
1903
+
1904
+ this.stats.discard();
1905
+ this.stats = null;
1822
1906
  }
1823
1907
 
1824
- onAuthError(error: Error): void {
1825
- const stats = this.getActiveStats();
1826
- if (stats) {
1827
- const committed = this.consume(
1828
- stats
1829
- .stopTimer(DurationKey.AUTH)
1830
- .setError(error instanceof SendbirdError ? error.code : undefined, error.message)
1831
- .stopTimer(DurationKey.TOTAL_DURATION)
1832
- .commit(),
1833
- );
1834
- if (committed) {
1835
- this.consumedByAuthError = true;
1836
- }
1837
- }
1908
+ /** Auth settled while this span was measuring; a finalize that waited on in-flight auth can proceed. */
1909
+ onAuthSettled(): void {
1910
+ this.tryFinalize();
1838
1911
  }
1839
1912
 
1840
- onGetChannelStart(channelUrl?: string): void {
1841
- const stats = this.getActiveStats();
1842
- if (!stats || !this.pinChannelUrl(channelUrl)) return;
1913
+ /** Commit once both facts exist: the page render ended (render or failure) and auth is not in flight. */
1914
+ private tryFinalize(): void {
1915
+ if (!this.stats) return;
1916
+ if (this.stats.getTimer(DurationKey.PAGE_RENDER).endedAt === null) return;
1917
+
1918
+ const auth = this.stats.getTimer(DurationKey.AUTH);
1919
+ if (auth.startAt !== null && auth.endedAt === null) return;
1843
1920
 
1844
- stats.startTimer(DurationKey.GET_CHANNEL);
1921
+ if (this.measure.pageRenderState === 'channel_failed') {
1922
+ // Early error commit (iOS/Android convergence) bypasses the visibility gate, like onAuthError.
1923
+ this.stats.commit();
1924
+ this.stats = null;
1925
+ return;
1926
+ }
1927
+ this.commitOrDefer();
1845
1928
  }
1929
+ }
1846
1930
 
1847
- onGetChannelComplete(params?: number | GetChannelCompleteParams): void {
1848
- const channelUrl = typeof params === 'object' ? params.channelUrl : undefined;
1849
- const conversationId = typeof params === 'object' ? params.conversationId : params;
1850
- const stats = this.getActiveStats();
1851
- if (!stats || !this.pinChannelUrl(channelUrl)) return;
1931
+ /**
1932
+ * Spec: conversation_initial_render
1933
+ *
1934
+ * Measures the first Conversation screen render as the sum of auth and the
1935
+ * page render span required before the first remote-rendered message list frame.
1936
+ *
1937
+ * Timeline:
1938
+ * - Resolve present method.
1939
+ * - Track auth independently; it can start before any Conversation screen exists.
1940
+ * - Start a span when the Conversation screen display lifecycle begins.
1941
+ * - Track get-channel and get-messages phases on the span.
1942
+ * - Capture cache completion as best-effort total_duration_from_cache_ms, but
1943
+ * do not commit until the remote render path completes.
1944
+ * - Commit on the first remote rendered frame. While the screen is hidden the
1945
+ * measurement still freezes at that frame, but emission is deferred until the
1946
+ * screen becomes visible.
1947
+ * - Discard (no payload) when the rendered frame was deferred by a
1948
+ * backgrounded page/app; the session stays consumed.
1949
+ * - Confluence/iOS/Android converge on early error commit for auth or
1950
+ * get-channel failures that prevent Conversation entry.
1951
+ * - Cache message-list failure is not terminal; remote message-list failure
1952
+ * stores error data, and the rendered signal commits the initial render attempt.
1953
+ *
1954
+ * Payload:
1955
+ * - value: total_duration_ms.
1956
+ * - extra.auth_duration_ms, get_channel_duration_ms, get_messages_duration_ms.
1957
+ * - extra.total_duration_from_cache_ms, present_method, channel_url.
1958
+ *
1959
+ * Method call order:
1960
+ * - Resolve present method - setPresentMethod()
1961
+ * - Start auth - onAuthStart()
1962
+ * - Complete auth - onAuthComplete()
1963
+ * - Fail auth - onAuthError()
1964
+ * - Enter display lifecycle - startSpan(channelUrl)
1965
+ * - Screen visibility change - setVisible(visible)
1966
+ * - Start get channel - span.onGetChannelStart(channelUrl)
1967
+ * - Complete get channel - span.onGetChannelComplete(channelUrl, conversationId)
1968
+ * - Fail get channel - span.onGetChannelError(channelUrl, error)
1969
+ * - Start remote message list - span.onGetMessagesStart()
1970
+ * - Complete cache message list - span.onGetMessagesCacheComplete(channelUrl, error)
1971
+ * - Complete remote message list - span.onGetMessagesComplete(channelUrl, error)
1972
+ * - Render conversation - span.onRendered()
1973
+ * - Render deferred by background - span.onRenderDiscarded()
1974
+ * - Abandon entry attempt - span.cancel()
1975
+ * - Logout (deauthenticate/dispose) - clear()
1976
+ *
1977
+ * Policy:
1978
+ * - Session-scoped; collect at most once per authenticated session. Only the
1979
+ * logout-time clear() reopens measurement: re-entering a conversation after
1980
+ * a commit is a warm navigation and is deliberately not re-measured.
1981
+ * - value = auth + (page render − overlap): the timers record their own event
1982
+ * boundaries and race freely, and the metric subtracts the overlapping
1983
+ * interval at payload time, so the auth wait is counted exactly once in any
1984
+ * ordering — a render, cache completion, or channel failure landing mid-auth
1985
+ * included; the cache extra derives the same way. The commit waits for
1986
+ * in-flight auth to settle, since the value needs the auth duration.
1987
+ * - startSpan supersedes the previous span: the old span becomes non-recording
1988
+ * and the page render phase restarts. Auth data carries over to the new span.
1989
+ * - After success or error commit, new spans are born non-recording until clear().
1990
+ * - An auth failure before any span started allows a fresh measurement on retry.
1991
+ * - A backgrounded page/app pauses requestAnimationFrame, so a render signal
1992
+ * arriving after background time would fold the idle dwell into
1993
+ * total_duration_ms. Such samples are discarded without a payload
1994
+ * (web-vitals convention) and consume the session like a commit.
1995
+ * - Measurement is independent of screen visibility: a hidden (launcher-closed
1996
+ * or route-inactive) preload keeps measuring with the same render criterion,
1997
+ * so visibility toggles never tear a measurement. Visibility only gates
1998
+ * emission: a render completed while hidden commits the moment the screen
1999
+ * becomes visible (setVisible), and a preload that is never shown is
2000
+ * cancelled with its span and never emitted.
2001
+ */
2002
+ declare class ConversationInitialRenderStatsTracker extends SpanStatsTrackerBase<ConversationInitialRenderStatsSpan> {
2003
+ private stats: ConversationInitialRenderStats | null = null;
2004
+ private authState: AuthState = 'idle';
2005
+ private presentMethod: PresentMethod = 'direct_present';
1852
2006
 
1853
- stats.stopTimer(DurationKey.GET_CHANNEL);
1854
- if (conversationId !== undefined) {
1855
- stats.setConversationId(conversationId);
2007
+ private getOrCreateStats(): ConversationInitialRenderStats | null {
2008
+ if (this.stats?.isCommitted()) return null;
2009
+ if (!this.stats) {
2010
+ this.stats = new ConversationInitialRenderStats()
2011
+ .setCommitCallback(this.commitCallback)
2012
+ .setPresentMethod(this.presentMethod);
1856
2013
  }
1857
- stats.startTimer(DurationKey.GET_MESSAGES);
2014
+ return this.stats;
1858
2015
  }
1859
2016
 
1860
- onGetChannelError(params: Error | GetChannelErrorParams): void {
1861
- const channelUrl = params instanceof Error ? undefined : params.channelUrl;
1862
- const error = params instanceof Error ? params : params.error;
1863
- const stats = this.getActiveStats();
1864
- if (stats && this.pinChannelUrl(channelUrl)) {
1865
- this.consume(
1866
- stats
1867
- .stopTimer(DurationKey.GET_CHANNEL)
1868
- .setError(error instanceof SendbirdError ? error.code : undefined, error.message)
1869
- .stopTimer(DurationKey.TOTAL_DURATION)
1870
- .commit(),
1871
- );
1872
- }
2017
+ private getActiveStats(): ConversationInitialRenderStats | null {
2018
+ if (!this.stats || this.stats.isCommitted()) return null;
2019
+ return this.stats;
1873
2020
  }
1874
2021
 
1875
- onCacheResult(error: Error | null): void {
1876
- const stats = this.getActiveStats();
1877
- if (stats) {
1878
- if (error) {
1879
- stats.setError(error instanceof SendbirdError ? error.code : undefined, error.message);
1880
- }
1881
- stats.stopTimer(DurationKey.TOTAL_DURATION_FROM_CACHE);
1882
- }
2022
+ setPresentMethod(method: PresentMethod): void {
2023
+ this.presentMethod = method;
2024
+ this.getActiveStats()?.setPresentMethod(method);
1883
2025
  }
1884
2026
 
1885
- onApiResult(error: Error | null): void {
1886
- const stats = this.getActiveStats();
1887
- if (stats) {
1888
- if (error) {
1889
- this.consume(
1890
- stats
1891
- .setError(error instanceof SendbirdError ? error.code : undefined, error.message)
1892
- .stopTimer(DurationKey.TOTAL_DURATION)
1893
- .stopTimer(DurationKey.GET_MESSAGES)
1894
- .commit(),
1895
- );
1896
- return;
1897
- }
1898
- this.apiResultReceived = true;
1899
- this.renderedAfterApiResult = false;
1900
- stats.stopTimer(DurationKey.GET_MESSAGES);
1901
- this.commitIfReady();
2027
+ onAuthStart(): void {
2028
+ if (this.stats?.isCommitted()) {
2029
+ if (this.authState !== 'failed' || this.activeSpan !== null) return;
2030
+ // Auth failed before any Conversation entry: retrying auth starts a new measurement.
2031
+ this.stats = null;
2032
+ this.authState = 'idle';
1902
2033
  }
1903
- }
2034
+ if (this.authState !== 'idle') return;
1904
2035
 
1905
- setChannelUrl(url: string): void {
1906
- this.pinChannelUrl(url);
2036
+ this.authState = 'in_progress';
2037
+ this.getOrCreateStats()?.startTimer(DurationKey.AUTH);
1907
2038
  }
1908
2039
 
1909
- commitPending(): boolean {
1910
- const stats = this.getActiveStats();
1911
- return stats ? this.consume(stats.stopTimer(DurationKey.TOTAL_DURATION).commit()) : true;
2040
+ onAuthComplete(): void {
2041
+ if (this.authState === 'in_progress') {
2042
+ this.authState = 'completed';
2043
+ this.getActiveStats()?.stopTimer(DurationKey.AUTH);
2044
+ }
2045
+
2046
+ this.activeSpan?.onAuthSettled();
1912
2047
  }
1913
2048
 
1914
- markRendered(channelUrl?: string): boolean {
1915
- if (!this.matchesChannelUrl(channelUrl)) return false;
2049
+ onAuthError(error: Error): void {
2050
+ if (this.authState !== 'in_progress') return;
1916
2051
 
1917
- this.renderedAfterApiResult = true;
1918
- return this.commitIfReady(channelUrl);
2052
+ this.authState = 'failed';
2053
+ this.getActiveStats()?.stopTimer(DurationKey.AUTH).setError(error).commit();
2054
+ // Keep the superseded reference: it marks that a span existed in this era,
2055
+ // which blocks the auth-retry reset above.
2056
+ this.activeSpan?.cancel();
1919
2057
  }
1920
2058
 
1921
- setPresentMethod(method: PresentMethod): void {
1922
- this.presentMethod = method;
1923
- this.getActiveStats()?.setPresentMethod(method);
2059
+ startSpan(channelUrl: string): ConversationInitialRenderStatsSpan {
2060
+ const superseded = this.activeSpan;
2061
+ superseded?.cancel();
2062
+
2063
+ const stats = this.getOrCreateStats();
2064
+ if (stats && superseded) stats.resetPageRenderSpan();
2065
+
2066
+ this.activeSpan = new ConversationInitialRenderStatsSpan(stats, channelUrl, this.visible);
2067
+ return this.activeSpan;
1924
2068
  }
1925
2069
 
1926
2070
  clear(): void {
2071
+ this.activeSpan?.cancel();
2072
+ this.activeSpan = null;
1927
2073
  this.stats = null;
1928
- this.apiResultReceived = false;
1929
- this.channelUrl = undefined;
1930
- this.consumed = false;
1931
- this.consumedByAuthError = false;
1932
- this.renderedAfterApiResult = false;
2074
+ this.authState = 'idle';
1933
2075
  }
1934
2076
  }
1935
2077
 
@@ -2063,153 +2205,263 @@ declare interface ConversationListHeaderTemplateProps {
2063
2205
  titleAlign?: 'start' | 'center' | 'end';
2064
2206
  }
2065
2207
 
2066
- declare class ConversationListInitialRenderStatsTracker {
2067
- private readonly callback: StatsAppendCallback;
2068
- private readonly clock: StatsClock;
2069
- private measurement = createEmptyProgress();
2070
- private renderedCommitted = false;
2071
- private startedAt: number | null = null;
2072
- private listStartedAt: number | null = null;
2073
- private authStartedAt: number | null = null;
2074
- private authDuration: number | null = null;
2075
- private listDuration: number | null = null;
2208
+ declare type ConversationListInitialRenderMeasure = {
2209
+ listStarted: boolean;
2210
+ listCompleted: boolean;
2211
+ };
2212
+
2213
+ declare class ConversationListInitialRenderStats extends AIAgentBaseStats {
2076
2214
  private presentMethod: PresentMethod = 'direct_present';
2077
- private committed = false;
2078
2215
 
2079
- constructor(callback: StatsAppendCallback, options?: TrackerOptions) {
2080
- this.callback = callback;
2081
- this.clock = getClock(options);
2216
+ setPresentMethod(method: PresentMethod): this {
2217
+ this.presentMethod = method;
2218
+ return this;
2082
2219
  }
2083
2220
 
2084
- onInitializeStart(): void {
2085
- if (this.measurement.rendered) return;
2086
- if (this.measurement.initialized) {
2087
- this.clearMeasurementState();
2088
- }
2089
- this.renderedCommitted = false;
2090
- this.startMeasurement();
2221
+ resetPageRenderSpan(): this {
2222
+ this.timers.delete(DurationKey.PAGE_RENDER);
2223
+ this.timers.delete(DurationKey.GET_LIST);
2224
+ this.errorCode = null;
2225
+ this.errorDescription = null;
2226
+ return this;
2091
2227
  }
2092
2228
 
2093
- onListRequestStart(): void {
2094
- if (this.measurement.listStarted && !this.measurement.listCompleted && !this.measurement.rendered) {
2095
- this.clearMeasurementState();
2096
- }
2097
-
2098
- this.markListRequestStartOnce();
2229
+ protected getMetricKey(): string {
2230
+ return METRIC_KEY_CONVERSATION_LIST_INITIAL_RENDER;
2099
2231
  }
2100
2232
 
2101
- onListRequestComplete(): void {
2102
- this.markListRequestCompleteOnce();
2103
- }
2233
+ protected buildPayload(): AIAgentStatPayload | null {
2234
+ const auth = this.getTimer(DurationKey.AUTH);
2235
+ const pageRender = this.getTimer(DurationKey.PAGE_RENDER);
2236
+ const listDuration = this.getTimer(DurationKey.GET_LIST).duration;
2104
2237
 
2105
- onRendered(): void {
2106
- this.markRenderedOnce();
2107
- }
2238
+ // Only the auth-error early commit legitimately lacks a page render duration;
2239
+ // any other commit without it is an incomplete measurement and must not emit.
2240
+ if (pageRender.duration === null && (this.errorCode === null || auth.duration === null)) return null;
2108
2241
 
2109
- setPresentMethod(method: PresentMethod): void {
2110
- this.presentMethod = method;
2242
+ // The timers race freely; subtracting the overlap counts the auth wait exactly once.
2243
+ const pageRenderDuration = pageRender.duration === null ? 0 : pageRender.duration - overlapMs(auth, pageRender);
2244
+ const totalDuration = (auth.duration ?? 0) + pageRenderDuration;
2245
+
2246
+ return this.createPayload(totalDuration, {
2247
+ extra: {
2248
+ present_method: this.presentMethod,
2249
+ [DurationKey.AUTH]: auth.duration,
2250
+ [DurationKey.GET_LIST]: listDuration,
2251
+ },
2252
+ });
2111
2253
  }
2254
+ }
2255
+
2256
+ /**
2257
+ * One ConversationList entry attempt. Owns the list request phase; the tracker
2258
+ * owns the cross-attempt policy (auth span, supersession, one commit per entry).
2259
+ *
2260
+ * Timers record their own event boundaries and race freely against auth; the
2261
+ * metric subtracts the overlap at payload time, so event ordering needs no
2262
+ * orchestration here.
2263
+ */
2264
+ declare class ConversationListInitialRenderStatsSpan extends StatsSpanBase<
2265
+ ConversationListInitialRenderStats,
2266
+ ConversationListInitialRenderMeasure
2267
+ > {
2268
+ protected measure: ConversationListInitialRenderMeasure = { listStarted: false, listCompleted: false };
2112
2269
 
2113
- start(presentMethod?: PresentMethod): void {
2114
- if (this.committed) return;
2115
- this.startedAt = this.clock.now();
2116
- if (presentMethod) this.presentMethod = presentMethod;
2270
+ constructor(stats: ConversationListInitialRenderStats | null, visible: boolean) {
2271
+ super(stats, visible);
2272
+ this.stats?.startTimer(DurationKey.PAGE_RENDER);
2117
2273
  }
2118
2274
 
2119
- onAuthStart(): void {
2120
- if (this.committed) {
2121
- this.clearMeasurementState();
2122
- this.renderedCommitted = false;
2123
- }
2124
- this.authDuration = null;
2125
- this.authStartedAt = this.clock.now();
2275
+ onListRequestStart(): void {
2276
+ if (!this.stats || this.measure.listStarted) return;
2277
+
2278
+ // After a failed attempt cleared the timers via resetPageRenderSpan(), this
2279
+ // re-baselines them to the retry so the earlier failure and downtime are excluded.
2280
+ this.stats.startTimer(DurationKey.PAGE_RENDER);
2281
+ this.stats.startTimer(DurationKey.GET_LIST);
2282
+ this.measure.listStarted = true;
2126
2283
  }
2127
2284
 
2128
- onAuthComplete(): void {
2129
- if (this.committed || this.authDuration !== null) return;
2130
- if (this.authStartedAt === null) {
2131
- this.authDuration = 0;
2285
+ onListRequestComplete(error?: unknown): void {
2286
+ if (!this.stats || !this.measure.listStarted || this.measure.listCompleted) return;
2287
+
2288
+ if (error) {
2289
+ // Post-auth failures are not collected; drop the in-flight attempt so a retry restarts clean.
2290
+ this.stats.resetPageRenderSpan();
2291
+ this.measure.listStarted = false;
2132
2292
  return;
2133
2293
  }
2134
- this.authDuration = this.clock.now() - this.authStartedAt;
2135
- this.authStartedAt = null;
2294
+
2295
+ this.stats.stopTimer(DurationKey.GET_LIST);
2296
+ this.measure.listCompleted = true;
2136
2297
  }
2137
2298
 
2138
- markListRequestStart(): void {
2139
- if (this.committed) return;
2140
- if (this.startedAt === null) this.start();
2141
- this.listStartedAt = this.clock.now();
2299
+ onRendered(): void {
2300
+ if (!this.stats || !this.measure.listCompleted) return;
2301
+
2302
+ this.stats.stopTimer(DurationKey.PAGE_RENDER);
2303
+ this.tryFinalize();
2142
2304
  }
2143
2305
 
2144
- markListRequestComplete(): void {
2145
- if (this.committed || this.listStartedAt === null) return;
2146
- this.listDuration = this.clock.now() - this.listStartedAt;
2306
+ onRenderDiscarded(): void {
2307
+ if (!this.stats || !this.measure.listCompleted) return;
2308
+
2309
+ this.stats.discard();
2310
+ this.stats = null;
2147
2311
  }
2148
2312
 
2149
- markRendered(): void {
2150
- if (this.committed || this.startedAt === null) return;
2151
- this.committed = true;
2152
- append(this.callback, METRIC_KEY_CONVERSATION_LIST_INITIAL_RENDER, this.clock.now() - this.startedAt, {
2153
- extra: {
2154
- ...(this.authDuration !== null && { auth_duration_ms: this.authDuration }),
2155
- ...(this.listDuration !== null && { get_list_duration_ms: this.listDuration }),
2156
- present_method: this.presentMethod,
2157
- },
2158
- });
2313
+ /** Auth settled while this span was measuring; a finalize that waited on in-flight auth can proceed. */
2314
+ onAuthSettled(): void {
2315
+ this.tryFinalize();
2159
2316
  }
2160
2317
 
2161
- clear(): void {
2162
- this.clearMeasurementState();
2163
- this.clearAuthState();
2164
- this.renderedCommitted = false;
2318
+ /** Commit once both facts exist: the render happened and auth is not in flight. */
2319
+ private tryFinalize(): void {
2320
+ if (!this.stats) return;
2321
+ if (this.stats.getTimer(DurationKey.PAGE_RENDER).endedAt === null) return;
2322
+
2323
+ const auth = this.stats.getTimer(DurationKey.AUTH);
2324
+ if (auth.startAt !== null && auth.endedAt === null) return;
2325
+
2326
+ this.commitOrDefer();
2165
2327
  }
2328
+ }
2329
+
2330
+ /**
2331
+ * Spec: conversation_list_initial_render
2332
+ *
2333
+ * Measures the first ConversationList screen render as the sum of auth and the
2334
+ * page render span required before the first rendered list frame.
2335
+ *
2336
+ * Timeline:
2337
+ * - Track auth independently; it can start before the list screen exists.
2338
+ * - Start a span when entering the display lifecycle.
2339
+ * - Track the list request phase on the span.
2340
+ * - Commit on the first rendered list frame. While the screen is hidden the
2341
+ * measurement still freezes at that frame, but emission is deferred until the
2342
+ * screen becomes visible.
2343
+ * - Discard (no payload) when the rendered frame was deferred by a
2344
+ * backgrounded page/app; the session stays consumed.
2345
+ *
2346
+ * Method call order:
2347
+ * - Resolve present method - setPresentMethod()
2348
+ * - Start auth - onAuthStart()
2349
+ * - Complete auth - onAuthComplete()
2350
+ * - Fail auth - onAuthError()
2351
+ * - Enter display lifecycle - startSpan()
2352
+ * - Screen visibility change - setVisible(visible)
2353
+ * - Start list request - span.onListRequestStart()
2354
+ * - Complete list request - span.onListRequestComplete()
2355
+ * - Fail list request - span.onListRequestComplete(error)
2356
+ * - Render list - span.onRendered()
2357
+ * - Render deferred by background - span.onRenderDiscarded()
2358
+ * - Abandon entry attempt - span.cancel()
2359
+ * - Logout (deauthenticate/dispose) - clear()
2360
+ *
2361
+ * Payload:
2362
+ * - value: total_duration_ms.
2363
+ * - extra.auth_duration_ms, get_list_duration_ms, present_method.
2364
+ *
2365
+ * Policy:
2366
+ * - Session-scoped; collect at most once per authenticated session. Only the
2367
+ * logout-time clear() reopens measurement: re-entering the list after a
2368
+ * commit is a warm navigation and is deliberately not re-measured.
2369
+ * - value = auth + (page render − overlap): both timers record their own event
2370
+ * boundaries and race freely, and the metric subtracts the overlapping
2371
+ * interval at payload time, so the auth wait is counted exactly once in any
2372
+ * ordering. The commit waits for in-flight auth to settle, since the value
2373
+ * needs the auth duration. A failed attempt re-baselines the page render
2374
+ * timer at its retry, excluding the failure and downtime.
2375
+ * - startSpan supersedes the previous span: the old span becomes non-recording
2376
+ * and the page render phase restarts. Auth data carries over to the new span.
2377
+ * - Success or auth failure closes collection until clear(). Post-auth failures
2378
+ * are not collected.
2379
+ * - An auth failure before any span is recording drops the pending measurement;
2380
+ * an auth retry starts a new one.
2381
+ * - A backgrounded page/app pauses requestAnimationFrame, so a render signal
2382
+ * arriving after background time would fold the idle dwell into
2383
+ * total_duration_ms. Such samples are discarded without a payload
2384
+ * (web-vitals convention) and consume the session like a commit.
2385
+ * - Measurement is independent of screen visibility: a hidden (launcher-closed
2386
+ * or route-inactive) preload keeps measuring with the same render criterion,
2387
+ * so visibility toggles never tear a measurement. Visibility only gates
2388
+ * emission: a render completed while hidden commits the moment the screen
2389
+ * becomes visible (setVisible), and a preload that is never shown is
2390
+ * cancelled with its span and never emitted.
2391
+ */
2392
+ declare class ConversationListInitialRenderStatsTracker extends SpanStatsTrackerBase<ConversationListInitialRenderStatsSpan> {
2393
+ private stats: ConversationListInitialRenderStats | null = null;
2394
+ private authState: AuthState_2 = 'idle';
2395
+ private presentMethod: PresentMethod = 'direct_present';
2166
2396
 
2167
- private clearMeasurementState(): void {
2168
- this.startedAt = null;
2169
- this.listStartedAt = null;
2170
- this.listDuration = null;
2171
- this.committed = false;
2172
- this.measurement = createEmptyProgress();
2397
+ private getOrCreateStats(): ConversationListInitialRenderStats | null {
2398
+ if (this.stats?.isCommitted()) return null;
2399
+ if (!this.stats) {
2400
+ this.stats = new ConversationListInitialRenderStats()
2401
+ .setCommitCallback(this.commitCallback)
2402
+ .setPresentMethod(this.presentMethod);
2403
+ }
2404
+ return this.stats;
2173
2405
  }
2174
2406
 
2175
- private clearAuthState(): void {
2176
- this.authStartedAt = null;
2177
- this.authDuration = null;
2407
+ private getActiveStats(): ConversationListInitialRenderStats | null {
2408
+ if (!this.stats || this.stats.isCommitted()) return null;
2409
+ return this.stats;
2178
2410
  }
2179
2411
 
2180
- private startMeasurement(): void {
2181
- if (this.measurement.initialized) return;
2412
+ setPresentMethod(method: PresentMethod): void {
2413
+ this.presentMethod = method;
2414
+ this.getActiveStats()?.setPresentMethod(method);
2415
+ }
2182
2416
 
2183
- this.clearMeasurementState();
2184
- this.start();
2185
- this.measurement.initialized = true;
2417
+ onAuthStart(): void {
2418
+ this.authState = 'in_progress';
2419
+ this.getOrCreateStats()?.startTimer(DurationKey.AUTH);
2186
2420
  }
2187
2421
 
2188
- private markListRequestStartOnce(): void {
2189
- this.startMeasurement();
2190
- if (this.measurement.listStarted) return;
2422
+ onAuthComplete(): void {
2423
+ const wasInProgress = this.authState === 'in_progress';
2424
+ this.authState = 'completed';
2425
+ if (wasInProgress) this.getActiveStats()?.stopTimer(DurationKey.AUTH);
2191
2426
 
2192
- this.markListRequestStart();
2193
- this.measurement.listStarted = true;
2427
+ this.activeSpan?.onAuthSettled();
2194
2428
  }
2195
2429
 
2196
- private markListRequestCompleteOnce(): void {
2197
- this.markListRequestStartOnce();
2198
- if (this.measurement.listCompleted) return;
2430
+ onAuthError(error: Error): void {
2431
+ const wasInProgress = this.authState === 'in_progress';
2432
+ this.authState = 'failed';
2433
+
2434
+ const stats = this.getActiveStats();
2435
+ if (!stats) return;
2436
+ if (!this.activeSpan?.isRecording) {
2437
+ // Auth failed before any span was recording: drop the pending measurement so an auth retry re-measures cleanly.
2438
+ this.stats = null;
2439
+ return;
2440
+ }
2441
+
2442
+ if (wasInProgress) stats.stopTimer(DurationKey.AUTH);
2199
2443
 
2200
- this.markListRequestComplete();
2201
- this.measurement.listCompleted = true;
2444
+ stats.setError(error).commit();
2445
+ this.activeSpan.cancel();
2202
2446
  }
2203
2447
 
2204
- private markRenderedOnce(): void {
2205
- if (this.renderedCommitted) return;
2448
+ startSpan(): ConversationListInitialRenderStatsSpan {
2449
+ const superseded = this.activeSpan;
2450
+ superseded?.cancel();
2451
+
2452
+ const stats = this.getOrCreateStats();
2453
+ if (stats && superseded) stats.resetPageRenderSpan();
2454
+ if (stats && this.authState === 'in_progress') stats.startTimer(DurationKey.AUTH);
2206
2455
 
2207
- this.startMeasurement();
2208
- if (this.measurement.rendered) return;
2456
+ this.activeSpan = new ConversationListInitialRenderStatsSpan(stats, this.visible);
2457
+ return this.activeSpan;
2458
+ }
2209
2459
 
2210
- this.markRendered();
2211
- this.measurement.rendered = true;
2212
- this.renderedCommitted = true;
2460
+ clear(): void {
2461
+ this.activeSpan?.cancel();
2462
+ this.activeSpan = null;
2463
+ this.stats = null;
2464
+ this.authState = 'idle';
2213
2465
  }
2214
2466
  }
2215
2467
 
@@ -2282,107 +2534,133 @@ export declare type ConversationListProps = PropsWithChildren<{
2282
2534
  conversationListFilter?: Partial<AIAgentGroupChannelFilter>;
2283
2535
  onOpenConversationView?: (channelUrl: string, status: 'open' | 'closed', options?: ConversationListOpenOptions) => void;
2284
2536
  announcementsEnabled?: boolean;
2537
+ /** @default true */
2285
2538
  conversationListStatsEnabled?: boolean;
2286
2539
  /** Custom styles for the conversation list container. */
2287
2540
  style?: CSSProperties;
2288
2541
  }>;
2289
2542
 
2290
- declare type ConversationMessageEvent = ConversationMessageEventPayload & { id: number };
2291
-
2292
- declare type ConversationMessageEventPayload =
2293
- | {
2294
- kind: 'messagesAdded';
2295
- source: ChatSDKCollectionEventSource;
2296
- messages: ChatSDKBaseMessage[];
2297
- }
2298
- | {
2299
- kind: 'messagesUpdated';
2300
- source: ChatSDKCollectionEventSource;
2301
- messages: ChatSDKBaseMessage[];
2302
- }
2303
- | {
2304
- kind: 'initialized';
2305
- source: 'cache' | 'api';
2306
- messages: ChatSDKBaseMessage[];
2307
- };
2308
-
2309
- declare type ConversationNotInteractiveMeasurement = {
2310
- timer: TimeoutId | null;
2311
- startedAt: number;
2312
- committed: boolean;
2313
- inputEnabled: boolean;
2314
- phaseReached: ConversationNotInteractivePhase;
2315
- historyLoadResult: HistoryLoadResult;
2316
- conversationId?: number;
2543
+ declare type ConversationNotInteractiveMeasure = {
2317
2544
  channelUrl?: string;
2545
+ historyLoadResult: HistoryLoadResult;
2546
+ inputEnabled: boolean;
2547
+ timer: TimeoutId | null;
2318
2548
  };
2319
2549
 
2320
2550
  declare type ConversationNotInteractivePhase = 'auth' | 'channel_join' | 'history_load' | 'input_enable' | 'render';
2321
2551
 
2322
- declare class ConversationNotInteractiveTimeoutStatsTracker {
2323
- private readonly callback: StatsAppendCallback;
2324
- private readonly clock: StatsClock;
2325
- private readonly setTimeoutFn: typeof setTimeout;
2326
- private readonly clearTimeoutFn: typeof clearTimeout;
2327
- private readonly measurements = new Map<string, ConversationNotInteractiveMeasurement>();
2552
+ declare class ConversationNotInteractiveTimeoutStats extends AIAgentBaseStats {
2553
+ private phaseReached: ConversationNotInteractivePhase = 'auth';
2554
+ private historyLoadResult: HistoryLoadResult = 'pending';
2328
2555
 
2329
- constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
2330
- this.callback = callback;
2331
- this.clock = getClock(options);
2332
- this.setTimeoutFn = getSetTimeout(options);
2333
- this.clearTimeoutFn = getClearTimeout(options);
2556
+ setHistoryLoadResult(result: HistoryLoadResult): this {
2557
+ this.historyLoadResult = result;
2558
+ return this;
2334
2559
  }
2335
2560
 
2336
- start(params?: { channelUrl?: string; conversationId?: number }): void {
2337
- const key = this.getKey(params?.channelUrl);
2338
- this.clearKey(key);
2339
-
2340
- const measurement = {
2341
- startedAt: this.clock.now(),
2342
- channelUrl: params?.channelUrl,
2343
- conversationId: params?.conversationId,
2344
- committed: false,
2345
- inputEnabled: false,
2346
- phaseReached: 'auth' as ConversationNotInteractivePhase,
2347
- historyLoadResult: 'pending' as HistoryLoadResult,
2348
- timer: null as TimeoutId | null,
2349
- };
2350
- measurement.timer = this.setTimeoutFn(() => this.commitTimeout(key), 10_000);
2351
- this.measurements.set(key, measurement);
2561
+ setPhaseReached(phase: ConversationNotInteractivePhase): this {
2562
+ this.phaseReached = phase;
2563
+ return this;
2352
2564
  }
2353
2565
 
2354
- markChannelJoined(conversationId?: number, channelUrl?: string): void {
2355
- const measurement = this.getMeasurement(channelUrl);
2356
- if (!measurement || measurement.committed) return;
2357
- measurement.phaseReached = 'channel_join';
2358
- if (conversationId !== undefined) measurement.conversationId = conversationId;
2566
+ protected getMetricKey(): string {
2567
+ return METRIC_KEY_CONVERSATION_NOT_INTERACTIVE_TIMEOUT;
2359
2568
  }
2360
2569
 
2361
- markHistoryLoaded(result: HistoryLoadResult, channelUrl?: string): void {
2362
- const measurement = this.getMeasurement(channelUrl);
2363
- if (!measurement || measurement.committed) return;
2364
- measurement.phaseReached = 'history_load';
2365
- measurement.historyLoadResult = result;
2366
- this.cancelIfReady(channelUrl);
2367
- }
2570
+ protected buildPayload(): AIAgentStatPayload | null {
2571
+ const duration = this.getTimer(DurationKey.TIMEOUT).duration;
2572
+ if (duration === null) return null;
2368
2573
 
2369
- markInputEnabled(channelUrl?: string): void {
2370
- const measurement = this.getMeasurement(channelUrl);
2371
- if (!measurement || measurement.committed) return;
2372
- measurement.phaseReached = 'input_enable';
2373
- measurement.inputEnabled = true;
2374
- this.cancelIfReady(channelUrl);
2574
+ return this.createPayload(duration, {
2575
+ extra: {
2576
+ outcome: 'timeout',
2577
+ phase_reached: this.phaseReached,
2578
+ history_load_result: this.historyLoadResult,
2579
+ },
2580
+ });
2581
+ }
2582
+ }
2583
+
2584
+ /**
2585
+ * Spec: conversation_not_interactive_timeout
2586
+ *
2587
+ * Detects a new Conversation entry that remains unusable for 10s.
2588
+ *
2589
+ * Timeline:
2590
+ * - Start when the Conversation screen display lifecycle begins.
2591
+ * - Track the farthest reached phase in auth -> channel_join -> history_load
2592
+ * -> input_enable -> render order.
2593
+ * - Success is input available and history display decision completed.
2594
+ * - Commit only if the 10s timeout fires before success.
2595
+ *
2596
+ * Payload:
2597
+ * - value: total_duration_ms.
2598
+ * - extra.phase_reached: farthest reached phase.
2599
+ * - extra.history_load_result: success | verified_empty | failed | pending.
2600
+ *
2601
+ * Policy:
2602
+ * - Entry-scoped; success or lifecycle abort clears the pending measurement.
2603
+ * - Placeholder/empty UI alone is not success unless the empty history result
2604
+ * is explicitly verified.
2605
+ */
2606
+ declare class ConversationNotInteractiveTimeoutStatsTracker extends MultiStatsTrackerBase<
2607
+ ConversationNotInteractiveTimeoutStats,
2608
+ ConversationNotInteractiveMeasure
2609
+ > {
2610
+ start(params?: { channelUrl?: string; conversationId?: number }): void {
2611
+ const key = this.getKey(params?.channelUrl);
2612
+ this.clearKey(key);
2613
+
2614
+ const record: StatsRecord<ConversationNotInteractiveTimeoutStats, ConversationNotInteractiveMeasure> = {
2615
+ stats: new ConversationNotInteractiveTimeoutStats()
2616
+ .setCommitCallback(this.commitCallback)
2617
+ .startTimer(DurationKey.TIMEOUT),
2618
+ measure: {
2619
+ channelUrl: params?.channelUrl,
2620
+ historyLoadResult: 'pending',
2621
+ inputEnabled: false,
2622
+ timer: null,
2623
+ },
2624
+ };
2625
+ if (params?.conversationId !== undefined) record.stats.setConversationId(params.conversationId);
2626
+ record.measure.timer = setTimeout(() => this.commitTimeout(key), 10_000);
2627
+ this.records.set(key, record);
2628
+ }
2629
+
2630
+ markChannelJoined(conversationId?: number, channelUrl?: string): void {
2631
+ const record = this.getRecord(channelUrl);
2632
+ if (!record) return;
2633
+ record.stats.setPhaseReached('channel_join');
2634
+ if (conversationId !== undefined) record.stats.setConversationId(conversationId);
2635
+ }
2636
+
2637
+ markHistoryLoaded(result: HistoryLoadResult, channelUrl?: string): void {
2638
+ const record = this.getRecord(channelUrl);
2639
+ if (!record) return;
2640
+ record.measure.historyLoadResult = result;
2641
+ record.stats.setHistoryLoadResult(result).setPhaseReached('history_load');
2642
+ this.cancelIfReady(channelUrl);
2643
+ }
2644
+
2645
+ markInputReady(channelUrl?: string): void {
2646
+ const record = this.getRecord(channelUrl);
2647
+ if (!record) return;
2648
+ record.measure.inputEnabled = true;
2649
+ record.stats.setPhaseReached('input_enable');
2650
+ this.cancelIfReady(channelUrl);
2375
2651
  }
2376
2652
 
2377
2653
  markRendered(channelUrl?: string): void {
2378
- const measurement = this.getMeasurement(channelUrl);
2379
- if (!measurement || measurement.committed) return;
2380
- measurement.phaseReached = 'render';
2654
+ const record = this.getRecord(channelUrl);
2655
+ if (!record) return;
2656
+ record.stats.setPhaseReached('render');
2381
2657
  }
2382
2658
 
2383
2659
  clear(): void {
2384
- this.measurements.forEach((measurement) => clearTimer(measurement.timer, this.clearTimeoutFn));
2385
- this.measurements.clear();
2660
+ this.records.forEach((record) => {
2661
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
2662
+ });
2663
+ this.records.clear();
2386
2664
  }
2387
2665
 
2388
2666
  clearChannel(channelUrl?: string): void {
@@ -2391,39 +2669,32 @@ declare class ConversationNotInteractiveTimeoutStatsTracker {
2391
2669
  }
2392
2670
 
2393
2671
  private clearKey(key: string): void {
2394
- const measurement = this.measurements.get(key);
2395
- if (!measurement) return;
2396
- clearTimer(measurement.timer, this.clearTimeoutFn);
2397
- this.measurements.delete(key);
2672
+ const record = this.records.get(key);
2673
+ if (!record) return;
2674
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
2675
+ this.records.delete(key);
2398
2676
  }
2399
2677
 
2400
2678
  private cancelIfReady(channelUrl?: string): void {
2401
- const measurement = this.getMeasurement(channelUrl);
2402
- if (!measurement || !measurement.inputEnabled || measurement.historyLoadResult === 'pending') return;
2403
- this.clearKey(this.getKey(measurement.channelUrl));
2679
+ const record = this.getRecord(channelUrl);
2680
+ if (!record || !record.measure.inputEnabled || record.measure.historyLoadResult === 'pending') return;
2681
+ this.clearKey(this.getKey(record.measure.channelUrl));
2404
2682
  }
2405
2683
 
2406
2684
  private commitTimeout(key: string): void {
2407
- const measurement = this.measurements.get(key);
2408
- if (!measurement || measurement.committed) return;
2409
- measurement.committed = true;
2410
- measurement.timer = null;
2411
- append(this.callback, METRIC_KEY_CONVERSATION_NOT_INTERACTIVE_TIMEOUT, this.clock.now() - measurement.startedAt, {
2412
- conversationId: measurement.conversationId,
2413
- error: { message: METRIC_KEY_CONVERSATION_NOT_INTERACTIVE_TIMEOUT },
2414
- extra: {
2415
- outcome: 'timeout',
2416
- phase_reached: measurement.phaseReached,
2417
- history_load_result: measurement.historyLoadResult,
2418
- },
2419
- });
2420
- this.measurements.delete(key);
2685
+ const record = this.records.get(key);
2686
+ if (!record) return;
2687
+ record.measure.timer = null;
2688
+ this.records.delete(key);
2689
+ record.stats.stopTimer(DurationKey.TIMEOUT).commit();
2421
2690
  }
2422
2691
 
2423
- private getMeasurement(channelUrl?: string): ConversationNotInteractiveMeasurement | undefined {
2424
- if (channelUrl !== undefined) return this.measurements.get(this.getKey(channelUrl));
2425
- if (this.measurements.size === 1) return Array.from(this.measurements.values())[0];
2426
- return this.measurements.get(this.getKey());
2692
+ private getRecord(
2693
+ channelUrl?: string,
2694
+ ): StatsRecord<ConversationNotInteractiveTimeoutStats, ConversationNotInteractiveMeasure> | undefined {
2695
+ if (channelUrl !== undefined) return this.records.get(this.getKey(channelUrl));
2696
+ if (this.records.size === 1) return this.records.values().next().value;
2697
+ return this.records.get(this.getKey());
2427
2698
  }
2428
2699
 
2429
2700
  private getKey(channelUrl?: string): string {
@@ -2436,7 +2707,10 @@ export declare type ConversationProps = PropsWithChildren<{
2436
2707
  onClearChannelUrl?: () => void;
2437
2708
  onNavigateToConversationList?: () => void;
2438
2709
  shouldMarkAsRead?: boolean;
2710
+ /** @internal */
2711
+ shouldInitializeConversation?: boolean;
2439
2712
  announcementsEnabled?: boolean;
2713
+ /** @default true */
2440
2714
  conversationStatsEnabled?: boolean;
2441
2715
  initialFocusTarget?: ConversationInitialFocusTarget;
2442
2716
  /** Custom styles for the conversation container. */
@@ -2634,11 +2908,20 @@ declare interface Dispatcher {
2634
2908
  }
2635
2909
 
2636
2910
  declare enum DurationKey {
2911
+ ACK_TO_FIRST_CHUNK = 'ack_to_first_chunk_ms',
2912
+ AFTER_TPEN_ABSENT = 'after_tpen_absent_ms',
2637
2913
  AUTH = 'auth_duration_ms',
2914
+ BEFORE_TPST_ABSENT = 'before_tpst_absent_ms',
2915
+ FIRST_CHUNK_TO_RENDER = 'first_chunk_to_render_ms',
2638
2916
  GET_CHANNEL = 'get_channel_duration_ms',
2917
+ GET_LIST = 'get_list_duration_ms',
2639
2918
  GET_MESSAGES = 'get_messages_duration_ms',
2640
- TOTAL_DURATION = 'total_duration_ms',
2641
- TOTAL_DURATION_FROM_CACHE = 'total_duration_from_cache_ms',
2919
+ // Internal timer key; the emitted value derives from it and is never emitted raw.
2920
+ PAGE_RENDER = 'page_render_duration_ms',
2921
+ // Emitted field name predates the auth/page-render split; kept for dashboard compatibility.
2922
+ PAGE_RENDER_FROM_CACHE = 'total_duration_from_cache_ms',
2923
+ SEND_TO_ACK = 'send_to_ack_ms',
2924
+ TIMEOUT = 'timeout_ms',
2642
2925
  }
2643
2926
 
2644
2927
  declare type ErrorAnnouncementParams =
@@ -2647,8 +2930,6 @@ declare type ErrorAnnouncementParams =
2647
2930
  | { type: 'csatFormError' }
2648
2931
  | { type: 'generic'; message: string };
2649
2932
 
2650
- declare type ErrorLike = StatsPayloadError;
2651
-
2652
2933
  /**
2653
2934
  * For better understanding: https://sendbird.atlassian.net/wiki/spaces/AA/pages/3075014695/Extended+Message+Payload+Spec
2654
2935
  */
@@ -2817,16 +3098,6 @@ export declare interface FunctionCallsInfo {
2817
3098
  };
2818
3099
  }
2819
3100
 
2820
- declare type GetChannelCompleteParams = {
2821
- channelUrl?: string;
2822
- conversationId?: number;
2823
- };
2824
-
2825
- declare type GetChannelErrorParams = {
2826
- channelUrl?: string;
2827
- error: Error;
2828
- };
2829
-
2830
3101
  export declare interface GroundednessInfo {
2831
3102
  id: number;
2832
3103
  source_type:
@@ -5206,92 +5477,139 @@ export declare const MessageListUILayout: {
5206
5477
 
5207
5478
  export declare const MessageLogs: ({ actionbook, functionCalls, groundedness, agentMessageTemplates, flaggedTypes, onClickActionbook, onClickFunctionCall, onClickFunctionCallDetail, onClickGroundedness, onClickAgentMessageTemplate, topContent, bottomContent, renderCustomGroundednessIcon, style, }: Props_2) => JSX.Element;
5208
5479
 
5209
- declare class MessagePendingStuckStatsTracker {
5210
- private readonly callback: StatsAppendCallback;
5211
- private readonly setTimeoutFn: typeof setTimeout;
5212
- private readonly clearTimeoutFn: typeof clearTimeout;
5213
- private readonly measurements = new Map<string, PendingMeasurement>();
5480
+ declare class MessagePendingStuckStats extends AIAgentBaseStats {
5481
+ private requestId?: string;
5482
+ private messageType?: PendingMessageType;
5483
+ private stuckReason?: PendingStuckReason;
5214
5484
 
5215
- constructor(callback: StatsAppendCallback, options?: PendingStuckTrackerOptions) {
5216
- this.callback = callback;
5217
- this.setTimeoutFn = getSetTimeout(options);
5218
- this.clearTimeoutFn = getClearTimeout(options);
5485
+ setRequestId(requestId: string): this {
5486
+ this.requestId = requestId;
5487
+ return this;
5488
+ }
5489
+
5490
+ setMessageType(messageType: PendingMessageType): this {
5491
+ this.messageType = messageType;
5492
+ return this;
5493
+ }
5494
+
5495
+ setStuckReason(stuckReason: PendingStuckReason): this {
5496
+ this.stuckReason = stuckReason;
5497
+ return this;
5498
+ }
5499
+
5500
+ protected getMetricKey(): string {
5501
+ return METRIC_KEY_MESSAGE_PENDING_STUCK;
5219
5502
  }
5220
5503
 
5504
+ protected buildPayload(): AIAgentStatPayload | null {
5505
+ if (this.requestId === undefined || this.messageType === undefined || this.stuckReason === undefined) return null;
5506
+
5507
+ return this.createPayload(this.stuckReason, {
5508
+ extra: {
5509
+ message_type: this.messageType,
5510
+ request_id: this.requestId,
5511
+ },
5512
+ });
5513
+ }
5514
+ }
5515
+
5516
+ /**
5517
+ * Spec: message_pending_stuck
5518
+ *
5519
+ * Detects a user message send that stays pending too long.
5520
+ *
5521
+ * Timeline:
5522
+ * - Start after the SDK registers a pending send with a request_id.
5523
+ * - Text messages commit after 5s without send completion.
5524
+ * - File messages commit after 10s without upload progress or send completion.
5525
+ * - Upload progress resets the file no-progress timer.
5526
+ *
5527
+ * Payload:
5528
+ * - value: stuck_reason = pending_too_long | upload_no_progress.
5529
+ * - extra.message_type, request_id.
5530
+ *
5531
+ * Policy:
5532
+ * - Send-scoped; each request_id is measured independently.
5533
+ * - Send completion, disappearance, or lifecycle abort clears the measurement
5534
+ * without emitting.
5535
+ * - Offline sends are excluded from measurement.
5536
+ * - There is no duration value and no resolved follow-up stat.
5537
+ */
5538
+ declare class MessagePendingStuckStatsTracker extends MultiStatsTrackerBase<MessagePendingStuckStats, PendingMeasure> {
5221
5539
  start(params: {
5222
5540
  requestId: string;
5223
5541
  channelUrl?: string;
5224
5542
  messageType: PendingMessageType;
5225
5543
  tracksUploadProgress?: boolean;
5226
5544
  }): void {
5227
- if (!params.requestId || this.measurements.has(params.requestId)) return;
5545
+ if (!params.requestId || this.records.has(params.requestId)) return;
5228
5546
 
5229
5547
  const usesUploadProgress = params.messageType !== 'text' && params.tracksUploadProgress === true;
5230
- const measurement: PendingMeasurement = {
5231
- channelUrl: params.channelUrl,
5232
- messageType: params.messageType,
5233
- stuckReason: usesUploadProgress ? 'upload_no_progress' : 'pending_too_long',
5234
- timer: null,
5548
+ const stats = new MessagePendingStuckStats()
5549
+ .setRequestId(params.requestId)
5550
+ .setMessageType(params.messageType)
5551
+ .setStuckReason(usesUploadProgress ? 'upload_no_progress' : 'pending_too_long')
5552
+ .setCommitCallback(this.commitCallback);
5553
+ const record: StatsRecord<MessagePendingStuckStats, PendingMeasure> = {
5554
+ stats,
5555
+ measure: {
5556
+ channelUrl: params.channelUrl,
5557
+ timer: null,
5558
+ usesUploadProgress,
5559
+ },
5235
5560
  };
5236
5561
  const timeoutMs = usesUploadProgress ? FILE_UPLOAD_NO_PROGRESS_TIMEOUT_MS : TEXT_PENDING_TIMEOUT_MS;
5237
- measurement.timer = this.setTimeoutFn(() => this.detect(params.requestId), timeoutMs);
5238
- this.measurements.set(params.requestId, measurement);
5562
+ record.measure.timer = setTimeout(() => this.detect(params.requestId), timeoutMs);
5563
+ this.records.set(params.requestId, record);
5239
5564
  }
5240
5565
 
5241
5566
  resolve(params: { requestId: string }): void {
5242
- const measurement = this.measurements.get(params.requestId);
5243
- if (!measurement) return;
5244
-
5245
- clearTimer(measurement.timer, this.clearTimeoutFn);
5246
- this.measurements.delete(params.requestId);
5567
+ this.cancel(params.requestId);
5247
5568
  }
5248
5569
 
5249
5570
  disappear(params: { requestId: string }): void {
5250
- const measurement = this.measurements.get(params.requestId);
5251
- if (!measurement) return;
5571
+ this.cancel(params.requestId);
5572
+ }
5573
+
5574
+ private cancel(requestId: string): void {
5575
+ const record = this.records.get(requestId);
5576
+ if (!record) return;
5252
5577
 
5253
- clearTimer(measurement.timer, this.clearTimeoutFn);
5254
- this.measurements.delete(params.requestId);
5578
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
5579
+ this.records.delete(requestId);
5255
5580
  }
5256
5581
 
5257
5582
  markUploadProgress(requestId: string): void {
5258
- const measurement = this.measurements.get(requestId);
5259
- if (!measurement) return;
5260
- if (measurement.stuckReason !== 'upload_no_progress') return;
5583
+ const record = this.records.get(requestId);
5584
+ if (!record) return;
5585
+ if (!record.measure.usesUploadProgress) return;
5261
5586
 
5262
- clearTimer(measurement.timer, this.clearTimeoutFn);
5263
- measurement.timer = this.setTimeoutFn(() => this.detect(requestId), FILE_UPLOAD_NO_PROGRESS_TIMEOUT_MS);
5587
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
5588
+ record.measure.timer = setTimeout(() => this.detect(requestId), FILE_UPLOAD_NO_PROGRESS_TIMEOUT_MS);
5264
5589
  }
5265
5590
 
5266
5591
  clear(): void {
5267
- this.measurements.forEach((measurement) => clearTimer(measurement.timer, this.clearTimeoutFn));
5268
- this.measurements.clear();
5592
+ this.records.forEach((record) => {
5593
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
5594
+ });
5595
+ this.records.clear();
5269
5596
  }
5270
5597
 
5271
5598
  clearChannel(channelUrl?: string): void {
5272
5599
  if (!channelUrl) return;
5273
- this.measurements.forEach((measurement, requestId) => {
5274
- if (measurement.channelUrl !== channelUrl) return;
5275
- clearTimer(measurement.timer, this.clearTimeoutFn);
5276
- this.measurements.delete(requestId);
5600
+ this.records.forEach((record, requestId) => {
5601
+ if (record.measure.channelUrl !== channelUrl) return;
5602
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
5603
+ this.records.delete(requestId);
5277
5604
  });
5278
5605
  }
5279
5606
 
5280
5607
  private detect(requestId: string): void {
5281
- const measurement = this.measurements.get(requestId);
5282
- if (!measurement) return;
5283
-
5284
- this.measurements.delete(requestId);
5285
- this.commit(requestId, measurement);
5286
- }
5608
+ const record = this.records.get(requestId);
5609
+ if (!record) return;
5287
5610
 
5288
- private commit(requestId: string, measurement: PendingMeasurement): void {
5289
- append(this.callback, METRIC_KEY_MESSAGE_PENDING_STUCK, measurement.stuckReason, {
5290
- extra: {
5291
- message_type: measurement.messageType,
5292
- request_id: requestId,
5293
- },
5294
- });
5611
+ this.records.delete(requestId);
5612
+ record.stats.commit();
5295
5613
  }
5296
5614
  }
5297
5615
 
@@ -5511,6 +5829,10 @@ declare type MultiSelectField = {
5511
5829
  };
5512
5830
  };
5513
5831
 
5832
+ declare abstract class MultiStatsTrackerBase<TStats, TMeasure> extends StatsTrackerBase {
5833
+ protected readonly records = new Map<string | number, StatsRecord<TStats, TMeasure>>();
5834
+ }
5835
+
5514
5836
  declare interface NetworkStateAdapter {
5515
5837
  /** Returns true if network is currently online */
5516
5838
  isOnline: () => boolean;
@@ -5661,6 +5983,14 @@ declare type OutgoingMessageUnion =
5661
5983
 
5662
5984
  declare function OutgoingTextBody(props: OutgoingMessageBodyProps): JSX.Element;
5663
5985
 
5986
+ declare type PageRenderState =
5987
+ | 'initialized'
5988
+ | 'channel_fetching'
5989
+ | 'channel_fetched'
5990
+ | 'channel_failed'
5991
+ | 'messages_fetching'
5992
+ | 'messages_fetching_completed';
5993
+
5664
5994
  declare interface PaletteColor {
5665
5995
  extraDark: string;
5666
5996
  dark: string;
@@ -5709,19 +6039,16 @@ declare type PendingAIResponseStart = {
5709
6039
  startedAt: number;
5710
6040
  };
5711
6041
 
5712
- declare type PendingMeasurement = {
6042
+ declare type PendingMeasure = {
5713
6043
  channelUrl?: string;
5714
- messageType: PendingMessageType;
5715
- stuckReason: PendingStuckReason;
5716
6044
  timer: TimeoutId | null;
6045
+ usesUploadProgress: boolean;
5717
6046
  };
5718
6047
 
5719
6048
  declare type PendingMessageType = 'text' | 'file_image' | 'file_video' | 'file_document' | 'file_audio';
5720
6049
 
5721
6050
  declare type PendingStuckReason = 'pending_too_long' | 'upload_no_progress';
5722
6051
 
5723
- declare type PendingStuckTrackerOptions = Pick<TimedTrackerOptions, 'setTimeout' | 'clearTimeout'>;
5724
-
5725
6052
  declare type PickMessageProps<
5726
6053
  Union,
5727
6054
  T extends Union extends { messageType: infer MT } ? MT : never = Union extends { messageType: infer MT } ? MT : never,
@@ -5859,74 +6186,113 @@ declare type Props_2 = {
5859
6186
 
5860
6187
  declare type ReactOnlyIconName = 'expand' | 'collapse' | 'chevron-right' | 'attach' | 'close-filled' | 'actionbook' | 'function' | 'confluence' | 'zendesk' | 'salesforce' | 'sprinklr' | 'website' | 'snippet' | 'template' | 'show' | 'mute' | 'activity';
5861
6188
 
5862
- declare type RecoveryMeasurement = {
5863
- timer: TimeoutId | null;
5864
- startedAt: number | null;
5865
- triggerType: RecoveryTriggerType | null;
6189
+ declare type RecoveryMeasure = {
5866
6190
  inputEnabled: boolean;
6191
+ started: boolean;
6192
+ timer: TimeoutId | null;
5867
6193
  };
5868
6194
 
5869
- declare class RecoveryNotInteractiveTimeoutStatsTracker {
5870
- private readonly callback: StatsAppendCallback;
5871
- private readonly clock: StatsClock;
5872
- private readonly setTimeoutFn: typeof setTimeout;
5873
- private readonly clearTimeoutFn: typeof clearTimeout;
5874
- private readonly measurements = new Map<string, RecoveryMeasurement>();
6195
+ declare class RecoveryNotInteractiveTimeoutStats extends AIAgentBaseStats {
6196
+ private triggerType?: RecoveryTriggerType;
5875
6197
 
5876
- constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
5877
- this.callback = callback;
5878
- this.clock = getClock(options);
5879
- this.setTimeoutFn = getSetTimeout(options);
5880
- this.clearTimeoutFn = getClearTimeout(options);
6198
+ setTriggerType(triggerType: RecoveryTriggerType): this {
6199
+ this.triggerType = triggerType;
6200
+ return this;
6201
+ }
6202
+
6203
+ protected getMetricKey(): string {
6204
+ return METRIC_KEY_RECOVERY_NOT_INTERACTIVE_TIMEOUT;
5881
6205
  }
5882
6206
 
6207
+ protected buildPayload(): AIAgentStatPayload | null {
6208
+ const duration = this.getTimer(DurationKey.TIMEOUT).duration;
6209
+ if (duration === null || this.triggerType === undefined) return null;
6210
+
6211
+ return this.createPayload(duration, {
6212
+ extra: {
6213
+ trigger_type: this.triggerType,
6214
+ },
6215
+ });
6216
+ }
6217
+ }
6218
+
6219
+ /**
6220
+ * Spec: recovery_not_interactive_timeout
6221
+ *
6222
+ * Detects a Conversation that does not become interactive within 10s after a
6223
+ * recovery trigger.
6224
+ *
6225
+ * Timeline:
6226
+ * - Start after foreground_resume when the app was backgrounded for at least
6227
+ * 5s, or after ws_reconnected.
6228
+ * - Skip the measurement when the input is already enabled.
6229
+ * - Cancel when input becomes enabled before the timeout.
6230
+ * - Commit only if the 10s timeout fires while input is still unavailable.
6231
+ *
6232
+ * Payload:
6233
+ * - value: total_duration_ms.
6234
+ * - extra.trigger_type: foreground_resume | ws_reconnected.
6235
+ *
6236
+ * Policy:
6237
+ * - Recovery-cycle scoped; one active measurement per Conversation/recovery
6238
+ * trigger.
6239
+ * - Successful recovery is not recorded by this metric.
6240
+ */
6241
+ declare class RecoveryNotInteractiveTimeoutStatsTracker extends MultiStatsTrackerBase<
6242
+ RecoveryNotInteractiveTimeoutStats,
6243
+ RecoveryMeasure
6244
+ > {
5883
6245
  start(triggerType: RecoveryTriggerType, channelUrl?: string): void {
5884
6246
  const key = this.getKey(channelUrl);
5885
- let measurement = this.measurements.get(key);
5886
- if (measurement && measurement.startedAt !== null) return;
5887
- if (measurement?.inputEnabled) return;
5888
-
5889
- measurement = {
5890
- startedAt: this.clock.now(),
5891
- triggerType,
5892
- inputEnabled: measurement?.inputEnabled ?? false,
5893
- timer: this.setTimeoutFn(() => this.commitTimeout(key), 10_000),
5894
- };
5895
- this.measurements.set(key, measurement);
6247
+ let record = this.records.get(key);
6248
+ if (record?.measure.started) return;
6249
+ if (record?.measure.inputEnabled) return;
6250
+
6251
+ if (!record) {
6252
+ record = this.createRecord(false);
6253
+ }
6254
+
6255
+ record.measure.started = true;
6256
+ record.stats = new RecoveryNotInteractiveTimeoutStats()
6257
+ .setTriggerType(triggerType)
6258
+ .setCommitCallback(this.commitCallback)
6259
+ .startTimer(DurationKey.TIMEOUT);
6260
+ record.measure.timer = setTimeout(() => this.commitTimeout(key), 10_000);
6261
+ this.records.set(key, record);
5896
6262
  }
5897
6263
 
5898
6264
  setInputEnabled(enabled: boolean, channelUrl?: string): void {
5899
6265
  const key = this.getKey(channelUrl);
5900
- let measurement = this.measurements.get(key);
6266
+ let record = this.records.get(key);
5901
6267
 
5902
- if (!measurement) {
6268
+ if (!record) {
5903
6269
  if (!enabled) return;
5904
- measurement = {
5905
- startedAt: null,
5906
- triggerType: null,
5907
- inputEnabled: enabled,
5908
- timer: null,
5909
- };
5910
- this.measurements.set(key, measurement);
6270
+ record = this.createRecord(enabled);
6271
+ this.records.set(key, record);
6272
+ return;
5911
6273
  }
5912
6274
 
5913
- measurement.inputEnabled = enabled;
6275
+ record.measure.inputEnabled = enabled;
5914
6276
  if (enabled) {
5915
- this.cancelMeasurement(measurement);
6277
+ this.cancelRecord(record);
6278
+ } else if (!record.measure.started) {
6279
+ this.records.delete(key);
5916
6280
  }
5917
6281
  }
5918
6282
 
5919
6283
  cancelPending(channelUrl: string): void {
5920
6284
  const key = this.getKey(channelUrl);
5921
- const measurement = this.measurements.get(key);
5922
- if (!measurement) return;
5923
- this.cancelMeasurement(measurement);
5924
- if (!measurement.inputEnabled) this.measurements.delete(key);
6285
+ const record = this.records.get(key);
6286
+ if (!record) return;
6287
+ this.cancelRecord(record);
6288
+ if (!record.measure.inputEnabled) this.records.delete(key);
5925
6289
  }
5926
6290
 
5927
6291
  clear(): void {
5928
- this.measurements.forEach((measurement) => clearTimer(measurement.timer, this.clearTimeoutFn));
5929
- this.measurements.clear();
6292
+ this.records.forEach((record) => {
6293
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
6294
+ });
6295
+ this.records.clear();
5930
6296
  }
5931
6297
 
5932
6298
  clearChannel(channelUrl?: string): void {
@@ -5935,35 +6301,41 @@ declare class RecoveryNotInteractiveTimeoutStatsTracker {
5935
6301
  }
5936
6302
 
5937
6303
  private clearKey(key: string): void {
5938
- const measurement = this.measurements.get(key);
5939
- if (!measurement) return;
5940
- clearTimer(measurement.timer, this.clearTimeoutFn);
5941
- this.measurements.delete(key);
6304
+ const record = this.records.get(key);
6305
+ if (!record) return;
6306
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
6307
+ this.records.delete(key);
5942
6308
  }
5943
6309
 
5944
6310
  private commitTimeout(key: string): void {
5945
- const measurement = this.measurements.get(key);
5946
- if (!measurement || measurement.startedAt === null || measurement.triggerType === null) return;
5947
- if (measurement.inputEnabled) {
5948
- this.cancelMeasurement(measurement);
6311
+ const record = this.records.get(key);
6312
+ if (!record || !record.measure.started) return;
6313
+ if (record.measure.inputEnabled) {
6314
+ this.cancelRecord(record);
5949
6315
  return;
5950
6316
  }
5951
- const duration = this.clock.now() - measurement.startedAt;
5952
- const triggerType = measurement.triggerType;
6317
+ record.stats.stopTimer(DurationKey.TIMEOUT);
5953
6318
  // Drop the entry so a subsequent trigger can record a fresh cycle.
5954
- this.measurements.delete(key);
5955
- append(this.callback, METRIC_KEY_RECOVERY_NOT_INTERACTIVE_TIMEOUT, duration, {
5956
- extra: {
5957
- trigger_type: triggerType,
5958
- },
5959
- });
6319
+ this.records.delete(key);
6320
+ record.stats.commit();
5960
6321
  }
5961
6322
 
5962
- private cancelMeasurement(measurement: RecoveryMeasurement): void {
5963
- clearTimer(measurement.timer, this.clearTimeoutFn);
5964
- measurement.timer = null;
5965
- measurement.startedAt = null;
5966
- measurement.triggerType = null;
6323
+ private cancelRecord(record: StatsRecord<RecoveryNotInteractiveTimeoutStats, RecoveryMeasure>): void {
6324
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
6325
+ record.measure.timer = null;
6326
+ record.measure.started = false;
6327
+ record.stats = new RecoveryNotInteractiveTimeoutStats().setCommitCallback(this.commitCallback);
6328
+ }
6329
+
6330
+ private createRecord(inputEnabled: boolean): StatsRecord<RecoveryNotInteractiveTimeoutStats, RecoveryMeasure> {
6331
+ return {
6332
+ stats: new RecoveryNotInteractiveTimeoutStats().setCommitCallback(this.commitCallback),
6333
+ measure: {
6334
+ inputEnabled,
6335
+ started: false,
6336
+ timer: null,
6337
+ },
6338
+ };
5967
6339
  }
5968
6340
 
5969
6341
  private getKey(channelUrl?: string): string {
@@ -5983,36 +6355,69 @@ declare type ReleaseDisabledByValue =
5983
6355
  | 'reconnecting'
5984
6356
  | 'handoff_pending';
5985
6357
 
5986
- declare class RenderedContentUnusableStatsTracker {
5987
- private readonly callback: StatsAppendCallback;
5988
- private readonly committed = new Set<string>();
6358
+ declare type RenderedContentError = {
6359
+ code?: number;
6360
+ message?: string;
6361
+ };
5989
6362
 
5990
- constructor(callback: StatsAppendCallback) {
5991
- this.callback = callback;
5992
- }
6363
+ declare type RenderedContentErrorDomain = 'empty_render' | 'template_error' | 'form_error';
6364
+
6365
+ declare type RenderedContentKind = 'text' | 'image' | 'video' | 'audio' | 'template' | 'form';
6366
+
6367
+ declare type RenderedContentUnusableMeasure = {
6368
+ committed: Set<string>;
6369
+ };
6370
+
6371
+ /**
6372
+ * Spec: rendered_content_unusable
6373
+ *
6374
+ * Records AI response content that reaches the render pipeline but cannot be
6375
+ * presented as usable visible content.
6376
+ *
6377
+ * Timeline:
6378
+ * - Trigger on final render pipeline failures such as empty_render or
6379
+ * template_error.
6380
+ * - Commit immediately when the SDK-owned renderer determines the content is
6381
+ * unusable.
6382
+ *
6383
+ * Payload:
6384
+ * - value: error_domain.
6385
+ * - extra.content_kind, message_id.
6386
+ *
6387
+ * Policy:
6388
+ * - Message/content scoped; duplicate failures for the same message, content
6389
+ * kind, and domain are suppressed.
6390
+ * - Transient network failures and retries that later render successfully are
6391
+ * not the target of this metric.
6392
+ */
6393
+ declare class RenderedContentUnusableStatsTracker extends StatsTrackerBase {
6394
+ private readonly measure: RenderedContentUnusableMeasure = {
6395
+ committed: new Set<string>(),
6396
+ };
5993
6397
 
5994
6398
  commit(params: {
5995
6399
  messageId: string | number;
5996
- contentKind: 'text' | 'image' | 'video' | 'audio' | 'template' | 'form';
5997
- errorDomain: 'empty_render' | 'template_error' | 'form_error';
5998
- error?: ErrorLike;
6400
+ contentKind: RenderedContentKind;
6401
+ errorDomain: RenderedContentErrorDomain;
6402
+ error?: RenderedContentError;
5999
6403
  }): void {
6000
6404
  const key = `${params.messageId}:${params.contentKind}:${params.errorDomain}`;
6001
- if (this.committed.has(key)) return;
6002
- evictOldestIfAtLimit(this.committed, RENDERED_CONTENT_UNUSABLE_COMMITTED_LIMIT);
6003
- this.committed.add(key);
6405
+ if (this.measure.committed.has(key)) return;
6406
+ evictOldestIfAtLimit(this.measure.committed, RENDERED_CONTENT_UNUSABLE_COMMITTED_LIMIT);
6407
+ this.measure.committed.add(key);
6004
6408
 
6005
- append(this.callback, METRIC_KEY_RENDERED_CONTENT_UNUSABLE, params.errorDomain, {
6006
- error: params.error ? { code: params.error.code, message: params.errorDomain } : undefined,
6007
- extra: {
6008
- content_kind: params.contentKind,
6009
- message_id: String(params.messageId),
6010
- },
6011
- });
6409
+ const stats = new RenderedContentUnusableStats()
6410
+ .setMessageId(params.messageId)
6411
+ .setContentKind(params.contentKind)
6412
+ .setErrorDomain(params.errorDomain)
6413
+ .setError(params.error ? { code: params.error.code, message: params.errorDomain } : undefined)
6414
+ .setCommitCallback(this.commitCallback);
6415
+
6416
+ stats.commit();
6012
6417
  }
6013
6418
 
6014
6419
  clear(): void {
6015
- this.committed.clear();
6420
+ this.measure.committed.clear();
6016
6421
  }
6017
6422
  }
6018
6423
 
@@ -6055,17 +6460,95 @@ declare type SingleSelectField = {
6055
6460
  layout: 'default';
6056
6461
  };
6057
6462
 
6058
- declare type StatsAppendCallback = (type: string, data: AIAgentStatPayload) => boolean;
6463
+ declare abstract class SpanStatsTrackerBase<
6464
+ TSpan extends { onVisibilityChanged(visible: boolean): void },
6465
+ > extends StatsTrackerBase {
6466
+ protected activeSpan: TSpan | null = null;
6467
+ // Screen visibility is cross-attempt state (it outlives supersessions), so the
6468
+ // tracker owns it and stamps it into each new span, like presentMethod.
6469
+ protected visible: boolean = true;
6059
6470
 
6060
- declare interface StatsClock {
6061
- now: () => number;
6471
+ setVisible(visible: boolean): void {
6472
+ this.visible = visible;
6473
+ this.activeSpan?.onVisibilityChanged(visible);
6474
+ }
6062
6475
  }
6063
6476
 
6064
- declare type StatsPayloadError = {
6065
- code?: number;
6066
- message?: string;
6477
+ declare type StatsAppendCallback = (type: string, data: AIAgentStatPayload) => boolean;
6478
+
6479
+ declare type StatsPayloadParams = {
6480
+ conversationId?: number;
6481
+ extra?: Record<string, unknown>;
6067
6482
  };
6068
6483
 
6484
+ declare type StatsRecord<TStats, TMeasure> = { stats: TStats; measure: TMeasure };
6485
+
6486
+ /**
6487
+ * One measurement attempt issued by a tracker (`startSpan`), reified as a handle.
6488
+ *
6489
+ * A span is a StatsRecord with lifecycle: `stats` is the metric instance and `measure`
6490
+ * is the tracking metadata, exactly like a keyed record in MultiStatsTrackerBase. The
6491
+ * two idioms differ only in how the record is held — looked up by an entity key versus
6492
+ * captured as a handle at the moment the measured work starts, so late async events
6493
+ * land on the span that owns them instead of whichever measurement is currently active.
6494
+ *
6495
+ * Cancelling severs the metric reference: a superseded or abandoned span becomes
6496
+ * non-recording and can never emit, without any caller-side staleness guards.
6497
+ *
6498
+ * Emission is visibility-gated: the measurement runs regardless of screen
6499
+ * visibility, but a completion reached while hidden (commitOrDefer) is held and
6500
+ * flushed by onVisibilityChanged when the screen becomes visible.
6501
+ */
6502
+ declare abstract class StatsSpanBase<TStats extends AIAgentBaseStats, TMeasure> {
6503
+ protected stats: TStats | null;
6504
+ protected abstract measure: TMeasure;
6505
+ private visible: boolean;
6506
+ private completedHidden = false;
6507
+
6508
+ constructor(stats: TStats | null, visible: boolean) {
6509
+ this.stats = stats;
6510
+ this.visible = visible;
6511
+ }
6512
+
6513
+ get isRecording(): boolean {
6514
+ return this.stats !== null && !this.stats.isCommitted();
6515
+ }
6516
+
6517
+ onVisibilityChanged(visible: boolean): void {
6518
+ if (!this.stats) return;
6519
+
6520
+ this.visible = visible;
6521
+ if (visible && this.completedHidden) {
6522
+ this.stats.commit();
6523
+ this.stats = null;
6524
+ }
6525
+ }
6526
+
6527
+ /** Commit now when the screen is visible; otherwise hold the frozen measurement until it becomes visible. */
6528
+ protected commitOrDefer(): void {
6529
+ if (!this.stats) return;
6530
+
6531
+ if (!this.visible) {
6532
+ this.completedHidden = true;
6533
+ return;
6534
+ }
6535
+
6536
+ this.stats.commit();
6537
+ this.stats = null;
6538
+ }
6539
+
6540
+ cancel(): void {
6541
+ this.stats = null;
6542
+ }
6543
+ }
6544
+
6545
+ declare abstract class StatsTrackerBase {
6546
+ protected readonly commitCallback: StatsAppendCallback;
6547
+ constructor(commitCallback: StatsAppendCallback) {
6548
+ this.commitCallback = commitCallback;
6549
+ }
6550
+ }
6551
+
6069
6552
  /** Per-event override for the platform `announceStatus` wrappers — same shape as `AnnounceOptions`. */
6070
6553
  declare type StatusAnnouncementOptions = AnnounceOptions;
6071
6554
 
@@ -6119,21 +6602,76 @@ declare enum StewardTaskStatus {
6119
6602
  CANCELED = 'CANCELED',
6120
6603
  }
6121
6604
 
6122
- declare class StreamStalledStatsTracker {
6123
- private readonly callback: StatsAppendCallback;
6124
- private readonly clock: StatsClock;
6125
- private readonly setTimeoutFn: typeof setTimeout;
6126
- private readonly clearTimeoutFn: typeof clearTimeout;
6127
- private readonly streams = new Map<number, StreamState>();
6128
- private readonly emittedMessageChannels = new Map<number, string | undefined>();
6605
+ declare type StreamMeasure = {
6606
+ channelUrl?: string;
6607
+ deliveryTimer: TimeoutId | null;
6608
+ lastRawAt: number;
6609
+ lastRawLength: number;
6610
+ lastRenderAt: number;
6611
+ lastRenderedLength: number;
6612
+ renderTimer: TimeoutId | null;
6613
+ };
6614
+
6615
+ declare class StreamStalledStats extends AIAgentBaseStats {
6616
+ private durationMs?: number;
6617
+ private messageId?: number;
6618
+ private stallKind?: StreamStallKind;
6129
6619
 
6130
- constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
6131
- this.callback = callback;
6132
- this.clock = getClock(options);
6133
- this.setTimeoutFn = getSetTimeout(options);
6134
- this.clearTimeoutFn = getClearTimeout(options);
6620
+ setMessageId(messageId: number): this {
6621
+ this.messageId = messageId;
6622
+ return this;
6135
6623
  }
6136
6624
 
6625
+ setStall(params: { stallKind: StreamStallKind; durationMs: number }): this {
6626
+ this.durationMs = params.durationMs;
6627
+ this.stallKind = params.stallKind;
6628
+ return this;
6629
+ }
6630
+
6631
+ protected getMetricKey(): string {
6632
+ return METRIC_KEY_STREAM_STALLED;
6633
+ }
6634
+
6635
+ protected buildPayload(): AIAgentStatPayload | null {
6636
+ if (this.durationMs === undefined || this.messageId === undefined || this.stallKind === undefined) return null;
6637
+
6638
+ return this.createPayload(this.durationMs, {
6639
+ extra: {
6640
+ message_id: String(this.messageId),
6641
+ stall_kind: this.stallKind,
6642
+ },
6643
+ });
6644
+ }
6645
+ }
6646
+
6647
+ /**
6648
+ * Spec: stream_stalled
6649
+ *
6650
+ * Detects AI response stream stalls and classifies the stalled area. Tracking
6651
+ * is keyed by message_id because multiple AI response messages can be active
6652
+ * independently.
6653
+ *
6654
+ * Timeline:
6655
+ * - Start when a streaming AI response produces raw data for a message_id.
6656
+ * - delivery stall: raw response data does not advance for 10s.
6657
+ * - render stall: raw response data is ahead/arriving, but visible render
6658
+ * progress does not advance for 5s.
6659
+ * - Raw progress refreshes the delivery checkpoint; rendered progress refreshes
6660
+ * the render checkpoint.
6661
+ *
6662
+ * Payload:
6663
+ * - value: stall_duration_ms.
6664
+ * - extra.stall_kind: delivery | render.
6665
+ * - extra.message_id: tracked AI response message id.
6666
+ *
6667
+ * Policy:
6668
+ * - Message-scoped; first stall only per message_id.
6669
+ * - Delivery and render stalls are mutually exclusive for the same turn.
6670
+ * - A short response completing before either threshold is not recorded.
6671
+ */
6672
+ declare class StreamStalledStatsTracker extends MultiStatsTrackerBase<StreamStalledStats, StreamMeasure> {
6673
+ private readonly emittedMessageChannels = new Map<number, string | undefined>();
6674
+
6137
6675
  markRawProgress(params: {
6138
6676
  messageId: number;
6139
6677
  channelUrl?: string;
@@ -6147,54 +6685,52 @@ declare class StreamStalledStatsTracker {
6147
6685
 
6148
6686
  if (this.emittedMessageChannels.has(params.messageId)) return;
6149
6687
 
6150
- const stream = this.getOrCreate(params.messageId, params.channelUrl);
6688
+ const record = this.getOrCreate(params.messageId, params.channelUrl);
6689
+ const measure = record.measure;
6151
6690
 
6152
- if (params.textLength > stream.lastRawLength) {
6153
- stream.chunksReceived += 1;
6154
- stream.lastRawLength = params.textLength;
6155
- stream.lastRawAt = this.clock.now();
6156
-
6157
- clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6158
- stream.deliveryTimer = null;
6159
- this.scheduleDeliveryStallCheck(params.messageId, stream);
6691
+ if (this.markRawProgressForRecord(record, params.textLength, performance.now())) {
6692
+ if (measure.deliveryTimer !== null) clearTimeout(measure.deliveryTimer);
6693
+ measure.deliveryTimer = null;
6694
+ this.scheduleDeliveryStallCheck(params.messageId, record);
6160
6695
  } else {
6161
- this.scheduleDeliveryStallCheck(params.messageId, stream);
6696
+ this.scheduleDeliveryStallCheck(params.messageId, record);
6162
6697
  }
6163
6698
 
6164
- if (stream.lastRenderedLength < stream.lastRawLength && stream.renderTimer === null) {
6165
- stream.lastRenderAt = this.clock.now();
6166
- this.scheduleRenderStallCheck(params.messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS);
6699
+ if (this.isRenderPending(record) && measure.renderTimer === null) {
6700
+ measure.lastRenderAt = performance.now();
6701
+ this.scheduleRenderStallCheck(params.messageId, record, STREAM_RENDER_STALL_THRESHOLD_MS);
6167
6702
  }
6168
6703
  }
6169
6704
 
6170
6705
  markRenderedProgress(params: { messageId: number; displayedLength: number }): void {
6171
- const stream = this.streams.get(params.messageId);
6172
- if (!stream || params.displayedLength <= stream.lastRenderedLength) return;
6706
+ const record = this.records.get(params.messageId);
6707
+ if (!record) return;
6708
+ const measure = record.measure;
6173
6709
 
6174
- stream.lastRenderedLength = params.displayedLength;
6175
- stream.lastRenderAt = this.clock.now();
6710
+ const progressed = this.markRenderedProgressForRecord(record, params.displayedLength, performance.now());
6711
+ if (!progressed) return;
6176
6712
 
6177
- if (stream.lastRenderedLength >= stream.lastRawLength) {
6178
- if (stream.deliveryTimer === null) {
6179
- this.clearStream(params.messageId, stream);
6713
+ if (!this.isRenderPending(record)) {
6714
+ if (measure.deliveryTimer === null) {
6715
+ this.clearRecord(params.messageId, record);
6180
6716
  return;
6181
6717
  }
6182
6718
 
6183
- clearTimer(stream.renderTimer, this.clearTimeoutFn);
6184
- stream.renderTimer = null;
6719
+ if (measure.renderTimer !== null) clearTimeout(measure.renderTimer);
6720
+ measure.renderTimer = null;
6185
6721
  return;
6186
6722
  }
6187
6723
 
6188
- this.scheduleRenderStallCheck(params.messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS);
6724
+ this.scheduleRenderStallCheck(params.messageId, record, STREAM_RENDER_STALL_THRESHOLD_MS);
6189
6725
  }
6190
6726
 
6191
6727
  hasPendingStream(messageId: number): boolean {
6192
- return this.streams.has(messageId);
6728
+ return this.records.has(messageId);
6193
6729
  }
6194
6730
 
6195
6731
  complete(messageId: number): void {
6196
- const stream = this.streams.get(messageId);
6197
- if (stream) this.clearStream(messageId, stream);
6732
+ const record = this.records.get(messageId);
6733
+ if (record) this.clearRecord(messageId, record);
6198
6734
  }
6199
6735
 
6200
6736
  // Final stream update: no more raw chunks are coming, so stop delivery-stall
@@ -6204,52 +6740,53 @@ declare class StreamStalledStatsTracker {
6204
6740
  // raw length — otherwise a renderer that freezes mid-catch-up never emits a
6205
6741
  // 'render' stall.
6206
6742
  private completeDelivery(messageId: number, finalRawLength: number): void {
6207
- const stream = this.streams.get(messageId);
6208
- if (!stream) return;
6743
+ const record = this.records.get(messageId);
6744
+ if (!record) return;
6745
+ const measure = record.measure;
6209
6746
 
6210
6747
  // The final stream:false payload can carry text beyond the last streamed
6211
6748
  // chunk; fold it into the raw length first so the render watch targets the
6212
6749
  // full text the animation still has to reveal (otherwise the stream could be
6213
6750
  // cleared while a freeze on that trailing text would go unreported).
6214
- if (finalRawLength > stream.lastRawLength) {
6215
- stream.lastRawLength = finalRawLength;
6216
- stream.lastRawAt = this.clock.now();
6751
+ if (finalRawLength > measure.lastRawLength) {
6752
+ measure.lastRawLength = finalRawLength;
6753
+ measure.lastRawAt = performance.now();
6217
6754
  }
6218
6755
 
6219
- clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6220
- stream.deliveryTimer = null;
6756
+ if (measure.deliveryTimer !== null) clearTimeout(measure.deliveryTimer);
6757
+ measure.deliveryTimer = null;
6221
6758
 
6222
- if (stream.lastRenderedLength >= stream.lastRawLength) {
6223
- this.clearStream(messageId, stream);
6759
+ if (!this.isRenderPending(record)) {
6760
+ this.clearRecord(messageId, record);
6224
6761
  return;
6225
6762
  }
6226
6763
 
6227
- if (stream.renderTimer === null) {
6228
- stream.lastRenderAt = this.clock.now();
6229
- this.scheduleRenderStallCheck(messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS);
6764
+ if (measure.renderTimer === null) {
6765
+ measure.lastRenderAt = performance.now();
6766
+ this.scheduleRenderStallCheck(messageId, record, STREAM_RENDER_STALL_THRESHOLD_MS);
6230
6767
  }
6231
6768
  }
6232
6769
 
6233
6770
  clearMessage(messageId: number): void {
6234
- const stream = this.streams.get(messageId);
6235
- if (stream) this.clearStream(messageId, stream);
6771
+ const record = this.records.get(messageId);
6772
+ if (record) this.clearRecord(messageId, record);
6236
6773
  this.emittedMessageChannels.delete(messageId);
6237
6774
  }
6238
6775
 
6239
6776
  clear(): void {
6240
- this.streams.forEach((stream) => {
6241
- clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6242
- clearTimer(stream.renderTimer, this.clearTimeoutFn);
6777
+ this.records.forEach((record) => {
6778
+ if (record.measure.deliveryTimer !== null) clearTimeout(record.measure.deliveryTimer);
6779
+ if (record.measure.renderTimer !== null) clearTimeout(record.measure.renderTimer);
6243
6780
  });
6244
- this.streams.clear();
6781
+ this.records.clear();
6245
6782
  this.emittedMessageChannels.clear();
6246
6783
  }
6247
6784
 
6248
6785
  clearChannel(channelUrl?: string): void {
6249
6786
  if (!channelUrl) return;
6250
- this.streams.forEach((stream, messageId) => {
6251
- if (stream.channelUrl !== channelUrl && stream.channelUrl !== undefined) return;
6252
- this.clearStream(messageId, stream);
6787
+ this.records.forEach((record, messageId) => {
6788
+ if (record.measure.channelUrl !== channelUrl && record.measure.channelUrl !== undefined) return;
6789
+ this.clearRecord(messageId, record);
6253
6790
  });
6254
6791
  this.emittedMessageChannels.forEach((emittedChannelUrl, messageId) => {
6255
6792
  if (emittedChannelUrl === channelUrl || emittedChannelUrl === undefined)
@@ -6258,83 +6795,95 @@ declare class StreamStalledStatsTracker {
6258
6795
  }
6259
6796
 
6260
6797
  private detectDelivery(messageId: number): void {
6261
- const stream = this.streams.get(messageId);
6262
- if (!stream) return;
6263
- stream.deliveryTimer = null;
6798
+ const record = this.records.get(messageId);
6799
+ if (!record) return;
6800
+ const measure = record.measure;
6801
+ measure.deliveryTimer = null;
6264
6802
  // Mutex: cancel any in-flight render stall check so delivery wins this turn.
6265
- clearTimer(stream.renderTimer, this.clearTimeoutFn);
6266
- stream.renderTimer = null;
6267
- this.emit(messageId, stream, 'delivery');
6803
+ if (measure.renderTimer !== null) clearTimeout(measure.renderTimer);
6804
+ measure.renderTimer = null;
6805
+ this.emit(messageId, record, 'delivery');
6268
6806
  }
6269
6807
 
6270
6808
  private detectRender(messageId: number): void {
6271
- const stream = this.streams.get(messageId);
6272
- if (!stream) return;
6273
- stream.renderTimer = null;
6274
- if (stream.lastRenderedLength >= stream.lastRawLength) return;
6809
+ const record = this.records.get(messageId);
6810
+ if (!record) return;
6811
+ const measure = record.measure;
6812
+ measure.renderTimer = null;
6813
+ if (!this.isRenderPending(record)) return;
6275
6814
 
6276
- const elapsedSinceRender = this.clock.now() - stream.lastRenderAt;
6815
+ const elapsedSinceRender = performance.now() - measure.lastRenderAt;
6277
6816
  if (elapsedSinceRender < STREAM_RENDER_STALL_THRESHOLD_MS) {
6278
- this.scheduleRenderStallCheck(messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS - elapsedSinceRender);
6817
+ this.scheduleRenderStallCheck(messageId, record, STREAM_RENDER_STALL_THRESHOLD_MS - elapsedSinceRender);
6279
6818
  return;
6280
6819
  }
6281
6820
 
6282
6821
  // Mutex rule: when raw progress has been silent for at least the render threshold,
6283
6822
  // delivery is the root cause and will win. Defer the render emit until delivery has
6284
6823
  // had a chance to fire so we don't surface render as the symptom.
6285
- const elapsedSinceRaw = this.clock.now() - stream.lastRawAt;
6286
- if (elapsedSinceRaw >= STREAM_RENDER_STALL_THRESHOLD_MS && stream.deliveryTimer !== null) {
6824
+ const elapsedSinceRaw = performance.now() - measure.lastRawAt;
6825
+ if (elapsedSinceRaw >= STREAM_RENDER_STALL_THRESHOLD_MS && measure.deliveryTimer !== null) {
6287
6826
  const untilDelivery = STREAM_DELIVERY_STALL_THRESHOLD_MS - elapsedSinceRaw;
6288
6827
  if (untilDelivery > 0) {
6289
- this.scheduleRenderStallCheck(messageId, stream, untilDelivery);
6828
+ this.scheduleRenderStallCheck(messageId, record, untilDelivery);
6290
6829
  return;
6291
6830
  }
6292
6831
  }
6293
6832
 
6294
- this.emit(messageId, stream, 'render');
6833
+ this.emit(messageId, record, 'render');
6295
6834
  }
6296
6835
 
6297
- private scheduleRenderStallCheck(messageId: number, stream: StreamState, delayMs: number): void {
6298
- if (stream.renderTimer !== null) return;
6299
- stream.renderTimer = this.setTimeoutFn(() => this.detectRender(messageId), delayMs);
6836
+ private scheduleRenderStallCheck(
6837
+ messageId: number,
6838
+ record: StatsRecord<StreamStalledStats, StreamMeasure>,
6839
+ delayMs: number,
6840
+ ): void {
6841
+ if (record.measure.renderTimer !== null) return;
6842
+ record.measure.renderTimer = setTimeout(() => this.detectRender(messageId), delayMs);
6300
6843
  }
6301
6844
 
6302
- private scheduleDeliveryStallCheck(messageId: number, stream: StreamState): void {
6303
- if (stream.deliveryTimer !== null) return;
6304
- stream.deliveryTimer = this.setTimeoutFn(() => this.detectDelivery(messageId), STREAM_DELIVERY_STALL_THRESHOLD_MS);
6845
+ private scheduleDeliveryStallCheck(messageId: number, record: StatsRecord<StreamStalledStats, StreamMeasure>): void {
6846
+ if (record.measure.deliveryTimer !== null) return;
6847
+ record.measure.deliveryTimer = setTimeout(() => this.detectDelivery(messageId), STREAM_DELIVERY_STALL_THRESHOLD_MS);
6305
6848
  }
6306
6849
 
6307
- private emit(messageId: number, stream: StreamState, stallKind: StreamStallKind): void {
6850
+ private emit(
6851
+ messageId: number,
6852
+ record: StatsRecord<StreamStalledStats, StreamMeasure>,
6853
+ stallKind: StreamStallKind,
6854
+ ): void {
6308
6855
  if (this.emittedMessageChannels.has(messageId)) return;
6309
6856
 
6310
- const stallStartedAt = stallKind === 'delivery' ? stream.lastRawAt : stream.lastRenderAt;
6311
- append(this.callback, METRIC_KEY_STREAM_STALLED, this.clock.now() - stallStartedAt, {
6312
- extra: {
6313
- message_id: String(messageId),
6314
- stall_kind: stallKind,
6315
- },
6316
- });
6317
- this.rememberEmitted(messageId, stream.channelUrl);
6318
- this.clearStream(messageId, stream);
6857
+ const stalledAt = performance.now();
6858
+ const stallStartedAt = stallKind === 'delivery' ? record.measure.lastRawAt : record.measure.lastRenderAt;
6859
+ record.stats.setStall({ stallKind, durationMs: stalledAt - stallStartedAt }).commit();
6860
+ this.rememberEmitted(messageId, record.measure.channelUrl);
6861
+ this.clearRecord(messageId, record);
6319
6862
  }
6320
6863
 
6321
- private getOrCreate(messageId: number, channelUrl: string | undefined): StreamState {
6322
- const existing = this.streams.get(messageId);
6864
+ private getOrCreate(
6865
+ messageId: number,
6866
+ channelUrl: string | undefined,
6867
+ ): StatsRecord<StreamStalledStats, StreamMeasure> {
6868
+ const existing = this.records.get(messageId);
6323
6869
  if (existing) return existing;
6324
6870
 
6325
- const now = this.clock.now();
6326
- const stream: StreamState = {
6327
- channelUrl,
6328
- chunksReceived: 0,
6329
- lastRawAt: now,
6330
- lastRenderAt: now,
6331
- lastRawLength: 0,
6332
- lastRenderedLength: 0,
6333
- deliveryTimer: null,
6334
- renderTimer: null,
6871
+ const startedAt = performance.now();
6872
+
6873
+ const record: StatsRecord<StreamStalledStats, StreamMeasure> = {
6874
+ stats: new StreamStalledStats().setMessageId(messageId).setCommitCallback(this.commitCallback),
6875
+ measure: {
6876
+ channelUrl,
6877
+ deliveryTimer: null,
6878
+ lastRawAt: startedAt,
6879
+ lastRawLength: 0,
6880
+ lastRenderAt: startedAt,
6881
+ lastRenderedLength: 0,
6882
+ renderTimer: null,
6883
+ },
6335
6884
  };
6336
- this.streams.set(messageId, stream);
6337
- return stream;
6885
+ this.records.set(messageId, record);
6886
+ return record;
6338
6887
  }
6339
6888
 
6340
6889
  private rememberEmitted(messageId: number, channelUrl: string | undefined): void {
@@ -6344,26 +6893,43 @@ declare class StreamStalledStatsTracker {
6344
6893
  this.emittedMessageChannels.set(messageId, channelUrl);
6345
6894
  }
6346
6895
 
6347
- private clearStream(messageId: number, stream: StreamState): void {
6348
- clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6349
- clearTimer(stream.renderTimer, this.clearTimeoutFn);
6350
- this.streams.delete(messageId);
6896
+ private clearRecord(messageId: string | number, record: StatsRecord<StreamStalledStats, StreamMeasure>): void {
6897
+ if (record.measure.deliveryTimer !== null) clearTimeout(record.measure.deliveryTimer);
6898
+ if (record.measure.renderTimer !== null) clearTimeout(record.measure.renderTimer);
6899
+ this.records.delete(messageId);
6900
+ }
6901
+
6902
+ private isRenderPending(record: StatsRecord<StreamStalledStats, StreamMeasure>): boolean {
6903
+ return record.measure.lastRenderedLength < record.measure.lastRawLength;
6904
+ }
6905
+
6906
+ private markRawProgressForRecord(
6907
+ record: StatsRecord<StreamStalledStats, StreamMeasure>,
6908
+ textLength: number,
6909
+ rawAt: number,
6910
+ ): boolean {
6911
+ if (textLength <= record.measure.lastRawLength) return false;
6912
+
6913
+ record.measure.lastRawLength = textLength;
6914
+ record.measure.lastRawAt = rawAt;
6915
+ return true;
6916
+ }
6917
+
6918
+ private markRenderedProgressForRecord(
6919
+ record: StatsRecord<StreamStalledStats, StreamMeasure>,
6920
+ displayedLength: number,
6921
+ renderedAt: number,
6922
+ ): boolean {
6923
+ if (displayedLength <= record.measure.lastRenderedLength) return false;
6924
+
6925
+ record.measure.lastRenderedLength = displayedLength;
6926
+ record.measure.lastRenderAt = renderedAt;
6927
+ return true;
6351
6928
  }
6352
6929
  }
6353
6930
 
6354
6931
  declare type StreamStallKind = 'delivery' | 'render';
6355
6932
 
6356
- declare type StreamState = {
6357
- channelUrl?: string;
6358
- chunksReceived: number;
6359
- lastRawAt: number;
6360
- lastRenderAt: number;
6361
- lastRawLength: number;
6362
- lastRenderedLength: number;
6363
- deliveryTimer: TimeoutId | null;
6364
- renderTimer: TimeoutId | null;
6365
- };
6366
-
6367
6933
  /**
6368
6934
  * StringSet type for React components
6369
6935
  * Uses SNAKE_CASE keys for backward compatibility
@@ -6638,11 +7204,6 @@ declare type TextField = {
6638
7204
  };
6639
7205
  };
6640
7206
 
6641
- declare type TimedTrackerOptions = TrackerOptions & {
6642
- setTimeout?: typeof setTimeout;
6643
- clearTimeout?: typeof clearTimeout;
6644
- };
6645
-
6646
7207
  declare type TimeoutId = ReturnType<typeof setTimeout>;
6647
7208
 
6648
7209
  declare interface TimerData {
@@ -6650,42 +7211,94 @@ declare interface TimerData {
6650
7211
  endTime: number | null;
6651
7212
  }
6652
7213
 
6653
- declare type TrackerOptions = {
6654
- clock?: StatsClock;
7214
+ /** A never-started timer reads as all-null; duration stays null until the timer stops. */
7215
+ declare type TimerReading = {
7216
+ startAt: number | null;
7217
+ endedAt: number | null;
7218
+ duration: number | null;
6655
7219
  };
6656
7220
 
6657
- declare class TypingIndicatorAbsentTimeoutStatsTracker {
6658
- private readonly callback: StatsAppendCallback;
6659
- private readonly clock: StatsClock;
6660
- private readonly setTimeoutFn: typeof setTimeout;
6661
- private readonly clearTimeoutFn: typeof clearTimeout;
6662
- private readonly turns = new Map<string, TypingTurn>();
6663
- private readonly pendingAIResponseStartsByUserMessageId = new Map<number, PendingAIResponseStart>();
7221
+ declare class TypingIndicatorAbsentTimeoutStats extends AIAgentBaseStats {
7222
+ private beforeTpstAbsentMs?: number;
7223
+ private afterTpenAbsentMs?: number;
7224
+
7225
+ setBeforeTpstAbsentMs(duration: number): this {
7226
+ this.beforeTpstAbsentMs = duration;
7227
+ return this;
7228
+ }
7229
+
7230
+ setAfterTpenAbsentMs(duration: number): this {
7231
+ this.afterTpenAbsentMs = duration;
7232
+ return this;
7233
+ }
6664
7234
 
6665
- constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
6666
- this.callback = callback;
6667
- this.clock = getClock(options);
6668
- this.setTimeoutFn = getSetTimeout(options);
6669
- this.clearTimeoutFn = getClearTimeout(options);
7235
+ protected getMetricKey(): string {
7236
+ return METRIC_KEY_AI_TYPING_INDICATOR_ABSENT_TIMEOUT;
6670
7237
  }
6671
7238
 
7239
+ protected buildPayload(): AIAgentStatPayload | null {
7240
+ if (this.beforeTpstAbsentMs === undefined && this.afterTpenAbsentMs === undefined) return null;
7241
+
7242
+ return this.createPayload((this.beforeTpstAbsentMs ?? 0) + (this.afterTpenAbsentMs ?? 0), {
7243
+ extra: {
7244
+ [DurationKey.BEFORE_TPST_ABSENT]: this.beforeTpstAbsentMs,
7245
+ [DurationKey.AFTER_TPEN_ABSENT]: this.afterTpenAbsentMs,
7246
+ },
7247
+ });
7248
+ }
7249
+ }
7250
+
7251
+ /**
7252
+ * Spec: ai_typing_indicator_absent_timeout
7253
+ *
7254
+ * Detects server typing signal gaps during an AI turn. The metric is emitted
7255
+ * only for AI conversations and tracks two absent phases in the same turn.
7256
+ *
7257
+ * Timeline:
7258
+ * - Start before-TPST measurement when the user message send turn starts.
7259
+ * - Close before-TPST when TPST/typing-start is observed.
7260
+ * - Start after-TPEN measurement when TPEN/typing-end is observed.
7261
+ * - Close after-TPEN when the AI response message starts streaming.
7262
+ * - Commit when either phase exceeds the 3s absent threshold, or when the
7263
+ * 30s collection safety timeout is reached.
7264
+ *
7265
+ * Payload:
7266
+ * - value: absent_duration_ms, the total measured absent duration.
7267
+ * - extra.before_tpst_absent_ms: gap before TPST, when present.
7268
+ * - extra.after_tpen_absent_ms: gap after TPEN, when present.
7269
+ *
7270
+ * Policy:
7271
+ * - Turn-scoped; each user message turn is measured independently.
7272
+ * - The Phase 2 example sums both measured absent phases in value, even when
7273
+ * only one phase reaches the threshold.
7274
+ * - Human-agent handoff/abort clears the pending turn without emitting.
7275
+ */
7276
+ declare class TypingIndicatorAbsentTimeoutStatsTracker extends MultiStatsTrackerBase<
7277
+ TypingIndicatorAbsentTimeoutStats,
7278
+ TypingMeasure
7279
+ > {
7280
+ private readonly pendingAIResponseStartsByUserMessageId = new Map<number, PendingAIResponseStart>();
7281
+
6672
7282
  startTurn(params: { requestId: string; channelUrl?: string }): void {
6673
7283
  this.cancelTurn(params.requestId);
6674
- const turn: TypingTurn = {
6675
- requestId: params.requestId,
6676
- channelUrl: params.channelUrl,
6677
- beforeTpstStartedAt: this.clock.now(),
6678
- afterTpenStartedAt: null,
6679
- timer: null,
7284
+ const record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure> = {
7285
+ stats: new TypingIndicatorAbsentTimeoutStats().setCommitCallback(this.commitCallback),
7286
+ measure: {
7287
+ afterTpenStartedAt: null,
7288
+ beforeTpstStartedAt: performance.now(),
7289
+ channelUrl: params.channelUrl,
7290
+ requestId: params.requestId,
7291
+ timer: null,
7292
+ },
6680
7293
  };
6681
- turn.timer = this.setTimeoutFn(() => this.commitTimeout(turn.requestId), TYPING_COLLECTION_TIMEOUT_MS);
6682
- this.turns.set(params.requestId, turn);
7294
+ record.measure.timer = setTimeout(() => this.commitTimeout(record.measure.requestId), TYPING_COLLECTION_TIMEOUT_MS);
7295
+ this.records.set(params.requestId, record);
6683
7296
  }
6684
7297
 
6685
7298
  markUserMessageAck(params: { requestId: string; messageId: number }): void {
6686
- const turn = this.turns.get(params.requestId);
6687
- if (!turn) return;
6688
- turn.userMessageId = params.messageId;
7299
+ const record = this.records.get(params.requestId);
7300
+ if (!record) return;
7301
+ record.measure.userMessageId = params.messageId;
6689
7302
 
6690
7303
  const pending = this.pendingAIResponseStartsByUserMessageId.get(params.messageId);
6691
7304
  if (!pending) return;
@@ -6699,12 +7312,12 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6699
7312
  replaceTurnRequestId(currentRequestId: string, nextRequestId: string): void {
6700
7313
  if (currentRequestId === nextRequestId) return;
6701
7314
 
6702
- const turn = this.turns.get(currentRequestId);
6703
- if (!turn) return;
7315
+ const record = this.records.get(currentRequestId);
7316
+ if (!record) return;
6704
7317
 
6705
- this.turns.delete(currentRequestId);
6706
- turn.requestId = nextRequestId;
6707
- this.turns.set(nextRequestId, turn);
7318
+ this.records.delete(currentRequestId);
7319
+ record.measure.requestId = nextRequestId;
7320
+ this.records.set(nextRequestId, record);
6708
7321
  }
6709
7322
 
6710
7323
  cancelTurn(requestId: string): void {
@@ -6712,20 +7325,20 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6712
7325
  }
6713
7326
 
6714
7327
  markTypingStarted(channelUrl?: string): void {
6715
- const now = this.clock.now();
6716
- this.turns.forEach((turn) => {
6717
- if (!matchesChannel(turn, channelUrl)) return;
6718
- this.recordAfterTpen(turn, now);
6719
- this.recordBeforeTpst(turn, now);
7328
+ const now = performance.now();
7329
+ this.records.forEach((record) => {
7330
+ if (!matchesChannel(record.measure.channelUrl, channelUrl)) return;
7331
+ this.recordAfterTpen(record, now);
7332
+ this.recordBeforeTpst(record, now);
6720
7333
  });
6721
7334
  }
6722
7335
 
6723
7336
  markTypingEnded(channelUrl?: string): void {
6724
- const now = this.clock.now();
6725
- this.turns.forEach((turn) => {
6726
- if (!matchesChannel(turn, channelUrl)) return;
6727
- this.recordBeforeTpst(turn, now);
6728
- turn.afterTpenStartedAt = now;
7337
+ const now = performance.now();
7338
+ this.records.forEach((record) => {
7339
+ if (!matchesChannel(record.measure.channelUrl, channelUrl)) return;
7340
+ this.recordBeforeTpst(record, now);
7341
+ record.measure.afterTpenStartedAt = now;
6729
7342
  });
6730
7343
  }
6731
7344
 
@@ -6733,9 +7346,15 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6733
7346
  const channelUrl = params.channelUrl;
6734
7347
  const userMessageId = params.userMessageId;
6735
7348
  const matchesUserMessage = userMessageId !== undefined;
6736
- const now = this.clock.now();
7349
+ const now = performance.now();
6737
7350
  const matched = this.applyAIResponseStarted({ channelUrl, matchesUserMessage, userMessageId }, now);
6738
- if (matched || !matchesUserMessage || userMessageId === undefined || !this.hasUnackedTurn(channelUrl)) return;
7351
+ if (
7352
+ matched ||
7353
+ !matchesUserMessage ||
7354
+ userMessageId === undefined ||
7355
+ !hasUnackedChannelTurn(this.records.values(), channelUrl)
7356
+ )
7357
+ return;
6739
7358
 
6740
7359
  if (!this.pendingAIResponseStartsByUserMessageId.has(userMessageId)) {
6741
7360
  evictOldestIfAtLimit(this.pendingAIResponseStartsByUserMessageId, 100);
@@ -6750,16 +7369,16 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6750
7369
  if (!params.matchesUserMessage) {
6751
7370
  const match = this.findSingleTurn(params.channelUrl);
6752
7371
  if (!match) return false;
6753
- this.completeTurn(match.requestId, match.turn, startedAt);
7372
+ this.completeTurn(match.requestId, match.record, startedAt);
6754
7373
  return true;
6755
7374
  }
6756
7375
 
6757
7376
  let matched = false;
6758
- this.turns.forEach((turn, requestId) => {
6759
- if (!matchesChannel(turn, params.channelUrl)) return;
6760
- if (params.matchesUserMessage && turn.userMessageId !== params.userMessageId) return;
7377
+ this.records.forEach((record) => {
7378
+ if (!matchesChannel(record.measure.channelUrl, params.channelUrl)) return;
7379
+ if (params.matchesUserMessage && record.measure.userMessageId !== params.userMessageId) return;
6761
7380
  matched = true;
6762
- this.completeTurn(requestId, turn, startedAt);
7381
+ this.completeTurn(record.measure.requestId, record, startedAt);
6763
7382
  });
6764
7383
  return matched;
6765
7384
  }
@@ -6767,38 +7386,38 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6767
7386
  private findSingleTurn(channelUrl?: string): TypingTurnEntry | undefined {
6768
7387
  let match: TypingTurnEntry | undefined;
6769
7388
  let matchedCount = 0;
6770
- this.turns.forEach((turn, requestId) => {
6771
- if (!matchesChannel(turn, channelUrl)) return;
7389
+ this.records.forEach((record) => {
7390
+ if (!matchesChannel(record.measure.channelUrl, channelUrl)) return;
6772
7391
  matchedCount += 1;
6773
7392
  if (matchedCount > 1) return;
6774
- match = { requestId, turn };
7393
+ match = { requestId: record.measure.requestId, record };
6775
7394
  });
6776
7395
  return matchedCount === 1 ? match : undefined;
6777
7396
  }
6778
7397
 
6779
- private completeTurn(requestId: string, turn: TypingTurn, startedAt: number): void {
6780
- this.recordBeforeTpst(turn, startedAt);
6781
- this.recordAfterTpen(turn, startedAt);
6782
- this.commitIfAbsent(requestId, turn);
6783
- }
6784
-
6785
- private hasUnackedTurn(channelUrl?: string): boolean {
6786
- return hasUnackedChannelTurn(this.turns.values(), channelUrl);
7398
+ private completeTurn(
7399
+ requestId: string,
7400
+ record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure>,
7401
+ startedAt: number,
7402
+ ): void {
7403
+ this.recordBeforeTpst(record, startedAt);
7404
+ this.recordAfterTpen(record, startedAt);
7405
+ this.commitIfAbsent(requestId, record);
6787
7406
  }
6788
7407
 
6789
7408
  clear(): void {
6790
- this.turns.forEach((turn) => {
6791
- clearTimer(turn.timer, this.clearTimeoutFn);
7409
+ this.records.forEach((record) => {
7410
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
6792
7411
  });
6793
- this.turns.clear();
7412
+ this.records.clear();
6794
7413
  this.pendingAIResponseStartsByUserMessageId.clear();
6795
7414
  }
6796
7415
 
6797
7416
  clearChannel(channelUrl?: string): void {
6798
7417
  if (!channelUrl) return;
6799
- this.turns.forEach((turn, requestId) => {
6800
- if (turn.channelUrl !== channelUrl) return;
6801
- this.cleanupTurn(requestId);
7418
+ this.records.forEach((record) => {
7419
+ if (record.measure.channelUrl !== channelUrl) return;
7420
+ this.cleanupTurn(record.measure.requestId);
6802
7421
  });
6803
7422
  this.pendingAIResponseStartsByUserMessageId.forEach((pending, userMessageId) => {
6804
7423
  if (pending.channelUrl === channelUrl) this.pendingAIResponseStartsByUserMessageId.delete(userMessageId);
@@ -6806,78 +7425,62 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6806
7425
  }
6807
7426
 
6808
7427
  private commitTimeout(requestId: string): void {
6809
- const turn = this.turns.get(requestId);
6810
- if (!turn) return;
6811
- const now = this.clock.now();
6812
- this.recordBeforeTpst(turn, now);
6813
- this.recordAfterTpen(turn, now);
6814
- this.commitIfAbsent(requestId, turn);
7428
+ const record = this.records.get(requestId);
7429
+ if (!record) return;
7430
+ const now = performance.now();
7431
+ this.recordBeforeTpst(record, now);
7432
+ this.recordAfterTpen(record, now);
7433
+ this.commitIfAbsent(requestId, record);
7434
+ }
7435
+
7436
+ private commitIfAbsent(
7437
+ requestId: string,
7438
+ record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure>,
7439
+ ): void {
7440
+ record.stats.commit();
7441
+ this.cleanupTurn(requestId);
7442
+ }
7443
+
7444
+ private cleanupTurn(requestId: string): void {
7445
+ const record = this.records.get(requestId);
7446
+ if (!record) return;
7447
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
7448
+ this.records.delete(requestId);
6815
7449
  }
6816
7450
 
6817
7451
  private recordBeforeTpst(
6818
- turn: { beforeTpstStartedAt: number | null; beforeTpstAbsentMs?: number },
7452
+ record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure>,
6819
7453
  endedAt: number,
6820
7454
  ): void {
6821
- if (turn.beforeTpstStartedAt === null) return;
6822
- const duration = endedAt - turn.beforeTpstStartedAt;
6823
- if (duration >= TYPING_ABSENT_THRESHOLD_MS) turn.beforeTpstAbsentMs = duration;
6824
- turn.beforeTpstStartedAt = null;
7455
+ if (record.measure.beforeTpstStartedAt === null) return;
7456
+ const duration = endedAt - record.measure.beforeTpstStartedAt;
7457
+ if (duration >= TYPING_ABSENT_THRESHOLD_MS) record.stats.setBeforeTpstAbsentMs(duration);
7458
+ record.measure.beforeTpstStartedAt = null;
6825
7459
  }
6826
7460
 
6827
7461
  private recordAfterTpen(
6828
- turn: { afterTpenStartedAt: number | null; afterTpenAbsentMs?: number },
7462
+ record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure>,
6829
7463
  endedAt: number,
6830
7464
  ): void {
6831
- if (turn.afterTpenStartedAt === null) return;
6832
- const duration = endedAt - turn.afterTpenStartedAt;
6833
- if (duration >= TYPING_ABSENT_THRESHOLD_MS) turn.afterTpenAbsentMs = duration;
6834
- turn.afterTpenStartedAt = null;
6835
- }
6836
-
6837
- private commitIfAbsent(requestId: string, turn: { beforeTpstAbsentMs?: number; afterTpenAbsentMs?: number }): void {
6838
- const beforeTpstAbsentMs = turn.beforeTpstAbsentMs;
6839
- const afterTpenAbsentMs = turn.afterTpenAbsentMs;
6840
- if (beforeTpstAbsentMs === undefined && afterTpenAbsentMs === undefined) {
6841
- this.cleanupTurn(requestId);
6842
- return;
6843
- }
6844
-
6845
- append(
6846
- this.callback,
6847
- METRIC_KEY_AI_TYPING_INDICATOR_ABSENT_TIMEOUT,
6848
- (beforeTpstAbsentMs ?? 0) + (afterTpenAbsentMs ?? 0),
6849
- {
6850
- extra: {
6851
- ...(beforeTpstAbsentMs !== undefined && { before_tpst_absent_ms: beforeTpstAbsentMs }),
6852
- ...(afterTpenAbsentMs !== undefined && { after_tpen_absent_ms: afterTpenAbsentMs }),
6853
- },
6854
- },
6855
- );
6856
- this.cleanupTurn(requestId);
6857
- }
6858
-
6859
- private cleanupTurn(requestId: string): void {
6860
- const turn = this.turns.get(requestId);
6861
- if (!turn) return;
6862
- clearTimer(turn.timer, this.clearTimeoutFn);
6863
- this.turns.delete(requestId);
7465
+ if (record.measure.afterTpenStartedAt === null) return;
7466
+ const duration = endedAt - record.measure.afterTpenStartedAt;
7467
+ if (duration >= TYPING_ABSENT_THRESHOLD_MS) record.stats.setAfterTpenAbsentMs(duration);
7468
+ record.measure.afterTpenStartedAt = null;
6864
7469
  }
6865
7470
  }
6866
7471
 
6867
- declare type TypingTurn = {
6868
- requestId: string;
6869
- channelUrl?: string;
6870
- userMessageId?: number;
6871
- beforeTpstStartedAt: number | null;
7472
+ declare type TypingMeasure = {
6872
7473
  afterTpenStartedAt: number | null;
6873
- beforeTpstAbsentMs?: number;
6874
- afterTpenAbsentMs?: number;
7474
+ beforeTpstStartedAt: number | null;
7475
+ channelUrl?: string;
7476
+ requestId: string;
6875
7477
  timer: TimeoutId | null;
7478
+ userMessageId?: number;
6876
7479
  };
6877
7480
 
6878
7481
  declare type TypingTurnEntry = {
6879
7482
  requestId: string;
6880
- turn: TypingTurn;
7483
+ record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure>;
6881
7484
  };
6882
7485
 
6883
7486
  declare interface TypographyShape {
@@ -6955,76 +7558,163 @@ export declare type UserActionStatus =
6955
7558
 
6956
7559
  export declare const useRefreshActiveChannel: (updater: () => Promise<void>) => () => Promise<void>;
6957
7560
 
6958
- declare class UserPerceivedResponseTimeStatsTracker {
6959
- private readonly callback: StatsAppendCallback;
6960
- private readonly clock: StatsClock;
6961
- private readonly random: () => number;
6962
- private readonly sampledByRequestId = new Map<string, UserTurn>();
6963
- private readonly sampledByAIMessageId = new Map<number, UserTurn>();
7561
+ declare type UserPerceivedResponseTimeMeasure = {
7562
+ ackAt?: number;
7563
+ aiMessageId?: number;
7564
+ channelUrl?: string;
7565
+ firstChunkAt?: number;
7566
+ isStreaming?: boolean;
7567
+ requestId: string;
7568
+ startedAt: number;
7569
+ userMessageId?: number;
7570
+ };
7571
+
7572
+ declare class UserPerceivedResponseTimeStats extends AIAgentBaseStats {
7573
+ private ackToFirstChunkMs?: number;
7574
+ private firstChunkToRenderMs?: number;
7575
+ private isStreaming?: boolean;
7576
+ private sendToAckMs?: number;
7577
+ private totalDurationMs?: number;
7578
+
7579
+ setDurations(params: {
7580
+ ackToFirstChunkMs: number;
7581
+ firstChunkToRenderMs: number;
7582
+ isStreaming: boolean;
7583
+ sendToAckMs: number;
7584
+ totalDurationMs: number;
7585
+ }): this {
7586
+ this.ackToFirstChunkMs = params.ackToFirstChunkMs;
7587
+ this.firstChunkToRenderMs = params.firstChunkToRenderMs;
7588
+ this.isStreaming = params.isStreaming;
7589
+ this.sendToAckMs = params.sendToAckMs;
7590
+ this.totalDurationMs = params.totalDurationMs;
7591
+ return this;
7592
+ }
7593
+
7594
+ protected getMetricKey(): string {
7595
+ return METRIC_KEY_USER_PERCEIVED_RESPONSE_TIME;
7596
+ }
7597
+
7598
+ protected buildPayload(): AIAgentStatPayload | null {
7599
+ if (
7600
+ this.ackToFirstChunkMs === undefined ||
7601
+ this.firstChunkToRenderMs === undefined ||
7602
+ this.isStreaming === undefined ||
7603
+ this.sendToAckMs === undefined ||
7604
+ this.totalDurationMs === undefined
7605
+ ) {
7606
+ return null;
7607
+ }
7608
+
7609
+ return this.createPayload(this.totalDurationMs, {
7610
+ extra: {
7611
+ is_streaming: this.isStreaming,
7612
+ [DurationKey.ACK_TO_FIRST_CHUNK]: this.ackToFirstChunkMs,
7613
+ [DurationKey.FIRST_CHUNK_TO_RENDER]: this.firstChunkToRenderMs,
7614
+ [DurationKey.SEND_TO_ACK]: this.sendToAckMs,
7615
+ },
7616
+ });
7617
+ }
7618
+ }
7619
+
7620
+ /**
7621
+ * Spec: user_perceived_response_time
7622
+ *
7623
+ * Measures the user-visible latency from SDK send to the first AI response
7624
+ * frame on screen. Sampling is per user message: 15% independent probability
7625
+ * by default, capped at 10 collected turns per runtime session.
7626
+ *
7627
+ * Timeline:
7628
+ * - T0: SDK send API is called, not the UI tap time.
7629
+ * - T1: send completion handler runs.
7630
+ * - T2: first raw AI response chunk reaches the SDK.
7631
+ * - T3: the first response frame is rendered in the visible message cell.
7632
+ *
7633
+ * Payload:
7634
+ * - value: total_perceived_ms = T3 - T0.
7635
+ * - extra.send_to_ack_ms = T1 - T0.
7636
+ * - extra.ack_to_first_chunk_ms = T2 - T1.
7637
+ * - extra.first_chunk_to_render_ms = T3 - T2.
7638
+ * - extra.is_streaming: whether the AI response is streaming.
7639
+ *
7640
+ * Policy:
7641
+ * - Turn-scoped; commit only after T3.
7642
+ * - Incomplete or aborted turns are not recorded.
7643
+ * - Human-agent handoff/abort clears pending measurements without emitting.
7644
+ */
7645
+ declare class UserPerceivedResponseTimeStatsTracker extends MultiStatsTrackerBase<
7646
+ UserPerceivedResponseTimeStats,
7647
+ UserPerceivedResponseTimeMeasure
7648
+ > {
7649
+ private readonly samplingRate: number;
6964
7650
  private readonly pendingAIResponseByUserMessageId = new Map<number, PendingAIResponseRaw>();
6965
7651
  private collectedCount = 0;
6966
7652
 
6967
- constructor(callback: StatsAppendCallback, options?: TrackerOptions & { random?: () => number }) {
6968
- this.callback = callback;
6969
- this.clock = getClock(options);
6970
- this.random = options?.random ?? Math.random;
7653
+ constructor(callback: StatsAppendCallback, options?: { samplingRate?: number }) {
7654
+ super(callback);
7655
+ this.samplingRate = options?.samplingRate ?? USER_PERCEIVED_RESPONSE_TIME_SAMPLING_RATE;
6971
7656
  }
6972
7657
 
6973
7658
  startTurn(params: { requestId: string; channelUrl?: string }): void {
6974
- if (this.collectedCount >= 10) return;
6975
- if (this.random() >= 0.15) return;
7659
+ if (this.collectedCount >= MAX_COLLECTED_COUNT) return;
7660
+ if (!isIncludedInSample(this.samplingRate)) return;
6976
7661
  this.collectedCount += 1;
6977
- this.sampledByRequestId.set(params.requestId, {
6978
- requestId: params.requestId,
6979
- channelUrl: params.channelUrl,
6980
- startedAt: this.clock.now(),
6981
- });
7662
+
7663
+ const record: StatsRecord<UserPerceivedResponseTimeStats, UserPerceivedResponseTimeMeasure> = {
7664
+ stats: new UserPerceivedResponseTimeStats().setCommitCallback(this.commitCallback),
7665
+ measure: {
7666
+ channelUrl: params.channelUrl,
7667
+ requestId: params.requestId,
7668
+ startedAt: performance.now(),
7669
+ },
7670
+ };
7671
+ this.records.set(params.requestId, record);
6982
7672
  }
6983
7673
 
6984
7674
  markUserMessageAck(params: { requestId: string; messageId: number }): void {
6985
- const turn = this.sampledByRequestId.get(params.requestId);
6986
- if (!turn) return;
7675
+ const record = this.records.get(params.requestId);
7676
+ if (!record) return;
6987
7677
 
6988
- turn.ackAt = this.clock.now();
6989
- turn.userMessageId = params.messageId;
7678
+ record.measure.ackAt = performance.now();
7679
+ record.measure.userMessageId = params.messageId;
6990
7680
 
6991
7681
  const pending = this.pendingAIResponseByUserMessageId.get(params.messageId);
6992
7682
  if (!pending) return;
6993
7683
  this.pendingAIResponseByUserMessageId.delete(params.messageId);
6994
- this.applyAIResponseRaw(turn, pending);
7684
+ this.applyAIResponseRaw(record, pending);
6995
7685
  }
6996
7686
 
6997
7687
  replaceTurnRequestId(currentRequestId: string, nextRequestId: string): void {
6998
7688
  if (currentRequestId === nextRequestId) return;
6999
7689
 
7000
- const turn = this.sampledByRequestId.get(currentRequestId);
7001
- if (!turn) return;
7690
+ const record = this.records.get(currentRequestId);
7691
+ if (!record) return;
7002
7692
 
7003
- this.sampledByRequestId.delete(currentRequestId);
7004
- turn.requestId = nextRequestId;
7005
- this.sampledByRequestId.set(nextRequestId, turn);
7693
+ this.records.delete(currentRequestId);
7694
+ record.measure.requestId = nextRequestId;
7695
+ this.records.set(nextRequestId, record);
7006
7696
  }
7007
7697
 
7008
7698
  cancelTurn(requestId: string): void {
7009
- const turn = this.sampledByRequestId.get(requestId);
7010
- if (!turn) return;
7011
- this.deleteTurn(turn);
7699
+ const record = this.records.get(requestId);
7700
+ if (!record) return;
7701
+ this.deleteRecord(record);
7012
7702
  // Return the unused sample slot so cancelled turns don't burn the per-session cap.
7013
7703
  if (this.collectedCount > 0) this.collectedCount -= 1;
7014
7704
  }
7015
7705
 
7016
7706
  markAIResponseRaw(params: { messageId: number; channelUrl?: string; aiResponse: AIResponseMessageMetadata }): void {
7017
- const firstChunkAt = this.clock.now();
7707
+ const firstChunkAt = performance.now();
7018
7708
  const userMessageId = params.aiResponse.userMessageId;
7019
- if (userMessageId === undefined && this.hasUnackedTurn(params.channelUrl)) return;
7709
+ if (userMessageId === undefined && hasUnackedChannelTurn(this.records.values(), params.channelUrl)) return;
7020
7710
 
7021
- const turn = this.findPendingTurn(params.channelUrl, userMessageId);
7022
- if (turn) {
7023
- this.applyAIResponseRaw(turn, { ...params, firstChunkAt });
7711
+ const record = this.findPendingRecord(params.channelUrl, userMessageId);
7712
+ if (record) {
7713
+ this.applyAIResponseRaw(record, { ...params, firstChunkAt });
7024
7714
  return;
7025
7715
  }
7026
7716
 
7027
- if (userMessageId === undefined || !this.hasUnackedTurn(params.channelUrl)) return;
7717
+ if (userMessageId === undefined || !hasUnackedChannelTurn(this.records.values(), params.channelUrl)) return;
7028
7718
  this.bufferAIResponseRaw(
7029
7719
  {
7030
7720
  messageId: params.messageId,
@@ -7037,26 +7727,31 @@ declare class UserPerceivedResponseTimeStatsTracker {
7037
7727
  }
7038
7728
 
7039
7729
  markAIResponseVisible(aiMessageId: number): boolean {
7040
- const turn = this.sampledByAIMessageId.get(aiMessageId);
7041
- if (!turn || turn.ackAt === undefined || turn.firstChunkAt === undefined || turn.userMessageId === undefined)
7042
- return false;
7730
+ const record = this.findRecordByAIMessageId(aiMessageId);
7731
+ if (!record) return false;
7043
7732
 
7044
- const renderedAt = this.clock.now();
7045
- append(this.callback, METRIC_KEY_USER_PERCEIVED_RESPONSE_TIME, renderedAt - turn.startedAt, {
7046
- extra: {
7047
- ack_to_first_chunk_ms: turn.firstChunkAt - turn.ackAt,
7048
- first_chunk_to_render_ms: renderedAt - turn.firstChunkAt,
7049
- is_streaming: Boolean(turn.isStreaming),
7050
- send_to_ack_ms: turn.ackAt - turn.startedAt,
7051
- },
7052
- });
7733
+ const measure = record.measure;
7734
+ if (measure.ackAt === undefined || measure.firstChunkAt === undefined || measure.userMessageId === undefined) {
7735
+ return false;
7736
+ }
7053
7737
 
7054
- this.deleteTurn(turn);
7738
+ const renderedAt = performance.now();
7739
+ record.stats
7740
+ .setDurations({
7741
+ ackToFirstChunkMs: measure.firstChunkAt - measure.ackAt,
7742
+ firstChunkToRenderMs: renderedAt - measure.firstChunkAt,
7743
+ isStreaming: Boolean(measure.isStreaming),
7744
+ sendToAckMs: measure.ackAt - measure.startedAt,
7745
+ totalDurationMs: renderedAt - measure.startedAt,
7746
+ })
7747
+ .commit();
7748
+
7749
+ this.deleteRecord(record);
7055
7750
  return true;
7056
7751
  }
7057
7752
 
7058
7753
  hasPendingAIResponse(aiMessageId: number): boolean {
7059
- return this.sampledByAIMessageId.has(aiMessageId);
7754
+ return this.findRecordByAIMessageId(aiMessageId) !== undefined;
7060
7755
  }
7061
7756
 
7062
7757
  clearMessage(aiMessageId: number): void {
@@ -7064,18 +7759,18 @@ declare class UserPerceivedResponseTimeStatsTracker {
7064
7759
  if (pending.messageId === aiMessageId) this.pendingAIResponseByUserMessageId.delete(userMessageId);
7065
7760
  });
7066
7761
 
7067
- const turn = this.sampledByAIMessageId.get(aiMessageId);
7068
- if (!turn) return;
7762
+ const record = this.findRecordByAIMessageId(aiMessageId);
7763
+ if (!record) return;
7069
7764
 
7070
- this.deleteTurn(turn);
7765
+ this.deleteRecord(record);
7071
7766
  if (this.collectedCount > 0) this.collectedCount -= 1;
7072
7767
  }
7073
7768
 
7074
7769
  clearChannel(channelUrl?: string): void {
7075
7770
  if (!channelUrl) return;
7076
- this.sampledByRequestId.forEach((turn) => {
7077
- if (turn.channelUrl !== channelUrl) return;
7078
- this.deleteTurn(turn);
7771
+ this.records.forEach((record) => {
7772
+ if (record.measure.channelUrl !== channelUrl) return;
7773
+ this.deleteRecord(record);
7079
7774
  if (this.collectedCount > 0) this.collectedCount -= 1;
7080
7775
  });
7081
7776
  this.pendingAIResponseByUserMessageId.forEach((pending, userMessageId) => {
@@ -7084,40 +7779,54 @@ declare class UserPerceivedResponseTimeStatsTracker {
7084
7779
  }
7085
7780
 
7086
7781
  clear(): void {
7087
- this.sampledByRequestId.clear();
7088
- this.sampledByAIMessageId.clear();
7782
+ this.records.clear();
7089
7783
  this.pendingAIResponseByUserMessageId.clear();
7090
7784
  this.collectedCount = 0;
7091
7785
  }
7092
7786
 
7093
- private findPendingTurn(channelUrl?: string, userMessageId?: number): UserTurn | undefined {
7787
+ private findRecordByAIMessageId(
7788
+ aiMessageId: number,
7789
+ ): StatsRecord<UserPerceivedResponseTimeStats, UserPerceivedResponseTimeMeasure> | undefined {
7790
+ for (const record of this.records.values()) {
7791
+ if (record.measure.aiMessageId === aiMessageId) return record;
7792
+ }
7793
+ return undefined;
7794
+ }
7795
+
7796
+ private findPendingRecord(
7797
+ channelUrl?: string,
7798
+ userMessageId?: number,
7799
+ ): StatsRecord<UserPerceivedResponseTimeStats, UserPerceivedResponseTimeMeasure> | undefined {
7094
7800
  if (userMessageId !== undefined) {
7095
- for (const turn of this.sampledByRequestId.values()) {
7096
- if (turn.ackAt === undefined || turn.firstChunkAt !== undefined) continue;
7097
- if (!matchesChannel(turn, channelUrl)) continue;
7098
- if (turn.userMessageId === userMessageId) return turn;
7801
+ for (const record of this.records.values()) {
7802
+ if (record.measure.ackAt === undefined || record.measure.firstChunkAt !== undefined) continue;
7803
+ if (!matchesChannel(record.measure.channelUrl, channelUrl)) continue;
7804
+ if (record.measure.userMessageId === userMessageId) return record;
7099
7805
  }
7100
7806
  return undefined;
7101
7807
  }
7102
7808
 
7103
- let match: UserTurn | undefined;
7104
- for (const turn of this.sampledByRequestId.values()) {
7105
- if (turn.ackAt === undefined || turn.firstChunkAt !== undefined) continue;
7106
- if (!matchesChannel(turn, channelUrl)) continue;
7809
+ let match: StatsRecord<UserPerceivedResponseTimeStats, UserPerceivedResponseTimeMeasure> | undefined;
7810
+ for (const record of this.records.values()) {
7811
+ if (record.measure.ackAt === undefined || record.measure.firstChunkAt !== undefined) continue;
7812
+ if (!matchesChannel(record.measure.channelUrl, channelUrl)) continue;
7107
7813
  if (match) return undefined;
7108
- match = turn;
7814
+ match = record;
7109
7815
  }
7110
7816
  return match;
7111
7817
  }
7112
7818
 
7113
- private applyAIResponseRaw(turn: UserTurn, params: PendingAIResponseRaw): void {
7114
- if (turn.firstChunkAt !== undefined) return;
7819
+ private applyAIResponseRaw(
7820
+ record: StatsRecord<UserPerceivedResponseTimeStats, UserPerceivedResponseTimeMeasure>,
7821
+ params: PendingAIResponseRaw,
7822
+ ): void {
7823
+ if (record.measure.firstChunkAt !== undefined) return;
7115
7824
 
7116
- turn.firstChunkAt = turn.ackAt === undefined ? params.firstChunkAt : Math.max(params.firstChunkAt, turn.ackAt);
7117
- turn.aiMessageId = params.messageId;
7118
- turn.channelUrl = turn.channelUrl ?? params.channelUrl;
7119
- turn.isStreaming = params.aiResponse.isStreaming;
7120
- this.sampledByAIMessageId.set(params.messageId, turn);
7825
+ record.measure.firstChunkAt =
7826
+ record.measure.ackAt === undefined ? params.firstChunkAt : Math.max(params.firstChunkAt, record.measure.ackAt);
7827
+ record.measure.aiMessageId = params.messageId;
7828
+ record.measure.channelUrl = record.measure.channelUrl ?? params.channelUrl;
7829
+ record.measure.isStreaming = params.aiResponse.isStreaming;
7121
7830
  }
7122
7831
 
7123
7832
  private bufferAIResponseRaw(params: PendingAIResponseRaw, userMessageId: number): void {
@@ -7127,14 +7836,10 @@ declare class UserPerceivedResponseTimeStatsTracker {
7127
7836
  }
7128
7837
  }
7129
7838
 
7130
- private hasUnackedTurn(channelUrl?: string): boolean {
7131
- return hasUnackedChannelTurn(this.sampledByRequestId.values(), channelUrl);
7132
- }
7133
-
7134
- private deleteTurn(turn: UserTurn): void {
7135
- this.sampledByRequestId.delete(turn.requestId);
7136
- if (turn.aiMessageId !== undefined) this.sampledByAIMessageId.delete(turn.aiMessageId);
7137
- if (turn.userMessageId !== undefined) this.pendingAIResponseByUserMessageId.delete(turn.userMessageId);
7839
+ private deleteRecord(record: StatsRecord<UserPerceivedResponseTimeStats, UserPerceivedResponseTimeMeasure>): void {
7840
+ this.records.delete(record.measure.requestId);
7841
+ if (record.measure.userMessageId !== undefined)
7842
+ this.pendingAIResponseByUserMessageId.delete(record.measure.userMessageId);
7138
7843
  }
7139
7844
  }
7140
7845
 
@@ -7155,17 +7860,6 @@ declare interface UserSessionInfo {
7155
7860
  sessionHandler: AIAgentSessionHandler;
7156
7861
  }
7157
7862
 
7158
- declare type UserTurn = {
7159
- requestId: string;
7160
- channelUrl?: string;
7161
- startedAt: number;
7162
- ackAt?: number;
7163
- firstChunkAt?: number;
7164
- userMessageId?: number;
7165
- aiMessageId?: number;
7166
- isStreaming?: boolean;
7167
- };
7168
-
7169
7863
  export { }
7170
7864
 
7171
7865