diff --git a/ee/frontend/mobile-replay/__mocks__/encoded-snapshot-data.ts b/ee/frontend/mobile-replay/__mocks__/encoded-snapshot-data.ts new file mode 100644 index 00000000000..ceb176d49ce --- /dev/null +++ b/ee/frontend/mobile-replay/__mocks__/encoded-snapshot-data.ts @@ -0,0 +1,6 @@ +export const encodedWebSnapshotData: string[] = [ + // first item could be a network event or something else + '{"windowId":"0191C63B-03FF-73B5-96BE-40BE2761621C","data":{"payload":{"requests":[{"duration":28,"entryType":"resource","initiatorType":"fetch","method":"GET","name":"https://1.bp.blogspot.com/-hkNkoCjc5UA/T4JTlCjhhfI/AAAAAAAAB98/XxQwZ-QPkI8/s1600/Free+Google+Wallpapers+3.jpg","responseStatus":200,"timestamp":1725369200216,"transferSize":82375}]},"plugin":"rrweb/network@1"},"timestamp":1725369200216,"type":6,"seen":8833798676917222}', + '{"windowId":"0191C63B-03FF-73B5-96BE-40BE2761621C","data":{"height":852,"width":393},"timestamp":1725607643113,"type":4,"seen":4930607506458337}', + '{"windowId":"0191C63B-03FF-73B5-96BE-40BE2761621C","data":{"initialOffset":{"left":0,"top":0},"wireframes":[{"base64":"data:image/jpeg;base64,/9j/4AAQSkZJR","height":852,"id":4324378400,"type":"screenshot","width":393,"x":0,"y":0}]},"timestamp":1725607643113,"type":2,"seen":2118469619185818}', +] diff --git a/ee/frontend/mobile-replay/__snapshots__/parsing.test.ts.snap b/ee/frontend/mobile-replay/__snapshots__/parsing.test.ts.snap new file mode 100644 index 00000000000..c916dd21d54 --- /dev/null +++ b/ee/frontend/mobile-replay/__snapshots__/parsing.test.ts.snap @@ -0,0 +1,339 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`snapshot parsing handles mobile data with no meta event 1`] = ` +[ + { + "data": { + "payload": { + "requests": [ + { + "duration": 28, + "entryType": "resource", + "initiatorType": "fetch", + "method": "GET", + "name": "https://1.bp.blogspot.com/-hkNkoCjc5UA/T4JTlCjhhfI/AAAAAAAAB98/XxQwZ-QPkI8/s1600/Free+Google+Wallpapers+3.jpg", + "responseStatus": 200, + "timestamp": 1725369200216, + "transferSize": 82375, + }, + ], + }, + "plugin": "rrweb/network@1", + }, + "seen": 8833798676917222, + "timestamp": 1725369200216, + "type": 6, + "windowId": "0191C63B-03FF-73B5-96BE-40BE2761621C", + }, + { + "data": { + "height": 852, + "href": "", + "width": 393, + }, + "timestamp": 1725607643113, + "type": 4, + "windowId": "0191C63B-03FF-73B5-96BE-40BE2761621C", + }, + { + "data": { + "initialOffset": { + "left": 0, + "top": 0, + }, + "node": { + "childNodes": [ + { + "id": 2, + "name": "html", + "publicId": "", + "systemId": "", + "type": 1, + }, + { + "attributes": { + "data-rrweb-id": 3, + "style": "height: 100vh; width: 100vw;", + }, + "childNodes": [ + { + "attributes": { + "data-rrweb-id": 4, + }, + "childNodes": [ + { + "attributes": { + "type": "text/css", + }, + "childNodes": [ + { + "id": 101, + "textContent": " + body { + margin: unset; + } + input, button, select, textarea { + font: inherit; + margin: 0; + padding: 0; + border: 0; + outline: 0; + background: transparent; + padding-block: 0 !important; + } + .input:focus { + outline: none; + } + img { + border-style: none; + } + ", + "type": 3, + }, + ], + "id": 100, + "tagName": "style", + "type": 2, + }, + ], + "id": 4, + "tagName": "head", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 5, + "style": "height: 100vh; width: 100vw;", + }, + "childNodes": [ + { + "attributes": { + "data-rrweb-id": 4324378400, + "height": 852, + "src": "data:image/jpeg;base64,/9j/4AAQSkZJR", + "style": "width: 393px;height: 852px;position: fixed;left: 0px;top: 0px;", + "width": 393, + }, + "childNodes": [], + "id": 4324378400, + "tagName": "img", + "type": 2, + }, + { + "attributes": { + "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", + "data-rrweb-id": 9, + }, + "childNodes": [], + "id": 9, + "tagName": "div", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 7, + }, + "childNodes": [], + "id": 7, + "tagName": "div", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 11, + }, + "childNodes": [], + "id": 11, + "tagName": "div", + "type": 2, + }, + ], + "id": 5, + "tagName": "body", + "type": 2, + }, + ], + "id": 3, + "tagName": "html", + "type": 2, + }, + ], + "id": 1, + "type": 0, + }, + }, + "timestamp": 1725607643113, + "type": 2, + "windowId": "0191C63B-03FF-73B5-96BE-40BE2761621C", + }, +] +`; + +exports[`snapshot parsing handles normal mobile data 1`] = ` +[ + { + "data": { + "payload": { + "requests": [ + { + "duration": 28, + "entryType": "resource", + "initiatorType": "fetch", + "method": "GET", + "name": "https://1.bp.blogspot.com/-hkNkoCjc5UA/T4JTlCjhhfI/AAAAAAAAB98/XxQwZ-QPkI8/s1600/Free+Google+Wallpapers+3.jpg", + "responseStatus": 200, + "timestamp": 1725369200216, + "transferSize": 82375, + }, + ], + }, + "plugin": "rrweb/network@1", + }, + "seen": 8833798676917222, + "timestamp": 1725369200216, + "type": 6, + "windowId": "0191C63B-03FF-73B5-96BE-40BE2761621C", + }, + { + "data": { + "height": 852, + "href": "", + "width": 393, + }, + "timestamp": 1725607643113, + "type": 4, + "windowId": "0191C63B-03FF-73B5-96BE-40BE2761621C", + }, + { + "data": { + "initialOffset": { + "left": 0, + "top": 0, + }, + "node": { + "childNodes": [ + { + "id": 2, + "name": "html", + "publicId": "", + "systemId": "", + "type": 1, + }, + { + "attributes": { + "data-rrweb-id": 3, + "style": "height: 100vh; width: 100vw;", + }, + "childNodes": [ + { + "attributes": { + "data-rrweb-id": 4, + }, + "childNodes": [ + { + "attributes": { + "type": "text/css", + }, + "childNodes": [ + { + "id": 101, + "textContent": " + body { + margin: unset; + } + input, button, select, textarea { + font: inherit; + margin: 0; + padding: 0; + border: 0; + outline: 0; + background: transparent; + padding-block: 0 !important; + } + .input:focus { + outline: none; + } + img { + border-style: none; + } + ", + "type": 3, + }, + ], + "id": 100, + "tagName": "style", + "type": 2, + }, + ], + "id": 4, + "tagName": "head", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 5, + "style": "height: 100vh; width: 100vw;", + }, + "childNodes": [ + { + "attributes": { + "data-rrweb-id": 4324378400, + "height": 852, + "src": "data:image/jpeg;base64,/9j/4AAQSkZJR", + "style": "width: 393px;height: 852px;position: fixed;left: 0px;top: 0px;", + "width": 393, + }, + "childNodes": [], + "id": 4324378400, + "tagName": "img", + "type": 2, + }, + { + "attributes": { + "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", + "data-rrweb-id": 9, + }, + "childNodes": [], + "id": 9, + "tagName": "div", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 7, + }, + "childNodes": [], + "id": 7, + "tagName": "div", + "type": 2, + }, + { + "attributes": { + "data-rrweb-id": 11, + }, + "childNodes": [], + "id": 11, + "tagName": "div", + "type": 2, + }, + ], + "id": 5, + "tagName": "body", + "type": 2, + }, + ], + "id": 3, + "tagName": "html", + "type": 2, + }, + ], + "id": 1, + "type": 0, + }, + }, + "timestamp": 1725607643113, + "type": 2, + "windowId": "0191C63B-03FF-73B5-96BE-40BE2761621C", + }, +] +`; diff --git a/ee/frontend/mobile-replay/parsing.test.ts b/ee/frontend/mobile-replay/parsing.test.ts new file mode 100644 index 00000000000..5d913b41178 --- /dev/null +++ b/ee/frontend/mobile-replay/parsing.test.ts @@ -0,0 +1,20 @@ +import { parseEncodedSnapshots } from 'scenes/session-recordings/player/sessionRecordingDataLogic' + +import { encodedWebSnapshotData } from './__mocks__/encoded-snapshot-data' + +describe('snapshot parsing', () => { + const sessionId = '12345' + const numberOfParsedLinesInData = 3 + + it('handles normal mobile data', async () => { + const parsed = await parseEncodedSnapshots(encodedWebSnapshotData, sessionId, true) + expect(parsed.length).toEqual(numberOfParsedLinesInData) + expect(parsed).toMatchSnapshot() + }) + it('handles mobile data with no meta event', async () => { + const withoutMeta = [encodedWebSnapshotData[0], encodedWebSnapshotData[2]] + const parsed = await parseEncodedSnapshots(withoutMeta, sessionId, true) + expect(parsed.length).toEqual(numberOfParsedLinesInData) + expect(parsed).toMatchSnapshot() + }) +}) diff --git a/frontend/src/scenes/session-recordings/player/__mocks__/encoded-snapshot-data.ts b/frontend/src/scenes/session-recordings/player/__mocks__/encoded-snapshot-data.ts new file mode 100644 index 00000000000..f522be7364d --- /dev/null +++ b/frontend/src/scenes/session-recordings/player/__mocks__/encoded-snapshot-data.ts @@ -0,0 +1,4 @@ +export const encodedWebSnapshotData: string[] = [ + '{"window_id":"0191c366-dd75-708c-bb41-c0d5bd2bb0dc","data":[{"type":4,"data":{"href":"http://localhost:8000/project/1","width":719,"height":914},"timestamp":1725560859629},{"type":3,"data":{"source":2,"type":0,"id":320,"x":21.41059112548828,"y":28.776042938232422},"timestamp":1725560861395},{"type":3,"data":{"source":2,"type":2,"id":320,"x":21,"y":28,"pointerType":0},"timestamp":1725560861398},{"type":3,"data":{"source":0,"texts":[],"attributes":[{"id":59,"attributes":{"class":"Navbar3000"}},{"id":313,"attributes":{"class":"Navbar3000__overlay"}}],"removes":[],"adds":[]},"timestamp":1725560861402}]}', + '{"window_id":"0191c366-dd75-708c-bb41-c0d5bd2bb0dc","data":[{"type":4,"data":{"href":"http://localhost:8000/project/1","width":719,"height":914},"timestamp":1725560859629},{"type":3,"data":{"source":2,"type":0,"id":320,"x":21.41059112548828,"y":28.776042938232422},"timestamp":1725560861395},{"type":3,"data":{"source":2,"type":2,"id":320,"x":21,"y":28,"pointerType":0},"timestamp":1725560861398},{"type":3,"data":{"source":0,"texts":[],"attributes":[{"id":59,"attributes":{"class":"Navbar3000"}},{"id":313,"attributes":{"class":"Navbar3000__overlay"}}],"removes":[],"adds":[]},"timestamp":1725560861402}]}', +] diff --git a/frontend/src/scenes/session-recordings/player/__snapshots__/sessionRecordingDataLogic.test.ts.snap b/frontend/src/scenes/session-recordings/player/__snapshots__/sessionRecordingDataLogic.test.ts.snap index 6fda01f3643..1dfae7ab19d 100644 --- a/frontend/src/scenes/session-recordings/player/__snapshots__/sessionRecordingDataLogic.test.ts.snap +++ b/frontend/src/scenes/session-recordings/player/__snapshots__/sessionRecordingDataLogic.test.ts.snap @@ -2078,3 +2078,193 @@ exports[`sessionRecordingDataLogic deduplicateSnapshots should match snapshot 1` }, ] `; + +exports[`sessionRecordingDataLogic snapshot parsing handles data with unparseable lines 1`] = ` +[ + { + "data": { + "height": 914, + "href": "http://localhost:8000/project/1", + "width": 719, + }, + "timestamp": 1725560859629, + "type": 4, + "windowId": "0191c366-dd75-708c-bb41-c0d5bd2bb0dc", + }, + { + "data": { + "id": 320, + "source": 2, + "type": 0, + "x": 21.41059112548828, + "y": 28.776042938232422, + }, + "timestamp": 1725560861395, + "type": 3, + "windowId": "0191c366-dd75-708c-bb41-c0d5bd2bb0dc", + }, + { + "data": { + "id": 320, + "pointerType": 0, + "source": 2, + "type": 2, + "x": 21, + "y": 28, + }, + "timestamp": 1725560861398, + "type": 3, + "windowId": "0191c366-dd75-708c-bb41-c0d5bd2bb0dc", + }, + { + "data": { + "adds": [], + "attributes": [ + { + "attributes": { + "class": "Navbar3000", + }, + "id": 59, + }, + { + "attributes": { + "class": "Navbar3000__overlay", + }, + "id": 313, + }, + ], + "removes": [], + "source": 0, + "texts": [], + }, + "timestamp": 1725560861402, + "type": 3, + "windowId": "0191c366-dd75-708c-bb41-c0d5bd2bb0dc", + }, +] +`; + +exports[`sessionRecordingDataLogic snapshot parsing handles normal web data 1`] = ` +[ + { + "data": { + "height": 914, + "href": "http://localhost:8000/project/1", + "width": 719, + }, + "timestamp": 1725560859629, + "type": 4, + "windowId": "0191c366-dd75-708c-bb41-c0d5bd2bb0dc", + }, + { + "data": { + "id": 320, + "source": 2, + "type": 0, + "x": 21.41059112548828, + "y": 28.776042938232422, + }, + "timestamp": 1725560861395, + "type": 3, + "windowId": "0191c366-dd75-708c-bb41-c0d5bd2bb0dc", + }, + { + "data": { + "id": 320, + "pointerType": 0, + "source": 2, + "type": 2, + "x": 21, + "y": 28, + }, + "timestamp": 1725560861398, + "type": 3, + "windowId": "0191c366-dd75-708c-bb41-c0d5bd2bb0dc", + }, + { + "data": { + "adds": [], + "attributes": [ + { + "attributes": { + "class": "Navbar3000", + }, + "id": 59, + }, + { + "attributes": { + "class": "Navbar3000__overlay", + }, + "id": 313, + }, + ], + "removes": [], + "source": 0, + "texts": [], + }, + "timestamp": 1725560861402, + "type": 3, + "windowId": "0191c366-dd75-708c-bb41-c0d5bd2bb0dc", + }, + { + "data": { + "height": 914, + "href": "http://localhost:8000/project/1", + "width": 719, + }, + "timestamp": 1725560859629, + "type": 4, + "windowId": "0191c366-dd75-708c-bb41-c0d5bd2bb0dc", + }, + { + "data": { + "id": 320, + "source": 2, + "type": 0, + "x": 21.41059112548828, + "y": 28.776042938232422, + }, + "timestamp": 1725560861395, + "type": 3, + "windowId": "0191c366-dd75-708c-bb41-c0d5bd2bb0dc", + }, + { + "data": { + "id": 320, + "pointerType": 0, + "source": 2, + "type": 2, + "x": 21, + "y": 28, + }, + "timestamp": 1725560861398, + "type": 3, + "windowId": "0191c366-dd75-708c-bb41-c0d5bd2bb0dc", + }, + { + "data": { + "adds": [], + "attributes": [ + { + "attributes": { + "class": "Navbar3000", + }, + "id": 59, + }, + { + "attributes": { + "class": "Navbar3000__overlay", + }, + "id": 313, + }, + ], + "removes": [], + "source": 0, + "texts": [], + }, + "timestamp": 1725560861402, + "type": 3, + "windowId": "0191c366-dd75-708c-bb41-c0d5bd2bb0dc", + }, +] +`; diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts index 13279a313cf..7a00e7b49b3 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts @@ -2,8 +2,10 @@ import { expectLogic } from 'kea-test-utils' import { api, MOCK_TEAM_ID } from 'lib/api.mock' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { convertSnapshotsByWindowId } from 'scenes/session-recordings/__mocks__/recording_snapshots' +import { encodedWebSnapshotData } from 'scenes/session-recordings/player/__mocks__/encoded-snapshot-data' import { deduplicateSnapshots, + parseEncodedSnapshots, sessionRecordingDataLogic, } from 'scenes/session-recordings/player/sessionRecordingDataLogic' import { teamLogic } from 'scenes/teamLogic' @@ -404,4 +406,30 @@ describe('sessionRecordingDataLogic', () => { ]) }) }) + + describe('snapshot parsing', () => { + const sessionId = '12345' + const numberOfParsedLinesInData = 8 + it('handles normal web data', async () => { + const parsed = await parseEncodedSnapshots(encodedWebSnapshotData, sessionId, false) + expect(parsed.length).toEqual(numberOfParsedLinesInData) + expect(parsed).toMatchSnapshot() + }) + + it('handles data with unparseable lines', async () => { + const parsed = await parseEncodedSnapshots( + encodedWebSnapshotData.map((line, index) => { + return index == 0 ? line.substring(0, line.length / 2) : line + }), + sessionId, + false + ) + + // unparseable lines are not returned + expect(encodedWebSnapshotData.length).toEqual(2) + expect(parsed.length).toEqual(numberOfParsedLinesInData / 2) + + expect(parsed).toMatchSnapshot() + }) + }) }) diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts index d5035a871c4..cd503f914a2 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts @@ -1,5 +1,5 @@ import posthogEE from '@posthog/ee/exports' -import { customEvent, EventType, eventWithTime } from '@rrweb/types' +import { customEvent, EventType, eventWithTime, fullSnapshotEvent } from '@rrweb/types' import { captureException, captureMessage } from '@sentry/react' import { actions, @@ -21,6 +21,7 @@ import api from 'lib/api' import { FEATURE_FLAGS } from 'lib/constants' import { Dayjs, dayjs } from 'lib/dayjs' import { featureFlagLogic, FeatureFlagsSet } from 'lib/logic/featureFlagLogic' +import { isObject } from 'lib/utils' import { chainToElements } from 'lib/utils/elements-chain' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import posthog from 'posthog-js' @@ -63,6 +64,54 @@ function isRecordingSnapshot(x: unknown): x is RecordingSnapshot { return typeof x === 'object' && x !== null && 'type' in x && 'timestamp' in x } +/* + there was a bug in mobile SDK that didn't consistently send a meta event with a full snapshot. + rrweb player hides itself until it has seen the meta event 🤷 + but we can patch a meta event into the recording data to make it work +*/ +function patchMetaEventIntoMobileData(parsedLines: RecordingSnapshot[]): RecordingSnapshot[] { + let fullSnapshotIndex: number = -1 + let metaIndex: number = -1 + try { + fullSnapshotIndex = parsedLines.findIndex((l) => l.type === EventType.FullSnapshot) + metaIndex = parsedLines.findIndex((l) => l.type === EventType.Meta) + + // then we need to patch the meta event into the snapshot data + if (fullSnapshotIndex > -1 && metaIndex === -1) { + const fullSnapshot = parsedLines[fullSnapshotIndex] as RecordingSnapshot & fullSnapshotEvent & eventWithTime + // a full snapshot (particularly from the mobile transformer) has a relatively fixed structure, + // but the types exposed by rrweb don't quite cover what we need , so... + const mainNode = fullSnapshot.data.node as any + const targetNode = mainNode.childNodes[1].childNodes[1].childNodes[0] + const { width, height } = targetNode.attributes + const metaEvent: RecordingSnapshot = { + windowId: fullSnapshot.windowId, + type: EventType.Meta, + timestamp: fullSnapshot.timestamp, + data: { + href: getHrefFromSnapshot(fullSnapshot) || '', + width, + height, + }, + } + parsedLines.splice(fullSnapshotIndex, 0, metaEvent) + } + } catch (e) { + captureException(e, { + tags: { feature: 'session-recording-missing-meta-patching' }, + extra: { fullSnapshotIndex, metaIndex }, + }) + } + + return parsedLines +} + +function hasAnyWireframes(snapshotData: Record[]): boolean { + return snapshotData.some((d) => { + return isObject(d.data) && 'wireframes' in d.data + }) +} + export const parseEncodedSnapshots = async ( items: (RecordingSnapshot | EncodedRecordingSnapshot | string)[], sessionId: string, @@ -72,9 +121,12 @@ export const parseEncodedSnapshots = async ( if (!postHogEEModule) { postHogEEModule = await posthogEE() } + const lineCount = items.length const unparseableLines: string[] = [] - const parsedLines = items.flatMap((l) => { + let isMobileSnapshots = false + + const parsedLines: RecordingSnapshot[] = items.flatMap((l) => { if (!l) { // blob files have an empty line at the end return [] @@ -83,6 +135,10 @@ export const parseEncodedSnapshots = async ( const snapshotLine = typeof l === 'string' ? (JSON.parse(l) as EncodedRecordingSnapshot) : l const snapshotData = isRecordingSnapshot(snapshotLine) ? [snapshotLine] : snapshotLine['data'] + if (!isMobileSnapshots) { + isMobileSnapshots = hasAnyWireframes(snapshotData) + } + return snapshotData.map((d: unknown) => { const snap = withMobileTransformer ? postHogEEModule?.mobileReplay?.transformEventToWeb(d) || (d as eventWithTime) @@ -118,11 +174,13 @@ export const parseEncodedSnapshots = async ( }) } - return parsedLines + return isMobileSnapshots ? patchMetaEventIntoMobileData(parsedLines) : parsedLines } -const getHrefFromSnapshot = (snapshot: RecordingSnapshot): string | undefined => { - return (snapshot.data as any)?.href || (snapshot.data as any)?.payload?.href +const getHrefFromSnapshot = (snapshot: unknown): string | undefined => { + return isObject(snapshot) && 'data' in snapshot + ? (snapshot.data as any)?.href || (snapshot.data as any)?.payload?.href + : undefined } /* @@ -500,12 +558,12 @@ export const sessionRecordingDataLogic = kea([ try { const query: HogQLQuery = { kind: NodeKind.HogQLQuery, - query: hogql`SELECT properties, uuid - FROM events - WHERE timestamp > ${dayjs(event.timestamp).subtract(1000, 'ms')} - AND timestamp < ${dayjs(event.timestamp).add(1000, 'ms')} - AND event = ${event.event} - AND uuid = ${event.id}`, + query: hogql`SELECT properties, uuid + FROM events + WHERE timestamp > ${dayjs(event.timestamp).subtract(1000, 'ms')} + AND timestamp < ${dayjs(event.timestamp).add(1000, 'ms')} + AND event = ${event.event} + AND uuid = ${event.id}`, } const response = await api.query(query) if (response.error) {