Skip to content

Commit e9127d8

Browse files
authored
fix(replay): detect and report rrweb silent initialization failure (#3174)
* fix(replay): detect and report rrweb silent initialization failure rrweb's record() wraps its entire body in a try-catch that silently swallows errors via console.warn and returns undefined. The SDK was not checking this return value, so it would report recording as active while rrweb never actually initialized - resulting in zero recording data being captured or flushed. Check the return value of rrwebRecord() in _startRecorder() and bail out with a new RRWEB_ERROR status if it fails. This makes the failure visible in SDK debug properties instead of silently losing data. * chore(replay): add changeset for rrweb init failure detection * fix(replay): fix lint and add mangled property name * fix(replay): address review feedback - Check _rrwebError flag directly in start() instead of redundantly setting it again, since _startRecorder() already sets the flag - Add $sdk_debug_replay_rrweb_error to debug properties
1 parent 6ee5f12 commit e9127d8

File tree

7 files changed

+149
-2
lines changed

7 files changed

+149
-2
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"posthog-js": patch
3+
---
4+
5+
Detect and report when rrweb fails to initialize. rrweb's `record()` silently swallows startup errors and returns `undefined`, which previously left the SDK reporting an active recording status while capturing zero data. The SDK now checks the return value and reports a new `rrweb_error` status, making the failure visible in debug properties.

packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3071,6 +3071,80 @@ describe('Lazy SessionRecording', () => {
30713071
})
30723072
})
30733073

