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