.
diff --git a/web/src/composables/useSessionReplay.ts b/web/src/composables/useSessionReplay.ts
index 7888a2f006..63b483c586 100644
--- a/web/src/composables/useSessionReplay.ts
+++ b/web/src/composables/useSessionReplay.ts
@@ -84,8 +84,7 @@ const defaultObject = {
selectedSession: {
start_time: 0,
end_time: 0,
- // Start of the first segment that carries a full snapshot, i.e. where playback can
- // actually begin. Null when no segment carries one. See SessionViewer's replayOrigin.
+ // Start of the first segment with a full snapshot, where playback can begin; null when none has one.
replay_start: null as number | null,
browser: "",
os: "",
@@ -98,6 +97,9 @@ const defaultObject = {
type: "",
time_spent: 0,
source: "",
+ // Server receive-time bounds of the replay rows, in µs; later queries search between them.
+ min_ts: 0,
+ max_ts: 0,
},
},
};
diff --git a/web/src/composables/useStreamingSearch.ts b/web/src/composables/useStreamingSearch.ts
index 1993cbc2ee..e8333054b7 100644
--- a/web/src/composables/useStreamingSearch.ts
+++ b/web/src/composables/useStreamingSearch.ts
@@ -38,12 +38,14 @@ type StreamHandler = (data: any, traceId: string) => void;
type ErrorHandler = (error: any, traceId: string) => void;
type CompleteHandler = (traceId: string) => void;
type ResetHandler = (data: any, traceId: string) => void;
+type ActivityHandler = () => void;
type TraceRecord = {
data: StreamHandler[];
error: ErrorHandler[];
complete: CompleteHandler[];
reset: ResetHandler[];
+ activity: ActivityHandler[];
isInitiated: boolean;
streamId: string | null;
abortController: AbortController | null;
@@ -118,7 +120,9 @@ const useHttpStreaming = () => {
data: (data: any, response: any) => void;
error: (data: any, response: any) => void;
complete: (data: any, response: any) => void;
- reset: (data: any, response: any) => void;
+ reset?: (data: any, response: any) => void;
+ // Fires on every raw chunk read, before any parsing, so a caller can time out a silent stream.
+ onActivity?: () => void;
},
) => {
const { traceId } = data;
@@ -129,6 +133,7 @@ const useHttpStreaming = () => {
error: [],
complete: [],
reset: [],
+ activity: [],
isInitiated: false,
streamId: null,
abortController: null,
@@ -140,7 +145,8 @@ const useHttpStreaming = () => {
traceMap.value[traceId].data.push((res) => handlers.data(data, res));
traceMap.value[traceId].error.push((err) => handlers.error(data, err));
traceMap.value[traceId].complete.push((_) => handlers.complete(data, _));
- traceMap.value[traceId].reset.push((res) => handlers.reset(data, res));
+ traceMap.value[traceId].reset.push((res) => handlers.reset?.(data, res));
+ if (handlers.onActivity) traceMap.value[traceId].activity.push(handlers.onActivity);
// If the stream connection is already initiated for this trace, exit early
if (traceMap.value[traceId].isInitiated) {
@@ -378,6 +384,8 @@ const useHttpStreaming = () => {
const { done, value } = await reader.read();
+ for (const handler of traceMap.value[traceId]?.activity ?? []) handler();
+
if (done) {
worker.postMessage({
action: "endStream",
diff --git a/web/src/locales/languages/ar-SA.json b/web/src/locales/languages/ar-SA.json
index 0305cf477a..c03e624507 100644
--- a/web/src/locales/languages/ar-SA.json
+++ b/web/src/locales/languages/ar-SA.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "لا توجد إعادة تشغيل متاحة",
"loadingSessionReplay": "إعادة تشغيل جلسة التحميل...",
+ "sessionReplaySeekBehindWindow": "هذه اللحظة تسبق الجزء المُحمَّل من الجلسة. أعد فتح الجلسة من هذا الحدث لتشغيله.",
+ "sessionReplayTruncated": "هذه الجلسة طويلة جدًا لتحميلها بالكامل. يتم عرض أول {count} مقطع.",
+ "sessionReplayLoadingPercent": "جارٍ التحميل {percent}%",
+ "sessionReplayFullyLoaded": "تم التحميل بالكامل",
+ "sessionReplayLoadFailedChip": "فشل التحميل · إعادة المحاولة",
+ "sessionReplayLoadFailed": "تعذّر تحميل إعادة تشغيل الجلسة.",
+ "sessionReplayLoadFailedRange": "تعذّر تحميل {from} – {to}",
+ "sessionReplayRetrying": "جارٍ إعادة المحاولة ({attempt} من {total})…",
+ "sessionReplayNextPart": "جارٍ تحميل الجزء التالي من الجلسة…",
+ "sessionReplayResumesOnItsOwn": "يستأنف التشغيل من تلقاء نفسه",
+ "sessionReplayLoadingUpTo": "جارٍ التحميل حتى {time}…",
+ "sessionReplaySinglePageHint": "تحتوي هذه الجلسة على صفحة واحدة، لذا يجب تحميل كل ما قبل {time} أولًا",
+ "sessionReplayNotLoadedYet": "لم يتم تحميله بعد",
+ "sessionReplayTruncatedTooltip": "غير متاح: تجاوز حد 50,000 مقطع",
+ "sessionReplaySkippedParts": "· تعذّر تحميل {n} جزء | · تعذّر تحميل {n} أجزاء",
+ "sessionReplayGapTooltip": "تعذّر تحميل هذا الجزء",
+ "sessionReplayRetry": "إعادة المحاولة",
+ "sessionReplayLiveBadge": "مباشر",
+ "sessionReplayLateRows": "وصل نشاط أقدم بعد تحميل إعادة التشغيل. أعد التحميل لتضمينه.",
+ "sessionReplayReload": "إعادة التحميل",
"seek": "ابحث",
"sessionMetricsAria": "ملخص مقاييس الجلسة",
"frustration": "{count} الإحباط | {count} الإحباطات",
diff --git a/web/src/locales/languages/de-DE.json b/web/src/locales/languages/de-DE.json
index f963c7b01c..40379688bd 100644
--- a/web/src/locales/languages/de-DE.json
+++ b/web/src/locales/languages/de-DE.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "Kein Session-Replay verfügbar",
"loadingSessionReplay": "Session-Replay wird geladen…",
+ "sessionReplaySeekBehindWindow": "Dieser Zeitpunkt liegt vor dem geladenen Teil der Sitzung. Öffnen Sie die Sitzung von diesem Ereignis aus erneut, um sie abzuspielen.",
+ "sessionReplayTruncated": "Diese Sitzung ist zu lang, um sie vollständig zu laden. Es werden die ersten {count} Segmente angezeigt.",
+ "sessionReplayLoadingPercent": "Wird geladen: {percent}%",
+ "sessionReplayFullyLoaded": "Vollständig geladen",
+ "sessionReplayLoadFailedChip": "Laden fehlgeschlagen · Erneut versuchen",
+ "sessionReplayLoadFailed": "Die Sitzungswiedergabe konnte nicht geladen werden.",
+ "sessionReplayLoadFailedRange": "{from} – {to} konnte nicht geladen werden",
+ "sessionReplayRetrying": "Erneuter Versuch ({attempt} von {total})…",
+ "sessionReplayNextPart": "Der nächste Teil der Sitzung wird geladen…",
+ "sessionReplayResumesOnItsOwn": "Die Wiedergabe wird automatisch fortgesetzt",
+ "sessionReplayLoadingUpTo": "Wird geladen bis {time}…",
+ "sessionReplaySinglePageHint": "Diese Sitzung hat nur eine Seite, daher muss zuerst alles vor {time} geladen werden",
+ "sessionReplayNotLoadedYet": "noch nicht geladen",
+ "sessionReplayTruncatedTooltip": "Nicht verfügbar: über dem Limit von 50.000 Segmenten",
+ "sessionReplaySkippedParts": "· {n} Teil konnte nicht geladen werden | · {n} Teile konnten nicht geladen werden",
+ "sessionReplayGapTooltip": "Dieser Teil konnte nicht geladen werden",
+ "sessionReplayRetry": "Erneut versuchen",
+ "sessionReplayLiveBadge": "Live",
+ "sessionReplayLateRows": "Frühere Aktivität ist nach dem Laden der Wiedergabe eingetroffen. Neu laden, um sie einzubeziehen.",
+ "sessionReplayReload": "Neu laden",
"seek": "Springen",
"sessionMetricsAria": "Zusammenfassung der Session-Metriken",
"frustration": "{count} Frustration | {count} Frustrationen",
diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json
index 341514061a..3165243cb6 100644
--- a/web/src/locales/languages/en-US.json
+++ b/web/src/locales/languages/en-US.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "No session replay available",
"loadingSessionReplay": "Loading session replay…",
+ "sessionReplaySeekBehindWindow": "This moment is before the part of the session that is loaded. Reopen the session from this event to play it.",
+ "sessionReplayTruncated": "This session is too long to load completely. Showing the first {count} segments.",
+ "sessionReplayLoadingPercent": "Loading {percent}%",
+ "sessionReplayFullyLoaded": "Fully loaded",
+ "sessionReplayLoadFailedChip": "Load failed · Retry",
+ "sessionReplayLoadFailed": "Couldn't load the session replay.",
+ "sessionReplayLoadFailedRange": "Couldn't load {from} – {to}",
+ "sessionReplayRetrying": "Retrying ({attempt} of {total})…",
+ "sessionReplayNextPart": "Loading the next part of the session…",
+ "sessionReplayResumesOnItsOwn": "Playback resumes on its own",
+ "sessionReplayLoadingUpTo": "Loading up to {time}…",
+ "sessionReplaySinglePageHint": "This session has one page, so everything before {time} has to load first",
+ "sessionReplayNotLoadedYet": "not loaded yet",
+ "sessionReplayTruncatedTooltip": "Not available: past the 50,000-segment limit",
+ "sessionReplaySkippedParts": "· {n} part couldn't load | · {n} parts couldn't load",
+ "sessionReplayGapTooltip": "This part couldn't load",
+ "sessionReplayRetry": "Retry",
+ "sessionReplayLiveBadge": "Live",
+ "sessionReplayLateRows": "Earlier activity arrived after the replay loaded. Reload to include it.",
+ "sessionReplayReload": "Reload",
"seek": "Seek",
"sessionMetricsAria": "Session metrics summary",
"frustration": "{count} Frustration | {count} Frustrations",
diff --git a/web/src/locales/languages/es-ES.json b/web/src/locales/languages/es-ES.json
index 76bd6ac1a0..4b1e48381b 100644
--- a/web/src/locales/languages/es-ES.json
+++ b/web/src/locales/languages/es-ES.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "No hay repetición de sesión disponible",
"loadingSessionReplay": "Cargando repetición de sesión…",
+ "sessionReplaySeekBehindWindow": "Este momento es anterior a la parte de la sesión que está cargada. Vuelve a abrir la sesión desde este evento para reproducirlo.",
+ "sessionReplayTruncated": "Esta sesión es demasiado larga para cargarla por completo. Se muestran los primeros {count} segmentos.",
+ "sessionReplayLoadingPercent": "Cargando {percent}%",
+ "sessionReplayFullyLoaded": "Cargado por completo",
+ "sessionReplayLoadFailedChip": "Error de carga · Reintentar",
+ "sessionReplayLoadFailed": "No se pudo cargar la repetición de sesión.",
+ "sessionReplayLoadFailedRange": "No se pudo cargar {from} – {to}",
+ "sessionReplayRetrying": "Reintentando ({attempt} de {total})…",
+ "sessionReplayNextPart": "Cargando la siguiente parte de la sesión…",
+ "sessionReplayResumesOnItsOwn": "La reproducción se reanuda automáticamente",
+ "sessionReplayLoadingUpTo": "Cargando hasta {time}…",
+ "sessionReplaySinglePageHint": "Esta sesión tiene una sola página, por lo que todo lo anterior a {time} debe cargarse primero",
+ "sessionReplayNotLoadedYet": "aún no cargado",
+ "sessionReplayTruncatedTooltip": "No disponible: supera el límite de 50.000 segmentos",
+ "sessionReplaySkippedParts": "· {n} parte no se pudo cargar | · {n} partes no se pudieron cargar",
+ "sessionReplayGapTooltip": "Esta parte no se pudo cargar",
+ "sessionReplayRetry": "Reintentar",
+ "sessionReplayLiveBadge": "En vivo",
+ "sessionReplayLateRows": "Llegó actividad anterior después de que se cargara la repetición. Vuelve a cargar para incluirla.",
+ "sessionReplayReload": "Recargar",
"seek": "Buscar",
"sessionMetricsAria": "Resumen de métricas de sesión",
"frustration": "{count} Frustración | {count} Frustraciones",
diff --git a/web/src/locales/languages/fr-FR.json b/web/src/locales/languages/fr-FR.json
index 6143f10819..cadda77c4e 100644
--- a/web/src/locales/languages/fr-FR.json
+++ b/web/src/locales/languages/fr-FR.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "Aucune relecture de session disponible",
"loadingSessionReplay": "Chargement de la relecture de session…",
+ "sessionReplaySeekBehindWindow": "Ce moment précède la partie de la session qui est chargée. Rouvrez la session à partir de cet événement pour le lire.",
+ "sessionReplayTruncated": "Cette session est trop longue pour être chargée complètement. Affichage des {count} premiers segments.",
+ "sessionReplayLoadingPercent": "Chargement {percent} %",
+ "sessionReplayFullyLoaded": "Entièrement chargé",
+ "sessionReplayLoadFailedChip": "Échec du chargement · Réessayer",
+ "sessionReplayLoadFailed": "Impossible de charger la relecture de session.",
+ "sessionReplayLoadFailedRange": "Impossible de charger {from} – {to}",
+ "sessionReplayRetrying": "Nouvelle tentative ({attempt} sur {total})…",
+ "sessionReplayNextPart": "Chargement de la partie suivante de la session…",
+ "sessionReplayResumesOnItsOwn": "La lecture reprend automatiquement",
+ "sessionReplayLoadingUpTo": "Chargement jusqu'à {time}…",
+ "sessionReplaySinglePageHint": "Cette session comporte une seule page, donc tout ce qui précède {time} doit être chargé d'abord",
+ "sessionReplayNotLoadedYet": "pas encore chargé",
+ "sessionReplayTruncatedTooltip": "Non disponible : au-delà de la limite de 50 000 segments",
+ "sessionReplaySkippedParts": "· {n} partie n'a pas pu être chargée | · {n} parties n'ont pas pu être chargées",
+ "sessionReplayGapTooltip": "Cette partie n'a pas pu être chargée",
+ "sessionReplayRetry": "Réessayer",
+ "sessionReplayLiveBadge": "Temps réel",
+ "sessionReplayLateRows": "Une activité antérieure est arrivée après le chargement de la relecture. Rechargez pour l'inclure.",
+ "sessionReplayReload": "Recharger",
"seek": "Aller à",
"sessionMetricsAria": "Résumé des métriques de session",
"frustration": "{count} Frustration | {count} Frustrations",
diff --git a/web/src/locales/languages/it-IT.json b/web/src/locales/languages/it-IT.json
index 33847fe57c..8d3f41225f 100644
--- a/web/src/locales/languages/it-IT.json
+++ b/web/src/locales/languages/it-IT.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "Nessuna sessione replay disponibile",
"loadingSessionReplay": "Caricamento sessione replay…",
+ "sessionReplaySeekBehindWindow": "Questo momento precede la parte caricata della sessione. Riapri la sessione da questo evento per riprodurlo.",
+ "sessionReplayTruncated": "Questa sessione è troppo lunga per essere caricata completamente. Vengono mostrati i primi {count} segmenti.",
+ "sessionReplayLoadingPercent": "Caricamento {percent}%",
+ "sessionReplayFullyLoaded": "Caricamento completo",
+ "sessionReplayLoadFailedChip": "Caricamento non riuscito · Riprova",
+ "sessionReplayLoadFailed": "Impossibile caricare la riproduzione della sessione.",
+ "sessionReplayLoadFailedRange": "Impossibile caricare {from} – {to}",
+ "sessionReplayRetrying": "Nuovo tentativo ({attempt} di {total})…",
+ "sessionReplayNextPart": "Caricamento della parte successiva della sessione…",
+ "sessionReplayResumesOnItsOwn": "La riproduzione riprende automaticamente",
+ "sessionReplayLoadingUpTo": "Caricamento fino a {time}…",
+ "sessionReplaySinglePageHint": "Questa sessione ha una sola pagina, quindi tutto ciò che precede {time} deve essere caricato prima",
+ "sessionReplayNotLoadedYet": "non ancora caricato",
+ "sessionReplayTruncatedTooltip": "Non disponibile: oltre il limite di 50.000 segmenti",
+ "sessionReplaySkippedParts": "· Impossibile caricare {n} parte | · Impossibile caricare {n} parti",
+ "sessionReplayGapTooltip": "Impossibile caricare questa parte",
+ "sessionReplayRetry": "Riprova",
+ "sessionReplayLiveBadge": "Live",
+ "sessionReplayLateRows": "Un'attività precedente è arrivata dopo il caricamento della riproduzione. Ricarica per includerla.",
+ "sessionReplayReload": "Ricarica",
"seek": "Cerca",
"sessionMetricsAria": "Riepilogo metriche sessione",
"frustration": "{count} frustrazione | {count} frustrazioni",
diff --git a/web/src/locales/languages/ja-JP.json b/web/src/locales/languages/ja-JP.json
index 6eb26d16b7..85de4590b6 100644
--- a/web/src/locales/languages/ja-JP.json
+++ b/web/src/locales/languages/ja-JP.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "セッションリプレイは利用できません",
"loadingSessionReplay": "セッションリプレイを読み込み中…",
+ "sessionReplaySeekBehindWindow": "この時点は、読み込み済みのセッション部分より前です。このイベントからセッションを開き直すと再生できます。",
+ "sessionReplayTruncated": "このセッションは長すぎて完全に読み込めません。最初の {count} セグメントを表示しています。",
+ "sessionReplayLoadingPercent": "読み込み中 {percent}%",
+ "sessionReplayFullyLoaded": "読み込み完了",
+ "sessionReplayLoadFailedChip": "読み込みに失敗 · 再試行",
+ "sessionReplayLoadFailed": "セッションリプレイを読み込めませんでした。",
+ "sessionReplayLoadFailedRange": "{from} – {to} を読み込めませんでした",
+ "sessionReplayRetrying": "再試行中({attempt}/{total})…",
+ "sessionReplayNextPart": "セッションの次の部分を読み込み中…",
+ "sessionReplayResumesOnItsOwn": "再生は自動的に再開されます",
+ "sessionReplayLoadingUpTo": "{time} まで読み込み中…",
+ "sessionReplaySinglePageHint": "このセッションは 1 ページのみのため、{time} より前をすべて先に読み込む必要があります",
+ "sessionReplayNotLoadedYet": "まだ読み込まれていません",
+ "sessionReplayTruncatedTooltip": "利用できません:50,000 セグメントの上限を超えています",
+ "sessionReplaySkippedParts": "· {n} 個の部分を読み込めませんでした | · {n} 個の部分を読み込めませんでした",
+ "sessionReplayGapTooltip": "この部分を読み込めませんでした",
+ "sessionReplayRetry": "再試行",
+ "sessionReplayLiveBadge": "ライブ",
+ "sessionReplayLateRows": "リプレイの読み込み後に、それより前のアクティビティが届きました。含めるには再読み込みしてください。",
+ "sessionReplayReload": "再読み込み",
"seek": "シーク",
"sessionMetricsAria": "セッションメトリクスの概要",
"frustration": "{count} フラストレーション | {count} フラストレーション",
diff --git a/web/src/locales/languages/ko-KR.json b/web/src/locales/languages/ko-KR.json
index bb3223e338..24f40632d6 100644
--- a/web/src/locales/languages/ko-KR.json
+++ b/web/src/locales/languages/ko-KR.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "사용 가능한 세션 재생이 없습니다",
"loadingSessionReplay": "세션 재생 로드 중…",
+ "sessionReplaySeekBehindWindow": "이 시점은 로드된 세션 부분보다 앞에 있습니다. 재생하려면 이 이벤트에서 세션을 다시 여세요.",
+ "sessionReplayTruncated": "이 세션은 너무 길어서 완전히 로드할 수 없습니다. 처음 {count}개 세그먼트를 표시합니다.",
+ "sessionReplayLoadingPercent": "{percent}% 로딩 중",
+ "sessionReplayFullyLoaded": "전체 로드됨",
+ "sessionReplayLoadFailedChip": "로드 실패 · 다시 시도",
+ "sessionReplayLoadFailed": "세션 리플레이를 로드할 수 없습니다.",
+ "sessionReplayLoadFailedRange": "{from} – {to} 구간을 로드할 수 없습니다",
+ "sessionReplayRetrying": "다시 시도 중 ({attempt}/{total})…",
+ "sessionReplayNextPart": "세션의 다음 부분을 로딩 중…",
+ "sessionReplayResumesOnItsOwn": "재생이 자동으로 재개됩니다",
+ "sessionReplayLoadingUpTo": "{time}까지 로딩 중…",
+ "sessionReplaySinglePageHint": "이 세션은 페이지가 하나이므로 {time} 이전의 모든 항목을 먼저 로드해야 합니다",
+ "sessionReplayNotLoadedYet": "아직 로드되지 않음",
+ "sessionReplayTruncatedTooltip": "사용할 수 없음: 50,000개 세그먼트 한도 초과",
+ "sessionReplaySkippedParts": "· {n}개 부분을 로드할 수 없음 | · {n}개 부분을 로드할 수 없음",
+ "sessionReplayGapTooltip": "이 부분을 로드할 수 없습니다",
+ "sessionReplayRetry": "다시 시도",
+ "sessionReplayLiveBadge": "라이브",
+ "sessionReplayLateRows": "리플레이가 로드된 후 이전 활동이 도착했습니다. 포함하려면 다시 로드하세요.",
+ "sessionReplayReload": "다시 로드",
"seek": "탐색",
"sessionMetricsAria": "세션 메트릭 요약",
"frustration": "{count} 좌절 | {count} 좌절",
diff --git a/web/src/locales/languages/nl-NL.json b/web/src/locales/languages/nl-NL.json
index 707b30f7bc..495d847e6c 100644
--- a/web/src/locales/languages/nl-NL.json
+++ b/web/src/locales/languages/nl-NL.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "Geen sessieherhaling beschikbaar",
"loadingSessionReplay": "Sessieherhaling laden…",
+ "sessionReplaySeekBehindWindow": "Dit moment ligt vóór het geladen deel van de sessie. Open de sessie opnieuw vanaf deze gebeurtenis om dit af te spelen.",
+ "sessionReplayTruncated": "Deze sessie is te lang om volledig te laden. De eerste {count} segmenten worden weergegeven.",
+ "sessionReplayLoadingPercent": "Laden {percent}%",
+ "sessionReplayFullyLoaded": "Volledig geladen",
+ "sessionReplayLoadFailedChip": "Laden mislukt · Opnieuw proberen",
+ "sessionReplayLoadFailed": "De sessie-replay kon niet worden geladen.",
+ "sessionReplayLoadFailedRange": "Laden van {from} – {to} mislukt",
+ "sessionReplayRetrying": "Opnieuw proberen ({attempt} van {total})…",
+ "sessionReplayNextPart": "Het volgende deel van de sessie laden…",
+ "sessionReplayResumesOnItsOwn": "Afspelen wordt automatisch hervat",
+ "sessionReplayLoadingUpTo": "Laden tot {time}…",
+ "sessionReplaySinglePageHint": "Deze sessie heeft één pagina, dus alles vóór {time} moet eerst geladen worden",
+ "sessionReplayNotLoadedYet": "nog niet geladen",
+ "sessionReplayTruncatedTooltip": "Niet beschikbaar: voorbij de limiet van 50.000 segmenten",
+ "sessionReplaySkippedParts": "· {n} deel kon niet worden geladen | · {n} delen konden niet worden geladen",
+ "sessionReplayGapTooltip": "Dit deel kon niet worden geladen",
+ "sessionReplayRetry": "Opnieuw proberen",
+ "sessionReplayLiveBadge": "Live",
+ "sessionReplayLateRows": "Eerdere activiteit kwam binnen nadat de replay was geladen. Herlaad om deze op te nemen.",
+ "sessionReplayReload": "Herladen",
"seek": "Zoeken",
"sessionMetricsAria": "Samenvatting sessiemetriek",
"frustration": "{count} frustratie | {count} frustraties",
diff --git a/web/src/locales/languages/pl-PL.json b/web/src/locales/languages/pl-PL.json
index ed377f86dc..3234bf77c9 100644
--- a/web/src/locales/languages/pl-PL.json
+++ b/web/src/locales/languages/pl-PL.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "Brak odtwarzania sesji",
"loadingSessionReplay": "Ładowanie odtwarzania sesji…",
+ "sessionReplaySeekBehindWindow": "Ten moment znajduje się przed wczytaną częścią sesji. Otwórz ponownie sesję od tego zdarzenia, aby go odtworzyć.",
+ "sessionReplayTruncated": "Ta sesja jest zbyt długa, aby załadować ją w całości. Wyświetlanych jest pierwszych {count} segmentów.",
+ "sessionReplayLoadingPercent": "Ładowanie {percent}%",
+ "sessionReplayFullyLoaded": "Załadowano w całości",
+ "sessionReplayLoadFailedChip": "Ładowanie nie powiodło się · Ponów",
+ "sessionReplayLoadFailed": "Nie udało się załadować odtwarzania sesji.",
+ "sessionReplayLoadFailedRange": "Nie udało się załadować {from} – {to}",
+ "sessionReplayRetrying": "Ponawianie ({attempt} z {total})…",
+ "sessionReplayNextPart": "Ładowanie kolejnej części sesji…",
+ "sessionReplayResumesOnItsOwn": "Odtwarzanie wznowi się automatycznie",
+ "sessionReplayLoadingUpTo": "Ładowanie do {time}…",
+ "sessionReplaySinglePageHint": "Ta sesja ma jedną stronę, więc wszystko przed {time} musi zostać załadowane najpierw",
+ "sessionReplayNotLoadedYet": "jeszcze nie załadowano",
+ "sessionReplayTruncatedTooltip": "Niedostępne: przekroczono limit 50 000 segmentów",
+ "sessionReplaySkippedParts": "· nie udało się załadować {n} części | · nie udało się załadować {n} części",
+ "sessionReplayGapTooltip": "Nie udało się załadować tej części",
+ "sessionReplayRetry": "Ponów",
+ "sessionReplayLiveBadge": "Na żywo",
+ "sessionReplayLateRows": "Wcześniejsza aktywność pojawiła się po załadowaniu odtwarzania. Odśwież, aby ją uwzględnić.",
+ "sessionReplayReload": "Odśwież",
"seek": "Przewiń",
"sessionMetricsAria": "Podsumowanie metryk sesji",
"frustration": "{count} frustracja | {count} frustracje",
diff --git a/web/src/locales/languages/pt-PT.json b/web/src/locales/languages/pt-PT.json
index 5191819b48..912155d0ce 100644
--- a/web/src/locales/languages/pt-PT.json
+++ b/web/src/locales/languages/pt-PT.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "Nenhum replay de sessão disponível",
"loadingSessionReplay": "Carregando replay de sessão…",
+ "sessionReplaySeekBehindWindow": "Este momento é anterior à parte da sessão que está carregada. Reabra a sessão a partir deste evento para reproduzi-la.",
+ "sessionReplayTruncated": "Esta sessão é longa demais para ser carregada por completo. Exibindo os primeiros {count} segmentos.",
+ "sessionReplayLoadingPercent": "Carregando {percent}%",
+ "sessionReplayFullyLoaded": "Totalmente carregado",
+ "sessionReplayLoadFailedChip": "Falha no carregamento · Tentar novamente",
+ "sessionReplayLoadFailed": "Não foi possível carregar a reprodução da sessão.",
+ "sessionReplayLoadFailedRange": "Não foi possível carregar {from} – {to}",
+ "sessionReplayRetrying": "Tentando novamente ({attempt} de {total})…",
+ "sessionReplayNextPart": "Carregando a próxima parte da sessão…",
+ "sessionReplayResumesOnItsOwn": "A reprodução é retomada automaticamente",
+ "sessionReplayLoadingUpTo": "Carregando até {time}…",
+ "sessionReplaySinglePageHint": "Esta sessão tem apenas uma página, então tudo antes de {time} precisa ser carregado primeiro",
+ "sessionReplayNotLoadedYet": "ainda não carregado",
+ "sessionReplayTruncatedTooltip": "Não disponível: além do limite de 50.000 segmentos",
+ "sessionReplaySkippedParts": "· {n} parte não pôde ser carregada | · {n} partes não puderam ser carregadas",
+ "sessionReplayGapTooltip": "Esta parte não pôde ser carregada",
+ "sessionReplayRetry": "Tentar novamente",
+ "sessionReplayLiveBadge": "Ao vivo",
+ "sessionReplayLateRows": "Atividades anteriores chegaram depois que a reprodução foi carregada. Recarregue para incluí-las.",
+ "sessionReplayReload": "Recarregar",
"seek": "Buscar",
"sessionMetricsAria": "Resumo das métricas da sessão",
"frustration": "{count} Frustração | {count} Frustrações",
diff --git a/web/src/locales/languages/ru-RU.json b/web/src/locales/languages/ru-RU.json
index dca85dc8da..8edbc1f5de 100644
--- a/web/src/locales/languages/ru-RU.json
+++ b/web/src/locales/languages/ru-RU.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "Нет доступного воспроизведения сеанса",
"loadingSessionReplay": "Загрузка воспроизведения сеанса…",
+ "sessionReplaySeekBehindWindow": "Этот момент находится до загруженной части сессии. Откройте сессию заново с этого события, чтобы воспроизвести её.",
+ "sessionReplayTruncated": "Эта сессия слишком длинная для полной загрузки. Показаны первые {count} сегментов.",
+ "sessionReplayLoadingPercent": "Загрузка {percent}%",
+ "sessionReplayFullyLoaded": "Загружено полностью",
+ "sessionReplayLoadFailedChip": "Ошибка загрузки · Повторить",
+ "sessionReplayLoadFailed": "Не удалось загрузить запись сессии.",
+ "sessionReplayLoadFailedRange": "Не удалось загрузить {from} – {to}",
+ "sessionReplayRetrying": "Повторная попытка ({attempt} из {total})…",
+ "sessionReplayNextPart": "Загрузка следующей части сессии…",
+ "sessionReplayResumesOnItsOwn": "Воспроизведение возобновится автоматически",
+ "sessionReplayLoadingUpTo": "Загрузка до {time}…",
+ "sessionReplaySinglePageHint": "В этой сессии одна страница, поэтому сначала нужно загрузить всё до {time}",
+ "sessionReplayNotLoadedYet": "ещё не загружено",
+ "sessionReplayTruncatedTooltip": "Недоступно: превышен лимит в 50 000 сегментов",
+ "sessionReplaySkippedParts": "· {n} часть не загрузилась | · {n} частей не загрузилось",
+ "sessionReplayGapTooltip": "Эту часть не удалось загрузить",
+ "sessionReplayRetry": "Повторить",
+ "sessionReplayLiveBadge": "В реальном времени",
+ "sessionReplayLateRows": "Более ранняя активность появилась после загрузки записи. Перезагрузите, чтобы включить её.",
+ "sessionReplayReload": "Перезагрузить",
"seek": "Перейти",
"sessionMetricsAria": "Сводка метрик сеанса",
"frustration": "{count} фрустрация | {count} фрустраций",
diff --git a/web/src/locales/languages/tr-TR.json b/web/src/locales/languages/tr-TR.json
index 8ba712c5a0..4217c0a35b 100644
--- a/web/src/locales/languages/tr-TR.json
+++ b/web/src/locales/languages/tr-TR.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "Oturum tekrarı mevcut değil",
"loadingSessionReplay": "Oturum tekrarı yükleniyor…",
+ "sessionReplaySeekBehindWindow": "Bu an, oturumun yüklenmiş olan bölümünden önce. Oynatmak için oturumu bu olaydan yeniden açın.",
+ "sessionReplayTruncated": "Bu oturum tamamen yüklenemeyecek kadar uzun. İlk {count} segment gösteriliyor.",
+ "sessionReplayLoadingPercent": "%{percent} yükleniyor",
+ "sessionReplayFullyLoaded": "Tamamen yüklendi",
+ "sessionReplayLoadFailedChip": "Yükleme başarısız · Yeniden dene",
+ "sessionReplayLoadFailed": "Oturum tekrarı yüklenemedi.",
+ "sessionReplayLoadFailedRange": "{from} – {to} yüklenemedi",
+ "sessionReplayRetrying": "Yeniden deneniyor ({attempt}/{total})…",
+ "sessionReplayNextPart": "Oturumun sonraki bölümü yükleniyor…",
+ "sessionReplayResumesOnItsOwn": "Oynatma kendiliğinden devam eder",
+ "sessionReplayLoadingUpTo": "{time} zamanına kadar yükleniyor…",
+ "sessionReplaySinglePageHint": "Bu oturumda tek sayfa var, bu nedenle {time} öncesindeki her şeyin önce yüklenmesi gerekiyor",
+ "sessionReplayNotLoadedYet": "henüz yüklenmedi",
+ "sessionReplayTruncatedTooltip": "Kullanılamıyor: 50.000 segment sınırı aşıldı",
+ "sessionReplaySkippedParts": "· {n} bölüm yüklenemedi | · {n} bölüm yüklenemedi",
+ "sessionReplayGapTooltip": "Bu bölüm yüklenemedi",
+ "sessionReplayRetry": "Yeniden dene",
+ "sessionReplayLiveBadge": "Canlı",
+ "sessionReplayLateRows": "Daha önceki etkinlik, tekrar yüklendikten sonra geldi. Dahil etmek için yeniden yükleyin.",
+ "sessionReplayReload": "Yeniden yükle",
"seek": "Atla",
"sessionMetricsAria": "Oturum metrikleri özeti",
"frustration": "{count} Hayal kırıklığı | {count} Hayal kırıklığı",
diff --git a/web/src/locales/languages/vi-VN.json b/web/src/locales/languages/vi-VN.json
index e74d901e57..fbacdd0848 100644
--- a/web/src/locales/languages/vi-VN.json
+++ b/web/src/locales/languages/vi-VN.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "Không có bản phát lại phiên nào",
"loadingSessionReplay": "Đang tải bản phát lại phiên…",
+ "sessionReplaySeekBehindWindow": "Thời điểm này nằm trước phần phiên đã được tải. Hãy mở lại phiên từ sự kiện này để phát.",
+ "sessionReplayTruncated": "Phiên này quá dài để tải hoàn toàn. Đang hiển thị {count} phân đoạn đầu tiên.",
+ "sessionReplayLoadingPercent": "Đang tải {percent}%",
+ "sessionReplayFullyLoaded": "Đã tải hoàn tất",
+ "sessionReplayLoadFailedChip": "Tải thất bại · Thử lại",
+ "sessionReplayLoadFailed": "Không thể tải bản phát lại phiên.",
+ "sessionReplayLoadFailedRange": "Không thể tải {from} – {to}",
+ "sessionReplayRetrying": "Đang thử lại ({attempt} trên {total})…",
+ "sessionReplayNextPart": "Đang tải phần tiếp theo của phiên…",
+ "sessionReplayResumesOnItsOwn": "Quá trình phát lại tự tiếp tục",
+ "sessionReplayLoadingUpTo": "Đang tải đến {time}…",
+ "sessionReplaySinglePageHint": "Phiên này chỉ có một trang, nên mọi thứ trước {time} phải được tải trước",
+ "sessionReplayNotLoadedYet": "chưa được tải",
+ "sessionReplayTruncatedTooltip": "Không khả dụng: vượt quá giới hạn 50.000 phân đoạn",
+ "sessionReplaySkippedParts": "· {n} phần không tải được | · {n} phần không tải được",
+ "sessionReplayGapTooltip": "Phần này không tải được",
+ "sessionReplayRetry": "Thử lại",
+ "sessionReplayLiveBadge": "Trực tiếp",
+ "sessionReplayLateRows": "Hoạt động trước đó đến sau khi bản phát lại đã tải. Tải lại để bao gồm hoạt động đó.",
+ "sessionReplayReload": "Tải lại",
"seek": "Tua",
"sessionMetricsAria": "Tóm tắt số liệu phiên",
"frustration": "{count} lần bực bội | {count} lần bực bội",
diff --git a/web/src/locales/languages/zh-CN.json b/web/src/locales/languages/zh-CN.json
index eb60ec923e..d7c451e59c 100644
--- a/web/src/locales/languages/zh-CN.json
+++ b/web/src/locales/languages/zh-CN.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "没有可用的会话回放",
"loadingSessionReplay": "正在加载会话回放…",
+ "sessionReplaySeekBehindWindow": "该时刻早于已加载的会话部分。请从此事件重新打开会话以进行播放。",
+ "sessionReplayTruncated": "此会话过长,无法完整加载。仅显示前 {count} 个片段。",
+ "sessionReplayLoadingPercent": "正在加载 {percent}%",
+ "sessionReplayFullyLoaded": "已完全加载",
+ "sessionReplayLoadFailedChip": "加载失败 · 重试",
+ "sessionReplayLoadFailed": "无法加载会话回放。",
+ "sessionReplayLoadFailedRange": "无法加载 {from} – {to}",
+ "sessionReplayRetrying": "正在重试(第 {attempt} 次,共 {total} 次)…",
+ "sessionReplayNextPart": "正在加载会话的下一部分…",
+ "sessionReplayResumesOnItsOwn": "播放将自动恢复",
+ "sessionReplayLoadingUpTo": "正在加载至 {time}…",
+ "sessionReplaySinglePageHint": "此会话只有一个页面,因此必须先加载 {time} 之前的所有内容",
+ "sessionReplayNotLoadedYet": "尚未加载",
+ "sessionReplayTruncatedTooltip": "不可用:已超出 50,000 个片段的限制",
+ "sessionReplaySkippedParts": "· {n} 个部分无法加载 | · {n} 个部分无法加载",
+ "sessionReplayGapTooltip": "此部分无法加载",
+ "sessionReplayRetry": "重试",
+ "sessionReplayLiveBadge": "实时",
+ "sessionReplayLateRows": "较早的活动在回放加载后才到达。请重新加载以包含它。",
+ "sessionReplayReload": "重新加载",
"seek": "定位",
"sessionMetricsAria": "会话指标摘要",
"frustration": "{count} 次挫败 | {count} 次挫败",
diff --git a/web/src/locales/languages/zh-TW.json b/web/src/locales/languages/zh-TW.json
index 9c29c67bbc..95905e868f 100644
--- a/web/src/locales/languages/zh-TW.json
+++ b/web/src/locales/languages/zh-TW.json
@@ -1350,6 +1350,26 @@
"rum": {
"noSessionReplay": "沒有可用的工作階段重播",
"loadingSessionReplay": "正在載入工作階段重播…",
+ "sessionReplaySeekBehindWindow": "此時刻早於已載入的工作階段部分。請從此事件重新開啟工作階段以播放。",
+ "sessionReplayTruncated": "此工作階段太長,無法完整載入。僅顯示前 {count} 個區段。",
+ "sessionReplayLoadingPercent": "正在載入 {percent}%",
+ "sessionReplayFullyLoaded": "已完整載入",
+ "sessionReplayLoadFailedChip": "載入失敗 · 重試",
+ "sessionReplayLoadFailed": "無法載入工作階段重播。",
+ "sessionReplayLoadFailedRange": "無法載入 {from} – {to}",
+ "sessionReplayRetrying": "正在重試(第 {attempt} 次,共 {total} 次)…",
+ "sessionReplayNextPart": "正在載入工作階段的下一部分…",
+ "sessionReplayResumesOnItsOwn": "播放會自行恢復",
+ "sessionReplayLoadingUpTo": "正在載入至 {time}…",
+ "sessionReplaySinglePageHint": "此工作階段只有一個頁面,因此必須先載入 {time} 之前的所有內容",
+ "sessionReplayNotLoadedYet": "尚未載入",
+ "sessionReplayTruncatedTooltip": "無法提供:已超過 50,000 個區段的上限",
+ "sessionReplaySkippedParts": "· {n} 個部分無法載入 | · {n} 個部分無法載入",
+ "sessionReplayGapTooltip": "此部分無法載入",
+ "sessionReplayRetry": "重試",
+ "sessionReplayLiveBadge": "即時",
+ "sessionReplayLateRows": "較早的活動在重播載入後才送達。請重新載入以納入。",
+ "sessionReplayReload": "重新載入",
"seek": "跳轉",
"sessionMetricsAria": "工作階段指標摘要",
"frustration": "{count} 次挫折 | {count} 次挫折",
diff --git a/web/src/styles/utilities.css b/web/src/styles/utilities.css
index 9a90d516bf..1c3f5c5c76 100644
--- a/web/src/styles/utilities.css
+++ b/web/src/styles/utilities.css
@@ -45,6 +45,15 @@
background-image: var(--color-chat-bubble-user);
}
+/* Diagonal hatch marking a span that exists but cannot be shown. Consumer: components/rum/ReplayLoadBand.vue. */
+@utility bg-hatch-unavailable {
+ background-image: repeating-linear-gradient(
+ 45deg,
+ var(--color-surface-subtle-hover) 0 0.1875rem,
+ transparent 0.1875rem 0.4375rem
+ );
+}
+
/* ── Drag-and-drop row affordances ──────────────────────────────────────────
Sortable adds these classes to a row CLONE it builds at runtime, so no
template can reach it and the name must be a single token. `!important`
diff --git a/web/src/utils/rum/sessionReplayChangeFormat.spec.ts b/web/src/utils/rum/sessionReplayChangeFormat.spec.ts
index aa879d2e15..79b102bf0b 100644
--- a/web/src/utils/rum/sessionReplayChangeFormat.spec.ts
+++ b/web/src/utils/rum/sessionReplayChangeFormat.spec.ts
@@ -17,6 +17,7 @@ import { describe, it, expect } from "vitest";
import {
createRecordConverter,
convertRecords,
+ dropChangesBeforeFirstSnapshot,
hasChangeFormatRecords,
} from "./sessionReplayChangeFormat";
@@ -140,6 +141,29 @@ describe("sessionReplayChangeFormat", () => {
const [out] = createRecordConverter().convert(record);
expect(out.data.node.adoptedStyleSheets).toEqual([{ cssRules: [".a{}", ".b{}"] }]);
});
+
+ it("drops insertions whose resolved parent is a text node instead of throwing", () => {
+ const record = fullSnapshot([
+ [
+ ADD_NODE,
+ [null, "#document"], // id0
+ [1, "BODY"], // id1 -> appended to the document
+ [1, "#text", "hi"], // id2 -> appended to BODY, has no childNodes
+ [1, "SPAN"], // id3 -> appendChild into the text node
+ [0, "B"], // id4 -> after id3, so parent is the text node
+ [-1, "I"], // id5 -> before id4, so parent is the text node
+ ],
+ ]);
+
+ const [out] = createRecordConverter().convert(record);
+
+ const body = out.data.node.childNodes[0];
+ expect(body.tagName).toBe("body");
+ expect(body.childNodes.map((n: any) => n.id)).toEqual([2]);
+ const text = body.childNodes[0];
+ expect(text.type).toBe(3);
+ expect(text.childNodes).toBeUndefined();
+ });
});
describe("incremental Change (type 12) conversion", () => {
@@ -208,6 +232,29 @@ describe("sessionReplayChangeFormat", () => {
});
expect(out[0].data.attributes).toEqual([{ id: 1, attributes: { hidden: null } }]);
});
+
+ it("adds into and removes from a text-node parent without throwing", () => {
+ const converter = createRecordConverter();
+ converter.convert(
+ fullSnapshot([[ADD_NODE, [null, "#document"], [1, "BODY"], [1, "#text", "hi"]]]),
+ );
+
+ const added = converter.convert({
+ type: 12,
+ timestamp: 5000,
+ data: [[ADD_NODE, [1, "SPAN"]]], // id3 -> appendChild into the text node id2
+ });
+ expect(added[0].data.adds).toEqual([
+ { parentId: 2, nextId: null, node: expect.objectContaining({ id: 3, tagName: "span" }) },
+ ]);
+
+ const removed = converter.convert({
+ type: 12,
+ timestamp: 6000,
+ data: [[REMOVE_NODE, 3]],
+ });
+ expect(removed[0].data.removes).toEqual([{ id: 3, parentId: 2 }]);
+ });
});
describe("unresolvable node references", () => {
@@ -344,6 +391,24 @@ describe("sessionReplayChangeFormat", () => {
expect(hasChangeFormatRecords([{ type: 2, data: { node: {} } }, { type: 3 }])).toBe(false);
});
+ it("dropChangesBeforeFirstSnapshot drops only the Change records before the snapshot", () => {
+ const meta = { type: 4, timestamp: 1, data: {} };
+ const earlyChange = { type: 12, timestamp: 2, data: [] };
+ const snapshot = fullSnapshot([[ADD_NODE, [null, "#document"]]]);
+ const laterChange = { type: 12, timestamp: 4, data: [] };
+
+ expect(dropChangesBeforeFirstSnapshot([meta, earlyChange, snapshot, laterChange])).toEqual([
+ meta,
+ snapshot,
+ laterChange,
+ ]);
+ });
+
+ it("dropChangesBeforeFirstSnapshot leaves a list with no Change-format snapshot alone", () => {
+ const records = [{ type: 4 }, { type: 12, data: [] }];
+ expect(dropChangesBeforeFirstSnapshot(records)).toEqual(records);
+ });
+
it("convertRecords threads state and resets on each full snapshot", () => {
const records = [
fullSnapshot([[ADD_NODE, [null, "#document"], [1, "BODY"]]]),
@@ -358,4 +423,88 @@ describe("sessionReplayChangeFormat", () => {
expect(out[2].data.node.childNodes[0].id).toBe(1);
});
});
+ describe("emitted records are copies", () => {
+ const snapshot = () =>
+ fullSnapshot([[ADD_NODE, [null, "#document"], [1, "BODY"], [1, "DIV", ["class", "a"]]]]);
+
+ function ids(node: any, out: number[] = []): number[] {
+ out.push(node.id);
+ for (const child of node.childNodes ?? []) ids(child, out);
+ return out;
+ }
+
+ it("leaves the emitted snapshot unchanged by later changes", () => {
+ const converter = createRecordConverter();
+ const [snap] = converter.convert(snapshot());
+ const before = JSON.stringify(snap);
+
+ converter.convert({
+ type: 12,
+ timestamp: 2000,
+ data: [
+ [ADD_NODE, [1, "SPAN"]],
+ [ATTRIBUTE, [2, ["class", "b"]]],
+ [REMOVE_NODE, 2],
+ ],
+ });
+
+ expect(JSON.stringify(snap)).toBe(before);
+ });
+
+ it("rebuilds at 0 without the ids later adds introduced, so a seek to 0 shows no duplicates", () => {
+ const converter = createRecordConverter();
+ const [snap] = converter.convert(snapshot());
+ const [mutation] = converter.convert({
+ type: 12,
+ timestamp: 2000,
+ data: [[ADD_NODE, [1, "SPAN"]]],
+ });
+
+ const snapshotIds = ids(snap.data.node);
+ expect(new Set(snapshotIds).size).toBe(snapshotIds.length);
+ expect(snapshotIds).not.toContain(mutation.data.adds[0].node.id);
+ });
+
+ it("keeps the earlier attribute state for a backward goto inside the run", () => {
+ const converter = createRecordConverter();
+ const [snap] = converter.convert(snapshot());
+ const [add] = converter.convert({
+ type: 12,
+ timestamp: 2000,
+ data: [[ADD_NODE, [1, "SPAN", ["title", "first"]]]],
+ });
+ converter.convert({
+ type: 12,
+ timestamp: 3000,
+ data: [[ATTRIBUTE, [3, ["title", "second"]], [2, ["class", "z"]]]],
+ });
+
+ expect(snap.data.node.childNodes[0].childNodes[0].attributes.class).toBe("a");
+ expect(add.data.adds[0].node.attributes.title).toBe("first");
+ });
+ });
+
+ describe("markStale", () => {
+ it("drops Change records until the next Change-format full snapshot", () => {
+ const converter = createRecordConverter();
+ converter.convert(fullSnapshot([[ADD_NODE, [null, "#document"], [1, "BODY"]]]));
+ converter.markStale();
+
+ const change = { type: 12, timestamp: 2000, data: [[TEXT, [1, "x"]]] };
+ expect(converter.convert(change)).toEqual([]);
+
+ const [snap] = converter.convert(
+ fullSnapshot([[ADD_NODE, [null, "#document"], [1, "BODY"]]]),
+ );
+ expect(snap.type).toBe(2);
+ expect(converter.convert(change)).toHaveLength(1);
+ });
+
+ it("passes classic records through while stale", () => {
+ const converter = createRecordConverter();
+ converter.markStale();
+ const meta = { type: 4, timestamp: 1, data: { width: 1, height: 1 } };
+ expect(converter.convert(meta)).toEqual([meta]);
+ });
+ });
});
diff --git a/web/src/utils/rum/sessionReplayChangeFormat.ts b/web/src/utils/rum/sessionReplayChangeFormat.ts
index 9fb67027f0..42fc945ffe 100644
--- a/web/src/utils/rum/sessionReplayChangeFormat.ts
+++ b/web/src/utils/rum/sessionReplayChangeFormat.ts
@@ -109,6 +109,12 @@ function getValidTagName(tagName: string): string {
return processedTagName;
}
+// structuredClone is missing before Safari 15.4, and the emitted tree is plain JSON anyway.
+function cloneTree(node: any): any {
+ if (typeof structuredClone === "function") return structuredClone(node);
+ return JSON.parse(JSON.stringify(node));
+}
+
// ---------------------------------------------------------------------------
// Internal decoder state
// ---------------------------------------------------------------------------
@@ -132,10 +138,13 @@ export interface RecordConverter {
* Records that are already in the classic format are returned unchanged.
*/
convert(record: any): any[];
+ /** A segment was skipped: drop Change records until the next Change-format full snapshot. */
+ markStale(): void;
}
export function createRecordConverter(): RecordConverter {
let stringTable: string[] = [];
+ let stale = false;
let nextNodeId = 0;
let nodes = new Map
();
let styleSheets = new Map();
@@ -223,18 +232,10 @@ export function createRecordConverter(): RecordConverter {
}
}
- // Children of a tracked node, or null when that node cannot hold any.
- //
- // Node ids are positional, so a mutation can name a parent the stream never established
- // — a segment dropped in transit, or a stream that opened without a FullSnapshot, both
- // leave the counter pointing somewhere else. The id then lands on a text or comment
- // node, which has no `childNodes`, and reading `.length` off it used to throw. That
- // throw escaped through VideoPlayer's unawaited setupSession() and left the player with
- // nothing at all, so one unresolvable reference cost the entire replay. Skipping the
- // single placement degrades far more gracefully.
- function childNodesOf(tracked: any): any[] | null {
+ // Text and CDATA nodes carry no childNodes, so every read of them must tolerate its absence.
+ function childrenOf(tracked: TrackedNode | undefined): any[] | undefined {
const children = tracked?.node?.childNodes;
- return Array.isArray(children) ? children : null;
+ return Array.isArray(children) ? children : undefined;
}
// Resolve where a node with the given id and insertion point sits in the tree.
@@ -250,28 +251,27 @@ export function createRecordConverter(): RecordConverter {
if (insertionPoint > 0) {
// appendChild to (id - insertionPoint)
const parentId = id - insertionPoint;
- const parent = nodes.get(parentId);
- const index = childNodesOf(parent)?.length ?? 0;
- return { parentId, index };
+ const children = childrenOf(nodes.get(parentId));
+ return { parentId, index: children ? children.length : 0 };
}
if (insertionPoint === 0) {
// insert right after previously-added node (id - 1)
const prevSiblingId = id - 1;
const prevSibling = nodes.get(prevSiblingId);
const parentId = prevSibling ? prevSibling.parentId : -1;
- const siblings = childNodesOf(nodes.get(parentId));
- if (!siblings) return { parentId, index: 0 };
- const idx = siblings.findIndex((c: any) => c.id === prevSiblingId);
- return { parentId, index: idx === -1 ? siblings.length : idx + 1 };
+ const children = childrenOf(nodes.get(parentId));
+ if (!children) return { parentId, index: 0 };
+ const idx = children.findIndex((c: any) => c.id === prevSiblingId);
+ return { parentId, index: idx === -1 ? children.length : idx + 1 };
}
// insertionPoint < 0 : insert before (id + insertionPoint)
const nextSiblingId = id + insertionPoint;
const nextSibling = nodes.get(nextSiblingId);
const parentId = nextSibling ? nextSibling.parentId : -1;
- const siblings = childNodesOf(nodes.get(parentId));
- if (!siblings) return { parentId, index: 0 };
- const idx = siblings.findIndex((c: any) => c.id === nextSiblingId);
- return { parentId, index: idx === -1 ? siblings.length : idx };
+ const children = childrenOf(nodes.get(parentId));
+ if (!children) return { parentId, index: 0 };
+ const idx = children.findIndex((c: any) => c.id === nextSiblingId);
+ return { parentId, index: idx === -1 ? children.length : idx };
}
function applyStyleSheetToNode(nodeId: number, sheetIds: number[]) {
@@ -339,7 +339,10 @@ export function createRecordConverter(): RecordConverter {
documentNode = node;
nodes.set(id, { node, parentId: -1 });
} else {
- childNodesOf(nodes.get(placement.parentId))?.splice(placement.index, 0, node);
+ const children = childrenOf(nodes.get(placement.parentId));
+ if (children) {
+ children.splice(placement.index, 0, node);
+ }
nodes.set(id, { node, parentId: placement.parentId });
}
}
@@ -405,13 +408,10 @@ export function createRecordConverter(): RecordConverter {
const out: any = { ...record };
delete out.format;
out.type = RecordType.FullSnapshot;
+ // rrweb rebuilds from this event on every seek, so it must not see the live tree later changes keep editing.
out.data = {
- // Emit a copy. `documentNode` stays the converter's live tree: every later Change
- // record in this view mutates it in place (children spliced in/out, attributes and
- // text rewritten). The caller converts a whole session before playback starts, so
- // handing out the live tree would replay each view in its final state.
node: documentNode
- ? structuredClone(documentNode)
+ ? cloneTree(documentNode)
: { type: NodeType.Document, childNodes: [], id: 0 },
initialOffset,
};
@@ -447,17 +447,15 @@ export function createRecordConverter(): RecordConverter {
let nextId: number | null = null;
if (placement !== null) {
parentId = placement.parentId;
- const siblings = childNodesOf(nodes.get(parentId));
- if (siblings) {
- const sibling = siblings[placement.index];
+ const children = childrenOf(nodes.get(parentId));
+ if (children) {
+ const sibling = children[placement.index];
nextId = sibling ? sibling.id : null;
- siblings.splice(placement.index, 0, node);
+ children.splice(placement.index, 0, node);
}
}
nodes.set(id, { node, parentId });
- // Emit the node with empty children; descendants arrive as their own adds.
- // Copy `attributes` too: the tracked node keeps receiving Attribute changes,
- // and a shared object would rewrite this add with attributes it only gets later.
+ // Descendants arrive as their own adds, and attributes are copied because later changes edit the tracked node.
const emitted = node.attributes
? { ...node, attributes: { ...node.attributes }, childNodes: [] }
: { ...node, childNodes: [] };
@@ -472,10 +470,10 @@ export function createRecordConverter(): RecordConverter {
const parentId = tracked ? tracked.parentId : -1;
removes.push({ id: nodeId, parentId });
if (tracked) {
- const siblings = childNodesOf(nodes.get(parentId));
- if (siblings) {
- const idx = siblings.findIndex((c: any) => c.id === nodeId);
- if (idx !== -1) siblings.splice(idx, 1);
+ const children = childrenOf(nodes.get(parentId));
+ if (children) {
+ const idx = children.findIndex((c: any) => c.id === nodeId);
+ if (idx !== -1) children.splice(idx, 1);
}
nodes.delete(nodeId);
}
@@ -628,14 +626,19 @@ export function createRecordConverter(): RecordConverter {
record.format === SnapshotFormatChange &&
Array.isArray(record.data)
) {
+ stale = false;
return [convertFullSnapshot(record)];
}
if (record && record.type === RecordType.Change) {
- return convertChange(record);
+ // String table and node ids are out of step after a skipped segment, so the frame freezes instead of corrupting.
+ return stale ? [] : convertChange(record);
}
// Already classic (or a passthrough record like Meta/Focus/type-3/type-8).
return [record];
},
+ markStale() {
+ stale = true;
+ },
};
}
@@ -665,3 +668,12 @@ export function hasChangeFormatRecords(records: any[]): boolean {
(r.type === RecordType.FullSnapshot && r.format === SnapshotFormatChange)),
);
}
+
+// A cold converter has no string table, so Change records before the first snapshot decode to empty strings; classic records are kept.
+export function dropChangesBeforeFirstSnapshot(records: any[]): any[] {
+ const snapshotIndex = records.findIndex(
+ (r) => r && r.type === RecordType.FullSnapshot && r.format === SnapshotFormatChange,
+ );
+ if (snapshotIndex <= 0) return records;
+ return records.filter((r, i) => i >= snapshotIndex || !r || r.type !== RecordType.Change);
+}
diff --git a/web/src/utils/rum/sessionReplayLive.spec.ts b/web/src/utils/rum/sessionReplayLive.spec.ts
new file mode 100644
index 0000000000..767188e976
--- /dev/null
+++ b/web/src/utils/rum/sessionReplayLive.spec.ts
@@ -0,0 +1,121 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, expect, it } from "vitest";
+import {
+ ACTIVE_WINDOW_MS,
+ LIVE_IDLE_STOP_MS,
+ LIVE_MAX_SESSION_MS,
+ errorLogKey,
+ eventKey,
+ isSessionLive,
+ mergeManifestTail,
+ raiseUpperTs,
+ shouldStopLive,
+} from "./sessionReplayLive";
+import { segmentId } from "./sessionReplayManifest";
+
+const row = (start: number, end = start + 999, extra: Record = {}) => ({
+ start,
+ end,
+ records_count: 1,
+ ...extra,
+});
+
+describe("isSessionLive", () => {
+ const now = 1_700_000_000_000;
+
+ it("is live up to and including the active window, like the Sessions list", () => {
+ expect(isSessionLive(now - ACTIVE_WINDOW_MS, now)).toBe(true);
+ expect(isSessionLive(now - ACTIVE_WINDOW_MS - 1, now)).toBe(false);
+ expect(isSessionLive(now - 1000, now)).toBe(true);
+ });
+
+ it("is never live without an end time", () => {
+ expect(isSessionLive(0, now)).toBe(false);
+ expect(isSessionLive(undefined, now)).toBe(false);
+ });
+});
+
+describe("raiseUpperTs", () => {
+ it("moves to the largest arrival time seen and never falls back", () => {
+ expect(raiseUpperTs(100, [{ _timestamp: 90 }, { _timestamp: 250 }], "_timestamp")).toBe(250);
+ expect(raiseUpperTs(250, [{ _timestamp: 10 }], "_timestamp")).toBe(250);
+ expect(raiseUpperTs(250, [], "_timestamp")).toBe(250);
+ expect(raiseUpperTs(250, [{}], "_timestamp")).toBe(250);
+ });
+});
+
+describe("mergeManifestTail", () => {
+ const manifest = [row(0), row(1000), row(2000)];
+ const known = new Set(manifest.map(segmentId));
+
+ it("appends only ids the manifest does not hold, in manifest order", () => {
+ const polled = [row(4000), row(2000), row(3000), row(3000, 3999, { _timestamp: 7 })];
+
+ const { appended, late } = mergeManifestTail(manifest, polled, known);
+
+ expect(appended.map((r) => r.start)).toEqual([3000, 4000]);
+ expect(late).toBe(false);
+ });
+
+ it("does not insert a new row that sorts before the tail, and flags it", () => {
+ const tiedBefore = row(2000, 2500);
+
+ const { appended, late } = mergeManifestTail(manifest, [tiedBefore, row(3000)], known);
+
+ expect(appended.map((r) => r.start)).toEqual([3000]);
+ expect(late).toBe(true);
+ });
+
+ it("returns nothing new for a poll that only repeats known rows", () => {
+ expect(mergeManifestTail(manifest, [row(1000), row(2000)], known)).toEqual({
+ appended: [],
+ late: false,
+ });
+ });
+});
+
+describe("shouldStopLive", () => {
+ const start = 1_700_000_000_000;
+
+ it("stops 15 minutes after the last new segment id", () => {
+ const last = start + 60_000;
+ expect(shouldStopLive(last, start, last + LIVE_IDLE_STOP_MS - 1)).toBe(false);
+ expect(shouldStopLive(last, start, last + LIVE_IDLE_STOP_MS)).toBe(true);
+ });
+
+ it("stops once the session reaches 4 hours", () => {
+ const now = start + LIVE_MAX_SESSION_MS;
+ expect(shouldStopLive(now, start, now)).toBe(true);
+ expect(shouldStopLive(now - 1, start, now - 1)).toBe(false);
+ });
+});
+
+describe("event keys", () => {
+ it("keys a RUM event by its own id and type", () => {
+ expect(eventKey({ type: "view", view_id: "v1" })).toBe("view|v1");
+ expect(eventKey({ type: "action", action_id: "a1" })).toBe("action|a1");
+ expect(eventKey({ type: "action" })).toBeNull();
+ });
+
+ it("keys an error log by arrival time, device time and message", () => {
+ const hit = { _timestamp: 5, date: 4, message: "boom" };
+ expect(errorLogKey(hit, "_timestamp")).toBe("5|4|boom");
+ expect(errorLogKey({ ...hit, error_id: "x" }, "_timestamp")).toBe(
+ errorLogKey(hit, "_timestamp"),
+ );
+ });
+});
diff --git a/web/src/utils/rum/sessionReplayLive.ts b/web/src/utils/rum/sessionReplayLive.ts
new file mode 100644
index 0000000000..07ed088e5d
--- /dev/null
+++ b/web/src/utils/rum/sessionReplayLive.ts
@@ -0,0 +1,92 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import {
+ compareManifestOrder,
+ segmentId,
+ type ManifestEntry,
+} from "@/utils/rum/sessionReplayManifest";
+
+export interface ManifestTailMerge {
+ appended: ManifestEntry[];
+ late: boolean;
+}
+
+/** A session whose last replay row is this recent is still being recorded; the Sessions list uses the same rule. */
+export const ACTIVE_WINDOW_MS = 5 * 60_000;
+export const LIVE_POLL_MS = 30_000;
+// The SDK expires a session after 15 minutes without activity and ends every session at 4 hours.
+export const LIVE_IDLE_STOP_MS = 15 * 60_000;
+export const LIVE_MAX_SESSION_MS = 4 * 3_600_000;
+export const LIVE_OVERLAP_US = 60_000_000;
+
+export function isSessionLive(endTimeMs: number | null | undefined, nowMs: number): boolean {
+ const end = Number(endTimeMs);
+ return end > 0 && nowMs - end <= ACTIVE_WINDOW_MS;
+}
+
+// The bound only moves forward, so a poll that returns nothing never narrows the body window.
+export function raiseUpperTs(current: number, rows: any[], field: string): number {
+ let upper = current;
+ for (const row of rows) {
+ const value = Number(row?.[field]);
+ if (value > upper) upper = value;
+ }
+ return upper;
+}
+
+// The manifest only grows at its end, because the converter cannot take a segment before one the player already holds.
+export function mergeManifestTail(
+ manifest: ManifestEntry[],
+ rows: ManifestEntry[],
+ known: { has: (id: string) => boolean },
+): ManifestTailMerge {
+ const appended: ManifestEntry[] = [];
+ const seen = new Set();
+ let tail = manifest[manifest.length - 1];
+ let late = false;
+ for (const row of [...rows].sort(compareManifestOrder)) {
+ const id = segmentId(row);
+ if (known.has(id) || seen.has(id)) continue;
+ seen.add(id);
+ if (tail && compareManifestOrder(row, tail) < 0) {
+ late = true;
+ continue;
+ }
+ appended.push(row);
+ tail = row;
+ }
+ return { appended, late };
+}
+
+export function shouldStopLive(
+ lastNewIdAtMs: number,
+ sessionStartMs: number,
+ nowMs: number,
+): boolean {
+ if (nowMs - lastNewIdAtMs >= LIVE_IDLE_STOP_MS) return true;
+ return sessionStartMs > 0 && nowMs - sessionStartMs >= LIVE_MAX_SESSION_MS;
+}
+
+/** A RUM event's own id; view rows are re-sent under the same view_id as the view updates. */
+export function eventKey(hit: any): string | null {
+ const id = hit?.[`${hit?.type}_id`];
+ return id === undefined || id === null || id === "" ? null : `${hit.type}|${id}`;
+}
+
+// Error log rows carry no stable id, so the arrival time, device time and message stand in for one.
+export function errorLogKey(hit: any, timestampField: string): string {
+ return `${hit?.[timestampField]}|${hit?.date}|${hit?.message}`;
+}
diff --git a/web/src/utils/rum/sessionReplayLoadPlan.spec.ts b/web/src/utils/rum/sessionReplayLoadPlan.spec.ts
new file mode 100644
index 0000000000..a94358994c
--- /dev/null
+++ b/web/src/utils/rum/sessionReplayLoadPlan.spec.ts
@@ -0,0 +1,148 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, expect, it } from "vitest";
+import { nextBatches } from "./sessionReplayLoadPlan";
+import {
+ createLoaderState,
+ extendLoaderState,
+ markInFlight,
+ markSkipped,
+ markStored,
+} from "./sessionReplayLoader";
+import type { ManifestEntry } from "./sessionReplayManifest";
+
+const range = (from: number, to: number) =>
+ Array.from({ length: to - from + 1 }, (_, i) => from + i);
+
+// One row a second, so no two rows tie on start unless a test makes them.
+function manifestOf(n: number): ManifestEntry[] {
+ return Array.from({ length: n }, (_, i) => ({ start: i * 1000, end: i * 1000 + 999 }));
+}
+
+describe("nextBatches", () => {
+ it("cuts contiguous missing segments into batches of at most 25", () => {
+ const manifest = manifestOf(60);
+ const state = createLoaderState(60);
+
+ expect(nextBatches(manifest, state, { appendedThroughIndex: -1 })).toEqual([
+ range(0, 24),
+ range(25, 49),
+ range(50, 59),
+ ]);
+ });
+
+ it("honours a custom batch size", () => {
+ const manifest = manifestOf(7);
+ const state = createLoaderState(7);
+
+ expect(nextBatches(manifest, state, { appendedThroughIndex: -1 }, { batch: 3 })).toEqual([
+ [0, 1, 2],
+ [3, 4, 5],
+ [6],
+ ]);
+ });
+
+ it("returns no more batches than the free in-flight slots", () => {
+ const manifest = manifestOf(200);
+ const state = createLoaderState(200);
+ const run = { appendedThroughIndex: -1 };
+
+ expect(nextBatches(manifest, state, run)).toHaveLength(3);
+ expect(nextBatches(manifest, state, run, { inFlight: 1 })).toEqual([
+ range(0, 24),
+ range(25, 49),
+ ]);
+ expect(nextBatches(manifest, state, run, { inFlight: 3 })).toEqual([]);
+ expect(nextBatches(manifest, state, run, { maxInFlight: 1 })).toEqual([range(0, 24)]);
+ });
+
+ it("starts after the run's appended edge", () => {
+ const manifest = manifestOf(40);
+ const state = createLoaderState(40);
+
+ expect(nextBatches(manifest, state, { appendedThroughIndex: 9 })[0]).toEqual(range(10, 34));
+ });
+
+ it("skips stored, in-flight and skipped segments and never spans them", () => {
+ const manifest = manifestOf(12);
+ const state = createLoaderState(12);
+ markStored(state, 2);
+ markInFlight(state, [5, 6]);
+ markSkipped(state, 9, "network");
+
+ expect(nextBatches(manifest, state, { appendedThroughIndex: -1 })).toEqual([
+ [0, 1],
+ [3, 4],
+ [7, 8],
+ ]);
+ expect(nextBatches(manifest, state, { appendedThroughIndex: -1 }, { maxInFlight: 4 })).toEqual([
+ [0, 1],
+ [3, 4],
+ [7, 8],
+ [10, 11],
+ ]);
+ });
+
+ it("re-requests a fetchedMissing segment but not a skipped one", () => {
+ const manifest = manifestOf(3);
+ const state = createLoaderState(3);
+ state.status[0] = "fetchedMissing";
+ markSkipped(state, 1, "missing");
+ markStored(state, 2);
+
+ expect(nextBatches(manifest, state, { appendedThroughIndex: -1 })).toEqual([[0]]);
+ });
+
+ it("fills holes before the edge only after the tail, and never joins the two", () => {
+ const manifest = manifestOf(10);
+ const state = createLoaderState(10);
+ markStored(state, 6);
+
+ expect(nextBatches(manifest, state, { appendedThroughIndex: 5 })).toEqual([
+ [7, 8, 9],
+ [0, 1, 2, 3, 4, 5],
+ ]);
+ });
+
+ it("ends a full batch before rows tied on start, so one query never repeats another", () => {
+ const manifest = manifestOf(30);
+ manifest[25] = { start: manifest[24].start, end: manifest[24].start + 500 };
+ const state = createLoaderState(30);
+
+ const batches = nextBatches(manifest, state, { appendedThroughIndex: -1 });
+ expect(batches[0]).toEqual(range(0, 23));
+ expect(batches[1]).toEqual(range(24, 29));
+ });
+
+ it("covers segments a live poll appended", () => {
+ const manifest = manifestOf(4);
+ const state = createLoaderState(2);
+ markStored(state, 0);
+ markStored(state, 1);
+ extendLoaderState(state, 2);
+
+ expect(nextBatches(manifest, state, { appendedThroughIndex: 1 })).toEqual([[2, 3]]);
+ });
+
+ it("returns nothing when every segment is settled", () => {
+ const manifest = manifestOf(2);
+ const state = createLoaderState(2);
+ markStored(state, 0);
+ markSkipped(state, 1, "parse");
+
+ expect(nextBatches(manifest, state, { appendedThroughIndex: 1 })).toEqual([]);
+ });
+});
diff --git a/web/src/utils/rum/sessionReplayLoadPlan.ts b/web/src/utils/rum/sessionReplayLoadPlan.ts
new file mode 100644
index 0000000000..8ba5db1371
--- /dev/null
+++ b/web/src/utils/rum/sessionReplayLoadPlan.ts
@@ -0,0 +1,66 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { SEGMENT_BATCH, isFetchable, type LoaderState } from "@/utils/rum/sessionReplayLoader";
+import type { ManifestEntry } from "@/utils/rum/sessionReplayManifest";
+
+export interface LoadPlanRun {
+ appendedThroughIndex: number;
+}
+
+export interface LoadPlanOptions {
+ batch?: number;
+ maxInFlight?: number;
+ inFlight?: number;
+}
+
+export const MAX_IN_FLIGHT = 3;
+
+// Tail after the run edge first, then holes before it; a batch never spans a segment that is stored, in flight or skipped.
+export function nextBatches(
+ manifest: ManifestEntry[],
+ state: LoaderState,
+ run: LoadPlanRun,
+ opts: LoadPlanOptions = {},
+): number[][] {
+ const size = opts.batch ?? SEGMENT_BATCH;
+ const slots = (opts.maxInFlight ?? MAX_IN_FLIGHT) - (opts.inFlight ?? 0);
+ const count = Math.min(manifest.length, state.status.length);
+ const out: number[][] = [];
+ if (slots <= 0 || size <= 0) return out;
+ const from = Math.min(Math.max(0, run.appendedThroughIndex + 1), count);
+ let current: number[] = [];
+ const close = () => {
+ if (current.length) out.push(current);
+ current = [];
+ };
+ // Body queries select by start, so a full batch ends before rows tied with the next one and they travel together.
+ const visit = (i: number) => {
+ if (!isFetchable(state, i)) return close();
+ if (current.length >= size) {
+ let keep = current.length;
+ while (keep > 0 && manifest[current[keep - 1]].start === manifest[i].start) keep--;
+ if (keep === 0) keep = current.length;
+ out.push(current.slice(0, keep));
+ current = current.slice(keep);
+ }
+ current.push(i);
+ };
+ for (let i = from; i < count && out.length < slots; i++) visit(i);
+ close();
+ for (let i = 0; i < from && out.length < slots; i++) visit(i);
+ close();
+ return out.slice(0, slots);
+}
diff --git a/web/src/utils/rum/sessionReplayLoader.spec.ts b/web/src/utils/rum/sessionReplayLoader.spec.ts
new file mode 100644
index 0000000000..cecbeff65a
--- /dev/null
+++ b/web/src/utils/rum/sessionReplayLoader.spec.ts
@@ -0,0 +1,197 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ appendableIndexes,
+ createActivityTimer,
+ createLoaderState,
+ createQueryError,
+ failBatch,
+ isFetchable,
+ isRetryableError,
+ isSettled,
+ markInFlight,
+ markSkipped,
+ markStored,
+ networkSkippedIndexes,
+ requeueNetworkSkips,
+ settleBatch,
+ withRetries,
+} from "./sessionReplayLoader";
+
+const noWait = () => Promise.resolve();
+
+describe("settleBatch", () => {
+ it("re-queues a segment a successful batch did not return, at most twice", () => {
+ const state = createLoaderState(1);
+ for (let attempt = 1; attempt <= 2; attempt++) {
+ markInFlight(state, [0]);
+ settleBatch(state, [0]);
+ expect(state.status[0]).toBe("fetchedMissing");
+ expect(isFetchable(state, 0)).toBe(true);
+ }
+ markInFlight(state, [0]);
+ settleBatch(state, [0]);
+ expect(state.status[0]).toBe("skipped");
+ expect(state.skipReason[0]).toBe("missing");
+ expect(isFetchable(state, 0)).toBe(false);
+ });
+
+ it("leaves segments that were stored alone", () => {
+ const state = createLoaderState(2);
+ markInFlight(state, [0, 1]);
+ markStored(state, 0);
+ settleBatch(state, [0, 1]);
+ expect(state.status).toEqual(["stored", "fetchedMissing"]);
+ });
+});
+
+describe("failBatch and requeueNetworkSkips", () => {
+ it("skips a batch that ran out of attempts and re-queues only network failures", () => {
+ const state = createLoaderState(4);
+ markInFlight(state, [0, 1]);
+ failBatch(state, [0, 1]);
+ markSkipped(state, 2, "parse");
+ markStored(state, 3);
+ expect(networkSkippedIndexes(state)).toEqual([0, 1]);
+ expect(isSettled(state)).toBe(true);
+
+ expect(requeueNetworkSkips(state)).toEqual([0, 1]);
+ expect(state.status).toEqual(["missing", "missing", "skipped", "stored"]);
+ expect(isSettled(state)).toBe(false);
+ });
+});
+
+describe("appendableIndexes", () => {
+ it("hands over segments in order until the first one still outstanding", () => {
+ const state = createLoaderState(5);
+ markStored(state, 1);
+ markSkipped(state, 2, "network");
+ markStored(state, 4);
+ expect(appendableIndexes(state, 0)).toEqual([1, 2]);
+ });
+
+ it("holds a later segment back until the earlier one arrives", () => {
+ const state = createLoaderState(3);
+ markStored(state, 2);
+ expect(appendableIndexes(state, 0)).toEqual([]);
+ markStored(state, 1);
+ expect(appendableIndexes(state, 0)).toEqual([1, 2]);
+ });
+});
+
+describe("isRetryableError", () => {
+ it.each([
+ ["a network failure with no status", new Error("Failed to fetch"), true],
+ ["a 500", createQueryError("x", 500), true],
+ ["a 503 from axios", { response: { status: 503 } }, true],
+ ["a 429", createQueryError("x", 429), true],
+ ["a timeout", createQueryError("x", undefined, "timeout"), true],
+ ["a 401 after a token refresh", createQueryError("x", 401), true],
+ ["a 400", createQueryError("x", 400), false],
+ ["a 403", createQueryError("x", 403), false],
+ ["a 404", { response: { status: 404 } }, false],
+ ["an in-stream invalid-SQL code", createQueryError("x", undefined, "20001"), false],
+ ["an in-stream field-not-found code", createQueryError("x", undefined, "20004"), false],
+ ["an in-stream search timeout code", createQueryError("x", undefined, "20010"), true],
+ ["an in-stream rate-limit code", createQueryError("x", undefined, "20012"), true],
+ ["an in-stream internal error code", createQueryError("x", undefined, "10001"), true],
+ ])("classifies %s", (_label, error, expected) => {
+ expect(isRetryableError(error)).toBe(expected);
+ });
+});
+
+describe("withRetries", () => {
+ it("makes three attempts for a retryable error, waiting 1 s then 2 s", async () => {
+ const fn = vi.fn().mockRejectedValue(createQueryError("x", 502));
+ const wait = vi.fn(noWait);
+ const onRetry = vi.fn();
+ await expect(withRetries(fn, { isCancelled: () => false, wait, onRetry })).rejects.toThrow();
+ expect(fn).toHaveBeenCalledTimes(3);
+ expect(wait.mock.calls.map((c) => c[0])).toEqual([1000, 2000]);
+ expect(onRetry.mock.calls.map((c) => c[0])).toEqual([2, 3]);
+ });
+
+ it("makes one attempt for an error that cannot succeed next time", async () => {
+ const fn = vi.fn().mockRejectedValue(createQueryError("x", 400));
+ await expect(withRetries(fn, { isCancelled: () => false, wait: noWait })).rejects.toThrow();
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+
+ it("returns the first success", async () => {
+ const fn = vi
+ .fn()
+ .mockRejectedValueOnce(createQueryError("x", 500))
+ .mockResolvedValueOnce("ok");
+ await expect(withRetries(fn, { isCancelled: () => false, wait: noWait })).resolves.toBe("ok");
+ expect(fn).toHaveBeenCalledTimes(2);
+ });
+
+ it("stops retrying once the caller is cancelled during a wait", async () => {
+ const fn = vi.fn().mockRejectedValue(createQueryError("x", 500));
+ await expect(withRetries(fn, { isCancelled: () => true, wait: noWait })).rejects.toThrow();
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("createActivityTimer", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("fires when no first byte arrives within 90 s", () => {
+ const onTimeout = vi.fn();
+ createActivityTimer(onTimeout);
+ vi.advanceTimersByTime(89_999);
+ expect(onTimeout).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(1);
+ expect(onTimeout).toHaveBeenCalledTimes(1);
+ });
+
+ it("fires after 30 s of silence once the stream has started", () => {
+ const onTimeout = vi.fn();
+ const timer = createActivityTimer(onTimeout);
+ vi.advanceTimersByTime(5_000);
+ timer.touch();
+ vi.advanceTimersByTime(29_999);
+ expect(onTimeout).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(1);
+ expect(onTimeout).toHaveBeenCalledTimes(1);
+ });
+
+ it("never fires on a request that keeps sending, however long it runs", () => {
+ const onTimeout = vi.fn();
+ const timer = createActivityTimer(onTimeout);
+ for (let i = 0; i < 20; i++) {
+ vi.advanceTimersByTime(20_000);
+ timer.touch();
+ }
+ expect(onTimeout).not.toHaveBeenCalled();
+ });
+
+ it("stops once the request settles", () => {
+ const onTimeout = vi.fn();
+ const timer = createActivityTimer(onTimeout);
+ timer.clear();
+ timer.touch();
+ vi.advanceTimersByTime(200_000);
+ expect(onTimeout).not.toHaveBeenCalled();
+ });
+});
diff --git a/web/src/utils/rum/sessionReplayLoader.ts b/web/src/utils/rum/sessionReplayLoader.ts
new file mode 100644
index 0000000000..ac5ab3388a
--- /dev/null
+++ b/web/src/utils/rum/sessionReplayLoader.ts
@@ -0,0 +1,199 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+export type SegmentStatus = "missing" | "inFlight" | "stored" | "fetchedMissing" | "skipped";
+
+export type SkipReason = "network" | "missing" | "parse";
+
+/** Per-segment loader state, indexed by manifest position. */
+export interface LoaderState {
+ status: SegmentStatus[];
+ missingCount: number[];
+ skipReason: (SkipReason | null)[];
+}
+
+export interface RetryOptions {
+ isCancelled: () => boolean;
+ onRetry?: (attempt: number) => void;
+ delays?: number[];
+ wait?: (ms: number) => Promise;
+}
+
+export interface ActivityTimer {
+ touch: () => void;
+ clear: () => void;
+}
+
+export const SEGMENT_BATCH = 25;
+export const MAX_ATTEMPTS = 3;
+export const RETRY_DELAYS_MS = [1000, 2000];
+export const MAX_MISSING_REQUEUES = 2;
+export const FIRST_BYTE_TIMEOUT_MS = 90_000;
+export const IDLE_TIMEOUT_MS = 30_000;
+// Parquet file not found, SQL execute error, cancelled, timed out, rate limited (infra/src/errors/mod.rs).
+const TRANSIENT_SEARCH_CODES = new Set([20006, 20008, 20009, 20010, 20012]);
+
+const defaultWait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+export function createLoaderState(count: number): LoaderState {
+ return {
+ status: new Array(count).fill("missing"),
+ missingCount: new Array(count).fill(0),
+ skipReason: new Array(count).fill(null),
+ };
+}
+
+/** Adds `count` missing segments at the end, for manifest rows a live poll appended. */
+export function extendLoaderState(state: LoaderState, count: number): void {
+ for (let i = 0; i < count; i++) {
+ state.status.push("missing");
+ state.missingCount.push(0);
+ state.skipReason.push(null);
+ }
+}
+
+/** Skipped and capped segments are never fetchable, which is what stops the watchdog looping. */
+export function isFetchable(state: LoaderState, index: number): boolean {
+ const status = state.status[index];
+ return status === "missing" || status === "fetchedMissing";
+}
+
+export function markInFlight(state: LoaderState, indexes: number[]): void {
+ for (const i of indexes) state.status[i] = "inFlight";
+}
+
+export function markStored(state: LoaderState, index: number): void {
+ state.status[index] = "stored";
+ state.skipReason[index] = null;
+}
+
+export function markSkipped(state: LoaderState, index: number, reason: SkipReason): void {
+ state.status[index] = "skipped";
+ state.skipReason[index] = reason;
+}
+
+// A successful batch that did not return a segment re-queues it, at most twice, then gives up on it.
+export function settleBatch(state: LoaderState, indexes: number[]): void {
+ for (const i of indexes) {
+ if (state.status[i] !== "inFlight") continue;
+ state.missingCount[i]++;
+ if (state.missingCount[i] > MAX_MISSING_REQUEUES) markSkipped(state, i, "missing");
+ else state.status[i] = "fetchedMissing";
+ }
+}
+
+export function failBatch(state: LoaderState, indexes: number[]): void {
+ for (const i of indexes) {
+ if (state.status[i] === "inFlight") markSkipped(state, i, "network");
+ }
+}
+
+/** Indexes the player may take next: every one in order until the first segment still outstanding. */
+export function appendableIndexes(state: LoaderState, appendedThroughIndex: number): number[] {
+ const out: number[] = [];
+ for (let i = appendedThroughIndex + 1; i < state.status.length; i++) {
+ const status = state.status[i];
+ if (status !== "stored" && status !== "skipped") break;
+ out.push(i);
+ }
+ return out;
+}
+
+export function isSettled(state: LoaderState): boolean {
+ return state.status.every((s) => s === "stored" || s === "skipped");
+}
+
+export function networkSkippedIndexes(state: LoaderState): number[] {
+ const out: number[] = [];
+ state.skipReason.forEach((reason, i) => {
+ if (reason === "network" && state.status[i] === "skipped") out.push(i);
+ });
+ return out;
+}
+
+// A parse failure is never retried; only segments lost to the network go back on the queue.
+export function requeueNetworkSkips(state: LoaderState): number[] {
+ const indexes = networkSkippedIndexes(state);
+ for (const i of indexes) {
+ state.status[i] = "missing";
+ state.missingCount[i] = 0;
+ state.skipReason[i] = null;
+ }
+ return indexes;
+}
+
+/** An error carrying the HTTP status or code, so the retry policy can classify it. */
+export function createQueryError(message: string, status?: number, code?: string): Error {
+ return Object.assign(new Error(message), { status, code });
+}
+
+// Only failures that can succeed on a second try are retried; a bad query or a missing permission never will.
+export function isRetryableError(error: any): boolean {
+ if (error?.code === "timeout") return true;
+ const status = errorStatus(error);
+ if (status !== undefined) return status >= 500 || status === 429 || status === 401;
+ return !isPermanentSearchCode(error?.code);
+}
+
+// The caller's cancel check runs after every wait, so leaving the page stops the retries too.
+export async function withRetries(
+ fn: (attempt: number) => Promise,
+ options: RetryOptions,
+): Promise {
+ const delays = options.delays ?? RETRY_DELAYS_MS;
+ const wait = options.wait ?? defaultWait;
+ for (let attempt = 1; ; attempt++) {
+ try {
+ return await fn(attempt);
+ } catch (error) {
+ if (attempt >= delays.length + 1 || !isRetryableError(error)) throw error;
+ await wait(delays[attempt - 1]);
+ if (options.isCancelled()) throw error;
+ options.onRetry?.(attempt + 1);
+ }
+ }
+}
+
+// A stream can stay open without sending anything, so a request with no first byte or a long silence is given up.
+export function createActivityTimer(
+ onTimeout: () => void,
+ firstByteMs = FIRST_BYTE_TIMEOUT_MS,
+ idleMs = IDLE_TIMEOUT_MS,
+): ActivityTimer {
+ let handle: ReturnType | null = setTimeout(onTimeout, firstByteMs);
+ return {
+ touch() {
+ if (handle === null) return;
+ clearTimeout(handle);
+ handle = setTimeout(onTimeout, idleMs);
+ },
+ clear() {
+ if (handle !== null) clearTimeout(handle);
+ handle = null;
+ },
+ };
+}
+
+// Search error codes 20xxx that describe the query itself; cancel, timeout, rate limit and execution errors can pass next time.
+function isPermanentSearchCode(code: unknown): boolean {
+ const value = Number(code);
+ if (!Number.isInteger(value) || value < 20000 || value >= 30000) return false;
+ return !TRANSIENT_SEARCH_CODES.has(value);
+}
+
+function errorStatus(error: any): number | undefined {
+ const status = error?.status ?? error?.response?.status;
+ return typeof status === "number" && status > 0 ? status : undefined;
+}
diff --git a/web/src/utils/rum/sessionReplayManifest.spec.ts b/web/src/utils/rum/sessionReplayManifest.spec.ts
new file mode 100644
index 0000000000..ab30ef59bf
--- /dev/null
+++ b/web/src/utils/rum/sessionReplayManifest.spec.ts
@@ -0,0 +1,178 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, it, expect } from "vitest";
+import {
+ dedupManifest,
+ trimBeforeReplayStart,
+ findTargetIndex,
+ segmentId,
+ selectInitialWindow,
+ snapshotStarts,
+ summarizeManifest,
+} from "./sessionReplayManifest";
+
+const manifest = [
+ { start: 1000, end: 1999, has_full_snapshot: true, records_count: 5 },
+ { start: 2000, end: 2999, has_full_snapshot: false, records_count: 3 },
+ { start: 3000, end: 3999, has_full_snapshot: false, records_count: 2 },
+ { start: 4000, end: 4999, has_full_snapshot: true, records_count: 7 },
+ { start: 5000, end: 5999, has_full_snapshot: false, records_count: 1 },
+];
+
+describe("sessionReplayManifest", () => {
+ describe("findTargetIndex", () => {
+ it("returns the last segment starting at or before the target", () => {
+ expect(findTargetIndex(manifest, 3500)).toBe(2);
+ });
+
+ it("returns -1 when the target precedes the session", () => {
+ expect(findTargetIndex(manifest, 10)).toBe(-1);
+ });
+
+ it("returns the final segment for a target past the session end", () => {
+ expect(findTargetIndex(manifest, 99999)).toBe(4);
+ });
+ });
+
+ describe("selectInitialWindow", () => {
+ it("runs from the last snapshot at or before the target through the target segment", () => {
+ expect(selectInitialWindow(manifest, 5500)).toEqual({
+ anchorIndex: 3,
+ targetIndex: 4,
+ from: 4000,
+ to: 5000,
+ });
+ });
+
+ it("falls back to the first segment when no snapshot precedes the target", () => {
+ const noEarlySnapshot = [
+ { start: 1000, end: 1999, has_full_snapshot: false },
+ { start: 2000, end: 2999, has_full_snapshot: false },
+ ];
+ expect(selectInitialWindow(noEarlySnapshot, 2500)).toEqual({
+ anchorIndex: 0,
+ targetIndex: 1,
+ from: 1000,
+ to: 2000,
+ });
+ });
+
+ it("treats a target before the session as the first segment", () => {
+ expect(selectInitialWindow(manifest, 0)).toEqual({
+ anchorIndex: 0,
+ targetIndex: 0,
+ from: 1000,
+ to: 1000,
+ });
+ });
+
+ it("returns null for an empty manifest", () => {
+ expect(selectInitialWindow([], 1000)).toBeNull();
+ });
+
+ it("accepts has_full_snapshot as the string form some ingest paths send", () => {
+ const stringy = [
+ { start: 1000, end: 1999, has_full_snapshot: "true" },
+ { start: 2000, end: 2999, has_full_snapshot: false },
+ ];
+ expect(selectInitialWindow(stringy, 2500)?.anchorIndex).toBe(0);
+ });
+ });
+
+ describe("snapshotStarts", () => {
+ it("lists only the segments that can anchor a cold player", () => {
+ expect(snapshotStarts(manifest)).toEqual([1000, 4000]);
+ });
+ });
+
+ describe("segmentId", () => {
+ it("keys a row by view, index in view, span and record count", () => {
+ expect(
+ segmentId({ start: 1, end: 2, records_count: 3, view_id: "v1", index_in_view: 4 }),
+ ).toBe("v1|4|1|2|3");
+ });
+
+ it("falls back to span and record count when the schema has no view columns", () => {
+ expect(segmentId({ start: 1, end: 2, records_count: 3 })).toBe("1|2|3");
+ });
+
+ it("keeps two views that tie on start, end and record count apart", () => {
+ const a = { start: 1, end: 2, records_count: 3, view_id: "a", index_in_view: 0 };
+ const b = { ...a, view_id: "b" };
+ expect(segmentId(a)).not.toBe(segmentId(b));
+ });
+ });
+
+ describe("trimBeforeReplayStart", () => {
+ const rows = [
+ { start: 1000, end: 1999, records_count: 5 },
+ { start: 2000, end: 2999, records_count: 3, has_full_snapshot: true },
+ { start: 3000, end: 3999, records_count: 1 },
+ ];
+
+ it("drops leading rows that start before the first full snapshot", () => {
+ expect(trimBeforeReplayStart(rows, 2000)).toEqual(rows.slice(1));
+ });
+
+ it("keeps every row when replay_start is unknown", () => {
+ expect(trimBeforeReplayStart(rows, null)).toBe(rows);
+ expect(trimBeforeReplayStart(rows, undefined)).toBe(rows);
+ });
+ });
+
+ describe("dedupManifest", () => {
+ it("drops an exact duplicate row stored twice by a retried upload", () => {
+ const row = { start: 1000, end: 1999, records_count: 5, view_id: "v", index_in_view: 0 };
+ const next = { start: 2000, end: 2999, records_count: 1, view_id: "v", index_in_view: 1 };
+ expect(dedupManifest([row, { ...row }, next])).toEqual([row, next]);
+ });
+
+ it("keeps rows that tie on start at a page edge but are different segments", () => {
+ const rows = [
+ { start: 1000, end: 1000, records_count: 1, view_id: "a", index_in_view: 3 },
+ { start: 1000, end: 1500, records_count: 2, view_id: "b", index_in_view: 0 },
+ ];
+ expect(dedupManifest(rows)).toHaveLength(2);
+ });
+
+ it("keeps the manifest order of the first occurrence", () => {
+ const rows = [
+ { start: 3, end: 4, records_count: 1 },
+ { start: 1, end: 2, records_count: 1 },
+ { start: 3, end: 4, records_count: 1 },
+ ];
+ expect(dedupManifest(rows).map((r) => r.start)).toEqual([3, 1]);
+ });
+ });
+
+ describe("summarizeManifest", () => {
+ it("totals the segments and records that were listed", () => {
+ expect(summarizeManifest(manifest, true)).toEqual({
+ segmentCount: 5,
+ recordCount: 18,
+ truncated: false,
+ });
+ });
+
+ it("flags a manifest that was cut short by the page cap", () => {
+ expect(summarizeManifest(manifest, false).truncated).toBe(true);
+ });
+
+ it("counts a missing records_count as zero rather than NaN", () => {
+ expect(summarizeManifest([{ start: 1, end: 2 }], true).recordCount).toBe(0);
+ });
+ });
+});
diff --git a/web/src/utils/rum/sessionReplayManifest.ts b/web/src/utils/rum/sessionReplayManifest.ts
new file mode 100644
index 0000000000..59797f667d
--- /dev/null
+++ b/web/src/utils/rum/sessionReplayManifest.ts
@@ -0,0 +1,134 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+export interface ManifestEntry {
+ start: number;
+ end: number;
+ has_full_snapshot?: boolean | string;
+ records_count?: number;
+ view_id?: string | null;
+ index_in_view?: number | null;
+}
+
+export interface SegmentWindow {
+ anchorIndex: number;
+ targetIndex: number;
+ from: number;
+ to: number;
+}
+
+export interface ManifestSummary {
+ segmentCount: number;
+ recordCount: number;
+ truncated: boolean;
+}
+
+// has_full_snapshot arrives as a boolean from the schema but as a string from older ingest paths.
+export function hasFullSnapshot(entry: ManifestEntry): boolean {
+ return entry.has_full_snapshot === true || entry.has_full_snapshot === "true";
+}
+
+/** Identity of one stored segment; rows from a schema without the view columns fall back to span and record count. */
+export function segmentId(entry: ManifestEntry): string {
+ const base = `${entry.start}|${entry.end}|${Number(entry.records_count) || 0}`;
+ if (entry.view_id === undefined) return base;
+ return `${entry.view_id ?? ""}|${Number(entry.index_in_view) || 0}|${base}`;
+}
+
+// A retried upload or a tie at a page edge returns the same segment twice, and decoding it twice corrupts the converter.
+export function dedupManifest(rows: ManifestEntry[]): ManifestEntry[] {
+ const seen = new Set();
+ const out: ManifestEntry[] = [];
+ for (const row of rows) {
+ const id = segmentId(row);
+ if (seen.has(id)) continue;
+ seen.add(id);
+ out.push(row);
+ }
+ return out;
+}
+
+/** The manifest query's sort (start, end, index_in_view, view_id) as a comparator. */
+export function compareManifestOrder(a: ManifestEntry, b: ManifestEntry): number {
+ const byStart = Number(a.start) - Number(b.start);
+ if (byStart) return byStart;
+ const byEnd = Number(a.end) - Number(b.end);
+ if (byEnd) return byEnd;
+ const byIndex = (Number(a.index_in_view) || 0) - (Number(b.index_in_view) || 0);
+ if (byIndex) return byIndex;
+ const viewA = a.view_id ?? "";
+ const viewB = b.view_id ?? "";
+ return viewA < viewB ? -1 : viewA > viewB ? 1 : 0;
+}
+
+// Rows before the first full snapshot are orphan mutations that decode against an empty id space, so playback starts at replayStart.
+export function trimBeforeReplayStart(
+ rows: ManifestEntry[],
+ replayStart: number | null | undefined,
+): ManifestEntry[] {
+ const floor = Number(replayStart);
+ if (!(floor > 0)) return rows;
+ return rows.filter((row) => Number(row.start) >= floor);
+}
+
+/** Index of the segment holding `target`, or the last one starting at or before it. */
+export function findTargetIndex(manifest: ManifestEntry[], target: number): number {
+ let index = -1;
+ for (let i = 0; i < manifest.length; i++) {
+ if (manifest[i].start <= target) index = i;
+ else break;
+ }
+ return index;
+}
+
+// A replay can only start cold at a full snapshot, so a target with none before it falls back to the first segment.
+export function selectInitialWindow(
+ manifest: ManifestEntry[],
+ target: number,
+): SegmentWindow | null {
+ if (!manifest.length) return null;
+
+ const targetIndex = Math.max(findTargetIndex(manifest, target), 0);
+
+ let anchorIndex = -1;
+ for (let i = targetIndex; i >= 0; i--) {
+ if (hasFullSnapshot(manifest[i])) {
+ anchorIndex = i;
+ break;
+ }
+ }
+ if (anchorIndex === -1) anchorIndex = 0;
+
+ return {
+ anchorIndex,
+ targetIndex,
+ from: manifest[anchorIndex].start,
+ to: manifest[targetIndex].start,
+ };
+}
+
+/** Starts of the segments that can anchor a cold player, used by the seek planner. */
+export function snapshotStarts(manifest: ManifestEntry[]): number[] {
+ return manifest.filter(hasFullSnapshot).map((entry) => entry.start);
+}
+
+/** What was actually listed, so a manifest cut short by the page cap can be reported. */
+export function summarizeManifest(manifest: ManifestEntry[], complete: boolean): ManifestSummary {
+ return {
+ segmentCount: manifest.length,
+ recordCount: manifest.reduce((total, entry) => total + (Number(entry.records_count) || 0), 0),
+ truncated: !complete,
+ };
+}
diff --git a/web/src/utils/rum/sessionReplaySeekPlan.spec.ts b/web/src/utils/rum/sessionReplaySeekPlan.spec.ts
new file mode 100644
index 0000000000..266642f515
--- /dev/null
+++ b/web/src/utils/rum/sessionReplaySeekPlan.spec.ts
@@ -0,0 +1,98 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, it, expect } from "vitest";
+import {
+ isBeforeRun,
+ isCoveredBrowser,
+ isCoveredMobile,
+ type RunCoverage,
+} from "./sessionReplaySeekPlan";
+
+// A run anchored at segment 2 (session ms 10 000), appended through segment 5 of 9, player holding events up to 30 000.
+const run: RunCoverage = {
+ anchorIndex: 2,
+ appendedThroughIndex: 5,
+ lastIndex: 9,
+ anchorStartMs: 10_000,
+ playerEndMs: 30_000,
+};
+
+describe("isCoveredBrowser", () => {
+ it("covers a target between the anchor and the player's last event", () => {
+ expect(isCoveredBrowser(20_000, run)).toBe(true);
+ expect(isCoveredBrowser(30_000, run)).toBe(true);
+ });
+
+ it("does not cover a target before the run's anchor segment", () => {
+ expect(isCoveredBrowser(9_999, run)).toBe(false);
+ expect(isBeforeRun(9_999, run)).toBe(true);
+ });
+
+ it("does not cover an idle gap after the last appended event while later segments are still to come", () => {
+ expect(isCoveredBrowser(30_001, run)).toBe(false);
+ });
+
+ it("covers everything up to the session end once the last segment is appended", () => {
+ expect(isCoveredBrowser(90_000, { ...run, appendedThroughIndex: 9 })).toBe(true);
+ });
+
+ it("covers nothing before the first append", () => {
+ expect(isCoveredBrowser(10_000, { ...run, appendedThroughIndex: 1 })).toBe(false);
+ });
+
+ it("covers nothing for an empty manifest", () => {
+ expect(isCoveredBrowser(0, { ...run, lastIndex: -1, anchorIndex: 0, anchorStartMs: 0 })).toBe(
+ false,
+ );
+ });
+
+ it("covers nothing until the player has reported what it holds", () => {
+ expect(isCoveredBrowser(20_000, { ...run, playerEndMs: null })).toBe(false);
+ });
+});
+
+describe("isCoveredMobile", () => {
+ const records = [
+ { type: 4, timestamp: 1000 },
+ { type: 10, timestamp: 1000 },
+ { type: 11, timestamp: 2000 },
+ { type: 10, timestamp: 5000 },
+ { type: 11, timestamp: 6000 },
+ ];
+
+ it("covers a target with a full screen at or before it and records past it", () => {
+ expect(isCoveredMobile(1500, records)).toBe(true);
+ expect(isCoveredMobile(6000, records)).toBe(true);
+ });
+
+ it("does not cover a target past the last loaded record", () => {
+ expect(isCoveredMobile(6001, records)).toBe(false);
+ });
+
+ it("does not cover a target with no full screen before it", () => {
+ expect(isCoveredMobile(900, records)).toBe(false);
+ expect(
+ isCoveredMobile(1500, [
+ { type: 11, timestamp: 1000 },
+ { type: 11, timestamp: 2000 },
+ ]),
+ ).toBe(false);
+ });
+
+ it("does not cover anything before a record has loaded", () => {
+ expect(isCoveredMobile(0, [])).toBe(false);
+ });
+});
diff --git a/web/src/utils/rum/sessionReplaySeekPlan.ts b/web/src/utils/rum/sessionReplaySeekPlan.ts
new file mode 100644
index 0000000000..9258e416f7
--- /dev/null
+++ b/web/src/utils/rum/sessionReplaySeekPlan.ts
@@ -0,0 +1,50 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+/** What the live browser player holds, all times in session ms. */
+export interface RunCoverage {
+ anchorIndex: number;
+ appendedThroughIndex: number;
+ lastIndex: number;
+ anchorStartMs: number;
+ playerEndMs: number | null;
+}
+
+export interface CoverageRecord {
+ type: number;
+ timestamp: number;
+}
+
+// The mobile SDKs mark a full screen with record type 10.
+const MOBILE_FULL_SNAPSHOT = 10;
+
+/** True when a target sits before the run's anchor, which the forward-only loader never reaches. */
+export function isBeforeRun(target: number, run: RunCoverage): boolean {
+ return target < run.anchorStartMs;
+}
+
+// rrweb cannot sit past its last event, so until the run reaches the last segment an idle gap after the edge is not covered.
+export function isCoveredBrowser(target: number, run: RunCoverage): boolean {
+ if (run.lastIndex < 0 || run.appendedThroughIndex < run.anchorIndex) return false;
+ if (run.playerEndMs === null || isBeforeRun(target, run)) return false;
+ if (run.appendedThroughIndex >= run.lastIndex) return true;
+ return target <= run.playerEndMs;
+}
+
+// The wireframe player redraws from the latest full screen, so the target needs one at or before it.
+export function isCoveredMobile(targetAbs: number, records: CoverageRecord[]): boolean {
+ if (!records.length || records[records.length - 1].timestamp < targetAbs) return false;
+ return records.some((r) => r.type === MOBILE_FULL_SNAPSHOT && r.timestamp <= targetAbs);
+}
diff --git a/web/src/utils/rum/sessionReplayTimeline.spec.ts b/web/src/utils/rum/sessionReplayTimeline.spec.ts
new file mode 100644
index 0000000000..0a84f661db
--- /dev/null
+++ b/web/src/utils/rum/sessionReplayTimeline.spec.ts
@@ -0,0 +1,75 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, expect, it } from "vitest";
+import {
+ canResume,
+ formatReplayTime,
+ isLoadedAt,
+ isSkipMarker,
+ playbackMargin,
+ shouldBuffer,
+ skippedCount,
+ timelineLength,
+ toPercent,
+} from "./sessionReplayTimeline";
+
+describe("sessionReplayTimeline", () => {
+ it("scales the margin with the user speed, not the skip timer speed", () => {
+ expect(playbackMargin(4)).toBe(4000);
+ expect(playbackMargin(0)).toBe(1000);
+ });
+
+ it("pauses one margin before the loaded end and resumes only with two margins of headroom", () => {
+ expect(shouldBuffer(115_999, 120_000, 4)).toBe(false);
+ expect(shouldBuffer(116_000, 120_000, 4)).toBe(true);
+ expect(canResume(116_000, 123_999, 4)).toBe(false);
+ expect(canResume(116_000, 124_000, 4)).toBe(true);
+ });
+
+ it("sizes the bar from the metadata and widens it only for records past the end", () => {
+ const start = 1_704_110_400_000;
+ expect(timelineLength(start, start + 600_000, start + 5_000)).toBe(600_000);
+ expect(timelineLength(start, start + 600_000, start + 601_000)).toBe(601_000);
+ expect(timelineLength(start, start - 1, start - 1)).toBe(0);
+ });
+
+ it("guards the bar scale against a zero timeline", () => {
+ expect(toPercent(500, 0)).toBe(0);
+ expect(toPercent(500, 1000)).toBe(50);
+ expect(toPercent(5000, 1000)).toBe(100);
+ });
+
+ it("treats only player or fetched ranges as loaded", () => {
+ const ranges = [
+ { start: 0, end: 100, state: "inPlayer" as const },
+ { start: 200, end: 300, state: "skipped" as const },
+ ];
+ expect(isLoadedAt(50, ranges)).toBe(true);
+ expect(isLoadedAt(250, ranges)).toBe(false);
+ expect(skippedCount(ranges)).toBe(1);
+ });
+
+ it("recognises a skip marker", () => {
+ expect(isSkipMarker({ skipped: true, segmentId: "a", start: 1, end: 2 })).toBe(true);
+ expect(isSkipMarker({ records: [] })).toBe(false);
+ });
+
+ it("formats minutes and hours", () => {
+ expect(formatReplayTime(61_000)).toBe("01:01");
+ expect(formatReplayTime(3_661_000)).toBe("01:01:01");
+ expect(formatReplayTime(-5)).toBe("00:00");
+ });
+});
diff --git a/web/src/utils/rum/sessionReplayTimeline.ts b/web/src/utils/rum/sessionReplayTimeline.ts
new file mode 100644
index 0000000000..e468a2a45c
--- /dev/null
+++ b/web/src/utils/rum/sessionReplayTimeline.ts
@@ -0,0 +1,103 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+export type LoadState = "loading" | "complete" | "failed" | "live" | "empty" | "error";
+
+export type RangeState = "inPlayer" | "fetched" | "skipped" | "unavailable";
+
+/** A span of the session in session ms, and what the viewer holds for it. */
+export interface LoadedRange {
+ start: number;
+ end: number;
+ state: RangeState;
+}
+
+export type PlaybackState =
+ | "loading"
+ | "empty"
+ | "error"
+ | "paused"
+ | "playing"
+ | "buffering"
+ | "waiting"
+ | "failed"
+ | "ended";
+
+/** Stands in the segment list where a segment could not be loaded, so the player can step past it. */
+export interface SkipMarker {
+ skipped: true;
+ segmentId: string;
+ start: number;
+ end: number;
+}
+
+export type ReplayIntent = "play" | "pause";
+
+/** Loading and live both mean more data can still reach the player. */
+export function expectsMoreData(state: LoadState): boolean {
+ return state === "loading" || state === "live";
+}
+
+export function isSkipMarker(segment: any): segment is SkipMarker {
+ return !!segment && segment.skipped === true;
+}
+
+// Skip-inactivity raises the timer speed far above this, but a skip stops at the next loaded interaction, so the user speed is the right scale.
+export function playbackMargin(speed: number): number {
+ return 1000 * (speed > 0 ? speed : 1);
+}
+
+/** Pause before the player runs out of loaded events; both values are in player time, so the origin cancels out. */
+export function shouldBuffer(currentTime: number, loadedTotal: number, speed: number): boolean {
+ return currentTime >= loadedTotal - playbackMargin(speed);
+}
+
+// Twice the pause threshold, so playback does not stutter between pausing and resuming.
+export function canResume(currentTime: number, loadedTotal: number, speed: number): boolean {
+ return loadedTotal - currentTime >= 2 * playbackMargin(speed);
+}
+
+/** Bar length in session ms: the metadata span, widened only if loaded records run past it. */
+export function timelineLength(
+ sessionStartMs: number,
+ sessionEndMs: number,
+ loadedEndMs: number,
+): number {
+ return Math.max(0, sessionEndMs - sessionStartMs, loadedEndMs - sessionStartMs);
+}
+
+export function toPercent(ms: number, total: number): number {
+ if (!(total > 0)) return 0;
+ return Math.max(0, Math.min(100, (ms / total) * 100));
+}
+
+export function isLoadedAt(ms: number, ranges: LoadedRange[]): boolean {
+ return ranges.some(
+ (r) => (r.state === "inPlayer" || r.state === "fetched") && r.start <= ms && ms <= r.end,
+ );
+}
+
+export function skippedCount(ranges: LoadedRange[]): number {
+ return ranges.filter((r) => r.state === "skipped").length;
+}
+
+export function formatReplayTime(milliSeconds: number): string {
+ const ms = Math.max(0, milliSeconds);
+ const hours = Math.floor(ms / 3_600_000);
+ const minutes = String(Math.floor((ms % 3_600_000) / 60_000)).padStart(2, "0");
+ const seconds = String(Math.floor((ms % 60_000) / 1000)).padStart(2, "0");
+ if (hours === 0) return `${minutes}:${seconds}`;
+ return `${String(hours).padStart(2, "0")}:${minutes}:${seconds}`;
+}
diff --git a/web/src/views/RUM/AppSessions.vue b/web/src/views/RUM/AppSessions.vue
index e9d75eaba3..67964dce2c 100644
--- a/web/src/views/RUM/AppSessions.vue
+++ b/web/src/views/RUM/AppSessions.vue
@@ -487,6 +487,7 @@ import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
import { COL } from "@/lib/core/Table/OTable.types";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
+import { isSessionLive } from "@/utils/rum/sessionReplayLive";
import OSplitter from "@/lib/core/Splitter/OSplitter.vue";
import { durationFormatter, b64DecodeUnicode, b64EncodeUnicode } from "@/utils/zincutils";
import SearchFieldList from "@/components/common/sidebar/SearchFieldList.vue";
@@ -1389,10 +1390,8 @@ const previousWindowTotals = ref(null);
const frustrationCluster = ref(null);
const errorCluster = ref(null);
-// A session with ≤1 event or under 10s of activity counts as a bounce; a
-// session whose last replay event is within the last 5 minutes is still live.
+// A session with ≤1 event or under 10s of activity counts as a bounce.
const BOUNCE_MAX_MS = 10_000;
-const ACTIVE_WINDOW_MS = 5 * 60_000;
// Heuristic bucketing of the UA device/os family into the segment values.
const classifyDevice = (family?: string, os?: string): DeviceSegment => {
@@ -1427,7 +1426,7 @@ const enrichedRows = computed(() =>
rows.value.map((row: any) => ({
...row,
is_bounce: (row.events ?? 0) <= 1 || (row.time_spent ?? 0) < BOUNCE_MAX_MS,
- is_active: !!row.end_time && Date.now() - row.end_time <= ACTIVE_WINDOW_MS,
+ is_active: isSessionLive(row.end_time, Date.now()),
device_type: classifyDevice(row.device_family, row.os),
platform: classifySource(row.source),
})),
diff --git a/web/src/views/RUM/SessionViewer.spec.ts b/web/src/views/RUM/SessionViewer.spec.ts
index 6d98e865e0..ce6bb2a5f4 100644
--- a/web/src/views/RUM/SessionViewer.spec.ts
+++ b/web/src/views/RUM/SessionViewer.spec.ts
@@ -15,51 +15,60 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
+import { defineComponent } from "vue";
import { createRouter, createWebHistory } from "vue-router";
// --- Module-level mocks (hoisted by Vitest before any import) ---
-vi.mock("@/composables/useSessionReplay", () => ({
- default: () => ({
- sessionState: {
- data: {
- selectedSession: {
- start_time: 1692884313968,
- end_time: 1692884769270,
- browser: "Chrome",
- os: "macOS",
- ip: "1.2.3.4",
- user_email: "user@example.com",
- city: "San Francisco",
- country: "US",
- session_id: "session-abc",
+// Reactive like the real store, so computeds over the selected session follow getSession().
+vi.mock("@/composables/useSessionReplay", async () => {
+ const { reactive } = await import("vue");
+ return {
+ default: () => ({
+ sessionState: reactive({
+ data: {
+ selectedSession: {
+ start_time: 1692884313968,
+ end_time: 1692884769270,
+ browser: "Chrome",
+ os: "macOS",
+ ip: "1.2.3.4",
+ user_email: "user@example.com",
+ city: "San Francisco",
+ country: "US",
+ session_id: "session-abc",
+ },
},
- },
- },
- }),
+ }),
+ }),
+ };
+});
+
+// Shared so the tests can read the time window every query was built with.
+const queryPayload = vi.hoisted(() => ({
+ build: null as any,
}));
vi.mock("@/composables/useQuery", () => ({
default: () => ({
- buildQueryPayload: vi.fn().mockReturnValue({
- query: { sql: "" },
- aggs: {},
- }),
+ buildQueryPayload: (queryPayload.build ??= vi.fn(() => ({ query: { sql: "" }, aggs: {} }))),
getTimeInterval: vi.fn().mockReturnValue({ interval: "1m" }),
parseQuery: vi.fn().mockReturnValue({}),
}),
}));
+// Shared so a test can add the view columns the schema guard looks for.
+const replaySchema = vi.hoisted(() => ({
+ fields: { geo_info_country: true, geo_info_city: true } as Record,
+}));
+
vi.mock("@/composables/rum/usePerformance", () => ({
default: () => ({
performanceState: {
data: {
streams: {
_sessionreplay: {
- schema: {
- geo_info_country: true,
- geo_info_city: true,
- },
+ schema: replaySchema.fields,
},
},
},
@@ -92,6 +101,38 @@ vi.mock("@/services/search", async (importOriginal) => {
});
});
+// The replay-segment fetches go over HTTP streaming; capture their SQL and answer them here.
+// A responder returns hits, { error } to fail the request, or "hang" to leave it open and silent.
+const streaming = vi.hoisted(() => ({
+ sqls: [] as string[],
+ requests: [] as any[],
+ responder: (_sql: string, _from: number): any => [],
+ cancel: null as any,
+}));
+
+vi.mock("@/composables/useStreamingSearch", () => ({
+ default: () => ({
+ fetchQueryDataWithHttpStream: async (data: any, handlers: any) => {
+ const sql = data.queryReq.query.sql as string;
+ streaming.sqls.push(sql);
+ streaming.requests.push({ data, handlers });
+ const result = streaming.responder(sql, data.queryReq.query.from);
+ if (result === "hang") return;
+ if (result?.error) {
+ handlers.error(data, { type: "error", content: result.error });
+ return;
+ }
+ handlers.onActivity?.();
+ handlers.data(data, {
+ type: "search_response_hits",
+ content: { results: { hits: result } },
+ });
+ handlers.complete(data, null);
+ },
+ cancelStreamQueryBasedOnRequestId: (streaming.cancel ??= vi.fn()),
+ }),
+}));
+
vi.mock("@/utils/date", () => ({
formatDate: vi.fn().mockReturnValue("Jun 04, 2026 12:00:00 +0000"),
}));
@@ -104,11 +145,64 @@ vi.mock("@/utils/zincutils", async (importOriginal) => {
};
});
+// Stubs expose seekTo, so a seek after a re-render still reaches a spy.
+const playerStubs = vi.hoisted(() => ({
+ videoSeek: null as any,
+ mobileSeek: null as any,
+}));
+
// --- Component import (sees the mocks above) ---
import SessionViewer from "./SessionViewer.vue";
import store from "@/test/unit/helpers/store";
import ShareButton from "@/components/common/ShareButton.vue";
+import OBadge from "@/lib/core/Badge/OBadge.vue";
import searchService from "@/services/search";
+import { ACTIVE_WINDOW_MS } from "@/utils/rum/sessionReplayLive";
+import { b64DecodeUnicode } from "@/utils/zincutils";
+
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+async function flushMany(times = 12) {
+ for (let i = 0; i < times; i++) await flush();
+}
+
+const fixtureStart = 1692884313968;
+
+// Three segments, full snapshots at 0 s and 2 s of the session.
+const fixtureRows = [
+ { start: fixtureStart, end: fixtureStart + 999, has_full_snapshot: true, records_count: 2 },
+ {
+ start: fixtureStart + 1000,
+ end: fixtureStart + 1999,
+ has_full_snapshot: false,
+ records_count: 1,
+ },
+ {
+ start: fixtureStart + 2000,
+ end: fixtureStart + 2999,
+ has_full_snapshot: true,
+ records_count: 1,
+ },
+];
+
+// Body rows carry the key columns the loader matches on, as the bodies query selects them.
+function fixtureBody(row: any) {
+ return {
+ ...row,
+ segment: JSON.stringify({ records: [{ type: 4, timestamp: row.start, data: {} }] }),
+ };
+}
+
+// Answers the manifest with `rows`, and a body range with every row whose start falls inside it.
+function rowsResponder(rows: any[], body = fixtureBody) {
+ return (sql: string, from: number) => {
+ if (from > 0) return [];
+ if (sql.includes("has_full_snapshot")) return rows;
+ const lo = Number(/start >= (\d+)/.exec(sql)?.[1] ?? 0);
+ const hi = Number(/start <= (\d+)/.exec(sql)?.[1] ?? 0);
+ return rows.filter((row) => row.start >= lo && row.start <= hi).map(body);
+ };
+}
// ---------------------------------------------------------------------------
// Mount factory — centralises stub config; update in one place when the
@@ -134,7 +228,20 @@ function mountSessionViewer(router = createTestRouter()) {
plugins: [store, router],
stubs: {
// Stub heavyweight child components — not the subjects of these tests
- VideoPlayer: { template: '' },
+ VideoPlayer: defineComponent({
+ template: '',
+ setup(_props, { expose }) {
+ expose({ seekTo: (...args: any[]) => playerStubs.videoSeek?.(...args) });
+ return {};
+ },
+ }),
+ MobileSessionPlayer: defineComponent({
+ template: '',
+ setup(_props, { expose }) {
+ expose({ seekTo: (...args: any[]) => playerStubs.mobileSeek?.(...args) });
+ return {};
+ },
+ }),
PlayerEventsSidebar: {
template: '',
props: ["events", "sessionDetails", "sessionId", "currentTime", "startTime", "endTime"],
@@ -566,30 +673,39 @@ describe("SessionViewer.vue", () => {
expect((wrapper.vm as any).selectedEvent).toEqual(payload);
});
+ // Seeks now go to the player in session ms through the coverage check, so they need a loaded replay.
+ async function mountReady() {
+ wrapper.unmount();
+ streaming.responder = rowsResponder(fixtureRows);
+ wrapper = mountSessionViewer(router);
+ await flushMany();
+ const seekTo = vi.fn();
+ (wrapper.vm as any).videoPlayerRef = { seekTo };
+ (wrapper.vm as any).playerLoadedEndMs = 2999;
+ (wrapper.vm as any).playerTakenCount = (wrapper.vm as any).segments.length;
+ (wrapper.vm as any).handlePlayerReady();
+ return seekTo;
+ }
+
it("should always seek the video player to the event relative time", async () => {
- const gotoSpy = vi.fn();
- (wrapper.vm as any).videoPlayerRef = {
- goto: gotoSpy,
- playerState: { isPlaying: false },
- };
+ const seekTo = await mountReady();
const payload = { event_id: "evt-2", relativeTime: 12345, type: "view" };
(wrapper.vm as any).handleSidebarEvent("event-click", payload);
- expect(gotoSpy).toHaveBeenCalledWith(12345, false);
+ expect(seekTo).toHaveBeenCalledWith(12345, false);
+ streaming.responder = () => [];
});
it("should auto-play the video when it is already playing during seek", async () => {
- const gotoSpy = vi.fn();
- (wrapper.vm as any).videoPlayerRef = {
- goto: gotoSpy,
- playerState: { isPlaying: true },
- };
+ const seekTo = await mountReady();
+ (wrapper.vm as any).replayIntent = "play";
const payload = { event_id: "evt-3", relativeTime: 9999, type: "action" };
(wrapper.vm as any).handleSidebarEvent("event-click", payload);
- expect(gotoSpy).toHaveBeenCalledWith(9999, true);
+ expect(seekTo).toHaveBeenCalledWith(9999, true);
+ streaming.responder = () => [];
});
it("should look up the raw event from rawEventsMap and set selectedRawEvent", async () => {
@@ -826,3 +942,1498 @@ describe("SessionViewer.vue — repeated view documents", () => {
}
});
});
+
+// ---------------------------------------------------------------------------
+// Loader, seeks and failures. Every suite sets its own responder and resets the shared mocks.
+// ---------------------------------------------------------------------------
+
+const S = fixtureStart;
+const sessionLookupRow = {
+ zo_sql_timestamp: 1692884313968000,
+ start_time: 1692884313968,
+ end_time: 1692884769270,
+ browser: "Chrome",
+ os: "macOS",
+ ip: "1.2.3.4",
+ source: "browser",
+ city: "San Francisco",
+ country: "US",
+ session_id: "session-abc",
+};
+
+async function pushRoute(query: Record = {}) {
+ const router = createTestRouter();
+ await router.push({
+ path: "/rum/sessions/session-abc",
+ query: { start_time: "1692884313968000", end_time: "1692884769270000", ...query },
+ });
+ return router;
+}
+
+async function mountLoaded(query: Record = {}) {
+ const wrapper = mountSessionViewer(await pushRoute(query));
+ await flushMany();
+ return wrapper;
+}
+
+function resetStreaming(responder: (sql: string, from: number) => any) {
+ streaming.sqls = [];
+ streaming.requests = [];
+ streaming.responder = responder;
+ streaming.cancel?.mockClear();
+}
+
+// n segments one second apart; only the first holds a full snapshot.
+function manyRows(n: number) {
+ return Array.from({ length: n }, (_, i) => ({
+ start: S + i * 1000,
+ end: S + i * 1000 + 999,
+ has_full_snapshot: i === 0,
+ records_count: 1,
+ }));
+}
+
+const bodySqls = () => streaming.sqls.filter((sql) => sql.includes("segment"));
+
+describe("SessionViewer.vue — segment manifest and windowed fetch", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ replaySchema.fields = { geo_info_country: true, geo_info_city: true };
+ resetStreaming(rowsResponder(fixtureRows));
+ });
+
+ it("lists the manifest without the segment body and without select *", async () => {
+ const wrapper = await mountLoaded();
+
+ const manifestSql = streaming.sqls[0];
+ expect(manifestSql).toContain(
+ 'select start, "end", has_full_snapshot, records_count, _timestamp from',
+ );
+ expect(manifestSql).not.toContain("select *");
+ expect(manifestSql).not.toContain(", segment");
+ wrapper.unmount();
+ });
+
+ it("sorts by start then end when the schema has no view columns", async () => {
+ const wrapper = await mountLoaded();
+
+ expect(streaming.sqls[0]).toMatch(/order by start asc, "end" asc$/);
+ expect(streaming.sqls[1]).toMatch(/order by start asc, "end" asc$/);
+ wrapper.unmount();
+ });
+
+ it("selects and sorts by the view columns when the schema has them", async () => {
+ replaySchema.fields = { ...replaySchema.fields, view_id: true, index_in_view: true };
+ const wrapper = await mountLoaded();
+
+ expect(streaming.sqls[0]).toContain(
+ 'select start, "end", has_full_snapshot, records_count, view_id, index_in_view, _timestamp from',
+ );
+ expect(streaming.sqls[1]).toContain(
+ 'select start, "end", segment, records_count, view_id, index_in_view from',
+ );
+ expect(streaming.sqls[1]).toMatch(
+ /order by start asc, "end" asc, index_in_view asc, view_id asc$/,
+ );
+ wrapper.unmount();
+ });
+
+ // The bodies query now also selects records_count, the key column the loader matches on.
+ it("fetches only the window from the anchor snapshot through the target", async () => {
+ const wrapper = await mountLoaded();
+
+ const windowSql = streaming.sqls[1];
+ expect(windowSql).toContain('select start, "end", segment, records_count from');
+ expect(windowSql).toContain(`and start >= ${S} and start <= ${S}`);
+ wrapper.unmount();
+ });
+
+ it("loads the remaining segments in the background, later segments only", async () => {
+ const wrapper = await mountLoaded();
+
+ const backgroundSql = streaming.sqls[2];
+ expect(backgroundSql).toContain(`and start >= ${S + 1000} and start <= ${S + 2000}`);
+ expect((wrapper.vm as any).segments).toHaveLength(3);
+ wrapper.unmount();
+ });
+
+ it("feeds the player in manifest order and reports a complete load", async () => {
+ const wrapper = await mountLoaded();
+ const vm = wrapper.vm as any;
+
+ const starts = vm.segments.map((segment: any) => segment.records[0].timestamp);
+ expect(starts).toEqual([S, S + 1000, S + 2000]);
+ expect(vm.loadState).toBe("complete");
+ expect(vm.loadPercent).toBe(100);
+ expect(vm.loadedRanges).toEqual([{ start: 0, end: 1692884769270 - S, state: "inPlayer" }]);
+ wrapper.unmount();
+ });
+
+ it("shows the session duration in the header", async () => {
+ const wrapper = await mountLoaded();
+ expect(wrapper.find('[data-test="session-viewer-duration"]').text()).toBe("7m 35s");
+ wrapper.unmount();
+ });
+});
+
+describe("SessionViewer.vue — segment identity (Risk 1)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ replaySchema.fields = { geo_info_country: true, geo_info_city: true };
+ });
+
+ it("dedups an exact duplicate manifest row before counting segments and records", async () => {
+ resetStreaming(rowsResponder([fixtureRows[0], fixtureRows[1], fixtureRows[1], fixtureRows[2]]));
+ const wrapper = await mountLoaded();
+ const vm = wrapper.vm as any;
+
+ expect(vm.manifest).toHaveLength(3);
+ expect(vm.manifestSummary.recordCount).toBe(4);
+ expect(vm.segments).toHaveLength(3);
+ wrapper.unmount();
+ });
+
+ it("stores an exact duplicate body row once", async () => {
+ const responder = rowsResponder(fixtureRows);
+ resetStreaming((sql, from) => {
+ const hits = responder(sql, from);
+ return sql.includes("segment") ? [...hits, ...hits] : hits;
+ });
+ const wrapper = await mountLoaded();
+
+ expect((wrapper.vm as any).segments).toHaveLength(3);
+ expect((wrapper.vm as any).loadPercent).toBe(100);
+ wrapper.unmount();
+ });
+
+ it("stores a row tied at a batch edge once and does not fetch it again", async () => {
+ const rows = manyRows(27);
+ // Rows 25 and 26 tie on start, so the full batch ends before them and they travel together in the next one.
+ rows[26] = { ...rows[26], start: rows[25].start, end: rows[25].start + 500 };
+ resetStreaming(rowsResponder(rows));
+ const wrapper = await mountLoaded();
+ const vm = wrapper.vm as any;
+
+ expect(bodySqls()).toHaveLength(3);
+ expect(bodySqls().filter((sql) => sql.includes(`start <= ${rows[25].start}`))).toHaveLength(1);
+ expect(vm.segments).toHaveLength(27);
+ expect(new Set(vm.segments).size).toBe(27);
+ expect(vm.loadState).toBe("complete");
+ wrapper.unmount();
+ });
+});
+
+describe("SessionViewer.vue — server-clock query windows (Risk 3)", () => {
+ const minTs = 1692884313968000;
+ const maxTs = 1692884769270000 + 5_000_000;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ replaySchema.fields = { geo_info_country: true, geo_info_city: true };
+ resetStreaming(rowsResponder(fixtureRows));
+ });
+
+ const windowsBy = (size: number) =>
+ queryPayload.build.mock.calls
+ .map((call: any[]) => call[0])
+ .filter((payload: any) => payload.size === size)
+ .map((payload: any) => payload.timestamps);
+
+ it("asks getSession for the latest arrival time too", async () => {
+ const wrapper = await mountLoaded();
+ const sql = (vi.mocked(searchService.search).mock.calls[0][0] as any).query.query.sql;
+ expect(sql).toContain("max(_timestamp) as max_ts");
+ wrapper.unmount();
+ });
+
+ it("searches segments and events between the server arrival times", async () => {
+ vi.mocked(searchService.search).mockResolvedValueOnce({
+ data: { hits: [{ ...sessionLookupRow, max_ts: maxTs }] },
+ } as any);
+ const wrapper = await mountLoaded();
+
+ for (const window of windowsBy(1000)) {
+ expect(window).toEqual({ startTime: minTs - 1_000_000, endTime: maxTs + 1_000_000 });
+ }
+ expect(windowsBy(150)[0]).toEqual({
+ startTime: minTs - 60_000_000,
+ endTime: maxTs + 60_000_000,
+ });
+ wrapper.unmount();
+ });
+
+ it("falls back to the route range ±1 day when the arrival times are missing", async () => {
+ const wrapper = await mountLoaded();
+ const day = 86_400_000_000;
+
+ expect(windowsBy(1000)[0]).toEqual({
+ startTime: 1692884313968000 - day,
+ endTime: 1692884769270000 + day,
+ });
+ expect(windowsBy(150)[0]).toEqual({
+ startTime: 1692884313968000 - day,
+ endTime: 1692884769270000 + day,
+ });
+ wrapper.unmount();
+ });
+
+ it("uses a bounded lookup for a deep link without start and end, and shows not-found", async () => {
+ vi.mocked(searchService.search).mockResolvedValueOnce({ data: { hits: [] } } as any);
+ const router = createTestRouter();
+ await router.push({ path: "/rum/sessions/session-abc" });
+ const wrapper = mountSessionViewer(router);
+ await flushMany();
+
+ const query = (vi.mocked(searchService.search).mock.calls[0][0] as any).query.query;
+ expect(Number.isFinite(query.start_time)).toBe(true);
+ expect(Number.isFinite(query.end_time)).toBe(true);
+ expect(query.end_time - query.start_time).toBe(30 * 86_400_000_000);
+ expect(wrapper.find('[data-test="session-viewer-no-replay"]').exists()).toBe(true);
+ wrapper.unmount();
+ });
+});
+
+describe("SessionViewer.vue — base64 SQL encoding", () => {
+ const plainPayload = () => ({ query: { sql: "" }, aggs: {} });
+ let wasEnabled: boolean;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ replaySchema.fields = { geo_info_country: true, geo_info_city: true };
+ wasEnabled = store.state.zoConfig.sql_base64_enabled;
+ store.state.zoConfig.sql_base64_enabled = true;
+ // Mirrors buildQueryPayload, which marks the payload base64 when the cluster flag is on.
+ (queryPayload.build ??= vi.fn()).mockImplementation(() => ({
+ ...plainPayload(),
+ encoding: "base64",
+ }));
+ const plain = rowsResponder(fixtureRows);
+ resetStreaming((sql, from) => plain(b64DecodeUnicode(sql) ?? "", from));
+ });
+
+ afterEach(() => {
+ store.state.zoConfig.sql_base64_enabled = wasEnabled;
+ queryPayload.build.mockImplementation(plainPayload);
+ });
+
+ it("sends the segment and event SQL base64-encoded", async () => {
+ const wrapper = await mountLoaded();
+
+ const segmentSqls = streaming.sqls.map((sql) => b64DecodeUnicode(sql) ?? "");
+ expect(segmentSqls.some((sql) => sql.includes("has_full_snapshot"))).toBe(true);
+ expect(segmentSqls.some((sql) => sql.includes("segment"))).toBe(true);
+ expect(streaming.sqls.every((sql) => !sql.includes("select"))).toBe(true);
+
+ const eventQueries = vi
+ .mocked(searchService.search)
+ .mock.calls.map((call: any[]) => call[0].query)
+ .filter((query: any) => query.encoding === "base64");
+ const eventSqls = eventQueries.map((query: any) => b64DecodeUnicode(query.query.sql) ?? "");
+ expect(eventSqls.some((sql) => sql.includes('"_rumdata"'))).toBe(true);
+ expect(eventSqls.some((sql) => sql.includes('"_rumlog"'))).toBe(true);
+ expect(eventQueries.every((query: any) => !query.query.sql.includes("select"))).toBe(true);
+ wrapper.unmount();
+ });
+});
+
+describe("SessionViewer.vue — retries and the error state (E0)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ replaySchema.fields = { geo_info_country: true, geo_info_city: true };
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ async function mountWithFakeTimers() {
+ const router = await pushRoute();
+ const wrapper = mountSessionViewer(router);
+ await vi.advanceTimersByTimeAsync(10_000);
+ return wrapper;
+ }
+
+ it("retries a failing manifest three times, then shows the error state", async () => {
+ const good = rowsResponder(fixtureRows);
+ resetStreaming((sql, from) =>
+ sql.includes("has_full_snapshot")
+ ? { error: { code: 500, message: "boom" } }
+ : good(sql, from),
+ );
+ const wrapper = await mountWithFakeTimers();
+ const vm = wrapper.vm as any;
+
+ expect(streaming.sqls.filter((sql) => sql.includes("has_full_snapshot"))).toHaveLength(3);
+ expect(vm.loadState).toBe("error");
+ expect(vm.pendingSeekMs).toBeNull();
+
+ streaming.responder = good;
+ await vm.handleRetry();
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(vm.loadState).toBe("complete");
+ expect(vm.segments).toHaveLength(3);
+ wrapper.unmount();
+ });
+
+ it("does not retry an error that cannot succeed next time", async () => {
+ resetStreaming(() => ({ error: { code: 400, message: "bad sql" } }));
+ const wrapper = await mountWithFakeTimers();
+
+ expect(streaming.sqls).toHaveLength(1);
+ expect((wrapper.vm as any).loadState).toBe("error");
+ wrapper.unmount();
+ });
+
+ it("does not retry an in-stream search error that describes the query itself", async () => {
+ resetStreaming(() => ({ error: { code: 20001, message: "sql not valid" } }));
+ const wrapper = await mountWithFakeTimers();
+
+ expect(streaming.sqls).toHaveLength(1);
+ expect((wrapper.vm as any).loadState).toBe("error");
+ wrapper.unmount();
+ });
+
+ it("retries an in-stream search timeout code", async () => {
+ resetStreaming(() => ({ error: { code: 20010, message: "search timeout" } }));
+ const wrapper = await mountWithFakeTimers();
+
+ expect(streaming.sqls).toHaveLength(3);
+ wrapper.unmount();
+ });
+
+ it("keeps a mobile player on its loading state while a Retry after a manifest failure runs", async () => {
+ const good = rowsResponder(fixtureRows);
+ resetStreaming((sql, from) =>
+ sql.includes("has_full_snapshot") ? { error: { status: 502 } } : good(sql, from),
+ );
+ vi.mocked(searchService.search).mockResolvedValueOnce({
+ data: { hits: [{ ...sessionLookupRow, source: "android" }] },
+ } as any);
+ const wrapper = await mountWithFakeTimers();
+ const vm = wrapper.vm as any;
+ expect(vm.loadState).toBe("error");
+ expect(vm.segmentsLoading).toBe(false);
+
+ const retry = vm.handleRetry();
+ await vi.advanceTimersByTimeAsync(0);
+ const mobile = wrapper.findComponent('[data-test="stub-mobile-player"]');
+ expect(vm.loadState).toBe("loading");
+ expect(mobile.attributes("is-loading")).toBe("true");
+
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(vm.retryAttempt).toBe(2);
+ expect(vm.segmentsLoading).toBe(true);
+
+ await vi.advanceTimersByTimeAsync(5_000);
+ await retry;
+ expect(vm.loadState).toBe("error");
+ expect(vm.segmentsLoading).toBe(false);
+ wrapper.unmount();
+ });
+
+ it("clears a pending seek when the first window fails", async () => {
+ const good = rowsResponder(fixtureRows);
+ resetStreaming((sql, from) =>
+ sql.includes("segment") ? { error: { status: 503 } } : good(sql, from),
+ );
+ const router = await pushRoute({ event_time: String(S + 2500) });
+ const wrapper = mountSessionViewer(router);
+ await vi.advanceTimersByTimeAsync(10_000);
+ const vm = wrapper.vm as any;
+
+ expect(bodySqls()).toHaveLength(3);
+ expect(vm.loadState).toBe("error");
+ expect(vm.pendingSeekMs).toBeNull();
+ wrapper.unmount();
+ });
+
+ it("retries getSession and shows the error state instead of an empty player", async () => {
+ resetStreaming(rowsResponder(fixtureRows));
+ vi.mocked(searchService.search).mockRejectedValue({ response: { status: 503 } });
+ const wrapper = await mountWithFakeTimers();
+ const vm = wrapper.vm as any;
+
+ expect(vi.mocked(searchService.search)).toHaveBeenCalledTimes(3);
+ expect(vm.loadState).toBe("error");
+ expect(streaming.sqls).toHaveLength(0);
+
+ vi.mocked(searchService.search).mockImplementation(async (params: any) => {
+ const sql: string = params?.query?.query?.sql ?? "";
+ return { data: { hits: sql.includes("min(start)") ? [sessionLookupRow] : [] } } as any;
+ });
+ await vm.handleRetry();
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(vm.loadState).toBe("complete");
+ wrapper.unmount();
+ });
+});
+
+describe("SessionViewer.vue — background batches (G5)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ replaySchema.fields = { geo_info_country: true, geo_info_city: true };
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ async function mountWithFakeTimers() {
+ const wrapper = mountSessionViewer(await pushRoute());
+ await vi.advanceTimersByTimeAsync(10_000);
+ return wrapper;
+ }
+
+ it("skips past a batch that failed three times and keeps loading after it", async () => {
+ const rows = manyRows(30);
+ const good = rowsResponder(rows);
+ resetStreaming((sql, from) =>
+ sql.includes(`start >= ${S + 1000} `) ? { error: { status: 502 } } : good(sql, from),
+ );
+ const wrapper = await mountWithFakeTimers();
+ const vm = wrapper.vm as any;
+
+ expect(bodySqls().filter((sql) => sql.includes(`start >= ${S + 1000} `))).toHaveLength(3);
+ expect(vm.segments).toHaveLength(30);
+ expect(vm.segments[1]).toMatchObject({ skipped: true, start: S + 1000 });
+ expect(vm.segments[26].records).toBeDefined();
+ expect(vm.loadState).toBe("failed");
+ expect(vm.failedFromMs).toBe(1000);
+ expect(vm.loadedRanges.filter((r: any) => r.state === "skipped")).toHaveLength(25);
+
+ streaming.responder = good;
+ await vm.handleRetry();
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(vm.loadState).toBe("complete");
+ expect(vm.segments).toHaveLength(30);
+ wrapper.unmount();
+ });
+
+ it("re-queues a segment missing from a successful batch twice, then skips it", async () => {
+ const good = rowsResponder(fixtureRows);
+ resetStreaming((sql, from) =>
+ sql.includes("segment")
+ ? good(sql, from).filter((row: any) => row.start !== S + 2000)
+ : good(sql, from),
+ );
+ const wrapper = await mountWithFakeTimers();
+ const vm = wrapper.vm as any;
+
+ expect(bodySqls().filter((sql) => sql.includes(`start <= ${S + 2000}`))).toHaveLength(3);
+ expect(vm.segments[2]).toMatchObject({ skipped: true });
+ expect(vm.loadState).toBe("complete");
+ wrapper.unmount();
+ });
+
+ it("marks a body that fails to parse as skipped and never retries it", async () => {
+ resetStreaming(
+ rowsResponder(fixtureRows, (row: any) =>
+ row.start === S + 1000 ? { ...row, segment: "{not json" } : fixtureBody(row),
+ ),
+ );
+ const wrapper = await mountWithFakeTimers();
+ const vm = wrapper.vm as any;
+
+ expect(vm.segments[1]).toMatchObject({ skipped: true });
+ expect(vm.loadState).toBe("complete");
+ const before = streaming.sqls.length;
+ await vm.handleRetry();
+ await vi.advanceTimersByTimeAsync(5_000);
+ expect(streaming.sqls).toHaveLength(before);
+ wrapper.unmount();
+ });
+
+ it("runs a watchdog while buffering that never re-queues skipped segments", async () => {
+ const good = rowsResponder(fixtureRows);
+ resetStreaming((sql, from) =>
+ sql.includes(`start >= ${S + 1000} `) ? { error: { status: 500 } } : good(sql, from),
+ );
+ const wrapper = await mountWithFakeTimers();
+ const vm = wrapper.vm as any;
+ expect(vm.loader.status).toEqual(["stored", "skipped", "skipped"]);
+
+ // Still loading, loop idle, and the segment right after the player's edge is skipped: the watchdog's own case.
+ vm.run = { ...vm.run, appendedThroughIndex: 0 };
+ vm.loadState = "loading";
+ vm.playerPlaybackState = "buffering";
+ await wrapper.vm.$nextTick();
+ const before = streaming.sqls.length;
+ await vi.advanceTimersByTimeAsync(20_000);
+ expect(streaming.sqls).toHaveLength(before);
+
+ // Control: the same watchdog does fetch a segment that is merely missing.
+ vm.loader.status[1] = "missing";
+ await vi.advanceTimersByTimeAsync(2_000);
+ expect(streaming.sqls.length).toBeGreaterThan(before);
+ expect(streaming.sqls[before]).toContain(`start >= ${S + 1000} `);
+ wrapper.unmount();
+ });
+});
+
+describe("SessionViewer.vue — request time limits and cancellation (G6)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ replaySchema.fields = { geo_info_country: true, geo_info_city: true };
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("cancels a request with no first byte after 90 s and retries it as a timeout", async () => {
+ resetStreaming(() => "hang");
+ const wrapper = mountSessionViewer(await pushRoute());
+ await vi.advanceTimersByTimeAsync(89_000);
+ expect(streaming.cancel).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(streaming.cancel).toHaveBeenCalledWith(
+ expect.objectContaining({ trace_id: streaming.requests[0].data.traceId }),
+ );
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(streaming.sqls).toHaveLength(2);
+ wrapper.unmount();
+ });
+
+ it("does not time out a request that keeps sending", async () => {
+ resetStreaming(() => "hang");
+ const wrapper = mountSessionViewer(await pushRoute());
+ await vi.advanceTimersByTimeAsync(0);
+ const { handlers } = streaming.requests[0];
+ for (let i = 0; i < 10; i++) {
+ await vi.advanceTimersByTimeAsync(20_000);
+ handlers.onActivity();
+ }
+ expect(streaming.cancel).not.toHaveBeenCalled();
+ expect(streaming.sqls).toHaveLength(1);
+ wrapper.unmount();
+ });
+
+ it("times out after 30 s of silence once the stream has started", async () => {
+ resetStreaming(() => "hang");
+ const wrapper = mountSessionViewer(await pushRoute());
+ await vi.advanceTimersByTimeAsync(0);
+ streaming.requests[0].handlers.onActivity();
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(streaming.cancel).toHaveBeenCalledTimes(1);
+ wrapper.unmount();
+ });
+
+ it("cancels every live request when the page unmounts", async () => {
+ resetStreaming(() => "hang");
+ const wrapper = mountSessionViewer(await pushRoute());
+ await vi.advanceTimersByTimeAsync(0);
+ const traceId = streaming.requests[0].data.traceId;
+
+ wrapper.unmount();
+
+ expect(streaming.cancel).toHaveBeenCalledWith(expect.objectContaining({ trace_id: traceId }));
+ });
+});
+
+describe("SessionViewer.vue — seeks in session ms (G4, G7)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ replaySchema.fields = { geo_info_country: true, geo_info_city: true };
+ resetStreaming(rowsResponder(fixtureRows));
+ });
+
+ function readyPlayer(wrapper: VueWrapper, endMs = 2999) {
+ const seekTo = vi.fn();
+ playerStubs.videoSeek = seekTo;
+ const vm = wrapper.vm as any;
+ vm.playerLoadedEndMs = endMs;
+ vm.playerTakenCount = vm.segments.length;
+ vm.handlePlayerReady();
+ return seekTo;
+ }
+
+ it("anchors the first window on the snapshot before the forwarded event", async () => {
+ const wrapper = await mountLoaded({ event_time: String(S + 2500) });
+
+ expect((wrapper.vm as any).windowStart).toBe(S + 2000);
+ wrapper.unmount();
+ });
+
+ it("turns the forwarded event into a one-time pending seek, consumed after ready", async () => {
+ const wrapper = await mountLoaded({ event_time: String(S + 2500) });
+ const vm = wrapper.vm as any;
+ expect(vm.pendingSeekMs).toBe(2500);
+
+ const seekTo = readyPlayer(wrapper);
+ expect(seekTo).toHaveBeenCalledWith(2500, false);
+ expect(vm.pendingSeekMs).toBeNull();
+
+ vm.handlePlayerReady();
+ expect(seekTo).toHaveBeenCalledTimes(1);
+ wrapper.unmount();
+ });
+
+ it("hands a sidebar seek to the player in session ms; the player owns its origin", async () => {
+ const wrapper = await mountLoaded({ event_time: String(S + 2500) });
+ const seekTo = readyPlayer(wrapper);
+ seekTo.mockClear();
+
+ (wrapper.vm as any).handleSidebarEvent("event-click", { event_id: "e1", relativeTime: 2400 });
+
+ expect(seekTo).toHaveBeenCalledWith(2400, false);
+ wrapper.unmount();
+ });
+
+ it("routes a bar seek request through the same coverage check", async () => {
+ const wrapper = await mountLoaded();
+ const seekTo = readyPlayer(wrapper);
+
+ (wrapper.vm as any).requestSeek(1500);
+
+ expect(seekTo).toHaveBeenCalledWith(1500, false);
+ wrapper.unmount();
+ });
+
+ it("keeps the behind-window notice for a target before the window, and never waits on it", async () => {
+ const wrapper = await mountLoaded({ event_time: String(S + 2500) });
+ const seekTo = readyPlayer(wrapper);
+ seekTo.mockClear();
+
+ (wrapper.vm as any).handleSidebarEvent("event-click", { event_id: "e1", relativeTime: 500 });
+
+ expect(seekTo).toHaveBeenCalledWith(2000, false);
+ expect((wrapper.vm as any).pendingSeekMs).toBeNull();
+ expect((wrapper.vm as any).segmentNotice).toContain("before the part of the session");
+ wrapper.unmount();
+ });
+
+ it("waits on a target past the player's edge and seeks once the loader covers it", async () => {
+ const rows = manyRows(5);
+ const good = rowsResponder(rows);
+ resetStreaming((sql, from) =>
+ sql.includes(`start >= ${S + 1000} `) ? "hang" : good(sql, from),
+ );
+ const wrapper = await mountLoaded();
+ const vm = wrapper.vm as any;
+ const seekTo = readyPlayer(wrapper, 999);
+
+ vm.handleSidebarEvent("event-click", { event_id: "e1", relativeTime: 3500 });
+ expect(seekTo).not.toHaveBeenCalled();
+ expect(vm.pendingSeekMs).toBe(3500);
+
+ const pending = streaming.requests.find((r) =>
+ r.data.queryReq.query.sql.includes(`start >= ${S + 1000} `),
+ );
+ pending.handlers.data(pending.data, {
+ type: "search_response_hits",
+ content: { results: { hits: rows.slice(1).map(fixtureBody) } },
+ });
+ pending.handlers.complete(pending.data, null);
+ await flushMany();
+
+ // The run now holds the last segment, but the player has not converted it, so seeking would drop records.
+ expect(vm.run.appendedThroughIndex).toBe(4);
+ expect(seekTo).not.toHaveBeenCalled();
+ expect(vm.pendingSeekMs).toBe(3500);
+
+ wrapper.findComponent('[data-test="stub-video-player"]').vm.$emit("segments-taken", 5);
+ await flushMany();
+
+ expect(seekTo).toHaveBeenCalledWith(3500, false);
+ expect(vm.pendingSeekMs).toBeNull();
+ wrapper.unmount();
+ });
+});
+
+describe("SessionViewer.vue — playback starts at the first full snapshot", () => {
+ // The first view lost its snapshot segment, so the row at S is an orphan mutation batch.
+ const orphanRows = [
+ { start: S, end: S + 999, has_full_snapshot: false, records_count: 1 },
+ ...fixtureRows.slice(1).map((row) => ({ ...row, has_full_snapshot: true })),
+ ];
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ replaySchema.fields = { geo_info_country: true, geo_info_city: true, has_full_snapshot: true };
+ resetStreaming(rowsResponder(orphanRows));
+ vi.mocked(searchService.search).mockResolvedValueOnce({
+ data: { hits: [{ ...sessionLookupRow, replay_start: S + 1000 }] },
+ } as any);
+ });
+
+ it("asks getSession for replay_start when the stream has has_full_snapshot", async () => {
+ const wrapper = await mountLoaded();
+ const sql = (vi.mocked(searchService.search).mock.calls[0][0] as any).query.query.sql;
+ expect(sql).toContain("min(case when has_full_snapshot then start end) as replay_start");
+ wrapper.unmount();
+ });
+
+ it("drops manifest rows before replay_start, so the first window opens on a snapshot", async () => {
+ const wrapper = await mountLoaded();
+ const vm = wrapper.vm as any;
+
+ expect(vm.manifest.map((row: any) => row.start)).toEqual([S + 1000, S + 2000]);
+ expect(streaming.sqls[1]).toContain(`and start >= ${S + 1000}`);
+ wrapper.unmount();
+ });
+
+ it("lands a sidebar seek before replay_start on the first playable frame", async () => {
+ const wrapper = await mountLoaded();
+ const vm = wrapper.vm as any;
+ const seekTo = vi.fn();
+ playerStubs.videoSeek = seekTo;
+ vm.playerLoadedEndMs = 2999;
+ vm.playerTakenCount = vm.segments.length;
+ vm.handlePlayerReady();
+
+ vm.handleSidebarEvent("event-click", { event_id: "e1", relativeTime: 200 });
+
+ expect(seekTo).toHaveBeenCalledWith(1000, false);
+ expect(vm.unreachableSeek).toBe(false);
+ wrapper.unmount();
+ });
+});
+
+describe("SessionViewer.vue — mobile sessions (F0.2)", () => {
+ const mobileBody = (row: any) => ({
+ ...row,
+ segment: JSON.stringify({
+ records: [
+ { type: 10, timestamp: row.start, data: { wireframes: [] } },
+ { type: 11, timestamp: row.end, data: {} },
+ ],
+ }),
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ replaySchema.fields = { geo_info_country: true, geo_info_city: true };
+ resetStreaming(rowsResponder(fixtureRows, mobileBody));
+ });
+
+ function mountMobile(query: Record = {}) {
+ vi.mocked(searchService.search).mockResolvedValueOnce({
+ data: { hits: [{ ...sessionLookupRow, source: "android" }] },
+ } as any);
+ return mountLoaded(query);
+ }
+
+ it("loads a mobile session opened from an event from its first segment", async () => {
+ const wrapper = await mountMobile({ event_time: String(S + 2500) });
+
+ expect(streaming.sqls[1]).toContain(`and start >= ${S} and start <= ${S}`);
+ expect((wrapper.vm as any).isMobileReplay).toBe(true);
+ wrapper.unmount();
+ });
+
+ it("seeks the mobile player to the event in session ms after ready", async () => {
+ const wrapper = await mountMobile({ event_time: String(S + 2500) });
+ const vm = wrapper.vm as any;
+ const seekTo = vi.fn();
+ playerStubs.mobileSeek = seekTo;
+ vm.handlePlayerReady();
+
+ expect(seekTo).toHaveBeenCalledWith(2500, false);
+ expect(vm.pendingSeekMs).toBeNull();
+ wrapper.unmount();
+ });
+
+ it("sends a sidebar click to the mobile player, or waits when it is not loaded yet", async () => {
+ const wrapper = await mountMobile();
+ const vm = wrapper.vm as any;
+ const seekTo = vi.fn();
+ playerStubs.mobileSeek = seekTo;
+ vm.handlePlayerReady();
+
+ vm.handleSidebarEvent("event-click", { event_id: "e1", relativeTime: 1500 });
+ expect(seekTo).toHaveBeenCalledWith(1500, false);
+
+ vm.loadState = "loading";
+ vm.handleSidebarEvent("event-click", { event_id: "e2", relativeTime: 9000 });
+ expect(seekTo).toHaveBeenCalledTimes(1);
+ expect(vm.pendingSeekMs).toBe(9000);
+ wrapper.unmount();
+ });
+
+ it("stops waiting on a target past the last record once nothing more can load", async () => {
+ const wrapper = await mountMobile();
+ const vm = wrapper.vm as any;
+ const seekTo = vi.fn();
+ playerStubs.mobileSeek = seekTo;
+ vm.handlePlayerReady();
+ vm.loadState = "loading";
+ vm.handleSidebarEvent("event-click", { event_id: "e2", relativeTime: 9000 });
+ expect(vm.pendingSeekMs).toBe(9000);
+
+ vm.loadState = "complete";
+ await wrapper.vm.$nextTick();
+
+ expect(seekTo).toHaveBeenCalledWith(9000, false);
+ expect(vm.pendingSeekMs).toBeNull();
+ wrapper.unmount();
+ });
+
+ it("anchors a mobile session on segment 0 even when a later row ties on the session start", async () => {
+ const tied = [
+ { start: S, end: S + 500, has_full_snapshot: false, records_count: 1 },
+ { start: S, end: S + 999, has_full_snapshot: true, records_count: 2 },
+ ...fixtureRows.slice(1),
+ ];
+ resetStreaming(rowsResponder(tied, mobileBody));
+ const wrapper = await mountMobile();
+
+ expect((wrapper.vm as any).run.anchorIndex).toBe(0);
+ expect((wrapper.vm as any).segments[0].records[1].timestamp).toBe(S + 500);
+ wrapper.unmount();
+ });
+
+ it("keeps the browser path windowed with no mobile calls", async () => {
+ resetStreaming(rowsResponder(fixtureRows));
+ const wrapper = await mountLoaded({ event_time: String(S + 2500) });
+ const vm = wrapper.vm as any;
+
+ expect(streaming.sqls[1]).toContain(`and start >= ${S + 2000} and start <= ${S + 2000}`);
+ expect(vm.isMobileReplay).toBe(false);
+ expect(wrapper.find('[data-test="stub-mobile-player"]').exists()).toBe(false);
+ wrapper.unmount();
+ });
+});
+
+describe("SessionViewer.vue — parallel batches, ordered append (D1)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ replaySchema.fields = { geo_info_country: true, geo_info_city: true };
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ // Every background batch hangs until the test answers it, so completion order is the test's choice.
+ function hangBatches(rows: any[], fail: (sql: string) => boolean = () => false) {
+ const good = rowsResponder(rows);
+ resetStreaming((sql, from) => {
+ if (!sql.includes("segment") || sql.includes(`start >= ${S} `)) return good(sql, from);
+ return fail(sql) ? { error: { status: 502 } } : "hang";
+ });
+ }
+
+ const batchRequest = (lo: number) =>
+ streaming.requests.filter((r) => r.data.queryReq.query.sql.includes(`start >= ${lo} `));
+
+ function answer(request: any, rows: any[]) {
+ const hits = rowsResponder(rows)(request.data.queryReq.query.sql, 0);
+ request.handlers.data(request.data, {
+ type: "search_response_hits",
+ content: { results: { hits } },
+ });
+ request.handlers.complete(request.data, null);
+ }
+
+ async function mountHanging() {
+ const wrapper = mountSessionViewer(await pushRoute());
+ await vi.advanceTimersByTimeAsync(100);
+ return wrapper;
+ }
+
+ it("keeps three batches in flight and starts the next one when a slot frees", async () => {
+ const rows = manyRows(101);
+ hangBatches(rows);
+ const wrapper = await mountHanging();
+
+ const background = bodySqls().slice(1);
+ expect(background).toHaveLength(3);
+ expect(background[0]).toContain(`start >= ${S + 1000} and start <= ${S + 25_000}`);
+ expect(background[1]).toContain(`start >= ${S + 26_000} and start <= ${S + 50_000}`);
+ expect(background[2]).toContain(`start >= ${S + 51_000} and start <= ${S + 75_000}`);
+
+ answer(batchRequest(S + 1000)[0], rows);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(bodySqls()).toHaveLength(5);
+ expect(bodySqls()[4]).toContain(`start >= ${S + 76_000} and start <= ${S + 100_000}`);
+ wrapper.unmount();
+ });
+
+ it("stores a later batch that lands first, and appends it only once the earlier one lands", async () => {
+ const rows = manyRows(76);
+ hangBatches(rows);
+ const wrapper = await mountHanging();
+ const vm = wrapper.vm as any;
+
+ answer(batchRequest(S + 26_000)[0], rows);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(vm.loader.status[26]).toBe("stored");
+ expect(vm.segments).toHaveLength(1);
+ expect(vm.run.appendedThroughIndex).toBe(0);
+
+ answer(batchRequest(S + 1000)[0], rows);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(vm.segments).toHaveLength(51);
+ const starts = vm.segments.map((segment: any) => segment.records[0].timestamp);
+ expect(starts).toEqual(rows.slice(0, 51).map((row) => row.start));
+
+ answer(batchRequest(S + 51_000)[0], rows);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(vm.segments).toHaveLength(76);
+ expect(vm.loadState).toBe("complete");
+ wrapper.unmount();
+ });
+
+ it("appends later stored segments after a failed middle batch's skip markers", async () => {
+ const rows = manyRows(76);
+ hangBatches(rows, (sql) => sql.includes(`start >= ${S + 26_000} `));
+ const wrapper = await mountHanging();
+ const vm = wrapper.vm as any;
+
+ answer(batchRequest(S + 51_000)[0], rows);
+ await vi.advanceTimersByTimeAsync(5_000);
+ expect(batchRequest(S + 26_000)).toHaveLength(3);
+ expect(vm.loader.status[30]).toBe("skipped");
+ expect(vm.segments).toHaveLength(1);
+ expect(vm.loadState).not.toBe("failed");
+
+ answer(batchRequest(S + 1000)[0], rows);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(vm.segments).toHaveLength(76);
+ expect(vm.segments[26]).toMatchObject({ skipped: true, start: S + 26_000 });
+ expect(vm.segments[50]).toMatchObject({ skipped: true });
+ expect(vm.segments[51].records[0].timestamp).toBe(S + 51_000);
+ expect(vm.loadState).toBe("failed");
+ expect(vm.failedFromMs).toBe(26_000);
+ wrapper.unmount();
+ });
+
+ it("has the watchdog leave an in-flight segment alone", async () => {
+ const rows = manyRows(30);
+ hangBatches(rows);
+ const wrapper = await mountHanging();
+ const vm = wrapper.vm as any;
+ expect(vm.loader.status[1]).toBe("inFlight");
+ const before = streaming.sqls.length;
+
+ vm.playerPlaybackState = "buffering";
+ await vi.advanceTimersByTimeAsync(10_000);
+
+ expect(streaming.sqls).toHaveLength(before);
+ wrapper.unmount();
+ });
+
+ it("cancels every in-flight batch when the page unmounts", async () => {
+ const rows = manyRows(101);
+ hangBatches(rows);
+ const wrapper = await mountHanging();
+ const traceIds = streaming.requests
+ .filter((r) => r.data.queryReq.query.sql.includes("segment"))
+ .slice(1)
+ .map((r) => r.data.traceId);
+ expect(new Set(traceIds).size).toBe(3);
+
+ wrapper.unmount();
+
+ for (const traceId of traceIds) {
+ expect(streaming.cancel).toHaveBeenCalledWith(expect.objectContaining({ trace_id: traceId }));
+ }
+ });
+});
+
+describe("SessionViewer.vue — sessions still being recorded (G9)", () => {
+ const NOW = 1_750_000_000_000;
+ const L = NOW - 10 * 60_000;
+ const maxTs = (NOW - 30_000) * 1000;
+ const server = { rows: [] as any[], rum: [] as any[], logs: [] as any[] };
+ let originalSearch: any;
+
+ // Every row arrives at the open's upper bound unless a test says otherwise, so each poll window re-returns it.
+ const liveRow = (i: number, extra: Record = {}) => ({
+ start: L + i * 1000,
+ end: L + i * 1000 + 999,
+ has_full_snapshot: i === 0,
+ records_count: 1,
+ _timestamp: maxTs,
+ ...extra,
+ });
+
+ // The manifest query has no start filter, so it answers by arrival time inside the request's window.
+ function liveResponder(sql: string, from: number) {
+ if (from > 0) return [];
+ if (sql.includes("has_full_snapshot")) {
+ const window = queryPayload.build.mock.calls.at(-1)[0].timestamps;
+ return server.rows.filter(
+ (row) => row._timestamp >= window.startTime && row._timestamp <= window.endTime,
+ );
+ }
+ const lo = Number(/start >= (\d+)/.exec(sql)?.[1] ?? 0);
+ const hi = Number(/start <= (\d+)/.exec(sql)?.[1] ?? 0);
+ return server.rows.filter((row) => row.start >= lo && row.start <= hi).map(fixtureBody);
+ }
+
+ let replayStart: number | undefined;
+
+ function lookupRow(endTime: number) {
+ return {
+ ...sessionLookupRow,
+ zo_sql_timestamp: L * 1000,
+ start_time: L,
+ end_time: endTime,
+ max_ts: maxTs,
+ replay_start: replayStart,
+ };
+ }
+
+ function serve(endTime: number) {
+ vi.mocked(searchService.search).mockImplementation(async (params: any) => {
+ const query = params?.query?.query ?? {};
+ const sql: string = query.sql ?? "";
+ const from = Number(query.from) || 0;
+ const size = Number(query.size) || 150;
+ if (sql.includes("min(start)")) return { data: { hits: [lookupRow(endTime)] } } as any;
+ const rows = sql.includes("_rumlog")
+ ? server.logs
+ : sql.includes("_rumdata")
+ ? server.rum
+ : [];
+ return { data: { hits: rows.slice(from, from + size) } } as any;
+ });
+ }
+
+ const segmentWindows = () =>
+ queryPayload.build.mock.calls
+ .map((call: any[]) => call[0])
+ .filter((payload: any) => payload.size === 1000)
+ .map((payload: any) => payload.timestamps);
+
+ // The open's manifest window starts a second before min_ts; a poll's starts a minute before upperTs.
+ const manifestPolls = () => {
+ const windows = segmentWindows();
+ return streaming.sqls.filter(
+ (sql, i) =>
+ sql.includes("has_full_snapshot") && windows[i]?.startTime !== L * 1000 - 1_000_000,
+ );
+ };
+
+ async function mountLive(endTime = NOW - 60_000) {
+ serve(endTime);
+ const wrapper = mountSessionViewer(await pushRoute());
+ await vi.advanceTimersByTimeAsync(100);
+ return wrapper;
+ }
+
+ const setVisibility = (state: "visible" | "hidden") => {
+ Object.defineProperty(document, "visibilityState", { value: state, configurable: true });
+ document.dispatchEvent(new Event("visibilitychange"));
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ originalSearch = vi.mocked(searchService.search).getMockImplementation();
+ replaySchema.fields = { geo_info_country: true, geo_info_city: true };
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW);
+ server.rows = [liveRow(0), liveRow(1), liveRow(2)];
+ server.rum = [];
+ server.logs = [];
+ replayStart = undefined;
+ resetStreaming(liveResponder);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.mocked(searchService.search).mockImplementation(originalSearch);
+ setVisibility("visible");
+ });
+
+ it("detects a live session with the Sessions list's rule, inclusive at the boundary", async () => {
+ const live = await mountLive(NOW - ACTIVE_WINDOW_MS);
+ const vm = live.vm as any;
+ expect(vm.isLive).toBe(true);
+ expect(vm.loadState).toBe("live");
+ expect(live.find('[data-test="session-viewer-live-badge"]').text()).toBe("Live");
+ const liveBadge = live
+ .findAllComponents(OBadge)
+ .find((badge) => badge.attributes("data-test") === "session-viewer-live-badge");
+ expect(liveBadge?.props("variant")).toBe("success");
+ live.unmount();
+
+ const ended = await mountLive(NOW - ACTIVE_WINDOW_MS - 1);
+ expect((ended.vm as any).isLive).toBe(false);
+ expect((ended.vm as any).loadState).toBe("complete");
+ expect(ended.find('[data-test="session-viewer-live-badge"]').exists()).toBe(false);
+ expect(manifestPolls()).toHaveLength(0);
+ await vi.advanceTimersByTimeAsync(60_000);
+ expect(manifestPolls()).toHaveLength(0);
+ ended.unmount();
+ });
+
+ it("selects the arrival time in the manifest, not as a sort key or part of the id", async () => {
+ const wrapper = await mountLive();
+ expect(streaming.sqls[0]).toContain("records_count, _timestamp from");
+ expect(streaming.sqls[0]).toMatch(/order by start asc, "end" asc$/);
+ expect((wrapper.vm as any).segmentIds[0]).not.toContain(String(liveRow(0)._timestamp));
+ wrapper.unmount();
+ });
+
+ it("raises upperTs from polled arrival times only, and bounds body queries with it", async () => {
+ const wrapper = await mountLive();
+ const vm = wrapper.vm as any;
+ expect(vm.upperTs).toBe(maxTs);
+ for (const window of segmentWindows()) {
+ expect(window).toEqual({ startTime: L * 1000 - 1_000_000, endTime: maxTs + 1_000_000 });
+ }
+
+ const arrival = maxTs + 20_000_000;
+ server.rows.push(liveRow(3, { _timestamp: arrival }));
+ await vi.advanceTimersByTimeAsync(30_000);
+
+ expect(vm.upperTs).toBe(arrival);
+ const windows = segmentWindows();
+ const bodyIndex = streaming.sqls.findLastIndex((sql) => sql.includes("segment"));
+ expect(streaming.sqls[bodyIndex]).toContain(`start >= ${L + 3000} `);
+ expect(windows[bodyIndex]).toEqual({
+ startTime: L * 1000 - 1_000_000,
+ endTime: arrival + 1_000_000,
+ });
+
+ // The manifest poll looks from a minute before the bound to a minute past the later of now and the bound.
+ const pollIndex = streaming.sqls.findIndex(
+ (sql, i) => i > 0 && sql.includes("has_full_snapshot"),
+ );
+ expect(windows[pollIndex]).toEqual({
+ startTime: maxTs - 60_000_000,
+ endTime: (NOW + 30_000) * 1000 + 60_000_000,
+ });
+
+ server.rows = server.rows.map((row) => ({ ...row, _timestamp: 1 }));
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(vm.upperTs).toBe(arrival);
+ wrapper.unmount();
+ });
+
+ // The mocked payload drops the timestamps, so carry them the way buildQueryPayload does to read each event query's window.
+ const eventWindows = (stream: string) =>
+ vi
+ .mocked(searchService.search)
+ .mock.calls.map((call: any[]) => call[0]?.query?.query ?? {})
+ .filter((query: any) => String(query.sql).includes(stream))
+ .map((query: any) => ({ startTime: query.start_time, endTime: query.end_time }));
+
+ async function withWindowedPayload(check: (wrapper: VueWrapper) => Promise | void) {
+ queryPayload.build.mockImplementation((opts: any) => ({
+ query: { sql: "", start_time: opts.timestamps.startTime, end_time: opts.timestamps.endTime },
+ aggs: {},
+ }));
+ let wrapper: VueWrapper | undefined;
+ try {
+ wrapper = await mountLive();
+ await check(wrapper);
+ } finally {
+ wrapper?.unmount();
+ queryPayload.build.mockImplementation(() => ({ query: { sql: "" }, aggs: {} }));
+ }
+ }
+
+ it("opens the event queries a minute either side of the replay rows, the manifest a second before", async () => {
+ await withWindowedPayload(() => {
+ for (const stream of ['"_rumdata"', '"_rumlog"']) {
+ expect(eventWindows(stream)[0], stream).toEqual({
+ startTime: L * 1000 - 60_000_000,
+ endTime: maxTs + 60_000_000,
+ });
+ }
+ expect(segmentWindows()[0]).toEqual({
+ startTime: L * 1000 - 1_000_000,
+ endTime: maxTs + 1_000_000,
+ });
+ });
+ });
+
+ it("starts an event poll with no cursor yet a minute before min_ts", async () => {
+ await withWindowedPayload(async () => {
+ await vi.advanceTimersByTimeAsync(30_000);
+ for (const stream of ['"_rumdata"', '"_rumlog"']) {
+ const windows = eventWindows(stream);
+ expect(windows.length, stream).toBeGreaterThan(1);
+ expect(windows.at(-1), stream).toEqual({
+ startTime: L * 1000 - 60_000_000,
+ endTime: (NOW + 30_000) * 1000 + 60_000_000,
+ });
+ }
+ });
+ });
+
+ it("appends only new segment ids, loads them, and extends the session end", async () => {
+ const wrapper = await mountLive();
+ const vm = wrapper.vm as any;
+ expect(vm.segments).toHaveLength(3);
+
+ // The second row ends after the metadata end, so the timeline has to grow to hold it.
+ server.rows.push(liveRow(3), liveRow(600));
+ await vi.advanceTimersByTimeAsync(30_000);
+
+ expect(manifestPolls()[0]).not.toContain("start >=");
+ expect(vm.manifest).toHaveLength(5);
+ expect(vm.segments).toHaveLength(5);
+ expect(vm.segments[4].records[0].timestamp).toBe(L + 600_000);
+ expect(vm.sessionEndMs).toBe(L + 600_999);
+ expect(vm.loadState).toBe("live");
+
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(vm.manifest).toHaveLength(5);
+ expect(vm.segments).toHaveLength(5);
+ wrapper.unmount();
+ });
+
+ const lateNotice = (wrapper: VueWrapper) =>
+ wrapper.find('[data-test="session-viewer-late-rows-notice"]');
+
+ it("does not insert a late row with an earlier start, fetch it or feed it, and says so", async () => {
+ const wrapper = await mountLive();
+ const vm = wrapper.vm as any;
+ const manifestBefore = vm.manifest;
+ const statusBefore = [...vm.loader.status];
+ const bodiesBefore = bodySqls().length;
+
+ server.rows.push(liveRow(1, { end: L + 1500 }));
+ await vi.advanceTimersByTimeAsync(30_000);
+
+ expect(manifestPolls()).toHaveLength(1);
+ expect(vm.manifest).toBe(manifestBefore);
+ expect(vm.loader.status).toEqual(statusBefore);
+ expect(vm.segmentIds).toHaveLength(3);
+ expect(bodySqls()).toHaveLength(bodiesBefore);
+ expect(vm.segments).toHaveLength(3);
+ expect(lateNotice(wrapper).text()).toContain(
+ "Earlier activity arrived after the replay loaded. Reload to include it.",
+ );
+ wrapper.unmount();
+ });
+
+ it("drops a row before the first full snapshot that a poll returns again, without the notice", async () => {
+ replayStart = L + 1000;
+ server.rows = [
+ liveRow(0, { has_full_snapshot: false }),
+ liveRow(1, { has_full_snapshot: true }),
+ liveRow(2),
+ ];
+ const wrapper = await mountLive();
+ const vm = wrapper.vm as any;
+ expect(vm.manifest).toHaveLength(2);
+
+ await vi.advanceTimersByTimeAsync(30_000);
+
+ expect(manifestPolls()).toHaveLength(1);
+ expect(vm.manifest).toHaveLength(2);
+ expect(vm.lateRows).toBe(false);
+ expect(lateNotice(wrapper).exists()).toBe(false);
+ wrapper.unmount();
+ });
+
+ it("does nothing for a known row a poll returns again", async () => {
+ const wrapper = await mountLive();
+ const vm = wrapper.vm as any;
+ const manifestBefore = vm.manifest;
+ const bodiesBefore = bodySqls().length;
+
+ await vi.advanceTimersByTimeAsync(30_000);
+
+ expect(manifestPolls()).toHaveLength(1);
+ expect(vm.manifest).toBe(manifestBefore);
+ expect(bodySqls()).toHaveLength(bodiesBefore);
+ expect(vm.lateRows).toBe(false);
+ expect(lateNotice(wrapper).exists()).toBe(false);
+ wrapper.unmount();
+ });
+
+ it("keeps the notice once shown, and still appends a new row after the tail", async () => {
+ const wrapper = await mountLive();
+ const vm = wrapper.vm as any;
+
+ server.rows.push(liveRow(1, { end: L + 1500 }));
+ await vi.advanceTimersByTimeAsync(30_000);
+ server.rows = server.rows.filter((row) => row.end !== L + 1500);
+ server.rows.push(liveRow(3));
+ await vi.advanceTimersByTimeAsync(30_000);
+
+ expect(vm.manifest).toHaveLength(4);
+ expect(vm.segments).toHaveLength(4);
+ expect(lateNotice(wrapper).exists()).toBe(true);
+ wrapper.unmount();
+ });
+
+ it("reloads the page from the notice's Reload button", async () => {
+ const reload = vi.fn();
+ const original = window.location;
+ Object.defineProperty(window, "location", {
+ value: { ...original, reload },
+ configurable: true,
+ });
+ try {
+ const wrapper = await mountLive();
+ server.rows.push(liveRow(1, { end: L + 1500 }));
+ await vi.advanceTimersByTimeAsync(30_000);
+
+ await wrapper.find('[data-test="session-viewer-late-rows-reload"]').trigger("click");
+
+ expect(reload).toHaveBeenCalledTimes(1);
+ wrapper.unmount();
+ } finally {
+ Object.defineProperty(window, "location", { value: original, configurable: true });
+ }
+ });
+
+ it("dedups polled events by id, replaces a re-sent view, and pages past 150", async () => {
+ const at = (offset: number) => ({ date: L + offset, _timestamp: (L + offset) * 1000 });
+ server.rum = [
+ { type: "view", view_id: "v1", view_loading_type: "initial_load", view_url: "/a", ...at(10) },
+ { type: "action", action_id: "a1", action_type: "click", ...at(20) },
+ ];
+ const wrapper = await mountLive();
+ const vm = wrapper.vm as any;
+ expect(vm.segmentEvents).toHaveLength(2);
+
+ const more = Array.from({ length: 160 }, (_, i) => ({
+ type: "action",
+ action_id: `n${i}`,
+ action_type: "click",
+ ...at(100 + i),
+ }));
+ // The re-send keeps the view's date but arrives later.
+ const resent = { ...server.rum[0], view_url: "/b", _timestamp: (L + 300) * 1000 };
+ server.rum = [server.rum[1], resent, ...more];
+ await vi.advanceTimersByTimeAsync(30_000);
+
+ expect(vm.segmentEvents).toHaveLength(162);
+ const views = vm.segmentEvents.filter((event: any) => event.type === "view");
+ expect(views).toHaveLength(1);
+ expect(views[0].name).toBe("initial_load : /b");
+ expect(vm.segmentEvents.filter((event: any) => event.id === "a1")).toHaveLength(1);
+ wrapper.unmount();
+ });
+
+ it("dedups polled error logs by arrival time, device time and message", async () => {
+ const log = (offset: number, message: string) => ({
+ date: L + offset,
+ _timestamp: (L + offset) * 1000,
+ message,
+ status: "error",
+ });
+ server.logs = [log(50, "boom")];
+ const wrapper = await mountLive();
+ const vm = wrapper.vm as any;
+ expect(vm.segmentEvents).toHaveLength(1);
+
+ server.logs = [log(50, "boom"), log(60, "bang")];
+ await vi.advanceTimersByTimeAsync(30_000);
+
+ expect(vm.segmentEvents.map((event: any) => event.name)).toEqual(["boom", "bang"]);
+ wrapper.unmount();
+ });
+
+ it("stops 15 minutes after the last new segment id, then reads complete", async () => {
+ const wrapper = await mountLive();
+ const vm = wrapper.vm as any;
+
+ await vi.advanceTimersByTimeAsync(14 * 60_000);
+ expect(vm.isLive).toBe(true);
+
+ await vi.advanceTimersByTimeAsync(60_000);
+ expect(vm.isLive).toBe(false);
+ expect(vm.loadState).toBe("complete");
+ expect(wrapper.find('[data-test="session-viewer-live-badge"]').exists()).toBe(false);
+
+ const polls = manifestPolls().length;
+ await vi.advanceTimersByTimeAsync(120_000);
+ expect(manifestPolls()).toHaveLength(polls);
+ wrapper.unmount();
+ });
+
+ it("pauses polling while the tab is hidden and resumes once it is visible", async () => {
+ const wrapper = await mountLive();
+
+ setVisibility("hidden");
+ await vi.advanceTimersByTimeAsync(120_000);
+ expect(manifestPolls()).toHaveLength(0);
+
+ setVisibility("visible");
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(manifestPolls()).toHaveLength(1);
+ wrapper.unmount();
+ });
+
+ it("clears the poll timer and the visibility listener on unmount", async () => {
+ const removeListener = vi.spyOn(document, "removeEventListener");
+ const wrapper = await mountLive();
+ expect(vi.getTimerCount()).toBe(1);
+
+ wrapper.unmount();
+
+ expect(vi.getTimerCount()).toBe(0);
+ expect(removeListener).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
+ removeListener.mockRestore();
+ });
+
+ it("pages the first events load, so the live cursor never skips events past the first page", async () => {
+ const at = (offset: number) => ({ date: L + offset, _timestamp: (L + offset) * 1000 });
+ // A fresh re-send of the view sorts first by date, so a single page would put the cursor at its arrival.
+ server.rum = [
+ { type: "view", view_id: "v1", view_url: "/a", date: L + 1, _timestamp: NOW * 1000 },
+ ...Array.from({ length: 200 }, (_, i) => ({
+ type: "action",
+ action_id: `a${i}`,
+ action_type: "click",
+ ...at(10 + i),
+ })),
+ ];
+ const wrapper = await mountLive();
+
+ expect((wrapper.vm as any).segmentEvents).toHaveLength(201);
+ wrapper.unmount();
+ });
+
+ it("keeps an event without an id once across overlapping polls", async () => {
+ server.rum = [
+ { type: "action", action_type: "click", date: L + 20, _timestamp: (L + 20) * 1000 },
+ ];
+ const wrapper = await mountLive();
+ const vm = wrapper.vm as any;
+ expect(vm.segmentEvents).toHaveLength(1);
+
+ await vi.advanceTimersByTimeAsync(90_000);
+ expect(vm.segmentEvents).toHaveLength(1);
+ wrapper.unmount();
+ });
+
+ it("keeps the view re-send with the latest arrival time, whatever order it comes in", async () => {
+ const view = (url: string, arrival: number) => ({
+ type: "view",
+ view_id: "v1",
+ view_loading_type: "initial_load",
+ view_url: url,
+ date: L + 10,
+ _timestamp: arrival,
+ });
+ server.rum = [view("/new", (L + 900) * 1000), view("/old", (L + 100) * 1000)];
+ const wrapper = await mountLive();
+ const vm = wrapper.vm as any;
+ expect(vm.segmentEvents.map((event: any) => event.name)).toEqual(["initial_load : /new"]);
+
+ server.rum = [view("/old", (L + 100) * 1000)];
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(vm.segmentEvents.map((event: any) => event.name)).toEqual(["initial_load : /new"]);
+ expect(vm.rawEventsMap.get("v1").view_url).toBe("/new");
+ wrapper.unmount();
+ });
+
+ it("drops the Live badge when there is no manifest to follow or the load failed", async () => {
+ server.rows = [];
+ const empty = await mountLive();
+ expect((empty.vm as any).loadState).toBe("empty");
+ expect(empty.find('[data-test="session-viewer-live-badge"]').exists()).toBe(false);
+ empty.unmount();
+
+ resetStreaming(() => ({ error: { status: 403 } }));
+ const failed = await mountLive();
+ expect((failed.vm as any).loadState).toBe("error");
+ expect(failed.find('[data-test="session-viewer-live-badge"]').exists()).toBe(false);
+ failed.unmount();
+ });
+});
diff --git a/web/src/views/RUM/SessionViewer.vue b/web/src/views/RUM/SessionViewer.vue
index eb76a65aaa..b6bd6a6fe8 100644
--- a/web/src/views/RUM/SessionViewer.vue
+++ b/web/src/views/RUM/SessionViewer.vue
@@ -25,6 +25,22 @@ along with this program. If not, see .
}"
bleed
>
+
+
+ {{
+ sessionDetails.id ? raw(sessionDetails.id) : t("rum.sessionReplay")
+ }}
+ {{ t("rum.sessionReplayLiveBadge") }}
+
+
.
{{ sessionDetails.date }}