From a6333921f10327df2a1701c4ec143d268f1a90f8 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 29 Feb 2024 11:48:57 +0100 Subject: [PATCH] feat: Refactor loading of snapshots --- .../__mocks__/recording_snapshots.ts | 4 +- .../sessionRecordingFilePlaybackLogic.ts | 46 +- .../session-recordings/file-playback/types.ts | 20 + .../player/inspector/playerInspectorLogic.ts | 12 +- .../player/sessionRecordingDataLogic.test.ts | 8 +- .../player/sessionRecordingDataLogic.ts | 401 +++++++++--------- .../player/sessionRecordingPlayerLogic.ts | 50 ++- frontend/src/types.ts | 7 +- .../session-recordings-consumer-v3.ts | 13 + .../session_recording_api.py | 11 +- 10 files changed, 284 insertions(+), 288 deletions(-) create mode 100644 frontend/src/scenes/session-recordings/file-playback/types.ts diff --git a/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts b/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts index 11b40c149c9aa..fb337c0ab5e58 100644 --- a/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts +++ b/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts @@ -1,5 +1,5 @@ import { eventWithTime } from '@rrweb/types' -import { prepareRecordingSnapshots } from 'scenes/session-recordings/player/sessionRecordingDataLogic' +import { dedupeRecordingSnapshots } from 'scenes/session-recordings/player/sessionRecordingDataLogic' import { RecordingSnapshot } from '~/types' @@ -27,7 +27,7 @@ export const convertSnapshotsResponse = ( snapshotsByWindowId: { [key: string]: eventWithTime[] }, existingSnapshots?: RecordingSnapshot[] ): RecordingSnapshot[] => { - return prepareRecordingSnapshots(convertSnapshotsByWindowId(snapshotsByWindowId), existingSnapshots) + return dedupeRecordingSnapshots([...convertSnapshotsByWindowId(snapshotsByWindowId), ...(existingSnapshots ?? [])]) } export const sortedRecordingSnapshots = (): { snapshot_data_by_window_id: Record } => { diff --git a/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackLogic.ts b/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackLogic.ts index 652053e746dda..8c3bb1173897b 100644 --- a/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackLogic.ts +++ b/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackLogic.ts @@ -1,5 +1,4 @@ import { lemonToast } from '@posthog/lemon-ui' -import { eventWithTime } from '@rrweb/types' import { BuiltLogic, connect, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' import { beforeUnload } from 'kea-router' @@ -11,51 +10,15 @@ import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { Scene } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' -import { Breadcrumb, PersonType, RecordingSnapshot, ReplayTabs, SessionRecordingType } from '~/types' +import { Breadcrumb, ReplayTabs } from '~/types' import { + dedupeRecordingSnapshots, parseEncodedSnapshots, - prepareRecordingSnapshots, sessionRecordingDataLogic, } from '../player/sessionRecordingDataLogic' -import type { sessionRecordingDataLogicType } from '../player/sessionRecordingDataLogicType' import type { sessionRecordingFilePlaybackLogicType } from './sessionRecordingFilePlaybackLogicType' - -export type ExportedSessionRecordingFileV1 = { - version: '2022-12-02' - data: { - person: PersonType | null - snapshotsByWindowId: Record - } -} - -export type ExportedSessionRecordingFileV2 = { - version: '2023-04-28' - data: { - id: SessionRecordingType['id'] - person: SessionRecordingType['person'] - snapshots: RecordingSnapshot[] - } -} - -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 || [], - }, - } -} +import { ExportedSessionRecordingFileV1, ExportedSessionRecordingFileV2 } from './types' export const parseExportedSessionRecording = (fileData: string): ExportedSessionRecordingFileV2 => { const data = JSON.parse(fileData) as ExportedSessionRecordingFileV1 | ExportedSessionRecordingFileV2 @@ -177,7 +140,7 @@ export const sessionRecordingFilePlaybackLogic = kea + } +} + +export type ExportedSessionRecordingFileV2 = { + version: '2023-04-28' + data: { + id: SessionRecordingType['id'] + person: SessionRecordingType['person'] + snapshots: RecordingSnapshot[] + } +} diff --git a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts index 43afd5ed8e7fa..c311f015f635b 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts +++ b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts @@ -154,7 +154,7 @@ export const playerInspectorLogic = kea([ [ 'sessionPlayerData', 'sessionPlayerMetaDataLoading', - 'sessionPlayerSnapshotDataLoading', + 'snapshotsLoading', 'sessionEventsData', 'sessionEventsDataLoading', 'windowIds', @@ -796,7 +796,7 @@ export const playerInspectorLogic = kea([ (s) => [ s.sessionEventsDataLoading, s.sessionPlayerMetaDataLoading, - s.sessionPlayerSnapshotDataLoading, + s.snapshotsLoading, s.sessionEventsData, s.consoleLogs, s.allPerformanceEvents, @@ -805,7 +805,7 @@ export const playerInspectorLogic = kea([ ( sessionEventsDataLoading, sessionPlayerMetaDataLoading, - sessionPlayerSnapshotDataLoading, + snapshotsLoading, events, logs, performanceEvents, @@ -813,19 +813,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 660b42bc7b82e..6d63d57b27d2d 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts @@ -6,7 +6,7 @@ import { snapshotsAsRealTimeJSONPayload, } from 'scenes/session-recordings/__mocks__/recording_snapshots' import { - prepareRecordingSnapshots, + dedupeRecordingSnapshots, sessionRecordingDataLogic, } from 'scenes/session-recordings/player/sessionRecordingDataLogic' import { teamLogic } from 'scenes/teamLogic' @@ -302,7 +302,7 @@ describe('sessionRecordingDataLogic', () => { expect(snapshotsWithDuplicates.length).toEqual(snapshots.length + 2) - expect(prepareRecordingSnapshots(snapshots)).toEqual(prepareRecordingSnapshots(snapshotsWithDuplicates)) + expect(dedupeRecordingSnapshots(snapshots)).toEqual(dedupeRecordingSnapshots(snapshotsWithDuplicates)) }) it('should cope with two not duplicate snapshots with the same timestamp and delay', () => { @@ -326,13 +326,13 @@ describe('sessionRecordingDataLogic', () => { }, ] // we call this multiple times and pass existing data in, so we need to make sure it doesn't change - expect(prepareRecordingSnapshots(verySimilarSnapshots, verySimilarSnapshots)).toEqual(verySimilarSnapshots) + expect(dedupeRecordingSnapshots(verySimilarSnapshots, verySimilarSnapshots)).toEqual(verySimilarSnapshots) }) it('should match snapshot', () => { const snapshots = convertSnapshotsByWindowId(sortedRecordingSnapshotsJson.snapshot_data_by_window_id) - expect(prepareRecordingSnapshots(snapshots)).toMatchSnapshot() + expect(dedupeRecordingSnapshots(snapshots)).toMatchSnapshot() }) }) diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts index 368a06b522851..b3e0c30ecbf38 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,25 +37,22 @@ 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' const IS_TEST_MODE = process.env.NODE_ENV === 'test' const BUFFER_MS = 60000 // +- before and after start and end of a recording to query for. const DEFAULT_REALTIME_POLLING_MILLIS = 3000 -const REALTIME_POLLING_PARAMS = { - source: SnapshotSourceType.realtime, - version: '2', -} let postHogEEModule: PostHogEE @@ -125,14 +121,10 @@ const getHrefFromSnapshot = (snapshot: RecordingSnapshot): string | undefined => return (snapshot.data as any)?.href || (snapshot.data as any)?.payload?.href } -export const prepareRecordingSnapshots = ( - newSnapshots?: RecordingSnapshot[], - existingSnapshots?: RecordingSnapshot[] -): RecordingSnapshot[] => { +export const dedupeRecordingSnapshots = (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. @@ -205,34 +197,31 @@ 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 = prepareRecordingSnapshots( - await parseEncodedSnapshots( - encodedResponse, - props.sessionRecordingId, - !!featureFlags[FEATURE_FLAGS.SESSION_REPLAY_MOBILE] - ), - existingData?.snapshots ?? [] + const transformed = await parseEncodedSnapshots( + encodedResponse, + props.sessionRecordingId, + !!featureFlags[FEATURE_FLAGS.SESSION_REPLAY_MOBILE] ) if (featureFlags[FEATURE_FLAGS.SESSION_REPLAY_EXPORT_MOBILE_DATA]) { - untransformed = prepareRecordingSnapshots( - 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), @@ -248,24 +237,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, { @@ -280,43 +264,33 @@ export const sessionRecordingDataLogic = kea([ loadRecordingMetaFailure: () => true, }, ], - snapshotsLoaded: [ - false as boolean, + snapshotsBySource: [ + null as Record | null, { - loadRecordingSnapshotsSuccess: () => true, - loadRecordingSnapshotsFailure: () => true, + loadSnapshotsForSourceSuccess: (state, { snapshotsForSource }) => { + const sourceKey = getSourceKey(snapshotsForSource.source) + return { + ...state, + [sourceKey]: snapshotsForSource, + } + }, }, ], })), 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) + loadSnapshots: () => { + // This kicks off the loading chain + if (!values.snapshotSourcesLoading) { + actions.loadSnapshotSources() } - - cache.realTimePollingTimeoutID = setTimeout(() => { - actions.pollRecordingSnapshots() - }, props.realTimePollingIntervalMilliseconds || DEFAULT_REALTIME_POLLING_MILLIS) }, maybeLoadRecordingMeta: () => { if (!values.sessionPlayerMetaDataLoading) { actions.loadRecordingMeta() } }, - loadRecordingSnapshots: () => { + loadSnapshotSources: () => { + // We only load events once we actually start loading the recording actions.loadEvents() }, loadRecordingMetaSuccess: () => { @@ -326,47 +300,76 @@ export const sessionRecordingDataLogic = kea([ 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], - }) - // If we only have a realtime source and its empty, start polling it anyway - if (sources[0].source === SnapshotSourceType.realtime) { - actions.startRealTimePolling() - } + loadSnapshotSourcesSuccess: () => { + // When we receive the list of sources we can kick off the loading chain + actions.loadNextSnapshotSource() + }, - return - } + loadSnapshotsForSourceSuccess: ({ snapshotsForSource }) => { + const sources = values.snapshotSources + const snapshots = snapshotsForSource.snapshots - if (!cache.firstPaintDuration) { - cache.firstPaintDuration = Math.round(performance.now() - cache.snapshotsStartTime) - actions.reportViewed() - } + // 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 - const nextSourceToLoad = sources?.find((s) => !s.loaded) + 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) { - actions.loadRecordingSnapshots(nextSourceToLoad) - } else { - cache.snapshotsLoadDuration = Math.round(performance.now() - cache.snapshotsStartTime) - actions.reportUsageIfFullyLoaded() + return actions.loadSnapshotsForSource(nextSourceToLoad) + } - // If we have a realtime source, start polling it - const realTimeSource = sources?.find((s) => s.source === SnapshotSourceType.realtime) - if (realTimeSource) { - actions.startRealTimePolling() - } + // 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() } }, - loadRecordingSnapshotsFailure: () => { + 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() @@ -416,7 +419,7 @@ export const sessionRecordingDataLogic = kea([ } }, })), - loaders(({ values, props, cache, actions }) => ({ + loaders(({ values, props, cache }) => ({ sessionPlayerMetaData: { loadRecordingMeta: async (_, breakpoint) => { if (!props.sessionRecordingId) { @@ -446,43 +449,29 @@ export const sessionRecordingDataLogic = kea([ } }, }, - sessionPlayerSnapshotData: [ - null as SessionPlayerSnapshotData | null, + snapshotSources: [ + null as SessionRecordingSnapshotSource[] | null, { - pollRecordingSnapshots: async (_, breakpoint: BreakPointFunction) => { - const params = { ...REALTIME_POLLING_PARAMS } - - if (values.featureFlags[FEATURE_FLAGS.SESSION_REPLAY_V3_INGESTION_PLAYBACK]) { - params.version = '3' + loadSnapshotSources: async () => { + const params = { + version: values.featureFlags[FEATURE_FLAGS.SESSION_REPLAY_V3_INGESTION_PLAYBACK] ? '3' : '2', } - 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 - ) + const sources = response.sources ?? [] - if (transformed.length === (values.sessionPlayerSnapshotData?.snapshots || []).length) { - actions.pollingLoadedNoNewData() - } - - return { - ...(values.sessionPlayerSnapshotData || {}), - snapshots: transformed, - untransformed_snapshots: untransformed ?? undefined, - } - } - return values.sessionPlayerSnapshotData + return 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() @@ -491,73 +480,25 @@ 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 + const blobResponseType = source.source === SnapshotSourceType.blob || params.version === '3' - posthog.capture('recording_snapshot_loaded', { - source: source.source, - duration: Math.round(performance.now() - snapshotLoadingStartTime), - }) - } + const response = blobResponseType + ? await api.recordings.getBlobSnapshots(props.sessionRecordingId, params) + : (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, etag } }, }, ], @@ -719,23 +660,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 ) }, @@ -749,12 +689,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 }, @@ -768,18 +708,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, @@ -790,10 +730,28 @@ 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 dedupeRecordingSnapshots(allSnapshots) + }, + // { + // resultEqualityCheck: (prev, next) => { + // // TODO: Do we do equality on length? Would simplify re-renders... + // }, + // }, + ], + snapshotsByWindowId: [ - (s) => [s.sessionPlayerSnapshotData], - (sessionPlayerSnapshotData): Record => { - return mapSnapshotsToWindowId(sessionPlayerSnapshotData?.snapshots || []) + (s) => [s.snapshots], + (snapshots): Record => { + return mapSnapshotsToWindowId(snapshots || []) }, ], @@ -857,6 +815,27 @@ export const sessionRecordingDataLogic = kea([ return Object.keys(snapshotsByWindowId) }, ], + + createExportJSON: [ + (s) => [s.snapshots, s.sessionPlayerMetaData], + ( + snapshots, + sessionPlayerMetaData + ): ((exportUntransformedMobileSnapshotData: boolean) => ExportedSessionRecordingFileV2) => { + return (exportUntransformedMobileSnapshotData: boolean) => ({ + version: '2023-04-28', + data: { + id: sessionPlayerMetaData?.id ?? '', + person: sessionPlayerMetaData?.person, + snapshots: snapshots, + // TODO: What about this?! + // snapshots: exportUntransformedMobileSnapshotData + // ? sessionPlayerSnapshotData?.untransformed_snapshots || [] + // : sessionPlayerSnapshotData?.snapshots || [], + }, + }) + }, + ], }), afterMount(({ cache }) => { resetTimingsCache(cache) diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts index ffc5b39f3d35c..036e02b5b2227 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/sessionRecordingFilePlaybackLogic' 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,8 @@ export const sessionRecordingPlayerLogic = kea( sessionRecordingDataLogic(props), [ 'maybeLoadRecordingMeta', - 'loadRecordingSnapshots', - 'loadRecordingSnapshotsSuccess', - 'loadRecordingSnapshotsFailure', + 'loadSnapshots', + 'loadSnapshotsForSourceFailure', 'loadRecordingMetaSuccess', 'maybePersistRecording', ], @@ -168,7 +168,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 +359,7 @@ export const sessionRecordingPlayerLogic = kea( s.isScrubbing, s.isSkippingInactivity, s.snapshotsLoaded, - s.sessionPlayerSnapshotDataLoading, + s.snapshotsLoading, ], ( playingState, @@ -620,13 +620,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 +651,22 @@ 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() - }, - - loadRecordingSnapshotsFailure: () => { + loadSnapshotsForSourceFailure: () => { 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() @@ -735,7 +732,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() @@ -936,10 +933,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)], @@ -1001,6 +995,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 178c0032da777..6f88753b63560 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -785,7 +785,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 { diff --git a/plugin-server/src/main/ingestion-queues/session-recording/session-recordings-consumer-v3.ts b/plugin-server/src/main/ingestion-queues/session-recording/session-recordings-consumer-v3.ts index da8a198dd0b13..8c8aaaee5823b 100644 --- a/plugin-server/src/main/ingestion-queues/session-recording/session-recordings-consumer-v3.ts +++ b/plugin-server/src/main/ingestion-queues/session-recording/session-recordings-consumer-v3.ts @@ -455,6 +455,8 @@ export class SessionRecordingIngesterV3 { return } + const ifNoneMatch = req.headers['if-none-match'] + status.info('🔁', 'session-replay-ingestion - fetching session', { projectId, sessionId }) // We don't know the partition upfront so we have to recursively check all partitions @@ -468,8 +470,19 @@ export class SessionRecordingIngesterV3 { continue } + const etag = exists.mtimeMs.toString() + + // Set a weak etag header so that subsequent requests can be short circuited + res.setHeader('ETag', `W/${etag}`) + + if (etag && ifNoneMatch === etag) { + res.sendStatus(304) + return + } + const fileStream = createReadStream(path.join(sessionDir, BUFFER_FILE_NAME)) fileStream.pipe(res) + status.info('⚡️', `Took ${Date.now() - startTime}ms to find the file`) return } diff --git a/posthog/session_recordings/session_recording_api.py b/posthog/session_recordings/session_recording_api.py index d3a40ad4b818c..bd448d6d3ced7 100644 --- a/posthog/session_recordings/session_recording_api.py +++ b/posthog/session_recordings/session_recording_api.py @@ -417,12 +417,21 @@ def snapshots(self, request: request.Request, **kwargs): with requests.get( url=f"{settings.RECORDINGS_INGESTER_URL}/api/projects/{self.team.pk}/session_recordings/{str(recording.session_id)}/snapshots", stream=True, + headers={ + "if-none-match": request.headers.get("If-None-Match", ""), + }, ) as r: if r.status_code == 404: - return Response({"snapshots": []}) + raise exceptions.NotFound("Realtime snapshots not found") + + if r.status_code == 304: + response = HttpResponse(status=304) + response["ETag"] = r.headers.get("ETag") + return response response = HttpResponse(content=r.raw, content_type="application/json") response["Content-Disposition"] = "inline" + response["ETag"] = r.headers.get("ETag") return response else: snapshots = get_realtime_snapshots(team_id=self.team.pk, session_id=str(recording.session_id)) or []