mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
feat(replay): add full snapshot in mobile recording if missing (#39715)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: pauldambra <paul@posthog.com>
This commit is contained in:
@@ -1,174 +1,5 @@
|
||||
// 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`] = `
|
||||
[
|
||||
{
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { parseEncodedSnapshots } from 'scenes/session-recordings/player/snapshot-processing/process-all-snapshots'
|
||||
import {
|
||||
parseEncodedSnapshots,
|
||||
processAllSnapshots,
|
||||
} from 'scenes/session-recordings/player/snapshot-processing/process-all-snapshots'
|
||||
import { keyForSource } from 'scenes/session-recordings/player/snapshot-processing/source-key'
|
||||
|
||||
import { encodedWebSnapshotData } from './__mocks__/encoded-snapshot-data'
|
||||
|
||||
@@ -14,7 +18,22 @@ describe('snapshot parsing', () => {
|
||||
it('handles mobile data with no meta event', async () => {
|
||||
const withoutMeta = [encodedWebSnapshotData[0], encodedWebSnapshotData[2]]
|
||||
const parsed = await parseEncodedSnapshots(withoutMeta, sessionId)
|
||||
expect(parsed.length).toEqual(numberOfParsedLinesInData)
|
||||
expect(parsed).toMatchSnapshot()
|
||||
|
||||
const source = { source: 'blob_v2', blob_key: '0' } as any
|
||||
const results = processAllSnapshots(
|
||||
[source],
|
||||
{ [keyForSource(source)]: { snapshots: parsed } } as any,
|
||||
{},
|
||||
() => ({ width: '400', height: '800', href: 'https://example.com' }),
|
||||
sessionId
|
||||
)
|
||||
|
||||
expect(results.length).toEqual(numberOfParsedLinesInData)
|
||||
const meta = results.find((r) => r.type === 4)!
|
||||
expect(meta.data).toEqual({ width: 400, height: 800, href: 'https://example.com' })
|
||||
// Should include at least one full or incremental afterward
|
||||
expect(results.some((r) => r.type === 2 || r.type === 3)).toBe(true)
|
||||
// Preserve total count
|
||||
expect(results.length).toEqual(numberOfParsedLinesInData)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
import { eventWithTime } from '@posthog/rrweb-types'
|
||||
import { fullSnapshotEvent } from '@posthog/rrweb-types'
|
||||
import { EventType } from '@posthog/rrweb-types'
|
||||
|
||||
import { isObject } from 'lib/utils'
|
||||
|
||||
import { RecordingSnapshot } from '~/types'
|
||||
|
||||
import { throttleCapture } from './throttle-capturing'
|
||||
|
||||
export interface ViewportResolution {
|
||||
width: string
|
||||
height: string
|
||||
@@ -21,50 +11,3 @@ export const getHrefFromSnapshot = (snapshot: unknown): string | undefined => {
|
||||
? (snapshot.data as any)?.href || (snapshot.data as any)?.payload?.href
|
||||
: undefined
|
||||
}
|
||||
|
||||
/*
|
||||
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
|
||||
*/
|
||||
export function patchMetaEventIntoMobileData(
|
||||
parsedLines: RecordingSnapshot[],
|
||||
sessionRecordingId: string
|
||||
): 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) {
|
||||
throttleCapture(`${sessionRecordingId}-missing-mobile-meta-patching`, () => {
|
||||
posthog.captureException(e, {
|
||||
tags: { feature: 'session-recording-missing-mobile-meta-patching' },
|
||||
extra: { fullSnapshotIndex, metaIndex },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return parsedLines
|
||||
}
|
||||
|
||||
@@ -7,6 +7,46 @@ import { getDecompressionWorkerManager } from './DecompressionWorkerManager'
|
||||
import { hasAnyWireframes, parseEncodedSnapshots, processAllSnapshots } from './process-all-snapshots'
|
||||
import { keyForSource } from './source-key'
|
||||
|
||||
// Mock the EE exports early so modules under test see it when imported
|
||||
jest.mock('@posthog/ee/exports', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
mobileReplay: {
|
||||
transformEventToWeb: jest.fn((event: any) => {
|
||||
// Transform mobile FullSnapshot (wireframes) into a rrweb-like full snapshot structure
|
||||
if (event?.type === 2 && event?.data?.wireframes !== undefined) {
|
||||
return {
|
||||
...event,
|
||||
data: {
|
||||
node: {
|
||||
childNodes: [
|
||||
{},
|
||||
{
|
||||
childNodes: [
|
||||
{},
|
||||
{
|
||||
childNodes: [
|
||||
{
|
||||
attributes: { width: 400, height: 800 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
initialOffset: { top: 0, left: 0 },
|
||||
href: 'https://example.com',
|
||||
},
|
||||
}
|
||||
}
|
||||
return event
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the decompression worker manager
|
||||
jest.mock('./DecompressionWorkerManager', () => ({
|
||||
getDecompressionWorkerManager: jest.fn(),
|
||||
@@ -288,4 +328,400 @@ describe('process all snapshots', () => {
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mobile recording detection', () => {
|
||||
it('detects mobile recordings with wireframes in incremental updates', () => {
|
||||
const mobileIncrementalSnapshot = {
|
||||
type: 3,
|
||||
timestamp: 1000,
|
||||
data: {
|
||||
source: 0,
|
||||
updates: [
|
||||
{
|
||||
wireframe: {
|
||||
type: 'screenshot',
|
||||
base64: 'data:image/webp;base64,test',
|
||||
width: 400,
|
||||
height: 800,
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
expect(hasAnyWireframes([mobileIncrementalSnapshot])).toBe(true)
|
||||
})
|
||||
|
||||
it('detects mobile recordings with wireframes in full snapshots', () => {
|
||||
const mobileFullSnapshot = {
|
||||
type: 2,
|
||||
timestamp: 1000,
|
||||
data: {
|
||||
wireframes: [
|
||||
{
|
||||
type: 'screenshot',
|
||||
base64: 'data:image/webp;base64,test',
|
||||
width: 400,
|
||||
height: 800,
|
||||
},
|
||||
],
|
||||
initialOffset: { top: 0, left: 0 },
|
||||
},
|
||||
}
|
||||
|
||||
expect(hasAnyWireframes([mobileFullSnapshot])).toBe(true)
|
||||
})
|
||||
|
||||
it('does not detect web recordings as mobile', () => {
|
||||
const webIncrementalSnapshot = {
|
||||
type: 3,
|
||||
timestamp: 1000,
|
||||
data: {
|
||||
source: 0,
|
||||
adds: [],
|
||||
removes: [],
|
||||
texts: [],
|
||||
attributes: [],
|
||||
},
|
||||
}
|
||||
|
||||
expect(hasAnyWireframes([webIncrementalSnapshot])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('synthetic full snapshot creation', () => {
|
||||
it('creates synthetic full snapshot when mobile recording starts with incremental snapshot', async () => {
|
||||
const sessionId = 'test-mobile-session'
|
||||
|
||||
const snapshotJson = JSON.stringify({
|
||||
window_id: '1',
|
||||
data: [
|
||||
{
|
||||
type: 3,
|
||||
timestamp: 1000,
|
||||
data: {
|
||||
source: 0,
|
||||
updates: [
|
||||
{
|
||||
wireframe: {
|
||||
type: 'screenshot',
|
||||
base64: 'data:image/webp;base64,test',
|
||||
width: 400,
|
||||
height: 800,
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const parsed = await parseEncodedSnapshots([snapshotJson], sessionId)
|
||||
|
||||
// In the new single-loop model, synthetic/Meta injection happens in processAllSnapshots, not parse
|
||||
// So parsed should contain only the original incremental
|
||||
expect(parsed).toHaveLength(1)
|
||||
expect(parsed[0].type).toBe(3)
|
||||
|
||||
const key = keyForSource({ source: 'blob_v2', blob_key: '0' } as any)
|
||||
const results = processAllSnapshots(
|
||||
[{ source: 'blob_v2', blob_key: '0' } as any],
|
||||
{ [key]: { snapshots: parsed } } as any,
|
||||
{},
|
||||
() => ({ width: '400', height: '800', href: 'https://example.com' }),
|
||||
sessionId
|
||||
)
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(2)
|
||||
expect(results[0].windowId).toBe('1')
|
||||
|
||||
const hasFullSnapshot = results.some((r) => r.type === 2)
|
||||
const hasIncrementalSnapshot = results.some((r) => r.type === 3)
|
||||
expect(hasFullSnapshot).toBe(true)
|
||||
expect(hasIncrementalSnapshot).toBe(true)
|
||||
|
||||
const fullSnapshot = results.find((r) => r.type === 2)
|
||||
const incrementalSnapshot = results.find((r) => r.type === 3)
|
||||
expect(fullSnapshot?.timestamp).toBe(999)
|
||||
expect(incrementalSnapshot?.timestamp).toBe(1000)
|
||||
})
|
||||
|
||||
it('does not create synthetic snapshot when mobile recording starts with full snapshot', async () => {
|
||||
const sessionId = 'test-mobile-session'
|
||||
|
||||
const snapshotJson = JSON.stringify({
|
||||
window_id: '1',
|
||||
data: [
|
||||
{
|
||||
type: 2,
|
||||
timestamp: 1000,
|
||||
data: {
|
||||
wireframes: [
|
||||
{
|
||||
type: 'screenshot',
|
||||
base64: 'data:image/webp;base64,test',
|
||||
width: 400,
|
||||
height: 800,
|
||||
},
|
||||
],
|
||||
initialOffset: { top: 0, left: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const parsed = await parseEncodedSnapshots([snapshotJson], sessionId)
|
||||
expect(parsed).toHaveLength(1)
|
||||
expect(parsed[0].type).toBe(2)
|
||||
|
||||
const key = keyForSource({ source: 'blob_v2', blob_key: '0' } as any)
|
||||
const results = processAllSnapshots(
|
||||
[{ source: 'blob_v2', blob_key: '0' } as any],
|
||||
{ [key]: { snapshots: parsed } } as any,
|
||||
{},
|
||||
() => ({ width: '400', height: '800', href: 'https://example.com' }),
|
||||
sessionId
|
||||
)
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(2) // Meta + Full
|
||||
expect(results[0].windowId).toBe('1')
|
||||
|
||||
const fullSnapshots = results.filter((r) => r.type === 2)
|
||||
expect(fullSnapshots).toHaveLength(1)
|
||||
expect(fullSnapshots[0].timestamp).toBe(1000)
|
||||
})
|
||||
|
||||
it('does not create synthetic snapshot for web recordings', async () => {
|
||||
const sessionId = 'test-web-session'
|
||||
|
||||
const snapshotJson = JSON.stringify({
|
||||
window_id: '1',
|
||||
data: [
|
||||
{
|
||||
type: 3,
|
||||
timestamp: 1000,
|
||||
data: {
|
||||
source: 0,
|
||||
adds: [],
|
||||
removes: [],
|
||||
texts: [],
|
||||
attributes: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const parsed = await parseEncodedSnapshots([snapshotJson], sessionId)
|
||||
expect(parsed).toHaveLength(1)
|
||||
expect(parsed[0].windowId).toBe('1')
|
||||
expect(parsed[0].type).toBe(3)
|
||||
|
||||
const key = keyForSource({ source: 'blob_v2', blob_key: '0' } as any)
|
||||
const results = processAllSnapshots(
|
||||
[{ source: 'blob_v2', blob_key: '0' } as any],
|
||||
{ [key]: { snapshots: parsed } } as any,
|
||||
{},
|
||||
() => ({ width: '100', height: '100', href: 'https://example.com' }),
|
||||
sessionId
|
||||
)
|
||||
|
||||
// Web events should not generate synthetic full
|
||||
const hasFullSnapshot = results.some((r) => r.type === 2)
|
||||
expect(hasFullSnapshot).toBe(false)
|
||||
})
|
||||
|
||||
it('creates synthetic snapshot with correct windowId from original event', async () => {
|
||||
const sessionId = 'test-mobile-session'
|
||||
|
||||
const snapshotJson = JSON.stringify({
|
||||
window_id: 'custom-window-123',
|
||||
data: [
|
||||
{
|
||||
type: 3,
|
||||
timestamp: 2000,
|
||||
data: {
|
||||
source: 0,
|
||||
updates: [
|
||||
{
|
||||
wireframe: {
|
||||
type: 'screenshot',
|
||||
base64: 'data:image/webp;base64,test',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const parsed = await parseEncodedSnapshots([snapshotJson], sessionId)
|
||||
|
||||
const key = keyForSource({ source: 'blob_v2', blob_key: '0' } as any)
|
||||
const result = processAllSnapshots(
|
||||
[{ source: 'blob_v2', blob_key: '0' } as any],
|
||||
{ [key]: { snapshots: parsed } } as any,
|
||||
{},
|
||||
() => ({ width: '400', height: '800', href: 'https://example.com' }),
|
||||
sessionId
|
||||
)
|
||||
|
||||
result.forEach((event) => {
|
||||
expect(event.windowId).toBe('custom-window-123')
|
||||
})
|
||||
|
||||
const fullSnapshot = result.find((r) => r.type === 2)
|
||||
expect(fullSnapshot?.timestamp).toBe(1999)
|
||||
})
|
||||
|
||||
it('handles multiple mobile events correctly (only first gets synthetic)', async () => {
|
||||
const sessionId = 'test-mobile-session'
|
||||
|
||||
const snapshotJson = JSON.stringify({
|
||||
window_id: '1',
|
||||
data: [
|
||||
{
|
||||
type: 3,
|
||||
timestamp: 1000,
|
||||
data: {
|
||||
source: 0,
|
||||
updates: [{ wireframe: { type: 'screenshot' } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 3,
|
||||
timestamp: 2000,
|
||||
data: {
|
||||
source: 0,
|
||||
updates: [{ wireframe: { type: 'screenshot' } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const parsed = await parseEncodedSnapshots([snapshotJson], sessionId)
|
||||
|
||||
const key = keyForSource({ source: 'blob_v2', blob_key: '0' } as any)
|
||||
const results = processAllSnapshots(
|
||||
[{ source: 'blob_v2', blob_key: '0' } as any],
|
||||
{ [key]: { snapshots: parsed } } as any,
|
||||
{},
|
||||
() => ({ width: '400', height: '800', href: 'https://example.com' }),
|
||||
sessionId
|
||||
)
|
||||
|
||||
const fullSnapshots = results.filter((r) => r.type === 2)
|
||||
expect(fullSnapshots).toHaveLength(1)
|
||||
expect(fullSnapshots[0].timestamp).toBe(999)
|
||||
|
||||
const incrementalSnapshots = results.filter((r) => r.type === 3)
|
||||
expect(incrementalSnapshots).toHaveLength(2)
|
||||
expect(incrementalSnapshots[0].timestamp).toBe(1000)
|
||||
expect(incrementalSnapshots[1].timestamp).toBe(2000)
|
||||
})
|
||||
|
||||
it('preserves original event data when creating synthetic snapshot', async () => {
|
||||
const sessionId = 'test-mobile-session'
|
||||
|
||||
const originalEventData = {
|
||||
source: 0,
|
||||
updates: [
|
||||
{
|
||||
wireframe: {
|
||||
type: 'screenshot',
|
||||
base64: 'data:image/webp;base64,original-data',
|
||||
width: 400,
|
||||
height: 800,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const snapshotJson = JSON.stringify({
|
||||
window_id: '1',
|
||||
data: [
|
||||
{
|
||||
type: 3,
|
||||
timestamp: 1000,
|
||||
data: originalEventData,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await parseEncodedSnapshots([snapshotJson], sessionId)
|
||||
|
||||
const originalEvent = result.find((r) => r.type === 3 && r.timestamp === 1000)
|
||||
expect(originalEvent).toBeTruthy()
|
||||
expect(originalEvent?.data).toEqual(originalEventData)
|
||||
})
|
||||
|
||||
it('handles edge cases gracefully', async () => {
|
||||
const sessionId = 'test-edge-cases'
|
||||
|
||||
const emptyWireframesJson = JSON.stringify({
|
||||
window_id: '1',
|
||||
data: [
|
||||
{
|
||||
type: 3,
|
||||
timestamp: 1000,
|
||||
data: {
|
||||
source: 0,
|
||||
updates: [
|
||||
{
|
||||
wireframe: {
|
||||
type: 'screenshot',
|
||||
base64: '',
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const parsed = await parseEncodedSnapshots([emptyWireframesJson], sessionId)
|
||||
|
||||
const key = keyForSource({ source: 'blob_v2', blob_key: '0' } as any)
|
||||
const result = processAllSnapshots(
|
||||
[{ source: 'blob_v2', blob_key: '0' } as any],
|
||||
{ [key]: { snapshots: parsed } } as any,
|
||||
{},
|
||||
() => ({ width: '400', height: '800', href: 'https://example.com' }),
|
||||
sessionId
|
||||
)
|
||||
|
||||
const hasFullSnapshot = result.some((r) => r.type === 2)
|
||||
expect(hasFullSnapshot).toBe(true)
|
||||
})
|
||||
|
||||
it('handles missing windowId gracefully', async () => {
|
||||
const sessionId = 'test-missing-windowid'
|
||||
|
||||
const snapshotJson = JSON.stringify({
|
||||
window_id: '1',
|
||||
data: [
|
||||
{
|
||||
type: 3,
|
||||
timestamp: 1000,
|
||||
data: {
|
||||
source: 0,
|
||||
updates: [{ wireframe: { type: 'screenshot' } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await parseEncodedSnapshots([snapshotJson], sessionId)
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
result.forEach((event) => {
|
||||
expect(event.windowId).toBe('1')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
} from 'scenes/session-recordings/player/snapshot-processing/chrome-extension-stripping'
|
||||
import { chunkMutationSnapshot } from 'scenes/session-recordings/player/snapshot-processing/chunk-large-mutations'
|
||||
import { decompressEvent } from 'scenes/session-recordings/player/snapshot-processing/decompress'
|
||||
import {
|
||||
ViewportResolution,
|
||||
patchMetaEventIntoMobileData,
|
||||
} from 'scenes/session-recordings/player/snapshot-processing/patch-meta-event'
|
||||
import { ViewportResolution } from 'scenes/session-recordings/player/snapshot-processing/patch-meta-event'
|
||||
import { SourceKey, keyForSource } from 'scenes/session-recordings/player/snapshot-processing/source-key'
|
||||
import { throttleCapture } from 'scenes/session-recordings/player/snapshot-processing/throttle-capturing'
|
||||
|
||||
@@ -28,6 +25,52 @@ import {
|
||||
import { PostHogEE } from '../../../../../@posthog/ee/types'
|
||||
|
||||
export type ProcessingCache = Record<SourceKey, RecordingSnapshot[]>
|
||||
|
||||
function isLikelyMobileScreenshot(snapshot: RecordingSnapshot): boolean {
|
||||
if (snapshot.type !== EventType.IncrementalSnapshot) {
|
||||
return false
|
||||
}
|
||||
const data: any = (snapshot as any).data
|
||||
// Detect React Native wireframe incremental format
|
||||
return !!(data && Array.isArray(data.updates) && data.updates.some((u: any) => u && 'wireframe' in u))
|
||||
}
|
||||
|
||||
function createMinimalFullSnapshot(windowId: string | undefined, timestamp: number): RecordingSnapshot {
|
||||
// Create a minimal rrweb full document snapshot structure sufficient for playback
|
||||
const htmlNode = {
|
||||
type: 1, // NodeType.Element
|
||||
tagName: 'html',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 1,
|
||||
tagName: 'head',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
},
|
||||
{
|
||||
type: 1,
|
||||
tagName: 'body',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
const documentNode = {
|
||||
type: 0, // NodeType.Document
|
||||
childNodes: [htmlNode],
|
||||
}
|
||||
return {
|
||||
type: EventType.FullSnapshot,
|
||||
timestamp,
|
||||
windowId,
|
||||
data: {
|
||||
node: documentNode,
|
||||
initialOffset: { top: 0, left: 0 },
|
||||
},
|
||||
} as unknown as RecordingSnapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* NB this mutates processingCache and returns the processed snapshots
|
||||
*
|
||||
@@ -49,6 +92,7 @@ export function processAllSnapshots(
|
||||
const matchedExtensions = new Set<string>()
|
||||
|
||||
let hasSeenMeta = false
|
||||
const seenFullByWindow: Record<string, boolean> = {}
|
||||
|
||||
// we loop over this data as little as possible,
|
||||
// since it could be large and processed more than once,
|
||||
@@ -78,6 +122,47 @@ export function processAllSnapshots(
|
||||
let previousTimestamp = null
|
||||
let seenHashes = new Set<number>()
|
||||
|
||||
// Helper to inject a Meta event before a full snapshot when missing
|
||||
const pushPatchedMeta = (ts: number, winId?: string): boolean => {
|
||||
if (hasSeenMeta) {
|
||||
return false
|
||||
}
|
||||
const viewport = viewportForTimestamp(ts)
|
||||
if (viewport && viewport.width && viewport.height) {
|
||||
const metaEvent: RecordingSnapshot = {
|
||||
type: EventType.Meta,
|
||||
timestamp: ts,
|
||||
// windowId is required on RecordingSnapshot type; cast to satisfy typing when undefined
|
||||
windowId: winId as unknown as string,
|
||||
data: {
|
||||
width: parseInt(viewport.width, 10),
|
||||
height: parseInt(viewport.height, 10),
|
||||
href: viewport.href || 'unknown',
|
||||
},
|
||||
}
|
||||
result.push(metaEvent)
|
||||
sourceResult.push(metaEvent)
|
||||
throttleCapture(`${sessionRecordingId}-patched-meta`, () => {
|
||||
posthog.capture('patched meta into web recording', {
|
||||
throttleCaptureKey: `${sessionRecordingId}-patched-meta`,
|
||||
sessionRecordingId,
|
||||
sourceKey: sourceKey,
|
||||
feature: 'session-recording-meta-patching',
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
throttleCapture(`${sessionRecordingId}-no-viewport-found`, () => {
|
||||
posthog.captureException(new Error('No event viewport or meta snapshot found for full snapshot'), {
|
||||
throttleCaptureKey: `${sessionRecordingId}-no-viewport-found`,
|
||||
sessionRecordingId,
|
||||
sourceKey: sourceKey,
|
||||
feature: 'session-recording-meta-patching',
|
||||
})
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
while (snapshotIndex < sortedSnapshots.length) {
|
||||
let snapshot = sortedSnapshots[snapshotIndex]
|
||||
let currentTimestamp = snapshot.timestamp
|
||||
@@ -109,46 +194,33 @@ export function processAllSnapshots(
|
||||
hasSeenMeta = true
|
||||
}
|
||||
|
||||
const windowId = snapshot.windowId
|
||||
const hasSeenFullForWindow = !!seenFullByWindow[windowId]
|
||||
|
||||
if (
|
||||
snapshot.type === EventType.IncrementalSnapshot &&
|
||||
!hasSeenFullForWindow &&
|
||||
isLikelyMobileScreenshot(snapshot)
|
||||
) {
|
||||
// Inject a synthetic full snapshot (and meta if needed) immediately before the first incremental
|
||||
const syntheticTimestamp = Math.max(0, snapshot.timestamp - 1)
|
||||
|
||||
const metaInserted = pushPatchedMeta(syntheticTimestamp, snapshot.windowId)
|
||||
|
||||
const syntheticFull = createMinimalFullSnapshot(snapshot.windowId, syntheticTimestamp)
|
||||
result.push(syntheticFull)
|
||||
sourceResult.push(syntheticFull)
|
||||
seenFullByWindow[windowId] = true
|
||||
// mark meta as seen only if we actually inserted it; otherwise allow next full to patch
|
||||
hasSeenMeta = hasSeenMeta || metaInserted
|
||||
}
|
||||
|
||||
// Process chrome extension data
|
||||
if (snapshot.type === EventType.FullSnapshot) {
|
||||
// Check if we need to patch a meta event before this full snapshot
|
||||
if (!hasSeenMeta) {
|
||||
const viewport = viewportForTimestamp(snapshot.timestamp)
|
||||
if (viewport && viewport.width && viewport.height) {
|
||||
const metaEvent: RecordingSnapshot = {
|
||||
type: EventType.Meta,
|
||||
timestamp: snapshot.timestamp,
|
||||
windowId: snapshot.windowId,
|
||||
data: {
|
||||
width: parseInt(viewport.width, 10),
|
||||
height: parseInt(viewport.height, 10),
|
||||
href: viewport.href || 'unknown',
|
||||
},
|
||||
}
|
||||
result.push(metaEvent)
|
||||
sourceResult.push(metaEvent)
|
||||
throttleCapture(`${sessionRecordingId}-patched-meta`, () => {
|
||||
posthog.capture('patched meta into web recording', {
|
||||
throttleCaptureKey: `${sessionRecordingId}-patched-meta`,
|
||||
sessionRecordingId,
|
||||
sourceKey: sourceKey,
|
||||
feature: 'session-recording-meta-patching',
|
||||
})
|
||||
})
|
||||
} else {
|
||||
throttleCapture(`${sessionRecordingId}-no-viewport-found`, () => {
|
||||
posthog.captureException(
|
||||
new Error('No event viewport or meta snapshot found for full snapshot'),
|
||||
{
|
||||
throttleCaptureKey: `${sessionRecordingId}-no-viewport-found`,
|
||||
sessionRecordingId,
|
||||
sourceKey: sourceKey,
|
||||
feature: 'session-recording-meta-patching',
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
seenFullByWindow[snapshot.windowId] = true
|
||||
|
||||
// Ensure meta before this full snapshot if missing
|
||||
pushPatchedMeta(snapshot.timestamp, snapshot.windowId)
|
||||
|
||||
// Reset for next potential full snapshot
|
||||
hasSeenMeta = false
|
||||
@@ -231,7 +303,7 @@ function hashSnapshot(snapshot: RecordingSnapshot): number {
|
||||
function coerceToEventWithTime(d: unknown, sessionRecordingId: string): eventWithTime {
|
||||
// we decompress first so that we could support partial compression on mobile in the future
|
||||
const currentEvent = decompressEvent(d, sessionRecordingId)
|
||||
return postHogEEModule?.mobileReplay?.transformEventToWeb(currentEvent) || (currentEvent as eventWithTime)
|
||||
return postHogEEModule?.mobileReplay?.transformEventToWeb(currentEvent) ?? (currentEvent as eventWithTime)
|
||||
}
|
||||
|
||||
export const parseEncodedSnapshots = async (
|
||||
@@ -304,7 +376,6 @@ export const parseEncodedSnapshots = async (
|
||||
|
||||
const lineCount = items.length
|
||||
const unparseableLines: string[] = []
|
||||
let isMobileSnapshots = false
|
||||
|
||||
const parsedLines: RecordingSnapshot[] = items.flatMap((l) => {
|
||||
if (!l) {
|
||||
@@ -335,10 +406,6 @@ export const parseEncodedSnapshots = async (
|
||||
snapshotData = snapshotLine['data']
|
||||
}
|
||||
|
||||
if (!isMobileSnapshots) {
|
||||
isMobileSnapshots = hasAnyWireframes(snapshotData)
|
||||
}
|
||||
|
||||
return snapshotData.flatMap((d: unknown) => {
|
||||
const snap = coerceToEventWithTime(d, sessionId)
|
||||
|
||||
@@ -373,7 +440,7 @@ export const parseEncodedSnapshots = async (
|
||||
})
|
||||
}
|
||||
|
||||
return isMobileSnapshots ? patchMetaEventIntoMobileData(parsedLines, sessionId) : parsedLines
|
||||
return parsedLines
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user