@newrelic/browser-agent 1.249.0 → 1.250.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/cjs/common/config/state/init.js +1 -1
- package/dist/cjs/common/constants/env.cdn.js +1 -1
- package/dist/cjs/common/constants/env.npm.js +1 -1
- package/dist/cjs/common/harvest/harvest-scheduler.js +2 -2
- package/dist/cjs/common/harvest/harvest.js +4 -3
- package/dist/cjs/common/ids/unique-id.js +1 -1
- package/dist/cjs/common/session/constants.js +20 -2
- package/dist/cjs/common/session/session-entity.js +8 -26
- package/dist/cjs/common/url/encode.js +2 -0
- package/dist/cjs/features/metrics/aggregate/index.js +3 -1
- package/dist/cjs/features/session_replay/aggregate/index.js +114 -277
- package/dist/cjs/features/session_replay/constants.js +57 -2
- package/dist/cjs/features/session_replay/instrument/index.js +38 -16
- package/dist/cjs/features/session_replay/shared/recorder-events.js +31 -0
- package/dist/cjs/features/session_replay/shared/recorder.js +155 -0
- package/dist/cjs/features/session_replay/{replay-mode.js → shared/replay-mode.js} +5 -5
- package/dist/cjs/features/session_trace/aggregate/index.js +25 -25
- package/dist/esm/common/config/state/init.js +1 -1
- package/dist/esm/common/constants/env.cdn.js +1 -1
- package/dist/esm/common/constants/env.npm.js +1 -1
- package/dist/esm/common/harvest/harvest-scheduler.js +1 -1
- package/dist/esm/common/harvest/harvest.js +4 -3
- package/dist/esm/common/ids/unique-id.js +1 -1
- package/dist/esm/common/session/constants.js +16 -1
- package/dist/esm/common/session/session-entity.js +2 -16
- package/dist/esm/common/url/encode.js +2 -0
- package/dist/esm/features/metrics/aggregate/index.js +3 -1
- package/dist/esm/features/session_replay/aggregate/index.js +95 -254
- package/dist/esm/features/session_replay/constants.js +49 -1
- package/dist/esm/features/session_replay/instrument/index.js +24 -1
- package/dist/esm/features/session_replay/shared/recorder-events.js +24 -0
- package/dist/esm/features/session_replay/shared/recorder.js +148 -0
- package/dist/esm/features/session_replay/{replay-mode.js → shared/replay-mode.js} +4 -4
- package/dist/esm/features/session_trace/aggregate/index.js +2 -2
- package/dist/types/common/harvest/harvest.d.ts +1 -1
- package/dist/types/common/harvest/harvest.d.ts.map +1 -1
- package/dist/types/common/session/constants.d.ts +15 -0
- package/dist/types/common/session/session-entity.d.ts +0 -15
- package/dist/types/common/session/session-entity.d.ts.map +1 -1
- package/dist/types/common/url/encode.d.ts +1 -1
- package/dist/types/common/url/encode.d.ts.map +1 -1
- package/dist/types/features/metrics/aggregate/index.d.ts.map +1 -1
- package/dist/types/features/session_replay/aggregate/index.d.ts +7 -63
- package/dist/types/features/session_replay/aggregate/index.d.ts.map +1 -1
- package/dist/types/features/session_replay/constants.d.ts +55 -0
- package/dist/types/features/session_replay/constants.d.ts.map +1 -1
- package/dist/types/features/session_replay/instrument/index.d.ts +2 -0
- package/dist/types/features/session_replay/instrument/index.d.ts.map +1 -1
- package/dist/types/features/session_replay/shared/recorder-events.d.ts +21 -0
- package/dist/types/features/session_replay/shared/recorder-events.d.ts.map +1 -0
- package/dist/types/features/session_replay/shared/recorder.d.ts +40 -0
- package/dist/types/features/session_replay/shared/recorder.d.ts.map +1 -0
- package/dist/types/features/session_replay/shared/replay-mode.d.ts.map +1 -0
- package/dist/types/features/session_trace/aggregate/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/common/config/state/init.js +1 -1
- package/src/common/harvest/harvest-scheduler.js +1 -1
- package/src/common/harvest/harvest.js +4 -3
- package/src/common/ids/unique-id.js +1 -1
- package/src/common/session/__mocks__/session-entity.js +0 -6
- package/src/common/session/constants.js +18 -0
- package/src/common/session/session-entity.js +1 -17
- package/src/common/url/encode.js +2 -1
- package/src/features/metrics/aggregate/index.js +3 -1
- package/src/features/session_replay/aggregate/index.js +88 -246
- package/src/features/session_replay/constants.js +45 -0
- package/src/features/session_replay/instrument/index.js +18 -1
- package/src/features/session_replay/shared/recorder-events.js +25 -0
- package/src/features/session_replay/shared/recorder.js +145 -0
- package/src/features/session_replay/{replay-mode.js → shared/replay-mode.js} +4 -4
- package/src/features/session_trace/aggregate/index.js +2 -2
- package/dist/types/features/session_replay/replay-mode.d.ts.map +0 -1
- /package/dist/types/features/session_replay/{replay-mode.d.ts → shared/replay-mode.d.ts} +0 -0
|
@@ -4,7 +4,7 @@ import { stringify } from '../util/stringify'
|
|
|
4
4
|
import { ee } from '../event-emitter/contextual-ee'
|
|
5
5
|
import { Timer } from '../timer/timer'
|
|
6
6
|
import { isBrowserScope } from '../constants/runtime'
|
|
7
|
-
import { DEFAULT_EXPIRES_MS, DEFAULT_INACTIVE_MS, PREFIX } from './constants'
|
|
7
|
+
import { DEFAULT_EXPIRES_MS, DEFAULT_INACTIVE_MS, MODE, PREFIX, SESSION_EVENTS, SESSION_EVENT_TYPES } from './constants'
|
|
8
8
|
import { InteractionTimer } from '../timer/interaction-timer'
|
|
9
9
|
import { wrapEvents } from '../wrap'
|
|
10
10
|
import { getModeledObject } from '../config/state/configurable'
|
|
@@ -13,11 +13,6 @@ import { SUPPORTABILITY_METRIC_CHANNEL } from '../../features/metrics/constants'
|
|
|
13
13
|
import { FEATURE_NAMES } from '../../loaders/features/features'
|
|
14
14
|
import { windowAddEventListener } from '../event-listener/event-listener-opts'
|
|
15
15
|
|
|
16
|
-
export const MODE = {
|
|
17
|
-
OFF: 0,
|
|
18
|
-
FULL: 1,
|
|
19
|
-
ERROR: 2
|
|
20
|
-
}
|
|
21
16
|
// this is what can be stored in local storage (not enforced but probably should be)
|
|
22
17
|
// these values should sync between local storage and the parent class props
|
|
23
18
|
const model = {
|
|
@@ -31,17 +26,6 @@ const model = {
|
|
|
31
26
|
traceHarvestStarted: false,
|
|
32
27
|
custom: {}
|
|
33
28
|
}
|
|
34
|
-
export const SESSION_EVENTS = {
|
|
35
|
-
PAUSE: 'session-pause',
|
|
36
|
-
RESET: 'session-reset',
|
|
37
|
-
RESUME: 'session-resume',
|
|
38
|
-
UPDATE: 'session-update'
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export const SESSION_EVENT_TYPES = {
|
|
42
|
-
SAME_TAB: 'same-tab',
|
|
43
|
-
CROSS_TAB: 'cross-tab'
|
|
44
|
-
}
|
|
45
29
|
|
|
46
30
|
export class SessionEntity {
|
|
47
31
|
/**
|
package/src/common/url/encode.js
CHANGED
|
@@ -67,7 +67,8 @@ export function obj (payload, maxBytes) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// Constructs an HTTP parameter to add to the BAM router URL
|
|
70
|
-
export function param (name, value) {
|
|
70
|
+
export function param (name, value, base = {}) {
|
|
71
|
+
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
|
|
71
72
|
if (value && typeof (value) === 'string') {
|
|
72
73
|
return '&' + name + '=' + qs(value)
|
|
73
74
|
}
|
|
@@ -90,9 +90,11 @@ export class Aggregate extends AggregateBase {
|
|
|
90
90
|
if (rules.length > 0 && !validateRules(rules)) this.storeSupportabilityMetrics('Generic/Obfuscate/Invalid')
|
|
91
91
|
|
|
92
92
|
// Check if proxy for either chunks or beacon is being used
|
|
93
|
-
const { proxy } = getConfiguration(this.agentIdentifier)
|
|
93
|
+
const { proxy, privacy } = getConfiguration(this.agentIdentifier)
|
|
94
94
|
if (proxy.assets) this.storeSupportabilityMetrics('Config/AssetsUrl/Changed')
|
|
95
95
|
if (proxy.beacon) this.storeSupportabilityMetrics('Config/BeaconUrl/Changed')
|
|
96
|
+
|
|
97
|
+
if (!(isBrowserScope && privacy.cookies_enabled)) this.storeSupportabilityMetrics('Config/SessionTracking/Disabled')
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
eachSessionChecks () {
|
|
@@ -12,10 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
import { registerHandler } from '../../../common/event-emitter/register-handler'
|
|
14
14
|
import { HarvestScheduler } from '../../../common/harvest/harvest-scheduler'
|
|
15
|
-
import { FEATURE_NAME } from '../constants'
|
|
16
|
-
import { stringify } from '../../../common/util/stringify'
|
|
15
|
+
import { ABORT_REASONS, FEATURE_NAME, MAX_PAYLOAD_SIZE, QUERY_PARAM_PADDING, RRWEB_EVENT_TYPES } from '../constants'
|
|
17
16
|
import { getConfigurationValue, getInfo, getRuntime } from '../../../common/config/config'
|
|
18
|
-
import { SESSION_EVENTS, MODE, SESSION_EVENT_TYPES } from '../../../common/session/session-entity'
|
|
19
17
|
import { AggregateBase } from '../../utils/aggregate-base'
|
|
20
18
|
import { sharedChannel } from '../../../common/constants/shared-channel'
|
|
21
19
|
import { obj as encodeObj } from '../../../common/url/encode'
|
|
@@ -26,131 +24,65 @@ import { handle } from '../../../common/event-emitter/handle'
|
|
|
26
24
|
import { FEATURE_NAMES } from '../../../loaders/features/features'
|
|
27
25
|
import { RRWEB_VERSION } from '../../../common/constants/env'
|
|
28
26
|
import { now } from '../../../common/timing/now'
|
|
27
|
+
import { MODE, SESSION_EVENTS, SESSION_EVENT_TYPES } from '../../../common/session/constants'
|
|
28
|
+
import { stringify } from '../../../common/util/stringify'
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
export const RRWEB_EVENT_TYPES = {
|
|
33
|
-
DomContentLoaded: 0,
|
|
34
|
-
Load: 1,
|
|
35
|
-
FullSnapshot: 2,
|
|
36
|
-
IncrementalSnapshot: 3,
|
|
37
|
-
Meta: 4,
|
|
38
|
-
Custom: 5
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const ABORT_REASONS = {
|
|
42
|
-
RESET: {
|
|
43
|
-
message: 'Session was reset',
|
|
44
|
-
sm: 'Reset'
|
|
45
|
-
},
|
|
46
|
-
IMPORT: {
|
|
47
|
-
message: 'Recorder failed to import',
|
|
48
|
-
sm: 'Import'
|
|
49
|
-
},
|
|
50
|
-
TOO_MANY: {
|
|
51
|
-
message: '429: Too Many Requests',
|
|
52
|
-
sm: 'Too-Many'
|
|
53
|
-
},
|
|
54
|
-
TOO_BIG: {
|
|
55
|
-
message: 'Payload was too large',
|
|
56
|
-
sm: 'Too-Big'
|
|
57
|
-
},
|
|
58
|
-
CROSS_TAB: {
|
|
59
|
-
message: 'Session Entity was set to OFF on another tab',
|
|
60
|
-
sm: 'Cross-Tab'
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
let recorder, gzipper, u8
|
|
65
|
-
|
|
66
|
-
/** Vortex caps payload sizes at 1MB */
|
|
67
|
-
export const MAX_PAYLOAD_SIZE = 1000000
|
|
68
|
-
/** Unloading caps around 64kb */
|
|
69
|
-
export const IDEAL_PAYLOAD_SIZE = 64000
|
|
70
|
-
/** Reserved room for query param attrs */
|
|
71
|
-
const QUERY_PARAM_PADDING = 5000
|
|
72
|
-
/** Interval between forcing new full snapshots -- 15 seconds in error mode (x2), 5 minutes in full mode */
|
|
73
|
-
const CHECKOUT_MS = { [MODE.ERROR]: 15000, [MODE.FULL]: 300000, [MODE.OFF]: 0 }
|
|
30
|
+
let gzipper, u8
|
|
74
31
|
|
|
75
32
|
export class Aggregate extends AggregateBase {
|
|
76
33
|
static featureName = FEATURE_NAME
|
|
77
|
-
|
|
34
|
+
// pass the recorder into the aggregator
|
|
35
|
+
constructor (agentIdentifier, aggregator, args) {
|
|
78
36
|
super(agentIdentifier, aggregator, FEATURE_NAME)
|
|
79
|
-
/** Each page mutation or event will be stored (raw) in this array. This array will be cleared on each harvest */
|
|
80
|
-
this.events = []
|
|
81
|
-
/** Backlog used for a 2-part sliding window to guarantee a 15-30s buffer window */
|
|
82
|
-
this.backloggedEvents = []
|
|
83
37
|
/** The interval to harvest at. This gets overridden if the size of the payload exceeds certain thresholds */
|
|
84
38
|
this.harvestTimeSeconds = getConfigurationValue(this.agentIdentifier, 'session_replay.harvestTimeSeconds') || 60
|
|
85
39
|
/** Set once the recorder has fully initialized after flag checks and sampling */
|
|
86
40
|
this.initialized = false
|
|
87
|
-
/** Set once an error has been detected on the page. Never unset */
|
|
88
|
-
this.errorNoticed = false
|
|
89
|
-
/** The "mode" to record in. Defaults to "OFF" until flags and sampling are checked. See "MODE" constant. */
|
|
90
|
-
this.mode = MODE.OFF
|
|
91
41
|
/** Set once the feature has been "aborted" to prevent other side-effects from continuing */
|
|
92
42
|
this.blocked = false
|
|
93
|
-
/** True when actively recording, false when paused or stopped */
|
|
94
|
-
this.recording = false
|
|
95
43
|
/** can shut off efforts to compress the data */
|
|
96
44
|
this.shouldCompress = true
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
* -- When visibility changes from "hidden" -> "visible", it must capture a full snapshot for the replay to work correctly across tabs
|
|
101
|
-
*/
|
|
102
|
-
this.hasSnapshot = false
|
|
103
|
-
/** Payload metadata -- Should indicate that the payload being sent has a meta node. The meta node should always precede a snapshot node. */
|
|
104
|
-
this.hasMeta = false
|
|
105
|
-
/** Payload metadata -- Should indicate that the payload being sent contains an error. Used for query/filter purposes in UI */
|
|
106
|
-
this.hasError = false
|
|
107
|
-
|
|
108
|
-
/** Payload metadata -- Should indicate when a replay blob started recording. Resets each time a harvest occurs.
|
|
109
|
-
* cycle timestamps are used as fallbacks if event timestamps cannot be used
|
|
110
|
-
*/
|
|
111
|
-
this.cycleTimestamp = undefined
|
|
112
|
-
|
|
113
|
-
/** A value which increments with every new mutation node reported. Resets after a harvest is sent */
|
|
114
|
-
this.payloadBytesEstimation = 0
|
|
115
|
-
|
|
116
|
-
/** Hold on to the last meta node, so that it can be re-inserted if the meta and snapshot nodes are broken up due to harvesting */
|
|
117
|
-
this.lastMeta = undefined
|
|
45
|
+
/** the mode to start in. Defaults to off */
|
|
46
|
+
const { session } = getRuntime(this.agentIdentifier)
|
|
47
|
+
this.mode = session.state.sessionReplayMode || MODE.OFF
|
|
118
48
|
|
|
119
49
|
/** set by BCS response */
|
|
120
50
|
this.entitled = false
|
|
121
51
|
|
|
52
|
+
this.recorder = args?.recorder
|
|
53
|
+
if (this.recorder) this.recorder.parent = this
|
|
54
|
+
|
|
122
55
|
const shouldSetup = (
|
|
123
56
|
getConfigurationValue(agentIdentifier, 'privacy.cookies_enabled') === true &&
|
|
124
57
|
getConfigurationValue(agentIdentifier, 'session_trace.enabled') === true
|
|
125
58
|
)
|
|
126
59
|
|
|
127
|
-
/** The method to stop recording. This defaults to a noop, but is overwritten once the recording library is imported and initialized */
|
|
128
|
-
this.stopRecording = () => { /* no-op until set by rrweb initializer */ }
|
|
129
|
-
|
|
130
60
|
if (shouldSetup) {
|
|
131
61
|
// The SessionEntity class can emit a message indicating the session was cleared and reset (expiry, inactivity). This feature must abort and never resume if that occurs.
|
|
132
62
|
this.ee.on(SESSION_EVENTS.RESET, () => {
|
|
63
|
+
this.scheduler.runHarvest()
|
|
133
64
|
this.abort(ABORT_REASONS.RESET)
|
|
134
65
|
})
|
|
135
66
|
|
|
136
67
|
// The SessionEntity class can emit a message indicating the session was paused (visibility change). This feature must stop recording if that occurs.
|
|
137
|
-
this.ee.on(SESSION_EVENTS.PAUSE, () => { this.stopRecording() })
|
|
68
|
+
this.ee.on(SESSION_EVENTS.PAUSE, () => { this.recorder?.stopRecording() })
|
|
138
69
|
// The SessionEntity class can emit a message indicating the session was resumed (visibility change). This feature must start running again (if already running) if that occurs.
|
|
139
70
|
this.ee.on(SESSION_EVENTS.RESUME, () => {
|
|
71
|
+
if (!this.recorder) return
|
|
140
72
|
// if the mode changed on a different tab, it needs to update this instance to match
|
|
141
73
|
const { session } = getRuntime(this.agentIdentifier)
|
|
142
74
|
this.mode = session.state.sessionReplayMode
|
|
143
75
|
if (!this.initialized || this.mode === MODE.OFF) return
|
|
144
|
-
this.startRecording()
|
|
76
|
+
this.recorder?.startRecording()
|
|
145
77
|
})
|
|
146
78
|
|
|
147
79
|
this.ee.on(SESSION_EVENTS.UPDATE, (type, data) => {
|
|
148
|
-
if (!this.initialized || this.blocked || type !== SESSION_EVENT_TYPES.CROSS_TAB) return
|
|
80
|
+
if (!this.recorder || !this.initialized || this.blocked || type !== SESSION_EVENT_TYPES.CROSS_TAB) return
|
|
149
81
|
if (this.mode !== MODE.OFF && data.sessionReplayMode === MODE.OFF) this.abort(ABORT_REASONS.CROSS_TAB)
|
|
150
82
|
this.mode = data.sessionReplay
|
|
151
83
|
})
|
|
152
84
|
|
|
153
|
-
// Bespoke logic for
|
|
85
|
+
// Bespoke logic for blobs endpoint.
|
|
154
86
|
this.scheduler = new HarvestScheduler('browser/blobs', {
|
|
155
87
|
onFinished: this.onHarvestFinished.bind(this),
|
|
156
88
|
retryDelay: this.harvestTimeSeconds,
|
|
@@ -162,7 +94,7 @@ export class Aggregate extends AggregateBase {
|
|
|
162
94
|
// if it has aborted or BCS returned bad entitlements, do not allow
|
|
163
95
|
if (this.blocked || !this.entitled) return
|
|
164
96
|
// if it isnt already (fully) initialized... initialize it
|
|
165
|
-
if (!recorder) this.initializeRecording(false, true, true)
|
|
97
|
+
if (!this.recorder) this.initializeRecording(false, true, true)
|
|
166
98
|
// its been initialized and imported the recorder but its not recording (mode === off || error)
|
|
167
99
|
else if (this.mode !== MODE.FULL) this.switchToFull()
|
|
168
100
|
// if it gets all the way to here, that means a full session is already recording... do nothing
|
|
@@ -175,8 +107,8 @@ export class Aggregate extends AggregateBase {
|
|
|
175
107
|
// Wait for an error to be reported. This currently is wrapped around the "Error" feature. This is a feature-feature dependency.
|
|
176
108
|
// This was to ensure that all errors, including those on the page before load and those handled with "noticeError" are accounted for. Needs evalulation
|
|
177
109
|
registerHandler('errorAgg', (e) => {
|
|
178
|
-
this.hasError = true
|
|
179
110
|
this.errorNoticed = true
|
|
111
|
+
if (this.recorder) this.recorder.currentBufferTarget.hasError = true
|
|
180
112
|
// run once
|
|
181
113
|
if (this.mode === MODE.ERROR && globalScope?.document.visibilityState === 'visible') {
|
|
182
114
|
this.switchToFull()
|
|
@@ -185,6 +117,7 @@ export class Aggregate extends AggregateBase {
|
|
|
185
117
|
|
|
186
118
|
this.waitForFlags(['sr']).then(([flagOn]) => {
|
|
187
119
|
this.entitled = flagOn
|
|
120
|
+
if (!this.entitled && this.recorder?.recording) this.recorder.abort(ABORT_REASONS.ENTITLEMENTS)
|
|
188
121
|
this.initializeRecording(
|
|
189
122
|
(Math.random() * 100) < getConfigurationValue(this.agentIdentifier, 'session_replay.error_sampling_rate'),
|
|
190
123
|
(Math.random() * 100) < getConfigurationValue(this.agentIdentifier, 'session_replay.sampling_rate')
|
|
@@ -198,9 +131,9 @@ export class Aggregate extends AggregateBase {
|
|
|
198
131
|
switchToFull () {
|
|
199
132
|
this.mode = MODE.FULL
|
|
200
133
|
// if the error was noticed AFTER the recorder was already imported....
|
|
201
|
-
if (recorder && this.initialized) {
|
|
202
|
-
this.stopRecording()
|
|
203
|
-
this.startRecording()
|
|
134
|
+
if (this.recorder && this.initialized) {
|
|
135
|
+
this.recorder.stopRecording()
|
|
136
|
+
this.recorder.startRecording()
|
|
204
137
|
|
|
205
138
|
this.scheduler.startTimer(this.harvestTimeSeconds)
|
|
206
139
|
|
|
@@ -218,15 +151,15 @@ export class Aggregate extends AggregateBase {
|
|
|
218
151
|
*/
|
|
219
152
|
async initializeRecording (errorSample, fullSample, ignoreSession) {
|
|
220
153
|
this.initialized = true
|
|
221
|
-
if (!this.entitled
|
|
154
|
+
if (!this.entitled) return
|
|
222
155
|
|
|
223
|
-
const { session } = getRuntime(this.agentIdentifier)
|
|
224
156
|
// if theres an existing session replay in progress, there's no need to sample, just check the entitlements response
|
|
225
157
|
// if not, these sample flags need to be checked
|
|
226
158
|
// if this isnt the FIRST load of a session AND
|
|
227
159
|
// we are not actively recording SR... DO NOT import or run the recording library
|
|
228
160
|
// session replay samples can only be decided on the first load of a session
|
|
229
161
|
// session replays can continue if already in progress
|
|
162
|
+
const { session } = getRuntime(this.agentIdentifier)
|
|
230
163
|
if (!session.isNew && !ignoreSession) { // inherit the mode of the existing session
|
|
231
164
|
this.mode = session.state.sessionReplayMode
|
|
232
165
|
} else {
|
|
@@ -234,7 +167,20 @@ export class Aggregate extends AggregateBase {
|
|
|
234
167
|
if (fullSample) this.mode = MODE.FULL // full mode has precedence over error mode
|
|
235
168
|
else if (errorSample) this.mode = MODE.ERROR
|
|
236
169
|
// If neither are selected, then don't record (early return)
|
|
237
|
-
else
|
|
170
|
+
else {
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!this.recorder) {
|
|
176
|
+
try {
|
|
177
|
+
// Do not change the webpackChunkName or it will break the webpack nrba-chunking plugin
|
|
178
|
+
const { Recorder } = (await import(/* webpackChunkName: "recorder" */'../shared/recorder'))
|
|
179
|
+
this.recorder = new Recorder(this)
|
|
180
|
+
this.recorder.currentBufferTarget.hasError = this.errorNoticed
|
|
181
|
+
} catch (err) {
|
|
182
|
+
return this.abort(ABORT_REASONS.IMPORT)
|
|
183
|
+
}
|
|
238
184
|
}
|
|
239
185
|
|
|
240
186
|
// If an error was noticed before the mode could be set (like in the early lifecycle of the page), immediately set to FULL mode
|
|
@@ -242,17 +188,10 @@ export class Aggregate extends AggregateBase {
|
|
|
242
188
|
this.mode = MODE.FULL
|
|
243
189
|
}
|
|
244
190
|
|
|
245
|
-
try {
|
|
246
|
-
// Do not change the webpackChunkName or it will break the webpack nrba-chunking plugin
|
|
247
|
-
recorder = (await import(/* webpackChunkName: "recorder" */'rrweb')).record
|
|
248
|
-
} catch (err) {
|
|
249
|
-
return this.abort(ABORT_REASONS.IMPORT)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
191
|
// FULL mode records AND reports from the beginning, while ERROR mode only records (but does not report).
|
|
253
192
|
// ERROR mode will do this until an error is thrown, and then switch into FULL mode.
|
|
254
193
|
// If an error happened in ERROR mode before we've gotten to this stage, it will have already set the mode to FULL
|
|
255
|
-
if (this.mode === MODE.FULL) {
|
|
194
|
+
if (this.mode === MODE.FULL && !this.scheduler.started) {
|
|
256
195
|
// We only report (harvest) in FULL mode
|
|
257
196
|
this.scheduler.startTimer(this.harvestTimeSeconds)
|
|
258
197
|
}
|
|
@@ -266,63 +205,79 @@ export class Aggregate extends AggregateBase {
|
|
|
266
205
|
// compressor failed to load, but we can still record without compression as a last ditch effort
|
|
267
206
|
this.shouldCompress = false
|
|
268
207
|
}
|
|
269
|
-
this.startRecording()
|
|
208
|
+
if (!this.recorder.recording) this.recorder.startRecording()
|
|
270
209
|
|
|
271
210
|
this.syncWithSessionManager({ sessionReplayMode: this.mode })
|
|
272
211
|
}
|
|
273
212
|
|
|
274
213
|
prepareHarvest () {
|
|
275
|
-
if (
|
|
276
|
-
const
|
|
214
|
+
if (!this.recorder) return
|
|
215
|
+
const recorderEvents = this.recorder.getEvents()
|
|
216
|
+
// get the event type and use that to trigger another harvest if needed
|
|
217
|
+
if (!recorderEvents.events.length || (this.mode !== MODE.FULL) || this.blocked) return
|
|
218
|
+
|
|
219
|
+
const payload = this.getHarvestContents(recorderEvents)
|
|
277
220
|
if (!payload.body.length) {
|
|
278
|
-
this.clearBuffer()
|
|
221
|
+
this.recorder.clearBuffer()
|
|
279
222
|
return
|
|
280
223
|
}
|
|
224
|
+
|
|
225
|
+
let len = 0
|
|
281
226
|
if (this.shouldCompress) {
|
|
282
|
-
payload.body = gzipper(u8(
|
|
227
|
+
payload.body = gzipper(u8(`[${payload.body.map(e => e.__serialized).join(',')}]`))
|
|
228
|
+
len = payload.body.length
|
|
283
229
|
this.scheduler.opts.gzip = true
|
|
284
230
|
} else {
|
|
231
|
+
payload.body = payload.body.map(({ __serialized, ...node }) => node)
|
|
232
|
+
len = stringify(payload.body).length
|
|
285
233
|
this.scheduler.opts.gzip = false
|
|
286
234
|
}
|
|
235
|
+
|
|
236
|
+
if (len > MAX_PAYLOAD_SIZE) {
|
|
237
|
+
this.abort(ABORT_REASONS.TOO_BIG)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
287
240
|
// TODO -- Gracefully handle the buffer for retries.
|
|
288
241
|
const { session } = getRuntime(this.agentIdentifier)
|
|
289
242
|
if (!session.state.sessionReplaySentFirstChunk) this.syncWithSessionManager({ sessionReplaySentFirstChunk: true })
|
|
290
|
-
this.clearBuffer()
|
|
243
|
+
this.recorder.clearBuffer()
|
|
244
|
+
if (recorderEvents.type === 'preloaded') this.scheduler.runHarvest()
|
|
291
245
|
return [payload]
|
|
292
246
|
}
|
|
293
247
|
|
|
294
|
-
getHarvestContents () {
|
|
248
|
+
getHarvestContents (recorderEvents) {
|
|
249
|
+
recorderEvents ??= this.recorder.getEvents()
|
|
250
|
+
let events = recorderEvents.events
|
|
295
251
|
const agentRuntime = getRuntime(this.agentIdentifier)
|
|
296
252
|
const info = getInfo(this.agentIdentifier)
|
|
297
253
|
const endUserId = info.jsAttributes?.['enduser.id']
|
|
298
254
|
|
|
299
|
-
if (this.backloggedEvents.length) this.events = [...this.backloggedEvents, ...this.events]
|
|
300
|
-
|
|
301
255
|
// do not let the first node be a full snapshot node, since this NEEDS to be preceded by a meta node
|
|
302
256
|
// we will manually inject it if this happens
|
|
303
|
-
const payloadStartsWithFullSnapshot =
|
|
304
|
-
if (payloadStartsWithFullSnapshot && !!this.lastMeta) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
this.lastMeta = undefined
|
|
257
|
+
const payloadStartsWithFullSnapshot = events?.[0]?.type === RRWEB_EVENT_TYPES.FullSnapshot
|
|
258
|
+
if (payloadStartsWithFullSnapshot && !!this.recorder.lastMeta) {
|
|
259
|
+
recorderEvents.hasMeta = true
|
|
260
|
+
events.unshift(this.recorder.lastMeta) // --> pushed the meta from a previous payload into newer payload... but it still has old timestamps
|
|
261
|
+
this.recorder.lastMeta = undefined
|
|
308
262
|
}
|
|
309
263
|
|
|
310
264
|
// do not let the last node be a meta node, since this NEEDS to precede a snapshot
|
|
311
265
|
// we will manually inject it later if we find a payload that is missing a meta node
|
|
312
|
-
const payloadEndsWithMeta =
|
|
266
|
+
const payloadEndsWithMeta = events[events.length - 1]?.type === RRWEB_EVENT_TYPES.Meta
|
|
313
267
|
if (payloadEndsWithMeta) {
|
|
314
|
-
this.lastMeta =
|
|
315
|
-
|
|
316
|
-
|
|
268
|
+
this.recorder.lastMeta = events[events.length - 1]
|
|
269
|
+
events = events.slice(0, events.length - 1)
|
|
270
|
+
recorderEvents.hasMeta = !!events.find(x => x.type === RRWEB_EVENT_TYPES.Meta)
|
|
317
271
|
}
|
|
318
272
|
|
|
319
273
|
const agentOffset = getRuntime(this.agentIdentifier).offset
|
|
320
274
|
const relativeNow = now()
|
|
321
275
|
|
|
322
|
-
const firstEventTimestamp =
|
|
323
|
-
const lastEventTimestamp =
|
|
324
|
-
const firstTimestamp = firstEventTimestamp ||
|
|
276
|
+
const firstEventTimestamp = events[0]?.timestamp // from rrweb node
|
|
277
|
+
const lastEventTimestamp = events[events.length - 1]?.timestamp // from rrweb node
|
|
278
|
+
const firstTimestamp = firstEventTimestamp || recorderEvents.cycleTimestamp
|
|
325
279
|
const lastTimestamp = lastEventTimestamp || agentOffset + relativeNow
|
|
280
|
+
|
|
326
281
|
return {
|
|
327
282
|
qs: {
|
|
328
283
|
browser_monitoring_key: info.licenseKey,
|
|
@@ -337,23 +292,23 @@ export class Aggregate extends AggregateBase {
|
|
|
337
292
|
'replay.firstTimestampOffset': firstTimestamp - agentOffset,
|
|
338
293
|
'replay.lastTimestamp': lastTimestamp,
|
|
339
294
|
'replay.durationMs': lastTimestamp - firstTimestamp,
|
|
340
|
-
'replay.nodes':
|
|
295
|
+
'replay.nodes': events.length,
|
|
341
296
|
'session.durationMs': agentRuntime.session.getDuration(),
|
|
342
297
|
agentVersion: agentRuntime.version,
|
|
343
298
|
session: agentRuntime.session.state.value,
|
|
344
299
|
rst: relativeNow,
|
|
345
|
-
hasMeta:
|
|
346
|
-
hasSnapshot:
|
|
347
|
-
hasError:
|
|
300
|
+
hasMeta: recorderEvents.hasMeta || false,
|
|
301
|
+
hasSnapshot: recorderEvents.hasSnapshot || false,
|
|
302
|
+
hasError: recorderEvents.hasError || false,
|
|
348
303
|
isFirstChunk: agentRuntime.session.state.sessionReplaySentFirstChunk === false,
|
|
349
|
-
decompressedBytes:
|
|
304
|
+
decompressedBytes: recorderEvents.payloadBytesEstimation,
|
|
350
305
|
'rrweb.version': RRWEB_VERSION,
|
|
351
306
|
// customer-defined data should go last so that if it exceeds the query param padding limit it will be truncated instead of important attrs
|
|
352
307
|
...(endUserId && { 'enduser.id': endUserId })
|
|
353
308
|
// The Query Param is being arbitrarily limited in length here. It is also applied when estimating the size of the payload in getPayloadSize()
|
|
354
309
|
}, QUERY_PARAM_PADDING).substring(1) // remove the leading '&'
|
|
355
310
|
},
|
|
356
|
-
body:
|
|
311
|
+
body: events
|
|
357
312
|
}
|
|
358
313
|
}
|
|
359
314
|
|
|
@@ -366,111 +321,6 @@ export class Aggregate extends AggregateBase {
|
|
|
366
321
|
if (this.blocked) this.scheduler.stopTimer(true)
|
|
367
322
|
}
|
|
368
323
|
|
|
369
|
-
/** Clears the buffer (this.events), and resets all payload metadata properties */
|
|
370
|
-
clearBuffer () {
|
|
371
|
-
if (this.mode === MODE.ERROR) this.backloggedEvents = this.events
|
|
372
|
-
else this.backloggedEvents = []
|
|
373
|
-
this.events = []
|
|
374
|
-
this.hasSnapshot = false
|
|
375
|
-
this.hasMeta = false
|
|
376
|
-
this.hasError = false
|
|
377
|
-
this.payloadBytesEstimation = 0
|
|
378
|
-
this.clearTimestamps()
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/** Begin recording using configured recording lib */
|
|
382
|
-
startRecording () {
|
|
383
|
-
if (!recorder) {
|
|
384
|
-
warn('Recording library was never imported')
|
|
385
|
-
return this.abort(ABORT_REASONS.IMPORT)
|
|
386
|
-
}
|
|
387
|
-
this.recording = true
|
|
388
|
-
const { block_class, ignore_class, mask_text_class, block_selector, mask_input_options, mask_text_selector, mask_all_inputs, inline_images, inline_stylesheet, collect_fonts } = getConfigurationValue(this.agentIdentifier, 'session_replay')
|
|
389
|
-
// set up rrweb configurations for maximum privacy --
|
|
390
|
-
// https://newrelic.atlassian.net/wiki/spaces/O11Y/pages/2792293280/2023+02+28+Browser+-+Session+Replay#Configuration-options
|
|
391
|
-
const stop = recorder({
|
|
392
|
-
emit: this.store.bind(this),
|
|
393
|
-
blockClass: block_class,
|
|
394
|
-
ignoreClass: ignore_class,
|
|
395
|
-
maskTextClass: mask_text_class,
|
|
396
|
-
blockSelector: block_selector,
|
|
397
|
-
maskInputOptions: mask_input_options,
|
|
398
|
-
maskTextSelector: mask_text_selector,
|
|
399
|
-
maskAllInputs: mask_all_inputs,
|
|
400
|
-
inlineImages: inline_images,
|
|
401
|
-
inlineStylesheet: inline_stylesheet,
|
|
402
|
-
collectFonts: collect_fonts,
|
|
403
|
-
checkoutEveryNms: CHECKOUT_MS[this.mode]
|
|
404
|
-
})
|
|
405
|
-
|
|
406
|
-
this.stopRecording = () => {
|
|
407
|
-
this.recording = false
|
|
408
|
-
stop()
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/** Store a payload in the buffer (this.events). This should be the callback to the recording lib noticing a mutation */
|
|
413
|
-
store (event, isCheckout) {
|
|
414
|
-
this.setTimestamps()
|
|
415
|
-
if (this.blocked) return
|
|
416
|
-
const eventBytes = stringify(event).length
|
|
417
|
-
/** The estimated size of the payload after compression */
|
|
418
|
-
const payloadSize = this.getPayloadSize(eventBytes)
|
|
419
|
-
// Vortex will block payloads at a certain size, we might as well not send.
|
|
420
|
-
if (payloadSize > MAX_PAYLOAD_SIZE) {
|
|
421
|
-
this.clearBuffer()
|
|
422
|
-
return this.abort(ABORT_REASONS.TOO_BIG)
|
|
423
|
-
}
|
|
424
|
-
// Checkout events are flags by the recording lib that indicate a fullsnapshot was taken every n ms. These are important
|
|
425
|
-
// to help reconstruct the replay later and must be included. While waiting and buffering for errors to come through,
|
|
426
|
-
// each time we see a new checkout, we can drop the old data.
|
|
427
|
-
// we need to check for meta because rrweb will flag it as checkout twice, once for meta, then once for snapshot
|
|
428
|
-
if (this.mode === MODE.ERROR && isCheckout && event.type === RRWEB_EVENT_TYPES.Meta) {
|
|
429
|
-
// we are still waiting for an error to throw, so keep wiping the buffer over time
|
|
430
|
-
this.clearBuffer()
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// meta event
|
|
434
|
-
if (event.type === RRWEB_EVENT_TYPES.Meta) {
|
|
435
|
-
this.hasMeta = true
|
|
436
|
-
}
|
|
437
|
-
// snapshot event
|
|
438
|
-
if (event.type === RRWEB_EVENT_TYPES.FullSnapshot) {
|
|
439
|
-
this.hasSnapshot = true
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
this.events.push(event)
|
|
443
|
-
this.payloadBytesEstimation += eventBytes
|
|
444
|
-
|
|
445
|
-
// We are making an effort to try to keep payloads manageable for unloading. If they reach the unload limit before their interval,
|
|
446
|
-
// it will send immediately. This often happens on the first snapshot, which can be significantly larger than the other payloads.
|
|
447
|
-
if (payloadSize > IDEAL_PAYLOAD_SIZE && this.mode !== MODE.ERROR) {
|
|
448
|
-
// if we've made it to the ideal size of ~64kb before the interval timer, we should send early.
|
|
449
|
-
this.scheduler.runHarvest()
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/** force the recording lib to take a full DOM snapshot. This needs to occur in certain cases, like visibility changes */
|
|
454
|
-
takeFullSnapshot () {
|
|
455
|
-
if (!recorder) return
|
|
456
|
-
recorder.takeFullSnapshot()
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
setTimestamps () {
|
|
460
|
-
// fallbacks if timestamps cannot be derived from rrweb events
|
|
461
|
-
if (!this.cycleTimestamp) this.cycleTimestamp = getRuntime(this.agentIdentifier).offset + globalScope.performance.now()
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
clearTimestamps () {
|
|
465
|
-
this.cycleTimestamp = undefined
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
/** Estimate the payload size */
|
|
469
|
-
getPayloadSize (newBytes = 0) {
|
|
470
|
-
// the query param padding constant gives us some padding for the other metadata to be safely injected
|
|
471
|
-
return this.estimateCompression(this.payloadBytesEstimation + newBytes) + QUERY_PARAM_PADDING
|
|
472
|
-
}
|
|
473
|
-
|
|
474
324
|
/**
|
|
475
325
|
* Forces the agent into OFF mode so that changing tabs or navigating
|
|
476
326
|
* does not restart the recording. This is used when the customer calls
|
|
@@ -479,7 +329,7 @@ export class Aggregate extends AggregateBase {
|
|
|
479
329
|
forceStop (forceHarvest) {
|
|
480
330
|
if (forceHarvest) this.scheduler.runHarvest()
|
|
481
331
|
this.mode = MODE.OFF
|
|
482
|
-
this.stopRecording()
|
|
332
|
+
this.recorder?.stopRecording?.()
|
|
483
333
|
this.syncWithSessionManager({ sessionReplayMode: this.mode })
|
|
484
334
|
}
|
|
485
335
|
|
|
@@ -489,19 +339,11 @@ export class Aggregate extends AggregateBase {
|
|
|
489
339
|
handle(SUPPORTABILITY_METRIC_CHANNEL, [`SessionReplay/Abort/${reason.sm}`], undefined, FEATURE_NAMES.metrics, this.ee)
|
|
490
340
|
this.blocked = true
|
|
491
341
|
this.mode = MODE.OFF
|
|
492
|
-
this.stopRecording()
|
|
342
|
+
this.recorder?.stopRecording?.()
|
|
493
343
|
this.syncWithSessionManager({ sessionReplayMode: this.mode })
|
|
494
|
-
this.clearTimestamps()
|
|
344
|
+
this.recorder?.clearTimestamps?.()
|
|
495
345
|
this.ee.emit('REPLAY_ABORTED')
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
/** Extensive research has yielded about an 88% compression factor on these payloads.
|
|
499
|
-
* This is an estimation using that factor as to not cause performance issues while evaluating
|
|
500
|
-
* https://staging.onenr.io/037jbJWxbjy
|
|
501
|
-
* */
|
|
502
|
-
estimateCompression (data) {
|
|
503
|
-
if (this.shouldCompress) return data * AVG_COMPRESSION
|
|
504
|
-
return data
|
|
346
|
+
this.recorder?.clearBuffer?.()
|
|
505
347
|
}
|
|
506
348
|
|
|
507
349
|
syncWithSessionManager (state = {}) {
|
|
@@ -1,3 +1,48 @@
|
|
|
1
|
+
import { MODE } from '../../common/session/constants'
|
|
1
2
|
import { FEATURE_NAMES } from '../../loaders/features/features'
|
|
2
3
|
|
|
3
4
|
export const FEATURE_NAME = FEATURE_NAMES.sessionReplay
|
|
5
|
+
|
|
6
|
+
export const AVG_COMPRESSION = 0.12
|
|
7
|
+
export const RRWEB_EVENT_TYPES = {
|
|
8
|
+
DomContentLoaded: 0,
|
|
9
|
+
Load: 1,
|
|
10
|
+
FullSnapshot: 2,
|
|
11
|
+
IncrementalSnapshot: 3,
|
|
12
|
+
Meta: 4,
|
|
13
|
+
Custom: 5
|
|
14
|
+
}
|
|
15
|
+
/** Vortex caps payload sizes at 1MB */
|
|
16
|
+
export const MAX_PAYLOAD_SIZE = 1000000
|
|
17
|
+
/** Unloading caps around 64kb */
|
|
18
|
+
export const IDEAL_PAYLOAD_SIZE = 64000
|
|
19
|
+
/** Interval between forcing new full snapshots -- 15 seconds in error mode (x2), 5 minutes in full mode */
|
|
20
|
+
export const CHECKOUT_MS = { [MODE.ERROR]: 15000, [MODE.FULL]: 300000, [MODE.OFF]: 0 }
|
|
21
|
+
export const ABORT_REASONS = {
|
|
22
|
+
RESET: {
|
|
23
|
+
message: 'Session was reset',
|
|
24
|
+
sm: 'Reset'
|
|
25
|
+
},
|
|
26
|
+
IMPORT: {
|
|
27
|
+
message: 'Recorder failed to import',
|
|
28
|
+
sm: 'Import'
|
|
29
|
+
},
|
|
30
|
+
TOO_MANY: {
|
|
31
|
+
message: '429: Too Many Requests',
|
|
32
|
+
sm: 'Too-Many'
|
|
33
|
+
},
|
|
34
|
+
TOO_BIG: {
|
|
35
|
+
message: 'Payload was too large',
|
|
36
|
+
sm: 'Too-Big'
|
|
37
|
+
},
|
|
38
|
+
CROSS_TAB: {
|
|
39
|
+
message: 'Session Entity was set to OFF on another tab',
|
|
40
|
+
sm: 'Cross-Tab'
|
|
41
|
+
},
|
|
42
|
+
ENTITLEMENTS: {
|
|
43
|
+
message: 'Session Replay is not allowed and will not be started',
|
|
44
|
+
sm: 'Entitlement'
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Reserved room for query param attrs */
|
|
48
|
+
export const QUERY_PARAM_PADDING = 5000
|