@sendbird/ai-agent-messenger-react 1.35.1 → 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 +1592 -902
  3. package/dist/index.js +5314 -5158
  4. package/package.json +6 -6
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;
@@ -1607,7 +1656,6 @@ declare interface ConversationContextProps {
1607
1656
  export declare const ConversationContextProvider: ({ children, ...props }: PropsWithChildren<ConversationContextProps>) => JSX.Element;
1608
1657
 
1609
1658
  declare interface ConversationContextValue extends AIAgentConversationContextValue {
1610
- conversationStatsEnabled: boolean;
1611
1659
  scrollSource: ConversationScrollContextValue;
1612
1660
  goToActiveConversation: () => Promise<void>;
1613
1661
  onNavigateToConversationList?: () => void;
@@ -1704,234 +1752,326 @@ declare interface ConversationHeaderTemplateProps {
1704
1752
 
1705
1753
  declare type ConversationInitialFocusTarget = 'messageInput';
1706
1754
 
1755
+ declare type ConversationInitialRenderMeasure = {
1756
+ channelUrl: string;
1757
+ pageRenderState: PageRenderState;
1758
+ };
1759
+
1707
1760
  declare class ConversationInitialRenderStats extends AIAgentBaseStats {
1761
+ private channelUrl: string | null = null;
1708
1762
  private presentMethod: PresentMethod = 'direct_present';
1709
1763
 
1764
+ setChannelUrl(url: string): this {
1765
+ this.channelUrl = url;
1766
+ return this;
1767
+ }
1768
+
1710
1769
  setPresentMethod(method: PresentMethod): this {
1711
1770
  this.presentMethod = method;
1712
1771
  return this;
1713
1772
  }
1714
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
+
1715
1786
  protected getMetricKey(): string {
1716
1787
  return METRIC_KEY_CONVERSATION_INITIAL_RENDER;
1717
1788
  }
1718
1789
 
1719
1790
  protected buildPayload(): AIAgentStatPayload | null {
1720
- const authDuration = this.getDuration(DurationKey.AUTH);
1721
- const getChannelDuration = this.getDuration(DurationKey.GET_CHANNEL);
1722
- const getMessagesDuration = this.getDuration(DurationKey.GET_MESSAGES);
1723
- const totalDurationFromCache = this.getDuration(DurationKey.TOTAL_DURATION_FROM_CACHE);
1724
- 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);
1725
1794
 
1726
- const extra: Record<string, unknown> = {
1727
- ...this.extraData,
1728
- present_method: this.presentMethod,
1729
- ...(authDuration !== null && { [DurationKey.AUTH]: authDuration }),
1730
- ...(getChannelDuration !== null && { [DurationKey.GET_CHANNEL]: getChannelDuration }),
1731
- ...(getMessagesDuration !== null && { [DurationKey.GET_MESSAGES]: getMessagesDuration }),
1732
- ...(totalDurationFromCache !== null && { [DurationKey.TOTAL_DURATION_FROM_CACHE]: totalDurationFromCache }),
1733
- };
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;
1798
+
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);
1734
1802
 
1735
- return createStatsPayload(this.getMetricKey(), totalDuration ?? 0, {
1736
- conversationId: this.conversationId ?? undefined,
1737
- error: this.errorCode !== null ? { code: this.errorCode, message: this.errorDescription ?? '' } : undefined,
1738
- extra,
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
+ },
1739
1812
  });
1740
1813
  }
1741
1814
  }
1742
1815
 
1743
1816
  /**
1744
- * 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.
1745
1826
  */
