Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: manage capture pageview hook lifecycle #1408

Merged
merged 4 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions cypress/e2e/session-recording.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,10 +397,18 @@ describe('Session recording', () => {
// a meta and then a full snapshot
expect(captures[1]['properties']['$snapshot_data'][0].type).to.equal(4) // meta
expect(captures[1]['properties']['$snapshot_data'][1].type).to.equal(2) // full_snapshot
expect(captures[1]['properties']['$snapshot_data'][2].type).to.equal(5) // custom event with options
expect(captures[1]['properties']['$snapshot_data'][3].type).to.equal(5) // custom event with posthog config

expect(captures[1]['properties']['$snapshot_data'][2].type).to.equal(5)
expect(captures[1]['properties']['$snapshot_data'][2].data.tag).to.equal('$pageview')

expect(captures[1]['properties']['$snapshot_data'][3].type).to.equal(5) // custom event with options
expect(captures[1]['properties']['$snapshot_data'][3].data.tag).to.equal('$session_options')

expect(captures[1]['properties']['$snapshot_data'][4].type).to.equal(5) // custom event with posthog config
expect(captures[1]['properties']['$snapshot_data'][4].data.tag).to.equal('$posthog_config') // custom event with posthog config

const xPositions = []
for (let i = 4; i < captures[1]['properties']['$snapshot_data'].length; i++) {
for (let i = 5; i < captures[1]['properties']['$snapshot_data'].length; i++) {
expect(captures[1]['properties']['$snapshot_data'][i].type).to.equal(3)
expect(captures[1]['properties']['$snapshot_data'][i].data.source).to.equal(
6,
Expand Down
46 changes: 45 additions & 1 deletion src/__tests__/extensions/replay/sessionrecording.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ describe('SessionRecording', () => {
let sessionIdGeneratorMock: Mock
let windowIdGeneratorMock: Mock
let onFeatureFlagsCallback: ((flags: string[], variants: Record<string, string | boolean>) => void) | null
let removeCaptureHookMock: Mock
let addCaptureHookMock: Mock

const addRRwebToWindow = () => {
assignableWindow.rrweb = {
Expand Down Expand Up @@ -173,6 +175,10 @@ describe('SessionRecording', () => {

sessionManager = new SessionIdManager(config, postHogPersistence, sessionIdGeneratorMock, windowIdGeneratorMock)

// add capture hook returns an unsubscribe function
removeCaptureHookMock = jest.fn()
addCaptureHookMock = jest.fn().mockImplementation(() => removeCaptureHookMock)

posthog = {
get_property: (property_key: string): Property | undefined => {
return postHogPersistence?.['props'][property_key]
Expand All @@ -185,7 +191,7 @@ describe('SessionRecording', () => {
},
sessionManager: sessionManager,
requestRouter: new RequestRouter({ config } as any),
_addCaptureHook: jest.fn(),
_addCaptureHook: addCaptureHookMock,
consent: { isOptedOut: () => false },
} as unknown as PostHog

Expand Down Expand Up @@ -308,6 +314,44 @@ describe('SessionRecording', () => {
expect((sessionRecording as any)._startCapture).toHaveBeenCalled()
})

it('sets the pageview capture hook once', () => {
expect(sessionRecording['_removePageViewCaptureHook']).toBeUndefined()

sessionRecording.startIfEnabledOrStop()

expect(sessionRecording['_removePageViewCaptureHook']).not.toBeUndefined()
expect(posthog._addCaptureHook).toHaveBeenCalledTimes(1)

// calling a second time doesn't add another capture hook
sessionRecording.startIfEnabledOrStop()
expect(posthog._addCaptureHook).toHaveBeenCalledTimes(1)
})

it('removes the pageview capture hook on stop', () => {
sessionRecording.startIfEnabledOrStop()
expect(sessionRecording['_removePageViewCaptureHook']).not.toBeUndefined()

expect(removeCaptureHookMock).not.toHaveBeenCalled()
sessionRecording.stopRecording()

expect(removeCaptureHookMock).toHaveBeenCalledTimes(1)
expect(sessionRecording['_removePageViewCaptureHook']).toBeUndefined()
})

it('sets the window event listeners', () => {
//mock window add event listener to check if it is called
const addEventListener = jest.fn().mockImplementation(() => () => {})
window.addEventListener = addEventListener

sessionRecording.startIfEnabledOrStop()
expect(sessionRecording['_onBeforeUnload']).not.toBeNull()
// we register 4 event listeners
expect(window.addEventListener).toHaveBeenCalledTimes(4)

// window.addEventListener('blah', someFixedListenerInstance) is safe to call multiple times,
// so we don't need to test if the addEvenListener registrations are called multiple times
})

it('emits an options event', () => {
sessionRecording.startIfEnabledOrStop()
expect((sessionRecording as any)['_tryAddCustomEvent']).toHaveBeenCalledWith('$session_options', {
Expand Down
48 changes: 28 additions & 20 deletions src/extensions/replay/sessionrecording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export class SessionRecording {

private _fullSnapshotTimer?: ReturnType<typeof setInterval>

private _removePageViewCaptureHook: (() => void) | undefined = undefined
// if pageview capture is disabled
// then we can manually track href changes
private _lastHref?: string
Expand Down Expand Up @@ -284,16 +285,35 @@ export class SessionRecording {
if (this.isRecordingEnabled) {
this._startCapture()

// calling addEventListener multiple times is safe and will not add duplicates
window?.addEventListener('beforeunload', this._onBeforeUnload)
window?.addEventListener('offline', this._onOffline)
window?.addEventListener('online', this._onOnline)
window?.addEventListener('visibilitychange', this._onVisibilityChange)

if (isNullish(this._removePageViewCaptureHook)) {
// :TRICKY: rrweb does not capture navigation within SPA-s, so hook into our $pageview events to get access to all events.
// Dropping the initial event is fine (it's always captured by rrweb).
this._removePageViewCaptureHook = this.instance._addCaptureHook((eventName) => {
// If anything could go wrong here it has the potential to block the main loop,
// so we catch all errors.
try {
if (eventName === '$pageview') {
const href = window ? this._maskUrl(window.location.href) : ''
if (!href) {
return
}
this._tryAddCustomEvent('$pageview', { href })
}
} catch (e) {
logger.error('Could not add $pageview to rrweb session', e)
}
})
}

logger.info(LOGGER_PREFIX + ' started')
} else {
this.stopRecording()
this.clearBuffer()
clearInterval(this._fullSnapshotTimer)
}
}

Expand All @@ -308,6 +328,12 @@ export class SessionRecording {
window?.removeEventListener('online', this._onOnline)
window?.removeEventListener('visibilitychange', this._onVisibilityChange)

this.clearBuffer()
clearInterval(this._fullSnapshotTimer)

this._removePageViewCaptureHook?.()
this._removePageViewCaptureHook = undefined

logger.info(LOGGER_PREFIX + ' stopped')
}
}
Expand Down Expand Up @@ -661,24 +687,6 @@ export class SessionRecording {
...sessionRecordingOptions,
})

// :TRICKY: rrweb does not capture navigation within SPA-s, so hook into our $pageview events to get access to all events.
// Dropping the initial event is fine (it's always captured by rrweb).
this.instance._addCaptureHook((eventName) => {
// If anything could go wrong here it has the potential to block the main loop,
// so we catch all errors.
try {
if (eventName === '$pageview') {
const href = window ? this._maskUrl(window.location.href) : ''
if (!href) {
return
}
this._tryAddCustomEvent('$pageview', { href })
}
} catch (e) {
logger.error('Could not add $pageview to rrweb session', e)
}
})

// We reset the last activity timestamp, resetting the idle timer
this._lastActivityTimestamp = Date.now()
this.isIdle = false
Expand Down
4 changes: 2 additions & 2 deletions src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,8 +864,8 @@ export class PostHog {
return data
}

_addCaptureHook(callback: (eventName: string, eventPayload?: CaptureResult) => void): void {
this.on('eventCaptured', (data) => callback(data.event, data))
_addCaptureHook(callback: (eventName: string, eventPayload?: CaptureResult) => void): () => void {
return this.on('eventCaptured', (data) => callback(data.event, data))
}

_calculate_event_properties(event_name: string, event_properties: Properties, timestamp?: Date): Properties {
Expand Down
Loading