Skip to content

Commit

Permalink
feat: Session Replay Dynamic Loading (#832)
Browse files Browse the repository at this point in the history
  • Loading branch information
metal-messiah authored Jan 9, 2024
1 parent 9ad6312 commit 1af7b89
Show file tree
Hide file tree
Showing 23 changed files with 630 additions and 405 deletions.
2 changes: 1 addition & 1 deletion src/common/config/state/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const model = () => {
sampling_rate: 50, // float from 0 - 100
error_sampling_rate: 50, // float from 0 - 100
collect_fonts: false, // serialize fonts for collection without public asset url, this is currently broken in RRWeb -- https://github.com/rrweb-io/rrweb/issues/1304. When fixed, revisit with test cases
inline_images: false, // serialize images for collection without public asset url
inline_images: false, // serialize images for collection without public asset url -- right now this is only useful for testing as it easily generates payloads too large to be harvested
inline_stylesheet: true, // serialize css for collection without public asset url
// recording config settings
mask_all_inputs: true,
Expand Down
2 changes: 1 addition & 1 deletion src/common/harvest/harvest-scheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as submitData from '../util/submit-data'
import { SharedContext } from '../context/shared-context'
import { Harvest } from './harvest'
import { subscribeToEOL } from '../unload/eol'
import { SESSION_EVENTS } from '../session/session-entity'
import { SESSION_EVENTS } from '../session/constants'

/**
* Periodically invokes harvest calls and handles retries
Expand Down
1 change: 0 additions & 1 deletion src/common/harvest/harvest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { Harvest } from './harvest'

jest.enableAutomock()
jest.unmock('./harvest')

let harvestInstance

beforeEach(() => {
Expand Down
6 changes: 0 additions & 6 deletions src/common/session/__mocks__/session-entity.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
export const SESSION_EVENTS = {
PAUSE: 'session-pause',
RESET: 'session-reset',
RESUME: 'session-resume'
}

export const SessionEntity = jest.fn(function () {
this.setup = jest.fn()
this.sync = jest.fn()
Expand Down
18 changes: 18 additions & 0 deletions src/common/session/constants.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
export const PREFIX = 'NRBA'
export const DEFAULT_EXPIRES_MS = 14400000
export const DEFAULT_INACTIVE_MS = 1800000

export const SESSION_EVENTS = {
PAUSE: 'session-pause',
RESET: 'session-reset',
RESUME: 'session-resume',
UPDATE: 'session-update'
}

export const SESSION_EVENT_TYPES = {
SAME_TAB: 'same-tab',
CROSS_TAB: 'cross-tab'
}

export const MODE = {
OFF: 0,
FULL: 1,
ERROR: 2
}
18 changes: 1 addition & 17 deletions src/common/session/session-entity.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { stringify } from '../util/stringify'
import { ee } from '../event-emitter/contextual-ee'
import { Timer } from '../timer/timer'
import { isBrowserScope } from '../constants/runtime'
import { DEFAULT_EXPIRES_MS, DEFAULT_INACTIVE_MS, PREFIX } from './constants'
import { DEFAULT_EXPIRES_MS, DEFAULT_INACTIVE_MS, MODE, PREFIX, SESSION_EVENTS, SESSION_EVENT_TYPES } from './constants'
import { InteractionTimer } from '../timer/interaction-timer'
import { wrapEvents } from '../wrap'
import { getModeledObject } from '../config/state/configurable'
Expand All @@ -13,11 +13,6 @@ import { SUPPORTABILITY_METRIC_CHANNEL } from '../../features/metrics/constants'
import { FEATURE_NAMES } from '../../loaders/features/features'
import { windowAddEventListener } from '../event-listener/event-listener-opts'

export const MODE = {
OFF: 0,
FULL: 1,
ERROR: 2
}
// this is what can be stored in local storage (not enforced but probably should be)
// these values should sync between local storage and the parent class props
const model = {
Expand All @@ -31,17 +26,6 @@ const model = {
traceHarvestStarted: false,
custom: {}
}
export const SESSION_EVENTS = {
PAUSE: 'session-pause',
RESET: 'session-reset',
RESUME: 'session-resume',
UPDATE: 'session-update'
}

export const SESSION_EVENT_TYPES = {
SAME_TAB: 'same-tab',
CROSS_TAB: 'cross-tab'
}

export class SessionEntity {
/**
Expand Down
3 changes: 2 additions & 1 deletion src/common/url/encode.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ export function obj (payload, maxBytes) {
}

// Constructs an HTTP parameter to add to the BAM router URL
export function param (name, value) {
export function param (name, value, base = {}) {
if (Object.keys(base).includes(name)) return '' // we assume if feature supplied a matching qp to the base, we should honor what the feature sent over the default
if (value && typeof (value) === 'string') {
return '&' + name + '=' + qs(value)
}
Expand Down
41 changes: 24 additions & 17 deletions src/features/session_replay/aggregate/index.component-test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Aggregate as SessionReplayAgg, AVG_COMPRESSION, MAX_PAYLOAD_SIZE, IDEAL_PAYLOAD_SIZE } from '.'
import { Aggregate as SessionReplayAgg } from '.'
import { AVG_COMPRESSION, IDEAL_PAYLOAD_SIZE } from '../constants'
import { Aggregator } from '../../../common/aggregate/aggregator'
import { SESSION_EVENTS, SessionEntity, MODE } from '../../../common/session/session-entity'
import { SessionEntity } from '../../../common/session/session-entity'
import { setConfiguration } from '../../../common/config/config'
import { configure } from '../../../loaders/configure/configure'
import { Recorder } from '../shared/recorder'
import { MODE, SESSION_EVENTS } from '../../../common/session/constants'

jest.mock('../../../common/util/console', () => ({
warn: jest.fn()
Expand Down Expand Up @@ -88,10 +91,10 @@ describe('Session Replay', () => {
sr.ee.emit('rumresp-sr', [true])
await wait(1)
expect(sr.initialized).toBeTruthy()
expect(sr.recording).toBeTruthy()
expect(sr.recorder.recording).toBeTruthy()
sr.ee.emit(SESSION_EVENTS.RESET)
expect(global.XMLHttpRequest).toHaveBeenCalled()
expect(sr.recording).toBeFalsy()
expect(sr.recorder.recording).toBeFalsy()
expect(sr.blocked).toBeTruthy()
})

Expand All @@ -100,11 +103,11 @@ describe('Session Replay', () => {
sr.ee.emit('rumresp-sr', [true])
await wait(1)
expect(sr.initialized).toBeTruthy()
expect(sr.recording).toBeTruthy()
expect(sr.recorder.recording).toBeTruthy()
sr.ee.emit(SESSION_EVENTS.PAUSE)
expect(sr.recording).toBeFalsy()
expect(sr.recorder.recording).toBeFalsy()
sr.ee.emit(SESSION_EVENTS.RESUME)
expect(sr.recording).toBeTruthy()
expect(sr.recorder.recording).toBeTruthy()
})

test('Session SR mode matches SR mode -- FULL', async () => {
Expand Down Expand Up @@ -135,13 +138,13 @@ describe('Session Replay', () => {
// do not emit sr flag
await wait(1000)
expect(sr.initialized).toEqual(false)
expect(sr.recording).toEqual(false)
expect(sr.recorder).toBeUndefined()

// emit a false flag
sr.ee.emit('rumresp-sr', [false])
await wait(1)
expect(sr.initialized).toEqual(true)
expect(sr.recording).toEqual(false)
expect(sr.recorder).toBeUndefined()
})

test('Does not run if cookies_enabled is false', async () => {
Expand All @@ -150,7 +153,7 @@ describe('Session Replay', () => {
sr.ee.emit('rumresp-sr', [true])
await wait(1)
expect(sr.initialized).toEqual(false)
expect(sr.recording).toEqual(false)
expect(sr.recorder).toBeUndefined()
})

test('Does not run if session_trace is disabled', async () => {
Expand All @@ -159,7 +162,7 @@ describe('Session Replay', () => {
sr.ee.emit('rumresp-sr', [true])
await wait(1)
expect(sr.initialized).toEqual(false)
expect(sr.recording).toEqual(false)
expect(sr.recorder).toBeUndefined()
})
})

Expand Down Expand Up @@ -281,7 +284,7 @@ describe('Session Replay', () => {
})
expect(harvestContents.qs.attributes.includes('content_encoding')).toEqual(false)
expect(harvestContents.qs.attributes.includes('isFirstChunk')).toEqual(true)
expect(harvestContents.body).toEqual(expect.any(Array))
expect(harvestContents.body).toEqual(expect.any(Object))
})

test('Clears the event buffer when staged for harvesting', async () => {
Expand All @@ -291,14 +294,15 @@ describe('Session Replay', () => {
await wait(1)

sr.prepareHarvest()
expect(sr.events.length).toEqual(0)
expect(sr.recorder.getEvents().events.length).toEqual(0)
})

test('Harvests early if exceeds limit', async () => {
let after = 0
const spy = jest.spyOn(sr.scheduler, 'runHarvest').mockImplementation(() => { after = Date.now() })
setConfiguration(agentIdentifier, { ...init })
sr.payloadBytesEstimation = IDEAL_PAYLOAD_SIZE / AVG_COMPRESSION
sr.recorder = new Recorder(sr)
sr.recorder.currentBufferTarget.payloadBytesEstimation = IDEAL_PAYLOAD_SIZE / AVG_COMPRESSION
const before = Date.now()
sr.ee.emit('rumresp-sr', [true])
await wait(1)
Expand All @@ -307,11 +311,14 @@ describe('Session Replay', () => {
})

test('Aborts if exceeds total limit', async () => {
const spy = jest.spyOn(sr.scheduler, 'runHarvest')
const spy = jest.spyOn(sr.scheduler.harvest, '_send')
setConfiguration(agentIdentifier, { ...init })
sr.payloadBytesEstimation = (MAX_PAYLOAD_SIZE + 1) / AVG_COMPRESSION
sr.shouldCompress = false
sr.recorder = new Recorder(sr)
Array.from({ length: 100000 }).forEach(() => sr.recorder.currentBufferTarget.add({ test: 1 })) // fill the events array with tons of events
sr.recorder.currentBufferTarget.payloadBytesEstimation = sr.recorder.currentBufferTarget.events.join('').length
sr.ee.emit('rumresp-sr', [true])
await wait(1)
await wait(100)
expect(spy).not.toHaveBeenCalled()
expect(sr.blocked).toEqual(true)
expect(sr.mode).toEqual(MODE.OFF)
Expand Down
Loading

0 comments on commit 1af7b89

Please sign in to comment.