@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.
Files changed (48) hide show
  1. package/dist/cjs/cdn/polyfills/lite.js +13 -1
  2. package/dist/cjs/cdn/polyfills/pro.js +17 -1
  3. package/dist/cjs/cdn/polyfills/spa.js +18 -1
  4. package/dist/cjs/common/config/state/init.js +48 -19
  5. package/dist/cjs/common/constants/env.cdn.js +1 -1
  6. package/dist/cjs/common/constants/env.npm.js +1 -1
  7. package/dist/cjs/common/dom/query-selector.js +16 -0
  8. package/dist/cjs/features/metrics/aggregate/index.js +10 -7
  9. package/dist/cjs/features/page_view_timing/aggregate/index.js +9 -9
  10. package/dist/cjs/features/session_replay/aggregate/index.js +90 -40
  11. package/dist/cjs/features/utils/instrument-base.js +1 -0
  12. package/dist/cjs/loaders/browser-agent.js +2 -1
  13. package/dist/esm/cdn/polyfills/lite.js +8 -1
  14. package/dist/esm/cdn/polyfills/pro.js +13 -2
  15. package/dist/esm/cdn/polyfills/spa.js +13 -1
  16. package/dist/esm/common/config/state/init.js +48 -19
  17. package/dist/esm/common/constants/env.cdn.js +1 -1
  18. package/dist/esm/common/constants/env.npm.js +1 -1
  19. package/dist/esm/common/dom/query-selector.js +9 -0
  20. package/dist/esm/features/metrics/aggregate/index.js +10 -7
  21. package/dist/esm/features/page_view_timing/aggregate/index.js +9 -9
  22. package/dist/esm/features/session_replay/aggregate/index.js +90 -40
  23. package/dist/esm/features/utils/instrument-base.js +1 -0
  24. package/dist/esm/loaders/browser-agent.js +2 -1
  25. package/dist/types/common/config/state/init.d.ts.map +1 -1
  26. package/dist/types/common/dom/query-selector.d.ts +2 -0
  27. package/dist/types/common/dom/query-selector.d.ts.map +1 -0
  28. package/dist/types/features/metrics/aggregate/index.d.ts.map +1 -1
  29. package/dist/types/features/page_view_timing/aggregate/index.d.ts.map +1 -1
  30. package/dist/types/features/session_replay/aggregate/index.d.ts +17 -5
  31. package/dist/types/features/session_replay/aggregate/index.d.ts.map +1 -1
  32. package/dist/types/features/utils/instrument-base.d.ts.map +1 -1
  33. package/dist/types/loaders/browser-agent.d.ts.map +1 -1
  34. package/package.json +2 -3
  35. package/src/cdn/polyfills/lite.js +14 -1
  36. package/src/cdn/polyfills/pro.js +23 -2
  37. package/src/cdn/polyfills/spa.js +24 -1
  38. package/src/common/config/state/init.js +46 -17
  39. package/src/common/config/state/init.test.js +40 -0
  40. package/src/common/dom/query-selector.js +9 -0
  41. package/src/common/dom/query-selector.test.js +24 -0
  42. package/src/common/harvest/harvest-scheduler.test.js +2 -2
  43. package/src/features/metrics/aggregate/index.js +5 -3
  44. package/src/features/page_view_timing/aggregate/index.js +7 -6
  45. package/src/features/session_replay/aggregate/index.component-test.js +10 -10
  46. package/src/features/session_replay/aggregate/index.js +71 -34
  47. package/src/features/utils/instrument-base.js +1 -0
  48. package/src/loaders/browser-agent.js +3 -1
@@ -4,4 +4,27 @@
4
4
  */
5
5
 
6
6
  import '../polyfills.js'
7
- import '../spa'
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
- blockSelector: '[data-nr-block]',
8
- maskInputOptions: { password: true }
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
- sampleRate: 0.1,
44
- errorSampleRate: 0.1,
64
+ sampling_rate: 50, // float from 0 - 100
65
+ error_sampling_rate: 50, // float from 0 - 100
45
66
  // recording config settings
46
- maskTextSelector: '*',
47
- maskAllInputs: true,
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 blockClass () { return 'nr-block' },
50
- get ignoreClass () { return 'nr-ignore' },
51
- get maskTextClass () { return 'nr-mask' },
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 blockSelector () {
55
- return hiddenState.blockSelector
81
+ get block_selector () {
82
+ return hiddenState.block_selector
56
83
  },
57
- set blockSelector (val) {
58
- hiddenState.blockSelector += `,${val}`
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 maskInputOptions () {
62
- return hiddenState.maskInputOptions
89
+ get mask_input_options () {
90
+ return hiddenState.mask_input_options
63
91
  },
64
- set maskInputOptions (val) {
65
- hiddenState.maskInputOptions = { ...val, password: true }
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,9 @@
1
+ export const isValidSelector = (selector) => {
2
+ if (!selector || typeof selector !== 'string') return false
3
+ try {
4
+ document.createDocumentFragment().querySelector(selector)
5
+ } catch {
6
+ return false
7
+ }
8
+ return true
9
+ }
@@ -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
- // *cli, Mar 23 - Per NR-94597, this feature should only harvest ONCE at the (potential) EoL time of the page.
33
- scheduler = new HarvestScheduler('jserrors', { onUnload: () => this.unload() }, this)
34
- scheduler.harvest.on('jserrors', () => ({ body: this.aggregator.take(['cm', 'sm']) }))
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}`, () => { this.scheduler.startTimer(harvestTimeSeconds, initialHarvestSeconds) })
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, sampleRate: 1, errorSampleRate: 0 } }
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: { sampleRate: 0, errorSampleRate: 1 } })
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: { sampleRate: 0, errorSampleRate: 0 } })
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: { errorSampleRate: 1, sampleRate: 1 } })
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: { errorSampleRate: 0, sampleRate: 1 } })
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: { errorSampleRate: 1, sampleRate: 0 } })
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: { errorSampleRate: 0, sampleRate: 0 } })
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: { errorSampleRate: 1, sampleRate: 0 } })
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: { errorSampleRate: 1, sampleRate: 0 } })
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: { errorSampleRate: 1, sampleRate: 0 } })
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
- this.timestamp = { first: undefined, last: undefined }
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.errorSampleRate'),
127
- Math.random() < getConfigurationValue(this.agentIdentifier, 'session_replay.sampleRate')
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': this.timestamp.first,
225
- 'replay.lastTimestamp': this.timestamp.last,
226
- 'replay.durationMs': this.timestamp.last - this.timestamp.first,
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 { blockClass, ignoreClass, maskTextClass, blockSelector, maskInputOptions, maskTextSelector, maskAllInputs } = getConfigurationValue(this.agentIdentifier, 'session_replay')
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
- return this.abort()
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
- this.setTimestamps(event)
307
- if (event.type === 2) this.hasSnapshot = true
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 (rrwebEvent) {
327
- if (!rrwebEvent) return
328
- if (!this.timestamp.first) this.timestamp.first = rrwebEvent.timestamp
329
- this.timestamp.last = rrwebEvent.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
  })