@agent-native/core 0.84.51 → 0.84.52
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +8 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/run-store.ts +33 -6
- package/corpus/core/src/client/AssistantChat.tsx +84 -23
- package/corpus/core/src/client/agent-chat-adapter.ts +19 -0
- package/corpus/core/src/client/blocks/library/DiffBlock.tsx +7 -5
- package/corpus/core/src/client/session-replay.ts +170 -47
- package/corpus/core/src/client/sse-event-processor.ts +75 -14
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +5 -0
- package/corpus/templates/analytics/changelog/2026-07-02-agent-chat-can-keep-working-through-longer-data-queries-inst.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-02-session-replay-playback-recovers-from-failed-snapshot-uploads.md +6 -0
- package/corpus/templates/analytics/netlify.toml +3 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +9 -5
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +3 -0
- package/corpus/templates/plan/changelog/2026-07-02-agent-chat-can-keep-working-through-longer-visual-plan-updat.md +6 -0
- package/corpus/templates/plan/netlify.toml +3 -0
- package/corpus/templates/plan/server/plugins/agent-chat.ts +4 -0
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +30 -6
- package/dist/agent/run-store.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +64 -13
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +14 -0
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/blocks/library/DiffBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/DiffBlock.js +6 -5
- package/dist/client/blocks/library/DiffBlock.js.map +1 -1
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +134 -39
- package/dist/client/session-replay.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts +25 -0
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +50 -13
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/package.json +1 -1
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.84.52
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ca38cc7: Carry action-preparation stall detection across reconnect reads for the same run so zero-byte tool input retries cannot keep background chats alive indefinitely.
|
|
8
|
+
- 899ebf1: Hide the diff "Show all lines" footer when annotation anchoring already renders every line.
|
|
9
|
+
- c1e18fb: Make session replay uploads retry failed batches without advancing sequence ids.
|
|
10
|
+
|
|
3
11
|
## 0.84.51
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.84.
|
|
3
|
+
"version": "0.84.52",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -763,9 +763,9 @@ export async function reconcileTerminalRunFromEvents(
|
|
|
763
763
|
sql: `UPDATE agent_runs
|
|
764
764
|
SET status = ?,
|
|
765
765
|
completed_at = COALESCE(completed_at, ?, ${livenessBasisSql()}),
|
|
766
|
-
error_code =
|
|
767
|
-
error_detail =
|
|
768
|
-
terminal_reason =
|
|
766
|
+
error_code = ?,
|
|
767
|
+
error_detail = ?,
|
|
768
|
+
terminal_reason = ?
|
|
769
769
|
WHERE id = ?
|
|
770
770
|
AND (
|
|
771
771
|
status = 'running'
|
|
@@ -775,8 +775,6 @@ export async function reconcileTerminalRunFromEvents(
|
|
|
775
775
|
status,
|
|
776
776
|
latest.eventAt,
|
|
777
777
|
errorCode,
|
|
778
|
-
errorCode,
|
|
779
|
-
errorDetail,
|
|
780
778
|
errorDetail,
|
|
781
779
|
terminalReason,
|
|
782
780
|
runId,
|
|
@@ -1248,7 +1246,7 @@ export async function listRunsForThread(
|
|
|
1248
1246
|
await ensureRunTables();
|
|
1249
1247
|
const limit = Math.min(Math.max(options.limit ?? 10, 1), 50);
|
|
1250
1248
|
const client = getDbExec();
|
|
1251
|
-
|
|
1249
|
+
let { rows } = await client.execute({
|
|
1252
1250
|
sql: `SELECT id, thread_id, turn_id, status, started_at, heartbeat_at, completed_at, last_progress_at, error_code, abort_reason, dispatch_mode, terminal_reason, diag_stage
|
|
1253
1251
|
FROM agent_runs
|
|
1254
1252
|
WHERE thread_id = ?
|
|
@@ -1256,6 +1254,35 @@ export async function listRunsForThread(
|
|
|
1256
1254
|
LIMIT ?`,
|
|
1257
1255
|
args: [threadId, limit],
|
|
1258
1256
|
});
|
|
1257
|
+
let repairedTerminalRow = false;
|
|
1258
|
+
for (const r of rows) {
|
|
1259
|
+
const row = r as {
|
|
1260
|
+
id?: string;
|
|
1261
|
+
status?: string;
|
|
1262
|
+
error_code?: string | null;
|
|
1263
|
+
};
|
|
1264
|
+
const runId = row.id;
|
|
1265
|
+
if (!runId) continue;
|
|
1266
|
+
const canReconcileFromEvents =
|
|
1267
|
+
row.status === "running" ||
|
|
1268
|
+
(row.status === "errored" &&
|
|
1269
|
+
row.error_code === STALE_RUN_ERROR_EVENT.errorCode);
|
|
1270
|
+
if (!canReconcileFromEvents) continue;
|
|
1271
|
+
repairedTerminalRow =
|
|
1272
|
+
(await reconcileTerminalRunFromEvents(runId).catch(() => false)) ||
|
|
1273
|
+
repairedTerminalRow;
|
|
1274
|
+
}
|
|
1275
|
+
if (repairedTerminalRow) {
|
|
1276
|
+
const refreshed = await client.execute({
|
|
1277
|
+
sql: `SELECT id, thread_id, turn_id, status, started_at, heartbeat_at, completed_at, last_progress_at, error_code, abort_reason, dispatch_mode, terminal_reason, diag_stage
|
|
1278
|
+
FROM agent_runs
|
|
1279
|
+
WHERE thread_id = ?
|
|
1280
|
+
ORDER BY started_at DESC
|
|
1281
|
+
LIMIT ?`,
|
|
1282
|
+
args: [threadId, limit],
|
|
1283
|
+
});
|
|
1284
|
+
rows = refreshed.rows;
|
|
1285
|
+
}
|
|
1259
1286
|
return rows.map((r) => {
|
|
1260
1287
|
const row = r as {
|
|
1261
1288
|
id: string;
|
|
@@ -153,6 +153,7 @@ import {
|
|
|
153
153
|
import {
|
|
154
154
|
AgentAutoContinueSignal,
|
|
155
155
|
type ContentPart,
|
|
156
|
+
type PreparingActionState,
|
|
156
157
|
readSSEStreamRaw,
|
|
157
158
|
settleInterruptedToolCalls,
|
|
158
159
|
} from "./sse-event-processor.js";
|
|
@@ -675,7 +676,7 @@ export function resolveAssistantChatRunningStatusLabel({
|
|
|
675
676
|
}): string {
|
|
676
677
|
if (runningActivityLabel) return runningActivityLabel;
|
|
677
678
|
if (isAutoResuming) return "Resuming";
|
|
678
|
-
if (isReconnecting && hasReconnectContent) return "
|
|
679
|
+
if (isReconnecting && hasReconnectContent) return "Still working";
|
|
679
680
|
return "Thinking";
|
|
680
681
|
}
|
|
681
682
|
|
|
@@ -1516,7 +1517,7 @@ const AssistantChatInner = forwardRef<
|
|
|
1516
1517
|
const textStreaming = showRunningInUI || externalStreaming;
|
|
1517
1518
|
// A revealed activity label wins; otherwise keep recovery states calm and
|
|
1518
1519
|
// product-facing. Reconnect is transport machinery, so normal replay reads as
|
|
1519
|
-
//
|
|
1520
|
+
// ongoing work instead of exposing "Reconnecting" mid-chat.
|
|
1520
1521
|
const runningStatusLabel = resolveAssistantChatRunningStatusLabel({
|
|
1521
1522
|
runningActivityLabel,
|
|
1522
1523
|
isAutoResuming,
|
|
@@ -1863,6 +1864,27 @@ const AssistantChatInner = forwardRef<
|
|
|
1863
1864
|
const streamReconnect = async () => {
|
|
1864
1865
|
let noProgressDuringReconnect = false;
|
|
1865
1866
|
let latestContent: ContentPart[] = [];
|
|
1867
|
+
const preparingActionState: PreparingActionState = {};
|
|
1868
|
+
const sameRunStillActive = async (): Promise<
|
|
1869
|
+
"active" | "inactive" | "unknown"
|
|
1870
|
+
> => {
|
|
1871
|
+
try {
|
|
1872
|
+
const res = await fetch(
|
|
1873
|
+
`${apiUrl}/runs/active?threadId=${encodeURIComponent(threadId)}`,
|
|
1874
|
+
{ signal: abortCtrl.signal },
|
|
1875
|
+
);
|
|
1876
|
+
if (!res.ok) return "unknown";
|
|
1877
|
+
const info = (await res.json()) as ActiveRunLookup;
|
|
1878
|
+
return info.active === true &&
|
|
1879
|
+
String(info.runId ?? "") === runId &&
|
|
1880
|
+
info.status === "running" &&
|
|
1881
|
+
!activeRunLooksStale(info)
|
|
1882
|
+
? "active"
|
|
1883
|
+
: "inactive";
|
|
1884
|
+
} catch {
|
|
1885
|
+
return "unknown";
|
|
1886
|
+
}
|
|
1887
|
+
};
|
|
1866
1888
|
const threadPollInterval =
|
|
1867
1889
|
afterSeq > 0
|
|
1868
1890
|
? window.setInterval(() => {
|
|
@@ -1871,15 +1893,28 @@ const AssistantChatInner = forwardRef<
|
|
|
1871
1893
|
}, 2000)
|
|
1872
1894
|
: undefined;
|
|
1873
1895
|
try {
|
|
1874
|
-
const
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
);
|
|
1878
|
-
if (sseRes.ok && sseRes.body) {
|
|
1879
|
-
const content: ContentPart[] = [];
|
|
1880
|
-
latestContent = content;
|
|
1881
|
-
const toolCallCounter = { value: 0 };
|
|
1896
|
+
const content: ContentPart[] = [];
|
|
1897
|
+
latestContent = content;
|
|
1898
|
+
const toolCallCounter = { value: 0 };
|
|
1882
1899
|
|
|
1900
|
+
while (
|
|
1901
|
+
reconnectRunIdRef.current === runId &&
|
|
1902
|
+
!abortCtrl.signal.aborted
|
|
1903
|
+
) {
|
|
1904
|
+
const reconnectAfterSeq = resolveReconnectAfterSeq(threadId, runId);
|
|
1905
|
+
reconnectTailOnlyRef.current = reconnectAfterSeq > 0;
|
|
1906
|
+
const sseRes = await fetch(
|
|
1907
|
+
`${apiUrl}/runs/${encodeURIComponent(runId)}/events?after=${reconnectAfterSeq}`,
|
|
1908
|
+
{ signal: abortCtrl.signal },
|
|
1909
|
+
);
|
|
1910
|
+
if (!sseRes.ok || !sseRes.body) {
|
|
1911
|
+
const activeState = await sameRunStillActive();
|
|
1912
|
+
if (activeState !== "inactive") {
|
|
1913
|
+
await new Promise((resolve) => window.setTimeout(resolve, 250));
|
|
1914
|
+
continue;
|
|
1915
|
+
}
|
|
1916
|
+
break;
|
|
1917
|
+
}
|
|
1883
1918
|
let rafPending = false;
|
|
1884
1919
|
let latestSnapshot: ContentPart[] = [];
|
|
1885
1920
|
const scheduleUpdate = (snapshot: ContentPart[]) => {
|
|
@@ -1892,21 +1927,47 @@ const AssistantChatInner = forwardRef<
|
|
|
1892
1927
|
});
|
|
1893
1928
|
};
|
|
1894
1929
|
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1930
|
+
try {
|
|
1931
|
+
await readSSEStreamRaw(
|
|
1932
|
+
sseRes.body,
|
|
1933
|
+
content,
|
|
1934
|
+
toolCallCounter,
|
|
1935
|
+
tabId,
|
|
1936
|
+
scheduleUpdate,
|
|
1937
|
+
(seq) => {
|
|
1938
|
+
markReconnectProgress();
|
|
1939
|
+
updateActiveRunSeq(seq);
|
|
1940
|
+
},
|
|
1941
|
+
{ preparingActionState },
|
|
1942
|
+
);
|
|
1943
|
+
if (reconnectAfterSeq === 0) {
|
|
1944
|
+
setReconnectContent([...content]);
|
|
1945
|
+
}
|
|
1946
|
+
break;
|
|
1947
|
+
} catch (err) {
|
|
1948
|
+
if (
|
|
1949
|
+
err instanceof AgentAutoContinueSignal &&
|
|
1950
|
+
err.reason === "stream_ended"
|
|
1951
|
+
) {
|
|
1952
|
+
if (reconnectAfterSeq === 0) {
|
|
1953
|
+
setReconnectContent([...content]);
|
|
1954
|
+
}
|
|
1955
|
+
const activeState = await sameRunStillActive();
|
|
1956
|
+
if (activeState !== "inactive") {
|
|
1957
|
+
await new Promise((resolve) =>
|
|
1958
|
+
window.setTimeout(resolve, 250),
|
|
1959
|
+
);
|
|
1960
|
+
continue;
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
throw err;
|
|
1908
1964
|
}
|
|
1909
1965
|
}
|
|
1966
|
+
if (reconnectTimedOut && abortCtrl.signal.aborted) {
|
|
1967
|
+
const timeoutError = new Error("Reconnect timed out");
|
|
1968
|
+
timeoutError.name = "AbortError";
|
|
1969
|
+
throw timeoutError;
|
|
1970
|
+
}
|
|
1910
1971
|
} catch (err) {
|
|
1911
1972
|
if (
|
|
1912
1973
|
err instanceof AgentAutoContinueSignal &&
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type AgentActivityTrailEntry,
|
|
21
21
|
type AgentAutoContinueErrorInfo,
|
|
22
22
|
type ContentPart,
|
|
23
|
+
type PreparingActionState,
|
|
23
24
|
readSSEStream,
|
|
24
25
|
settleInterruptedToolCalls,
|
|
25
26
|
} from "./sse-event-processor.js";
|
|
@@ -1381,6 +1382,10 @@ export function createAgentChatAdapter(
|
|
|
1381
1382
|
let runId: string | null = null;
|
|
1382
1383
|
let lastSeq = -1;
|
|
1383
1384
|
const seenRunSeqs = new Map<string, number>();
|
|
1385
|
+
const preparingActionStatesByRun = new Map<
|
|
1386
|
+
string,
|
|
1387
|
+
PreparingActionState
|
|
1388
|
+
>();
|
|
1384
1389
|
let currentRunDispatchMode: string | null = null;
|
|
1385
1390
|
let currentMessageText = normalizeMentions(
|
|
1386
1391
|
recoveryMessageText.trim() || userMessageText,
|
|
@@ -1570,9 +1575,23 @@ export function createAgentChatAdapter(
|
|
|
1570
1575
|
}
|
|
1571
1576
|
};
|
|
1572
1577
|
|
|
1578
|
+
const preparingActionStateForRun = (
|
|
1579
|
+
id: string | null,
|
|
1580
|
+
): PreparingActionState | undefined => {
|
|
1581
|
+
if (!id) return undefined;
|
|
1582
|
+
const existing = preparingActionStatesByRun.get(id);
|
|
1583
|
+
if (existing) return existing;
|
|
1584
|
+
const state: PreparingActionState = {};
|
|
1585
|
+
preparingActionStatesByRun.set(id, state);
|
|
1586
|
+
return state;
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1573
1589
|
const currentSSEOptions = () => ({
|
|
1574
1590
|
durableBackgroundRun:
|
|
1575
1591
|
currentRunDispatchMode?.startsWith("background") === true,
|
|
1592
|
+
...(runId
|
|
1593
|
+
? { preparingActionState: preparingActionStateForRun(runId) }
|
|
1594
|
+
: {}),
|
|
1576
1595
|
});
|
|
1577
1596
|
|
|
1578
1597
|
const captureChatClientError = (
|
|
@@ -838,8 +838,8 @@ function DiffRead({
|
|
|
838
838
|
effectiveMode === "split" ? splitLineCount : rows.length;
|
|
839
839
|
const shouldLimitRows = totalVisibleLineCount > DEFAULT_VISIBLE_DIFF_LINES;
|
|
840
840
|
// Never truncate away an annotated row: extend the window past the last one.
|
|
841
|
-
const
|
|
842
|
-
if (
|
|
841
|
+
const collapsedRowLimit = useMemo(() => {
|
|
842
|
+
if (!shouldLimitRows) return undefined;
|
|
843
843
|
let limit = DEFAULT_VISIBLE_DIFF_LINES;
|
|
844
844
|
if (hasAnnotations) {
|
|
845
845
|
for (let idx = rows.length - 1; idx >= limit; idx -= 1) {
|
|
@@ -850,8 +850,10 @@ function DiffRead({
|
|
|
850
850
|
}
|
|
851
851
|
}
|
|
852
852
|
return limit;
|
|
853
|
-
}, [
|
|
854
|
-
const
|
|
853
|
+
}, [shouldLimitRows, hasAnnotations, rows, markersForRow]);
|
|
854
|
+
const hasHiddenRows =
|
|
855
|
+
collapsedRowLimit != null && collapsedRowLimit < totalVisibleLineCount;
|
|
856
|
+
const rowLimit = showAllRows ? undefined : collapsedRowLimit;
|
|
855
857
|
const displayedRows =
|
|
856
858
|
effectiveMode === "unified" && rowLimit ? rows.slice(0, rowLimit) : rows;
|
|
857
859
|
|
|
@@ -965,7 +967,7 @@ function DiffRead({
|
|
|
965
967
|
ctx={ctx}
|
|
966
968
|
/>
|
|
967
969
|
)}
|
|
968
|
-
{!unchanged &&
|
|
970
|
+
{!unchanged && hasHiddenRows && (
|
|
969
971
|
<button
|
|
970
972
|
type="button"
|
|
971
973
|
data-plan-interactive
|
|
@@ -8,6 +8,7 @@ type ReplayEvent = Record<string, unknown>;
|
|
|
8
8
|
type QueuedReplayEvent = {
|
|
9
9
|
json: string;
|
|
10
10
|
timestampMs: number;
|
|
11
|
+
type: number | null;
|
|
11
12
|
};
|
|
12
13
|
type ReplayStopFn = () => void;
|
|
13
14
|
export type SessionReplayUrlMatcher =
|
|
@@ -48,6 +49,7 @@ interface SessionReplayState {
|
|
|
48
49
|
/** Pre-serialized + scrubbed event JSON strings, ready to splice at flush. */
|
|
49
50
|
queue: QueuedReplayEvent[];
|
|
50
51
|
queuedBytes: number;
|
|
52
|
+
retryBatches: QueuedReplayEvent[][];
|
|
51
53
|
flushTimer: number | null;
|
|
52
54
|
maxDurationTimer: number | null;
|
|
53
55
|
flushing: boolean;
|
|
@@ -195,6 +197,7 @@ const DEFAULT_MAX_DURATION_MS = 30 * 60 * 1000;
|
|
|
195
197
|
const DEFAULT_MAX_EVENTS_PER_BATCH = 50;
|
|
196
198
|
const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
|
|
197
199
|
const MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES = 60 * 1024;
|
|
200
|
+
const RRWEB_FULL_SNAPSHOT_EVENT_TYPE = 2;
|
|
198
201
|
const URL_LIKE_KEYS = new Set([
|
|
199
202
|
"url",
|
|
200
203
|
"uri",
|
|
@@ -219,6 +222,7 @@ function getState(): SessionReplayState {
|
|
|
219
222
|
sequence: 0,
|
|
220
223
|
queue: [],
|
|
221
224
|
queuedBytes: 0,
|
|
225
|
+
retryBatches: [],
|
|
222
226
|
flushTimer: null,
|
|
223
227
|
maxDurationTimer: null,
|
|
224
228
|
flushing: false,
|
|
@@ -613,11 +617,10 @@ function enqueueReplayEvent(
|
|
|
613
617
|
state.queue.push({
|
|
614
618
|
json: serialized,
|
|
615
619
|
timestampMs: replayEventTimestampMs(event),
|
|
620
|
+
type: typeof event.type === "number" ? event.type : null,
|
|
616
621
|
});
|
|
617
622
|
state.queuedBytes += estimatedBytes;
|
|
618
|
-
|
|
619
|
-
void flushSessionReplay("max-events");
|
|
620
|
-
}
|
|
623
|
+
flushQueuedReplayIfNeeded(state);
|
|
621
624
|
}
|
|
622
625
|
|
|
623
626
|
function replayExtraProperties(
|
|
@@ -668,11 +671,18 @@ function replayPropertiesForUpload(
|
|
|
668
671
|
return properties;
|
|
669
672
|
}
|
|
670
673
|
|
|
674
|
+
interface ReplayUploadPayload {
|
|
675
|
+
body: string;
|
|
676
|
+
replayId: string;
|
|
677
|
+
sessionId: string;
|
|
678
|
+
sequence: number;
|
|
679
|
+
}
|
|
680
|
+
|
|
671
681
|
function buildReplayBody(
|
|
672
682
|
state: SessionReplayState,
|
|
673
683
|
reason: string,
|
|
674
684
|
events: QueuedReplayEvent[],
|
|
675
|
-
):
|
|
685
|
+
): ReplayUploadPayload | null {
|
|
676
686
|
const options = state.options;
|
|
677
687
|
if (!options || !state.replayId) return null;
|
|
678
688
|
const sessionId = getAnalyticsSessionId();
|
|
@@ -713,19 +723,17 @@ function buildReplayBody(
|
|
|
713
723
|
timestamp: new Date().toISOString(),
|
|
714
724
|
properties,
|
|
715
725
|
};
|
|
716
|
-
state.sequence += 1;
|
|
717
|
-
persistReplaySequence(
|
|
718
|
-
sessionId,
|
|
719
|
-
state.replayId,
|
|
720
|
-
state.startedAtMs,
|
|
721
|
-
state.sequence,
|
|
722
|
-
);
|
|
723
726
|
// Events are already serialized+scrubbed JSON strings; splice them into the
|
|
724
727
|
// envelope without re-serializing the (potentially large) events array.
|
|
725
728
|
const envelopeJson = JSON.stringify(envelope);
|
|
726
|
-
return
|
|
727
|
-
.
|
|
728
|
-
|
|
729
|
+
return {
|
|
730
|
+
body: `${envelopeJson.slice(0, -1)},"events":[${events
|
|
731
|
+
.map((event) => event.json)
|
|
732
|
+
.join(",")}]}`,
|
|
733
|
+
replayId: state.replayId,
|
|
734
|
+
sessionId,
|
|
735
|
+
sequence: state.sequence,
|
|
736
|
+
};
|
|
729
737
|
}
|
|
730
738
|
|
|
731
739
|
interface ReplayUploadBody {
|
|
@@ -745,13 +753,6 @@ function isCrossOriginReplayEndpoint(endpoint: string): boolean {
|
|
|
745
753
|
}
|
|
746
754
|
}
|
|
747
755
|
|
|
748
|
-
function replayUploadByteLength(body: string): number {
|
|
749
|
-
if (typeof TextEncoder !== "undefined") {
|
|
750
|
-
return new TextEncoder().encode(body).byteLength;
|
|
751
|
-
}
|
|
752
|
-
return body.length;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
756
|
async function gzipReplayBody(body: string): Promise<Blob | null> {
|
|
756
757
|
if (
|
|
757
758
|
typeof CompressionStream === "undefined" ||
|
|
@@ -792,39 +793,68 @@ async function buildReplayUploadBody(body: string): Promise<ReplayUploadBody> {
|
|
|
792
793
|
};
|
|
793
794
|
}
|
|
794
795
|
|
|
796
|
+
function replayUploadBodyBytes(body: BodyInit): number {
|
|
797
|
+
if (typeof body === "string") {
|
|
798
|
+
if (typeof TextEncoder !== "undefined") {
|
|
799
|
+
return new TextEncoder().encode(body).byteLength;
|
|
800
|
+
}
|
|
801
|
+
return body.length;
|
|
802
|
+
}
|
|
803
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
804
|
+
return body.size;
|
|
805
|
+
}
|
|
806
|
+
if (body instanceof ArrayBuffer) {
|
|
807
|
+
return body.byteLength;
|
|
808
|
+
}
|
|
809
|
+
if (ArrayBuffer.isView(body)) {
|
|
810
|
+
return body.byteLength;
|
|
811
|
+
}
|
|
812
|
+
return MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES + 1;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function canUseReplayKeepalive(body: BodyInit): boolean {
|
|
816
|
+
return replayUploadBodyBytes(body) <= MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES;
|
|
817
|
+
}
|
|
818
|
+
|
|
795
819
|
async function sendReplayUpload(
|
|
796
820
|
options: NormalizedSessionReplayOptions,
|
|
797
821
|
body: string,
|
|
822
|
+
callbacks: { beforeKeepaliveUpload?: () => void } = {},
|
|
798
823
|
): Promise<void> {
|
|
799
824
|
if (isCrossOriginReplayEndpoint(options.endpoint)) {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
804
|
-
await fetch(options.endpoint, {
|
|
825
|
+
const canUseKeepalive = canUseReplayKeepalive(body);
|
|
826
|
+
if (canUseKeepalive) callbacks.beforeKeepaliveUpload?.();
|
|
827
|
+
const response = await fetch(options.endpoint, {
|
|
805
828
|
method: "POST",
|
|
806
829
|
body,
|
|
807
|
-
keepalive:
|
|
808
|
-
replayUploadByteLength(body) <= MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES,
|
|
830
|
+
keepalive: canUseKeepalive,
|
|
809
831
|
headers: { "Content-Type": "text/plain;charset=UTF-8" },
|
|
810
|
-
})
|
|
832
|
+
});
|
|
833
|
+
if (!response.ok) {
|
|
834
|
+
throw new Error(
|
|
835
|
+
`Session replay upload failed with HTTP ${response.status}`,
|
|
836
|
+
);
|
|
837
|
+
}
|
|
811
838
|
return;
|
|
812
839
|
}
|
|
813
840
|
|
|
814
841
|
const upload = await buildReplayUploadBody(body);
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
}
|
|
819
|
-
await fetch(options.endpoint, {
|
|
842
|
+
const canUseKeepalive = canUseReplayKeepalive(upload.body);
|
|
843
|
+
if (canUseKeepalive) callbacks.beforeKeepaliveUpload?.();
|
|
844
|
+
const response = await fetch(options.endpoint, {
|
|
820
845
|
method: "POST",
|
|
821
846
|
body: upload.body,
|
|
822
|
-
keepalive:
|
|
847
|
+
keepalive: canUseKeepalive,
|
|
823
848
|
headers: {
|
|
824
849
|
...upload.headers,
|
|
825
850
|
"X-Agent-Native-Analytics-Key": options.publicKey,
|
|
826
851
|
},
|
|
827
|
-
})
|
|
852
|
+
});
|
|
853
|
+
if (!response.ok) {
|
|
854
|
+
throw new Error(
|
|
855
|
+
`Session replay upload failed with HTTP ${response.status}`,
|
|
856
|
+
);
|
|
857
|
+
}
|
|
828
858
|
}
|
|
829
859
|
|
|
830
860
|
function isFinalFlushReason(reason: string): boolean {
|
|
@@ -838,26 +868,118 @@ function isFinalFlushReason(reason: string): boolean {
|
|
|
838
868
|
].includes(reason);
|
|
839
869
|
}
|
|
840
870
|
|
|
871
|
+
function shouldReserveSequenceBeforeKeepalive(reason: string): boolean {
|
|
872
|
+
return (
|
|
873
|
+
reason === "pagehide" ||
|
|
874
|
+
reason === "beforeunload" ||
|
|
875
|
+
reason === "visibility-hidden"
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function hasFullSnapshot(events: QueuedReplayEvent[]): boolean {
|
|
880
|
+
return events.some((event) => event.type === RRWEB_FULL_SNAPSHOT_EVENT_TYPE);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function hasPendingReplayBatch(state: SessionReplayState): boolean {
|
|
884
|
+
return state.retryBatches.length > 0 || state.queue.length > 0;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function shouldFlushQueuedReplay(state: SessionReplayState): boolean {
|
|
888
|
+
if (!state.options || state.queue.length === 0) return false;
|
|
889
|
+
return (
|
|
890
|
+
hasFullSnapshot(state.queue) ||
|
|
891
|
+
state.queue.length >= state.options.maxEventsPerBatch ||
|
|
892
|
+
state.queuedBytes >= state.options.maxBatchBytes
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function flushQueuedReplayIfNeeded(state: SessionReplayState): void {
|
|
897
|
+
const options = state.options;
|
|
898
|
+
if (!options) return;
|
|
899
|
+
if (state.retryBatches.length > 0) return;
|
|
900
|
+
if (!shouldFlushQueuedReplay(state)) return;
|
|
901
|
+
const reason = hasFullSnapshot(state.queue)
|
|
902
|
+
? "full-snapshot"
|
|
903
|
+
: state.queue.length >= options.maxEventsPerBatch
|
|
904
|
+
? "max-events"
|
|
905
|
+
: "max-bytes";
|
|
906
|
+
void flushSessionReplay(reason);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function queuedReplayBytes(events: QueuedReplayEvent[]): number {
|
|
910
|
+
return events.reduce((total, event) => total + event.json.length, 0);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function restoreReplayEvents(
|
|
914
|
+
state: SessionReplayState,
|
|
915
|
+
events: QueuedReplayEvent[],
|
|
916
|
+
): void {
|
|
917
|
+
state.retryBatches.unshift(events);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function advanceReplaySequence(
|
|
921
|
+
state: SessionReplayState,
|
|
922
|
+
payload: ReplayUploadPayload,
|
|
923
|
+
): void {
|
|
924
|
+
if (state.replayId !== payload.replayId) return;
|
|
925
|
+
state.sequence = Math.max(state.sequence, payload.sequence + 1);
|
|
926
|
+
persistReplaySequence(
|
|
927
|
+
payload.sessionId,
|
|
928
|
+
payload.replayId,
|
|
929
|
+
state.startedAtMs,
|
|
930
|
+
state.sequence,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function rollbackReplaySequenceReservation(
|
|
935
|
+
state: SessionReplayState,
|
|
936
|
+
payload: ReplayUploadPayload,
|
|
937
|
+
): void {
|
|
938
|
+
if (state.replayId !== payload.replayId) return;
|
|
939
|
+
if (state.sequence !== payload.sequence + 1) return;
|
|
940
|
+
state.sequence = payload.sequence;
|
|
941
|
+
persistReplaySequence(
|
|
942
|
+
payload.sessionId,
|
|
943
|
+
payload.replayId,
|
|
944
|
+
state.startedAtMs,
|
|
945
|
+
state.sequence,
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
841
949
|
export async function flushSessionReplay(reason = "manual"): Promise<void> {
|
|
842
950
|
const state = getState();
|
|
843
|
-
if (!state.options || state
|
|
844
|
-
const events = state.
|
|
845
|
-
state.queuedBytes =
|
|
846
|
-
const
|
|
847
|
-
if (!
|
|
848
|
-
state
|
|
849
|
-
state.queuedBytes += events.reduce(
|
|
850
|
-
(total, event) => total + event.json.length,
|
|
851
|
-
0,
|
|
852
|
-
);
|
|
951
|
+
if (!state.options || !hasPendingReplayBatch(state) || state.flushing) return;
|
|
952
|
+
const events = state.retryBatches.shift() ?? state.queue.splice(0);
|
|
953
|
+
state.queuedBytes = queuedReplayBytes(state.queue);
|
|
954
|
+
const payload = buildReplayBody(state, reason, events);
|
|
955
|
+
if (!payload || !state.options) {
|
|
956
|
+
restoreReplayEvents(state, events);
|
|
853
957
|
return;
|
|
854
958
|
}
|
|
855
959
|
state.flushing = true;
|
|
960
|
+
let uploaded = false;
|
|
961
|
+
let reservedSequence = false;
|
|
856
962
|
try {
|
|
857
|
-
await sendReplayUpload(state.options, body
|
|
963
|
+
await sendReplayUpload(state.options, payload.body, {
|
|
964
|
+
beforeKeepaliveUpload: shouldReserveSequenceBeforeKeepalive(reason)
|
|
965
|
+
? () => {
|
|
966
|
+
advanceReplaySequence(state, payload);
|
|
967
|
+
reservedSequence = true;
|
|
968
|
+
}
|
|
969
|
+
: undefined,
|
|
970
|
+
});
|
|
971
|
+
if (!reservedSequence) advanceReplaySequence(state, payload);
|
|
972
|
+
uploaded = true;
|
|
973
|
+
} catch (error) {
|
|
974
|
+
if (reservedSequence) rollbackReplaySequenceReservation(state, payload);
|
|
975
|
+
restoreReplayEvents(state, events);
|
|
976
|
+
console.warn("[session-replay] upload failed", error);
|
|
858
977
|
} finally {
|
|
859
978
|
state.flushing = false;
|
|
860
979
|
}
|
|
980
|
+
if (uploaded && hasPendingReplayBatch(state)) {
|
|
981
|
+
flushQueuedReplayIfNeeded(state);
|
|
982
|
+
}
|
|
861
983
|
}
|
|
862
984
|
|
|
863
985
|
function installUrlMonitor(state: SessionReplayState): void {
|
|
@@ -1002,6 +1124,7 @@ async function startSessionReplayRecorder(
|
|
|
1002
1124
|
state.sequence = replaySession.sequence;
|
|
1003
1125
|
state.queue = [];
|
|
1004
1126
|
state.queuedBytes = 0;
|
|
1127
|
+
state.retryBatches = [];
|
|
1005
1128
|
state.stopRecorder = null;
|
|
1006
1129
|
state.lastAuthenticatedProperties = replayUserEmail(initialProperties)
|
|
1007
1130
|
? { ...initialProperties }
|