From 5f6f8b84c7600a9a594bfe91ddd3323a4de44446 Mon Sep 17 00:00:00 2001 From: Omkar Kesarkhane <127078473+omkarK06@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:57:09 +0000 Subject: [PATCH] perf(rum): progressive session replay loading, load states and live sessions (#14956) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/translations/.translation_state.json | 20 + .../rum/MobileSessionPlayer.spec.ts | 260 +++ .../components/rum/MobileSessionPlayer.vue | 396 +++- web/src/components/rum/ReplayLoadBand.vue | 60 + .../components/rum/ReplayPlaybackOverlay.vue | 106 ++ .../components/rum/ReplayStatusChip.spec.ts | 60 + web/src/components/rum/ReplayStatusChip.vue | 95 + web/src/components/rum/VideoPlayer.spec.ts | 700 ++++++- web/src/components/rum/VideoPlayer.vue | 680 +++++-- web/src/composables/useSessionReplay.ts | 6 +- web/src/composables/useStreamingSearch.ts | 12 +- web/src/locales/languages/ar-SA.json | 20 + web/src/locales/languages/de-DE.json | 20 + web/src/locales/languages/en-US.json | 20 + web/src/locales/languages/es-ES.json | 20 + web/src/locales/languages/fr-FR.json | 20 + web/src/locales/languages/it-IT.json | 20 + web/src/locales/languages/ja-JP.json | 20 + web/src/locales/languages/ko-KR.json | 20 + web/src/locales/languages/nl-NL.json | 20 + web/src/locales/languages/pl-PL.json | 20 + web/src/locales/languages/pt-PT.json | 20 + web/src/locales/languages/ru-RU.json | 20 + web/src/locales/languages/tr-TR.json | 20 + web/src/locales/languages/vi-VN.json | 20 + web/src/locales/languages/zh-CN.json | 20 + web/src/locales/languages/zh-TW.json | 20 + web/src/styles/utilities.css | 9 + .../rum/sessionReplayChangeFormat.spec.ts | 149 ++ .../utils/rum/sessionReplayChangeFormat.ts | 92 +- web/src/utils/rum/sessionReplayLive.spec.ts | 121 ++ web/src/utils/rum/sessionReplayLive.ts | 92 + .../utils/rum/sessionReplayLoadPlan.spec.ts | 148 ++ web/src/utils/rum/sessionReplayLoadPlan.ts | 66 + web/src/utils/rum/sessionReplayLoader.spec.ts | 197 ++ web/src/utils/rum/sessionReplayLoader.ts | 199 ++ .../utils/rum/sessionReplayManifest.spec.ts | 178 ++ web/src/utils/rum/sessionReplayManifest.ts | 134 ++ .../utils/rum/sessionReplaySeekPlan.spec.ts | 98 + web/src/utils/rum/sessionReplaySeekPlan.ts | 50 + .../utils/rum/sessionReplayTimeline.spec.ts | 75 + web/src/utils/rum/sessionReplayTimeline.ts | 103 + web/src/views/RUM/AppSessions.vue | 7 +- web/src/views/RUM/SessionViewer.spec.ts | 1687 ++++++++++++++++- web/src/views/RUM/SessionViewer.vue | 1458 +++++++++++--- web/src/workers/rumcssworker.js | 2 + web/src/workers/rumcssworker.spec.ts | 60 + 47 files changed, 7044 insertions(+), 596 deletions(-) create mode 100644 web/src/components/rum/MobileSessionPlayer.spec.ts create mode 100644 web/src/components/rum/ReplayLoadBand.vue create mode 100644 web/src/components/rum/ReplayPlaybackOverlay.vue create mode 100644 web/src/components/rum/ReplayStatusChip.spec.ts create mode 100644 web/src/components/rum/ReplayStatusChip.vue create mode 100644 web/src/utils/rum/sessionReplayLive.spec.ts create mode 100644 web/src/utils/rum/sessionReplayLive.ts create mode 100644 web/src/utils/rum/sessionReplayLoadPlan.spec.ts create mode 100644 web/src/utils/rum/sessionReplayLoadPlan.ts create mode 100644 web/src/utils/rum/sessionReplayLoader.spec.ts create mode 100644 web/src/utils/rum/sessionReplayLoader.ts create mode 100644 web/src/utils/rum/sessionReplayManifest.spec.ts create mode 100644 web/src/utils/rum/sessionReplayManifest.ts create mode 100644 web/src/utils/rum/sessionReplaySeekPlan.spec.ts create mode 100644 web/src/utils/rum/sessionReplaySeekPlan.ts create mode 100644 web/src/utils/rum/sessionReplayTimeline.spec.ts create mode 100644 web/src/utils/rum/sessionReplayTimeline.ts create mode 100644 web/src/workers/rumcssworker.spec.ts diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index 1e66589c61..bdfff3077a 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -15018,6 +15018,26 @@ "sessionLabel": "6fa3cbf451b2a1d5", "sessionMetricsAria": "398b8aa8e579aa20", "sessionReplay": "e5f9092688b45946", + "sessionReplayFullyLoaded": "d727c5b7eac98172", + "sessionReplayGapTooltip": "845d2c16fca701e3", + "sessionReplayLateRows": "0d3f9aa8ff8ecee0", + "sessionReplayLiveBadge": "b64ac05f17e64d03", + "sessionReplayLoadFailed": "33fa76b6dfbcea01", + "sessionReplayLoadFailedChip": "e19dfc5168673751", + "sessionReplayLoadFailedRange": "030d8850149c3900", + "sessionReplayLoadingPercent": "66cd816153742451", + "sessionReplayLoadingUpTo": "16790fb00406a6b5", + "sessionReplayNextPart": "fedf510fb72584a8", + "sessionReplayNotLoadedYet": "c9b9b5120a593e3d", + "sessionReplayReload": "bdc090ec61e3fcfc", + "sessionReplayResumesOnItsOwn": "0e496709e2f462cc", + "sessionReplayRetry": "942087cc2d41e013", + "sessionReplayRetrying": "682078bef4f97e8f", + "sessionReplaySeekBehindWindow": "e06a4af88ab0f1dd", + "sessionReplaySinglePageHint": "fb90f0998266b0b5", + "sessionReplaySkippedParts": "98f4625dfd6c951f", + "sessionReplayTruncated": "b0d73b7b692c25d4", + "sessionReplayTruncatedTooltip": "f26b8c3219619d3b", "sessions": "6fa3cbf451b2a1d5", "sessionsHitError": "0cf8356781b7e9b0", "sessionsWithErrors": "7ab563a196adff8a", diff --git a/web/src/components/rum/MobileSessionPlayer.spec.ts b/web/src/components/rum/MobileSessionPlayer.spec.ts new file mode 100644 index 0000000000..33a7bd3ed5 --- /dev/null +++ b/web/src/components/rum/MobileSessionPlayer.spec.ts @@ -0,0 +1,260 @@ +// 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 { mount } from "@vue/test-utils"; +import MobileSessionPlayer from "@/components/rum/MobileSessionPlayer.vue"; +import { buildMobileTimeline } from "@/composables/rum/useMobileSessionReplay"; +import i18n from "@/locales"; + +const T = 1_700_000_000_000; + +const wireframe = (id: number, text: string) => ({ + id, + x: 0, + y: 0, + width: 100, + height: 20, + type: "text", + text, +}); + +// Segment A: 0–5 s with a full screen showing "first"; segment B: 10–20 s with one showing "second". +const segmentA = { + records: [ + { type: 4, timestamp: T, data: { width: 400, height: 800 } }, + { type: 10, timestamp: T, data: { wireframes: [wireframe(1, "first")] } }, + { type: 11, timestamp: T + 5_000, data: {} }, + ], +}; +const segmentB = { + records: [ + { type: 10, timestamp: T + 10_000, data: { wireframes: [wireframe(2, "second")] } }, + { type: 11, timestamp: T + 20_000, data: {} }, + ], +}; + +const tl = (segments: any[]) => buildMobileTimeline(segments); + +function mountPlayer(props: Record = {}) { + return mount(MobileSessionPlayer, { + props: { + timeline: tl([segmentA]), + sessionStartMs: T, + loadState: "loading", + speed: 1, + ...props, + }, + global: { + plugins: [i18n], + stubs: { + OIcon: { + template: '', + props: ["name", "size"], + emits: ["click"], + }, + }, + }, + }); +} + +const playIcon = (wrapper: ReturnType) => + wrapper.find('[data-test="rum-mobile-replay-play-btn"]'); + +describe("MobileSessionPlayer", () => { + beforeEach(() => { + vi.useFakeTimers({ + toFake: [ + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + "requestAnimationFrame", + "cancelAnimationFrame", + "performance", + ], + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps playing from the same position when a later batch arrives", async () => { + const wrapper = mountPlayer(); + await playIcon(wrapper).trigger("click"); + vi.advanceTimersByTime(2_000); + await wrapper.vm.$nextTick(); + const before = wrapper.find('[data-test="rum-mobile-replay-time"]').text(); + expect(before).toBe("00:01 / 00:05"); + + await wrapper.setProps({ timeline: tl([segmentA, segmentB]) }); + + expect(wrapper.find('[data-test="rum-mobile-replay-time"]').text()).toBe("00:01 / 00:20"); + expect(playIcon(wrapper).attributes("data-name")).toBe("pause-circle-filled"); + wrapper.unmount(); + }); + + it("holds at the loaded edge while loading, then resumes when more arrives", async () => { + const wrapper = mountPlayer(); + await playIcon(wrapper).trigger("click"); + vi.advanceTimersByTime(6_000); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.playbackState).toBe("buffering"); + expect(wrapper.find('[data-test="replay-overlay-buffering"]').exists()).toBe(true); + + await wrapper.setProps({ timeline: tl([segmentA, segmentB]) }); + expect(wrapper.vm.playbackState).toBe("playing"); + wrapper.unmount(); + }); + + it("ends at the run's last segment instead of buffering while earlier holes load", async () => { + const wrapper = mountPlayer({ runComplete: true }); + await playIcon(wrapper).trigger("click"); + vi.advanceTimersByTime(6_000); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.playbackState).toBe("ended"); + wrapper.unmount(); + }); + + it("plays a seek the parent makes in the tick it clears the pending target", async () => { + const wrapper = mountPlayer({ timeline: tl([segmentA, segmentB]), pendingSeekMs: 12_000 }); + expect(wrapper.vm.playbackState).toBe("waiting"); + + wrapper.vm.seekTo(12_000, true); + expect(wrapper.vm.playbackState).toBe("playing"); + await wrapper.setProps({ pendingSeekMs: null }); + expect(wrapper.vm.playbackState).toBe("playing"); + wrapper.unmount(); + }); + + it("uses the fixed session length from the parent for the total", () => { + const wrapper = mountPlayer({ sessionEndMs: T + 600_000 }); + expect(wrapper.find('[data-test="rum-mobile-replay-time"]').text()).toBe("00:00 / 10:00"); + wrapper.unmount(); + }); + + it("shows hours in the readout once the session passes an hour", () => { + const wrapper = mountPlayer({ sessionEndMs: T + 3_725_000 }); + expect(wrapper.find('[data-test="rum-mobile-replay-time"]').text()).toBe("00:00 / 01:02:05"); + wrapper.unmount(); + }); + + it("lands seekTo on the right frame for a covered target", async () => { + const wrapper = mountPlayer({ timeline: tl([segmentA, segmentB]), loadState: "complete" }); + expect(wrapper.text()).toContain("first"); + + wrapper.vm.seekTo(12_000, false); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("second"); + expect(wrapper.find('[data-test="rum-mobile-replay-time"]').text()).toBe("00:12 / 00:20"); + wrapper.unmount(); + }); + + it("shows Waiting for a pending target until the parent seeks there", async () => { + const wrapper = mountPlayer(); + await wrapper.setProps({ pendingSeekMs: 15_000 }); + expect(wrapper.vm.playbackState).toBe("waiting"); + expect(wrapper.find('[data-test="replay-overlay-waiting"]').text()).toContain( + "Loading up to 00:15", + ); + + await wrapper.setProps({ timeline: tl([segmentA, segmentB]), pendingSeekMs: null }); + wrapper.vm.seekTo(15_000, false); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.playbackState).toBe("paused"); + expect(wrapper.text()).toContain("second"); + wrapper.unmount(); + }); + + it("ends at the session end and sends Play as a seek to 0", async () => { + const wrapper = mountPlayer({ loadState: "complete" }); + await playIcon(wrapper).trigger("click"); + vi.advanceTimersByTime(6_000); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.playbackState).toBe("ended"); + + await playIcon(wrapper).trigger("click"); + expect(wrapper.emitted("seek-request")?.at(-1)).toEqual([0]); + wrapper.unmount(); + }); + + it("turns a bar click into a seek request in session ms", async () => { + const wrapper = mountPlayer({ sessionEndMs: T + 100_000 }); + const bar = wrapper.find('[data-test="rum-mobile-replay-playback-bar"]'); + (bar.element as HTMLElement).getBoundingClientRect = () => ({ left: 0, width: 200 }) as DOMRect; + await bar.trigger("click", { clientX: 50 }); + expect(wrapper.emitted("seek-request")?.at(-1)).toEqual([25_000]); + wrapper.unmount(); + }); + + it("shows a failed load as an error with Retry, not as a missing replay", async () => { + const wrapper = mountPlayer({ timeline: tl([]), loadState: "error" }); + expect(wrapper.find('[data-test="rum-mobile-replay-empty"]').exists()).toBe(false); + await wrapper.find('[data-test="replay-overlay-retry"]').trigger("click"); + expect(wrapper.emitted("retry")).toHaveLength(1); + wrapper.unmount(); + }); + + it("in a live session, ends at the loaded end, then resumes in place on new activity", async () => { + const wrapper = mountPlayer({ loadState: "live", runComplete: true }); + await playIcon(wrapper).trigger("click"); + vi.advanceTimersByTime(6_000); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.playbackState).toBe("ended"); + expect(wrapper.find('[data-test="replay-status-chip"]').exists()).toBe(false); + expect(wrapper.emitted("seek-request")).toBeUndefined(); + + await wrapper.setProps({ timeline: tl([segmentA, segmentB]) }); + expect(wrapper.vm.playbackState).toBe("playing"); + expect(wrapper.find('[data-test="rum-mobile-replay-time"]').text()).toBe("00:05 / 00:20"); + expect(wrapper.emitted("seek-request")).toBeUndefined(); + wrapper.unmount(); + }); + + it("in a live session, stays put on new activity once the user moved the playhead", async () => { + const wrapper = mountPlayer({ loadState: "live", runComplete: true }); + await playIcon(wrapper).trigger("click"); + vi.advanceTimersByTime(6_000); + await wrapper.vm.$nextTick(); + wrapper.vm.seekTo(2_000, false); + + await wrapper.setProps({ timeline: tl([segmentA, segmentB]) }); + expect(wrapper.vm.playbackState).toBe("paused"); + wrapper.unmount(); + }); + + it("in a live session, buffers at the edge while a batch is still in flight", async () => { + const wrapper = mountPlayer({ loadState: "live", runComplete: false }); + await playIcon(wrapper).trigger("click"); + vi.advanceTimersByTime(6_000); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.playbackState).toBe("buffering"); + + await wrapper.setProps({ timeline: tl([segmentA, segmentB]) }); + expect(wrapper.vm.playbackState).toBe("playing"); + wrapper.unmount(); + }); + + it("announces ready once the first records are there", async () => { + const wrapper = mountPlayer({ timeline: tl([]) }); + expect(wrapper.emitted("ready")).toBeUndefined(); + await wrapper.setProps({ timeline: tl([segmentA]) }); + await wrapper.setProps({ timeline: tl([segmentA, segmentB]) }); + expect(wrapper.emitted("ready")).toHaveLength(1); + wrapper.unmount(); + }); +}); diff --git a/web/src/components/rum/MobileSessionPlayer.vue b/web/src/components/rum/MobileSessionPlayer.vue index 03b658eb6b..9804e2bc26 100644 --- a/web/src/components/rum/MobileSessionPlayer.vue +++ b/web/src/components/rum/MobileSessionPlayer.vue @@ -21,26 +21,83 @@ import OIcon from "@/lib/core/Icon/OIcon.vue"; import OSwitch from "@/lib/forms/Switch/OSwitch.vue"; import OSelect from "@/lib/forms/Select/OSelect.vue"; import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue"; +import type { SwitchValue } from "@/lib/forms/Switch/OSwitch.types"; +import ReplayLoadBand from "@/components/rum/ReplayLoadBand.vue"; +import ReplayPlaybackOverlay from "@/components/rum/ReplayPlaybackOverlay.vue"; +import ReplayStatusChip from "@/components/rum/ReplayStatusChip.vue"; import { - buildMobileTimeline, wireframesAt, viewportAt, wireframeStyle, - type MobileSegment, + type MobileTimeline, type Wireframe, } from "@/composables/rum/useMobileSessionReplay"; +import { MAX_ATTEMPTS } from "@/utils/rum/sessionReplayLoader"; +import { + canResume, + expectsMoreData, + formatReplayTime, + shouldBuffer, + skippedCount, + timelineLength, + toPercent, + type LoadState, + type LoadedRange, + type PlaybackState, + type ReplayIntent, +} from "@/utils/rum/sessionReplayTimeline"; -const props = defineProps<{ - /** Parsed `_sessionreplay` segments (each the decoded wireframe segment JSON). */ - segments: MobileSegment[]; - /** RUM events (action/view/error) with `relativeTime` — rendered as timeline markers. */ - events?: any[]; - /** - * True while the parent is still fetching this session's replay segments. Without it the - * player treats "segments not loaded yet" the same as "session has no replay" and flashes - * the empty state before the segment query resolves. - */ - isLoading?: boolean; +type Mode = "paused" | "playing" | "buffering" | "waiting" | "failed" | "ended"; + +const props = withDefaults( + defineProps<{ + /** Timestamp-ordered wireframe records, built once by the parent from the loaded segments. */ + timeline: MobileTimeline; + /** RUM events (action/view/error) with `relativeTime` — rendered as timeline markers. */ + events?: any[]; + /** True until the first window has loaded, so "not loaded yet" is not shown as "no replay". */ + isLoading?: boolean; + sessionStartMs?: number; + sessionEndMs?: number; + loadedRanges?: LoadedRange[]; + loadState?: LoadState; + runComplete?: boolean; + loadPercent?: number; + failedFromMs?: number | null; + pendingSeekMs?: number | null; + retryAttempt?: number; + speed?: number; + skipInactivity?: boolean; + intent?: ReplayIntent; + }>(), + { + events: () => [], + isLoading: false, + sessionStartMs: 0, + sessionEndMs: 0, + loadedRanges: () => [], + loadState: "complete", + runComplete: false, + loadPercent: 0, + failedFromMs: null, + pendingSeekMs: null, + retryAttempt: 0, + speed: undefined, + skipInactivity: undefined, + intent: "pause", + }, +); + +const emit = defineEmits<{ + ready: []; + "time-update": [sessionMs: number]; + "seek-request": [sessionMs: number]; + retry: []; + "update:speed": [speed: number]; + "update:skipInactivity": [skip: boolean]; + "update:intent": [intent: ReplayIntent]; + "playback-state": [state: PlaybackState]; + "loaded-end-change": [sessionMs: number]; }>(); const { t } = useI18nTyped(); @@ -56,25 +113,50 @@ const speedOptions = [ { label: raw("8x"), value: 8 }, ]; -const timeline = computed(() => buildMobileTimeline(props.segments ?? [])); +const timeline = computed(() => props.timeline); const playhead = ref(0); // ms offset from timeline.startTime -const playing = ref(false); -const speed = ref(1); -const skipInactivity = ref(false); +const mode = ref("paused"); +const localSpeed = ref(1); +const localSkipInactivity = ref(false); +// Never shrinks: a batch whose records end before the metadata end must not pull the bar back. +const timelineMs = ref(0); const stageRef = ref(null); const stageWidth = ref(0); const stageHeight = ref(0); +let failedWasPlaying = false; +let announcedReady = false; +// Ended at the loaded end of a live session; new activity resumes it unless the user moved the playhead since. +let liveEnded = false; +const speed = computed(() => props.speed ?? localSpeed.value); +const skipInactivity = computed(() => props.skipInactivity ?? localSkipInactivity.value); +// Once the run holds the last segment, nothing still loading can extend this player. +const awaitingData = computed(() => expectsMoreData(props.loadState) && !props.runComplete); +const playing = computed(() => mode.value === "playing" || mode.value === "buffering"); const currentTime = computed(() => timeline.value.startTime + playhead.value); const viewport = computed(() => viewportAt(timeline.value.records, currentTime.value)); const currentWireframes = computed(() => wireframesAt(timeline.value.records, currentTime.value), ); const hasReplay = computed(() => timeline.value.records.length > 0); -const progressPct = computed(() => - timeline.value.duration > 0 ? (playhead.value / timeline.value.duration) * 100 : 0, +const sessionStart = computed(() => props.sessionStartMs || timeline.value.startTime); +const originOffsetMs = computed(() => + hasReplay.value ? timeline.value.startTime - sessionStart.value : 0, ); +const sessionTimeMs = computed(() => originOffsetMs.value + playhead.value); +const displayMs = computed(() => props.pendingSeekMs ?? sessionTimeMs.value); +const progressPct = computed(() => toPercent(displayMs.value, timelineMs.value)); +const loadedEndSessionMs = computed(() => + hasReplay.value ? timeline.value.endTime - sessionStart.value : 0, +); + +const playbackState = computed(() => { + if (props.loadState === "error") return "error"; + if (props.loadState === "empty") return "empty"; + if (!hasReplay.value) return "loading"; + return mode.value; +}); // Fit the dp-based wireframe canvas inside the stage on BOTH axes. // @@ -103,20 +185,13 @@ const canvasStyle = computed(() => ({ "transform-origin": "top left", })); -function fmt(ms: number): string { - const s = Math.max(0, Math.floor(ms / 1000)); - return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; -} - function imageSrc(w: Wireframe): string | undefined { return w.base64 ? `data:image/png;base64,${w.base64}` : undefined; } // ---- event timeline markers (error highlight) ---------------------------- function markerLeftPct(event: any): number { - const rel = Number(event?.relativeTime ?? 0); - const pct = timeline.value.duration > 0 ? (rel / timeline.value.duration) * 100 : 0; - return Math.max(0, Math.min(100, pct)); + return toPercent(Number(event?.relativeTime ?? 0), timelineMs.value); } // Applied via a :style binding, so the token is reached by var() here (a // sanctioned raw-var site: JS-generated style values have no utility class). @@ -137,6 +212,15 @@ function markerTooltip(event: any): string { return event?.type === "error" ? t("rum.errorEventTooltip", { name: label }) : label; } +function refreshTimeline() { + if (!hasReplay.value) return; + const end = props.sessionEndMs || timeline.value.endTime; + timelineMs.value = Math.max( + timelineMs.value, + timelineLength(sessionStart.value, end, timeline.value.endTime), + ); +} + // ---- playback loop ------------------------------------------------------- let rafId: number | null = null; let lastTs = 0; @@ -148,54 +232,169 @@ function nextRecordAfter(absTime: number): number | null { return null; } +// Reaching the end of what is loaded means different things depending on whether more is coming. +function handleLoadedEdge() { + stopTick(); + if (awaitingData.value) { + mode.value = "buffering"; + } else if (props.loadState === "failed") { + mode.value = "failed"; + failedWasPlaying = true; + } else if (props.loadState === "live") { + mode.value = "ended"; + liveEnded = true; + } else { + mode.value = "ended"; + emit("update:intent", "pause"); + } +} + function tick(ts: number) { - if (!playing.value) return; + if (mode.value !== "playing") return; + const duration = timeline.value.duration; const delta = lastTs ? ts - lastTs : 0; lastTs = ts; - playhead.value = Math.min(timeline.value.duration, playhead.value + delta * speed.value); + playhead.value = Math.min(duration, playhead.value + delta * speed.value); if (skipInactivity.value) { const abs = timeline.value.startTime + playhead.value; const next = nextRecordAfter(abs); if (next != null && next - abs > SKIP_THRESHOLD_MS) { - playhead.value = Math.min(timeline.value.duration, next - timeline.value.startTime); + playhead.value = Math.min(duration, next - timeline.value.startTime); } } + emit("time-update", sessionTimeMs.value); - if (playhead.value >= timeline.value.duration) { - playing.value = false; + if (awaitingData.value && shouldBuffer(playhead.value, duration, speed.value)) { + stopTick(); + mode.value = "buffering"; + return; + } + if (playhead.value >= duration) { + handleLoadedEdge(); return; } rafId = requestAnimationFrame(tick); } -function play() { - if (!hasReplay.value) return; - if (playhead.value >= timeline.value.duration) playhead.value = 0; - playing.value = true; +function startTick() { + mode.value = "playing"; + liveEnded = false; lastTs = 0; + if (rafId != null) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(tick); } -function pause() { - playing.value = false; + +function stopTick() { if (rafId != null) cancelAnimationFrame(rafId); rafId = null; } + +// Resumes where the clock held once enough has loaded past it, or unconditionally once nothing more is coming. +function tryResume() { + if (mode.value !== "buffering" || props.pendingSeekMs !== null) return; + const final = + props.loadState === "complete" || (expectsMoreData(props.loadState) && !awaitingData.value); + if (final || canResume(playhead.value, timeline.value.duration, speed.value)) { + startTick(); + } +} + +// The playhead is where the live edge stopped it, so ticking on from there is the resume. +function resumeLiveEnded() { + if (mode.value !== "ended" || !liveEnded || props.pendingSeekMs !== null) return; + if (playhead.value < timeline.value.duration) startTick(); +} + +function beginPlayback() { + if (!hasReplay.value) return; + if (awaitingData.value && shouldBuffer(playhead.value, timeline.value.duration, speed.value)) { + mode.value = "buffering"; + return; + } + startTick(); +} + +// A pending seek is display-only, so the playhead must not start moving until the parent seeks. +function startPlayback() { + if (props.pendingSeekMs !== null) { + mode.value = "buffering"; + return; + } + beginPlayback(); +} + +function play() { + emit("update:intent", "play"); + if (mode.value === "ended") { + emit("seek-request", 0); + return; + } + if (mode.value === "failed") { + mode.value = "buffering"; + emit("retry"); + return; + } + if (mode.value === "waiting") { + mode.value = "buffering"; + return; + } + startPlayback(); +} + +// Pausing while buffering cancels the auto-resume; a seek still waiting on data keeps waiting, paused. +function pause() { + emit("update:intent", "pause"); + failedWasPlaying = false; + stopTick(); + if (mode.value !== "playing" && mode.value !== "buffering") return; + mode.value = props.pendingSeekMs !== null ? "waiting" : "paused"; +} + function togglePlay() { - playing.value ? pause() : play(); + if (playing.value) pause(); + else play(); } -function seekTo(ms: number) { - playhead.value = Math.max(0, Math.min(timeline.value.duration, ms)); + +/** The only seek entry point: session ms in, converted to this player's own playhead. */ +function seekTo(sessionMs: number, shouldPlay = false) { + const offset = sessionStart.value + sessionMs - timeline.value.startTime; + playhead.value = Math.max(0, Math.min(timeline.value.duration, offset)); lastTs = 0; + liveEnded = false; + // The parent clears pendingSeekMs in this same tick, so the prop still holds the stale target here. + if (shouldPlay) { + beginPlayback(); + } else { + stopTick(); + mode.value = "paused"; + } } + +// Seeks go to the parent, which knows what is loaded; the player never jumps into unloaded time itself. function skip(direction: "forward" | "backward") { - seekTo(playhead.value + (direction === "forward" ? 1 : -1) * SKIP_SECONDS * 1000); + if (!(timelineMs.value > 0)) return; + const delta = (direction === "forward" ? 1 : -1) * SKIP_SECONDS * 1000; + emit("seek-request", Math.max(0, Math.min(timelineMs.value, displayMs.value + delta))); } + function onBarClick(e: MouseEvent) { + if (!(timelineMs.value > 0)) return; const bar = e.currentTarget as HTMLElement; const rect = bar.getBoundingClientRect(); const ratio = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0; - seekTo(ratio * timeline.value.duration); + emit("seek-request", Math.max(0, Math.min(1, ratio)) * timelineMs.value); +} + +function setSpeed(value: unknown) { + if (typeof value !== "number") return; + localSpeed.value = value; + emit("update:speed", value); +} + +function setSkipInactivity(value: SwitchValue) { + localSkipInactivity.value = !!value; + emit("update:skipInactivity", !!value); } // Measure the stage so we can scale to fit. BOTH axes are needed: the stage is @@ -215,19 +414,83 @@ watch(stageRef, (el) => { } }); -// Reset when a different session's segments load. +// A later batch only adds later records, so the playhead is left where it is and a held clock may resume. watch( - () => props.segments, + () => [timeline.value.endTime, props.sessionStartMs, props.sessionEndMs], () => { - pause(); - playhead.value = 0; + refreshTimeline(); + if (hasReplay.value) emit("loaded-end-change", loadedEndSessionMs.value); + tryResume(); + resumeLiveEnded(); + }, + { immediate: true }, +); + +watch( + hasReplay, + (has) => { + if (!has || announcedReady) return; + announcedReady = true; + emit("ready"); + }, + { immediate: true }, +); + +// The parent holds the target; the playhead stays put until the parent calls seekTo. +watch( + () => props.pendingSeekMs, + (target, previous) => { + if (target !== null && (previous ?? null) === null) { + const wasPlaying = playing.value; + stopTick(); + mode.value = wasPlaying ? "buffering" : "waiting"; + return; + } + if (target !== null || (mode.value !== "buffering" && mode.value !== "waiting")) return; + if (props.loadState === "failed") { + failedWasPlaying = mode.value === "buffering"; + mode.value = "failed"; + } else if (mode.value === "waiting") { + mode.value = "paused"; + } else { + tryResume(); + } + }, + { immediate: true }, +); + +watch( + () => props.loadState, + (state, previous) => { + if (state === "complete") { + tryResume(); + return; + } + if (state === "failed" && props.pendingSeekMs === null) { + if (mode.value === "buffering" || mode.value === "waiting") { + failedWasPlaying = mode.value === "buffering"; + mode.value = "failed"; + } + return; + } + if (expectsMoreData(state) && previous === "failed" && mode.value === "failed") { + mode.value = failedWasPlaying ? "buffering" : "paused"; + } }, ); +watch(awaitingData, (awaiting) => { + if (!awaiting && expectsMoreData(props.loadState)) tryResume(); +}); + +watch(playbackState, (state) => emit("playback-state", state)); + onBeforeUnmount(() => { - pause(); + stopTick(); resizeObserver?.disconnect(); }); + +defineExpose({ seekTo, play, pause, togglePlay, playbackState }); + @@ -285,8 +568,11 @@ onBeforeUnmount(() => { data-test="rum-mobile-replay-playback-bar" @click="onBarClick" > +
{ class="text-text-body ms-2 whitespace-nowrap tabular-nums" data-test="rum-mobile-replay-time" > - {{ fmt(playhead) }} / {{ fmt(timeline.duration) }} + {{ formatReplayTime(displayMs) }} / {{ formatReplayTime(timelineMs) }} +
diff --git a/web/src/components/rum/ReplayLoadBand.vue b/web/src/components/rum/ReplayLoadBand.vue new file mode 100644 index 0000000000..44554282a4 --- /dev/null +++ b/web/src/components/rum/ReplayLoadBand.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/web/src/components/rum/ReplayPlaybackOverlay.vue b/web/src/components/rum/ReplayPlaybackOverlay.vue new file mode 100644 index 0000000000..b7edf41903 --- /dev/null +++ b/web/src/components/rum/ReplayPlaybackOverlay.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/web/src/components/rum/ReplayStatusChip.spec.ts b/web/src/components/rum/ReplayStatusChip.spec.ts new file mode 100644 index 0000000000..f4e5344aa1 --- /dev/null +++ b/web/src/components/rum/ReplayStatusChip.spec.ts @@ -0,0 +1,60 @@ +// 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, afterEach } from "vitest"; +import { mount, VueWrapper } from "@vue/test-utils"; +import i18n from "@/locales"; +import ReplayStatusChip from "./ReplayStatusChip.vue"; + +function mountChip(loadState: string) { + return mount(ReplayStatusChip, { + props: { loadState: loadState as any }, + global: { plugins: [i18n] }, + }); +} + +function dotClasses(wrapper: VueWrapper) { + return wrapper.find('[data-test="replay-status-chip"] > span').classes(); +} + +describe("ReplayStatusChip", () => { + let wrapper: VueWrapper; + + afterEach(() => { + wrapper.unmount(); + }); + + it("shows nothing while the session is live, even once fully loaded", async () => { + wrapper = mountChip("loading"); + await wrapper.setProps({ loadState: "live" }); + + expect(wrapper.find('[data-test="replay-status-chip"]').exists()).toBe(false); + }); + + it("shows a primary dot while loading", () => { + wrapper = mountChip("loading"); + + const classes = dotClasses(wrapper); + expect(classes).toContain("bg-button-primary"); + }); + + it("shows a teal dot once a load completes", async () => { + wrapper = mountChip("loading"); + await wrapper.setProps({ loadState: "complete" }); + + const classes = dotClasses(wrapper); + expect(classes).toContain("bg-badge-teal-solid-bg"); + }); +}); diff --git a/web/src/components/rum/ReplayStatusChip.vue b/web/src/components/rum/ReplayStatusChip.vue new file mode 100644 index 0000000000..b6e79b609c --- /dev/null +++ b/web/src/components/rum/ReplayStatusChip.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/web/src/components/rum/VideoPlayer.spec.ts b/web/src/components/rum/VideoPlayer.spec.ts index a557ca984f..221017feb3 100644 --- a/web/src/components/rum/VideoPlayer.spec.ts +++ b/web/src/components/rum/VideoPlayer.spec.ts @@ -14,16 +14,13 @@ // along with this program. If not, see . import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { defineComponent, shallowRef } from "vue"; import { mount, flushPromises } from "@vue/test-utils"; import VideoPlayer from "@/components/rum/VideoPlayer.vue"; import i18n from "@/locales"; import store from "@/test/unit/helpers/store"; // vi.mock is hoisted — must be at top of file -vi.mock("lodash-es", () => ({ - cloneDeep: vi.fn((obj) => JSON.parse(JSON.stringify(obj))), -})); - vi.mock("@/utils/zincutils", () => ({ getPath: vi.fn(() => "/test/path"), })); @@ -37,23 +34,42 @@ vi.mock("vuex", () => ({ })), })); +// Hoisted so the append assertions can read the same spies the component calls. +const playerSpies = vi.hoisted(() => ({ + addEvent: vi.fn(), + getCurrentTime: vi.fn(() => 0), + destroyReplayer: vi.fn(), + meta: { startTime: 1704110400000, endTime: 1704110520000, totalTime: 120000 }, + instances: [] as any[], +})); + +// A plain function, not an arrow: the component calls `new rrwebPlayer(...)`. vi.mock("@openobserve/rrweb-player", () => ({ - default: vi.fn(() => ({ - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - play: vi.fn(), - pause: vi.fn(), - setSpeed: vi.fn(), - toggleSkipInactive: vi.fn(), - goto: vi.fn(), - getMetaData: vi.fn(() => ({ - startTime: 1704110400000, - endTime: 1704110520000, - totalTime: 120000, - })), - triggerResize: vi.fn(), - $set: vi.fn(), - })), + default: vi.fn(function () { + const listeners: Record void> = {}; + const instance = { + listeners, + addEventListener: vi.fn((name: string, handler: (event?: any) => void) => { + listeners[name] = handler; + }), + removeEventListener: vi.fn(), + play: vi.fn(), + pause: vi.fn(), + setSpeed: vi.fn(), + toggleSkipInactive: vi.fn(), + goto: vi.fn(), + getMetaData: vi.fn(() => ({ ...playerSpies.meta })), + getReplayer: vi.fn(() => ({ + getCurrentTime: playerSpies.getCurrentTime, + destroy: playerSpies.destroyReplayer, + })), + addEvent: playerSpies.addEvent, + triggerResize: vi.fn(), + $set: vi.fn(), + }; + playerSpies.instances.push(instance); + return instance; + }), })); vi.mock("@openobserve/rrweb-player/dist/style.css", () => ({})); @@ -104,6 +120,17 @@ const mockSegments = [ }, ]; +// A segment that arrives after the player was built, later than the mocked playhead. +const laterSegment = { + records: [ + { + type: 3, + timestamp: 1704110500000, + data: { source: 0, adds: [], removes: [], attributes: [], texts: [] }, + }, + ], +}; + // --------------------------------------------------------------------------- // Mount factory — single source of truth for stubs/plugins // --------------------------------------------------------------------------- @@ -129,6 +156,31 @@ function mountComponent(props: Record = {}) { }); } +// Production fills segments by assigning a shallowRef; setProps cannot show that regression. +function mountWithShallowSegments(initial: any[]) { + const segments = shallowRef(initial); + const Parent = defineComponent({ + components: { VideoPlayer }, + setup: () => ({ segments, events: mockEvents }), + template: ``, + }); + + const parent = mount(Parent, { + global: { + plugins: [i18n], + provide: { store }, + stubs: { + OIcon: { + template: '', + props: ["name", "size"], + }, + }, + }, + }); + + return { parent, segments }; +} + // --------------------------------------------------------------------------- // Suites // --------------------------------------------------------------------------- @@ -291,8 +343,9 @@ describe("VideoPlayer", () => { // ========================================================================== describe("togglePlay", () => { + // isPlaying is derived from the playback state now, so the tests drive it through togglePlay. it("should set isPlaying to true when togglePlay is called while not playing", () => { - wrapper.vm.playerState.isPlaying = false; + expect(wrapper.vm.playerState.isPlaying).toBe(false); wrapper.vm.togglePlay(); @@ -300,7 +353,8 @@ describe("VideoPlayer", () => { }); it("should set isPlaying to false when togglePlay is called while playing", () => { - wrapper.vm.playerState.isPlaying = true; + wrapper.vm.togglePlay(); + expect(wrapper.vm.playerState.isPlaying).toBe(true); wrapper.vm.togglePlay(); @@ -373,6 +427,86 @@ describe("VideoPlayer", () => { localWrapper.unmount(); }); + it("should build the player when a parent assigns a shallowRef segment list", async () => { + const { default: rrwebPlayerMock } = await import("@openobserve/rrweb-player"); + (rrwebPlayerMock as ReturnType).mockClear(); + + const { parent, segments } = mountWithShallowSegments([]); + await flushPromises(); + expect(rrwebPlayerMock).not.toHaveBeenCalled(); + + segments.value = [...mockSegments]; + await flushPromises(); + await parent.vm.$nextTick(); + await flushPromises(); + + expect(rrwebPlayerMock).toHaveBeenCalledTimes(1); + + parent.unmount(); + }); + + it("should append later segments through addEvent instead of rebuilding the player", async () => { + const { default: rrwebPlayerMock } = await import("@openobserve/rrweb-player"); + (rrwebPlayerMock as ReturnType).mockClear(); + playerSpies.addEvent.mockClear(); + + const { parent, segments } = mountWithShallowSegments([]); + await flushPromises(); + segments.value = [...mockSegments]; + await flushPromises(); + expect(rrwebPlayerMock).toHaveBeenCalledTimes(1); + + segments.value = [...segments.value, laterSegment]; + await flushPromises(); + await parent.vm.$nextTick(); + await flushPromises(); + + expect(rrwebPlayerMock).toHaveBeenCalledTimes(1); + expect(playerSpies.addEvent).toHaveBeenCalledTimes(1); + expect(playerSpies.addEvent.mock.calls[0][0]).toMatchObject({ + type: 3, + timestamp: 1704110500000, + }); + + parent.unmount(); + }); + + it("should skip an appended record that is at or before the playhead", async () => { + playerSpies.addEvent.mockClear(); + + const { parent, segments } = mountWithShallowSegments([]); + await flushPromises(); + segments.value = [...mockSegments]; + await flushPromises(); + + // Playhead sits past the appended record's timestamp; the player would apply it at once. + playerSpies.getCurrentTime.mockReturnValueOnce(20000); + const stale = { + records: [{ type: 3, timestamp: 1704110410000, data: { source: 3, id: 1, x: 0, y: 0 } }], + }; + segments.value = [...segments.value, stale]; + await flushPromises(); + await parent.vm.$nextTick(); + await flushPromises(); + + expect(playerSpies.addEvent).not.toHaveBeenCalled(); + + parent.unmount(); + }); + + it("should destroy the replayer before the player on unmount", async () => { + playerSpies.destroyReplayer.mockClear(); + + const { parent, segments } = mountWithShallowSegments([]); + await flushPromises(); + segments.value = [...mockSegments]; + await flushPromises(); + + parent.unmount(); + + expect(playerSpies.destroyReplayer).toHaveBeenCalledTimes(1); + }); + it("should not create a player instance when segments array is empty", async () => { const { default: rrwebPlayerMock } = await import("@openobserve/rrweb-player"); (rrwebPlayerMock as ReturnType).mockClear(); @@ -705,4 +839,526 @@ describe("VideoPlayer", () => { localWrapper.unmount(); }); }); + + // ========================================================================== + // PLAYBACK OVER A PARTLY LOADED SESSION (fixed timeline, buffering, seeks) + // ========================================================================== + + describe("Playback over a partly loaded session", () => { + const origin = 1704110400000; + // The player's first event is one minute into the session, as for an event_time window. + const sessionStart = origin - 60_000; + const sessionEnd = sessionStart + 600_000; + + const batch = (timestamp: number) => ({ + records: [ + { + type: 3, + timestamp, + data: { source: 0, adds: [], removes: [], attributes: [], texts: [] }, + }, + ], + }); + + async function mountPlayer(props: Record = {}) { + playerSpies.instances.length = 0; + const local = mountComponent({ + events: [], + segments: [], + sessionStartMs: sessionStart, + sessionEndMs: sessionEnd, + loadState: "loading", + speed: 1, + ...props, + }); + await flushPromises(); + await local.setProps({ segments: mockSegments }); + await flushPromises(); + await local.vm.$nextTick(); + return { local, instance: playerSpies.instances[0] }; + } + + async function append(local: any, meta: Partial, timestamp: number) { + Object.assign(playerSpies.meta, meta); + await local.setProps({ segments: [...(local.props("segments") as any[]), batch(timestamp)] }); + await flushPromises(); + await local.vm.$nextTick(); + await flushPromises(); + } + + async function playTo(local: any, instance: any, payload: number) { + local.vm.togglePlay(); + instance.listeners["ui-update-current-time"]({ payload }); + await local.vm.$nextTick(); + } + + beforeEach(() => { + playerSpies.meta = { startTime: origin, endTime: origin + 120_000, totalTime: 120_000 }; + playerSpies.getCurrentTime.mockReturnValue(0); + }); + + it("keeps the duration label fixed across three batch appends", async () => { + Object.assign(playerSpies.meta, { endTime: origin + 5_000, totalTime: 5_000 }); + const { local } = await mountPlayer(); + expect(local.vm.playerState.duration).toBe("10:00"); + + for (const [i, end] of [60_000, 120_000, 180_000].entries()) { + await append(local, { endTime: origin + end, totalTime: end }, origin + end - 1 - i); + expect(local.vm.playerState.duration).toBe("10:00"); + } + local.unmount(); + }); + + it("widens the timeline only when loaded records run past the metadata end", async () => { + const { local } = await mountPlayer({ sessionEndMs: origin + 60_000 }); + expect(local.vm.playerState.duration).toBe("03:00"); + await append(local, { endTime: origin + 30_000, totalTime: 30_000 }, origin + 29_000); + expect(local.vm.playerState.duration).toBe("03:00"); + local.unmount(); + }); + + it("emits ready once the player is built and reports its loaded edge in session ms", async () => { + const { local } = await mountPlayer(); + expect(local.emitted("ready")).toHaveLength(1); + expect(local.emitted("loaded-end-change")?.at(-1)).toEqual([180_000]); + local.unmount(); + }); + + it("shows the time label in session ms, not the player's own time", async () => { + const { local, instance } = await mountPlayer(); + instance.listeners["ui-update-current-time"]({ payload: 2_000 }); + await local.vm.$nextTick(); + expect(local.find('[data-test="video-player-time"]').text()).toBe("01:02"); + local.unmount(); + }); + + it("pauses one margin before the loaded edge, with epoch timestamps and an origin after the session start", async () => { + const { local, instance } = await mountPlayer(); + await playTo(local, instance, 118_999); + expect(local.vm.playbackState).toBe("playing"); + + instance.listeners["ui-update-current-time"]({ payload: 119_000 }); + await local.vm.$nextTick(); + + expect(local.vm.playbackState).toBe("buffering"); + expect(instance.pause).toHaveBeenCalled(); + expect(local.vm.playerState.isPlaying).toBe(true); + expect(local.find('[data-test="replay-overlay-buffering"]').text()).toContain( + "Loading the next part of the session", + ); + local.unmount(); + }); + + it("resumes at the held time, not at 0, once an append leaves two margins of headroom", async () => { + const { local, instance } = await mountPlayer(); + await playTo(local, instance, 119_000); + instance.goto.mockClear(); + + await append(local, { endTime: origin + 130_000, totalTime: 130_000 }, origin + 129_000); + + expect(instance.goto).toHaveBeenCalledWith(119_000, true); + expect(instance.goto).not.toHaveBeenCalledWith(0, expect.anything()); + expect(local.vm.playbackState).toBe("playing"); + local.unmount(); + }); + + it("ends in Buffering through the finish safety net when a skip overshoots the margin", async () => { + const { local, instance } = await mountPlayer(); + await playTo(local, instance, 1_000); + instance.listeners.finish(); + await local.vm.$nextTick(); + expect(local.vm.playbackState).toBe("buffering"); + local.unmount(); + }); + + it("resumes unconditionally when loading completes inside the margin, then ends on finish", async () => { + const { local, instance } = await mountPlayer(); + await playTo(local, instance, 119_000); + instance.goto.mockClear(); + + await append(local, { endTime: origin + 120_500, totalTime: 120_500 }, origin + 120_400); + expect(local.vm.playbackState).toBe("buffering"); + expect(instance.goto).not.toHaveBeenCalled(); + + await local.setProps({ loadState: "complete" }); + expect(instance.goto).toHaveBeenCalledWith(119_000, true); + + instance.listeners.finish(); + await local.vm.$nextTick(); + expect(local.vm.playbackState).toBe("ended"); + local.unmount(); + }); + + it("goes to Failed on finish when the load failed, and to Ended when it completed", async () => { + const failed = await mountPlayer({ loadState: "failed" }); + await playTo(failed.local, failed.instance, 1_000); + failed.instance.listeners.finish(); + await failed.local.vm.$nextTick(); + expect(failed.local.vm.playbackState).toBe("failed"); + failed.local.unmount(); + + const complete = await mountPlayer({ loadState: "complete" }); + await playTo(complete.local, complete.instance, 1_000); + complete.instance.listeners.finish(); + await complete.local.vm.$nextTick(); + expect(complete.local.vm.playbackState).toBe("ended"); + complete.local.unmount(); + }); + + it("treats the run edge as the end once the run holds the last segment, while holes still load", async () => { + const { local, instance } = await mountPlayer({ runComplete: true }); + await playTo(local, instance, 119_500); + expect(local.vm.playbackState).toBe("playing"); + + instance.listeners.finish(); + await local.vm.$nextTick(); + expect(local.vm.playbackState).toBe("ended"); + local.unmount(); + }); + + it("resumes from Buffering once the last segment of the run is in the player", async () => { + const { local, instance } = await mountPlayer(); + await playTo(local, instance, 119_000); + instance.goto.mockClear(); + + Object.assign(playerSpies.meta, { endTime: origin + 120_500, totalTime: 120_500 }); + await local.setProps({ + runComplete: true, + segments: [...(local.props("segments") as any[]), batch(origin + 120_400)], + }); + expect(local.vm.playbackState).toBe("buffering"); + await flushPromises(); + await local.vm.$nextTick(); + await flushPromises(); + + expect(instance.goto).toHaveBeenCalledWith(119_000, true); + expect(local.vm.playbackState).toBe("playing"); + local.unmount(); + }); + + it("reports how many segments the player holds, after it has converted them", async () => { + const { local } = await mountPlayer(); + expect(local.emitted("segments-taken")?.at(-1)).toEqual([1]); + await append(local, { endTime: origin + 130_000, totalTime: 130_000 }, origin + 129_000); + expect(local.emitted("segments-taken")?.at(-1)).toEqual([2]); + local.unmount(); + }); + + it("does not start rrweb on build while a pending seek is still outstanding", async () => { + playerSpies.instances.length = 0; + const local = mountComponent({ + events: [], + segments: [], + sessionStartMs: sessionStart, + sessionEndMs: sessionEnd, + loadState: "loading", + pendingSeekMs: 300_000, + speed: 1, + }); + await flushPromises(); + expect(local.vm.playbackState).toBe("loading"); + local.vm.togglePlay(); + await local.setProps({ intent: "play", segments: mockSegments }); + await flushPromises(); + await local.vm.$nextTick(); + const instance = playerSpies.instances[0]; + + expect(local.emitted("ready")).toHaveLength(1); + expect(instance.goto).not.toHaveBeenCalled(); + expect(instance.play).not.toHaveBeenCalled(); + expect(local.vm.playbackState).toBe("buffering"); + local.unmount(); + }); + + it("keeps the mode set by a seek the parent makes on ready", async () => { + playerSpies.instances.length = 0; + let local: any = null; + local = mountComponent({ + events: [], + segments: [], + sessionStartMs: sessionStart, + sessionEndMs: sessionEnd, + loadState: "loading", + pendingSeekMs: 90_000, + intent: "play", + speed: 1, + onReady: () => local.vm.seekTo(90_000, true), + }); + await flushPromises(); + await local.setProps({ segments: mockSegments }); + await flushPromises(); + await local.vm.$nextTick(); + const instance = playerSpies.instances[0]; + + expect(instance.goto).toHaveBeenLastCalledWith(30_000, true); + expect(local.vm.playbackState).toBe("playing"); + local.unmount(); + }); + + it("cancels the auto-resume when the user pauses while buffering", async () => { + const { local, instance } = await mountPlayer(); + await playTo(local, instance, 119_000); + local.vm.togglePlay(); + expect(local.vm.playbackState).toBe("paused"); + instance.goto.mockClear(); + + await append(local, { endTime: origin + 200_000, totalTime: 200_000 }, origin + 199_000); + + expect(instance.goto).not.toHaveBeenCalled(); + expect(local.vm.playbackState).toBe("paused"); + local.unmount(); + }); + + it("from Failed, a covered seek plays if the user was playing and shows the frame if paused", async () => { + const { local, instance } = await mountPlayer({ loadState: "failed" }); + await playTo(local, instance, 1_000); + instance.listeners.finish(); + await local.vm.$nextTick(); + + local.vm.seekTo(90_000, true); + expect(instance.goto).toHaveBeenLastCalledWith(30_000, true); + expect(local.vm.playbackState).toBe("playing"); + + instance.listeners.finish(); + local.vm.seekTo(90_000, false); + expect(instance.goto).toHaveBeenLastCalledWith(30_000, false); + expect(local.vm.playbackState).toBe("paused"); + local.unmount(); + }); + + it("leaves rrweb's playhead alone for a pending seek, and loses no record between the edge and the target", async () => { + const { local, instance } = await mountPlayer(); + instance.listeners["ui-update-current-time"]({ payload: 10_000 }); + instance.goto.mockClear(); + + await local.setProps({ pendingSeekMs: 300_000 }); + expect(instance.goto).not.toHaveBeenCalled(); + expect(local.vm.playbackState).toBe("waiting"); + expect(local.vm.playerState.time).toBe("05:00"); + expect(local.find('[data-test="replay-overlay-waiting"]').text()).toContain( + "Loading up to 05:00", + ); + + instance.listeners["ui-update-current-time"]({ payload: 20_000 }); + expect(local.vm.playerState.actualTime).toBe(10_000); + + playerSpies.addEvent.mockClear(); + playerSpies.getCurrentTime.mockReturnValue(10_000); + await append(local, { endTime: origin + 250_000, totalTime: 250_000 }, origin + 200_000); + expect(playerSpies.addEvent).toHaveBeenCalledWith( + expect.objectContaining({ timestamp: origin + 200_000 }), + ); + + await local.setProps({ pendingSeekMs: null }); + local.vm.seekTo(300_000, false); + expect(instance.goto).toHaveBeenLastCalledWith(240_000, false); + local.unmount(); + }); + + it("dims the played fill while a seek waits on data", async () => { + const { local } = await mountPlayer({ pendingSeekMs: 300_000 }); + expect(local.find('[data-test="video-player-progress"]').classes()).toContain("opacity-50"); + local.unmount(); + }); + + it("sends Play in Ended to the parent as a seek to 0 instead of restarting the controller", async () => { + const { local, instance } = await mountPlayer({ loadState: "complete" }); + await playTo(local, instance, 1_000); + instance.listeners.finish(); + await local.vm.$nextTick(); + instance.play.mockClear(); + instance.goto.mockClear(); + + local.vm.togglePlay(); + + expect(local.emitted("seek-request")?.at(-1)).toEqual([0]); + expect(instance.play).not.toHaveBeenCalled(); + expect(instance.goto).not.toHaveBeenCalled(); + local.unmount(); + }); + + it("never calls the controller's play() while the load is incomplete", async () => { + const { local, instance } = await mountPlayer(); + local.vm.togglePlay(); + expect(instance.play).not.toHaveBeenCalled(); + expect(instance.goto).toHaveBeenLastCalledWith(0, true); + local.unmount(); + }); + + it("turns bar clicks and ±10 s into seek requests in session ms instead of seeking itself", async () => { + const { local, instance } = await mountPlayer(); + instance.goto.mockClear(); + + await local + .find('[data-test="video-player-playback-bar"]') + .trigger("click", { clientX: 400 }); + expect(local.emitted("seek-request")?.at(-1)).toEqual([300_000]); + + instance.listeners["ui-update-current-time"]({ payload: 0 }); + local.vm.$.setupState.skipTo("forward"); + expect(local.emitted("seek-request")?.at(-1)).toEqual([70_000]); + expect(instance.goto).not.toHaveBeenCalled(); + local.unmount(); + }); + + it("shows the load percentage, then Fully loaded for 3 s", async () => { + vi.useFakeTimers(); + try { + const { local } = await mountPlayer({ loadPercent: 45.6 }); + expect(local.find('[data-test="replay-status-chip"]').text()).toBe("Loading 45%"); + + await local.setProps({ loadState: "complete" }); + expect(local.find('[data-test="replay-status-chip"]').text()).toBe("Fully loaded"); + + vi.advanceTimersByTime(3000); + await local.vm.$nextTick(); + expect(local.find('[data-test="replay-status-chip"]').exists()).toBe(false); + local.unmount(); + } finally { + vi.useRealTimers(); + } + }); + + it("in a live session, ends at the loaded end with nothing in flight and resumes in place with goto", async () => { + const { local, instance } = await mountPlayer({ loadState: "live", runComplete: true }); + await playTo(local, instance, 119_900); + instance.listeners.finish(); + await local.vm.$nextTick(); + expect(local.vm.playbackState).toBe("ended"); + expect(local.emitted("update:intent")?.at(-1)).toEqual(["play"]); + instance.play.mockClear(); + instance.goto.mockClear(); + + await append(local, { endTime: origin + 150_000, totalTime: 150_000 }, origin + 149_000); + + expect(instance.goto).toHaveBeenCalledWith(119_900, true); + expect(instance.play).not.toHaveBeenCalled(); + expect(local.vm.playbackState).toBe("playing"); + local.unmount(); + }); + + it("in a live session, does not resume on new activity once the user moved the playhead", async () => { + const { local, instance } = await mountPlayer({ loadState: "live", runComplete: true }); + await playTo(local, instance, 119_900); + instance.listeners.finish(); + await local.vm.$nextTick(); + local.vm.seekTo(90_000, false); + instance.goto.mockClear(); + + await append(local, { endTime: origin + 150_000, totalTime: 150_000 }, origin + 149_000); + + expect(instance.goto).not.toHaveBeenCalled(); + expect(local.vm.playbackState).toBe("paused"); + local.unmount(); + }); + + it("in a live session, buffers at the edge while a batch is in flight", async () => { + const { local, instance } = await mountPlayer({ loadState: "live" }); + await playTo(local, instance, 119_000); + expect(local.vm.playbackState).toBe("buffering"); + instance.listeners.finish(); + await local.vm.$nextTick(); + expect(local.vm.playbackState).toBe("buffering"); + local.unmount(); + }); + + it("shows no chip while live and never Fully loaded", async () => { + vi.useFakeTimers(); + try { + const { local } = await mountPlayer({ loadPercent: 45.6 }); + await local.setProps({ loadState: "live", loadPercent: 100 }); + const chip = () => local.find('[data-test="replay-status-chip"]'); + expect(chip().exists()).toBe(false); + + vi.advanceTimersByTime(5000); + await local.vm.$nextTick(); + expect(chip().exists()).toBe(false); + + await local.setProps({ loadState: "complete" }); + expect(chip().exists()).toBe(false); + local.unmount(); + } finally { + vi.useRealTimers(); + } + }); + + it("shows a failed chip and overlay whose Retry reaches the parent", async () => { + const { local, instance } = await mountPlayer({ loadState: "failed", failedFromMs: 60_000 }); + await playTo(local, instance, 1_000); + instance.listeners.finish(); + await local.vm.$nextTick(); + + expect(local.find('[data-test="replay-overlay-failed"]').text()).toContain( + "Couldn't load 01:00 – 10:00", + ); + await local.find('[data-test="replay-status-chip-failed"]').trigger("click"); + await local.find('[data-test="replay-overlay-retry"]').trigger("click"); + expect(local.emitted("retry")).toHaveLength(2); + local.unmount(); + }); + + it("draws the loaded band and counts the parts that could not load", async () => { + const { local } = await mountPlayer({ + loadedRanges: [ + { start: 60_000, end: 180_000, state: "inPlayer" }, + { start: 180_000, end: 185_000, state: "skipped" }, + { start: 500_000, end: 600_000, state: "unavailable" }, + ], + }); + expect(local.findAll('[data-test="replay-load-band-inPlayer"]')).toHaveLength(1); + expect(local.find('[data-test="replay-load-band-skipped"]').attributes("title")).toBe( + "This part couldn't load", + ); + expect(local.find('[data-test="replay-load-band-unavailable"]').attributes("title")).toBe( + "Not available: past the 50,000-segment limit", + ); + expect(local.find('[data-test="replay-status-chip"]').text()).toContain( + "· 1 part couldn't load", + ); + local.unmount(); + }); + + it("tells the user that a hovered span is not loaded yet", async () => { + const { local } = await mountPlayer({ + loadedRanges: [{ start: 60_000, end: 180_000, state: "inPlayer" }], + }); + const bar = local.find('[data-test="video-player-playback-bar"]'); + await bar.trigger("mousemove", { clientX: 400 }); + expect(local.find('[data-test="video-player-hover-tooltip"]').text()).toBe( + "05:00 · not loaded yet", + ); + await bar.trigger("mousemove", { clientX: 160 }); + expect(local.find('[data-test="video-player-hover-tooltip"]').text()).toBe("02:00"); + local.unmount(); + }); + }); + + describe("Error and empty states", () => { + it("shows the load error with a Retry that reaches the parent", async () => { + const local = mountComponent({ loadState: "error" }); + await flushPromises(); + expect(local.find('[data-test="replay-overlay-error"]').text()).toContain( + "Couldn't load the session replay.", + ); + await local.find('[data-test="replay-overlay-retry"]').trigger("click"); + expect(local.emitted("retry")).toHaveLength(1); + local.unmount(); + }); + + it("shows a real empty state for a session with no replay", async () => { + const local = mountComponent({ loadState: "empty" }); + await flushPromises(); + expect(local.find('[data-test="replay-overlay-empty"]').text()).toContain( + "No session replay available", + ); + local.unmount(); + }); + + it("shows the retry attempt under the loading spinner", async () => { + const local = mountComponent({ isLoading: true, retryAttempt: 2 }); + await flushPromises(); + expect(local.find('[data-test="video-player-retrying"]').text()).toBe("Retrying (2 of 3)…"); + local.unmount(); + }); + }); }); diff --git a/web/src/components/rum/VideoPlayer.vue b/web/src/components/rum/VideoPlayer.vue index 871efc6e7f..45848b2a45 100644 --- a/web/src/components/rum/VideoPlayer.vue +++ b/web/src/components/rum/VideoPlayer.vue @@ -25,15 +25,32 @@ along with this program. If not, see .
{{ t("rum.loadingSessions") }}
+
+ {{ t("rum.sessionReplayRetrying", { attempt: retryAttempt, total: MAX_ATTEMPTS }) }} +
-
+
+
. data-test="video-player-playback-bar" class="bg-surface-subtle relative mt-2 mb-3 h-[0.3125rem] w-full cursor-pointer" @click="handlePlaybackBarClick" + @mousemove="handleBarHover" + @mouseleave="hoverMs = null" > +
+
+ {{ hoverLabel }} +
@@ -107,20 +139,27 @@ along with this program. If not, see . />
-
{{ playerState.time }}
+
{{ playerState.time }}
/
-
{{ playerState.duration }}
+
{{ playerState.duration }}
+
. 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 > +