@newrelic/browser-agent 1.255.0 → 1.256.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 (78) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cjs/common/constants/env.cdn.js +2 -2
  3. package/dist/cjs/common/constants/env.npm.js +2 -2
  4. package/dist/cjs/common/constants/runtime.js +1 -1
  5. package/dist/cjs/common/harvest/harvest.js +1 -0
  6. package/dist/cjs/common/session/session-entity.js +2 -1
  7. package/dist/cjs/common/timer/interaction-timer.js +16 -2
  8. package/dist/cjs/common/timing/time-keeper.js +1 -2
  9. package/dist/cjs/features/jserrors/aggregate/index.js +16 -6
  10. package/dist/cjs/features/jserrors/instrument/index.js +8 -3
  11. package/dist/cjs/features/session_replay/aggregate/index.js +48 -29
  12. package/dist/cjs/features/session_replay/constants.js +2 -1
  13. package/dist/cjs/features/session_replay/instrument/index.js +9 -2
  14. package/dist/cjs/features/session_replay/shared/recorder-events.js +1 -9
  15. package/dist/cjs/features/session_replay/shared/recorder.js +22 -50
  16. package/dist/cjs/features/session_replay/shared/utils.js +12 -0
  17. package/dist/cjs/features/session_trace/aggregate/index.js +19 -22
  18. package/dist/cjs/loaders/api/api.js +7 -1
  19. package/dist/cjs/loaders/configure/configure.js +1 -0
  20. package/dist/esm/common/constants/env.cdn.js +2 -2
  21. package/dist/esm/common/constants/env.npm.js +2 -2
  22. package/dist/esm/common/constants/runtime.js +1 -1
  23. package/dist/esm/common/harvest/harvest.js +1 -0
  24. package/dist/esm/common/session/session-entity.js +2 -1
  25. package/dist/esm/common/timer/interaction-timer.js +16 -2
  26. package/dist/esm/common/timing/time-keeper.js +1 -3
  27. package/dist/esm/features/jserrors/aggregate/index.js +16 -6
  28. package/dist/esm/features/jserrors/instrument/index.js +8 -3
  29. package/dist/esm/features/session_replay/aggregate/index.js +48 -29
  30. package/dist/esm/features/session_replay/constants.js +2 -1
  31. package/dist/esm/features/session_replay/instrument/index.js +9 -2
  32. package/dist/esm/features/session_replay/shared/recorder-events.js +1 -9
  33. package/dist/esm/features/session_replay/shared/recorder.js +23 -51
  34. package/dist/esm/features/session_replay/shared/utils.js +11 -0
  35. package/dist/esm/features/session_trace/aggregate/index.js +19 -22
  36. package/dist/esm/loaders/api/api.js +7 -1
  37. package/dist/esm/loaders/configure/configure.js +1 -0
  38. package/dist/types/common/constants/runtime.d.ts.map +1 -1
  39. package/dist/types/common/session/session-entity.d.ts.map +1 -1
  40. package/dist/types/common/timer/interaction-timer.d.ts +2 -0
  41. package/dist/types/common/timer/interaction-timer.d.ts.map +1 -1
  42. package/dist/types/common/timing/time-keeper.d.ts.map +1 -1
  43. package/dist/types/features/jserrors/aggregate/index.d.ts +2 -1
  44. package/dist/types/features/jserrors/aggregate/index.d.ts.map +1 -1
  45. package/dist/types/features/jserrors/instrument/index.d.ts.map +1 -1
  46. package/dist/types/features/session_replay/aggregate/index.d.ts +5 -2
  47. package/dist/types/features/session_replay/aggregate/index.d.ts.map +1 -1
  48. package/dist/types/features/session_replay/constants.d.ts +1 -0
  49. package/dist/types/features/session_replay/constants.d.ts.map +1 -1
  50. package/dist/types/features/session_replay/instrument/index.d.ts +1 -0
  51. package/dist/types/features/session_replay/instrument/index.d.ts.map +1 -1
  52. package/dist/types/features/session_replay/shared/recorder-events.d.ts +0 -8
  53. package/dist/types/features/session_replay/shared/recorder-events.d.ts.map +1 -1
  54. package/dist/types/features/session_replay/shared/recorder.d.ts +1 -17
  55. package/dist/types/features/session_replay/shared/recorder.d.ts.map +1 -1
  56. package/dist/types/features/session_replay/shared/utils.d.ts +8 -0
  57. package/dist/types/features/session_replay/shared/utils.d.ts.map +1 -1
  58. package/dist/types/features/session_trace/aggregate/index.d.ts +2 -1
  59. package/dist/types/features/session_trace/aggregate/index.d.ts.map +1 -1
  60. package/dist/types/loaders/api/api.d.ts.map +1 -1
  61. package/dist/types/loaders/configure/configure.d.ts.map +1 -1
  62. package/package.json +2 -2
  63. package/src/common/constants/runtime.js +1 -1
  64. package/src/common/harvest/harvest.js +1 -1
  65. package/src/common/session/session-entity.js +2 -1
  66. package/src/common/timer/interaction-timer.js +17 -2
  67. package/src/common/timing/time-keeper.js +1 -3
  68. package/src/features/jserrors/aggregate/index.js +15 -6
  69. package/src/features/jserrors/instrument/index.js +9 -4
  70. package/src/features/session_replay/aggregate/index.js +43 -25
  71. package/src/features/session_replay/constants.js +2 -1
  72. package/src/features/session_replay/instrument/index.js +7 -2
  73. package/src/features/session_replay/shared/recorder-events.js +1 -6
  74. package/src/features/session_replay/shared/recorder.js +21 -27
  75. package/src/features/session_replay/shared/utils.js +12 -0
  76. package/src/features/session_trace/aggregate/index.js +18 -16
  77. package/src/loaders/api/api.js +10 -1
  78. package/src/loaders/configure/configure.js +1 -0
