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:
Alex V
2025-10-17 22:00:29 +03:00
committed by GitHub
parent 576cb263b3
commit 7dfb988311
5 changed files with 574 additions and 278 deletions

View File

@@ -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`] = `
[
{

View File

@@ -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)
})
})

View File

@@ -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
}

View File

@@ -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')
})
})
})
})

View File

@@ -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
}
/*