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