@@ -1,5 +1,3 @@
1
- import { globalScope } from '../constants/runtime'
2
-
3
1
  /**
4
2
  * Class used to adjust the timestamp of harvested data to New Relic server time. This
5
3
  * is done by tracking the performance timings of the RUM call and applying a calculation
@@ -33,7 +31,7 @@ export class TimeKeeper {
33
31
  #ready = false
34
32
 
35
33
  constructor () {
36
- this.#originTime = globalScope.performance.timeOrigin || globalScope.performance.timing.navigationStart
34
+ this.#originTime = Date.now() - performance.now()
37
35
  }
38
36
 
39
37
  get ready () {
@@ -38,10 +38,13 @@ export class Aggregate extends AggregateBase {
38
38
  this.bufferedErrorsUnderSpa = {}
39
39
  this.currentBody = undefined
40
40
  this.errorOnPage = false
41
+ this.replayAborted = false
41
42
 
42
43
  // this will need to change to match whatever ee we use in the instrument
43
44
  this.ee.on('interactionDone', (interaction, wasSaved) => this.onInteractionDone(interaction, wasSaved))
44
45
 
46
+ this.ee.on('REPLAY_ABORTED', () => { this.replayAborted = true })
47
+
45
48
  register('err', (...args) => this.storeError(...args), this.featureName, this.ee)
46
49
  register('ierr', (...args) => this.storeError(...args), this.featureName, this.ee)
47
50
  register('softNavFlush', (interactionId, wasFinished, softNavAttrs) =>
@@ -78,9 +81,16 @@ export class Aggregate extends AggregateBase {
78
81
  payload.qs.ri = releaseIds
79
82
  }
80
83
 
81
- if (body && body.err && body.err.length && !this.errorOnPage) {
82
- payload.qs.pve = '1'
83
- this.errorOnPage = true
84
+ if (body && body.err && body.err.length) {
85
+ if (this.replayAborted) {
86
+ body.err.forEach((e) => {
87
+ delete e.params?.hasReplay
88
+ })
89
+ }
90
+ if (!this.errorOnPage) {
91
+ payload.qs.pve = '1'
92
+ this.errorOnPage = true
93
+ }
84
94
  }
85
95
  return payload
86
96
  }
@@ -133,7 +143,7 @@ export class Aggregate extends AggregateBase {
133
143
  return canonicalStackString
134
144
  }
135
145
 
136
- storeError (err, time, internal, customAttributes) {
146
+ storeError (err, time, internal, customAttributes, hasReplay) {
137
147
  // are we in an interaction
138
148
  time = time || now()
139
149
  const agentRuntime = getRuntime(this.agentIdentifier)
@@ -189,7 +199,7 @@ export class Aggregate extends AggregateBase {
189
199
  this.pageviewReported[bucketHash] = true
190
200
  }
191
201
 
192
- if (agentRuntime?.session?.state?.sessionReplayMode) params.hasReplay = true
202
+ if (hasReplay && !this.replayAborted) params.hasReplay = hasReplay
193
203
  params.firstOccurrenceTimestamp = this.observedAt[bucketHash]
194
204
  params.timestamp = this.observedAt[bucketHash]
195
205
 
@@ -199,7 +209,6 @@ export class Aggregate extends AggregateBase {
199
209
  // Trace sends the error in its payload, and both trace & replay simply listens for any error to occur.
200
210
  const jsErrorEvent = [type, bucketHash, params, newMetrics, customAttributes]
201
211
  handle('errorAgg', jsErrorEvent, undefined, FEATURE_NAMES.sessionTrace, this.ee)
202
- handle('errorAgg', jsErrorEvent, undefined, FEATURE_NAMES.sessionReplay, this.ee)
203
212
  // still send EE events for other features such as above, but stop this one from aggregating internal data
204
213
  if (this.blocked) return
205
214
 
@@ -12,11 +12,13 @@ import { eventListenerOpts } from '../../../common/event-listener/event-listener
12
12
  import { stringify } from '../../../common/util/stringify'
13
13
  import { UncaughtError } from './uncaught-error'
14
14
  import { now } from '../../../common/timing/now'
15
+ import { SR_EVENT_EMITTER_TYPES } from '../../session_replay/constants'
15
16
 
16
17
  export class Instrument extends InstrumentBase {
17
18
  static featureName = FEATURE_NAME
18
19
 
19
20
  #seenErrors = new Set()
21
+ #replayRunning = false
20
22
 
21
23
  constructor (agentIdentifier, aggregator, auto = true) {
22
24
  super(agentIdentifier, aggregator, FEATURE_NAME, auto)
@@ -36,13 +38,16 @@ export class Instrument extends InstrumentBase {
36
38
 
37
39
  this.ee.on('internal-error', (error) => {
38
40
  if (!this.abortHandler) return
39
- handle('ierr', [this.#castError(error), now(), true], undefined, FEATURE_NAMES.jserrors, this.ee)
41
+ handle('ierr', [this.#castError(error), now(), true, {}, this.#replayRunning], undefined, FEATURE_NAMES.jserrors, this.ee)
42
+ })
43
+
44
+ this.ee.on(SR_EVENT_EMITTER_TYPES.REPLAY_RUNNING, (isRunning) => {
45
+ this.#replayRunning = isRunning
40
46
  })
41
47
 
42
48
  globalScope.addEventListener('unhandledrejection', (promiseRejectionEvent) => {
43
49
  if (!this.abortHandler) return
44
-
45
- handle('err', [this.#castPromiseRejectionEvent(promiseRejectionEvent), now(), false, { unhandledPromiseRejection: 1 }], undefined, FEATURE_NAMES.jserrors, this.ee)
50
+ handle('err', [this.#castPromiseRejectionEvent(promiseRejectionEvent), now(), false, { unhandledPromiseRejection: 1 }, this.#replayRunning], undefined, FEATURE_NAMES.jserrors, this.ee)
46
51
  }, eventListenerOpts(false, this.removeOnAbort?.signal))
47
52
 
48
53
  globalScope.addEventListener('error', (errorEvent) => {
@@ -57,7 +62,7 @@ export class Instrument extends InstrumentBase {
57
62
  return
58
63
  }
59
64
 
60
- handle('err', [this.#castErrorEvent(errorEvent), now()], undefined, FEATURE_NAMES.jserrors, this.ee)
65
+ handle('err', [this.#castErrorEvent(errorEvent), now(), false, {}, this.#replayRunning], undefined, FEATURE_NAMES.jserrors, this.ee)
61
66
  }, eventListenerOpts(false, this.removeOnAbort?.signal))
62
67
 
63
68
  this.abortHandler = this.#abort // we also use this as a flag to denote that the feature is active or on and handling errors
@@ -28,9 +28,12 @@ import { stringify } from '../../../common/util/stringify'
28
28
  import { stylesheetEvaluator } from '../shared/stylesheet-evaluator'
29
29
  import { deregisterDrain } from '../../../common/drain/drain'
30
30
  import { now } from '../../../common/timing/now'
31
+ import { buildNRMetaNode } from '../shared/utils'
31
32
 
32
33
  export class Aggregate extends AggregateBase {
33
34
  static featureName = FEATURE_NAME
35
+ mode = MODE.OFF
36
+
34
37
  // pass the recorder into the aggregator
35
38
  constructor (agentIdentifier, aggregator, args) {
36
39
  super(agentIdentifier, aggregator, FEATURE_NAME)
@@ -44,9 +47,6 @@ export class Aggregate extends AggregateBase {
44
47
  this.gzipper = undefined
45
48
  /** populated with the u8 string lib async */