3074+
describe('when rrweb record() returns undefined', () => {
3075+
it('does not report recording as started', () => {
3076+
loadScriptMock.mockImplementation((_ph: any, _path: any, callback: any) => {
3077+
assignableWindow.__PosthogExtensions__.rrweb = {
3078+
record: jest.fn(() => undefined),
3079+
version: 'fake',
3080+
wasMaxDepthReached: jest.fn(() => false),
3081+
resetMaxDepthState: jest.fn(),
3082+
}
3083+
assignableWindow.__PosthogExtensions__.rrweb.record.takeFullSnapshot = jest.fn()
3084+
assignableWindow.__PosthogExtensions__.rrweb.record.addCustomEvent = jest.fn()
3085+
assignableWindow.__PosthogExtensions__.initSessionRecording = () => {
3086+
return new LazyLoadedSessionRecording(posthog)
3087+
}
3088+
callback()
3089+
})
3090+
3091+
sessionRecording.onRemoteConfig(
3092+
makeFlagsResponse({
3093+
sessionRecording: {
3094+
endpoint: '/s/',
3095+
},
3096+
})
3097+
)
3098+
3099+
expect(sessionRecording.started).toEqual(false)
3100+
expect(sessionRecording.status).toEqual('rrweb_error')
3101+
})
3102+
3103+
it('recovers when rrweb starts successfully on retry', () => {
3104+
let recordCallCount = 0
3105+
loadScriptMock.mockImplementation((_ph: any, _path: any, callback: any) => {
3106+
assignableWindow.__PosthogExtensions__.rrweb = {
3107+
record: jest.fn(({ emit }) => {
3108+
recordCallCount++
3109+
if (recordCallCount === 1) {
3110+
return undefined
3111+
}
3112+
_emit = emit
3113+
return () => {}
3114+
}),
3115+
version: 'fake',
3116+
wasMaxDepthReached: jest.fn(() => false),
3117+
resetMaxDepthState: jest.fn(),
3118+
}
3119+
assignableWindow.__PosthogExtensions__.rrweb.record.takeFullSnapshot = jest.fn(() => {
3120+
_emit(createFullSnapshot())
3121+
})
3122+
assignableWindow.__PosthogExtensions__.rrweb.record.addCustomEvent = jest.fn()
3123+
assignableWindow.__PosthogExtensions__.initSessionRecording = () => {
3124+
return new LazyLoadedSessionRecording(posthog)
3125+
}
3126+
callback()
3127+
})
3128+
3129+
sessionRecording.onRemoteConfig(
3130+
makeFlagsResponse({
3131+
sessionRecording: {
3132+
endpoint: '/s/',
3133+
},
3134+
})
3135+
)
3136+
3137+
expect(sessionRecording.started).toEqual(false)
3138+
expect(sessionRecording.status).toEqual('rrweb_error')
3139+
3140+
// simulate session rotation triggering a restart
3141+
sessionRecording['_lazyLoadedSessionRecording']!.start()
3142+
3143+
expect(sessionRecording.started).toEqual(true)
3144+
expect(sessionRecording.status).not.toEqual('rrweb_error')
3145+
})
3146+
})
3147+
30743148
describe('buffering minimum duration', () => {
30753149
it('can report no duration when no data', () => {
30763150
sessionRecording.onRemoteConfig(

packages/browser/src/__tests__/extensions/replay/sessionRecordingStatus.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
LinkedFlagMatching,
99
PAUSED,
1010
RecordingTriggersStatus,
11+
RRWEB_ERROR,
1112
SAMPLED,
1213
SessionRecordingStatus,
1314
TRIGGER_ACTIVATED,
@@ -30,6 +31,7 @@ const defaultTriggersStatus: RecordingTriggersStatus = {
3031
receivedFlags: true,
3132
isRecordingEnabled: true,
3233
isSampled: undefined,
34+
rrwebError: false,
3335
urlTriggerMatching: {
3436
onRemoteConfig: () => {},
3537
_instance: fakePostHog,
@@ -58,6 +60,31 @@ const makeLinkedFlagMatcher = (linkedFlag: string | null, linkedFlagSeen: boolea
5860

5961
const testCases: TestConfig[] = [
6062
// Basic states
63+
{
64+
name: 'rrweb error',
65+
config: { rrwebError: true },
66+
anyMatchExpected: RRWEB_ERROR,
67+
allMatchExpected: RRWEB_ERROR,
68+
},
69+
{
70+
name: 'rrweb error overrides sampling true',
71+
config: { rrwebError: true, isSampled: true },
72+
anyMatchExpected: RRWEB_ERROR,
73+
allMatchExpected: RRWEB_ERROR,
74+
},
75+
{
76+
name: 'rrweb error overrides active trigger',
77+
config: {
78+
rrwebError: true,
79+
urlTriggerMatching: {
80+
...defaultTriggersStatus.urlTriggerMatching,
81+
_instance: fakePostHog,
82+
triggerStatus: () => TRIGGER_ACTIVATED,
83+
} as unknown as URLTriggerMatching,
84+
},
85+
anyMatchExpected: RRWEB_ERROR,
86+
allMatchExpected: RRWEB_ERROR,
87+
},
6188
{
6289
name: 'flags not received',
6390
config: { receivedFlags: false },

packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt
333333
*/
334334
private _queuedRRWebEvents: QueuedRRWebEvent[] = []
335335
private _isIdle: boolean | 'unknown' = 'unknown'
336+
private _rrwebError = false
336337
private _maxDepthExceeded = false
337338

338339
private _linkedFlagMatching: LinkedFlagMatching
@@ -813,6 +814,10 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt
813814
this._makeSamplingDecision(this.sessionId)
814815
this._startRecorder()
815816

817+
if (this._rrwebError) {
818+
return
819+
}
820+
816821
// calling addEventListener multiple times is safe and will not add duplicates
817822
addEventListener(window, 'beforeunload', this._onBeforeUnload)
818823
addEventListener(window, 'offline', this._onOffline)
@@ -1124,6 +1129,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt
11241129
isRecordingEnabled: true,
11251130
// things that do still vary
11261131
isSampled: this._isSampled,
1132+
rrwebError: this._rrwebError,
11271133
urlTriggerMatching: this._urlTriggerMatching,
11281134
eventTriggerMatching: this._eventTriggerMatching,
11291135
linkedFlagMatching: this._linkedFlagMatching,
@@ -1550,6 +1556,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt
15501556
$sdk_debug_replay_flushed_size: this._flushedSizeTracker?.currentTrackedSize,
15511557
$sdk_debug_replay_full_snapshots: this._fullSnapshotTimestamps,
15521558
$snapshot_max_depth_exceeded: this._maxDepthExceeded,
1559+
$sdk_debug_replay_rrweb_error: this._rrwebError,
15531560
}
15541561
}
15551562

@@ -1636,6 +1643,16 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt
16361643
...sessionRecordingOptions,
16371644
})
16381645

1646+
if (!this._stopRrweb) {
1647+
this._rrwebError = true
1648+
logger.error(
1649+
'rrweb failed to start - Loss of recording data is possible. Check the browser console for rrweb errors.'
1650+
)
1651+
return
1652+
}
1653+
1654+
this._rrwebError = false
1655+
16391656
// We reset the last activity timestamp, resetting the idle timer
16401657
this._lastActivityTimestamp = Date.now()
16411658
// stay unknown if we're not sure if we're idle or not

packages/browser/src/extensions/replay/external/triggerMatching.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const BUFFERING = 'buffering'
1515
export const PAUSED = 'paused'
1616
export const PENDING_CONFIG = 'pending_config'
1717
export const LAZY_LOADING = 'lazy_loading'
18+
export const RRWEB_ERROR = 'rrweb_error'
1819

1920
const TRIGGER = 'trigger'
2021
export const TRIGGER_ACTIVATED = TRIGGER + '_activated'
@@ -25,6 +26,7 @@ export interface RecordingTriggersStatus {
2526
get receivedFlags(): boolean
2627
get isRecordingEnabled(): false | true | undefined
2728
get isSampled(): false | true | null
29+
get rrwebError(): boolean
2830
get urlTriggerMatching(): URLTriggerMatching
2931
get eventTriggerMatching(): EventTriggerMatching
3032
get linkedFlagMatching(): LinkedFlagMatching
@@ -49,7 +51,16 @@ export type TriggerStatus = (typeof triggerStatuses)[number]
4951
* the sample rate determined this session should be sent to the server.
5052
*/
5153
// eslint-disable-next-line @typescript-eslint/no-unused-vars
52-
const sessionRecordingStatuses = [DISABLED, SAMPLED, ACTIVE, BUFFERING, PAUSED, PENDING_CONFIG, LAZY_LOADING] as const
54+
const sessionRecordingStatuses = [
55+
DISABLED,
56+
SAMPLED,
57+
ACTIVE,
58+
BUFFERING,
59+
PAUSED,
60+
PENDING_CONFIG,
61+
LAZY_LOADING,
62+
RRWEB_ERROR,
63+
] as const
5364
export type SessionRecordingStatus = (typeof sessionRecordingStatuses)[number]
5465

5566
// while we have both lazy and eager loaded replay we might get either type of config
@@ -395,6 +406,10 @@ export class EventTriggerMatching implements TriggerStatusMatching {
395406

396407
// we need a no-op matcher before we can lazy-load the other matches, since all matchers wait on remote config anyway
397408
export function nullMatchSessionRecordingStatus(triggersStatus: RecordingTriggersStatus): SessionRecordingStatus {
409+
if (triggersStatus.rrwebError) {
410+
return RRWEB_ERROR
411+
}
412+
398413
if (!triggersStatus.isRecordingEnabled) {
399414
return DISABLED
400415
}
@@ -403,6 +418,10 @@ export function nullMatchSessionRecordingStatus(triggersStatus: RecordingTrigger
403418
}
404419

405420
export function anyMatchSessionRecordingStatus(triggersStatus: RecordingTriggersStatus): SessionRecordingStatus {
421+
if (triggersStatus.rrwebError) {
422+
return RRWEB_ERROR
423+
}
424+
406425
if (!triggersStatus.receivedFlags) {
407426
return BUFFERING
408427
}
@@ -446,6 +465,10 @@ export function anyMatchSessionRecordingStatus(triggersStatus: RecordingTriggers
446465
}
447466

448467
export function allMatchSessionRecordingStatus(triggersStatus: RecordingTriggersStatus): SessionRecordingStatus {
468+
if (triggersStatus.rrwebError) {
469+
return RRWEB_ERROR
470+
}
471+
449472
if (!triggersStatus.receivedFlags) {
450473
return BUFFERING
451474
}

packages/browser/src/extensions/replay/types/rrweb.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export type recordOptions = {
104104

105105
// Replication of `record` from inside `@rrweb/record`
106106
export type rrwebRecord = {
107-
(options: recordOptions): () => void
107+
(options: recordOptions): (() => void) | undefined
108108
addCustomEvent: (tag: string, payload: any) => void
109109
takeFullSnapshot: () => void
110110
mirror: {

packages/browser/terser-mangled-names.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,7 @@
448448
"_resumeRecording",
449449
"_resumeSavedTour",
450450
"_retryQueue",
451+
"_rrwebError",
451452
"_runBeforeSend",
452453
"_sampleRate",
453454
"_samplingSessionListener",

0 commit comments

Comments
 (0)