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>
This commit is contained in:
Omkar Kesarkhane
2026-09-30 17:57:09 +00:00
committed by GitHub
co-authored by github-actions[bot]
parent ace01d321d
commit 5f6f8b84c7
47 changed files with 7044 additions and 596 deletions
@@ -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",
@@ -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 <http://www.gnu.org/licenses/>.
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<string, any> = {}) {
return mount(MobileSessionPlayer, {
props: {
timeline: tl([segmentA]),
sessionStartMs: T,
loadState: "loading",
speed: 1,
...props,
},
global: {
plugins: [i18n],
stubs: {
OIcon: {
template: '<i data-test="OIcon" :data-name="name" @click="$emit(\'click\')"></i>',
props: ["name", "size"],
emits: ["click"],
},
},
},
});
}
const playIcon = (wrapper: ReturnType<typeof mountPlayer>) =>
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();
});
});
+345 -51
View File
@@ -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<number>(1);
const skipInactivity = ref(false);
const mode = ref<Mode>("paused");
const localSpeed = ref<number>(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<HTMLElement | null>(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<Wireframe[]>(() =>
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<PlaybackState>(() => {
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 });
</script>
<template>
@@ -242,6 +505,17 @@ onBeforeUnmount(() => {
>
<OSpinner size="md" />
<span>{{ t("rum.loadingSessionReplay") }}</span>
<span v-if="retryAttempt > 1" class="text-xs" data-test="rum-mobile-replay-retrying">
{{ t("rum.sessionReplayRetrying", { attempt: retryAttempt, total: MAX_ATTEMPTS }) }}
</span>
</div>
<div
v-else-if="loadState === 'error'"
class="text-text-secondary relative flex h-full items-center justify-center"
data-test="rum-mobile-replay-error"
>
<ReplayPlaybackOverlay state="error" @retry="emit('retry')" />
</div>
<div
@@ -276,6 +550,15 @@ onBeforeUnmount(() => {
</div>
</template>
</div>
<ReplayPlaybackOverlay
:state="playbackState"
:pending-seek-ms="pendingSeekMs"
:loaded-end-ms="loadedEndSessionMs"
:failed-from-ms="failedFromMs"
:timeline-ms="timelineMs"
:retry-attempt="retryAttempt"
@retry="emit('retry')"
/>
</div>
<!-- Controls, matching the browser session player. -->
@@ -285,8 +568,11 @@ onBeforeUnmount(() => {
data-test="rum-mobile-replay-playback-bar"
@click="onBarClick"
>
<ReplayLoadBand :ranges="loadedRanges" :timeline-ms="timelineMs" />
<div
class="bg-accent absolute top-0 left-0 h-full transition-[width] duration-100 ease-linear"
:class="{ 'opacity-50': pendingSeekMs !== null }"
data-test="rum-mobile-replay-progress"
:style="{ width: `${progressPct}%` }"
/>
<div
@@ -338,22 +624,30 @@ onBeforeUnmount(() => {
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) }}
</span>
<ReplayStatusChip
:load-state="loadState"
:load-percent="loadPercent"
:skipped-parts="skippedCount(loadedRanges)"
@retry="emit('retry')"
/>
</div>
<div class="flex items-center gap-2">
<OSwitch
v-model="skipInactivity"
:model-value="skipInactivity"
:label="t('rum.skipInactivity')"
data-test="rum-mobile-replay-skip-inactive"
class="whitespace-nowrap"
@update:model-value="setSkipInactivity"
/>
<OSelect
v-model="speed"
:model-value="speed"
:options="speedOptions"
:searchable="false"
data-test="rum-mobile-replay-speed-select"
@update:model-value="setSpeed"
/>
</div>
</div>
+60
View File
@@ -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 <http://www.gnu.org/licenses/>.
-->
<template>
<div
v-for="(range, i) in visibleRanges"
:key="`${range.state}-${range.start}-${i}`"
class="absolute top-0 h-full"
:class="stateClass[range.state]"
:data-test="`replay-load-band-${range.state}`"
:title="stateTitle(range.state)"
:style="{
left: `${toPercent(range.start, timelineMs)}%`,
width: `${Math.max(toPercent(range.end, timelineMs) - toPercent(range.start, timelineMs), 0)}%`,
}"
/>
</template>
<script setup lang="ts">
import { computed, type PropType } from "vue";
import { useI18nTyped } from "@/types/i18n";
import { toPercent, type LoadedRange, type RangeState } from "@/utils/rum/sessionReplayTimeline";
const props = defineProps({
ranges: { type: Array as PropType<LoadedRange[]>, default: () => [] },
timelineMs: { type: Number, required: true },
});
const { t } = useI18nTyped();
const stateClass: Record<RangeState, string> = {
inPlayer: "bg-surface-subtle-hover",
fetched: "bg-surface-subtle-hover/50",
skipped: "bg-badge-error-solid-bg/60",
unavailable: "bg-hatch-unavailable",
};
const visibleRanges = computed(() =>
props.timelineMs > 0 ? props.ranges.filter((r) => r.end >= r.start) : [],
);
function stateTitle(state: RangeState): string | undefined {
if (state === "skipped") return t("rum.sessionReplayGapTooltip");
if (state === "unavailable") return t("rum.sessionReplayTruncatedTooltip");
return undefined;
}
</script>
@@ -0,0 +1,106 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<div
v-if="visible"
class="bg-surface-overlay/80 absolute inset-0 z-10 flex items-center justify-center p-3 text-center"
:data-test="`replay-overlay-${state}`"
>
<div class="flex flex-col items-center gap-1.5 text-sm">
<template v-if="state === 'error'">
<span data-test="replay-overlay-message">{{ t("rum.sessionReplayLoadFailed") }}</span>
<OButton size="sm" data-test="replay-overlay-retry" @click="emit('retry')">
{{ t("rum.sessionReplayRetry") }}
</OButton>
</template>
<span v-else-if="state === 'empty'" data-test="replay-overlay-message">
{{ t("rum.noSessionReplay") }}
</span>
<template v-else-if="state === 'failed'">
<span data-test="replay-overlay-message">
{{ t("rum.sessionReplayLoadFailedRange", { from: failedFromLabel, to: failedToLabel }) }}
</span>
<OButton size="sm" data-test="replay-overlay-retry" @click="emit('retry')">
{{ t("rum.sessionReplayRetry") }}
</OButton>
</template>
<template v-else-if="pendingSeekMs !== null">
<OSpinner size="sm" />
<span data-test="replay-overlay-message">
{{ t("rum.sessionReplayLoadingUpTo", { time: targetLabel }) }}
</span>
<div class="bg-surface-subtle h-1 w-56 overflow-hidden rounded-full">
<div
class="bg-button-primary h-full"
data-test="replay-overlay-progress"
:style="{ width: `${seekProgress}%` }"
/>
</div>
<span v-if="singleSnapshot" class="text-text-secondary text-xs">
{{ t("rum.sessionReplaySinglePageHint", { time: targetLabel }) }}
</span>
</template>
<template v-else>
<OSpinner size="sm" />
<span data-test="replay-overlay-message">{{ t("rum.sessionReplayNextPart") }}</span>
<span class="text-text-secondary text-xs">{{ t("rum.sessionReplayResumesOnItsOwn") }}</span>
</template>
<span
v-if="retryAttempt > 1"
class="text-text-secondary text-xs"
data-test="replay-overlay-retrying"
>
{{ t("rum.sessionReplayRetrying", { attempt: retryAttempt, total: MAX_ATTEMPTS }) }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, type PropType } from "vue";
import { useI18nTyped } from "@/types/i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import { MAX_ATTEMPTS } from "@/utils/rum/sessionReplayLoader";
import { formatReplayTime, toPercent, type PlaybackState } from "@/utils/rum/sessionReplayTimeline";
const props = defineProps({
state: { type: String as PropType<PlaybackState>, required: true },
pendingSeekMs: { type: Number as PropType<number | null>, default: null },
loadedEndMs: { type: Number, default: 0 },
failedFromMs: { type: Number as PropType<number | null>, default: null },
timelineMs: { type: Number, default: 0 },
singleSnapshot: { type: Boolean, default: false },
retryAttempt: { type: Number, default: 0 },
});
const emit = defineEmits<{ retry: [] }>();
const { t } = useI18nTyped();
const visible = computed(() =>
["buffering", "waiting", "failed", "error", "empty"].includes(props.state),
);
const targetLabel = computed(() => formatReplayTime(props.pendingSeekMs ?? 0));
const seekProgress = computed(() => toPercent(props.loadedEndMs, props.pendingSeekMs ?? 0));
const failedFromLabel = computed(() => formatReplayTime(props.failedFromMs ?? props.loadedEndMs));
const failedToLabel = computed(() => formatReplayTime(props.timelineMs));
</script>
@@ -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 <http://www.gnu.org/licenses/>.
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");
});
});
@@ -0,0 +1,95 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<OButton
v-if="loadState === 'failed'"
variant="ghost-destructive"
size="xs"
data-test="replay-status-chip-failed"
@click="emit('retry')"
>
{{ t("rum.sessionReplayLoadFailedChip") }}{{ skippedSuffix }}
</OButton>
<span
v-else-if="label"
class="border-border-default bg-surface-subtle text-text-secondary inline-flex items-center gap-1.5 rounded-full border px-2 text-xs whitespace-nowrap"
data-test="replay-status-chip"
>
<span class="h-1.5 w-1.5 rounded-full" :class="dotClass" />
{{ label }}{{ skippedSuffix }}
</span>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch, type PropType } from "vue";
import { useI18nTyped } from "@/types/i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import type { LoadState } from "@/utils/rum/sessionReplayTimeline";
const FULLY_LOADED_VISIBLE_MS = 3000;
const props = defineProps({
loadState: { type: String as PropType<LoadState>, required: true },
loadPercent: { type: Number, default: 0 },
skippedParts: { type: Number, default: 0 },
});
const emit = defineEmits<{ retry: [] }>();
const { t } = useI18nTyped();
const showFullyLoaded = ref(false);
let hideTimer: ReturnType<typeof setTimeout> | null = null;
const label = computed(() => {
if (props.loadState === "loading") {
return t("rum.sessionReplayLoadingPercent", { percent: Math.floor(props.loadPercent) });
}
if (props.loadState === "complete" && showFullyLoaded.value) {
return t("rum.sessionReplayFullyLoaded");
}
return "";
});
const dotClass = computed(() => {
return props.loadState === "complete" ? "bg-badge-teal-solid-bg" : "bg-button-primary";
});
const skippedSuffix = computed(() =>
props.skippedParts > 0
? ` ${t("rum.sessionReplaySkippedParts", { n: props.skippedParts }, props.skippedParts)}`
: "",
);
// Only a load that finished while the user watched announces it; a session opened already complete does not.
watch(
() => props.loadState,
(state, previous) => {
if (hideTimer) clearTimeout(hideTimer);
hideTimer = null;
showFullyLoaded.value = state === "complete" && previous === "loading";
if (!showFullyLoaded.value) return;
hideTimer = setTimeout(() => {
showFullyLoaded.value = false;
}, FULLY_LOADED_VISIBLE_MS);
},
);
onBeforeUnmount(() => {
if (hideTimer) clearTimeout(hideTimer);
});
</script>
+678 -22
View File
@@ -14,16 +14,13 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
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<string, (event?: any) => 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<string, any> = {}) {
});
}
// Production fills segments by assigning a shallowRef; setProps cannot show that regression.
function mountWithShallowSegments(initial: any[]) {
const segments = shallowRef<any[]>(initial);
const Parent = defineComponent({
components: { VideoPlayer },
setup: () => ({ segments, events: mockEvents }),
template: `<VideoPlayer :events="events" :segments="segments" :is-loading="false" />`,
});
const parent = mount(Parent, {
global: {
plugins: [i18n],
provide: { store },
stubs: {
OIcon: {
template: '<i data-test="OIcon" :data-name="name"></i>',
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<string, any> = {}) {
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<typeof playerSpies.meta>, 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();
});
});
});
+550 -130
View File
@@ -25,15 +25,32 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="w-full text-center">
{{ t("rum.loadingSessions") }}
</div>
<div
v-if="retryAttempt > 1"
class="text-text-secondary w-full text-center text-xs"
data-test="video-player-retrying"
>
{{ t("rum.sessionReplayRetrying", { attempt: retryAttempt, total: MAX_ATTEMPTS }) }}
</div>
</div>
</div>
<div ref="playerContainerRef" class="flex min-h-0 flex-1 items-center justify-center">
<div ref="playerContainerRef" class="relative flex min-h-0 flex-1 items-center justify-center">
<div
ref="playerRef"
id="player"
class="player flex h-full cursor-pointer items-center"
@click="togglePlay"
/>
<ReplayPlaybackOverlay
:state="playbackState"
:pending-seek-ms="pendingSeekMs"
:loaded-end-ms="loadedEndSessionMs"
:failed-from-ms="failedFromMs"
:timeline-ms="timelineMs"
:single-snapshot="singleSnapshot"
:retry-attempt="retryAttempt"
@retry="emit('retry')"
/>
</div>
<div class="controls-container w-full p-2 pt-3">
<div
@@ -41,9 +58,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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"
>
<ReplayLoadBand :ranges="loadedRanges" :timeline-ms="timelineMs" />
<div
class="bg-button-primary! absolute"
:class="{ 'opacity-50': pendingSeekMs !== null }"
data-test="video-player-progress"
:style="{
width: playerState.progressWidth + 'px',
left: 0,
@@ -74,7 +96,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
event.frustration_types && event.frustration_types.length > 0
? '0.1875rem'
: '0.125rem',
left: (event.relativeTime / playerState.totalTime) * playerState.width + 'px',
left: markerLeft(event) + 'px',
bottom: '-0.3125rem',
height:
event.frustration_types && event.frustration_types.length > 0
@@ -82,7 +104,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
: '0.9375rem',
}"
:title="getEventTooltip(event)"
@mouseenter="hoverEvent = event"
@mouseleave="hoverEvent = null"
/>
<div
v-if="hoverLabel"
class="bg-surface-overlay border-border-default text-text-body rounded-default pointer-events-none absolute bottom-4 z-10 -translate-x-1/2 border px-2 py-1 text-xs whitespace-nowrap shadow-md"
data-test="video-player-hover-tooltip"
:style="{ left: `${toPercent(hoverMs ?? 0, timelineMs)}%` }"
>
{{ hoverLabel }}
</div>
</div>
<div class="controls flex items-center justify-between">
<div class="flex items-center">
@@ -107,20 +139,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</div>
<div class="ms-4 flex items-center">
<div>{{ playerState.time }}</div>
<div data-test="video-player-time">{{ playerState.time }}</div>
<div class="px-1">/</div>
<div>{{ playerState.duration }}</div>
<div data-test="video-player-duration">{{ playerState.duration }}</div>
</div>
<ReplayStatusChip
class="ms-3"
:load-state="loadState"
:load-percent="loadPercent"
:skipped-parts="skippedCount(loadedRanges)"
@retry="emit('retry')"
/>
</div>
<div class="flex items-center">
<OSwitch
class="me-3 whitespace-nowrap"
v-model="playerState.skipInactivity"
:model-value="skipInactivityValue"
:label="t('rum.skipInactivity')"
@update:model-value="toggleSkipInactive"
@update:model-value="handleSkipInactivityChange"
/>
<OSelect
v-model="playerState.speed"
:model-value="speedValue"
:options="speedOptions"
:searchable="false"
@update:model-value="setSpeed"
@@ -132,11 +171,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script setup lang="ts">
import { cloneDeep } from "lodash-es";
import {
computed,
markRaw,
nextTick,
ref,
shallowRef,
watch,
type PropType,
type Ref,
onBeforeUnmount,
onMounted,
@@ -151,8 +193,38 @@ 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 type { SelectModelValue } from "@/lib/forms/Select/OSelect.types";
import { createRecordConverter } from "@/utils/rum/sessionReplayChangeFormat";
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 {
createRecordConverter,
dropChangesBeforeFirstSnapshot,
type RecordConverter,
} from "@/utils/rum/sessionReplayChangeFormat";
import { resolveRelativeLinks } from "@/utils/rum/sessionReplayUrls";
import { MAX_ATTEMPTS } from "@/utils/rum/sessionReplayLoader";
import {
canResume,
expectsMoreData,
formatReplayTime,
isLoadedAt,
isSkipMarker,
shouldBuffer,
skippedCount,
timelineLength,
toPercent,
type LoadState,
type LoadedRange,
type PlaybackState,
type ReplayIntent,
} from "@/utils/rum/sessionReplayTimeline";
type Mode = "paused" | "playing" | "buffering" | "waiting" | "failed" | "ended";
const DEFAULT_SPEED = 4;
const SKIP_SECONDS = 10;
const props = defineProps({
events: {
type: Array,
@@ -166,8 +238,34 @@ const props = defineProps({
type: Boolean,
required: true,
},
sessionStartMs: { type: Number, default: 0 },
sessionEndMs: { type: Number, default: 0 },
loadedRanges: { type: Array as PropType<LoadedRange[]>, default: () => [] },
loadState: { type: String as PropType<LoadState>, default: "complete" },
runComplete: { type: Boolean, default: false },
loadPercent: { type: Number, default: 0 },
failedFromMs: { type: Number as PropType<number | null>, default: null },
pendingSeekMs: { type: Number as PropType<number | null>, default: null },
singleSnapshot: { type: Boolean, default: false },
retryAttempt: { type: Number, default: 0 },
speed: { type: Number as PropType<number | undefined>, default: undefined },
skipInactivity: { type: Boolean as PropType<boolean | undefined>, default: undefined },
intent: { type: String as PropType<ReplayIntent>, default: "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];
"segments-taken": [count: number];
}>();
const { t } = useI18nTyped();
const store = useStore();
@@ -180,7 +278,8 @@ const playerRef = ref<HTMLElement | null>(null);
const playbackBarRef = ref<HTMLElement | null>(null);
const session = ref<any>([]);
// shallowRef: the converted record list is large and nothing reads it reactively.
const session = shallowRef<any[]>([]);
const playerContainerRef = ref<HTMLElement | null>(null);
@@ -188,10 +287,34 @@ const worker: Ref<Worker | null> = ref(null);
const workerProcessId = ref(0);
// Appended segments must reuse this same converter, in order, or they decode against lost state.
let runConverter: RecordConverter | null = null;
// A later batch may carry no Meta record, so the page URL for resolving relative links must outlive one batch.
let runPageHref: string | undefined;
let convertedSegmentCount = 0;
let segmentWork: Promise<unknown> = Promise.resolve();
// rrweb's controller restarts at 0 on play() after a finish, so every resume after one must go through goto.
let finished = false;
let failedWasPlaying = false;
let seekCount = 0;
// Ended at the loaded end of a live session; new activity resumes it unless the user moved the playhead since.
let liveEnded = false;
const sessionWidth = ref(0);
const sessionHeight = ref(0);
const resizeObserver = ref<ResizeObserver | null>(null);
const mode = ref<Mode>("paused");
const playerBuilt = ref(false);
// How many of the segments prop the live player holds; the parent's coverage check waits on it.
const takenCount = ref(0);
const localSpeed = ref(DEFAULT_SPEED);
const localSkipInactivity = ref(true);
// Never shrinks: a batch whose records end before the metadata end must not pull the bar back.
const timelineMs = ref(0);
const hoverMs = ref<number | null>(null);
const hoverEvent = ref<any>(null);
const speedOptions = [
{
label: raw("0.5x"),
@@ -219,18 +342,78 @@ const speedOptions = [
},
];
const speedValue = computed(() => props.speed ?? localSpeed.value);
const skipInactivityValue = computed(() => props.skipInactivity ?? localSkipInactivity.value);
const playbackState = computed<PlaybackState>(() => {
if (props.loadState === "error") return "error";
if (props.loadState === "empty") return "empty";
if (!playerBuilt.value) return "loading";
return mode.value;
});
// Once the run has reached the last segment and the player holds it, a hole still loading can never extend this player.
const awaitingData = computed(
() =>
expectsMoreData(props.loadState) &&
!(props.runComplete && takenCount.value >= props.segments.length),
);
const sessionStart = computed(() => props.sessionStartMs || playerState.value.startTime);
// The player's time 0 is its first loaded event, which an event_time window puts after the session start.
const originOffsetMs = computed(() =>
playerBuilt.value ? playerState.value.startTime - sessionStart.value : 0,
);
const sessionTimeMs = computed(() => originOffsetMs.value + playerState.value.actualTime);
const displayMs = computed(() => props.pendingSeekMs ?? sessionTimeMs.value);
const loadedEndSessionMs = computed(() =>
playerBuilt.value ? playerState.value.endTime - sessionStart.value : 0,
);
const hoverLabel = computed(() => {
if (hoverMs.value === null || !(timelineMs.value > 0)) return "";
const parts: string[] = [formatReplayTime(hoverMs.value)];
if (hoverEvent.value) {
parts.push(
hoverEvent.value.type === "error" ? t("rum.error") : getEventTooltip(hoverEvent.value),
);
}
if (props.loadedRanges.length && !isLoadedAt(hoverMs.value, props.loadedRanges)) {
parts.push(t("rum.sessionReplayNotLoadedYet"));
}
return parts.join(" · ");
});
const playerState = ref({
isPlaying: false,
time: "00.00",
duration: "00.00", // in ms
speed: 4,
progressWidth: 0,
get isPlaying() {
return mode.value === "playing" || mode.value === "buffering";
},
get time() {
return playerBuilt.value || props.pendingSeekMs !== null
? formatReplayTime(displayMs.value)
: "00.00";
},
get duration() {
return timelineMs.value > 0 ? formatReplayTime(timelineMs.value) : "00.00";
},
get speed() {
return speedValue.value;
},
get skipInactivity() {
return skipInactivityValue.value;
},
get progressWidth() {
return (toPercent(displayMs.value, timelineMs.value) / 100) * this.width;
},
playBackEvents: {
views: true,
actions: true,
errors: true,
},
skipInactivity: true,
fullScreen: false,
startTime: 0,
endTime: 0,
@@ -276,16 +459,20 @@ onBeforeUnmount(() => {
if (worker.value) {
worker.value.terminate();
}
// $destroy frees the replayer's events, canvas/iframe, timers, and listeners.
// Replayer.destroy() removes its wrapper from the root, so it has to run before $destroy().
if (player.value) {
try {
player.value.pause?.();
player.value.getReplayer?.()?.destroy?.();
player.value.$destroy?.();
} catch {
// teardown race — the instance is already gone
}
player.value = null;
}
runConverter = null;
runPageHref = undefined;
convertedSegmentCount = 0;
rrwebPlayer = null;
});
@@ -328,22 +515,31 @@ function calculatePlayerDimensions(): { width: number; height: number } {
return { width: playerWidth, height: playerHeight };
}
const setupSession = async () => {
session.value = [];
if (!props.segments.length) return;
// The converter threads node-id and string-table state across one forward-only run, so the same instance must serve every batch of that run.
const convertSegments = (segments: any[], converter: RecordConverter, cold: boolean) => {
const out: any[] = [];
let skippedRecords = 0;
// The SDK v7 serialization emits session-replay snapshots in the compact "Change"
// format (FullSnapshot type 2 with format:1 and data:Change[], plus Change records of
// type 12). @openobserve/rrweb-player only understands the classic rrweb format, so we
// convert here. A single converter instance threads node-id / string-table state across
// all records in order (it resets itself on each full snapshot, mirroring the SDK).
const recordConverter = createRecordConverter();
let pageHref: string | undefined;
props.segments.forEach((segment: any) => {
segments.forEach((segment: any, segmentIndex: number) => {
// A skipped segment leaves the converter out of step until the next full snapshot.
if (isSkipMarker(segment)) {
converter.markStale();
return;
}
const convertedRecords: any[] = [];
segment.records.forEach((record: any) => {
convertedRecords.push(...recordConverter.convert(cloneDeep(record)));
// A cold converter has no string table, so Change records before the run's first snapshot decode to empty strings.
const records =
cold && segmentIndex === 0
? dropChangesBeforeFirstSnapshot(segment.records ?? [])
: (segment.records ?? []);
records.forEach((record: any) => {
// One unconvertible record must not cost the whole session, so skip it and carry on.
try {
convertedRecords.push(...converter.convert(record));
} catch (e) {
skippedRecords++;
console.error("Session replay: skipped an unconvertible record", e);
}
});
convertedRecords.forEach((record: any) => {
let segCopy = record;
@@ -360,13 +556,13 @@ const setupSession = async () => {
};
segCopy = seg;
}
if (segCopy.type === 4) pageHref = segCopy.data?.href;
if (segCopy.type === 4) runPageHref = segCopy.data?.href;
if (segCopy.type === 3 && segCopy.data?.source === 0) {
segCopy.data.adds?.forEach((add: any) => resolveRelativeLinks(add.node, pageHref));
segCopy.data.adds?.forEach((add: any) => resolveRelativeLinks(add.node, runPageHref));
}
try {
if (segCopy.type === 2 && segCopy.data.node.type === 0) {
resolveRelativeLinks(segCopy.data.node, pageHref);
resolveRelativeLinks(segCopy.data.node, runPageHref);
segCopy.data.node.childNodes.forEach((child: any) => {
if (child.type === 2 && child.tagName === "html") {
child.childNodes.forEach((_child: any) => {
@@ -396,24 +592,28 @@ const setupSession = async () => {
} catch (e) {
console.log(e);
}
session.value.push(segCopy);
out.push(segCopy);
});
});
// let lastEventTime = 1692884586897;
// const inactivityThreshold = 5000; // 5 seconds
if (skippedRecords) {
console.warn(`Session replay: ${skippedRecords} record(s) could not be converted`);
}
// session.value.forEach((event) => {
// const currentTime = event.timestamp;
// if (currentTime - lastEventTime > inactivityThreshold) {
// console.log(
// `Inactivity detected between timestamps ${lastEventTime} and ${currentTime} is of ${formatTimeDifference(
// currentTime - lastEventTime
// )} ms.`
// );
// }
// lastEventTime = currentTime;
// });
return out;
};
const setupSession = async () => {
session.value = [];
if (!props.segments.length) return;
runConverter = null;
runPageHref = undefined;
convertedSegmentCount = 0;
const converter = createRecordConverter();
const consumed = props.segments.length;
session.value = convertSegments(props.segments.slice(0, consumed), converter, true);
session.value.every((segment: any) => {
if (segment.data.height && segment.data.width) {
@@ -433,44 +633,86 @@ const setupSession = async () => {
await nextTick();
if (!playerRef.value) return;
if (player.value) return;
player.value = new rrwebPlayer({
target: playerRef.value as HTMLElement,
props: {
events: session.value,
UNSAFE_replayCanvas: false,
mouseTail: false,
autoPlay: false,
showController: false,
width: playerWidth,
height: playerHeight,
mutateChildNodes: true,
speed: playerState.value.speed,
skipInactive: playerState.value.skipInactivity,
},
});
player.value = markRaw(
new rrwebPlayer({
target: playerRef.value as HTMLElement,
props: {
events: session.value,
UNSAFE_replayCanvas: false,
mouseTail: false,
autoPlay: false,
showController: false,
width: playerWidth,
height: playerHeight,
mutateChildNodes: true,
speed: speedValue.value,
skipInactive: skipInactivityValue.value,
},
}),
);
// events.forEach((event) => {
// if (event.type === 2 || event.type === 4) {
// player.value?.addEvent(event);
// }
// });
// Adopt the run only once the player exists; a discarded conversion must not be appended to.
runConverter = converter;
convertedSegmentCount = consumed;
takenCount.value = consumed;
player.value.addEventListener("ui-update-current-time", updateProgressBar);
player.value.addEventListener("finish", () => {
playerState.value.isPlaying = false;
});
player.value.addEventListener("finish", handleFinish);
player.value.addEventListener("error", () => {
console.error("Playback error:");
});
// player.value.addEventListener("event-cast", (e) => {
// // console.log("event casted:", e);
// });
if (!player.value) return;
updatePlayerState();
playerBuilt.value = true;
emit("segments-taken", consumed);
const seeksBefore = seekCount;
emit("ready");
// The parent may have seeked on ready, which already set the mode; its cleared pendingSeekMs has not reached the props yet.
if (seekCount !== seeksBefore) return;
// A Play pressed while the first window was still loading is honoured once there is something to play.
if (mode.value === "playing" || props.intent === "play") startPlayback();
};
// Re-running setupSession per batch would re-convert the whole session, which is the cost this path exists to avoid.
const appendSegments = async (newSegments: any[], takenAfter: number) => {
if (!player.value || !runConverter || !newSegments.length) return;
const records = convertSegments(newSegments, runConverter, false).sort(
(a: any, b: any) => a.timestamp - b.timestamp,
);
// The player applies an event at or before the playhead at once, so this is a conservative proxy for its private baselineTime.
const meta = player.value.getMetaData?.();
const replayer = player.value.getReplayer?.();
const playhead =
meta && replayer ? (meta.startTime ?? 0) + (replayer.getCurrentTime?.() ?? 0) : -Infinity;
let skipped = 0;
for (const record of records) {
if (record.timestamp <= playhead) {
skipped++;
continue;
}
try {
player.value.addEvent(record);
} catch (e) {
skipped++;
console.error("Session replay: failed to append a record", e);
}
}
if (skipped) {
console.warn(`Session replay: ${skipped} appended record(s) were skipped`);
}
// addEvent and the controller's meta refresh both defer through a microtask.
await nextTick();
updatePlayerState();
takenCount.value = takenAfter;
emit("segments-taken", takenAfter);
tryResume();
resumeLiveEnded();
};
const updatePlayerState = () => {
@@ -481,12 +723,20 @@ const updatePlayerState = () => {
playerState.value.startTime = playerMeta?.startTime;
playerState.value.endTime = playerMeta?.endTime;
playerState.value.totalTime = playerMeta?.totalTime;
playerState.value.duration = formatTimeDifference(playerState.value.totalTime);
refreshTimeline();
const playbackBarWidth = playbackBarRef.value?.clientWidth || 0;
// calculate width of progress bar
playerState.value.width = playbackBarWidth;
player.value.triggerResize();
emit("loaded-end-change", playerState.value.endTime - sessionStart.value);
};
const refreshTimeline = () => {
const loadedEnd = playerBuilt.value || player.value ? playerState.value.endTime : 0;
const start = sessionStart.value;
const end = props.sessionEndMs || loadedEnd;
if (!start) return;
timelineMs.value = Math.max(timelineMs.value, timelineLength(start, end, loadedEnd));
};
const getEventMarkerClass = (event: any) => {
@@ -514,43 +764,107 @@ const getEventTooltip = (event: any) => {
return eventName;
};
function formatTimeDifference(milliSeconds: number) {
// Calculate hours, minutes, and seconds
let hours: string | number = Math.floor(milliSeconds / (1000 * 60 * 60));
let minutes: string | number = Math.floor((milliSeconds % (1000 * 60 * 60)) / (1000 * 60));
let seconds: string | number = Math.floor((milliSeconds % (1000 * 60)) / 1000);
// Add leading zeros if needed
hours = hours < 10 ? "0" + hours : hours;
minutes = minutes < 10 ? "0" + minutes : minutes;
seconds = seconds < 10 ? "0" + seconds : seconds;
if (hours === "00") {
return `${minutes}:${seconds}`;
}
if (hours === "00" && minutes === "00") {
return `${seconds}`;
}
return `${hours}:${minutes}:${seconds}`;
}
const markerLeft = (event: any) =>
(toPercent(Number(event.relativeTime) || 0, timelineMs.value) / 100) * playerState.value.width;
// A pending seek is drawn by the parent's target, so a late tick from the old position must not overwrite it.
const updateProgressBar = (time: { payload: number }) => {
playerState.value.progressWidth =
(time.payload / playerState.value.totalTime) * playerState.value.width;
if (props.pendingSeekMs !== null) return;
playerState.value.actualTime = time.payload;
playerState.value.time = formatTimeDifference(time.payload);
emit("time-update", sessionTimeMs.value);
if (
mode.value === "playing" &&
awaitingData.value &&
shouldBuffer(time.payload, playerState.value.totalTime, speedValue.value)
) {
enterBuffering();
}
};
// Safety net for a skip that overshoots the margin: the load state decides what the end of the loaded data means.
const handleFinish = () => {
finished = true;
if (mode.value !== "playing" && mode.value !== "buffering") return;
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");
}
};
const enterBuffering = () => {
player.value?.pause();
mode.value = "buffering";
};
const resumeAt = (offset: number) => {
player.value?.goto(offset, true);
finished = false;
liveEnded = false;
mode.value = "playing";
};
// Resumes where the clock held, never at 0, once enough has loaded past it or nothing more is coming.
const tryResume = () => {
if (mode.value !== "buffering" || props.pendingSeekMs !== null || !player.value) return;
const now = playerState.value.actualTime;
const final =
props.loadState === "complete" || (expectsMoreData(props.loadState) && !awaitingData.value);
if (final || canResume(now, playerState.value.totalTime, speedValue.value)) {
resumeAt(now);
}
};
// goto, never the controller's play(): after a finish, play() restarts the replay at 0.
const resumeLiveEnded = () => {
if (mode.value !== "ended" || !liveEnded || props.pendingSeekMs !== null || !player.value) return;
resumeAt(playerState.value.actualTime);
};
// A pending seek is display-only, so rrweb must not start from where it sits until the parent seeks.
const startPlayback = () => {
if (props.pendingSeekMs !== null) {
mode.value = "buffering";
return;
}
if (!player.value) {
mode.value = "playing";
return;
}
const now = playerState.value.actualTime;
if (awaitingData.value && shouldBuffer(now, playerState.value.totalTime, speedValue.value)) {
mode.value = "buffering";
return;
}
if (finished || props.loadState !== "complete") {
resumeAt(now);
return;
}
player.value.play();
mode.value = "playing";
};
const handlePlaybackBarClick = (event: any) => {
if (!playbackBarRef.value) return;
const playbackBarEl = playbackBarRef.value.getBoundingClientRect();
if (!playbackBarRef.value || !(timelineMs.value > 0)) return;
const rect = playbackBarRef.value.getBoundingClientRect();
if (!(rect.width > 0)) return;
const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
emit("seek-request", ratio * timelineMs.value);
};
let time =
((event.clientX - playbackBarEl.left) / playerState.value.width) * playerState.value.totalTime;
goto(time, playerState.value.isPlaying);
const handleBarHover = (event: MouseEvent) => {
if (!playbackBarRef.value || !(timelineMs.value > 0)) return;
const rect = playbackBarRef.value.getBoundingClientRect();
if (!(rect.width > 0)) return;
const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
hoverMs.value = ratio * timelineMs.value;
};
// -------------- Player control methods ----------------
@@ -563,40 +877,71 @@ const togglePlay = () => {
};
const play = () => {
playerState.value.isPlaying = true;
player.value?.play();
emit("update:intent", "play");
if (mode.value === "ended") {
// goto(0) would restart at the run's origin, not the session start.
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.
const pause = () => {
playerState.value.isPlaying = false;
emit("update:intent", "pause");
failedWasPlaying = false;
if (mode.value !== "playing" && mode.value !== "buffering") return;
player.value?.pause();
mode.value = props.pendingSeekMs !== null ? "waiting" : "paused";
};
const setSpeed = (speed: SelectModelValue) => {
// speedOptions are numeric; ignore any non-numeric emission.
if (typeof speed === "number") player.value?.setSpeed(speed);
if (typeof speed !== "number") return;
localSpeed.value = speed;
player.value?.setSpeed(speed);
emit("update:speed", speed);
};
const toggleSkipInactive = () => {
player.value?.toggleSkipInactive();
localSkipInactivity.value = !skipInactivityValue.value;
emit("update:skipInactivity", localSkipInactivity.value);
};
const handleSkipInactivityChange = (value: SwitchValue) => {
if (!!value !== skipInactivityValue.value) toggleSkipInactive();
};
const goto = (timeOffset: number, play: boolean = false) => {
playerState.value.isPlaying = play;
player.value?.goto(timeOffset, play);
finished = false;
liveEnded = false;
mode.value = play ? "playing" : "paused";
};
const skipTo = (skipTo: string) => {
const seconds = 10;
if (skipTo === "forward") {
const newTime = Math.min(
playerState.value.actualTime + seconds * 1000,
playerState.value.totalTime,
);
goto(newTime, false);
} else {
const newTime = Math.max(playerState.value.actualTime - seconds * 1000, 0);
goto(newTime, false);
}
/** The only seek entry point: session ms in, converted to this player's own origin. */
const seekTo = (sessionMs: number, play: boolean = false) => {
seekCount++;
const offset = Math.max(0, sessionMs - originOffsetMs.value);
playerState.value.actualTime = offset;
goto(offset, play);
};
// Seeks go to the parent, which knows what is loaded; the player never jumps into unloaded time itself.
const skipTo = (direction: string) => {
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)));
};
const initializeWorker = () => {
@@ -632,21 +977,96 @@ const processCss = (cssString: string, id: string | number) => {
};
watch(
() => props.segments,
(value) => {
if (value.length) setupSession();
() => props.segments.length,
(length) => {
if (!length) return;
const step = () => {
if (!player.value) return setupSession();
const pending = props.segments.slice(convertedSegmentCount);
convertedSegmentCount = props.segments.length;
return appendSegments(pending as any[], convertedSegmentCount);
};
// Serialised and kept alive on failure: a batch must not convert before the previous one has adopted the run.
segmentWork = segmentWork.then(step, step).catch((e) => {
console.error("Session replay: segment processing failed", e);
});
},
{ deep: true, immediate: true },
{ immediate: true },
);
// The metadata length is known before the first frame, so the total shows from the start.
watch(
() => [props.sessionStartMs, props.sessionEndMs],
() => refreshTimeline(),
{ immediate: true },
);
// The parent holds the target; the rrweb playhead stays put, because moving it into unloaded time drops every record up to it.
watch(
() => props.pendingSeekMs,
(target, previous) => {
if (target !== null && (previous ?? null) === null) {
if (mode.value === "playing" || mode.value === "buffering") {
player.value?.pause();
mode.value = "buffering";
} else {
mode.value = "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") {
// A last batch that ends inside the margin would otherwise buffer forever; finish then leads to Ended.
if (mode.value === "buffering" && props.pendingSeekMs === null && player.value) {
resumeAt(playerState.value.actualTime);
}
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";
}
},
);
// The run reaching its last segment ends the wait just as a complete load does.
watch(awaitingData, (awaiting) => {
if (!awaiting && expectsMoreData(props.loadState)) tryResume();
});
watch(playbackState, (state) => emit("playback-state", state));
defineExpose({
goto,
seekTo,
play,
pause,
togglePlay,
setSpeed,
toggleSkipInactive,
playerState,
playbackState,
updatePlayerState,
});
</script>
+4 -2
View File
@@ -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,
},
},
};
+10 -2
View File
@@ -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",
+20
View File
@@ -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} الإحباطات",
+20
View File
@@ -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",
+20
View File
@@ -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",
+20
View File
@@ -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",
+20
View File
@@ -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",
+20
View File
@@ -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",
+20
View File
@@ -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} フラストレーション",
+20
View File
@@ -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} 좌절",
+20
View File
@@ -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",
+20
View File
@@ -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",
+20
View File
@@ -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",
+20
View File
@@ -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} фрустраций",
+20
View File
@@ -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ığı",
+20
View File
@@ -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",
+20
View File
@@ -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} 次挫败",
+20
View File
@@ -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} 次挫折",
+9
View File
@@ -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`
@@ -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]);
});
});
});
+52 -40
View File
@@ -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<number, TrackedNode>();
let styleSheets = new Map<number, StoredStyleSheet>();
@@ -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);
}
+121
View File
@@ -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 <http://www.gnu.org/licenses/>.
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<string, any> = {}) => ({
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"),
);
});
});
+92
View File
@@ -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 <http://www.gnu.org/licenses/>.
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<string>();
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}`;
}
@@ -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 <http://www.gnu.org/licenses/>.
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([]);
});
});
@@ -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 <http://www.gnu.org/licenses/>.
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);
}
@@ -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 <http://www.gnu.org/licenses/>.
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();
});
});
+199
View File
@@ -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 <http://www.gnu.org/licenses/>.
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<void>;
}
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<void>((resolve) => setTimeout(resolve, ms));
export function createLoaderState(count: number): LoaderState {
return {
status: new Array<SegmentStatus>(count).fill("missing"),
missingCount: new Array<number>(count).fill(0),
skipReason: new Array<SkipReason | null>(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<T>(
fn: (attempt: number) => Promise<T>,
options: RetryOptions,
): Promise<T> {
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<typeof setTimeout> | 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;
}
@@ -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 <http://www.gnu.org/licenses/>.
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);
});
});
});
+134
View File
@@ -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 <http://www.gnu.org/licenses/>.
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<string>();
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,
};
}
@@ -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 <http://www.gnu.org/licenses/>.
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);
});
});
@@ -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 <http://www.gnu.org/licenses/>.
/** 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);
}
@@ -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 <http://www.gnu.org/licenses/>.
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");
});
});
+103
View File
@@ -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 <http://www.gnu.org/licenses/>.
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}`;
}
+3 -4
View File
@@ -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<WindowTotals | null>(null);
const frustrationCluster = ref<SessionInsight | null>(null);
const errorCluster = ref<SessionInsight | null>(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),
})),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -13,6 +13,8 @@ function replaceAbsoluteUrlsWithProxies(proxyUrl, cssString, excludedDomains = [
const urlRegex = /url\(\s*(['"]?)(https?:\/\/[^'")]+)\1\s*\)/g;
function replaceWithProxy(match, t1, url) {
// Stored records are shared with every later conversion, so a second pass must not proxy a proxied URL again.
if (url.startsWith(proxyUrl)) return match;
const isExcluded = excludedDomains.some((domain) => url.includes(domain));
if (isExcluded) {
return match;
+60
View File
@@ -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 <http://www.gnu.org/licenses/>.
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const worker = vi.hoisted(() => ({
onMessage: null as null | ((event: any) => void),
posted: [] as any[],
}));
const proxyUrl = "https://api.test/proxy/org";
function rewrite(cssString: string): string {
worker.onMessage!({ data: { cssString, proxyUrl, id: 1 } });
return worker.posted[worker.posted.length - 1].updatedCssString;
}
describe("rumcssworker", () => {
beforeAll(async () => {
vi.stubGlobal("self", {
addEventListener: (_type: string, handler: (event: any) => void) => {
worker.onMessage = handler;
},
postMessage: (message: any) => worker.posted.push(message),
});
await import("./rumcssworker.js");
});
afterAll(() => {
vi.unstubAllGlobals();
});
it("proxies an absolute url", () => {
expect(rewrite('a{background:url("https://cdn.test/a.png")}')).toBe(
`a{background:url("${proxyUrl}/https://cdn.test/a.png")}`,
);
});
it("leaves an already proxied url alone, so a second pass is a no-op", () => {
const once = rewrite("a{background:url(https://cdn.test/a.png)}");
expect(rewrite(once)).toBe(once);
});
it("keeps excluded font hosts unproxied", () => {
const css = 'a{src:url("https://fonts.gstatic.com/x.woff2")}';
expect(rewrite(css)).toBe(css);
});
});