mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 00:08:55 +00:00
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:
co-authored by
github-actions[bot]
parent
ace01d321d
commit
5f6f8b84c7
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} الإحباطات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} フラストレーション",
|
||||
|
||||
@@ -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} 좌절",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} фрустраций",
|
||||
|
||||
@@ -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ığı",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} 次挫败",
|
||||
|
||||
@@ -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} 次挫折",
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
+1151
-307
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user