Skip to content

Commit

Permalink
feat: Adjust Session Replay Error Tracking (#951)
Browse files Browse the repository at this point in the history
  • Loading branch information
metal-messiah authored Apr 8, 2024
1 parent 3f7a6f5 commit 91d65b5
Show file tree
Hide file tree
Showing 13 changed files with 250 additions and 113 deletions.
21 changes: 15 additions & 6 deletions src/features/jserrors/aggregate/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,13 @@ export class Aggregate extends AggregateBase {
this.bufferedErrorsUnderSpa = {}
this.currentBody = undefined
this.errorOnPage = false
this.replayAborted = false

// this will need to change to match whatever ee we use in the instrument
this.ee.on('interactionDone', (interaction, wasSaved) => this.onInteractionDone(interaction, wasSaved))

this.ee.on('REPLAY_ABORTED', () => { this.replayAborted = true })

register('err', (...args) => this.storeError(...args), this.featureName, this.ee)
register('ierr', (...args) => this.storeError(...args), this.featureName, this.ee)
register('softNavFlush', (interactionId, wasFinished, softNavAttrs) =>
Expand Down Expand Up @@ -78,9 +81,16 @@ export class Aggregate extends AggregateBase {
payload.qs.ri = releaseIds
}

if (body && body.err && body.err.length && !this.errorOnPage) {
payload.qs.pve = '1'
this.errorOnPage = true
if (body && body.err && body.err.length) {
if (this.replayAborted) {
body.err.forEach((e) => {
delete e.params?.hasReplay
})
}
if (!this.errorOnPage) {
payload.qs.pve = '1'
this.errorOnPage = true
}
}
return payload
}
Expand Down Expand Up @@ -133,7 +143,7 @@ export class Aggregate extends AggregateBase {
return canonicalStackString
}

storeError (err, time, internal, customAttributes) {
storeError (err, time, internal, customAttributes, hasReplay) {
// are we in an interaction
time = time || now()
const agentRuntime = getRuntime(this.agentIdentifier)
Expand Down Expand Up @@ -189,7 +199,7 @@ export class Aggregate extends AggregateBase {
this.pageviewReported[bucketHash] = true
}

if (agentRuntime?.session?.state?.sessionReplayMode) params.hasReplay = true
if (hasReplay && !this.replayAborted) params.hasReplay = hasReplay
params.firstOccurrenceTimestamp = this.observedAt[bucketHash]
params.timestamp = this.observedAt[bucketHash]

Expand All @@ -199,7 +209,6 @@ export class Aggregate extends AggregateBase {
// Trace sends the error in its payload, and both trace & replay simply listens for any error to occur.
const jsErrorEvent = [type, bucketHash, params, newMetrics, customAttributes]
handle('errorAgg', jsErrorEvent, undefined, FEATURE_NAMES.sessionTrace, this.ee)
handle('errorAgg', jsErrorEvent, undefined, FEATURE_NAMES.sessionReplay, this.ee)
// still send EE events for other features such as above, but stop this one from aggregating internal data
if (this.blocked) return

Expand Down
13 changes: 9 additions & 4 deletions src/features/jserrors/instrument/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import { eventListenerOpts } from '../../../common/event-listener/event-listener
import { stringify } from '../../../common/util/stringify'
import { UncaughtError } from './uncaught-error'
import { now } from '../../../common/timing/now'
import { SR_EVENT_EMITTER_TYPES } from '../../session_replay/constants'

export class Instrument extends InstrumentBase {
static featureName = FEATURE_NAME

#seenErrors = new Set()
#replayRunning = false

constructor (agentIdentifier, aggregator, auto = true) {
super(agentIdentifier, aggregator, FEATURE_NAME, auto)
Expand All @@ -36,13 +38,16 @@ export class Instrument extends InstrumentBase {

this.ee.on('internal-error', (error) => {
if (!this.abortHandler) return
handle('ierr', [this.#castError(error), now(), true], undefined, FEATURE_NAMES.jserrors, this.ee)
handle('ierr', [this.#castError(error), now(), true, {}, this.#replayRunning], undefined, FEATURE_NAMES.jserrors, this.ee)
})

this.ee.on(SR_EVENT_EMITTER_TYPES.REPLAY_RUNNING, (isRunning) => {
this.#replayRunning = isRunning
})

globalScope.addEventListener('unhandledrejection', (promiseRejectionEvent) => {
if (!this.abortHandler) return

handle('err', [this.#castPromiseRejectionEvent(promiseRejectionEvent), now(), false, { unhandledPromiseRejection: 1 }], undefined, FEATURE_NAMES.jserrors, this.ee)
handle('err', [this.#castPromiseRejectionEvent(promiseRejectionEvent), now(), false, { unhandledPromiseRejection: 1 }, this.#replayRunning], undefined, FEATURE_NAMES.jserrors, this.ee)
}, eventListenerOpts(false, this.removeOnAbort?.signal))

globalScope.addEventListener('error', (errorEvent) => {
Expand All @@ -57,7 +62,7 @@ export class Instrument extends InstrumentBase {
return
}

handle('err', [this.#castErrorEvent(errorEvent), now()], undefined, FEATURE_NAMES.jserrors, this.ee)
handle('err', [this.#castErrorEvent(errorEvent), now(), false, {}, this.#replayRunning], undefined, FEATURE_NAMES.jserrors, this.ee)
}, eventListenerOpts(false, this.removeOnAbort?.signal))

this.abortHandler = this.#abort // we also use this as a flag to denote that the feature is active or on and handling errors
Expand Down
34 changes: 16 additions & 18 deletions src/features/session_replay/aggregate/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import { now } from '../../../common/timing/now'

export class Aggregate extends AggregateBase {
static featureName = FEATURE_NAME
mode = MODE.OFF

// pass the recorder into the aggregator
constructor (agentIdentifier, aggregator, args) {
super(agentIdentifier, aggregator, FEATURE_NAME)
Expand All @@ -44,17 +46,15 @@ export class Aggregate extends AggregateBase {
this.gzipper = undefined
/** populated with the u8 string lib async */
this.u8 = undefined
/** the mode to start in. Defaults to off */
const { session } = getRuntime(this.agentIdentifier)
this.mode = session.state.sessionReplayMode || MODE.OFF

/** set by BCS response */
this.entitled = false
/** set at BCS response, stored in runtime */
this.timeKeeper = undefined

this.recorder = args?.recorder
if (this.recorder) this.recorder.parent = this
this.preloaded = !!this.recorder
this.errorNoticed = args?.errorNoticed || false

handle(SUPPORTABILITY_METRIC_CHANNEL, ['Config/SessionReplay/Enabled'], undefined, FEATURE_NAMES.metrics, this.ee)

Expand Down Expand Up @@ -104,17 +104,6 @@ export class Aggregate extends AggregateBase {
this.forceStop(this.mode !== MODE.ERROR)
}, this.featureName, this.ee)

// Wait for an error to be reported. This currently is wrapped around the "Error" feature. This is a feature-feature dependency.
// This was to ensure that all errors, including those on the page before load and those handled with "noticeError" are accounted for. Needs evalulation
registerHandler('errorAgg', (e) => {
this.errorNoticed = true
if (this.recorder) this.recorder.currentBufferTarget.hasError = true
// run once
if (this.mode === MODE.ERROR && globalScope?.document.visibilityState === 'visible') {
this.switchToFull()
}
}, this.featureName, this.ee)

const { error_sampling_rate, sampling_rate, autoStart, block_selector, mask_text_selector, mask_all_inputs, inline_stylesheet, inline_images, collect_fonts } = getConfigurationValue(this.agentIdentifier, 'session_replay')

this.waitForFlags(['sr']).then(([flagOn]) => {
Expand Down Expand Up @@ -150,6 +139,14 @@ export class Aggregate extends AggregateBase {
handle(SUPPORTABILITY_METRIC_CHANNEL, ['Config/SessionReplay/ErrorSamplingRate/Value', error_sampling_rate], undefined, FEATURE_NAMES.metrics, this.ee)
}

handleError (e) {
if (this.recorder) this.recorder.currentBufferTarget.hasError = true
// run once
if (this.mode === MODE.ERROR && globalScope?.document.visibilityState === 'visible') {
this.switchToFull()
}
}

switchToFull () {
this.mode = MODE.FULL
// if the error was noticed AFTER the recorder was already imported....
Expand Down Expand Up @@ -210,12 +207,13 @@ export class Aggregate extends AggregateBase {
} catch (err) {
return this.abort(ABORT_REASONS.IMPORT)
}
} else {
this.recorder.parent = this
}

// If an error was noticed before the mode could be set (like in the early lifecycle of the page), immediately set to FULL mode
if (this.mode === MODE.ERROR && this.errorNoticed) {
this.mode = MODE.FULL
}
if (this.mode === MODE.ERROR && this.errorNoticed) this.mode = MODE.FULL
if (!this.preloaded) this.ee.on('err', e => this.handleError(e))

// FULL mode records AND reports from the beginning, while ERROR mode only records (but does not report).
// ERROR mode will do this until an error is thrown, and then switch into FULL mode.
Expand Down
3 changes: 2 additions & 1 deletion src/features/session_replay/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ export const FEATURE_NAME = FEATURE_NAMES.sessionReplay

export const SR_EVENT_EMITTER_TYPES = {
RECORD: 'recordReplay',
PAUSE: 'pauseReplay'
PAUSE: 'pauseReplay',
REPLAY_RUNNING: 'replayRunning'
}

export const AVG_COMPRESSION = 0.12
Expand Down
9 changes: 7 additions & 2 deletions src/features/session_replay/instrument/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export class Instrument extends InstrumentBase {
} catch (err) { }

if (this.#canPreloadRecorder(session)) {
/** If this is preloaded, set up a buffer, if not, later when sampling we will set up a .on for live events */
this.ee.on('err', (e) => {
this.errorNoticed = true
if (this.featAggregate) this.featAggregate.handleError()
})
this.#startRecording(session?.sessionReplayMode)
} else {
this.importAggregator()
Expand All @@ -45,9 +50,9 @@ export class Instrument extends InstrumentBase {

async #startRecording (mode) {
const { Recorder } = (await import(/* webpackChunkName: "recorder" */'../shared/recorder'))
this.recorder = new Recorder({ mode, agentIdentifier: this.agentIdentifier })
this.recorder = new Recorder({ mode, agentIdentifier: this.agentIdentifier, ee: this.ee })
this.recorder.startRecording()
this.abortHandler = this.recorder.stopRecording
this.importAggregator({ recorder: this.recorder })
this.importAggregator({ recorder: this.recorder, errorNoticed: this.errorNoticed })
}
}
5 changes: 4 additions & 1 deletion src/features/session_replay/shared/recorder.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { record as recorder } from 'rrweb'
import { stringify } from '../../../common/util/stringify'
import { AVG_COMPRESSION, CHECKOUT_MS, IDEAL_PAYLOAD_SIZE, QUERY_PARAM_PADDING, RRWEB_EVENT_TYPES } from '../constants'
import { AVG_COMPRESSION, CHECKOUT_MS, IDEAL_PAYLOAD_SIZE, QUERY_PARAM_PADDING, RRWEB_EVENT_TYPES, SR_EVENT_EMITTER_TYPES } from '../constants'
import { getConfigurationValue } from '../../../common/config/config'
import { RecorderEvents } from './recorder-events'
import { MODE } from '../../../common/session/constants'
Expand Down Expand Up @@ -99,8 +99,11 @@ export class Recorder {
checkoutEveryNms: CHECKOUT_MS[this.parent.mode]
})

this.parent.ee.emit(SR_EVENT_EMITTER_TYPES.REPLAY_RUNNING, [true, this.parent.mode])

this.stopRecording = () => {
this.recording = false
this.parent.ee.emit(SR_EVENT_EMITTER_TYPES.REPLAY_RUNNING, [false, this.parent.mode])
stop()
}
}
Expand Down
11 changes: 10 additions & 1 deletion src/loaders/api/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { gosCDN } from '../../common/window/nreum'
import { apiMethods, asyncApiMethods } from './api-methods'
import { SR_EVENT_EMITTER_TYPES } from '../../features/session_replay/constants'
import { now } from '../../common/timing/now'
import { MODE } from '../../common/session/constants'

export function setTopLevelCallers () {
const nr = gosCDN()
Expand All @@ -33,12 +34,20 @@ export function setTopLevelCallers () {
}
}

const replayRunning = {}

export function setAPI (agentIdentifier, forceDrain, runSoftNavOverSpa = false) {
if (!forceDrain) registerDrain(agentIdentifier, 'api')
const apiInterface = {}
var instanceEE = ee.get(agentIdentifier)
var tracerEE = instanceEE.get('tracer')

replayRunning[agentIdentifier] = MODE.OFF

instanceEE.on(SR_EVENT_EMITTER_TYPES.REPLAY_RUNNING, (isRunning) => {
replayRunning[agentIdentifier] = isRunning
})

var prefix = 'api-'
var spaPrefix = prefix + 'ixn-'

Expand Down Expand Up @@ -184,7 +193,7 @@ export function setAPI (agentIdentifier, forceDrain, runSoftNavOverSpa = false)
apiInterface.noticeError = function (err, customAttributes) {
if (typeof err === 'string') err = new Error(err)
handle(SUPPORTABILITY_METRIC_CHANNEL, ['API/noticeError/called'], undefined, FEATURE_NAMES.metrics, instanceEE)
handle('err', [err, now(), false, customAttributes], undefined, FEATURE_NAMES.jserrors, instanceEE)
handle('err', [err, now(), false, customAttributes, !!replayRunning[agentIdentifier]], undefined, FEATURE_NAMES.jserrors, instanceEE)
}

// theres no window.load event on non-browser scopes, lazy load immediately
Expand Down
58 changes: 58 additions & 0 deletions tests/assets/rrweb-split-errors.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<!--
Copyright 2020 New Relic Corporation.
PDX-License-Identifier: Apache-2.0
-->
<html>
<head>
<title>RUM Unit Test</title>
<style>
.left {
position: absolute;
left: 50px;
top: 200px;
}
.right {
position: absolute;
right: 50px;
top: 200px;
}
</style>
<link rel="stylesheet" type="text/css" href="style.css" />
{init} {config} {loader}
<script>
window.wasPreloaded = false;
window.addEventListener("load", () => {
try {
window.wasPreloaded = !!Object.values(newrelic.initializedAgents)[0].features.session_replay.recorder
} catch (err) {
// do nothing because it failed to get the recorder -- which inherently also means it was not preloaded
}
});
throw new Error('before load')
</script>
</head>
<body>
this is a page that provides several types of elements with selectors that session_replay can interact with based on how it is configured
<hr />
<hr />
<textarea id="plain"></textarea>
<textarea id="ignore" class="nr-ignore"></textarea>
<textarea id="block" class="nr-block"></textarea>
<textarea id="mask" class="nr-mask"></textarea>
<textarea id="nr-block" data-nr-block></textarea>
<textarea id="other-block" data-other-block></textarea>
<input type="password" id="pass-input" />
<input type="text" id="text-input" />
<hr />
<button onclick="moveImage()">Click</button>
<img src="https://upload.wikimedia.org/wikipedia/commons/d/d7/House_of_Commons_Chamber_1.png" />
<a href="./rrweb-instrumented.html" target="_blank">New Tab</a>
<script>
function moveImage() {
document.querySelector("img").classList.toggle("left");
document.querySelector("img").classList.toggle("right");
}
</script>
</body>
</html>
6 changes: 3 additions & 3 deletions tests/components/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ describe('setAPI', () => {
)
expect(handleModule.handle).toHaveBeenCalledWith(
'err',
[expect.any(Error), expect.toBeNumber(), false, undefined],
[expect.any(Error), expect.toBeNumber(), false, undefined, false],
undefined,
FEATURE_NAMES.jserrors,
instanceEE
Expand All @@ -566,7 +566,7 @@ describe('setAPI', () => {

expect(handleModule.handle).toHaveBeenCalledWith(
'err',
[args[0], expect.toBeNumber(), false, undefined],
[args[0], expect.toBeNumber(), false, undefined, false],
undefined,
FEATURE_NAMES.jserrors,
instanceEE
Expand All @@ -583,7 +583,7 @@ describe('setAPI', () => {

expect(handleModule.handle).toHaveBeenCalledWith(
'err',
[args[0], expect.toBeNumber(), false, args[1]],
[args[0], expect.toBeNumber(), false, args[1], false],
undefined,
FEATURE_NAMES.jserrors,
instanceEE
Expand Down
12 changes: 6 additions & 6 deletions tests/components/session_replay/aggregate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,14 @@ describe('Session Replay', () => {
})

describe('Session Replay Error Mode Behaviors', () => {
test('An error BEFORE rrweb import starts running in FULL from beginning', async () => {
setConfiguration(agentIdentifier, { session_replay: { error_sampling_rate: 100, sampling_rate: 0 } })
handle('errorAgg', ['test1'], undefined, FEATURE_NAMES.sessionReplay, ee.get(agentIdentifier))
test('An error BEFORE rrweb import starts running in ERROR from beginning (when not preloaded)', async () => {
setConfiguration(agentIdentifier, { session_replay: { preload: false, error_sampling_rate: 100, sampling_rate: 0 } })
handle('err', ['test1'], undefined, FEATURE_NAMES.sessionReplay, ee.get(agentIdentifier))
sr = new SessionReplayAgg(agentIdentifier, new Aggregator({}))
sr.ee.emit('rumresp', [{ sr: 1 }])
await wait(100)
expect(sr.mode).toEqual(MODE.FULL)
expect(sr.scheduler.started).toEqual(true)
expect(sr.mode).toEqual(MODE.ERROR)
expect(sr.scheduler.started).toEqual(false)
})

test('An error AFTER rrweb import changes mode and starts harvester', async () => {
Expand All @@ -229,7 +229,7 @@ describe('Session Replay', () => {
await wait(1)
expect(sr.mode).toEqual(MODE.ERROR)
expect(sr.scheduler.started).toEqual(false)
sr.ee.emit('errorAgg', ['test2'])
sr.ee.emit('err', ['test2'])
expect(sr.mode).toEqual(MODE.FULL)
expect(sr.scheduler.started).toEqual(true)
})
Expand Down
Loading

0 comments on commit 91d65b5

Please sign in to comment.