46
49
  this.u8 = undefined
47
- /** the mode to start in. Defaults to off */
48
- const { session } = getRuntime(this.agentIdentifier)
49
- this.mode = session.state.sessionReplayMode || MODE.OFF
50
50
 
51
51
  /** set by BCS response */
52
52
  this.entitled = false
@@ -54,13 +54,13 @@ export class Aggregate extends AggregateBase {
54
54
  this.timeKeeper = undefined
55
55
 
56
56
  this.recorder = args?.recorder
57
- if (this.recorder) this.recorder.parent = this
57
+ this.preloaded = !!this.recorder
58
+ this.errorNoticed = args?.errorNoticed || false
58
59
 
59
60
  handle(SUPPORTABILITY_METRIC_CHANNEL, ['Config/SessionReplay/Enabled'], undefined, FEATURE_NAMES.metrics, this.ee)
60
61
 
61
62
  // 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.
62
63
  this.ee.on(SESSION_EVENTS.RESET, () => {
63
- this.scheduler.runHarvest()
64
64
  this.abort(ABORT_REASONS.RESET)
65
65
  })
66
66
 
@@ -104,17 +104,6 @@ export class Aggregate extends AggregateBase {
104
104
  this.forceStop(this.mode !== MODE.ERROR)
105
105
  }, this.featureName, this.ee)
106
106
 
107
- // Wait for an error to be reported. This currently is wrapped around the "Error" feature. This is a feature-feature dependency.
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
109
- registerHandler('errorAgg', (e) => {
110
- this.errorNoticed = true
111
- if (this.recorder) this.recorder.currentBufferTarget.hasError = true
112
- // run once
113
- if (this.mode === MODE.ERROR && globalScope?.document.visibilityState === 'visible') {
114
- this.switchToFull()
115
- }
116
- }, this.featureName, this.ee)
117
-
118
107
  const { error_sampling_rate, sampling_rate, autoStart, block_selector, mask_text_selector, mask_all_inputs, inline_stylesheet, inline_images, collect_fonts } = getConfigurationValue(this.agentIdentifier, 'session_replay')
119
108
 
