Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: fixed window playback #23126

Merged
merged 10 commits into from
Jun 21, 2024
57 changes: 40 additions & 17 deletions frontend/src/scenes/session-recordings/player/PlayerMeta.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import './PlayerMeta.scss'

import { Link } from '@posthog/lemon-ui'
import { LemonSelect, LemonSelectOption, Link } from '@posthog/lemon-ui'
import clsx from 'clsx'
import { useValues } from 'kea'
import { useActions, useValues } from 'kea'
import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver'
import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton'
Expand Down Expand Up @@ -61,8 +61,18 @@ function URLOrScreen({ lastUrl }: { lastUrl: string | undefined }): JSX.Element
export function PlayerMeta(): JSX.Element {
const { logicProps, isFullScreen } = useValues(sessionRecordingPlayerLogic)

const { resolution, lastPageviewEvent, lastUrl, scale, currentWindowIndex, sessionPlayerMetaDataLoading } =
useValues(playerMetaLogic(logicProps))
const {
windowIds,
trackedWindow,
resolution,
lastPageviewEvent,
lastUrl,
scale,
currentWindowIndex,
sessionPlayerMetaDataLoading,
} = useValues(playerMetaLogic(logicProps))

const { setTrackedWindow } = useActions(playerMetaLogic(logicProps))

const { ref, size } = useResizeBreakpoints({
0: 'compact',
Expand Down Expand Up @@ -120,6 +130,25 @@ export function PlayerMeta(): JSX.Element {
)
}

const windowOptions: LemonSelectOption<string | null>[] = [
{
label: <IconWindow value={currentWindowIndex} className="text-muted-alt" />,
value: null,
labelInMenu: <>Follow the user</>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looked during the gif like this option also had an IconWindow in it. That somewhat confused me but I'm guessing it has changed

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, i realised it didn't make sense :)

},
]
windowIds.forEach((windowId, index) => {
windowOptions.push({
label: <IconWindow value={index + 1} className="text-muted-alt" />,
labelInMenu: (
<div className="flex flex-row gap-2 space-between items-center">
Follow window: <IconWindow value={index + 1} className="text-muted-alt" />
</div>
),
value: windowId,
})
})

return (
<DraggableToNotebook href={urls.replaySingle(logicProps.sessionRecordingId)} onlyWithModifierKey>
<div
Expand All @@ -138,19 +167,13 @@ export function PlayerMeta(): JSX.Element {
<LemonSkeleton className="w-1/3 h-4 my-1" />
) : (
<>
<Tooltip
title={
<>
Window {currentWindowIndex + 1}.
<br />
Each recording window translates to a distinct browser tab or window.
</>
}
>
<span>
<IconWindow value={currentWindowIndex + 1} className="text-muted-alt" />
</span>
</Tooltip>
<LemonSelect
size="xsmall"
options={windowOptions}
value={trackedWindow}
disabledReason={windowIds.length <= 1 ? "There's only one window" : undefined}
onSelect={(value) => setTrackedWindow(value)}
/>

<URLOrScreen lastUrl={lastUrl} />
{lastPageviewEvent?.properties?.['$screen_name'] && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const playerMetaLogic = kea<playerMetaLogicType>([
'sessionPlayerMetaData',
'sessionPlayerMetaDataLoading',
'windowIds',
'trackedWindow',
],
sessionRecordingPlayerLogic(props),
['scale', 'currentTimestamp', 'currentPlayerTime', 'currentSegment'],
Expand All @@ -34,7 +35,7 @@ export const playerMetaLogic = kea<playerMetaLogicType>([
],
actions: [
sessionRecordingDataLogic(props),
['loadRecordingMetaSuccess'],
['loadRecordingMetaSuccess', 'setTrackedWindow'],
sessionRecordingsListPropertiesLogic,
['maybeLoadPropertiesForSessions'],
],
Expand Down Expand Up @@ -89,7 +90,7 @@ export const playerMetaLogic = kea<playerMetaLogicType>([
const index = windowIds.findIndex((windowId) =>
currentSegment?.windowId ? windowId === currentSegment?.windowId : -1
)
return index === -1 ? 0 : index
return index === -1 ? 1 : index + 1
},
],
lastUrl: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,15 @@ export const sessionRecordingDataLogic = kea<sessionRecordingDataLogicType>([
persistRecording: true,
maybePersistRecording: true,
pollRealtimeSnapshots: true,
setTrackedWindow: (windowId: string | null) => ({ windowId }),
}),
reducers(() => ({
trackedWindow: [
null as string | null,
{
setTrackedWindow: (_, { windowId }) => windowId,
},
],
filters: [
{} as Partial<RecordingEventsFilters>,
{
Expand Down Expand Up @@ -741,9 +748,9 @@ export const sessionRecordingDataLogic = kea<sessionRecordingDataLogicType>([
],

segments: [
(s) => [s.snapshots, s.start, s.end],
(snapshots, start, end): RecordingSegment[] => {
return createSegments(snapshots || [], start, end)
(s) => [s.snapshots, s.start, s.end, s.trackedWindow],
(snapshots, start, end, trackedWindow): RecordingSegment[] => {
return createSegments(snapshots || [], start, end, trackedWindow)
},
],

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`segmenter can segment to include only one window 1`] = `
[
{
"durationMs": 0,
"endTimestamp": 1672531200010,
"isActive": false,
"kind": "gap",
"startTimestamp": 1672531200010,
"windowId": "C",
},
{
"durationMs": 90,
"endTimestamp": 1672531200100,
"isActive": false,
"kind": "gap",
"startTimestamp": 1672531200010,
"windowId": undefined,
},
{
"durationMs": 60,
"endTimestamp": 1672531200160,
"isActive": false,
"kind": "window",
"startTimestamp": 1672531200100,
"windowId": "C",
},
{
"durationMs": 40,
"endTimestamp": 1672531200200,
"isActive": false,
"kind": "gap",
"startTimestamp": 1672531200160,
"windowId": "B",
},
{
"durationMs": 0,
"endTimestamp": 1672531200200,
"isActive": false,
"kind": "gap",
"startTimestamp": 1672531200200,
"windowId": "C",
},
{
"durationMs": 9,
"endTimestamp": 1672531200210,
"isActive": false,
"kind": "buffer",
"startTimestamp": 1672531200201,
},
]
`;

exports[`segmenter ends a segment if it is the last window 1`] = `
[
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,23 @@ describe('segmenter', () => {

expect(segments).toMatchSnapshot()
})

it('can segment to include only one window', () => {
// NOTE: It is important that the segments are "inclusive" of the start and end timestamps as the player logic
// depends on this to choose which segment should be played next
const start = dayjs('2023-01-01T00:00:00.000Z')
const end = start.add(210, 'milliseconds')

const snapshots: RecordingSnapshot[] = [
{ windowId: 'A', timestamp: start.valueOf() + 10, type: 3, data: {} } as any,
{ windowId: 'C', timestamp: start.valueOf() + 100, type: 3, data: {} } as any,
{ windowId: 'B', timestamp: start.valueOf() + 120, type: 3, data: {} } as any,
{ windowId: 'C', timestamp: start.valueOf() + 160, type: 3, data: {} } as any,
{ windowId: 'B', timestamp: start.valueOf() + 200, type: 3, data: {} } as any,
]

const segments = createSegments(snapshots, start, end, 'C')

expect(segments).toMatchSnapshot()
})
})
28 changes: 25 additions & 3 deletions frontend/src/scenes/session-recordings/player/utils/segmenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ export const mapSnapshotsToWindowId = (snapshots: RecordingSnapshot[]): Record<s
return snapshotsByWindowId
}

export const createSegments = (snapshots: RecordingSnapshot[], start?: Dayjs, end?: Dayjs): RecordingSegment[] => {
export const createSegments = (
snapshots: RecordingSnapshot[],
start?: Dayjs,
end?: Dayjs,
trackedWindow?: string | null
): RecordingSegment[] => {
let segments: RecordingSegment[] = []
let activeSegment!: Partial<RecordingSegment>
let lastActiveEventTimestamp = 0
Expand Down Expand Up @@ -105,7 +110,9 @@ export const createSegments = (snapshots: RecordingSnapshot[], start?: Dayjs, en
}

// We've built the segments, but this might not account for "gaps" in them
// To account for this we build up a new segment list filling in gaps with the whatever window is available (preferably the previous one)
// To account for this we build up a new segment list filling in gaps with
// either the tracked window if the viewing window is fixed
// or whatever window is available (preferably the previous one)
// Or a "null" window if there is nothing (like if they navigated away to a different site)

const findWindowIdForTimestamp = (timestamp: number, preferredWindowId?: string): string | undefined => {
Expand All @@ -125,6 +132,21 @@ export const createSegments = (snapshots: RecordingSnapshot[], start?: Dayjs, en
}
}

if (trackedWindow) {
segments = segments.map((segment) => {
if (segment.windowId === trackedWindow) {
return segment
}
// every window segment that isn't the tracked window is a gap
return {
...segment,
windowId: trackedWindow,
isActive: false,
kind: 'gap',
}
})
}

segments = segments.reduce((acc, segment, index) => {
const previousSegment = segments[index - 1]
const list = [...acc]
Expand All @@ -134,7 +156,7 @@ export const createSegments = (snapshots: RecordingSnapshot[], start?: Dayjs, en
const startTimestamp = previousSegment.endTimestamp
const endTimestamp = segment.startTimestamp
// Offset the window ID check so we look for a subsequent segment
const windowId = findWindowIdForTimestamp(startTimestamp + 1, previousSegment.windowId)
const windowId = findWindowIdForTimestamp(startTimestamp + 1, trackedWindow || previousSegment.windowId)
const gapSegment: Partial<RecordingSegment> = {
kind: 'gap',
startTimestamp,
Expand Down
Loading