@newrelic/browser-agent 1.297.0-rc.2 → 1.297.0-rc.3

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 (34) hide show
  1. package/dist/cjs/common/constants/agent-constants.js +3 -2
  2. package/dist/cjs/common/constants/env.cdn.js +1 -1
  3. package/dist/cjs/common/constants/env.npm.js +1 -1
  4. package/dist/cjs/features/session_replay/aggregate/index.js +18 -21
  5. package/dist/cjs/features/session_replay/constants.js +5 -1
  6. package/dist/cjs/features/session_replay/instrument/index.js +36 -50
  7. package/dist/cjs/features/session_replay/shared/recorder.js +47 -25
  8. package/dist/cjs/features/utils/instrument-base.js +6 -2
  9. package/dist/esm/common/constants/agent-constants.js +2 -1
  10. package/dist/esm/common/constants/env.cdn.js +1 -1
  11. package/dist/esm/common/constants/env.npm.js +1 -1
  12. package/dist/esm/features/session_replay/aggregate/index.js +18 -21
  13. package/dist/esm/features/session_replay/constants.js +5 -1
  14. package/dist/esm/features/session_replay/instrument/index.js +36 -50
  15. package/dist/esm/features/session_replay/shared/recorder.js +48 -26
  16. package/dist/esm/features/utils/instrument-base.js +6 -2
  17. package/dist/types/common/constants/agent-constants.d.ts +1 -0
  18. package/dist/types/common/constants/agent-constants.d.ts.map +1 -1
  19. package/dist/types/features/session_replay/aggregate/index.d.ts +9 -2
  20. package/dist/types/features/session_replay/aggregate/index.d.ts.map +1 -1
  21. package/dist/types/features/session_replay/constants.d.ts +4 -0
  22. package/dist/types/features/session_replay/instrument/index.d.ts +7 -0
  23. package/dist/types/features/session_replay/instrument/index.d.ts.map +1 -1
  24. package/dist/types/features/session_replay/shared/recorder.d.ts +10 -4
  25. package/dist/types/features/session_replay/shared/recorder.d.ts.map +1 -1
  26. package/dist/types/features/utils/instrument-base.d.ts +2 -1
  27. package/dist/types/features/utils/instrument-base.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/common/constants/agent-constants.js +1 -0
  30. package/src/features/session_replay/aggregate/index.js +18 -19
  31. package/src/features/session_replay/constants.js +5 -1
  32. package/src/features/session_replay/instrument/index.js +39 -40
  33. package/src/features/session_replay/shared/recorder.js +53 -26
  34. package/src/features/utils/instrument-base.js +7 -2
@@ -16,9 +16,11 @@ import { setupPauseReplayAPI } from '../../../loaders/api/pauseReplay'
16
16
 
