diff --git a/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts b/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts index 9cc60a88fb8e4..1e73cc1c8d4c3 100644 --- a/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts +++ b/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts @@ -27,7 +27,7 @@ export const convertSnapshotsResponse = ( snapshotsByWindowId: { [key: string]: eventWithTime[] }, existingSnapshots?: RecordingSnapshot[] ): RecordingSnapshot[] => { - return deduplicateSnapshots(convertSnapshotsByWindowId(snapshotsByWindowId), existingSnapshots) + return deduplicateSnapshots([...convertSnapshotsByWindowId(snapshotsByWindowId), ...(existingSnapshots ?? [])]) } export const sortedRecordingSnapshots = (): { snapshot_data_by_window_id: Record } => { diff --git a/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackSceneLogic.ts b/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackSceneLogic.ts index 9c1606bdfe96b..30109a3936a2a 100644 --- a/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackSceneLogic.ts +++ b/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackSceneLogic.ts @@ -20,25 +20,6 @@ import type { sessionRecordingDataLogicType } from '../player/sessionRecordingDa import type { sessionRecordingFilePlaybackSceneLogicType } from './sessionRecordingFilePlaybackSceneLogicType' import { ExportedSessionRecordingFileV1, ExportedSessionRecordingFileV2 } from './types' -export const createExportedSessionRecording = ( - logic: BuiltLogic, - // DEBUG signal only, to be removed before release - exportUntransformedMobileSnapshotData: boolean -): ExportedSessionRecordingFileV2 => { - const { sessionPlayerMetaData, sessionPlayerSnapshotData } = logic.values - - return { - version: '2023-04-28', - data: { - id: sessionPlayerMetaData?.id ?? '', - person: sessionPlayerMetaData?.person, - snapshots: exportUntransformedMobileSnapshotData - ? sessionPlayerSnapshotData?.untransformed_snapshots || [] - : sessionPlayerSnapshotData?.snapshots || [], - }, - } -} - export const parseExportedSessionRecording = (fileData: string): ExportedSessionRecordingFileV2 => { const data = JSON.parse(fileData) as ExportedSessionRecordingFileV1 | ExportedSessionRecordingFileV2 @@ -163,9 +144,13 @@ export const sessionRecordingFilePlaybackSceneLogic = kea([ [ 'sessionPlayerData', 'sessionPlayerMetaDataLoading', - 'sessionPlayerSnapshotDataLoading', + 'snapshotsLoading', 'sessionEventsData', 'sessionEventsDataLoading', 'windowIds', @@ -856,7 +856,7 @@ export const playerInspectorLogic = kea([ (s) => [ s.sessionEventsDataLoading, s.sessionPlayerMetaDataLoading, - s.sessionPlayerSnapshotDataLoading, + s.snapshotsLoading, s.sessionEventsData, s.consoleLogs, s.allPerformanceEvents, @@ -865,7 +865,7 @@ export const playerInspectorLogic = kea([ ( sessionEventsDataLoading, sessionPlayerMetaDataLoading, - sessionPlayerSnapshotDataLoading, + snapshotsLoading, events, logs, performanceEvents, @@ -873,19 +873,19 @@ export const playerInspectorLogic = kea([ ): Record => { const tabEventsState = sessionEventsDataLoading ? 'loading' : events?.length ? 'ready' : 'empty' const tabConsoleState = - sessionPlayerMetaDataLoading || sessionPlayerSnapshotDataLoading || !logs + sessionPlayerMetaDataLoading || snapshotsLoading || !logs ? 'loading' : logs.length ? 'ready' : 'empty' const tabNetworkState = - sessionPlayerMetaDataLoading || sessionPlayerSnapshotDataLoading || !performanceEvents + sessionPlayerMetaDataLoading || snapshotsLoading || !performanceEvents ? 'loading' : performanceEvents.length ? 'ready' : 'empty' const tabDoctorState = - sessionPlayerMetaDataLoading || sessionPlayerSnapshotDataLoading || !performanceEvents + sessionPlayerMetaDataLoading || snapshotsLoading || !performanceEvents ? 'loading' : doctorEvents.length ? 'ready' diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts index 9262eb2f4d956..f0b310bf748f2 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts @@ -30,14 +30,12 @@ const BLOB_SOURCE: SessionRecordingSnapshotSource = { start_timestamp: '2023-08-11T12:03:36.097000Z', end_timestamp: '2023-08-11T12:04:52.268000Z', blob_key: '1691755416097-1691755492268', - loaded: false, } const REALTIME_SOURCE: SessionRecordingSnapshotSource = { source: 'realtime', start_timestamp: '2024-01-28T21:19:49.217000Z', end_timestamp: undefined, blob_key: undefined, - loaded: false, } describe('sessionRecordingDataLogic', () => { @@ -115,11 +113,14 @@ describe('sessionRecordingDataLogic', () => { it('loads all data', async () => { await expectLogic(logic, () => { logic.actions.loadRecordingMeta() - logic.actions.loadRecordingSnapshots() + logic.actions.loadSnapshots() }) .toDispatchActions([ + 'loadSnapshots', + 'loadSnapshotSources', 'loadRecordingMetaSuccess', - 'loadRecordingSnapshotsSuccess', + 'loadSnapshotSourcesSuccess', + 'loadSnapshotsForSourceSuccess', 'reportUsageIfFullyLoaded', ]) .toFinishAllListeners() @@ -174,9 +175,9 @@ describe('sessionRecordingDataLogic', () => { }) logic.mount() logic.actions.loadRecordingMeta() - logic.actions.loadRecordingSnapshots() + logic.actions.loadSnapshots() - await expectLogic(logic).toDispatchActions(['loadRecordingMetaSuccess', 'loadRecordingSnapshotsFailure']) + await expectLogic(logic).toDispatchActions(['loadRecordingMetaSuccess', 'loadSnapshotSourcesFailure']) expect(logic.values.sessionPlayerData).toMatchObject({ person: recordingMetaJson.person, durationMs: 11868, @@ -219,7 +220,7 @@ describe('sessionRecordingDataLogic', () => { }) await expectLogic(logic, () => { - logic.actions.loadRecordingSnapshots() + logic.actions.loadSnapshots() }).toDispatchActions(['loadEvents', 'loadEventsSuccess']) expect(api.create).toHaveBeenCalledWith( @@ -255,11 +256,11 @@ describe('sessionRecordingDataLogic', () => { describe('report usage', () => { it('sends `recording loaded` event only when entire recording has loaded', async () => { await expectLogic(logic, () => { - logic.actions.loadRecordingSnapshots() + logic.actions.loadSnapshots() }) .toDispatchActionsInAnyOrder([ - 'loadRecordingSnapshots', - 'loadRecordingSnapshotsSuccess', + 'loadSnapshots', + 'loadSnapshotsForSourceSuccess', 'loadEvents', 'loadEventsSuccess', ]) @@ -267,9 +268,9 @@ describe('sessionRecordingDataLogic', () => { }) it('sends `recording viewed` and `recording analyzed` event on first contentful paint', async () => { await expectLogic(logic, () => { - logic.actions.loadRecordingSnapshots() + logic.actions.loadSnapshots() }) - .toDispatchActions(['loadRecordingSnapshotsSuccess']) + .toDispatchActions(['loadSnapshotsForSourceSuccess']) .toDispatchActionsInAnyOrder([ eventUsageLogic.actionTypes.reportRecording, // loaded eventUsageLogic.actionTypes.reportRecording, // viewed @@ -278,7 +279,7 @@ describe('sessionRecordingDataLogic', () => { }) it('clears the cache after unmounting', async () => { await expectLogic(logic, () => { - logic.actions.loadRecordingSnapshots() + logic.actions.loadSnapshots() }) expect(Object.keys(logic.cache)).toEqual( expect.arrayContaining(['metaStartTime', 'snapshotsStartTime', 'eventsStartTime']) @@ -326,7 +327,9 @@ describe('sessionRecordingDataLogic', () => { }, ] // we call this multiple times and pass existing data in, so we need to make sure it doesn't change - expect(deduplicateSnapshots(verySimilarSnapshots, verySimilarSnapshots)).toEqual(verySimilarSnapshots) + expect(deduplicateSnapshots([...verySimilarSnapshots, ...verySimilarSnapshots])).toEqual( + verySimilarSnapshots + ) }) it('should match snapshot', () => { @@ -351,66 +354,68 @@ describe('sessionRecordingDataLogic', () => { it('loads each source, and on success reports recording viewed', async () => { await expectLogic(logic, () => { - logic.actions.loadRecordingSnapshots() - // loading the snapshots will trigger a loadRecordingSnapshotsSuccess + logic.actions.loadSnapshots() + // loading the snapshots will trigger a loadSnapshotsForSourceSuccess // that will have the blob source - // that triggers loadRecordingSnapshots + // that triggers loadNextSnapshotSource }).toDispatchActions([ // the action we triggered - logic.actionCreators.loadRecordingSnapshots(), - 'loadRecordingSnapshotsSuccess', + 'loadSnapshots', // the response to that triggers loading of the first item which is the blob source (action) => - action.type === logic.actionTypes.loadRecordingSnapshots && + action.type === logic.actionTypes.loadSnapshotsForSource && action.payload.source?.source === 'blob', - 'loadRecordingSnapshotsSuccess', + 'loadSnapshotsForSourceSuccess', // and then we report having viewed the recording 'reportViewed', // the response to the success action triggers loading of the second item which is the realtime source (action) => - action.type === logic.actionTypes.loadRecordingSnapshots && + action.type === logic.actionTypes.loadSnapshotsForSource && action.payload.source?.source === 'realtime', - 'loadRecordingSnapshotsSuccess', + 'loadSnapshotsForSourceSuccess', // having loaded any real time data we start polling to check for more - 'startRealTimePolling', + 'pollRealtimeSnapshots', + // which in turn triggers another load + (action) => + action.type === logic.actionTypes.loadSnapshotsForSource && + action.payload.source?.source === 'realtime', + 'loadSnapshotsForSourceSuccess', ]) }) - it('can start polling for snapshots', async () => { + it('polls up to a max threshold', async () => { await expectLogic(logic, () => { - logic.actions.startRealTimePolling() + logic.actions.loadSnapshots() }) .toDispatchActions([ - // the action we triggered - 'startRealTimePolling', - 'pollRecordingSnapshots', // 0 - 'pollRecordingSnapshotsSuccess', + 'loadSnapshotsForSource', // blob + 'loadSnapshotsForSourceSuccess', // the returned data isn't changing from our mock, // so we'll not keep polling indefinitely - 'pollRecordingSnapshots', // 1 - 'pollRecordingSnapshotsSuccess', - 'pollRecordingSnapshots', // 2 - 'pollRecordingSnapshotsSuccess', - 'pollRecordingSnapshots', // 3 - 'pollRecordingSnapshotsSuccess', - 'pollRecordingSnapshots', // 4 - 'pollRecordingSnapshotsSuccess', - 'pollRecordingSnapshots', // 5 - 'pollRecordingSnapshotsSuccess', - 'pollRecordingSnapshots', // 6 - 'pollRecordingSnapshotsSuccess', - 'pollRecordingSnapshots', // 7 - 'pollRecordingSnapshotsSuccess', - 'pollRecordingSnapshots', // 8 - 'pollRecordingSnapshotsSuccess', - 'pollRecordingSnapshots', // 9 - 'pollRecordingSnapshotsSuccess', - 'pollRecordingSnapshots', // 10 - 'pollRecordingSnapshotsSuccess', + 'loadSnapshotsForSource', // 1 + 'loadSnapshotsForSourceSuccess', + 'loadSnapshotsForSource', // 2 + 'loadSnapshotsForSourceSuccess', + 'loadSnapshotsForSource', // 3 + 'loadSnapshotsForSourceSuccess', + 'loadSnapshotsForSource', // 4 + 'loadSnapshotsForSourceSuccess', + 'loadSnapshotsForSource', // 5 + 'loadSnapshotsForSourceSuccess', + 'loadSnapshotsForSource', // 6 + 'loadSnapshotsForSourceSuccess', + 'loadSnapshotsForSource', // 7 + 'loadSnapshotsForSourceSuccess', + 'loadSnapshotsForSource', // 8 + 'loadSnapshotsForSourceSuccess', + 'loadSnapshotsForSource', // 9 + 'loadSnapshotsForSourceSuccess', + 'loadSnapshotsForSource', // 10 + 'loadSnapshotsForSourceSuccess', ]) .toNotHaveDispatchedActions([ // this isn't called again - 'pollRecordingSnapshots', + 'loadSnapshotsForSource', ]) await waitForExpect(() => { @@ -433,12 +438,14 @@ describe('sessionRecordingDataLogic', () => { it('should start polling even though realtime is empty', async () => { await expectLogic(logic, () => { - logic.actions.loadRecordingSnapshots() + logic.actions.loadSnapshots() }).toDispatchActions([ - 'loadRecordingSnapshotsSuccess', - 'startRealTimePolling', - 'pollRecordingSnapshots', - 'pollRecordingSnapshotsSuccess', + 'loadSnapshots', + 'loadSnapshotSourcesSuccess', + 'loadNextSnapshotSource', + 'pollRealtimeSnapshots', + 'loadSnapshotsForSource', + 'loadSnapshotsForSourceSuccess', ]) }) }) diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts index d71d860d5a568..f986818ecf485 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts @@ -5,7 +5,6 @@ import { actions, afterMount, beforeUnmount, - BreakPointFunction, connect, defaults, kea, @@ -38,15 +37,16 @@ import { RecordingSegment, RecordingSnapshot, SessionPlayerData, - SessionPlayerSnapshotData, SessionRecordingId, SessionRecordingSnapshotSource, + SessionRecordingSnapshotSourceResponse, SessionRecordingType, SessionRecordingUsageType, SnapshotSourceType, } from '~/types' import { PostHogEE } from '../../../../@posthog/ee/types' +import { ExportedSessionRecordingFileV2 } from '../file-playback/types' import type { sessionRecordingDataLogicType } from './sessionRecordingDataLogicType' import { createSegments, mapSnapshotsToWindowId } from './utils/segmenter' @@ -143,14 +143,10 @@ const cyrb53 = function (str: string, seed = 0): number { return 4294967296 * (2097151 & h2) + (h1 >>> 0) } -export const deduplicateSnapshots = ( - newSnapshots?: RecordingSnapshot[], - existingSnapshots?: RecordingSnapshot[] -): RecordingSnapshot[] => { +export const deduplicateSnapshots = (snapshots: RecordingSnapshot[] | null): RecordingSnapshot[] => { const seenHashes: Set = new Set() - return (newSnapshots || []) - .concat(existingSnapshots ? existingSnapshots ?? [] : []) + return (snapshots ?? []) .filter((snapshot) => { // For a multitude of reasons, there can be duplicate snapshots in the same recording. // we have to stringify the snapshot to compare it to other snapshots. @@ -230,30 +226,27 @@ function makeEventsQuery( async function processEncodedResponse( encodedResponse: (EncodedRecordingSnapshot | string)[], props: SessionRecordingDataLogicProps, - existingData: SessionPlayerSnapshotData | null, featureFlags: FeatureFlagsSet ): Promise<{ transformed: RecordingSnapshot[]; untransformed: RecordingSnapshot[] | null }> { let untransformed: RecordingSnapshot[] | null = null - const transformed = deduplicateSnapshots( - await parseEncodedSnapshots(encodedResponse, props.sessionRecordingId), - existingData?.snapshots ?? [] - ) + const transformed = await parseEncodedSnapshots(encodedResponse, props.sessionRecordingId) if (featureFlags[FEATURE_FLAGS.SESSION_REPLAY_EXPORT_MOBILE_DATA]) { - untransformed = deduplicateSnapshots( - await parseEncodedSnapshots( - encodedResponse, - props.sessionRecordingId, - false // don't transform mobile data - ), - existingData?.untransformed_snapshots ?? [] + untransformed = await parseEncodedSnapshots( + encodedResponse, + props.sessionRecordingId, + false // don't transform mobile data ) } return { transformed, untransformed } } +const getSourceKey = (source: SessionRecordingSnapshotSource): string => { + return `${source.source}-${source.blob_key}` +} + export const sessionRecordingDataLogic = kea([ path((key) => ['scenes', 'session-recordings', 'sessionRecordingDataLogic', key]), props({} as SessionRecordingDataLogicProps), @@ -269,24 +262,19 @@ export const sessionRecordingDataLogic = kea([ setFilters: (filters: Partial) => ({ filters }), loadRecordingMeta: true, maybeLoadRecordingMeta: true, - loadRecordingSnapshots: (source?: SessionRecordingSnapshotSource) => ({ source }), + loadSnapshots: true, + loadSnapshotSources: true, + loadNextSnapshotSource: true, + loadSnapshotsForSource: (source: Pick) => ({ source }), loadEvents: true, loadFullEventData: (event: RecordingEventType) => ({ event }), reportViewed: true, reportUsageIfFullyLoaded: true, persistRecording: true, maybePersistRecording: true, - startRealTimePolling: true, - pollRecordingSnapshots: true, - pollingLoadedNoNewData: true, + pollRealtimeSnapshots: true, }), reducers(() => ({ - unnecessaryPollingCount: [ - 0, - { - pollingLoadedNoNewData: (state) => state + 1, - }, - ], filters: [ {} as Partial, { @@ -301,143 +289,21 @@ export const sessionRecordingDataLogic = kea([ loadRecordingMetaFailure: () => true, }, ], - snapshotsLoaded: [ - false as boolean, + snapshotsBySource: [ + null as Record | null, { - loadRecordingSnapshotsSuccess: () => true, - loadRecordingSnapshotsFailure: () => true, - }, - ], - })), - listeners(({ values, actions, cache, props }) => ({ - pollRecordingSnapshotsSuccess: () => { - // always make sure we've cleared up the last timeout - clearTimeout(cache.realTimePollingTimeoutID) - cache.realTimePollingTimeoutID = null - - // ten is an arbitrary limit to try to avoid sending requests to our backend unnecessarily - // we could change this or add to it e.g. only poll if browser is visible to user - if (values.unnecessaryPollingCount <= 10) { - cache.realTimePollingTimeoutID = setTimeout(() => { - actions.pollRecordingSnapshots() - }, props.realTimePollingIntervalMilliseconds || DEFAULT_REALTIME_POLLING_MILLIS) - } - }, - startRealTimePolling: () => { - if (cache.realTimePollingTimeoutID) { - clearTimeout(cache.realTimePollingTimeoutID) - } - - cache.realTimePollingTimeoutID = setTimeout(() => { - actions.pollRecordingSnapshots() - }, props.realTimePollingIntervalMilliseconds || DEFAULT_REALTIME_POLLING_MILLIS) - }, - maybeLoadRecordingMeta: () => { - if (!values.sessionPlayerMetaDataLoading) { - actions.loadRecordingMeta() - } - }, - loadRecordingSnapshots: () => { - actions.loadEvents() - }, - loadRecordingMetaSuccess: () => { - cache.metadataLoadDuration = Math.round(performance.now() - cache.metaStartTime) - actions.reportUsageIfFullyLoaded() - }, - loadRecordingMetaFailure: () => { - cache.metadataLoadDuration = Math.round(performance.now() - cache.metaStartTime) - }, - loadRecordingSnapshotsSuccess: () => { - const { snapshots, sources } = values.sessionPlayerSnapshotData ?? {} - if (snapshots) { - if (!snapshots.length && sources?.length === 1) { - // We got only a single source to load, loaded it successfully, but it had no snapshots. - posthog.capture('recording_snapshots_v2_empty_response', { - source: sources[0], - }) + loadSnapshotsForSourceSuccess: (state, { snapshotsForSource }) => { + const sourceKey = getSourceKey(snapshotsForSource.source) - // If we only have a realtime source and its empty, start polling it anyway - if (sources[0].source === SnapshotSourceType.realtime) { - actions.startRealTimePolling() + return { + ...state, + [sourceKey]: snapshotsForSource, } - - return - } - - if (!cache.firstPaintDuration) { - cache.firstPaintDuration = Math.round(performance.now() - cache.snapshotsStartTime) - actions.reportViewed() - } - } - - const nextSourceToLoad = sources?.find((s) => !s.loaded) - - if (nextSourceToLoad) { - actions.loadRecordingSnapshots(nextSourceToLoad) - } else { - cache.snapshotsLoadDuration = Math.round(performance.now() - cache.snapshotsStartTime) - actions.reportUsageIfFullyLoaded() - - // If we have a realtime source, start polling it - const realTimeSource = sources?.find((s) => s.source === SnapshotSourceType.realtime) - if (realTimeSource) { - actions.startRealTimePolling() - } - } - }, - loadRecordingSnapshotsFailure: () => { - cache.snapshotsLoadDuration = Math.round(performance.now() - cache.snapshotsStartTime) - }, - loadEventsSuccess: () => { - cache.eventsLoadDuration = Math.round(performance.now() - cache.eventsStartTime) - actions.reportUsageIfFullyLoaded() - }, - loadEventsFailure: () => { - cache.eventsLoadDuration = Math.round(performance.now() - cache.eventsStartTime) - }, - reportUsageIfFullyLoaded: (_, breakpoint) => { - breakpoint() - if (values.fullyLoaded) { - eventUsageLogic.actions.reportRecording( - values.sessionPlayerData, - generateRecordingReportDurations(cache), - SessionRecordingUsageType.LOADED, - 0 - ) - // Reset cache now that final usage report has been sent - resetTimingsCache(cache) - } - }, - reportViewed: async (_, breakpoint) => { - const durations = generateRecordingReportDurations(cache) - breakpoint() - // Triggered on first paint - eventUsageLogic.actions.reportRecording( - values.sessionPlayerData, - durations, - SessionRecordingUsageType.VIEWED, - 0 - ) - await breakpoint(IS_TEST_MODE ? 1 : 10000) - eventUsageLogic.actions.reportRecording( - values.sessionPlayerData, - durations, - SessionRecordingUsageType.ANALYZED, - 10 - ) - }, - - maybePersistRecording: () => { - if (values.sessionPlayerMetaDataLoading) { - return - } - - if (values.sessionPlayerMetaData?.storage === 'object_storage') { - actions.persistRecording() - } - }, + }, + }, + ], })), - loaders(({ values, props, cache, actions }) => ({ + loaders(({ values, props, cache }) => ({ sessionPlayerMetaData: { loadRecordingMeta: async (_, breakpoint) => { if (!props.sessionRecordingId) { @@ -467,42 +333,27 @@ export const sessionRecordingDataLogic = kea([ } }, }, - sessionPlayerSnapshotData: [ - null as SessionPlayerSnapshotData | null, + snapshotSources: [ + null as SessionRecordingSnapshotSource[] | null, { - pollRecordingSnapshots: async (_, breakpoint: BreakPointFunction) => { + loadSnapshotSources: async () => { const params = { version: values.featureFlags[FEATURE_FLAGS.SESSION_REPLAY_V3_INGESTION_PLAYBACK] ? '3' : '2', - source: SnapshotSourceType.realtime, } - await breakpoint(1) // debounce const response = await api.recordings.listSnapshots(props.sessionRecordingId, params) - breakpoint() // handle out of order - - if (response.snapshots) { - const { transformed, untransformed } = await processEncodedResponse( - response.snapshots, - props, - values.sessionPlayerSnapshotData, - values.featureFlags - ) - - if (transformed.length === (values.sessionPlayerSnapshotData?.snapshots || []).length) { - actions.pollingLoadedNoNewData() - } - - return { - ...(values.sessionPlayerSnapshotData || {}), - snapshots: transformed, - untransformed_snapshots: untransformed ?? undefined, - } - } - return values.sessionPlayerSnapshotData + return response.sources ?? [] }, - loadRecordingSnapshots: async ({ source }, breakpoint): Promise => { - if (!props.sessionRecordingId) { - return values.sessionPlayerSnapshotData + }, + ], + snapshotsForSource: [ + null as SessionRecordingSnapshotSourceResponse | null, + { + loadSnapshotsForSource: async ({ source }, breakpoint) => { + const params = { + source: source.source, + blob_key: source.blob_key, + version: values.featureFlags[FEATURE_FLAGS.SESSION_REPLAY_V3_INGESTION_PLAYBACK] ? '3' : '2', } const snapshotLoadingStartTime = performance.now() @@ -511,73 +362,31 @@ export const sessionRecordingDataLogic = kea([ cache.snapshotsStartTime = snapshotLoadingStartTime } - const data: SessionPlayerSnapshotData = { - ...(values.sessionPlayerSnapshotData || {}), - } - await breakpoint(1) - if (source?.source === SnapshotSourceType.blob) { - const params = { - source: source.source, - blob_key: source.blob_key, - version: '2', - } - - if (values.featureFlags[FEATURE_FLAGS.SESSION_REPLAY_V3_INGESTION_PLAYBACK]) { - params.version = '3' - } - - if (!source.blob_key) { - throw new Error('Missing key') - } - const encodedResponse = await api.recordings.getBlobSnapshots(props.sessionRecordingId, params) - - const { transformed, untransformed } = await processEncodedResponse( - encodedResponse, - props, - values.sessionPlayerSnapshotData, - values.featureFlags - ) - data.snapshots = transformed - data.untransformed_snapshots = untransformed ?? undefined - } else { - const params = { - source: source?.source, - version: '2', - } - - if (values.featureFlags[FEATURE_FLAGS.SESSION_REPLAY_V3_INGESTION_PLAYBACK]) { - params.version = '3' - } - - const response = await api.recordings.listSnapshots(props.sessionRecordingId, params) - if (response.snapshots) { - const { transformed, untransformed } = await processEncodedResponse( - response.snapshots, - props, - values.sessionPlayerSnapshotData, - values.featureFlags - ) - data.snapshots = transformed - data.untransformed_snapshots = untransformed ?? undefined - } - - if (response.sources) { - data.sources = response.sources - } + if (source.source === SnapshotSourceType.blob && !source.blob_key) { + throw new Error('Missing key') } - if (source) { - source.loaded = true - - posthog.capture('recording_snapshot_loaded', { - source: source.source, - duration: Math.round(performance.now() - snapshotLoadingStartTime), - }) - } + const blobResponseType = source.source === SnapshotSourceType.blob || params.version === '3' + + const response = blobResponseType + ? await api.recordings.getBlobSnapshots(props.sessionRecordingId, params).catch((e) => { + if (source.source === 'realtime' && e.status === 404) { + // Realtime source is not always available so a 404 is expected + return [] + } + throw e + }) + : (await api.recordings.listSnapshots(props.sessionRecordingId, params)).snapshots ?? [] + + const { transformed, untransformed } = await processEncodedResponse( + response, + props, + values.featureFlags + ) - return data + return { snapshots: transformed, untransformed_snapshots: untransformed ?? undefined, source } }, }, ], @@ -703,6 +512,147 @@ export const sessionRecordingDataLogic = kea([ }, ], })), + listeners(({ values, actions, cache, props }) => ({ + loadSnapshots: () => { + // This kicks off the loading chain + if (!values.snapshotSourcesLoading) { + actions.loadSnapshotSources() + } + }, + maybeLoadRecordingMeta: () => { + if (!values.sessionPlayerMetaDataLoading) { + actions.loadRecordingMeta() + } + }, + loadSnapshotSources: () => { + // We only load events once we actually start loading the recording + actions.loadEvents() + }, + loadRecordingMetaSuccess: () => { + cache.metadataLoadDuration = Math.round(performance.now() - cache.metaStartTime) + actions.reportUsageIfFullyLoaded() + }, + loadRecordingMetaFailure: () => { + cache.metadataLoadDuration = Math.round(performance.now() - cache.metaStartTime) + }, + + loadSnapshotSourcesSuccess: () => { + // When we receive the list of sources we can kick off the loading chain + actions.loadNextSnapshotSource() + }, + + loadSnapshotsForSourceSuccess: ({ snapshotsForSource }) => { + const sources = values.snapshotSources + const snapshots = snapshotsForSource.snapshots + + // Cache the last response count to detect if we're getting the same data over and over + const newSnapshotsCount = snapshots.length + + if ((cache.lastSnapshotsCount ?? newSnapshotsCount) === newSnapshotsCount) { + cache.lastSnapshotsUnchangedCount = (cache.lastSnapshotsUnchangedCount ?? 0) + 1 + } else { + cache.lastSnapshotsUnchangedCount = 0 + } + cache.lastSnapshotsCount = newSnapshotsCount + + if (!snapshots.length && sources?.length === 1) { + // We got only a single source to load, loaded it successfully, but it had no snapshots. + posthog.capture('recording_snapshots_v2_empty_response', { + source: sources[0], + }) + } else if (!cache.firstPaintDuration) { + cache.firstPaintDuration = Math.round(performance.now() - cache.snapshotsStartTime) + actions.reportViewed() + } + + actions.loadNextSnapshotSource() + }, + + loadNextSnapshotSource: () => { + const nextSourceToLoad = values.snapshotSources?.find((s) => { + const sourceKey = getSourceKey(s) + return !values.snapshotsBySource?.[sourceKey] + }) + + if (nextSourceToLoad) { + return actions.loadSnapshotsForSource(nextSourceToLoad) + } + + // TODO: Move this to a one time check - only report once per recording + cache.snapshotsLoadDuration = Math.round(performance.now() - cache.snapshotsStartTime) + actions.reportUsageIfFullyLoaded() + + // If we have a realtime source, start polling it + const realTimeSource = values.snapshotSources?.find((s) => s.source === SnapshotSourceType.realtime) + if (realTimeSource) { + actions.pollRealtimeSnapshots() + } + }, + loadSnapshotsForSourceFailure: () => { + cache.snapshotsLoadDuration = Math.round(performance.now() - cache.snapshotsStartTime) + }, + pollRealtimeSnapshots: () => { + // always make sure we've cleared up the last timeout + clearTimeout(cache.realTimePollingTimeoutID) + cache.realTimePollingTimeoutID = null + + // ten is an arbitrary limit to try to avoid sending requests to our backend unnecessarily + // we could change this or add to it e.g. only poll if browser is visible to user + if ((cache.lastSnapshotsUnchangedCount ?? 0) <= 10) { + cache.realTimePollingTimeoutID = setTimeout(() => { + actions.loadSnapshotsForSource({ source: SnapshotSourceType.realtime }) + }, props.realTimePollingIntervalMilliseconds || DEFAULT_REALTIME_POLLING_MILLIS) + } + }, + loadEventsSuccess: () => { + cache.eventsLoadDuration = Math.round(performance.now() - cache.eventsStartTime) + actions.reportUsageIfFullyLoaded() + }, + loadEventsFailure: () => { + cache.eventsLoadDuration = Math.round(performance.now() - cache.eventsStartTime) + }, + reportUsageIfFullyLoaded: (_, breakpoint) => { + breakpoint() + if (values.fullyLoaded) { + eventUsageLogic.actions.reportRecording( + values.sessionPlayerData, + generateRecordingReportDurations(cache), + SessionRecordingUsageType.LOADED, + 0 + ) + // Reset cache now that final usage report has been sent + resetTimingsCache(cache) + } + }, + reportViewed: async (_, breakpoint) => { + const durations = generateRecordingReportDurations(cache) + breakpoint() + // Triggered on first paint + eventUsageLogic.actions.reportRecording( + values.sessionPlayerData, + durations, + SessionRecordingUsageType.VIEWED, + 0 + ) + await breakpoint(IS_TEST_MODE ? 1 : 10000) + eventUsageLogic.actions.reportRecording( + values.sessionPlayerData, + durations, + SessionRecordingUsageType.ANALYZED, + 10 + ) + }, + + maybePersistRecording: () => { + if (values.sessionPlayerMetaDataLoading) { + return + } + + if (values.sessionPlayerMetaData?.storage === 'object_storage') { + actions.persistRecording() + } + }, + })), selectors({ sessionPlayerData: [ (s, p) => [ @@ -739,23 +689,22 @@ export const sessionRecordingDataLogic = kea([ }), ], + snapshotsLoading: [ + (s) => [s.snapshotSourcesLoading, s.snapshotsForSourceLoading], + (snapshotSourcesLoading, snapshotsForSourceLoading): boolean => { + return snapshotSourcesLoading || snapshotsForSourceLoading + }, + ], + snapshotsLoaded: [(s) => [s.snapshotSources], (snapshotSources): boolean => !!snapshotSources], + fullyLoaded: [ - (s) => [ - s.sessionPlayerSnapshotData, - s.sessionPlayerMetaDataLoading, - s.sessionPlayerSnapshotDataLoading, - s.sessionEventsDataLoading, - ], - ( - sessionPlayerSnapshotData, - sessionPlayerMetaDataLoading, - sessionPlayerSnapshotDataLoading, - sessionEventsDataLoading - ): boolean => { + (s) => [s.snapshots, s.sessionPlayerMetaDataLoading, s.snapshotsLoading, s.sessionEventsDataLoading], + (snapshots, sessionPlayerMetaDataLoading, snapshotsLoading, sessionEventsDataLoading): boolean => { + // TODO: Do a proper check for all sources having been loaded return ( - !!sessionPlayerSnapshotData?.snapshots?.length && + !!snapshots.length && !sessionPlayerMetaDataLoading && - !sessionPlayerSnapshotDataLoading && + !snapshotsLoading && !sessionEventsDataLoading ) }, @@ -769,12 +718,12 @@ export const sessionRecordingDataLogic = kea([ ], end: [ - (s) => [s.sessionPlayerMetaData, s.sessionPlayerSnapshotData], - (meta, sessionPlayerSnapshotData): Dayjs | undefined => { + (s) => [s.sessionPlayerMetaData, s.snapshots], + (meta, snapshots): Dayjs | undefined => { // NOTE: We might end up with more snapshots than we knew about when we started the recording so we // either use the metadata end point or the last snapshot, whichever is later. const end = meta?.end_time ? dayjs(meta.end_time) : undefined - const lastEvent = sessionPlayerSnapshotData?.snapshots?.slice(-1)[0] + const lastEvent = snapshots?.slice(-1)[0] return lastEvent?.timestamp && lastEvent.timestamp > +(end ?? 0) ? dayjs(lastEvent.timestamp) : end }, @@ -788,18 +737,18 @@ export const sessionRecordingDataLogic = kea([ ], segments: [ - (s) => [s.sessionPlayerSnapshotData, s.start, s.end], - (sessionPlayerSnapshotData, start, end): RecordingSegment[] => { - return createSegments(sessionPlayerSnapshotData?.snapshots || [], start, end) + (s) => [s.snapshots, s.start, s.end], + (snapshots, start, end): RecordingSegment[] => { + return createSegments(snapshots || [], start, end) }, ], urls: [ - (s) => [s.sessionPlayerSnapshotData], - (sessionPlayerSnapshotData): { url: string; timestamp: number }[] => { + (s) => [s.snapshots], + (snapshots): { url: string; timestamp: number }[] => { return ( - sessionPlayerSnapshotData?.snapshots - ?.filter((snapshot) => getHrefFromSnapshot(snapshot)) + snapshots + .filter((snapshot) => getHrefFromSnapshot(snapshot)) .map((snapshot) => { return { url: getHrefFromSnapshot(snapshot) as string, @@ -810,10 +759,35 @@ export const sessionRecordingDataLogic = kea([ }, ], + snapshots: [ + (s) => [s.snapshotSources, s.snapshotsBySource], + (sources, snapshotsBySource): RecordingSnapshot[] => { + const allSnapshots = + sources?.flatMap((source) => { + const sourceKey = getSourceKey(source) + return snapshotsBySource?.[sourceKey]?.snapshots || [] + }) ?? [] + + return deduplicateSnapshots(allSnapshots) + }, + ], + untransformedSnapshots: [ + (s) => [s.snapshotSources, s.snapshotsBySource], + (sources, snapshotsBySource): RecordingSnapshot[] => { + const allSnapshots = + sources?.flatMap((source) => { + const sourceKey = getSourceKey(source) + return snapshotsBySource?.[sourceKey]?.untransformed_snapshots || [] + }) ?? [] + + return deduplicateSnapshots(allSnapshots) + }, + ], + snapshotsByWindowId: [ - (s) => [s.sessionPlayerSnapshotData], - (sessionPlayerSnapshotData): Record => { - return mapSnapshotsToWindowId(sessionPlayerSnapshotData?.snapshots || []) + (s) => [s.snapshots], + (snapshots): Record => { + return mapSnapshotsToWindowId(snapshots || []) }, ], @@ -877,6 +851,24 @@ export const sessionRecordingDataLogic = kea([ return Object.keys(snapshotsByWindowId) }, ], + + createExportJSON: [ + (s) => [s.sessionPlayerMetaData, s.snapshots, s.untransformedSnapshots], + ( + sessionPlayerMetaData, + snapshots, + untransformedSnapshots + ): ((exportUntransformedMobileSnapshotData: boolean) => ExportedSessionRecordingFileV2) => { + return (exportUntransformedMobileSnapshotData: boolean) => ({ + version: '2023-04-28', + data: { + id: sessionPlayerMetaData?.id ?? '', + person: sessionPlayerMetaData?.person, + snapshots: exportUntransformedMobileSnapshotData ? untransformedSnapshots : snapshots, + }, + }) + }, + ], }), afterMount(({ cache }) => { resetTimingsCache(cache) diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts index bd985c6da05d5..b10664a21bd46 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts @@ -83,8 +83,8 @@ describe('sessionRecordingPlayerLogic', () => { expect(logic.values.sessionPlayerData).toMatchSnapshot() await expectLogic(logic).toNotHaveDispatchedActions([ - sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingSnapshots, - sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingSnapshotsSuccess, + sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadSnapshotSources, + sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadSnapshotSourcesSuccess, ]) }) @@ -104,10 +104,10 @@ describe('sessionRecordingPlayerLogic', () => { await expectLogic(logic).toDispatchActions([ // once to gather sources - sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingSnapshots, + sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadSnapshotSources, // once to load source from that - sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingSnapshots, - sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingSnapshotsSuccess, + sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadSnapshotsForSource, + sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadSnapshotsForSourceSuccess, ]) expect(logic.values.sessionPlayerData).toMatchSnapshot() @@ -136,15 +136,11 @@ describe('sessionRecordingPlayerLogic', () => { logic.actions.seekToTime(50) // greater than null buffered time }) .toDispatchActions([ - sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingMeta, - sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingMetaSuccess, 'seekToTimestamp', + sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadSnapshotSourcesFailure, ]) .toFinishAllListeners() - .toDispatchActions([ - sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingSnapshots, - 'setErrorPlayerState', - ]) + .toDispatchActions(['setErrorPlayerState']) expect(logic.values).toMatchObject({ sessionPlayerData: { @@ -161,7 +157,10 @@ describe('sessionRecordingPlayerLogic', () => { logic = sessionRecordingPlayerLogic({ sessionRecordingId: '2', playerKey: 'test' }) logic.mount() - await expectLogic(logic).toDispatchActions(['initializePlayerFromStart']) + await expectLogic(logic).toDispatchActions([ + sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingMetaSuccess, + 'initializePlayerFromStart', + ]) expect(logic.cache.hasInitialized).toBeTruthy() logic.unmount() diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts index 95ed34a075539..d1bbf94be6fa6 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts @@ -15,6 +15,7 @@ import { selectors, } from 'kea' import { router } from 'kea-router' +import { subscriptions } from 'kea-subscriptions' import { delay } from 'kea-test-utils' import { now } from 'lib/dayjs' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' @@ -37,7 +38,6 @@ import { userLogic } from 'scenes/userLogic' import { AvailableFeature, RecordingSegment, SessionPlayerData, SessionPlayerState } from '~/types' -import { createExportedSessionRecording } from '../file-playback/sessionRecordingFilePlaybackSceneLogic' import type { sessionRecordingsPlaylistLogicType } from '../playlist/sessionRecordingsPlaylistLogicType' import { playerSettingsLogic } from './playerSettingsLogic' import { COMMON_REPLAYER_CONFIG, CorsPlugin } from './rrweb' @@ -102,10 +102,11 @@ export const sessionRecordingPlayerLogic = kea( sessionRecordingDataLogic(props), [ 'snapshotsLoaded', + 'snapshotsLoading', 'sessionPlayerData', 'sessionPlayerMetaData', - 'sessionPlayerSnapshotDataLoading', 'sessionPlayerMetaDataLoading', + 'createExportJSON', ], playerSettingsLogic, ['speed', 'skipInactivitySetting'], @@ -120,9 +121,9 @@ export const sessionRecordingPlayerLogic = kea( sessionRecordingDataLogic(props), [ 'maybeLoadRecordingMeta', - 'loadRecordingSnapshots', - 'loadRecordingSnapshotsSuccess', - 'loadRecordingSnapshotsFailure', + 'loadSnapshots', + 'loadSnapshotsForSourceFailure', + 'loadSnapshotSourcesFailure', 'loadRecordingMetaSuccess', 'maybePersistRecording', ], @@ -168,7 +169,7 @@ export const sessionRecordingPlayerLogic = kea( initializePlayerFromStart: true, incrementErrorCount: true, incrementWarningCount: (count: number = 1) => ({ count }), - updateFromMetadata: true, + syncSnapshotsWithPlayer: true, exportRecordingToFile: (exportUntransformedMobileData?: boolean) => ({ exportUntransformedMobileData }), deleteRecording: true, openExplorer: true, @@ -359,7 +360,7 @@ export const sessionRecordingPlayerLogic = kea( s.isScrubbing, s.isSkippingInactivity, s.snapshotsLoaded, - s.sessionPlayerSnapshotDataLoading, + s.snapshotsLoading, ], ( playingState, @@ -620,13 +621,15 @@ export const sessionRecordingPlayerLogic = kea( actions.setCurrentSegment(initialSegment) } }, - updateFromMetadata: async (_, breakpoint) => { + syncSnapshotsWithPlayer: async (_, breakpoint) => { // On loading more of the recording, trigger some state changes const currentEvents = values.player?.replayer?.service.state.context.events ?? [] const eventsToAdd = [] if (values.currentSegment?.windowId !== undefined) { // TODO: Probably need to check for de-dupes here.... + // TODO: We do some sorting and rearranging in the data logic... We may need to handle that here, replacing the + // whole events stream.... eventsToAdd.push( ...(values.sessionPlayerData.snapshotsByWindowId[values.currentSegment?.windowId] ?? []).slice( currentEvents.length @@ -649,27 +652,28 @@ export const sessionRecordingPlayerLogic = kea( }, loadRecordingMetaSuccess: () => { // As the connected data logic may be preloaded we call a shared function here and on mount - actions.updateFromMetadata() + actions.syncSnapshotsWithPlayer() if (props.autoPlay) { // Autoplay assumes we are playing immediately so lets go ahead and load more data actions.setPlay() } }, - loadRecordingSnapshotsSuccess: () => { - // As the connected data logic may be preloaded we call a shared function here and on mount - actions.updateFromMetadata() + loadSnapshotsForSourceFailure: () => { + if (Object.keys(values.sessionPlayerData.snapshotsByWindowId).length === 0) { + console.error('PostHog Recording Playback Error: No snapshots loaded') + actions.setErrorPlayerState(true) + } }, - - loadRecordingSnapshotsFailure: () => { + loadSnapshotSourcesFailure: () => { if (Object.keys(values.sessionPlayerData.snapshotsByWindowId).length === 0) { console.error('PostHog Recording Playback Error: No snapshots loaded') actions.setErrorPlayerState(true) } }, setPlay: () => { - if (!values.snapshotsLoaded && !values.sessionPlayerSnapshotDataLoading) { - actions.loadRecordingSnapshots() + if (!values.snapshotsLoaded) { + actions.loadSnapshots() } actions.stopAnimation() actions.restartIframePlayback() @@ -736,7 +740,7 @@ export const sessionRecordingPlayerLogic = kea( if (!values.snapshotsLoaded) { // We haven't started properly loading yet so nothing to do - } else if (!values.sessionPlayerSnapshotDataLoading && segment?.kind === 'buffer') { + } else if (!values.snapshotsLoading && segment?.kind === 'buffer') { // If not currently loading anything and part of the recording hasn't loaded, set error state values.player?.replayer?.pause() actions.endBuffer() @@ -937,10 +941,7 @@ export const sessionRecordingPlayerLogic = kea( await delay(delayTime) } - const payload = createExportedSessionRecording( - sessionRecordingDataLogic(props), - !!exportUntransformedMobileData - ) + const payload = values.createExportJSON(!!exportUntransformedMobileData) const recordingFile = new File( [JSON.stringify(payload, null, 2)], @@ -1002,6 +1003,18 @@ export const sessionRecordingPlayerLogic = kea( }, })), + subscriptions(({ actions }) => ({ + sessionPlayerData: (next, prev) => { + const hasSnapshotChanges = next?.snapshotsByWindowId !== prev?.snapshotsByWindowId + + // TODO: Detect if the order of the current window has changed (this would require re-initializing the player) + + if (hasSnapshotChanges) { + actions.syncSnapshotsWithPlayer() + } + }, + })), + beforeUnmount(({ values, actions, cache, props }) => { if (props.mode === SessionRecordingPlayerMode.Preview) { values.player?.replayer?.destroy() diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 80c14249936a2..0e0cd64843f27 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -802,6 +802,7 @@ export type EncodedRecordingSnapshot = { export const SnapshotSourceType = { blob: 'blob', realtime: 'realtime', + file: 'file', } as const export type SnapshotSourceType = (typeof SnapshotSourceType)[keyof typeof SnapshotSourceType] @@ -811,7 +812,12 @@ export interface SessionRecordingSnapshotSource { start_timestamp?: string end_timestamp?: string blob_key?: string - loaded: boolean +} + +export interface SessionRecordingSnapshotSourceResponse { + source: Pick + snapshots?: RecordingSnapshot[] + untransformed_snapshots?: RecordingSnapshot[] } export interface SessionRecordingSnapshotResponse {