120
109
  this.waitForFlags(['sr']).then(([flagOn]) => {
@@ -150,6 +139,14 @@ export class Aggregate extends AggregateBase {
150
139
  handle(SUPPORTABILITY_METRIC_CHANNEL, ['Config/SessionReplay/ErrorSamplingRate/Value', error_sampling_rate], undefined, FEATURE_NAMES.metrics, this.ee)
151
140
  }
152
141
 
142
+ handleError (e) {
143
+ if (this.recorder) this.recorder.currentBufferTarget.hasError = true
144
+ // run once
145
+ if (this.mode === MODE.ERROR && globalScope?.document.visibilityState === 'visible') {
146
+ this.switchToFull()
147
+ }
148
+ }
149
+
153
150
  switchToFull () {
154
151
  this.mode = MODE.FULL
155
152
  // if the error was noticed AFTER the recorder was already imported....
@@ -210,12 +207,13 @@ export class Aggregate extends AggregateBase {
210
207
  } catch (err) {
211
208
  return this.abort(ABORT_REASONS.IMPORT)
212
209
  }
210
+ } else {
211
+ this.recorder.parent = this
213
212
  }
214
213
 
215
214
  // If an error was noticed before the mode could be set (like in the early lifecycle of the page), immediately set to FULL mode
216
- if (this.mode === MODE.ERROR && this.errorNoticed) {
217
- this.mode = MODE.FULL
218
- }
215
+ if (this.mode === MODE.ERROR && this.errorNoticed) this.mode = MODE.FULL
216
+ if (!this.preloaded) this.ee.on('err', e => this.handleError(e))
219
217
 
220
218
  // FULL mode records AND reports from the beginning, while ERROR mode only records (but does not report).
221
219
  // ERROR mode will do this until an error is thrown, and then switch into FULL mode.
@@ -255,16 +253,29 @@ export class Aggregate extends AggregateBase {
255
253
  return
256
254
  }
257
255
 
256
+ handle(SUPPORTABILITY_METRIC_CHANNEL, ['SessionReplay/Harvest/Attempts'], undefined, FEATURE_NAMES.metrics, this.ee)
257
+
258
258
  let len = 0
259
259
  if (!!this.gzipper && !!this.u8) {
260
- payload.body = this.gzipper(this.u8(`[${payload.body.map(e => {
261
- if (e.__serialized) return e.__serialized
262
- return stringify(e)
260
+ payload.body = this.gzipper(this.u8(`[${payload.body.map(({ __serialized, ...e }) => {
261
+ if (e.__newrelic && __serialized) return __serialized
262
+ const output = { ...e }
263
+ if (!output.__newrelic) {
264
+ output.__newrelic = buildNRMetaNode(e.timestamp, this.timeKeeper)
265
+ output.timestamp = this.timeKeeper.correctAbsoluteTimestamp(e.timestamp)
266
+ }
267
+ return stringify(output)
263
268
  }).join(',')}]`))
264
269
  len = payload.body.length
265
270
  this.scheduler.opts.gzip = true
266
271
  } else {
267
- payload.body = payload.body.map(({ __serialized, ...node }) => node)
272
+ payload.body = payload.body.map(({ __serialized, ...node }) => {
273
+ if (node.__newrelic) return node
274
+ const output = { ...node }
275
+ output.__newrelic = buildNRMetaNode(node.timestamp, this.timeKeeper)
276
+ output.timestamp = this.timeKeeper.correctAbsoluteTimestamp(node.timestamp)
277
+ return output
278
+ })
268
279
  len = stringify(payload.body).length
269
280
  this.scheduler.opts.gzip = false
270
281
  }
@@ -281,6 +292,12 @@ export class Aggregate extends AggregateBase {
281
292
  return [payload]
282
293
  }
283
294
 
295
+ getCorrectedTimestamp (node) {
296
+ if (!node.timestamp) return
297
+ if (node.__newrelic) return node.timestamp
298
+ return this.timeKeeper.correctAbsoluteTimestamp(node.timestamp)
299
+ }
300
+
284
301
  getHarvestContents (recorderEvents) {
285
302
  recorderEvents ??= this.recorder.getEvents()
286
303
  let events = recorderEvents.events
@@ -308,8 +325,8 @@ export class Aggregate extends AggregateBase {
308
325
 
309
326
  const relativeNow = now()
310
327
 
311
- const firstEventTimestamp = events[0]?.timestamp // from rrweb node
312
- const lastEventTimestamp = events[events.length - 1]?.timestamp // from rrweb node
328
+ const firstEventTimestamp = this.getCorrectedTimestamp(events[0]) // from rrweb node
329
+ const lastEventTimestamp = this.getCorrectedTimestamp(events[events.length - 1]) // from rrweb node
313
330
  const firstTimestamp = firstEventTimestamp || this.timeKeeper.correctAbsoluteTimestamp(recorderEvents.cycleTimestamp) // from rrweb node || from when the harvest cycle started
314
331
  const lastTimestamp = lastEventTimestamp || this.timeKeeper.convertRelativeTimestamp(relativeNow)
315
332
 
@@ -341,6 +358,7 @@ export class Aggregate extends AggregateBase {
341
358
  invalidStylesheetsDetected: stylesheetEvaluator.invalidStylesheetsDetected,
342
359
  inlinedAllStylesheets: recorderEvents.inlinedAllStylesheets,
343
360
  'rrweb.version': RRWEB_VERSION,
361
+ 'payload.type': recorderEvents.type,
344
362
  // customer-defined data should go last so that if it exceeds the query param padding limit it will be truncated instead of important attrs
345
363
  ...(endUserId && { 'enduser.id': endUserId })
346
364
  // The Query Param is being arbitrarily limited in length here. It is also applied when estimating the size of the payload in getPayloadSize()
@@ -5,7 +5,8 @@ export const FEATURE_NAME = FEATURE_NAMES.sessionReplay
5
5
 
6
6
  export const SR_EVENT_EMITTER_TYPES = {
7
7
  RECORD: 'recordReplay',
8
- PAUSE: 'pauseReplay'
8
+ PAUSE: 'pauseReplay',
9
+ REPLAY_RUNNING: 'replayRunning'
9
10
  }
10
11
 
11
12
  export const AVG_COMPRESSION = 0.12
@@ -24,6 +24,11 @@ export class Instrument extends InstrumentBase {
24
24
  } catch (err) { }
25
25
 
26
26
  if (this.#canPreloadRecorder(session)) {
27
+ /** If this is preloaded, set up a buffer, if not, later when sampling we will set up a .on for live events */
28
+ this.ee.on('err', (e) => {
29
+ this.errorNoticed = true
30
+ if (this.featAggregate) this.featAggregate.handleError()
31
+ })
27
32
  this.#startRecording(session?.sessionReplayMode)
28
33
  } else {
29
34
  this.importAggregator()
@@ -45,9 +50,9 @@ export class Instrument extends InstrumentBase {
45
50
 
46
51
  async #startRecording (mode) {
47
52
  const { Recorder } = (await import(/* webpackChunkName: "recorder" */'../shared/recorder'))
48
- this.recorder = new Recorder({ mode, agentIdentifier: this.agentIdentifier })
53
+ this.recorder = new Recorder({ mode, agentIdentifier: this.agentIdentifier, ee: this.ee })
49
54
  this.recorder.startRecording()
50
55
  this.abortHandler = this.recorder.stopRecording
51
- this.importAggregator({ recorder: this.recorder })
56
+ this.importAggregator({ recorder: this.recorder, errorNoticed: this.errorNoticed })
52
57
  }
53
58
  }
@@ -1,16 +1,11 @@
1
1
  export class RecorderEvents {
2
- constructor ({ canCorrectTimestamps }) {
2
+ constructor () {
3
3
  /** The buffer to hold recorder event nodes */
4
4
  this.events = []
5
5
  /** Payload metadata -- Should indicate when a replay blob started recording. Resets each time a harvest occurs.
6
6
  * cycle timestamps are used as fallbacks if event timestamps cannot be used
7
7
  */
8
8
  this.cycleTimestamp = Date.now()
9
- /** Payload metadata -- Whether timestamps can be corrected, defaults as false, can be set to true if timekeeper is present at init time. Used to determine
10
- * if harvest needs to re-loop through nodes and correct them before sending. Ideal behavior is to correct them as they flow into the recorder
11
- * to prevent re-looping, but is not always possible since the timekeeper is not set until after page load and the recorder can be preloaded.
12
- */
13
- this.canCorrectTimestamps = !!canCorrectTimestamps
14
9
  /** A value which increments with every new mutation node reported. Resets after a harvest is sent */
15
10
  this.payloadBytesEstimation = 0
16
11
  /** Payload metadata -- Should indicate that the payload being sent has a full DOM snapshot. This can happen
@@ -1,6 +1,6 @@
1
1
  import { record as recorder } from 'rrweb'
2
2
  import { stringify } from '../../../common/util/stringify'
3
- import { AVG_COMPRESSION, CHECKOUT_MS, IDEAL_PAYLOAD_SIZE, QUERY_PARAM_PADDING, RRWEB_EVENT_TYPES } from '../constants'
3
+ import { AVG_COMPRESSION, CHECKOUT_MS, IDEAL_PAYLOAD_SIZE, QUERY_PARAM_PADDING, RRWEB_EVENT_TYPES, SR_EVENT_EMITTER_TYPES } from '../constants'
4
4
  import { getConfigurationValue } from '../../../common/config/config'
5
5
  import { RecorderEvents } from './recorder-events'
6
6
  import { MODE } from '../../../common/session/constants'
@@ -8,6 +8,7 @@ import { stylesheetEvaluator } from './stylesheet-evaluator'
8
8
  import { handle } from '../../../common/event-emitter/handle'
9
9
  import { SUPPORTABILITY_METRIC_CHANNEL } from '../../metrics/constants'
10
10
  import { FEATURE_NAMES } from '../../../loaders/features/features'
11
+ import { buildNRMetaNode } from './utils'
11
12
 
12
13
  export class Recorder {
13
14
  /** Each page mutation or event will be stored (raw) in this array. This array will be cleared on each harvest */
@@ -20,9 +21,9 @@ export class Recorder {
20
21
  #fixing = false
21
22
 
22
23
  constructor (parent) {
23
- this.#events = new RecorderEvents({ canCorrectTimestamps: !!parent.timeKeeper?.ready })
24
- this.#backloggedEvents = new RecorderEvents({ canCorrectTimestamps: !!parent.timeKeeper?.ready })
25
- this.#preloaded = [new RecorderEvents({ canCorrectTimestamps: !!parent.timeKeeper?.ready })]
24
+ this.#events = new RecorderEvents()
25
+ this.#backloggedEvents = new RecorderEvents()
26
+ this.#preloaded = [new RecorderEvents()]
26
27
  /** True when actively recording, false when paused or stopped */
27
28
  this.recording = false
28
29
  /** The pointer to the current bucket holding rrweb events */
@@ -40,14 +41,9 @@ export class Recorder {
40
41
  }
41
42
 
42
43
  getEvents () {
43
- if (this.#preloaded[0]?.events.length) {
44
- const preloadedEvents = this.returnCorrectTimestamps(this.#preloaded[0])
45
- return { ...this.#preloaded[0], events: preloadedEvents, type: 'preloaded' }
46
- }
47
- const backloggedEvents = this.returnCorrectTimestamps(this.#backloggedEvents)
48
- const events = this.returnCorrectTimestamps(this.#events)
44
+ if (this.#preloaded[0]?.events.length) return { ...this.#preloaded[0], type: 'preloaded' }
49
45
  return {
50
- events: [...backloggedEvents, ...events].filter(x => x),
46
+ events: [...this.#backloggedEvents.events, ...this.#events.events].filter(x => x),
51
47
  type: 'standard',
52
48
  cycleTimestamp: Math.min(this.#backloggedEvents.cycleTimestamp, this.#events.cycleTimestamp),
53
49
  payloadBytesEstimation: this.#backloggedEvents.payloadBytesEstimation + this.#events.payloadBytesEstimation,
@@ -58,30 +54,22 @@ export class Recorder {
58
54
  }
59
55
  }
60
56
 
61
- /**
62
- * Returns time-corrected events. If the events were correctable from the beginning, this correction will have already been applied.
63
- * @param {SessionReplayEvent[]} events The array of buffered SR nodes
64
- * @returns {CorrectedSessionReplayEvent[]}
65
- */
66
- returnCorrectTimestamps (events) {
67
- if (!this.parent.timeKeeper?.ready) return events.events
68
- return events.canCorrectTimestamps
69
- ? events.events
70
- : events.events.map(({ __serialized, timestamp, ...e }) => ({ timestamp: this.parent.timeKeeper.correctAbsoluteTimestamp(timestamp), ...e }))
71
- }
72
-
73
57
  /** Clears the buffer (this.#events), and resets all payload metadata properties */
74
58
  clearBuffer () {
75
59
  if (this.#preloaded[0]?.events.length) this.#preloaded.shift()
76
60
  else if (this.parent.mode === MODE.ERROR) this.#backloggedEvents = this.#events
77
- else this.#backloggedEvents = new RecorderEvents({ canCorrectTimestamps: !!this.parent.timeKeeper?.ready })
78
- this.#events = new RecorderEvents({ canCorrectTimestamps: !!this.parent.timeKeeper?.ready })
61
+ else this.#backloggedEvents = new RecorderEvents()
62
+ this.#events = new RecorderEvents()
79
63
  }
80
64
 
81
65
  /** Begin recording using configured recording lib */
82
66
  startRecording () {
83
67
  this.recording = true
84
68
  const { block_class, ignore_class, mask_text_class, block_selector, mask_input_options, mask_text_selector, mask_all_inputs, inline_stylesheet, inline_images, collect_fonts } = getConfigurationValue(this.parent.agentIdentifier, 'session_replay')
69
+ const customMasker = (text, element) => {
70
+ if (element?.type?.toLowerCase() !== 'password' && (element?.dataset.nrUnmask !== undefined || element?.classList.contains('nr-unmask'))) return text
71
+ return '*'.repeat(text.length)
72
+ }
85
73
  // set up rrweb configurations for maximum privacy --
86
74
  // https://newrelic.atlassian.net/wiki/spaces/O11Y/pages/2792293280/2023+02+28+Browser+-+Session+Replay#Configuration-options
87
75
  const stop = recorder({
@@ -92,15 +80,20 @@ export class Recorder {
92
80
  blockSelector: block_selector,
93
81
  maskInputOptions: mask_input_options,
94
82
  maskTextSelector: mask_text_selector,
83
+ maskTextFn: customMasker,
95
84
  maskAllInputs: mask_all_inputs,
85
+ maskInputFn: customMasker,
96
86
  inlineStylesheet: inline_stylesheet,
97
87
  inlineImages: inline_images,
98
88
  collectFonts: collect_fonts,
99
89
  checkoutEveryNms: CHECKOUT_MS[this.parent.mode]
100
90
  })
101
91
 
92
+ this.parent.ee.emit(SR_EVENT_EMITTER_TYPES.REPLAY_RUNNING, [true, this.parent.mode])
93
+
102
94
  this.stopRecording = () => {
103
95
  this.recording = false
96
+ this.parent.ee.emit(SR_EVENT_EMITTER_TYPES.REPLAY_RUNNING, [false, this.parent.mode])
104
97
  stop()
105
98
  }
106
99
  }
@@ -148,7 +141,8 @@ export class Recorder {
148
141
 
149
142
  if (this.parent.blocked) return
150
143
 
151
- if (this.currentBufferTarget.canCorrectTimestamps) {
144
+ if (this.parent.timeKeeper?.ready && !event.__newrelic) {
145
+ event.__newrelic = buildNRMetaNode(event.timestamp, this.parent.timeKeeper)
152
146
  event.timestamp = this.parent.timeKeeper.correctAbsoluteTimestamp(event.timestamp)
153
147
  }
154
148
  event.__serialized = stringify(event)
@@ -184,7 +178,7 @@ export class Recorder {
184
178
  this.parent.scheduler.runHarvest()
185
179
  } else {
186
180
  // we are still in "preload" and it triggered a "stop point". Make a new set, which will get pointed at on next cycle
187
- this.#preloaded.push(new RecorderEvents({ canCorrectTimestamps: !!this.parent.timeKeeper?.ready }))
181
+ this.#preloaded.push(new RecorderEvents())
188
182
  }
189
183
  }
190
184
  }
@@ -17,3 +17,15 @@ export function canImportReplayAgg (agentId, sessionMgr) {
17
17
  if (!hasReplayPrerequisite(agentId)) return false
18
18
  return !!sessionMgr?.isNew || !!sessionMgr?.state.sessionReplayMode // Session Replay should only try to run if already running from a previous page, or at the beginning of a session
19
19
  }
20
+
21
+ export function buildNRMetaNode (timestamp, timeKeeper) {
22
+ const correctedTimestamp = timeKeeper.correctAbsoluteTimestamp(timestamp)
23
+ return {
24
+ originalTimestamp: timestamp,
25
+ correctedTimestamp,
26
+ timestampDiff: timestamp - correctedTimestamp,
27
+ timeKeeperOriginTime: timeKeeper.originTime,
28
+ timeKeeperCorrectedOriginTime: timeKeeper.correctedOriginTime,
29
+ timeKeeperDiff: Math.floor(timeKeeper.originTime - timeKeeper.correctedOriginTime)
30
+ }
31
+ }
@@ -43,10 +43,11 @@ export class Aggregate extends AggregateBase {
43
43
  if (!this.agentRuntime.xhrWrappable) return
44
44
 
45
45
  this.resourceObserver = argsObj?.resourceObserver // undefined if observer couldn't be created
46
- this.ptid = ''
46
+ this.ptid = this.agentRuntime.ptid
47
47
  this.trace = {}
48
48
  this.nodeCount = 0
49
49
  this.sentTrace = null
50
+ this.everSent = false
50
51
  this.harvestTimeSeconds = getConfigurationValue(agentIdentifier, 'session_trace.harvestTimeSeconds') || 10
51
52
  this.maxNodesPerHarvest = getConfigurationValue(agentIdentifier, 'session_trace.maxNodesPerHarvest') || 1000
52
53
  /**
@@ -99,7 +100,7 @@ export class Aggregate extends AggregateBase {
99
100
 
100
101
  if (prevMode === MODE.ERROR && this.#scheduler) {
101
102
  this.trimSTNs(ERROR_MODE_SECONDS_WINDOW) // up until now, Trace would've been just buffering nodes up to max, which needs to be trimmed to last X seconds
102
- this.#scheduler.runHarvest({ needResponse: true })
103
+ this.#scheduler.runHarvest({})
103
104
  } else {
104
105
  controlTraceOp(MODE.FULL)
105
106
  }
@@ -119,7 +120,7 @@ export class Aggregate extends AggregateBase {
119
120
  const stopTracePerm = () => {
120
121
  if (sessionEntity.state.sessionTraceMode !== MODE.OFF) sessionEntity.write({ sessionTraceMode: MODE.OFF })
121
122
  operationalGate.permanentlyDecide(false)
122
- if (mostRecentModeKnown === MODE.FULL) this.#scheduler?.runHarvest() // allow queued nodes (past opGate) to final harvest, unless they were buffered in other modes
123
+ if (mostRecentModeKnown === MODE.FULL) this.#scheduler?.runHarvest({}) // allow queued nodes (past opGate) to final harvest, unless they were buffered in other modes
123
124
  this.#scheduler?.stopTimer(true) // the 'true' arg here will forcibly block any future call to runHarvest, so the last runHarvest above must be prior
124
125
  this.#scheduler = null
125
126
  }
@@ -138,7 +139,7 @@ export class Aggregate extends AggregateBase {
138
139
  this.ee.on(SESSION_EVENTS.RESUME, () => {
139
140
  const updatedTraceMode = sessionEntity.state.sessionTraceMode
140
141
  if (updatedTraceMode === MODE.OFF) stopTracePerm()
141
- else if (updatedTraceMode === MODE.FULL && this.#scheduler && !this.#scheduler.started) this.#scheduler.runHarvest({ needResponse: true })
142
+ else if (updatedTraceMode === MODE.FULL && this.#scheduler && !this.#scheduler.started) this.#scheduler.runHarvest({})
142
143
  mostRecentModeKnown = updatedTraceMode
143
144
  })
144
145
  this.ee.on(SESSION_EVENTS.PAUSE, () => { mostRecentModeKnown = sessionEntity.state.sessionTraceMode })
@@ -189,13 +190,12 @@ export class Aggregate extends AggregateBase {
189
190
  retryDelay: this.harvestTimeSeconds
190
191
  }, this)
191
192
  this.#scheduler.harvest.on('resources', this.#prepareHarvest.bind(this))
192
- if (dontStartHarvestYet === false) this.#scheduler.runHarvest({ needResponse: true }) // sends first stn harvest immediately
193
+ if (dontStartHarvestYet === false) this.#scheduler.runHarvest({}) // sends first stn harvest immediately
193
194
  startupBuffer.decide(true) // signal to ALLOW & process data in EE's buffer into internal nodes queued for next harvest
194
195
  }
195
196
 
196
197
  #onHarvestFinished (result) {
197
- if (result.sent && result.responseText && !this.ptid) { // continue interval harvest only if ptid was returned by server on the first
198
- this.agentRuntime.ptid = this.ptid = result.responseText
198
+ if (result.sent && !result.failed && !this.#scheduler.started) { // continue interval harvest only after first call
199
199
  this.#scheduler.startTimer(this.harvestTimeSeconds)
200
200
  }
201
201
 
@@ -212,15 +212,18 @@ export class Aggregate extends AggregateBase {
212
212
 
213
213
  #prepareHarvest (options) {
214
214
  if (this.isStandalone) {
215
- if (this.ptid && now() >= MAX_TRACE_DURATION) {
216
- // Perform a final harvest once we hit or exceed the max session trace time
217
- options.isFinalHarvest = true
218
- this.operationalGate.permanentlyDecide(false)
219
- this.#scheduler.stopTimer(true)
220
- } else if (this.ptid && this.nodeCount <= REQ_THRESHOLD_TO_SEND && !options.isFinalHarvest) {
221
- // Only harvest when more than some threshold of nodes are pending, after the very first harvest, with the exception of the last outgoing harvest.
222
- return
215
+ if (this.#scheduler.started) {
216
+ if (now() >= MAX_TRACE_DURATION) {
217
+ // Perform a final harvest once we hit or exceed the max session trace time
218
+ options.isFinalHarvest = true
219
+ this.operationalGate.permanentlyDecide(false)
220
+ this.#scheduler.stopTimer(true)
221
+ } else if (this.nodeCount <= REQ_THRESHOLD_TO_SEND && !options.isFinalHarvest) {
222
+ // Only harvest when more than some threshold of nodes are pending, after the very first harvest, with the exception of the last outgoing harvest.
223
+ return
224
+ }
223
225
  }
226
+ // else, we must be on the very first harvest (standalone mode), so go to next square
224
227
  } else {
225
228
  // -- *cli May '26 - Update: Not rate limiting backgrounded pages either for now.
226
229
  // if (this.ptid && document.visibilityState === 'hidden' && this.nodeCount <= REQ_THRESHOLD_TO_SEND) return
@@ -231,7 +234,6 @@ export class Aggregate extends AggregateBase {
231
234
  if (currentMode === MODE.OFF && Object.keys(this.trace).length === 0) return
232
235
  if (currentMode === MODE.ERROR) return // Trace in this mode should never be harvesting, even on unload
233
236
  }
234
-
235
237
  return this.takeSTNs(options.retry)
236
238
  }
237
239
 
@@ -15,6 +15,7 @@ import { gosCDN } from '../../common/window/nreum'
15
15
  import { apiMethods, asyncApiMethods } from './api-methods'
16
16
  import { SR_EVENT_EMITTER_TYPES } from '../../features/session_replay/constants'
17
17
  import { now } from '../../common/timing/now'
18
+ import { MODE } from '../../common/session/constants'
18
19
 
19
20
  export function setTopLevelCallers () {
20
21
  const nr = gosCDN()
@@ -33,12 +34,20 @@ export function setTopLevelCallers () {
33
34
  }
34
35
  }
35
36
 
37
+ const replayRunning = {}
38
+
36
39
  export function setAPI (agentIdentifier, forceDrain, runSoftNavOverSpa = false) {
37
40
  if (!forceDrain) registerDrain(agentIdentifier, 'api')
38
41
  const apiInterface = {}
39
42
  var instanceEE = ee.get(agentIdentifier)
40
43
  var tracerEE = instanceEE.get('tracer')
41
44
 
45
+ replayRunning[agentIdentifier] = MODE.OFF
46
+
47
+ instanceEE.on(SR_EVENT_EMITTER_TYPES.REPLAY_RUNNING, (isRunning) => {
48
+ replayRunning[agentIdentifier] = isRunning
49
+ })
50
+
42
51
  var prefix = 'api-'
43
52
  var spaPrefix = prefix + 'ixn-'
44
53
 
@@ -184,7 +193,7 @@ export function setAPI (agentIdentifier, forceDrain, runSoftNavOverSpa = false)
184
193
  apiInterface.noticeError = function (err, customAttributes) {
185
194
  if (typeof err === 'string') err = new Error(err)
186
195
  handle(SUPPORTABILITY_METRIC_CHANNEL, ['API/noticeError/called'], undefined, FEATURE_NAMES.metrics, instanceEE)
187
- handle('err', [err, now(), false, customAttributes], undefined, FEATURE_NAMES.jserrors, instanceEE)
196
+ handle('err', [err, now(), false, customAttributes, !!replayRunning[agentIdentifier]], undefined, FEATURE_NAMES.jserrors, instanceEE)
188
197
  }
189
198
 
190
199
  // theres no window.load event on non-browser scopes, lazy load immediately
@@ -52,6 +52,7 @@ export function configure (agent, opts = {}, loaderType, forceDrain) {
52
52
  ...(updatedInit.ajax.deny_list || []),
53
53
  ...(updatedInit.ajax.block_internal ? internalTrafficList : [])
54
54
  ]
55
+ runtime.ptid = agent.agentIdentifier
55
56
  setRuntime(agent.agentIdentifier, runtime)
56
57
 
57
58
  if (agent.api === undefined) agent.api = setAPI(agent.agentIdentifier, forceDrain, agent.runSoftNavOverSpa)