@newrelic/browser-agent 1.240.0 → 1.242.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/dist/cjs/cdn/polyfills/lite.js +13 -1
- package/dist/cjs/cdn/polyfills/pro.js +17 -1
- package/dist/cjs/cdn/polyfills/spa.js +18 -1
- package/dist/cjs/common/config/state/init.js +48 -19
- package/dist/cjs/common/constants/env.cdn.js +1 -1
- package/dist/cjs/common/constants/env.npm.js +1 -1
- package/dist/cjs/common/dom/query-selector.js +16 -0
- package/dist/cjs/features/metrics/aggregate/index.js +10 -7
- package/dist/cjs/features/page_view_timing/aggregate/index.js +9 -9
- package/dist/cjs/features/session_replay/aggregate/index.js +90 -40
- package/dist/cjs/features/utils/instrument-base.js +1 -0
- package/dist/cjs/loaders/browser-agent.js +2 -1
- package/dist/esm/cdn/polyfills/lite.js +8 -1
- package/dist/esm/cdn/polyfills/pro.js +13 -2
- package/dist/esm/cdn/polyfills/spa.js +13 -1
- package/dist/esm/common/config/state/init.js +48 -19
- package/dist/esm/common/constants/env.cdn.js +1 -1
- package/dist/esm/common/constants/env.npm.js +1 -1
- package/dist/esm/common/dom/query-selector.js +9 -0
- package/dist/esm/features/metrics/aggregate/index.js +10 -7
- package/dist/esm/features/page_view_timing/aggregate/index.js +9 -9
- package/dist/esm/features/session_replay/aggregate/index.js +90 -40
- package/dist/esm/features/utils/instrument-base.js +1 -0
- package/dist/esm/loaders/browser-agent.js +2 -1
- package/dist/types/common/config/state/init.d.ts.map +1 -1
- package/dist/types/common/dom/query-selector.d.ts +2 -0
- package/dist/types/common/dom/query-selector.d.ts.map +1 -0
- package/dist/types/features/metrics/aggregate/index.d.ts.map +1 -1
- package/dist/types/features/page_view_timing/aggregate/index.d.ts.map +1 -1
- package/dist/types/features/session_replay/aggregate/index.d.ts +17 -5
- package/dist/types/features/session_replay/aggregate/index.d.ts.map +1 -1
- package/dist/types/features/utils/instrument-base.d.ts.map +1 -1
- package/dist/types/loaders/browser-agent.d.ts.map +1 -1
- package/package.json +2 -3
- package/src/cdn/polyfills/lite.js +14 -1
- package/src/cdn/polyfills/pro.js +23 -2
- package/src/cdn/polyfills/spa.js +24 -1
- package/src/common/config/state/init.js +46 -17
- package/src/common/config/state/init.test.js +40 -0
- package/src/common/dom/query-selector.js +9 -0
- package/src/common/dom/query-selector.test.js +24 -0
- package/src/common/harvest/harvest-scheduler.test.js +2 -2
- package/src/features/metrics/aggregate/index.js +5 -3
- package/src/features/page_view_timing/aggregate/index.js +7 -6
- package/src/features/session_replay/aggregate/index.component-test.js +10 -10
- package/src/features/session_replay/aggregate/index.js +71 -34
- package/src/features/utils/instrument-base.js +1 -0
- package/src/loaders/browser-agent.js +3 -1
package/src/cdn/polyfills/spa.js
CHANGED
|
@@ -4,4 +4,27 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import '../polyfills.js'
|
|
7
|
-
import '
|
|
7
|
+
import { Agent } from '../../loaders/agent'
|
|
8
|
+
|
|
9
|
+
import { Instrument as InstrumentPageViewEvent } from '../../features/page_view_event/instrument'
|
|
10
|
+
import { Instrument as InstrumentPageViewTiming } from '../../features/page_view_timing/instrument'
|
|
11
|
+
import { Instrument as InstrumentMetrics } from '../../features/metrics/instrument'
|
|
12
|
+
import { Instrument as InstrumentErrors } from '../../features/jserrors/instrument'
|
|
13
|
+
import { Instrument as InstrumentXhr } from '../../features/ajax/instrument'
|
|
14
|
+
import { Instrument as InstrumentSessionTrace } from '../../features/session_trace/instrument'
|
|
15
|
+
import { Instrument as InstrumentSpa } from '../../features/spa/instrument'
|
|
16
|
+
import { Instrument as InstrumentPageAction } from '../../features/page_action/instrument'
|
|
17
|
+
|
|
18
|
+
new Agent({
|
|
19
|
+
features: [
|
|
20
|
+
InstrumentXhr,
|
|
21
|
+
InstrumentPageViewEvent,
|
|
22
|
+
InstrumentPageViewTiming,
|
|
23
|
+
InstrumentSessionTrace,
|
|
24
|
+
InstrumentMetrics,
|
|
25
|
+
InstrumentPageAction,
|
|
26
|
+
InstrumentErrors,
|
|
27
|
+
InstrumentSpa
|
|
28
|
+
],
|
|
29
|
+
loaderType: 'spa-polyfills'
|
|
30
|
+
})
|
|
@@ -1,11 +1,32 @@
|
|
|
1
|
+
import { isValidSelector } from '../../dom/query-selector'
|
|
1
2
|
import { DEFAULT_EXPIRES_MS, DEFAULT_INACTIVE_MS } from '../../session/constants'
|
|
3
|
+
import { warn } from '../../util/console'
|
|
2
4
|
import { gosNREUMInitializedAgents } from '../../window/nreum'
|
|
3
5
|
import { getModeledObject } from './configurable'
|
|
4
6
|
|
|
5
7
|
const model = () => {
|
|
6
8
|
const hiddenState = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
mask_selector: '*',
|
|
10
|
+
block_selector: '[data-nr-block]',
|
|
11
|
+
mask_input_options: {
|
|
12
|
+
color: false,
|
|
13
|
+
date: false,
|
|
14
|
+
'datetime-local': false,
|
|
15
|
+
email: false,
|
|
16
|
+
month: false,
|
|
17
|
+
number: false,
|
|
18
|
+
range: false,
|
|
19
|
+
search: false,
|
|
20
|
+
tel: false,
|
|
21
|
+
text: false,
|
|
22
|
+
time: false,
|
|
23
|
+
url: false,
|
|
24
|
+
week: false,
|
|
25
|
+
// unify textarea and select element with text input
|
|
26
|
+
textarea: false,
|
|
27
|
+
select: false,
|
|
28
|
+
password: true // This will be enforced to always be true in the setter
|
|
29
|
+
}
|
|
9
30
|
}
|
|
10
31
|
return {
|
|
11
32
|
proxy: {
|
|
@@ -40,29 +61,37 @@ const model = () => {
|
|
|
40
61
|
autoStart: true,
|
|
41
62
|
enabled: false,
|
|
42
63
|
harvestTimeSeconds: 60,
|
|
43
|
-
|
|
44
|
-
|
|
64
|
+
sampling_rate: 50, // float from 0 - 100
|
|
65
|
+
error_sampling_rate: 50, // float from 0 - 100
|
|
45
66
|
// recording config settings
|
|
46
|
-
|
|
47
|
-
|
|
67
|
+
mask_all_inputs: true,
|
|
68
|
+
// this has a getter/setter to facilitate validation of the selectors
|
|
69
|
+
get mask_text_selector () { return hiddenState.mask_selector },
|
|
70
|
+
set mask_text_selector (val) {
|
|
71
|
+
if (isValidSelector(val)) hiddenState.mask_selector = val + ',[data-nr-mask]'
|
|
72
|
+
else if (val === null) hiddenState.mask_selector = val // null is acceptable, which completely disables the behavior
|
|
73
|
+
else warn('An invalid session_replay.mask_selector was provided and will not be used', val)
|
|
74
|
+
},
|
|
48
75
|
// these properties only have getters because they are enforcable constants and should error if someone tries to override them
|
|
49
|
-
get
|
|
50
|
-
get
|
|
51
|
-
get
|
|
76
|
+
get block_class () { return 'nr-block' },
|
|
77
|
+
get ignore_class () { return 'nr-ignore' },
|
|
78
|
+
get mask_text_class () { return 'nr-mask' },
|
|
52
79
|
// props with a getter and setter are used to extend enforcable constants with customer input
|
|
53
80
|
// we must preserve data-nr-block no matter what else the customer sets
|
|
54
|
-
get
|
|
55
|
-
return hiddenState.
|
|
81
|
+
get block_selector () {
|
|
82
|
+
return hiddenState.block_selector
|
|
56
83
|
},
|
|
57
|
-
set
|
|
58
|
-
hiddenState.
|
|
84
|
+
set block_selector (val) {
|
|
85
|
+
if (isValidSelector(val)) hiddenState.block_selector += `,${val}`
|
|
86
|
+
else if (val !== '') warn('An invalid session_replay.block_selector was provided and will not be used', val)
|
|
59
87
|
},
|
|
60
88
|
// password: must always be present and true no matter what customer sets
|
|
61
|
-
get
|
|
62
|
-
return hiddenState.
|
|
89
|
+
get mask_input_options () {
|
|
90
|
+
return hiddenState.mask_input_options
|
|
63
91
|
},
|
|
64
|
-
set
|
|
65
|
-
hiddenState.
|
|
92
|
+
set mask_input_options (val) {
|
|
93
|
+
if (val && typeof val === 'object') hiddenState.mask_input_options = { ...val, password: true }
|
|
94
|
+
else warn('An invalid session_replay.mask_input_option was provided and will not be used', val)
|
|
66
95
|
}
|
|
67
96
|
},
|
|
68
97
|
spa: { enabled: true, harvestTimeSeconds: 10, autoStart: true }
|
|
@@ -26,3 +26,43 @@ test('getConfigurationValue parses path correctly', () => {
|
|
|
26
26
|
expect(getConfigurationValue('ab', 'page_action')).toEqual({ enabled: true, harvestTimeSeconds: 1000, autoStart: true })
|
|
27
27
|
expect(getConfigurationValue('ab', 'page_action.harvestTimeSeconds')).toEqual(1000)
|
|
28
28
|
})
|
|
29
|
+
|
|
30
|
+
describe('property getters/setters used for validation', () => {
|
|
31
|
+
test('invalid values do not pass through', () => {
|
|
32
|
+
setConfiguration('12345', {
|
|
33
|
+
session_replay: {
|
|
34
|
+
block_selector: '[invalid selector]',
|
|
35
|
+
mask_text_selector: '[invalid selector]',
|
|
36
|
+
mask_input_options: 'select:true'
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
expect(getConfigurationValue('12345', 'session_replay.block_selector')).toEqual('[data-nr-block]')
|
|
41
|
+
expect(getConfigurationValue('12345', 'session_replay.mask_text_selector')).toEqual('*')
|
|
42
|
+
expect(getConfigurationValue('12345', 'session_replay.mask_input_options')).toMatchObject({ password: true, select: false })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('valid values do pass through', () => {
|
|
46
|
+
setConfiguration('23456', {
|
|
47
|
+
session_replay: {
|
|
48
|
+
block_selector: '[block-text-test]',
|
|
49
|
+
mask_text_selector: '[mask-text-test]',
|
|
50
|
+
mask_input_options: { select: true }
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
expect(getConfigurationValue('23456', 'session_replay.block_selector')).toEqual('[data-nr-block],[block-text-test]')
|
|
55
|
+
expect(getConfigurationValue('23456', 'session_replay.mask_text_selector')).toEqual('[mask-text-test],[data-nr-mask]')
|
|
56
|
+
expect(getConfigurationValue('23456', 'session_replay.mask_input_options')).toMatchObject({ password: true, select: true })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('null accepted for mask_text', () => {
|
|
60
|
+
setConfiguration('34567', {
|
|
61
|
+
session_replay: {
|
|
62
|
+
mask_text_selector: null
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(getConfigurationValue('34567', 'session_replay.mask_text_selector')).toEqual(null)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { isValidSelector } from './query-selector'
|
|
2
|
+
describe('query selector tests', () => {
|
|
3
|
+
test('handles nullish values', () => {
|
|
4
|
+
expect(isValidSelector(null)).toEqual(false)
|
|
5
|
+
expect(isValidSelector('')).toEqual(false)
|
|
6
|
+
expect(isValidSelector(0)).toEqual(false)
|
|
7
|
+
expect(isValidSelector(false)).toEqual(false)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('handles truthy but invalid values', () => {
|
|
11
|
+
expect(isValidSelector({ test: 1 })).toEqual(false)
|
|
12
|
+
expect(isValidSelector([1, 2, 3])).toEqual(false)
|
|
13
|
+
expect(isValidSelector(1)).toEqual(false)
|
|
14
|
+
expect(isValidSelector(',')).toEqual(false)
|
|
15
|
+
expect(isValidSelector('[invalid space]')).toEqual(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('handles valid selectors', () => {
|
|
19
|
+
expect(isValidSelector('#id')).toEqual(true)
|
|
20
|
+
expect(isValidSelector('.class')).toEqual(true)
|
|
21
|
+
expect(isValidSelector('.multiple,#selectors')).toEqual(true)
|
|
22
|
+
expect(isValidSelector('[attr-selector]')).toEqual(true)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -36,7 +36,7 @@ describe('unload', () => {
|
|
|
36
36
|
expect(subscribeToEOL).toHaveBeenCalledWith(expect.any(Function))
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
-
test('should run onUnload callback', () => {
|
|
39
|
+
test('should run onUnload callback when started', () => {
|
|
40
40
|
harvestSchedulerInstance.opts.onUnload = jest.fn()
|
|
41
41
|
|
|
42
42
|
eolSubscribeFn()
|
|
@@ -44,7 +44,7 @@ describe('unload', () => {
|
|
|
44
44
|
expect(harvestSchedulerInstance.opts.onUnload).toHaveBeenCalledTimes(1)
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
test('should run harvest when not aborted', () => {
|
|
47
|
+
test('should run harvest when started and not aborted', () => {
|
|
48
48
|
harvestSchedulerInstance.aborted = false
|
|
49
49
|
|
|
50
50
|
eolSubscribeFn()
|
|
@@ -29,9 +29,11 @@ export class Aggregate extends AggregateBase {
|
|
|
29
29
|
this.singleChecks() // checks that are run only one time, at script load
|
|
30
30
|
this.eachSessionChecks() // the start of every time user engages with page
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
this.ee.on(`drain-${this.featureName}`, () => {
|
|
33
|
+
// *cli, Mar 23 - Per NR-94597, this feature should only harvest ONCE at the (potential) EoL time of the page.
|
|
34
|
+
scheduler = new HarvestScheduler('jserrors', { onUnload: () => this.unload() }, this)
|
|
35
|
+
scheduler.harvest.on('jserrors', () => ({ body: this.aggregator.take(['cm', 'sm']) }))
|
|
36
|
+
}) // this is needed to ensure EoL is "on" and sent
|
|
35
37
|
|
|
36
38
|
this.drain()
|
|
37
39
|
}
|
|
@@ -48,19 +48,20 @@ export class Aggregate extends AggregateBase {
|
|
|
48
48
|
|
|
49
49
|
/* It's important that CWV api, like "onLCP", is called before this scheduler is initialized. The reason is because they listen to the same
|
|
50
50
|
on vis change or pagehide events, and we'd want ex. onLCP to record the timing (win the race) before we try to send "final harvest". */
|
|
51
|
-
this.scheduler = new HarvestScheduler('events', {
|
|
52
|
-
onFinished: (...args) => this.onHarvestFinished(...args),
|
|
53
|
-
getPayload: (...args) => this.prepareHarvest(...args)
|
|
54
|
-
}, this)
|
|
55
51
|
|
|
56
|
-
registerHandler('timing', (name, value, attrs) => this.addTiming(name, value, attrs), this.featureName, this.ee) // notice CLS is added to all timings via 4th param
|
|
57
52
|
registerHandler('docHidden', msTimestamp => this.endCurrentSession(msTimestamp), this.featureName, this.ee)
|
|
58
53
|
registerHandler('winPagehide', msTimestamp => this.recordPageUnload(msTimestamp), this.featureName, this.ee)
|
|
59
54
|
|
|
60
55
|
const initialHarvestSeconds = getConfigurationValue(this.agentIdentifier, 'page_view_timing.initialHarvestSeconds') || 10
|
|
61
56
|
const harvestTimeSeconds = getConfigurationValue(this.agentIdentifier, 'page_view_timing.harvestTimeSeconds') || 30
|
|
62
57
|
// send initial data sooner, then start regular
|
|
63
|
-
this.ee.on(`drain-${this.featureName}`, () => {
|
|
58
|
+
this.ee.on(`drain-${this.featureName}`, () => {
|
|
59
|
+
this.scheduler = new HarvestScheduler('events', {
|
|
60
|
+
onFinished: (...args) => this.onHarvestFinished(...args),
|
|
61
|
+
getPayload: (...args) => this.prepareHarvest(...args)
|
|
62
|
+
}, this)
|
|
63
|
+
this.scheduler.startTimer(harvestTimeSeconds, initialHarvestSeconds)
|
|
64
|
+
})
|
|
64
65
|
|
|
65
66
|
this.drain()
|
|
66
67
|
}
|
|
@@ -39,7 +39,7 @@ class LocalMemory {
|
|
|
39
39
|
let sr, session
|
|
40
40
|
const agentIdentifier = 'abcd'
|
|
41
41
|
const info = { licenseKey: 1234, applicationID: 9876 }
|
|
42
|
-
const init = { session_replay: { enabled: true,
|
|
42
|
+
const init = { session_replay: { enabled: true, sampling_rate: 100, error_sampling_rate: 0 } }
|
|
43
43
|
|
|
44
44
|
const anyQuery = {
|
|
45
45
|
browser_monitoring_key: info.licenseKey,
|
|
@@ -99,14 +99,14 @@ describe('Session Replay', () => {
|
|
|
99
99
|
})
|
|
100
100
|
|
|
101
101
|
test('Session SR mode matches SR mode -- ERROR', async () => {
|
|
102
|
-
setConfiguration(agentIdentifier, { session_replay: {
|
|
102
|
+
setConfiguration(agentIdentifier, { session_replay: { sampling_rate: 0, error_sampling_rate: 100 } })
|
|
103
103
|
sr.ee.emit('rumresp-sr', [true])
|
|
104
104
|
await wait(1)
|
|
105
105
|
expect(session.state.sessionReplay).toEqual(sr.mode)
|
|
106
106
|
})
|
|
107
107
|
|
|
108
108
|
test('Session SR mode matches SR mode -- OFF', async () => {
|
|
109
|
-
setConfiguration(agentIdentifier, { session_replay: {
|
|
109
|
+
setConfiguration(agentIdentifier, { session_replay: { sampling_rate: 0, error_sampling_rate: 0 } })
|
|
110
110
|
sr.ee.emit('rumresp-sr', [true])
|
|
111
111
|
await wait(1)
|
|
112
112
|
expect(session.state.sessionReplay).toEqual(sr.mode)
|
|
@@ -149,28 +149,28 @@ describe('Session Replay', () => {
|
|
|
149
149
|
|
|
150
150
|
describe('Session Replay Sample -> Mode Behaviors', () => {
|
|
151
151
|
test('New Session -- Full 1 Error 1 === FULL', async () => {
|
|
152
|
-
setConfiguration(agentIdentifier, { session_replay: {
|
|
152
|
+
setConfiguration(agentIdentifier, { session_replay: { error_sampling_rate: 100, sampling_rate: 100 } })
|
|
153
153
|
sr.ee.emit('rumresp-sr', [true])
|
|
154
154
|
await wait(1)
|
|
155
155
|
expect(sr.mode).toEqual(MODE.FULL)
|
|
156
156
|
})
|
|
157
157
|
|
|
158
158
|
test('New Session -- Full 1 Error 0 === FULL', async () => {
|
|
159
|
-
setConfiguration(agentIdentifier, { session_replay: {
|
|
159
|
+
setConfiguration(agentIdentifier, { session_replay: { error_sampling_rate: 0, sampling_rate: 100 } })
|
|
160
160
|
sr.ee.emit('rumresp-sr', [true])
|
|
161
161
|
await wait(1)
|
|
162
162
|
expect(sr.mode).toEqual(MODE.FULL)
|
|
163
163
|
})
|
|
164
164
|
|
|
165
165
|
test('New Session -- Full 0 Error 1 === ERROR', async () => {
|
|
166
|
-
setConfiguration(agentIdentifier, { session_replay: {
|
|
166
|
+
setConfiguration(agentIdentifier, { session_replay: { error_sampling_rate: 100, sampling_rate: 0 } })
|
|
167
167
|
sr.ee.emit('rumresp-sr', [true])
|
|
168
168
|
await wait(1)
|
|
169
169
|
expect(sr.mode).toEqual(MODE.ERROR)
|
|
170
170
|
})
|
|
171
171
|
|
|
172
172
|
test('New Session -- Full 0 Error 0 === OFF', async () => {
|
|
173
|
-
setConfiguration(agentIdentifier, { session_replay: {
|
|
173
|
+
setConfiguration(agentIdentifier, { session_replay: { error_sampling_rate: 0, sampling_rate: 0 } })
|
|
174
174
|
sr.ee.emit('rumresp-sr', [true])
|
|
175
175
|
await wait(1)
|
|
176
176
|
expect(sr.mode).toEqual(MODE.OFF)
|
|
@@ -182,7 +182,7 @@ describe('Session Replay', () => {
|
|
|
182
182
|
expect(session.isNew).toBeFalsy()
|
|
183
183
|
primeSessionAndReplay(session)
|
|
184
184
|
// configure to get "error" sample ---> but should inherit FULL from session manager
|
|
185
|
-
setConfiguration(agentIdentifier, { session_replay: {
|
|
185
|
+
setConfiguration(agentIdentifier, { session_replay: { error_sampling_rate: 100, sampling_rate: 0 } })
|
|
186
186
|
sr.ee.emit('rumresp-sr', [true])
|
|
187
187
|
await wait(1)
|
|
188
188
|
expect(sr.mode).toEqual(MODE.FULL)
|
|
@@ -191,7 +191,7 @@ describe('Session Replay', () => {
|
|
|
191
191
|
|
|
192
192
|
describe('Session Replay Error Mode Behaviors', () => {
|
|
193
193
|
test('An error BEFORE rrweb import starts running in FULL from beginning', async () => {
|
|
194
|
-
setConfiguration(agentIdentifier, { session_replay: {
|
|
194
|
+
setConfiguration(agentIdentifier, { session_replay: { error_sampling_rate: 100, sampling_rate: 0 } })
|
|
195
195
|
sr.ee.emit('errorAgg')
|
|
196
196
|
sr.ee.emit('rumresp-sr', [true])
|
|
197
197
|
await wait(1)
|
|
@@ -200,7 +200,7 @@ describe('Session Replay', () => {
|
|
|
200
200
|
})
|
|
201
201
|
|
|
202
202
|
test('An error AFTER rrweb import changes mode and starts harvester', async () => {
|
|
203
|
-
setConfiguration(agentIdentifier, { session_replay: {
|
|
203
|
+
setConfiguration(agentIdentifier, { session_replay: { error_sampling_rate: 100, sampling_rate: 0 } })
|
|
204
204
|
sr.ee.emit('rumresp-sr', [true])
|
|
205
205
|
await wait(1)
|
|
206
206
|
expect(sr.mode).toEqual(MODE.ERROR)
|
|
@@ -20,6 +20,9 @@ import { AggregateBase } from '../../utils/aggregate-base'
|
|
|
20
20
|
import { sharedChannel } from '../../../common/constants/shared-channel'
|
|
21
21
|
import { obj as encodeObj } from '../../../common/url/encode'
|
|
22
22
|
import { warn } from '../../../common/util/console'
|
|
23
|
+
import { globalScope } from '../../../common/constants/runtime'
|
|
24
|
+
import { SUPPORTABILITY_METRIC_CHANNEL } from '../../metrics/constants'
|
|
25
|
+
import { FEATURE_NAMES } from '../../../loaders/features/features'
|
|
23
26
|
|
|
24
27
|
// would be better to get this dynamically in some way
|
|
25
28
|
export const RRWEB_VERSION = '2.0.0-alpha.8'
|
|
@@ -63,15 +66,22 @@ export class Aggregate extends AggregateBase {
|
|
|
63
66
|
* -- When visibility changes from "hidden" -> "visible", it must capture a full snapshot for the replay to work correctly across tabs
|
|
64
67
|
*/
|
|
65
68
|
this.hasSnapshot = false
|
|
69
|
+
/** Payload metadata -- Should indicate that the payload being sent has a meta node. The meta node should always precede a snapshot node. */
|
|
70
|
+
this.hasMeta = false
|
|
66
71
|
/** Payload metadata -- Should indicate that the payload being sent contains an error. Used for query/filter purposes in UI */
|
|
67
72
|
this.hasError = false
|
|
68
73
|
|
|
69
|
-
/** Payload metadata -- Should indicate when a replay blob started recording. Resets each time a harvest occurs.
|
|
70
|
-
|
|
74
|
+
/** Payload metadata -- Should indicate when a replay blob started recording. Resets each time a harvest occurs.
|
|
75
|
+
* cycle timestamps are used as fallbacks if event timestamps cannot be used
|
|
76
|
+
*/
|
|
77
|
+
this.timestamp = { event: { first: undefined, last: undefined }, cycle: { first: undefined, last: undefined } }
|
|
71
78
|
|
|
72
79
|
/** A value which increments with every new mutation node reported. Resets after a harvest is sent */
|
|
73
80
|
this.payloadBytesEstimation = 0
|
|
74
81
|
|
|
82
|
+
/** 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 */
|
|
83
|
+
this.lastMeta = undefined
|
|
84
|
+
|
|
75
85
|
const shouldSetup = (
|
|
76
86
|
getConfigurationValue(agentIdentifier, 'privacy.cookies_enabled') === true &&
|
|
77
87
|
getConfigurationValue(agentIdentifier, 'session_trace.enabled') === true
|
|
@@ -83,7 +93,7 @@ export class Aggregate extends AggregateBase {
|
|
|
83
93
|
if (shouldSetup) {
|
|
84
94
|
// 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.
|
|
85
95
|
this.ee.on(SESSION_EVENTS.RESET, () => {
|
|
86
|
-
this.abort()
|
|
96
|
+
this.abort('Session Reset')
|
|
87
97
|
})
|
|
88
98
|
|
|
89
99
|
// The SessionEntity class can emit a message indicating the session was paused (visibility change). This feature must stop recording if that occurs.
|
|
@@ -123,8 +133,8 @@ export class Aggregate extends AggregateBase {
|
|
|
123
133
|
|
|
124
134
|
this.waitForFlags(['sr']).then(([flagOn]) => this.initializeRecording(
|
|
125
135
|
flagOn,
|
|
126
|
-
Math.random() < getConfigurationValue(this.agentIdentifier, 'session_replay.
|
|
127
|
-
Math.random() < getConfigurationValue(this.agentIdentifier, 'session_replay.
|
|
136
|
+
(Math.random() * 100) < getConfigurationValue(this.agentIdentifier, 'session_replay.error_sampling_rate'),
|
|
137
|
+
(Math.random() * 100) < getConfigurationValue(this.agentIdentifier, 'session_replay.sampling_rate')
|
|
128
138
|
)).then(() => sharedChannel.onReplayReady(this.mode)) // notify watchers that replay started with the mode
|
|
129
139
|
|
|
130
140
|
this.drain()
|
|
@@ -164,6 +174,13 @@ export class Aggregate extends AggregateBase {
|
|
|
164
174
|
this.mode = MODE.FULL
|
|
165
175
|
}
|
|
166
176
|
|
|
177
|
+
try {
|
|
178
|
+
// Do not change the webpackChunkName or it will break the webpack nrba-chunking plugin
|
|
179
|
+
recorder = (await import(/* webpackChunkName: "recorder" */'rrweb')).record
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return this.abort('Recorder failed to import')
|
|
182
|
+
}
|
|
183
|
+
|
|
167
184
|
// FULL mode records AND reports from the beginning, while ERROR mode only records (but does not report).
|
|
168
185
|
// ERROR mode will do this until an error is thrown, and then switch into FULL mode.
|
|
169
186
|
// If an error happened in ERROR mode before we've gotten to this stage, it will have already set the mode to FULL
|
|
@@ -172,13 +189,6 @@ export class Aggregate extends AggregateBase {
|
|
|
172
189
|
this.scheduler.startTimer(this.harvestTimeSeconds)
|
|
173
190
|
}
|
|
174
191
|
|
|
175
|
-
try {
|
|
176
|
-
// Do not change the webpackChunkName or it will break the webpack nrba-chunking plugin
|
|
177
|
-
recorder = (await import(/* webpackChunkName: "recorder" */'rrweb')).record
|
|
178
|
-
} catch (err) {
|
|
179
|
-
return this.abort()
|
|
180
|
-
}
|
|
181
|
-
|
|
182
192
|
try {
|
|
183
193
|
// Do not change the webpackChunkName or it will break the webpack nrba-chunking plugin
|
|
184
194
|
const { gzipSync, strToU8 } = await import(/* webpackChunkName: "compressor" */'fflate')
|
|
@@ -213,6 +223,8 @@ export class Aggregate extends AggregateBase {
|
|
|
213
223
|
getHarvestContents () {
|
|
214
224
|
const agentRuntime = getRuntime(this.agentIdentifier)
|
|
215
225
|
const info = getInfo(this.agentIdentifier)
|
|
226
|
+
const firstTimestamp = this.timestamp.event.first || this.timestamp.cycle.first
|
|
227
|
+
const lastTimestamp = this.timestamp.event.last || this.timestamp.cycle.last
|
|
216
228
|
return {
|
|
217
229
|
qs: {
|
|
218
230
|
browser_monitoring_key: info.licenseKey,
|
|
@@ -221,11 +233,12 @@ export class Aggregate extends AggregateBase {
|
|
|
221
233
|
protocol_version: '0',
|
|
222
234
|
attributes: encodeObj({
|
|
223
235
|
...(this.shouldCompress && { content_encoding: 'gzip' }),
|
|
224
|
-
'replay.firstTimestamp':
|
|
225
|
-
'replay.lastTimestamp':
|
|
226
|
-
'replay.durationMs':
|
|
236
|
+
'replay.firstTimestamp': firstTimestamp,
|
|
237
|
+
'replay.lastTimestamp': lastTimestamp,
|
|
238
|
+
'replay.durationMs': lastTimestamp - firstTimestamp,
|
|
227
239
|
agentVersion: agentRuntime.version,
|
|
228
240
|
session: agentRuntime.session.state.value,
|
|
241
|
+
hasMeta: this.hasMeta,
|
|
229
242
|
hasSnapshot: this.hasSnapshot,
|
|
230
243
|
hasError: this.hasError,
|
|
231
244
|
isFirstChunk: this.isFirstChunk,
|
|
@@ -240,7 +253,7 @@ export class Aggregate extends AggregateBase {
|
|
|
240
253
|
onHarvestFinished (result) {
|
|
241
254
|
// The mutual decision for now is to stop recording and clear buffers if ingest is experiencing 429 rate limiting
|
|
242
255
|
if (result.status === 429) {
|
|
243
|
-
this.abort()
|
|
256
|
+
this.abort('429: Too many requests')
|
|
244
257
|
}
|
|
245
258
|
|
|
246
259
|
if (this.blocked) this.scheduler.stopTimer(true)
|
|
@@ -251,6 +264,7 @@ export class Aggregate extends AggregateBase {
|
|
|
251
264
|
this.events = []
|
|
252
265
|
this.isFirstChunk = false
|
|
253
266
|
this.hasSnapshot = false
|
|
267
|
+
this.hasMeta = false
|
|
254
268
|
this.hasError = false
|
|
255
269
|
this.payloadBytesEstimation = 0
|
|
256
270
|
this.clearTimestamps()
|
|
@@ -260,21 +274,24 @@ export class Aggregate extends AggregateBase {
|
|
|
260
274
|
startRecording () {
|
|
261
275
|
if (!recorder) {
|
|
262
276
|
warn('Recording library was never imported')
|
|
263
|
-
return this.abort()
|
|
277
|
+
return this.abort('Recorder was never imported')
|
|
264
278
|
}
|
|
279
|
+
this.clearTimestamps()
|
|
280
|
+
// set the fallbacks as early as possible
|
|
281
|
+
this.setTimestamps()
|
|
265
282
|
this.recording = true
|
|
266
|
-
const {
|
|
283
|
+
const { block_class, ignore_class, mask_text_class, block_selector, mask_input_options, mask_text_selector, mask_all_inputs } = getConfigurationValue(this.agentIdentifier, 'session_replay')
|
|
267
284
|
// set up rrweb configurations for maximum privacy --
|
|
268
285
|
// https://newrelic.atlassian.net/wiki/spaces/O11Y/pages/2792293280/2023+02+28+Browser+-+Session+Replay#Configuration-options
|
|
269
286
|
const stop = recorder({
|
|
270
287
|
emit: this.store.bind(this),
|
|
271
|
-
blockClass,
|
|
272
|
-
ignoreClass,
|
|
273
|
-
maskTextClass,
|
|
274
|
-
blockSelector,
|
|
275
|
-
maskInputOptions,
|
|
276
|
-
maskTextSelector,
|
|
277
|
-
maskAllInputs,
|
|
288
|
+
blockClass: block_class,
|
|
289
|
+
ignoreClass: ignore_class,
|
|
290
|
+
maskTextClass: mask_text_class,
|
|
291
|
+
blockSelector: block_selector,
|
|
292
|
+
maskInputOptions: mask_input_options,
|
|
293
|
+
maskTextSelector: mask_text_selector,
|
|
294
|
+
maskAllInputs: mask_all_inputs,
|
|
278
295
|
checkoutEveryNms: CHECKOUT_MS[this.mode]
|
|
279
296
|
})
|
|
280
297
|
|
|
@@ -286,6 +303,7 @@ export class Aggregate extends AggregateBase {
|
|
|
286
303
|
|
|
287
304
|
/** Store a payload in the buffer (this.events). This should be the callback to the recording lib noticing a mutation */
|
|
288
305
|
store (event, isCheckout) {
|
|
306
|
+
this.setTimestamps(event)
|
|
289
307
|
if (this.blocked) return
|
|
290
308
|
const eventBytes = stringify(event).length
|
|
291
309
|
/** The estimated size of the payload after compression */
|
|
@@ -293,7 +311,8 @@ export class Aggregate extends AggregateBase {
|
|
|
293
311
|
// Vortex will block payloads at a certain size, we might as well not send.
|
|
294
312
|
if (payloadSize > MAX_PAYLOAD_SIZE) {
|
|
295
313
|
this.clearBuffer()
|
|
296
|
-
|
|
314
|
+
this.ee.emit(SUPPORTABILITY_METRIC_CHANNEL, ['SessionReplay/Too-Big/Seen'], undefined, FEATURE_NAMES.metrics, this.ee)
|
|
315
|
+
return this.abort('Payload too big')
|
|
297
316
|
}
|
|
298
317
|
// Checkout events are flags by the recording lib that indicate a fullsnapshot was taken every n ms. These are important
|
|
299
318
|
// to help reconstruct the replay later and must be included. While waiting and buffering for errors to come through,
|
|
@@ -303,8 +322,21 @@ export class Aggregate extends AggregateBase {
|
|
|
303
322
|
this.clearBuffer()
|
|
304
323
|
}
|
|
305
324
|
|
|
306
|
-
|
|
307
|
-
if (event.type ===
|
|
325
|
+
// meta event
|
|
326
|
+
if (event.type === 4) {
|
|
327
|
+
this.hasMeta = true
|
|
328
|
+
this.lastMeta = event
|
|
329
|
+
}
|
|
330
|
+
// snapshot event
|
|
331
|
+
if (event.type === 2) {
|
|
332
|
+
this.hasSnapshot = true
|
|
333
|
+
// small chance that the meta event got separated from its matching snapshot across payload harvests
|
|
334
|
+
// it needs to precede the snapshot, so shove it in first.
|
|
335
|
+
if (!this.hasMeta) {
|
|
336
|
+
this.events.push(this.lastMeta)
|
|
337
|
+
this.hasMeta = true
|
|
338
|
+
}
|
|
339
|
+
}
|
|
308
340
|
|
|
309
341
|
this.events.push(event)
|
|
310
342
|
this.payloadBytesEstimation += eventBytes
|
|
@@ -323,14 +355,18 @@ export class Aggregate extends AggregateBase {
|
|
|
323
355
|
recorder.takeFullSnapshot()
|
|
324
356
|
}
|
|
325
357
|
|
|
326
|
-
setTimestamps (
|
|
327
|
-
if
|
|
328
|
-
|
|
329
|
-
this.timestamp.
|
|
358
|
+
setTimestamps (event) {
|
|
359
|
+
// fallbacks if timestamps cannot be derived from rrweb events
|
|
360
|
+
this.timestamp.cycle.last = getRuntime(this.agentIdentifier).offset + globalScope.performance.now()
|
|
361
|
+
if (!this.timestamp.cycle.first) this.timestamp.cycle.first = this.timestamp.cycle.last
|
|
362
|
+
// timestamps based on rrweb events
|
|
363
|
+
if (!event || !event.timestamp) return
|
|
364
|
+
if (!this.timestamp.event.first) this.timestamp.event.first = event.timestamp
|
|
365
|
+
this.timestamp.event.last = event.timestamp
|
|
330
366
|
}
|
|
331
367
|
|
|
332
368
|
clearTimestamps () {
|
|
333
|
-
this.timestamp = { first: undefined, last: undefined }
|
|
369
|
+
this.timestamp = { event: { first: undefined, last: undefined }, cycle: { first: undefined, last: undefined } }
|
|
334
370
|
}
|
|
335
371
|
|
|
336
372
|
/** Estimate the payload size */
|
|
@@ -340,7 +376,8 @@ export class Aggregate extends AggregateBase {
|
|
|
340
376
|
}
|
|
341
377
|
|
|
342
378
|
/** Abort the feature, once aborted it will not resume */
|
|
343
|
-
abort () {
|
|
379
|
+
abort (reason) {
|
|
380
|
+
warn(`SR aborted -- ${reason}`)
|
|
344
381
|
this.blocked = true
|
|
345
382
|
this.mode = MODE.OFF
|
|
346
383
|
this.stopRecording()
|
|
@@ -108,6 +108,7 @@ export class InstrumentBase extends FeatureBase {
|
|
|
108
108
|
warn(`Downloading and initializing ${this.featureName} failed...`, e)
|
|
109
109
|
this.abortHandler?.() // undo any important alterations made to the page
|
|
110
110
|
// not supported yet but nice to do: "abort" this agent's EE for this feature specifically
|
|
111
|
+
drain(this.agentIdentifier, this.featureName)
|
|
111
112
|
loadedSuccessfully(false)
|
|
112
113
|
}
|
|
113
114
|
}
|
|
@@ -8,6 +8,7 @@ import { Instrument as InstrumentXhr } from '../features/ajax/instrument'
|
|
|
8
8
|
import { Instrument as InstrumentSessionTrace } from '../features/session_trace/instrument'
|
|
9
9
|
import { Instrument as InstrumentSpa } from '../features/spa/instrument'
|
|
10
10
|
import { Instrument as InstrumentPageAction } from '../features/page_action/instrument'
|
|
11
|
+
import { Instrument as InstrumentSessionReplay } from '../features/session_replay/instrument'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* An agent class with all feature modules available. Features may be disabled and enabled via runtime configuration.
|
|
@@ -25,7 +26,8 @@ export class BrowserAgent extends Agent {
|
|
|
25
26
|
InstrumentMetrics,
|
|
26
27
|
InstrumentPageAction,
|
|
27
28
|
InstrumentErrors,
|
|
28
|
-
InstrumentSpa
|
|
29
|
+
InstrumentSpa,
|
|
30
|
+
InstrumentSessionReplay
|
|
29
31
|
],
|
|
30
32
|
loaderType: 'browser-agent'
|
|
31
33
|
})
|