1746
- declare class ConversationInitialRenderStatsTracker {
1747
- private readonly commitCallback: StatsAppendCallback;
1748
- private apiResultReceived = false;
1749
- private channelUrl?: string;
1750
- private consumed = false;
1751
- private consumedByAuthError = false;
1752
- private presentMethod: PresentMethod = 'direct_present';
1753
- private renderedAfterApiResult = false;
1754
- private stats: ConversationInitialRenderStats | null = null;
1827
+ declare class ConversationInitialRenderStatsSpan extends StatsSpanBase<
1828
+ ConversationInitialRenderStats,
1829
+ ConversationInitialRenderMeasure
1830
+ > {
1831
+ protected measure: ConversationInitialRenderMeasure;
1755
1832
 
1756
- constructor(commitCallback: StatsAppendCallback) {
1757
- 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);
1758
1840
  }
1759
1841
 
1760
- private getOrCreateStats(): ConversationInitialRenderStats | null {
1761
- if (this.consumed) return null;
1762
- if (this.stats?.isCommitted()) {
1763
- this.consumed = true;
1764
- return null;
1765
- }
1766
- if (!this.stats) {
1767
- this.stats = new ConversationInitialRenderStats()
1768
- .setCommitCallback(this.commitCallback)
1769
- .setPresentMethod(this.presentMethod);
1770
- this.apiResultReceived = false;
1771
- this.renderedAfterApiResult = false;
1772
- }
1773
- 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';
1774
1847
  }
1775
1848
 
1776
- private getActiveStats(): ConversationInitialRenderStats | null {
1777
- if (this.consumed || !this.stats || this.stats.isCommitted()) return null;
1778
- 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';
1779
1855
  }
1780
1856
 
1781
- private matchesChannelUrl(channelUrl?: string): boolean {
1782
- 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();
1783
1864
  }
1784
1865
 
1785
- private pinChannelUrl(channelUrl?: string): boolean {
1786
- if (channelUrl === undefined) return true;
1787
- if (!this.matchesChannelUrl(channelUrl)) return false;
1866
+ onGetMessagesStart(): void {
1867
+ if (!this.stats || this.measure.pageRenderState !== 'channel_fetched') return;
1788
1868
 
1789
- this.channelUrl = channelUrl;
1790
- this.stats?.setChannelUrl(channelUrl);
1791
- return true;
1869
+ this.stats.startTimer(DurationKey.GET_MESSAGES);
1870
+ this.measure.pageRenderState = 'messages_fetching';
1792
1871
  }
1793
1872
 
1794
- private consume(commitResult: boolean): boolean {
1795
- if (commitResult) {
1796
- 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;
1797
1881
  }
1798
- return commitResult;
1799
- }
1800
1882
 
1801
- private commitIfReady(channelUrl?: string): boolean {
1802
- if (this.consumed || !this.stats || this.stats.isCommitted()) return true;
1803
- if (!this.matchesChannelUrl(channelUrl)) return false;
1804
- if (!this.apiResultReceived || !this.renderedAfterApiResult) return false;
1805
- return this.consume(this.stats.stopTimer(DurationKey.TOTAL_DURATION).commit());
1883
+ this.stats.stopTimer(DurationKey.PAGE_RENDER_FROM_CACHE);
1806
1884
  }
1807
1885
 
1808
- start(channelUrl?: string): void {
1809
- const stats = this.getOrCreateStats();
1810
- 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;
1811
1889
 
1812
- 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';
1813
1892
  }
1814
1893
 
1815
- onAuthStart(): void {
1816
- if (this.consumedByAuthError) {
1817
- this.clear();
1818
- }
1819
- 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();
1820
1899
  }
1821
1900
 
1822
- onAuthComplete(): void {
1823
- 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;
1824
1906
  }
1825
1907
 
1826
- onAuthError(error: Error): void {
1827
- const stats = this.getActiveStats();
1828
- if (stats) {
1829
- const committed = this.consume(
1830
- stats
1831
- .stopTimer(DurationKey.AUTH)
1832
- .setError(error instanceof SendbirdError ? error.code : undefined, error.message)
1833
- .stopTimer(DurationKey.TOTAL_DURATION)
1834
- .commit(),
1835
- );
1836
- if (committed) {
1837
- this.consumedByAuthError = true;
1838
- }
1839
- }
1908
+ /** Auth settled while this span was measuring; a finalize that waited on in-flight auth can proceed. */
1909
+ onAuthSettled(): void {
1910
+ this.tryFinalize();
1840
1911
  }
1841
1912
 
1842
- onGetChannelStart(channelUrl?: string): void {
1843
- const stats = this.getActiveStats();
1844
- 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;
1845
1917
 
1846
- stats.startTimer(DurationKey.GET_CHANNEL);
1918
+ const auth = this.stats.getTimer(DurationKey.AUTH);
1919
+ if (auth.startAt !== null && auth.endedAt === null) return;
1920
+
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();
1847
1928
  }
1929
+ }
1848
1930
 
1849
- onGetChannelComplete(params?: number | GetChannelCompleteParams): void {
1850
- const channelUrl = typeof params === 'object' ? params.channelUrl : undefined;
1851
- const conversationId = typeof params === 'object' ? params.conversationId : params;
1852
- const stats = this.getActiveStats();
1853
- 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';
1854
2006
 
1855
- stats.stopTimer(DurationKey.GET_CHANNEL);
1856
- if (conversationId !== undefined) {
1857
- 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);
1858
2013
  }
1859
- stats.startTimer(DurationKey.GET_MESSAGES);
2014
+ return this.stats;
1860
2015
  }
1861
2016
 
1862
- onGetChannelError(params: Error | GetChannelErrorParams): void {
1863
- const channelUrl = params instanceof Error ? undefined : params.channelUrl;
1864
- const error = params instanceof Error ? params : params.error;
1865
- const stats = this.getActiveStats();
1866
- if (stats && this.pinChannelUrl(channelUrl)) {
1867
- this.consume(
1868
- stats
1869
- .stopTimer(DurationKey.GET_CHANNEL)
1870
- .setError(error instanceof SendbirdError ? error.code : undefined, error.message)
1871
- .stopTimer(DurationKey.TOTAL_DURATION)
1872
- .commit(),
1873
- );
1874
- }
2017
+ private getActiveStats(): ConversationInitialRenderStats | null {
2018
+ if (!this.stats || this.stats.isCommitted()) return null;
2019
+ return this.stats;
1875
2020
  }
1876
2021
 
1877
- onCacheResult(error: Error | null): void {
1878
- const stats = this.getActiveStats();
1879
- if (stats) {
1880
- if (error) {
1881
- stats.setError(error instanceof SendbirdError ? error.code : undefined, error.message);
1882
- }
1883
- stats.stopTimer(DurationKey.TOTAL_DURATION_FROM_CACHE);
1884
- }
2022
+ setPresentMethod(method: PresentMethod): void {
2023
+ this.presentMethod = method;
2024
+ this.getActiveStats()?.setPresentMethod(method);
1885
2025
  }
1886
2026
 
1887
- onApiResult(error: Error | null): void {
1888
- const stats = this.getActiveStats();
1889
- if (stats) {
1890
- if (error) {
1891
- this.consume(
1892
- stats
1893
- .setError(error instanceof SendbirdError ? error.code : undefined, error.message)
1894
- .stopTimer(DurationKey.TOTAL_DURATION)
1895
- .stopTimer(DurationKey.GET_MESSAGES)
1896
- .commit(),
1897
- );
1898
- return;
1899
- }
1900
- this.apiResultReceived = true;
1901
- this.renderedAfterApiResult = false;
1902
- stats.stopTimer(DurationKey.GET_MESSAGES);
1903
- 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';
1904
2033
  }
1905
- }
2034
+ if (this.authState !== 'idle') return;
1906
2035
 
1907
- setChannelUrl(url: string): void {
1908
- this.pinChannelUrl(url);
2036
+ this.authState = 'in_progress';
2037
+ this.getOrCreateStats()?.startTimer(DurationKey.AUTH);
1909
2038
  }
1910
2039
 
1911
- commitPending(): boolean {
1912
- const stats = this.getActiveStats();
1913
- 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();
1914
2047
  }
1915
2048
 
1916
- markRendered(channelUrl?: string): boolean {
1917
- if (!this.matchesChannelUrl(channelUrl)) return false;
2049
+ onAuthError(error: Error): void {
2050
+ if (this.authState !== 'in_progress') return;
1918
2051
 
1919
- this.renderedAfterApiResult = true;
1920
- 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();
1921
2057
  }
1922
2058
 
1923
- setPresentMethod(method: PresentMethod): void {
1924
- this.presentMethod = method;
1925
- 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;
1926
2068
  }
1927
2069
 
1928
2070
  clear(): void {
2071
+ this.activeSpan?.cancel();
2072
+ this.activeSpan = null;
1929
2073
  this.stats = null;
1930
- this.apiResultReceived = false;
1931
- this.channelUrl = undefined;
1932
- this.consumed = false;
1933
- this.consumedByAuthError = false;
1934
- this.renderedAfterApiResult = false;
2074
+ this.authState = 'idle';
1935
2075
  }
1936
2076
  }
1937
2077
 
@@ -2065,153 +2205,263 @@ declare interface ConversationListHeaderTemplateProps {
2065
2205
  titleAlign?: 'start' | 'center' | 'end';
2066
2206
  }
2067
2207
 
2068
- declare class ConversationListInitialRenderStatsTracker {
2069
- private readonly callback: StatsAppendCallback;
2070
- private readonly clock: StatsClock;
2071
- private measurement = createEmptyProgress();
2072
- private renderedCommitted = false;
2073
- private startedAt: number | null = null;
2074
- private listStartedAt: number | null = null;
2075
- private authStartedAt: number | null = null;
2076
- private authDuration: number | null = null;
2077
- private listDuration: number | null = null;
2208
+ declare type ConversationListInitialRenderMeasure = {
2209
+ listStarted: boolean;
2210
+ listCompleted: boolean;
2211
+ };
2212
+
2213
+ declare class ConversationListInitialRenderStats extends AIAgentBaseStats {
2078
2214
  private presentMethod: PresentMethod = 'direct_present';
2079
- private committed = false;
2080
2215
 
2081
- constructor(callback: StatsAppendCallback, options?: TrackerOptions) {
2082
- this.callback = callback;
2083
- this.clock = getClock(options);
2216
+ setPresentMethod(method: PresentMethod): this {
2217
+ this.presentMethod = method;
2218
+ return this;
2084
2219
  }
2085
2220
 
2086
- onInitializeStart(): void {
2087
- if (this.measurement.rendered) return;
2088
- if (this.measurement.initialized) {
2089
- this.clearMeasurementState();
2090
- }
2091
- this.renderedCommitted = false;
2092
- 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;
2093
2227
  }
2094
2228
 
2095
- onListRequestStart(): void {
2096
- if (this.measurement.listStarted && !this.measurement.listCompleted && !this.measurement.rendered) {
2097
- this.clearMeasurementState();
2098
- }
2099
-
2100
- this.markListRequestStartOnce();
2229
+ protected getMetricKey(): string {
2230
+ return METRIC_KEY_CONVERSATION_LIST_INITIAL_RENDER;
2101
2231
  }
2102
2232
 
2103
- onListRequestComplete(): void {
2104
- this.markListRequestCompleteOnce();
2105
- }
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;
2106
2237
 
2107
- onRendered(): void {
2108
- this.markRenderedOnce();
2109
- }
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;
2110
2241
 
2111
- setPresentMethod(method: PresentMethod): void {
2112
- 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
+ });
2113
2253
  }
2254
+ }
2114
2255
 
2115
- start(presentMethod?: PresentMethod): void {
2116
- if (this.committed) return;
2117
- this.startedAt = this.clock.now();
2118
- if (presentMethod) this.presentMethod = presentMethod;
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 };
2269
+
2270
+ constructor(stats: ConversationListInitialRenderStats | null, visible: boolean) {
2271
+ super(stats, visible);
2272
+ this.stats?.startTimer(DurationKey.PAGE_RENDER);
2119
2273
  }
2120
2274
 
2121
- onAuthStart(): void {
2122
- if (this.committed) {
2123
- this.clearMeasurementState();
2124
- this.renderedCommitted = false;
2125
- }
2126
- this.authDuration = null;
2127
- 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;
2128
2283
  }
2129
2284
 
2130
- onAuthComplete(): void {
2131
- if (this.committed || this.authDuration !== null) return;
2132
- if (this.authStartedAt === null) {
2133
- 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;
2134
2292
  return;
2135
2293
  }
2136
- this.authDuration = this.clock.now() - this.authStartedAt;
2137
- this.authStartedAt = null;
2294
+
2295
+ this.stats.stopTimer(DurationKey.GET_LIST);
2296
+ this.measure.listCompleted = true;
2138
2297
  }
2139
2298
 
2140
- markListRequestStart(): void {
2141
- if (this.committed) return;
2142
- if (this.startedAt === null) this.start();
2143
- 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();
2144
2304
  }
2145
2305
 
2146
- markListRequestComplete(): void {
2147
- if (this.committed || this.listStartedAt === null) return;
2148
- 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;
2149
2311
  }
2150
2312
 
2151
- markRendered(): void {
2152
- if (this.committed || this.startedAt === null) return;
2153
- this.committed = true;
2154
- append(this.callback, METRIC_KEY_CONVERSATION_LIST_INITIAL_RENDER, this.clock.now() - this.startedAt, {
2155
- extra: {
2156
- ...(this.authDuration !== null && { auth_duration_ms: this.authDuration }),
2157
- ...(this.listDuration !== null && { get_list_duration_ms: this.listDuration }),
2158
- present_method: this.presentMethod,
2159
- },
2160
- });
2313
+ /** Auth settled while this span was measuring; a finalize that waited on in-flight auth can proceed. */
2314
+ onAuthSettled(): void {
2315
+ this.tryFinalize();
2161
2316
  }
2162
2317
 
2163
- clear(): void {
2164
- this.clearMeasurementState();
2165
- this.clearAuthState();
2166
- 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();
2167
2327
  }
2328
+ }
2168
2329
 
2169
- private clearMeasurementState(): void {
2170
- this.startedAt = null;
2171
- this.listStartedAt = null;
2172
- this.listDuration = null;
2173
- this.committed = false;
2174
- this.measurement = createEmptyProgress();
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';
2396
+
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;
2175
2405
  }
2176
2406
 
2177
- private clearAuthState(): void {
2178
- this.authStartedAt = null;
2179
- this.authDuration = null;
2407
+ private getActiveStats(): ConversationListInitialRenderStats | null {
2408
+ if (!this.stats || this.stats.isCommitted()) return null;
2409
+ return this.stats;
2180
2410
  }
2181
2411
 
2182
- private startMeasurement(): void {
2183
- if (this.measurement.initialized) return;
2412
+ setPresentMethod(method: PresentMethod): void {
2413
+ this.presentMethod = method;
2414
+ this.getActiveStats()?.setPresentMethod(method);
2415
+ }
2184
2416
 
2185
- this.clearMeasurementState();
2186
- this.start();
2187
- this.measurement.initialized = true;
2417
+ onAuthStart(): void {
2418
+ this.authState = 'in_progress';
2419
+ this.getOrCreateStats()?.startTimer(DurationKey.AUTH);
2188
2420
  }
2189
2421
 
2190
- private markListRequestStartOnce(): void {
2191
- this.startMeasurement();
2192
- 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);
2193
2426
 
2194
- this.markListRequestStart();
2195
- this.measurement.listStarted = true;
2427
+ this.activeSpan?.onAuthSettled();
2196
2428
  }
2197
2429
 
2198
- private markListRequestCompleteOnce(): void {
2199
- this.markListRequestStartOnce();
2200
- 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);
2201
2443
 
2202
- this.markListRequestComplete();
2203
- this.measurement.listCompleted = true;
2444
+ stats.setError(error).commit();
2445
+ this.activeSpan.cancel();
2204
2446
  }
2205
2447
 
2206
- private markRenderedOnce(): void {
2207
- if (this.renderedCommitted) return;
2448
+ startSpan(): ConversationListInitialRenderStatsSpan {
2449
+ const superseded = this.activeSpan;
2450
+ superseded?.cancel();
2208
2451
 
2209
- this.startMeasurement();
2210
- if (this.measurement.rendered) return;
2452
+ const stats = this.getOrCreateStats();
2453
+ if (stats && superseded) stats.resetPageRenderSpan();
2454
+ if (stats && this.authState === 'in_progress') stats.startTimer(DurationKey.AUTH);
2455
+
2456
+ this.activeSpan = new ConversationListInitialRenderStatsSpan(stats, this.visible);
2457
+ return this.activeSpan;
2458
+ }
2211
2459
 
2212
- this.markRendered();
2213
- this.measurement.rendered = true;
2214
- this.renderedCommitted = true;
2460
+ clear(): void {
2461
+ this.activeSpan?.cancel();
2462
+ this.activeSpan = null;
2463
+ this.stats = null;
2464
+ this.authState = 'idle';
2215
2465
  }
2216
2466
  }
2217
2467
 
@@ -2284,107 +2534,133 @@ export declare type ConversationListProps = PropsWithChildren<{
2284
2534
  conversationListFilter?: Partial<AIAgentGroupChannelFilter>;
2285
2535
  onOpenConversationView?: (channelUrl: string, status: 'open' | 'closed', options?: ConversationListOpenOptions) => void;
2286
2536
  announcementsEnabled?: boolean;
2537
+ /** @default true */
2287
2538
  conversationListStatsEnabled?: boolean;
2288
2539
  /** Custom styles for the conversation list container. */
2289
2540
  style?: CSSProperties;
2290
2541
  }>;
2291
2542
 
2292
- declare type ConversationMessageEvent = ConversationMessageEventPayload & { id: number };
2293
-
2294
- declare type ConversationMessageEventPayload =
2295
- | {
2296
- kind: 'messagesAdded';
2297
- source: ChatSDKCollectionEventSource;
2298
- messages: ChatSDKBaseMessage[];
2299
- }
2300
- | {
2301
- kind: 'messagesUpdated';
2302
- source: ChatSDKCollectionEventSource;
2303
- messages: ChatSDKBaseMessage[];
2304
- }
2305
- | {
2306
- kind: 'initialized';
2307
- source: 'cache' | 'api';
2308
- messages: ChatSDKBaseMessage[];
2309
- };
2310
-
2311
- declare type ConversationNotInteractiveMeasurement = {
2312
- timer: TimeoutId | null;
2313
- startedAt: number;
2314
- committed: boolean;
2315
- inputEnabled: boolean;
2316
- phaseReached: ConversationNotInteractivePhase;
2317
- historyLoadResult: HistoryLoadResult;
2318
- conversationId?: number;
2543
+ declare type ConversationNotInteractiveMeasure = {
2319
2544
  channelUrl?: string;
2545
+ historyLoadResult: HistoryLoadResult;
2546
+ inputEnabled: boolean;
2547
+ timer: TimeoutId | null;
2320
2548
  };
2321
2549
 
2322
2550
  declare type ConversationNotInteractivePhase = 'auth' | 'channel_join' | 'history_load' | 'input_enable' | 'render';
2323
2551
 
2324
- declare class ConversationNotInteractiveTimeoutStatsTracker {
2325
- private readonly callback: StatsAppendCallback;
2326
- private readonly clock: StatsClock;
2327
- private readonly setTimeoutFn: typeof setTimeout;
2328
- private readonly clearTimeoutFn: typeof clearTimeout;
2329
- private readonly measurements = new Map<string, ConversationNotInteractiveMeasurement>();
2552
+ declare class ConversationNotInteractiveTimeoutStats extends AIAgentBaseStats {
2553
+ private phaseReached: ConversationNotInteractivePhase = 'auth';
2554
+ private historyLoadResult: HistoryLoadResult = 'pending';
2330
2555
 
2331
- constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
2332
- this.callback = callback;
2333
- this.clock = getClock(options);
2334
- this.setTimeoutFn = getSetTimeout(options);
2335
- this.clearTimeoutFn = getClearTimeout(options);
2556
+ setHistoryLoadResult(result: HistoryLoadResult): this {
2557
+ this.historyLoadResult = result;
2558
+ return this;
2336
2559
  }
2337
2560
 
2338
- start(params?: { channelUrl?: string; conversationId?: number }): void {
2339
- const key = this.getKey(params?.channelUrl);
2340
- this.clearKey(key);
2341
-
2342
- const measurement = {
2343
- startedAt: this.clock.now(),
2344
- channelUrl: params?.channelUrl,
2345
- conversationId: params?.conversationId,
2346
- committed: false,
2347
- inputEnabled: false,
2348
- phaseReached: 'auth' as ConversationNotInteractivePhase,
2349
- historyLoadResult: 'pending' as HistoryLoadResult,
2350
- timer: null as TimeoutId | null,
2351
- };
2352
- measurement.timer = this.setTimeoutFn(() => this.commitTimeout(key), 10_000);
2353
- this.measurements.set(key, measurement);
2561
+ setPhaseReached(phase: ConversationNotInteractivePhase): this {
2562
+ this.phaseReached = phase;
2563
+ return this;
2354
2564
  }
2355
2565
 
2356
- markChannelJoined(conversationId?: number, channelUrl?: string): void {
2357
- const measurement = this.getMeasurement(channelUrl);
2358
- if (!measurement || measurement.committed) return;
2359
- measurement.phaseReached = 'channel_join';
2360
- if (conversationId !== undefined) measurement.conversationId = conversationId;
2566
+ protected getMetricKey(): string {
2567
+ return METRIC_KEY_CONVERSATION_NOT_INTERACTIVE_TIMEOUT;
2361
2568
  }
2362
2569
 
2363
- markHistoryLoaded(result: HistoryLoadResult, channelUrl?: string): void {
2364
- const measurement = this.getMeasurement(channelUrl);
2365
- if (!measurement || measurement.committed) return;
2366
- measurement.phaseReached = 'history_load';
2367
- measurement.historyLoadResult = result;
2368
- this.cancelIfReady(channelUrl);
2369
- }
2570
+ protected buildPayload(): AIAgentStatPayload | null {
2571
+ const duration = this.getTimer(DurationKey.TIMEOUT).duration;
2572
+ if (duration === null) return null;
2370
2573
 
2371
- markInputEnabled(channelUrl?: string): void {
2372
- const measurement = this.getMeasurement(channelUrl);
2373
- if (!measurement || measurement.committed) return;
2374
- measurement.phaseReached = 'input_enable';
2375
- measurement.inputEnabled = true;
2376
- this.cancelIfReady(channelUrl);
2377
- }
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);
2651
+ }
2378
2652
 
2379
2653
  markRendered(channelUrl?: string): void {
2380
- const measurement = this.getMeasurement(channelUrl);
2381
- if (!measurement || measurement.committed) return;
2382
- measurement.phaseReached = 'render';
2654
+ const record = this.getRecord(channelUrl);
2655
+ if (!record) return;
2656
+ record.stats.setPhaseReached('render');
2383
2657
  }
2384
2658
 
2385
2659
  clear(): void {
2386
- this.measurements.forEach((measurement) => clearTimer(measurement.timer, this.clearTimeoutFn));
2387
- this.measurements.clear();
2660
+ this.records.forEach((record) => {
2661
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
2662
+ });
2663
+ this.records.clear();
2388
2664
  }
2389
2665
 
2390
2666
  clearChannel(channelUrl?: string): void {
@@ -2393,39 +2669,32 @@ declare class ConversationNotInteractiveTimeoutStatsTracker {
2393
2669
  }
2394
2670
 
2395
2671
  private clearKey(key: string): void {
2396
- const measurement = this.measurements.get(key);
2397
- if (!measurement) return;
2398
- clearTimer(measurement.timer, this.clearTimeoutFn);
2399
- 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);
2400
2676
  }
2401
2677
 
2402
2678
  private cancelIfReady(channelUrl?: string): void {
2403
- const measurement = this.getMeasurement(channelUrl);
2404
- if (!measurement || !measurement.inputEnabled || measurement.historyLoadResult === 'pending') return;
2405
- 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));
2406
2682
  }
2407
2683
 
2408
2684
  private commitTimeout(key: string): void {
2409
- const measurement = this.measurements.get(key);
2410
- if (!measurement || measurement.committed) return;
2411
- measurement.committed = true;
2412
- measurement.timer = null;
2413
- append(this.callback, METRIC_KEY_CONVERSATION_NOT_INTERACTIVE_TIMEOUT, this.clock.now() - measurement.startedAt, {
2414
- conversationId: measurement.conversationId,
2415
- error: { message: METRIC_KEY_CONVERSATION_NOT_INTERACTIVE_TIMEOUT },
2416
- extra: {
2417
- outcome: 'timeout',
2418
- phase_reached: measurement.phaseReached,
2419
- history_load_result: measurement.historyLoadResult,
2420
- },
2421
- });
2422
- 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();
2423
2690
  }
2424
2691
 
2425
- private getMeasurement(channelUrl?: string): ConversationNotInteractiveMeasurement | undefined {
2426
- if (channelUrl !== undefined) return this.measurements.get(this.getKey(channelUrl));
2427
- if (this.measurements.size === 1) return Array.from(this.measurements.values())[0];
2428
- 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());
2429
2698
  }
2430
2699
 
2431
2700
  private getKey(channelUrl?: string): string {
@@ -2441,6 +2710,7 @@ export declare type ConversationProps = PropsWithChildren<{
2441
2710
  /** @internal */
2442
2711
  shouldInitializeConversation?: boolean;
2443
2712
  announcementsEnabled?: boolean;
2713
+ /** @default true */
2444
2714
  conversationStatsEnabled?: boolean;
2445
2715
  initialFocusTarget?: ConversationInitialFocusTarget;
2446
2716
  /** Custom styles for the conversation container. */
@@ -2638,11 +2908,20 @@ declare interface Dispatcher {
2638
2908
  }
2639
2909
 
2640
2910
  declare enum DurationKey {
2911
+ ACK_TO_FIRST_CHUNK = 'ack_to_first_chunk_ms',
2912
+ AFTER_TPEN_ABSENT = 'after_tpen_absent_ms',
2641
2913
  AUTH = 'auth_duration_ms',
2914
+ BEFORE_TPST_ABSENT = 'before_tpst_absent_ms',
2915
+ FIRST_CHUNK_TO_RENDER = 'first_chunk_to_render_ms',
2642
2916
  GET_CHANNEL = 'get_channel_duration_ms',
2917
+ GET_LIST = 'get_list_duration_ms',
2643
2918
  GET_MESSAGES = 'get_messages_duration_ms',
2644
- TOTAL_DURATION = 'total_duration_ms',
2645
- 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',
2646
2925
  }
2647
2926
 
2648
2927
  declare type ErrorAnnouncementParams =
@@ -2651,8 +2930,6 @@ declare type ErrorAnnouncementParams =
2651
2930
  | { type: 'csatFormError' }
2652
2931
  | { type: 'generic'; message: string };
2653
2932
 
2654
- declare type ErrorLike = StatsPayloadError;
2655
-
2656
2933
  /**
2657
2934
  * For better understanding: https://sendbird.atlassian.net/wiki/spaces/AA/pages/3075014695/Extended+Message+Payload+Spec
2658
2935
  */
@@ -2821,16 +3098,6 @@ export declare interface FunctionCallsInfo {
2821
3098
  };
2822
3099
  }
2823
3100
 
2824
- declare type GetChannelCompleteParams = {
2825
- channelUrl?: string;
2826
- conversationId?: number;
2827
- };
2828
-
2829
- declare type GetChannelErrorParams = {
2830
- channelUrl?: string;
2831
- error: Error;
2832
- };
2833
-
2834
3101
  export declare interface GroundednessInfo {
2835
3102
  id: number;
2836
3103
  source_type:
@@ -5210,92 +5477,139 @@ export declare const MessageListUILayout: {
5210
5477
 
5211
5478
  export declare const MessageLogs: ({ actionbook, functionCalls, groundedness, agentMessageTemplates, flaggedTypes, onClickActionbook, onClickFunctionCall, onClickFunctionCallDetail, onClickGroundedness, onClickAgentMessageTemplate, topContent, bottomContent, renderCustomGroundednessIcon, style, }: Props_2) => JSX.Element;
5212
5479
 
5213
- declare class MessagePendingStuckStatsTracker {
5214
- private readonly callback: StatsAppendCallback;
5215
- private readonly setTimeoutFn: typeof setTimeout;
5216
- private readonly clearTimeoutFn: typeof clearTimeout;
5217
- 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;
5218
5484
 
5219
- constructor(callback: StatsAppendCallback, options?: PendingStuckTrackerOptions) {
5220
- this.callback = callback;
5221
- this.setTimeoutFn = getSetTimeout(options);
5222
- 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;
5223
5502
  }
5224
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> {
5225
5539
  start(params: {
5226
5540
  requestId: string;
5227
5541
  channelUrl?: string;
5228
5542
  messageType: PendingMessageType;
5229
5543
  tracksUploadProgress?: boolean;
5230
5544
  }): void {
5231
- if (!params.requestId || this.measurements.has(params.requestId)) return;
5545
+ if (!params.requestId || this.records.has(params.requestId)) return;
5232
5546
 
5233
5547
  const usesUploadProgress = params.messageType !== 'text' && params.tracksUploadProgress === true;
5234
- const measurement: PendingMeasurement = {
5235
- channelUrl: params.channelUrl,
5236
- messageType: params.messageType,
5237
- stuckReason: usesUploadProgress ? 'upload_no_progress' : 'pending_too_long',
5238
- 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
+ },
5239
5560
  };
5240
5561
  const timeoutMs = usesUploadProgress ? FILE_UPLOAD_NO_PROGRESS_TIMEOUT_MS : TEXT_PENDING_TIMEOUT_MS;
5241
- measurement.timer = this.setTimeoutFn(() => this.detect(params.requestId), timeoutMs);
5242
- this.measurements.set(params.requestId, measurement);
5562
+ record.measure.timer = setTimeout(() => this.detect(params.requestId), timeoutMs);
5563
+ this.records.set(params.requestId, record);
5243
5564
  }
5244
5565
 
5245
5566
  resolve(params: { requestId: string }): void {
5246
- const measurement = this.measurements.get(params.requestId);
5247
- if (!measurement) return;
5248
-
5249
- clearTimer(measurement.timer, this.clearTimeoutFn);
5250
- this.measurements.delete(params.requestId);
5567
+ this.cancel(params.requestId);
5251
5568
  }
5252
5569
 
5253
5570
  disappear(params: { requestId: string }): void {
5254
- const measurement = this.measurements.get(params.requestId);
5255
- 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;
5256
5577
 
5257
- clearTimer(measurement.timer, this.clearTimeoutFn);
5258
- this.measurements.delete(params.requestId);
5578
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
5579
+ this.records.delete(requestId);
5259
5580
  }
5260
5581
 
5261
5582
  markUploadProgress(requestId: string): void {
5262
- const measurement = this.measurements.get(requestId);
5263
- if (!measurement) return;
5264
- if (measurement.stuckReason !== 'upload_no_progress') return;
5583
+ const record = this.records.get(requestId);
5584
+ if (!record) return;
5585
+ if (!record.measure.usesUploadProgress) return;
5265
5586
 
5266
- clearTimer(measurement.timer, this.clearTimeoutFn);
5267
- 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);
5268
5589
  }
5269
5590
 
5270
5591
  clear(): void {
5271
- this.measurements.forEach((measurement) => clearTimer(measurement.timer, this.clearTimeoutFn));
5272
- this.measurements.clear();
5592
+ this.records.forEach((record) => {
5593
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
5594
+ });
5595
+ this.records.clear();
5273
5596
  }
5274
5597
 
5275
5598
  clearChannel(channelUrl?: string): void {
5276
5599
  if (!channelUrl) return;
5277
- this.measurements.forEach((measurement, requestId) => {
5278
- if (measurement.channelUrl !== channelUrl) return;
5279
- clearTimer(measurement.timer, this.clearTimeoutFn);
5280
- 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);
5281
5604
  });
5282
5605
  }
5283
5606
 
5284
5607
  private detect(requestId: string): void {
5285
- const measurement = this.measurements.get(requestId);
5286
- if (!measurement) return;
5287
-
5288
- this.measurements.delete(requestId);
5289
- this.commit(requestId, measurement);
5290
- }
5608
+ const record = this.records.get(requestId);
5609
+ if (!record) return;
5291
5610
 
5292
- private commit(requestId: string, measurement: PendingMeasurement): void {
5293
- append(this.callback, METRIC_KEY_MESSAGE_PENDING_STUCK, measurement.stuckReason, {
5294
- extra: {
5295
- message_type: measurement.messageType,
5296
- request_id: requestId,
5297
- },
5298
- });
5611
+ this.records.delete(requestId);
5612
+ record.stats.commit();
5299
5613
  }
5300
5614
  }
5301
5615
 
@@ -5515,6 +5829,10 @@ declare type MultiSelectField = {
5515
5829
  };
5516
5830
  };
5517
5831
 
5832
+ declare abstract class MultiStatsTrackerBase<TStats, TMeasure> extends StatsTrackerBase {
5833
+ protected readonly records = new Map<string | number, StatsRecord<TStats, TMeasure>>();
5834
+ }
5835
+
5518
5836
  declare interface NetworkStateAdapter {
5519
5837
  /** Returns true if network is currently online */
5520
5838
  isOnline: () => boolean;
@@ -5665,6 +5983,14 @@ declare type OutgoingMessageUnion =
5665
5983
 
5666
5984
  declare function OutgoingTextBody(props: OutgoingMessageBodyProps): JSX.Element;
5667
5985
 
5986
+ declare type PageRenderState =
5987
+ | 'initialized'
5988
+ | 'channel_fetching'
5989
+ | 'channel_fetched'
5990
+ | 'channel_failed'
5991
+ | 'messages_fetching'
5992
+ | 'messages_fetching_completed';
5993
+
5668
5994
  declare interface PaletteColor {
5669
5995
  extraDark: string;
5670
5996
  dark: string;
@@ -5713,19 +6039,16 @@ declare type PendingAIResponseStart = {
5713
6039
  startedAt: number;
5714
6040
  };
5715
6041
 
5716
- declare type PendingMeasurement = {
6042
+ declare type PendingMeasure = {
5717
6043
  channelUrl?: string;
5718
- messageType: PendingMessageType;
5719
- stuckReason: PendingStuckReason;
5720
6044
  timer: TimeoutId | null;
6045
+ usesUploadProgress: boolean;
5721
6046
  };
5722
6047
 
5723
6048
  declare type PendingMessageType = 'text' | 'file_image' | 'file_video' | 'file_document' | 'file_audio';
5724
6049
 
5725
6050
  declare type PendingStuckReason = 'pending_too_long' | 'upload_no_progress';
5726
6051
 
5727
- declare type PendingStuckTrackerOptions = Pick<TimedTrackerOptions, 'setTimeout' | 'clearTimeout'>;
5728
-
5729
6052
  declare type PickMessageProps<
5730
6053
  Union,
5731
6054
  T extends Union extends { messageType: infer MT } ? MT : never = Union extends { messageType: infer MT } ? MT : never,
@@ -5863,74 +6186,113 @@ declare type Props_2 = {
5863
6186
 
5864
6187
  declare type ReactOnlyIconName = 'expand' | 'collapse' | 'chevron-right' | 'attach' | 'close-filled' | 'actionbook' | 'function' | 'confluence' | 'zendesk' | 'salesforce' | 'sprinklr' | 'website' | 'snippet' | 'template' | 'show' | 'mute' | 'activity';
5865
6188
 
5866
- declare type RecoveryMeasurement = {
5867
- timer: TimeoutId | null;
5868
- startedAt: number | null;
5869
- triggerType: RecoveryTriggerType | null;
6189
+ declare type RecoveryMeasure = {
5870
6190
  inputEnabled: boolean;
6191
+ started: boolean;
6192
+ timer: TimeoutId | null;
5871
6193
  };
5872
6194
 
5873
- declare class RecoveryNotInteractiveTimeoutStatsTracker {
5874
- private readonly callback: StatsAppendCallback;
5875
- private readonly clock: StatsClock;
5876
- private readonly setTimeoutFn: typeof setTimeout;
5877
- private readonly clearTimeoutFn: typeof clearTimeout;
5878
- private readonly measurements = new Map<string, RecoveryMeasurement>();
6195
+ declare class RecoveryNotInteractiveTimeoutStats extends AIAgentBaseStats {
6196
+ private triggerType?: RecoveryTriggerType;
5879
6197
 
5880
- constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
5881
- this.callback = callback;
5882
- this.clock = getClock(options);
5883
- this.setTimeoutFn = getSetTimeout(options);
5884
- 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;
5885
6205
  }
5886
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
+ > {
5887
6245
  start(triggerType: RecoveryTriggerType, channelUrl?: string): void {
5888
6246
  const key = this.getKey(channelUrl);
5889
- let measurement = this.measurements.get(key);
5890
- if (measurement && measurement.startedAt !== null) return;
5891
- if (measurement?.inputEnabled) return;
5892
-
5893
- measurement = {
5894
- startedAt: this.clock.now(),
5895
- triggerType,
5896
- inputEnabled: measurement?.inputEnabled ?? false,
5897
- timer: this.setTimeoutFn(() => this.commitTimeout(key), 10_000),
5898
- };
5899
- 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);
5900
6262
  }
5901
6263
 
5902
6264
  setInputEnabled(enabled: boolean, channelUrl?: string): void {
5903
6265
  const key = this.getKey(channelUrl);
5904
- let measurement = this.measurements.get(key);
6266
+ let record = this.records.get(key);
5905
6267
 
5906
- if (!measurement) {
6268
+ if (!record) {
5907
6269
  if (!enabled) return;
5908
- measurement = {
5909
- startedAt: null,
5910
- triggerType: null,
5911
- inputEnabled: enabled,
5912
- timer: null,
5913
- };
5914
- this.measurements.set(key, measurement);
6270
+ record = this.createRecord(enabled);
6271
+ this.records.set(key, record);
6272
+ return;
5915
6273
  }
5916
6274
 
5917
- measurement.inputEnabled = enabled;
6275
+ record.measure.inputEnabled = enabled;
5918
6276
  if (enabled) {
5919
- this.cancelMeasurement(measurement);
6277
+ this.cancelRecord(record);
6278
+ } else if (!record.measure.started) {
6279
+ this.records.delete(key);
5920
6280
  }
5921
6281
  }
5922
6282
 
5923
6283
  cancelPending(channelUrl: string): void {
5924
6284
  const key = this.getKey(channelUrl);
5925
- const measurement = this.measurements.get(key);
5926
- if (!measurement) return;
5927
- this.cancelMeasurement(measurement);
5928
- 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);
5929
6289
  }
5930
6290
 
5931
6291
  clear(): void {
5932
- this.measurements.forEach((measurement) => clearTimer(measurement.timer, this.clearTimeoutFn));
5933
- this.measurements.clear();
6292
+ this.records.forEach((record) => {
6293
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
6294
+ });
6295
+ this.records.clear();
5934
6296
  }
5935
6297
 
5936
6298
  clearChannel(channelUrl?: string): void {
@@ -5939,35 +6301,41 @@ declare class RecoveryNotInteractiveTimeoutStatsTracker {
5939
6301
  }
5940
6302
 
5941
6303
  private clearKey(key: string): void {
5942
- const measurement = this.measurements.get(key);
5943
- if (!measurement) return;
5944
- clearTimer(measurement.timer, this.clearTimeoutFn);
5945
- 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);
5946
6308
  }
5947
6309
 
5948
6310
  private commitTimeout(key: string): void {
5949
- const measurement = this.measurements.get(key);
5950
- if (!measurement || measurement.startedAt === null || measurement.triggerType === null) return;
5951
- if (measurement.inputEnabled) {
5952
- 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);
5953
6315
  return;
5954
6316
  }
5955
- const duration = this.clock.now() - measurement.startedAt;
5956
- const triggerType = measurement.triggerType;
6317
+ record.stats.stopTimer(DurationKey.TIMEOUT);
5957
6318
  // Drop the entry so a subsequent trigger can record a fresh cycle.
5958
- this.measurements.delete(key);
5959
- append(this.callback, METRIC_KEY_RECOVERY_NOT_INTERACTIVE_TIMEOUT, duration, {
5960
- extra: {
5961
- trigger_type: triggerType,
5962
- },
5963
- });
6319
+ this.records.delete(key);
6320
+ record.stats.commit();
5964
6321
  }
5965
6322
 
5966
- private cancelMeasurement(measurement: RecoveryMeasurement): void {
5967
- clearTimer(measurement.timer, this.clearTimeoutFn);
5968
- measurement.timer = null;
5969
- measurement.startedAt = null;
5970
- 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
+ };
5971
6339
  }
5972
6340
 
5973
6341
  private getKey(channelUrl?: string): string {
@@ -5987,36 +6355,69 @@ declare type ReleaseDisabledByValue =
5987
6355
  | 'reconnecting'
5988
6356
  | 'handoff_pending';
5989
6357
 
5990
- declare class RenderedContentUnusableStatsTracker {
5991
- private readonly callback: StatsAppendCallback;
5992
- private readonly committed = new Set<string>();
6358
+ declare type RenderedContentError = {
6359
+ code?: number;
6360
+ message?: string;
6361
+ };
5993
6362
 
5994
- constructor(callback: StatsAppendCallback) {
5995
- this.callback = callback;
5996
- }
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
+ };
5997
6397
 
5998
6398
  commit(params: {
5999
6399
  messageId: string | number;
6000
- contentKind: 'text' | 'image' | 'video' | 'audio' | 'template' | 'form';
6001
- errorDomain: 'empty_render' | 'template_error' | 'form_error';
6002
- error?: ErrorLike;
6400
+ contentKind: RenderedContentKind;
6401
+ errorDomain: RenderedContentErrorDomain;
6402
+ error?: RenderedContentError;
6003
6403
  }): void {
6004
6404
  const key = `${params.messageId}:${params.contentKind}:${params.errorDomain}`;
6005
- if (this.committed.has(key)) return;
6006
- evictOldestIfAtLimit(this.committed, RENDERED_CONTENT_UNUSABLE_COMMITTED_LIMIT);
6007
- 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);
6008
6408
 
6009
- append(this.callback, METRIC_KEY_RENDERED_CONTENT_UNUSABLE, params.errorDomain, {
6010
- error: params.error ? { code: params.error.code, message: params.errorDomain } : undefined,
6011
- extra: {
6012
- content_kind: params.contentKind,
6013
- message_id: String(params.messageId),
6014
- },
6015
- });
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();
6016
6417
  }
6017
6418
 
6018
6419
  clear(): void {
6019
- this.committed.clear();
6420
+ this.measure.committed.clear();
6020
6421
  }
6021
6422
  }
6022
6423
 
@@ -6059,17 +6460,95 @@ declare type SingleSelectField = {
6059
6460
  layout: 'default';
6060
6461
  };
6061
6462
 
6062
- 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;
6063
6470
 
6064
- declare interface StatsClock {
6065
- now: () => number;
6471
+ setVisible(visible: boolean): void {
6472
+ this.visible = visible;
6473
+ this.activeSpan?.onVisibilityChanged(visible);
6474
+ }
6066
6475
  }
6067
6476
 
6068
- declare type StatsPayloadError = {
6069
- code?: number;
6070
- message?: string;
6477
+ declare type StatsAppendCallback = (type: string, data: AIAgentStatPayload) => boolean;
6478
+
6479
+ declare type StatsPayloadParams = {
6480
+ conversationId?: number;
6481
+ extra?: Record<string, unknown>;
6071
6482
  };
6072
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
+
6073
6552
  /** Per-event override for the platform `announceStatus` wrappers — same shape as `AnnounceOptions`. */
6074
6553
  declare type StatusAnnouncementOptions = AnnounceOptions;
6075
6554
 
@@ -6123,21 +6602,76 @@ declare enum StewardTaskStatus {
6123
6602
  CANCELED = 'CANCELED',
6124
6603
  }
6125
6604
 
6126
- declare class StreamStalledStatsTracker {
6127
- private readonly callback: StatsAppendCallback;
6128
- private readonly clock: StatsClock;
6129
- private readonly setTimeoutFn: typeof setTimeout;
6130
- private readonly clearTimeoutFn: typeof clearTimeout;
6131
- private readonly streams = new Map<number, StreamState>();
6132
- 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;
6133
6619
 
6134
- constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
6135
- this.callback = callback;
6136
- this.clock = getClock(options);
6137
- this.setTimeoutFn = getSetTimeout(options);
6138
- this.clearTimeoutFn = getClearTimeout(options);
6620
+ setMessageId(messageId: number): this {
6621
+ this.messageId = messageId;
6622
+ return this;
6139
6623
  }
6140
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
+
6141
6675
  markRawProgress(params: {
6142
6676
  messageId: number;
6143
6677
  channelUrl?: string;
@@ -6151,54 +6685,52 @@ declare class StreamStalledStatsTracker {
6151
6685
 
6152
6686
  if (this.emittedMessageChannels.has(params.messageId)) return;
6153
6687
 
6154
- const stream = this.getOrCreate(params.messageId, params.channelUrl);
6688
+ const record = this.getOrCreate(params.messageId, params.channelUrl);
6689
+ const measure = record.measure;
6155
6690
 
6156
- if (params.textLength > stream.lastRawLength) {
6157
- stream.chunksReceived += 1;
6158
- stream.lastRawLength = params.textLength;
6159
- stream.lastRawAt = this.clock.now();
6160
-
6161
- clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6162
- stream.deliveryTimer = null;
6163
- 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);
6164
6695
  } else {
6165
- this.scheduleDeliveryStallCheck(params.messageId, stream);
6696
+ this.scheduleDeliveryStallCheck(params.messageId, record);
6166
6697
  }
6167
6698
 
6168
- if (stream.lastRenderedLength < stream.lastRawLength && stream.renderTimer === null) {
6169
- stream.lastRenderAt = this.clock.now();
6170
- 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);
6171
6702
  }
6172
6703
  }
6173
6704
 
6174
6705
  markRenderedProgress(params: { messageId: number; displayedLength: number }): void {
6175
- const stream = this.streams.get(params.messageId);
6176
- if (!stream || params.displayedLength <= stream.lastRenderedLength) return;
6706
+ const record = this.records.get(params.messageId);
6707
+ if (!record) return;
6708
+ const measure = record.measure;
6177
6709
 
6178
- stream.lastRenderedLength = params.displayedLength;
6179
- stream.lastRenderAt = this.clock.now();
6710
+ const progressed = this.markRenderedProgressForRecord(record, params.displayedLength, performance.now());
6711
+ if (!progressed) return;
6180
6712
 
6181
- if (stream.lastRenderedLength >= stream.lastRawLength) {
6182
- if (stream.deliveryTimer === null) {
6183
- this.clearStream(params.messageId, stream);
6713
+ if (!this.isRenderPending(record)) {
6714
+ if (measure.deliveryTimer === null) {
6715
+ this.clearRecord(params.messageId, record);
6184
6716
  return;
6185
6717
  }
6186
6718
 
6187
- clearTimer(stream.renderTimer, this.clearTimeoutFn);
6188
- stream.renderTimer = null;
6719
+ if (measure.renderTimer !== null) clearTimeout(measure.renderTimer);
6720
+ measure.renderTimer = null;
6189
6721
  return;
6190
6722
  }
6191
6723
 
6192
- this.scheduleRenderStallCheck(params.messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS);
6724
+ this.scheduleRenderStallCheck(params.messageId, record, STREAM_RENDER_STALL_THRESHOLD_MS);
6193
6725
  }
6194
6726
 
6195
6727
  hasPendingStream(messageId: number): boolean {
6196
- return this.streams.has(messageId);
6728
+ return this.records.has(messageId);
6197
6729
  }
6198
6730
 
6199
6731
  complete(messageId: number): void {
6200
- const stream = this.streams.get(messageId);
6201
- if (stream) this.clearStream(messageId, stream);
6732
+ const record = this.records.get(messageId);
6733
+ if (record) this.clearRecord(messageId, record);
6202
6734
  }
6203
6735
 
6204
6736
  // Final stream update: no more raw chunks are coming, so stop delivery-stall
@@ -6208,52 +6740,53 @@ declare class StreamStalledStatsTracker {
6208
6740
  // raw length — otherwise a renderer that freezes mid-catch-up never emits a
6209
6741
  // 'render' stall.
6210
6742
  private completeDelivery(messageId: number, finalRawLength: number): void {
6211
- const stream = this.streams.get(messageId);
6212
- if (!stream) return;
6743
+ const record = this.records.get(messageId);
6744
+ if (!record) return;
6745
+ const measure = record.measure;
6213
6746
 
6214
6747
  // The final stream:false payload can carry text beyond the last streamed
6215
6748
  // chunk; fold it into the raw length first so the render watch targets the
6216
6749
  // full text the animation still has to reveal (otherwise the stream could be
6217
6750
  // cleared while a freeze on that trailing text would go unreported).
6218
- if (finalRawLength > stream.lastRawLength) {
6219
- stream.lastRawLength = finalRawLength;
6220
- stream.lastRawAt = this.clock.now();
6751
+ if (finalRawLength > measure.lastRawLength) {
6752
+ measure.lastRawLength = finalRawLength;
6753
+ measure.lastRawAt = performance.now();
6221
6754
  }
6222
6755
 
6223
- clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6224
- stream.deliveryTimer = null;
6756
+ if (measure.deliveryTimer !== null) clearTimeout(measure.deliveryTimer);
6757
+ measure.deliveryTimer = null;
6225
6758
 
6226
- if (stream.lastRenderedLength >= stream.lastRawLength) {
6227
- this.clearStream(messageId, stream);
6759
+ if (!this.isRenderPending(record)) {
6760
+ this.clearRecord(messageId, record);
6228
6761
  return;
6229
6762
  }
6230
6763
 
6231
- if (stream.renderTimer === null) {
6232
- stream.lastRenderAt = this.clock.now();
6233
- 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);
6234
6767
  }
6235
6768
  }
6236
6769
 
6237
6770
  clearMessage(messageId: number): void {
6238
- const stream = this.streams.get(messageId);
6239
- if (stream) this.clearStream(messageId, stream);
6771
+ const record = this.records.get(messageId);
6772
+ if (record) this.clearRecord(messageId, record);
6240
6773
  this.emittedMessageChannels.delete(messageId);
6241
6774
  }
6242
6775
 
6243
6776
  clear(): void {
6244
- this.streams.forEach((stream) => {
6245
- clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6246
- 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);
6247
6780
  });
6248
- this.streams.clear();
6781
+ this.records.clear();
6249
6782
  this.emittedMessageChannels.clear();
6250
6783
  }
6251
6784
 
6252
6785
  clearChannel(channelUrl?: string): void {
6253
6786
  if (!channelUrl) return;
6254
- this.streams.forEach((stream, messageId) => {
6255
- if (stream.channelUrl !== channelUrl && stream.channelUrl !== undefined) return;
6256
- 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);
6257
6790
  });
6258
6791
  this.emittedMessageChannels.forEach((emittedChannelUrl, messageId) => {
6259
6792
  if (emittedChannelUrl === channelUrl || emittedChannelUrl === undefined)
@@ -6262,83 +6795,95 @@ declare class StreamStalledStatsTracker {
6262
6795
  }
6263
6796
 
6264
6797
  private detectDelivery(messageId: number): void {
6265
- const stream = this.streams.get(messageId);
6266
- if (!stream) return;
6267
- stream.deliveryTimer = null;
6798
+ const record = this.records.get(messageId);
6799
+ if (!record) return;
6800
+ const measure = record.measure;
6801
+ measure.deliveryTimer = null;
6268
6802
  // Mutex: cancel any in-flight render stall check so delivery wins this turn.
6269
- clearTimer(stream.renderTimer, this.clearTimeoutFn);
6270
- stream.renderTimer = null;
6271
- this.emit(messageId, stream, 'delivery');
6803
+ if (measure.renderTimer !== null) clearTimeout(measure.renderTimer);
6804
+ measure.renderTimer = null;
6805
+ this.emit(messageId, record, 'delivery');
6272
6806
  }
6273
6807
 
6274
6808
  private detectRender(messageId: number): void {
6275
- const stream = this.streams.get(messageId);
6276
- if (!stream) return;
6277
- stream.renderTimer = null;
6278
- 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;
6279
6814
 
6280
- const elapsedSinceRender = this.clock.now() - stream.lastRenderAt;
6815
+ const elapsedSinceRender = performance.now() - measure.lastRenderAt;
6281
6816
  if (elapsedSinceRender < STREAM_RENDER_STALL_THRESHOLD_MS) {
6282
- this.scheduleRenderStallCheck(messageId, stream, STREAM_RENDER_STALL_THRESHOLD_MS - elapsedSinceRender);
6817
+ this.scheduleRenderStallCheck(messageId, record, STREAM_RENDER_STALL_THRESHOLD_MS - elapsedSinceRender);
6283
6818
  return;
6284
6819
  }
6285
6820
 
6286
6821
  // Mutex rule: when raw progress has been silent for at least the render threshold,
6287
6822
  // delivery is the root cause and will win. Defer the render emit until delivery has
6288
6823
  // had a chance to fire so we don't surface render as the symptom.
6289
- const elapsedSinceRaw = this.clock.now() - stream.lastRawAt;
6290
- 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) {
6291
6826
  const untilDelivery = STREAM_DELIVERY_STALL_THRESHOLD_MS - elapsedSinceRaw;
6292
6827
  if (untilDelivery > 0) {
6293
- this.scheduleRenderStallCheck(messageId, stream, untilDelivery);
6828
+ this.scheduleRenderStallCheck(messageId, record, untilDelivery);
6294
6829
  return;
6295
6830
  }
6296
6831
  }
6297
6832
 
6298
- this.emit(messageId, stream, 'render');
6833
+ this.emit(messageId, record, 'render');
6299
6834
  }
6300
6835
 
6301
- private scheduleRenderStallCheck(messageId: number, stream: StreamState, delayMs: number): void {
6302
- if (stream.renderTimer !== null) return;
6303
- 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);
6304
6843
  }
6305
6844
 
6306
- private scheduleDeliveryStallCheck(messageId: number, stream: StreamState): void {
6307
- if (stream.deliveryTimer !== null) return;
6308
- 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);
6309
6848
  }
6310
6849
 
6311
- 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 {
6312
6855
  if (this.emittedMessageChannels.has(messageId)) return;
6313
6856
 
6314
- const stallStartedAt = stallKind === 'delivery' ? stream.lastRawAt : stream.lastRenderAt;
6315
- append(this.callback, METRIC_KEY_STREAM_STALLED, this.clock.now() - stallStartedAt, {
6316
- extra: {
6317
- message_id: String(messageId),
6318
- stall_kind: stallKind,
6319
- },
6320
- });
6321
- this.rememberEmitted(messageId, stream.channelUrl);
6322
- 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);
6323
6862
  }
6324
6863
 
6325
- private getOrCreate(messageId: number, channelUrl: string | undefined): StreamState {
6326
- 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);
6327
6869
  if (existing) return existing;
6328
6870
 
6329
- const now = this.clock.now();
6330
- const stream: StreamState = {
6331
- channelUrl,
6332
- chunksReceived: 0,
6333
- lastRawAt: now,
6334
- lastRenderAt: now,
6335
- lastRawLength: 0,
6336
- lastRenderedLength: 0,
6337
- deliveryTimer: null,
6338
- 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
+ },
6339
6884
  };
6340
- this.streams.set(messageId, stream);
6341
- return stream;
6885
+ this.records.set(messageId, record);
6886
+ return record;
6342
6887
  }
6343
6888
 
6344
6889
  private rememberEmitted(messageId: number, channelUrl: string | undefined): void {
@@ -6348,26 +6893,43 @@ declare class StreamStalledStatsTracker {
6348
6893
  this.emittedMessageChannels.set(messageId, channelUrl);
6349
6894
  }
6350
6895
 
6351
- private clearStream(messageId: number, stream: StreamState): void {
6352
- clearTimer(stream.deliveryTimer, this.clearTimeoutFn);
6353
- clearTimer(stream.renderTimer, this.clearTimeoutFn);
6354
- 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;
6355
6928
  }
6356
6929
  }
6357
6930
 
6358
6931
  declare type StreamStallKind = 'delivery' | 'render';
6359
6932
 
6360
- declare type StreamState = {
6361
- channelUrl?: string;
6362
- chunksReceived: number;
6363
- lastRawAt: number;
6364
- lastRenderAt: number;
6365
- lastRawLength: number;
6366
- lastRenderedLength: number;
6367
- deliveryTimer: TimeoutId | null;
6368
- renderTimer: TimeoutId | null;
6369
- };
6370
-
6371
6933
  /**
6372
6934
  * StringSet type for React components
6373
6935
  * Uses SNAKE_CASE keys for backward compatibility
@@ -6642,11 +7204,6 @@ declare type TextField = {
6642
7204
  };
6643
7205
  };
6644
7206
 
6645
- declare type TimedTrackerOptions = TrackerOptions & {
6646
- setTimeout?: typeof setTimeout;
6647
- clearTimeout?: typeof clearTimeout;
6648
- };
6649
-
6650
7207
  declare type TimeoutId = ReturnType<typeof setTimeout>;
6651
7208
 
6652
7209
  declare interface TimerData {
@@ -6654,42 +7211,94 @@ declare interface TimerData {
6654
7211
  endTime: number | null;
6655
7212
  }
6656
7213
 
6657
- declare type TrackerOptions = {
6658
- 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;
6659
7219
  };
6660
7220
 
6661
- declare class TypingIndicatorAbsentTimeoutStatsTracker {
6662
- private readonly callback: StatsAppendCallback;
6663
- private readonly clock: StatsClock;
6664
- private readonly setTimeoutFn: typeof setTimeout;
6665
- private readonly clearTimeoutFn: typeof clearTimeout;
6666
- private readonly turns = new Map<string, TypingTurn>();
6667
- 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
+ }
6668
7234
 
6669
- constructor(callback: StatsAppendCallback, options?: TimedTrackerOptions) {
6670
- this.callback = callback;
6671
- this.clock = getClock(options);
6672
- this.setTimeoutFn = getSetTimeout(options);
6673
- this.clearTimeoutFn = getClearTimeout(options);
7235
+ protected getMetricKey(): string {
7236
+ return METRIC_KEY_AI_TYPING_INDICATOR_ABSENT_TIMEOUT;
6674
7237
  }
6675
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
+
6676
7282
  startTurn(params: { requestId: string; channelUrl?: string }): void {
6677
7283
  this.cancelTurn(params.requestId);
6678
- const turn: TypingTurn = {
6679
- requestId: params.requestId,
6680
- channelUrl: params.channelUrl,
6681
- beforeTpstStartedAt: this.clock.now(),
6682
- afterTpenStartedAt: null,
6683
- 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
+ },
6684
7293
  };
6685
- turn.timer = this.setTimeoutFn(() => this.commitTimeout(turn.requestId), TYPING_COLLECTION_TIMEOUT_MS);
6686
- 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);
6687
7296
  }
6688
7297
 
6689
7298
  markUserMessageAck(params: { requestId: string; messageId: number }): void {
6690
- const turn = this.turns.get(params.requestId);
6691
- if (!turn) return;
6692
- turn.userMessageId = params.messageId;
7299
+ const record = this.records.get(params.requestId);
7300
+ if (!record) return;
7301
+ record.measure.userMessageId = params.messageId;
6693
7302
 
6694
7303
  const pending = this.pendingAIResponseStartsByUserMessageId.get(params.messageId);
6695
7304
  if (!pending) return;
@@ -6703,12 +7312,12 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6703
7312
  replaceTurnRequestId(currentRequestId: string, nextRequestId: string): void {
6704
7313
  if (currentRequestId === nextRequestId) return;
6705
7314
 
6706
- const turn = this.turns.get(currentRequestId);
6707
- if (!turn) return;
7315
+ const record = this.records.get(currentRequestId);
7316
+ if (!record) return;
6708
7317
 
6709
- this.turns.delete(currentRequestId);
6710
- turn.requestId = nextRequestId;
6711
- this.turns.set(nextRequestId, turn);
7318
+ this.records.delete(currentRequestId);
7319
+ record.measure.requestId = nextRequestId;
7320
+ this.records.set(nextRequestId, record);
6712
7321
  }
6713
7322
 
6714
7323
  cancelTurn(requestId: string): void {
@@ -6716,20 +7325,20 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6716
7325
  }
6717
7326
 
6718
7327
  markTypingStarted(channelUrl?: string): void {
6719
- const now = this.clock.now();
6720
- this.turns.forEach((turn) => {
6721
- if (!matchesChannel(turn, channelUrl)) return;
6722
- this.recordAfterTpen(turn, now);
6723
- 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);
6724
7333
  });
6725
7334
  }
6726
7335
 
6727
7336
  markTypingEnded(channelUrl?: string): void {
6728
- const now = this.clock.now();
6729
- this.turns.forEach((turn) => {
6730
- if (!matchesChannel(turn, channelUrl)) return;
6731
- this.recordBeforeTpst(turn, now);
6732
- 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;
6733
7342
  });
6734
7343
  }
6735
7344
 
@@ -6737,9 +7346,15 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6737
7346
  const channelUrl = params.channelUrl;
6738
7347
  const userMessageId = params.userMessageId;
6739
7348
  const matchesUserMessage = userMessageId !== undefined;
6740
- const now = this.clock.now();
7349
+ const now = performance.now();
6741
7350
  const matched = this.applyAIResponseStarted({ channelUrl, matchesUserMessage, userMessageId }, now);
6742
- 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;
6743
7358
 
6744
7359
  if (!this.pendingAIResponseStartsByUserMessageId.has(userMessageId)) {
6745
7360
  evictOldestIfAtLimit(this.pendingAIResponseStartsByUserMessageId, 100);
@@ -6754,16 +7369,16 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6754
7369
  if (!params.matchesUserMessage) {
6755
7370
  const match = this.findSingleTurn(params.channelUrl);
6756
7371
  if (!match) return false;
6757
- this.completeTurn(match.requestId, match.turn, startedAt);
7372
+ this.completeTurn(match.requestId, match.record, startedAt);
6758
7373
  return true;
6759
7374
  }
6760
7375
 
6761
7376
  let matched = false;
6762
- this.turns.forEach((turn, requestId) => {
6763
- if (!matchesChannel(turn, params.channelUrl)) return;
6764
- 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;
6765
7380
  matched = true;
6766
- this.completeTurn(requestId, turn, startedAt);
7381
+ this.completeTurn(record.measure.requestId, record, startedAt);
6767
7382
  });
6768
7383
  return matched;
6769
7384
  }
@@ -6771,38 +7386,38 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6771
7386
  private findSingleTurn(channelUrl?: string): TypingTurnEntry | undefined {
6772
7387
  let match: TypingTurnEntry | undefined;
6773
7388
  let matchedCount = 0;
6774
- this.turns.forEach((turn, requestId) => {
6775
- if (!matchesChannel(turn, channelUrl)) return;
7389
+ this.records.forEach((record) => {
7390
+ if (!matchesChannel(record.measure.channelUrl, channelUrl)) return;
6776
7391
  matchedCount += 1;
6777
7392
  if (matchedCount > 1) return;
6778
- match = { requestId, turn };
7393
+ match = { requestId: record.measure.requestId, record };
6779
7394
  });
6780
7395
  return matchedCount === 1 ? match : undefined;
6781
7396
  }
6782
7397
 
6783
- private completeTurn(requestId: string, turn: TypingTurn, startedAt: number): void {
6784
- this.recordBeforeTpst(turn, startedAt);
6785
- this.recordAfterTpen(turn, startedAt);
6786
- this.commitIfAbsent(requestId, turn);
6787
- }
6788
-
6789
- private hasUnackedTurn(channelUrl?: string): boolean {
6790
- 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);
6791
7406
  }
6792
7407
 
6793
7408
  clear(): void {
6794
- this.turns.forEach((turn) => {
6795
- clearTimer(turn.timer, this.clearTimeoutFn);
7409
+ this.records.forEach((record) => {
7410
+ if (record.measure.timer !== null) clearTimeout(record.measure.timer);
6796
7411
  });
6797
- this.turns.clear();
7412
+ this.records.clear();
6798
7413
  this.pendingAIResponseStartsByUserMessageId.clear();
6799
7414
  }
6800
7415
 
6801
7416
  clearChannel(channelUrl?: string): void {
6802
7417
  if (!channelUrl) return;
6803
- this.turns.forEach((turn, requestId) => {
6804
- if (turn.channelUrl !== channelUrl) return;
6805
- this.cleanupTurn(requestId);
7418
+ this.records.forEach((record) => {
7419
+ if (record.measure.channelUrl !== channelUrl) return;
7420
+ this.cleanupTurn(record.measure.requestId);
6806
7421
  });
6807
7422
  this.pendingAIResponseStartsByUserMessageId.forEach((pending, userMessageId) => {
6808
7423
  if (pending.channelUrl === channelUrl) this.pendingAIResponseStartsByUserMessageId.delete(userMessageId);
@@ -6810,78 +7425,62 @@ declare class TypingIndicatorAbsentTimeoutStatsTracker {
6810
7425
  }
6811
7426
 
6812
7427
  private commitTimeout(requestId: string): void {
6813
- const turn = this.turns.get(requestId);
6814
- if (!turn) return;
6815
- const now = this.clock.now();
6816
- this.recordBeforeTpst(turn, now);
6817
- this.recordAfterTpen(turn, now);
6818
- 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);
6819
7449
  }
6820
7450
 
6821
7451
  private recordBeforeTpst(
6822
- turn: { beforeTpstStartedAt: number | null; beforeTpstAbsentMs?: number },
7452
+ record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure>,
6823
7453
  endedAt: number,
6824
7454
  ): void {
6825
- if (turn.beforeTpstStartedAt === null) return;
6826
- const duration = endedAt - turn.beforeTpstStartedAt;
6827
- if (duration >= TYPING_ABSENT_THRESHOLD_MS) turn.beforeTpstAbsentMs = duration;
6828
- 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;
6829
7459
  }
6830
7460
 
6831
7461
  private recordAfterTpen(
6832
- turn: { afterTpenStartedAt: number | null; afterTpenAbsentMs?: number },
7462
+ record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure>,
6833
7463
  endedAt: number,
6834
7464
  ): void {
6835
- if (turn.afterTpenStartedAt === null) return;
6836
- const duration = endedAt - turn.afterTpenStartedAt;
6837
- if (duration >= TYPING_ABSENT_THRESHOLD_MS) turn.afterTpenAbsentMs = duration;
6838
- turn.afterTpenStartedAt = null;
6839
- }
6840
-
6841
- private commitIfAbsent(requestId: string, turn: { beforeTpstAbsentMs?: number; afterTpenAbsentMs?: number }): void {
6842
- const beforeTpstAbsentMs = turn.beforeTpstAbsentMs;
6843
- const afterTpenAbsentMs = turn.afterTpenAbsentMs;
6844
- if (beforeTpstAbsentMs === undefined && afterTpenAbsentMs === undefined) {
6845
- this.cleanupTurn(requestId);
6846
- return;
6847
- }
6848
-
6849
- append(
6850
- this.callback,
6851
- METRIC_KEY_AI_TYPING_INDICATOR_ABSENT_TIMEOUT,
6852
- (beforeTpstAbsentMs ?? 0) + (afterTpenAbsentMs ?? 0),
6853
- {
6854
- extra: {
6855
- ...(beforeTpstAbsentMs !== undefined && { before_tpst_absent_ms: beforeTpstAbsentMs }),
6856
- ...(afterTpenAbsentMs !== undefined && { after_tpen_absent_ms: afterTpenAbsentMs }),
6857
- },
6858
- },
6859
- );
6860
- this.cleanupTurn(requestId);
6861
- }
6862
-
6863
- private cleanupTurn(requestId: string): void {
6864
- const turn = this.turns.get(requestId);
6865
- if (!turn) return;
6866
- clearTimer(turn.timer, this.clearTimeoutFn);
6867
- 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;
6868
7469
  }
6869
7470
  }
6870
7471
 
6871
- declare type TypingTurn = {
6872
- requestId: string;
6873
- channelUrl?: string;
6874
- userMessageId?: number;
6875
- beforeTpstStartedAt: number | null;
7472
+ declare type TypingMeasure = {
6876
7473
  afterTpenStartedAt: number | null;
6877
- beforeTpstAbsentMs?: number;
6878
- afterTpenAbsentMs?: number;
7474
+ beforeTpstStartedAt: number | null;
7475
+ channelUrl?: string;
7476
+ requestId: string;
6879
7477
  timer: TimeoutId | null;
7478
+ userMessageId?: number;
6880
7479
  };
6881
7480
 
6882
7481
  declare type TypingTurnEntry = {
6883
7482
  requestId: string;
6884
- turn: TypingTurn;
7483
+ record: StatsRecord<TypingIndicatorAbsentTimeoutStats, TypingMeasure>;
6885
7484
  };
6886
7485
 
6887
7486
  declare interface TypographyShape {
@@ -6959,76 +7558,163 @@ export declare type UserActionStatus =
6959
7558
 
6960
7559
  export declare const useRefreshActiveChannel: (updater: () => Promise<void>) => () => Promise<void>;
6961
7560
 
6962
- declare class UserPerceivedResponseTimeStatsTracker {
6963
- private readonly callback: StatsAppendCallback;
6964
- private readonly clock: StatsClock;
6965
- private readonly random: () => number;
6966
- private readonly sampledByRequestId = new Map<string, UserTurn>();
6967
- 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;
6968
7650
  private readonly pendingAIResponseByUserMessageId = new Map<number, PendingAIResponseRaw>();
6969
7651
  private collectedCount = 0;
6970
7652
 
6971
- constructor(callback: StatsAppendCallback, options?: TrackerOptions & { random?: () => number }) {
6972
- this.callback = callback;
6973
- this.clock = getClock(options);
6974
- 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;
6975
7656
  }
6976
7657
 
6977
7658
  startTurn(params: { requestId: string; channelUrl?: string }): void {
6978
- if (this.collectedCount >= 10) return;
6979
- if (this.random() >= 0.15) return;
7659
+ if (this.collectedCount >= MAX_COLLECTED_COUNT) return;
7660
+ if (!isIncludedInSample(this.samplingRate)) return;
6980
7661
  this.collectedCount += 1;
6981
- this.sampledByRequestId.set(params.requestId, {
6982
- requestId: params.requestId,
6983
- channelUrl: params.channelUrl,
6984
- startedAt: this.clock.now(),
6985
- });
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);
6986
7672
  }
6987
7673
 
6988
7674
  markUserMessageAck(params: { requestId: string; messageId: number }): void {
6989
- const turn = this.sampledByRequestId.get(params.requestId);
6990
- if (!turn) return;
7675
+ const record = this.records.get(params.requestId);
7676
+ if (!record) return;
6991
7677
 
6992
- turn.ackAt = this.clock.now();
6993
- turn.userMessageId = params.messageId;
7678
+ record.measure.ackAt = performance.now();
7679
+ record.measure.userMessageId = params.messageId;
6994
7680
 
6995
7681
  const pending = this.pendingAIResponseByUserMessageId.get(params.messageId);
6996
7682
  if (!pending) return;
6997
7683
  this.pendingAIResponseByUserMessageId.delete(params.messageId);
6998
- this.applyAIResponseRaw(turn, pending);
7684
+ this.applyAIResponseRaw(record, pending);
6999
7685
  }
7000
7686
 
7001
7687
  replaceTurnRequestId(currentRequestId: string, nextRequestId: string): void {
7002
7688
  if (currentRequestId === nextRequestId) return;
7003
7689
 
7004
- const turn = this.sampledByRequestId.get(currentRequestId);
7005
- if (!turn) return;
7690
+ const record = this.records.get(currentRequestId);
7691
+ if (!record) return;
7006
7692
 
7007
- this.sampledByRequestId.delete(currentRequestId);
7008
- turn.requestId = nextRequestId;
7009
- this.sampledByRequestId.set(nextRequestId, turn);
7693
+ this.records.delete(currentRequestId);
7694
+ record.measure.requestId = nextRequestId;
7695
+ this.records.set(nextRequestId, record);
7010
7696
  }
7011
7697
 
7012
7698
  cancelTurn(requestId: string): void {
7013
- const turn = this.sampledByRequestId.get(requestId);
7014
- if (!turn) return;
7015
- this.deleteTurn(turn);
7699
+ const record = this.records.get(requestId);
7700
+ if (!record) return;
7701
+ this.deleteRecord(record);
7016
7702
  // Return the unused sample slot so cancelled turns don't burn the per-session cap.
7017
7703
  if (this.collectedCount > 0) this.collectedCount -= 1;
7018
7704
  }
7019
7705
 
7020
7706
  markAIResponseRaw(params: { messageId: number; channelUrl?: string; aiResponse: AIResponseMessageMetadata }): void {
7021
- const firstChunkAt = this.clock.now();
7707
+ const firstChunkAt = performance.now();
7022
7708
  const userMessageId = params.aiResponse.userMessageId;
7023
- if (userMessageId === undefined && this.hasUnackedTurn(params.channelUrl)) return;
7709
+ if (userMessageId === undefined && hasUnackedChannelTurn(this.records.values(), params.channelUrl)) return;
7024
7710
 
7025
- const turn = this.findPendingTurn(params.channelUrl, userMessageId);
7026
- if (turn) {
7027
- this.applyAIResponseRaw(turn, { ...params, firstChunkAt });
7711
+ const record = this.findPendingRecord(params.channelUrl, userMessageId);
7712
+ if (record) {
7713
+ this.applyAIResponseRaw(record, { ...params, firstChunkAt });
7028
7714
  return;
7029
7715
  }
7030
7716
 
7031
- if (userMessageId === undefined || !this.hasUnackedTurn(params.channelUrl)) return;
7717
+ if (userMessageId === undefined || !hasUnackedChannelTurn(this.records.values(), params.channelUrl)) return;
7032
7718
  this.bufferAIResponseRaw(
7033
7719
  {
7034
7720
  messageId: params.messageId,
@@ -7041,26 +7727,31 @@ declare class UserPerceivedResponseTimeStatsTracker {
7041
7727
  }
7042
7728
 
7043
7729
  markAIResponseVisible(aiMessageId: number): boolean {
7044
- const turn = this.sampledByAIMessageId.get(aiMessageId);
7045
- if (!turn || turn.ackAt === undefined || turn.firstChunkAt === undefined || turn.userMessageId === undefined)
7046
- return false;
7730
+ const record = this.findRecordByAIMessageId(aiMessageId);
7731
+ if (!record) return false;
7047
7732
 
7048
- const renderedAt = this.clock.now();
7049
- append(this.callback, METRIC_KEY_USER_PERCEIVED_RESPONSE_TIME, renderedAt - turn.startedAt, {
7050
- extra: {
7051
- ack_to_first_chunk_ms: turn.firstChunkAt - turn.ackAt,
7052
- first_chunk_to_render_ms: renderedAt - turn.firstChunkAt,
7053
- is_streaming: Boolean(turn.isStreaming),
7054
- send_to_ack_ms: turn.ackAt - turn.startedAt,
7055
- },
7056
- });
7733
+ const measure = record.measure;
7734
+ if (measure.ackAt === undefined || measure.firstChunkAt === undefined || measure.userMessageId === undefined) {
7735
+ return false;
7736
+ }
7057
7737
 
7058
- 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);
7059
7750
  return true;
7060
7751
  }
7061
7752
 
7062
7753
  hasPendingAIResponse(aiMessageId: number): boolean {
7063
- return this.sampledByAIMessageId.has(aiMessageId);
7754
+ return this.findRecordByAIMessageId(aiMessageId) !== undefined;
7064
7755
  }
7065
7756
 
7066
7757
  clearMessage(aiMessageId: number): void {
@@ -7068,18 +7759,18 @@ declare class UserPerceivedResponseTimeStatsTracker {
7068
7759
  if (pending.messageId === aiMessageId) this.pendingAIResponseByUserMessageId.delete(userMessageId);
7069
7760
  });
7070
7761
 
7071
- const turn = this.sampledByAIMessageId.get(aiMessageId);
7072
- if (!turn) return;
7762
+ const record = this.findRecordByAIMessageId(aiMessageId);
7763
+ if (!record) return;
7073
7764
 
7074
- this.deleteTurn(turn);
7765
+ this.deleteRecord(record);
7075
7766
  if (this.collectedCount > 0) this.collectedCount -= 1;
7076
7767
  }
7077
7768
 
7078
7769
  clearChannel(channelUrl?: string): void {
7079
7770
  if (!channelUrl) return;
7080
- this.sampledByRequestId.forEach((turn) => {
7081
- if (turn.channelUrl !== channelUrl) return;
7082
- this.deleteTurn(turn);
7771
+ this.records.forEach((record) => {
7772
+ if (record.measure.channelUrl !== channelUrl) return;
7773
+ this.deleteRecord(record);
7083
7774
  if (this.collectedCount > 0) this.collectedCount -= 1;
7084
7775
  });
7085
7776
  this.pendingAIResponseByUserMessageId.forEach((pending, userMessageId) => {
@@ -7088,40 +7779,54 @@ declare class UserPerceivedResponseTimeStatsTracker {
7088
7779
  }
7089
7780
 
7090
7781
  clear(): void {
7091
- this.sampledByRequestId.clear();
7092
- this.sampledByAIMessageId.clear();
7782
+ this.records.clear();
7093
7783
  this.pendingAIResponseByUserMessageId.clear();
7094
7784
  this.collectedCount = 0;
7095
7785
  }
7096
7786
 
7097
- 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 {
7098
7800
  if (userMessageId !== undefined) {
7099
- for (const turn of this.sampledByRequestId.values()) {
7100
- if (turn.ackAt === undefined || turn.firstChunkAt !== undefined) continue;
7101
- if (!matchesChannel(turn, channelUrl)) continue;
7102
- 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;
7103
7805
  }
7104
7806
  return undefined;
7105
7807
  }
7106
7808
 
7107
- let match: UserTurn | undefined;
7108
- for (const turn of this.sampledByRequestId.values()) {
7109
- if (turn.ackAt === undefined || turn.firstChunkAt !== undefined) continue;
7110
- 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;
7111
7813
  if (match) return undefined;
7112
- match = turn;
7814
+ match = record;
7113
7815
  }
7114
7816
  return match;
7115
7817
  }
7116
7818
 
7117
- private applyAIResponseRaw(turn: UserTurn, params: PendingAIResponseRaw): void {
7118
- 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;
7119
7824
 
7120
- turn.firstChunkAt = turn.ackAt === undefined ? params.firstChunkAt : Math.max(params.firstChunkAt, turn.ackAt);
7121
- turn.aiMessageId = params.messageId;
7122
- turn.channelUrl = turn.channelUrl ?? params.channelUrl;
7123
- turn.isStreaming = params.aiResponse.isStreaming;
7124
- 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;
7125
7830
  }
7126
7831
 
7127
7832
  private bufferAIResponseRaw(params: PendingAIResponseRaw, userMessageId: number): void {
@@ -7131,14 +7836,10 @@ declare class UserPerceivedResponseTimeStatsTracker {
7131
7836
  }
7132
7837
  }
7133
7838
 
7134
- private hasUnackedTurn(channelUrl?: string): boolean {
7135
- return hasUnackedChannelTurn(this.sampledByRequestId.values(), channelUrl);
7136
- }
7137
-
7138
- private deleteTurn(turn: UserTurn): void {
7139
- this.sampledByRequestId.delete(turn.requestId);
7140
- if (turn.aiMessageId !== undefined) this.sampledByAIMessageId.delete(turn.aiMessageId);
7141
- 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);
7142
7843
  }
7143
7844
  }
7144
7845
 
@@ -7159,17 +7860,6 @@ declare interface UserSessionInfo {
7159
7860
  sessionHandler: AIAgentSessionHandler;
7160
7861
  }
7161
7862
 
7162
- declare type UserTurn = {
7163
- requestId: string;
7164
- channelUrl?: string;
7165
- startedAt: number;
7166
- ackAt?: number;
7167
- firstChunkAt?: number;
7168
- userMessageId?: number;
7169
- aiMessageId?: number;
7170
- isStreaming?: boolean;
7171
- };
7172
-
7173
7863
  export { }
7174
7864
 
7175
7865