17
17
  export class Instrument extends InstrumentBase {
18
18
  static featureName = FEATURE_NAME
19
+ /** @type {Promise|undefined} A promise that resolves when the recorder module is imported and added to the class. Undefined if the recorder has never been staged to import with `importRecorder`. */
20
+ #stagedImport
21
+ /** The RRWEB recorder instance, if imported */
22
+ recorder
19
23
 
20
- #mode
21
- #agentRef
22
24
  constructor (agentRef) {
23
25
  super(agentRef, FEATURE_NAME)
24
26
 
@@ -27,7 +29,6 @@ export class Instrument extends InstrumentBase {
27
29
  setupPauseReplayAPI(agentRef)
28
30
 
29
31
  let session
30
- this.#agentRef = agentRef
31
32
  try {
32
33
  session = JSON.parse(localStorage.getItem(`${PREFIX}_${DEFAULT_KEY}`))
33
34
  } catch (err) { }
@@ -37,15 +38,17 @@ export class Instrument extends InstrumentBase {
37
38
  }
38
39
 
39
40
  if (this.#canPreloadRecorder(session)) {
40
- this.#mode = session?.sessionReplayMode
41
- this.#preloadStartRecording()
42
- } else {
43
- this.importAggregator(this.#agentRef, () => import(/* webpackChunkName: "session_replay-aggregate" */ '../aggregate'))
41
+ this.importRecorder().then(recorder => {
42
+ recorder.startRecording(TRIGGERS.PRELOAD, session?.sessionReplayMode)
43
+ }) // could handle specific fail-state behaviors with a .catch block here
44
44
  }
45
45
 
46
+ this.importAggregator(this.agentRef, () => import(/* webpackChunkName: "session_replay-aggregate" */ '../aggregate'), this)
47
+
46
48
  /** If the recorder is running, we can pass error events on to the agg to help it switch to full mode later */
47
49
  this.ee.on('err', (e) => {
48
- if (this.#agentRef.runtime.isRecording) {
50
+ if (this.blocked) return
51
+ if (this.agentRef.runtime.isRecording) {
49
52
  this.errorNoticed = true
50
53
  handle(SR_EVENT_EMITTER_TYPES.ERROR_DURING_REPLAY, [e], undefined, this.featureName, this.ee)
51
54
  }
@@ -57,54 +60,50 @@ export class Instrument extends InstrumentBase {
57
60
  if (!session) { // this might be a new session if entity initializes: conservatively start recording if first-time config allows
58
61
  // Note: users with SR enabled, as well as these other configs enabled by-default, will be penalized by the recorder overhead EVEN IF they don't actually have or get
59
62
  // entitlement or sampling decision, or otherwise intentionally opted-in for the feature.
60
- return isPreloadAllowed(this.#agentRef.init)
63
+ return isPreloadAllowed(this.agentRef.init)
61
64
  } else if (session.sessionReplayMode === MODE.FULL || session.sessionReplayMode === MODE.ERROR) {
62
65
  return true // existing sessions get to continue recording, regardless of this page's configs or if it has expired (conservatively)
63
66
  } else { // SR mode was OFF but may potentially be turned on if session resets and configs allows the new session to have replay...
64
- return isPreloadAllowed(this.#agentRef.init)
67
+ return isPreloadAllowed(this.agentRef.init)
65
68
  }
66
69
  }
67
70
 
68
- #alreadyStarted = false
69
71
  /**
70
- * This func is use for early pre-load recording prior to replay feature (agg) being loaded onto the page. It should only setup once, including if already called and in-progress.
72
+ * Returns a promise that imports the recorder module. Only lets the recorder module be imported and instantiated once. Rejects if failed to import/instantiate.
73
+ * @returns {Promise}
71
74
  */
72
- async #preloadStartRecording (trigger) {
73
- if (this.#alreadyStarted) return
74
- this.#alreadyStarted = true
75
-
76
- try {
77
- const { Recorder } = (await import(/* webpackChunkName: "recorder" */'../shared/recorder'))
75
+ importRecorder () {
76
+ /** if we already have a recorder fully set up, just return it */
77
+ if (this.recorder) return Promise.resolve(this.recorder)
78
+ /** conditional -- if we have never started importing, stage the import and store it in state */
79
+ this.#stagedImport ??= import(/* webpackChunkName: "recorder" */'../shared/recorder')
80
+ .then(({ Recorder }) => {
81
+ this.recorder = new Recorder(this)
82
+ /** return the recorder for promise chaining */
83
+ return this.recorder
84
+ })
85
+ .catch(err => {
86
+ this.ee.emit('internal-error', [err])
87
+ this.blocked = true
88
+ /** return the err for promise chaining */
89
+ throw err
90
+ })
78
91
 
79
- // If startReplay() has been used by this point, we must record in full mode regardless of session preload:
80
- // Note: recorder starts here with w/e the mode is at this time, but this may be changed later (see #apiStartOrRestartReplay else-case)
81
- this.recorder ??= new Recorder({ ...this, mode: this.#mode, agentRef: this.#agentRef, trigger, timeKeeper: this.#agentRef.runtime.timeKeeper }) // if TK exists due to deferred state, pass it
82
- this.recorder.startRecording()
83
- this.abortHandler = this.recorder.stopRecording
84
- } catch (err) {
85
- this.parent.ee.emit('internal-error', [err])
86
- }
87
- this.importAggregator(this.#agentRef, () => import(/* webpackChunkName: "session_replay-aggregate" */ '../aggregate'), { recorder: this.recorder, errorNoticed: this.errorNoticed })
92
+ return this.#stagedImport
88
93
  }
89
94
 
90
95
  /**
91
96
  * Called whenever startReplay API is used. That could occur any time, pre or post load.
92
97
  */
93
98
  #apiStartOrRestartReplay () {
99
+ if (this.blocked) return
94
100
  if (this.featAggregate) { // post-load; there's possibly already an ongoing recording
95
- if (this.featAggregate.mode !== MODE.FULL) this.featAggregate.initializeRecording(MODE.FULL, true)
96
- } else { // pre-load
97
- this.#mode = MODE.FULL
98
- this.#preloadStartRecording(TRIGGERS.API)
99
- // There's a race here wherein either:
100
- // a. Recorder has not been initialized, and we've set the enforced mode, so we're good, or;
101
- // b. Record has been initialized, possibly with the "wrong" mode, so we have to correct that + restart.
102
- if (this.recorder && this.recorder.parent.mode !== MODE.FULL) {
103
- this.recorder.parent.mode = MODE.FULL
104
- this.recorder.stopRecording()
105
- this.recorder.startRecording()
106
- this.abortHandler = this.recorder.stopRecording
107
- }
101
+ if (this.featAggregate.mode !== MODE.FULL) this.featAggregate.initializeRecording(MODE.FULL, true, TRIGGERS.API)
102
+ } else {
103
+ this.importRecorder()
104
+ .then(() => {
105
+ this.recorder.startRecording(TRIGGERS.API, MODE.FULL)
106
+ }) // could handle specific fail-state behaviors with a .catch block here
108
107
  }
109
108
  }
110
109
  }
@@ -12,7 +12,7 @@ import { handle } from '../../../common/event-emitter/handle'
12
12
  import { SUPPORTABILITY_METRIC_CHANNEL } from '../../metrics/constants'
13
13
  import { FEATURE_NAMES } from '../../../loaders/features/features'
14
14
  import { customMasker } from './utils'
15
- import { IDEAL_PAYLOAD_SIZE } from '../../../common/constants/agent-constants'
15
+ import { IDEAL_PAYLOAD_SIZE, SESSION_ERROR } from '../../../common/constants/agent-constants'
16
16
  import { warn } from '../../../common/util/console'
17
17
  import { single } from '../../../common/util/invoke'
18
18
  import { registerHandler } from '../../../common/event-emitter/register-handler'
@@ -25,11 +25,21 @@ export class Recorder {
25
25
 
26
26
  #warnCSSOnce = single(() => warn(47)) // notifies user of potential replayer issue if fix_stylesheets is off
27
27
 
28
- constructor (parent) {
29
- /** The parent class that instantiated the recorder */
30
- this.parent = parent
28
+ #canRecord = true
29
+
30
+ triggerHistory = [] // useful for debugging
31
+
32
+ constructor (srInstrument) {
33
+ /** The parent classes that share the recorder */
34
+ this.srInstrument = srInstrument
35
+ // --- shortcuts
36
+ this.ee = srInstrument.ee
37
+ this.srFeatureName = srInstrument.featureName
38
+ this.agentRef = srInstrument.agentRef
39
+
40
+ this.isErrorMode = false
31
41
  /** A flag that can be set to false by failing conversions to stop the fetching process */
32
- this.shouldFix = this.parent.agentRef.init.session_replay.fix_stylesheets
42
+ this.shouldFix = this.agentRef.init.session_replay.fix_stylesheets
33
43
 
34
44
  /** Each page mutation or event will be stored (raw) in this array. This array will be cleared on each harvest */
35
45
  this.events = new RecorderEvents(this.shouldFix)
@@ -40,9 +50,18 @@ export class Recorder {
40
50
  /** 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 */
41
51
  this.lastMeta = false
42
52
  /** The method to stop recording. This defaults to a noop, but is overwritten once the recording library is imported and initialized */
43
- this.stopRecording = () => { this.parent.agentRef.runtime.isRecording = false }
53
+ this.stopRecording = () => { this.agentRef.runtime.isRecording = false }
44
54
 
45
- registerHandler(RRWEB_DATA_CHANNEL, (event, isCheckout) => { this.audit(event, isCheckout) }, this.parent.featureName, this.parent.ee)
55
+ registerHandler(SESSION_ERROR, () => {
56
+ this.#canRecord = false
57
+ this.stopRecording()
58
+ }, this.srFeatureName, this.ee)
59
+
60
+ registerHandler(RRWEB_DATA_CHANNEL, (event, isCheckout) => { this.audit(event, isCheckout) }, this.srFeatureName, this.ee)
61
+ }
62
+
63
+ get trigger () {
64
+ return this.triggerHistory[this.triggerHistory.length - 1]
46
65
  }
47
66
 
48
67
  getEvents () {
@@ -60,21 +79,29 @@ export class Recorder {
60
79
 
61
80
  /** Clears the buffer (this.events), and resets all payload metadata properties */
62
81
  clearBuffer () {
63
- this.backloggedEvents = (this.parent.mode === MODE.ERROR) ? this.events : new RecorderEvents(this.shouldFix)
82
+ this.backloggedEvents = (this.isErrorMode) ? this.events : new RecorderEvents(this.shouldFix)
64
83
  this.events = new RecorderEvents(this.shouldFix)
65
84
  }
66
85
 
67
86
  /** Begin recording using configured recording lib */
68
- startRecording () {
69
- this.parent.agentRef.runtime.isRecording = true
70
- const { block_class, ignore_class, mask_text_class, block_selector, mask_input_options, mask_text_selector, mask_all_inputs, inline_images, collect_fonts } = this.parent.agentRef.init.session_replay
87
+ startRecording (trigger, mode) {
88
+ if (!this.#canRecord) return
89
+ this.triggerHistory.push(trigger) // keep track of all triggers, useful for lifecycle debugging. "this.trigger" returns the latest entry
90
+
91
+ this.isErrorMode = mode === MODE.ERROR
92
+
93
+ /** if the recorder is already recording... lets stop it before starting a new one */
94
+ this.stopRecording()
95
+
96
+ this.agentRef.runtime.isRecording = true
97
+ const { block_class, ignore_class, mask_text_class, block_selector, mask_input_options, mask_text_selector, mask_all_inputs, inline_images, collect_fonts } = this.agentRef.init.session_replay
71
98
 
72
99
  // set up rrweb configurations for maximum privacy --
73
100
  // https://newrelic.atlassian.net/wiki/spaces/O11Y/pages/2792293280/2023+02+28+Browser+-+Session+Replay#Configuration-options
74
101
  let stop
75
102
  try {
76
103
  stop = recorder({
77
- emit: (event, isCheckout) => { handle(RRWEB_DATA_CHANNEL, [event, isCheckout], undefined, this.parent.featureName, this.parent.ee) },
104
+ emit: (event, isCheckout) => { handle(RRWEB_DATA_CHANNEL, [event, isCheckout], undefined, this.srFeatureName, this.ee) },
78
105
  blockClass: block_class,
79
106
  ignoreClass: ignore_class,
80
107
  maskTextClass: mask_text_class,
@@ -87,15 +114,15 @@ export class Recorder {
87
114
  inlineStylesheet: true,
88
115
  inlineImages: inline_images,
89
116
  collectFonts: collect_fonts,
90
- checkoutEveryNms: CHECKOUT_MS[this.parent.mode],
117
+ checkoutEveryNms: CHECKOUT_MS[mode],
91
118
  recordAfter: 'DOMContentLoaded'
92
119
  })
93
120
  } catch (err) {
94
- this.parent.ee.emit('internal-error', [err])
121
+ this.ee.emit('internal-error', [err])
95
122
  }
96
123
 
97
124
  this.stopRecording = () => {
98
- this.parent.agentRef.runtime.isRecording = false
125
+ this.agentRef.runtime.isRecording = false
99
126
  stop?.()
100
127
  }
101
128
  }
@@ -108,14 +135,14 @@ export class Recorder {
108
135
  */
109
136
  audit (event, isCheckout) {
110
137
  /** An count of stylesheet objects that were blocked from accessing contents via JS */
111
- const incompletes = this.parent.agentRef.init.session_replay.fix_stylesheets ? stylesheetEvaluator.evaluate() : 0
138
+ const incompletes = this.agentRef.init.session_replay.fix_stylesheets ? stylesheetEvaluator.evaluate() : 0
112
139
  const missingInlineSMTag = 'SessionReplay/Payload/Missing-Inline-Css/'
113
140
  /** only run the full fixing behavior (more costly) if fix_stylesheets is configured as on (default behavior) */
114
141
  if (!this.shouldFix) {
115
142
  if (incompletes > 0) {
116
143
  this.events.inlinedAllStylesheets = false
117
144
  this.#warnCSSOnce()
118
- handle(SUPPORTABILITY_METRIC_CHANNEL, [missingInlineSMTag + 'Skipped', incompletes], undefined, FEATURE_NAMES.metrics, this.parent.ee)
145
+ handle(SUPPORTABILITY_METRIC_CHANNEL, [missingInlineSMTag + 'Skipped', incompletes], undefined, FEATURE_NAMES.metrics, this.ee)
119
146
  }
120
147
  return this.store(event, isCheckout)
121
148
  }
@@ -128,8 +155,8 @@ export class Recorder {
128
155
  this.events.inlinedAllStylesheets = false
129
156
  this.shouldFix = false
130
157
  }
131
- handle(SUPPORTABILITY_METRIC_CHANNEL, [missingInlineSMTag + 'Failed', failedToFix], undefined, FEATURE_NAMES.metrics, this.parent.ee)
132
- handle(SUPPORTABILITY_METRIC_CHANNEL, [missingInlineSMTag + 'Fixed', incompletes - failedToFix], undefined, FEATURE_NAMES.metrics, this.parent.ee)
158
+ handle(SUPPORTABILITY_METRIC_CHANNEL, [missingInlineSMTag + 'Failed', failedToFix], undefined, FEATURE_NAMES.metrics, this.ee)
159
+ handle(SUPPORTABILITY_METRIC_CHANNEL, [missingInlineSMTag + 'Fixed', incompletes - failedToFix], undefined, FEATURE_NAMES.metrics, this.ee)
133
160
  this.takeFullSnapshot()
134
161
  })
135
162
  /** Only start ignoring data if got a faulty snapshot */
@@ -141,10 +168,10 @@ export class Recorder {
141
168
 
142
169
  /** Store a payload in the buffer (this.events). This should be the callback to the recording lib noticing a mutation */
143
170
  store (event, isCheckout) {
144
- if (!event || this.parent.blocked) return
171
+ if (!event || this.srInstrument.featAggregate?.blocked) return
145
172
 
146
173
  /** because we've waited until draining to process the buffered rrweb events, we can guarantee the timekeeper exists */
147
- event.timestamp = this.parent.timeKeeper.correctAbsoluteTimestamp(event.timestamp)
174
+ event.timestamp = this.agentRef.runtime.timeKeeper.correctAbsoluteTimestamp(event.timestamp)
148
175
  event.__serialized = stringify(event)
149
176
  const eventBytes = event.__serialized.length
150
177
  /** The estimated size of the payload after compression */
@@ -153,7 +180,7 @@ export class Recorder {
153
180
  // to help reconstruct the replay later and must be included. While waiting and buffering for errors to come through,
154
181
  // each time we see a new checkout, we can drop the old data.
155
182
  // we need to check for meta because rrweb will flag it as checkout twice, once for meta, then once for snapshot
156
- if (this.parent.mode === MODE.ERROR && isCheckout && event.type === RRWEB_EVENT_TYPES.Meta) {
183
+ if (this.isErrorMode && isCheckout && event.type === RRWEB_EVENT_TYPES.Meta) {
157
184
  // we are still waiting for an error to throw, so keep wiping the buffer over time
158
185
  this.clearBuffer()
159
186
  }
@@ -168,16 +195,16 @@ export class Recorder {
168
195
 
169
196
  // We are making an effort to try to keep payloads manageable for unloading. If they reach the unload limit before their interval,
170
197
  // it will send immediately. This often happens on the first snapshot, which can be significantly larger than the other payloads.
171
- if (((this.events.hasSnapshot && this.events.hasMeta) || payloadSize > IDEAL_PAYLOAD_SIZE) && this.parent.mode === MODE.FULL) {
198
+ if (((this.events.hasSnapshot && this.events.hasMeta) || payloadSize > IDEAL_PAYLOAD_SIZE) && !this.isErrorMode) {
172
199
  // if we've made it to the ideal size of ~16kb before the interval timer, we should send early.
173
- this.parent.agentRef.runtime.harvester.triggerHarvestFor(this.parent)
200
+ this.agentRef.runtime.harvester.triggerHarvestFor(this.srInstrument.featAggregate)
174
201
  }
175
202
  }
176
203
 
177
204
  /** force the recording lib to take a full DOM snapshot. This needs to occur in certain cases, like visibility changes */
178
205
  takeFullSnapshot () {
179
206
  try {
180
- if (!this.parent.agentRef.runtime.isRecording) return
207
+ if (!this.agentRef.runtime.isRecording) return
181
208
  recorder.takeFullSnapshot()
182
209
  } catch (err) {
183
210
  // in the off chance we think we are recording, but rrweb does not, rrweb's lib will throw an error. This catch is just a precaution
@@ -199,7 +226,7 @@ export class Recorder {
199
226
  * https://staging.onenr.io/037jbJWxbjy
200
227
  * */
201
228
  estimateCompression (data) {
202
- if (!!this.parent.gzipper && !!this.parent.u8) return data * AVG_COMPRESSION
229
+ if (!!this.srInstrument.featAggregate?.gzipper && !!this.srInstrument.featAggregate?.u8) return data * AVG_COMPRESSION
203
230
  return data
204
231
  }
205
232
  }
@@ -18,6 +18,8 @@ import { FEATURE_NAMES } from '../../loaders/features/features'
18
18
  import { hasReplayPrerequisite } from '../session_replay/shared/utils'
19
19
  import { canEnableSessionTracking } from './feature-gates'
20
20
  import { single } from '../../common/util/invoke'
21
+ import { SESSION_ERROR } from '../../common/constants/agent-constants'
22
+ import { handle } from '../../common/event-emitter/handle'
21
23
 
22
24
  /**
23
25
  * Base class for instrumenting a feature.
@@ -32,6 +34,8 @@ export class InstrumentBase extends FeatureBase {
32
34
  constructor (agentRef, featureName) {
33
35
  super(agentRef.agentIdentifier, featureName)
34
36
 
37
+ this.agentRef = agentRef
38
+
35
39
  /** @type {Function | undefined} This should be set by any derived Instrument class if it has things to do when feature fails or is killed. */
36
40
  this.abortHandler = undefined
37
41
 
@@ -74,7 +78,7 @@ export class InstrumentBase extends FeatureBase {
74
78
  * @param {Object} agentRef - reference to the base agent ancestor that this feature belongs to
75
79
  * @param {Function} fetchAggregator - a function that returns a promise that resolves to the aggregate module
76
80
  * @param {Object} [argsObjFromInstrument] - any values or references to pass down to aggregate
77
- * @returns void
81
+ * @returns
78
82
  */
79
83
  importAggregator (agentRef, fetchAggregator, argsObjFromInstrument = {}) {
80
84
  if (this.featAggregate) return
@@ -99,7 +103,7 @@ export class InstrumentBase extends FeatureBase {
99
103
  } catch (e) {
100
104
  warn(20, e)
101
105
  this.ee.emit('internal-error', [e])
102
- if (this.featureName === FEATURE_NAMES.sessionReplay) this.abortHandler?.() // SR should stop recording if session DNE
106
+ handle(SESSION_ERROR, [e], undefined, this.featureName, this.ee)
103
107
  }
104
108
 
105
109
  /**
@@ -140,6 +144,7 @@ export class InstrumentBase extends FeatureBase {
140
144
  * @returns
141
145
  */
142
146
  #shouldImportAgg (featureName, session, agentInit) {
147
+ if (this.blocked) return false
143
148
  switch (featureName) {
144
149
  case FEATURE_NAMES.sessionReplay: // the session manager must be initialized successfully for Replay & Trace features
145
150
  return hasReplayPrerequisite(agentInit) && !!session