From 7dfb988311999b4a79ebe2a7c4dfe5ce0648cd9d Mon Sep 17 00:00:00 2001 From: Alex V Date: Fri, 17 Oct 2025 22:00:29 +0300 Subject: [PATCH] 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 --- .../__snapshots__/parsing.test.ts.snap | 169 ------- ee/frontend/mobile-replay/parsing.test.ts | 25 +- .../snapshot-processing/patch-meta-event.ts | 57 --- .../process-all-snapshots.test.ts | 436 ++++++++++++++++++ .../process-all-snapshots.ts | 165 +++++-- 5 files changed, 574 insertions(+), 278 deletions(-) diff --git a/ee/frontend/mobile-replay/__snapshots__/parsing.test.ts.snap b/ee/frontend/mobile-replay/__snapshots__/parsing.test.ts.snap index c916dd21d5..9c2e6825d3 100644 --- a/ee/frontend/mobile-replay/__snapshots__/parsing.test.ts.snap +++ b/ee/frontend/mobile-replay/__snapshots__/parsing.test.ts.snap @@ -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`] = ` [ { diff --git a/ee/frontend/mobile-replay/parsing.test.ts b/ee/frontend/mobile-replay/parsing.test.ts index dec37fcff8..ceccc3630d 100644 --- a/ee/frontend/mobile-replay/parsing.test.ts +++ b/ee/frontend/mobile-replay/parsing.test.ts @@ -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) }) }) diff --git a/frontend/src/scenes/session-recordings/player/snapshot-processing/patch-meta-event.ts b/frontend/src/scenes/session-recordings/player/snapshot-processing/patch-meta-event.ts index 1793109d94..1d18e5d6b0 100644 --- a/frontend/src/scenes/session-recordings/player/snapshot-processing/patch-meta-event.ts +++ b/frontend/src/scenes/session-recordings/player/snapshot-processing/patch-meta-event.ts @@ -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 -} diff --git a/frontend/src/scenes/session-recordings/player/snapshot-processing/process-all-snapshots.test.ts b/frontend/src/scenes/session-recordings/player/snapshot-processing/process-all-snapshots.test.ts index fd704cf16f..455e0d9123 100644 --- a/frontend/src/scenes/session-recordings/player/snapshot-processing/process-all-snapshots.test.ts +++ b/frontend/src/scenes/session-recordings/player/snapshot-processing/process-all-snapshots.test.ts @@ -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') + }) + }) + }) }) diff --git a/frontend/src/scenes/session-recordings/player/snapshot-processing/process-all-snapshots.ts b/frontend/src/scenes/session-recordings/player/snapshot-processing/process-all-snapshots.ts index bb9d806ac3..ce2af4d5aa 100644 --- a/frontend/src/scenes/session-recordings/player/snapshot-processing/process-all-snapshots.ts +++ b/frontend/src/scenes/session-recordings/player/snapshot-processing/process-all-snapshots.ts @@ -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 + +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() let hasSeenMeta = false + const seenFullByWindow: Record = {} // 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() + // 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 } /*