@newrelic/browser-agent 1.236.0 → 1.237.1

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 (118) hide show
  1. package/dist/cjs/common/config/state/init.js +1 -0
  2. package/dist/cjs/common/config/state/runtime.js +2 -1
  3. package/dist/cjs/common/constants/env.cdn.js +1 -1
  4. package/dist/cjs/common/constants/env.npm.js +1 -1
  5. package/dist/cjs/common/deny-list/deny-list.js +14 -10
  6. package/dist/cjs/common/harvest/harvest.js +8 -22
  7. package/dist/cjs/common/util/submit-data.js +4 -36
  8. package/dist/cjs/common/wrap/wrap-jsonp.js +12 -6
  9. package/dist/cjs/features/ajax/aggregate/index.js +24 -27
  10. package/dist/cjs/features/jserrors/aggregate/compute-stack-trace.js +1 -1
  11. package/dist/cjs/features/jserrors/constants.js +2 -4
  12. package/dist/cjs/features/jserrors/instrument/index.js +79 -88
  13. package/dist/cjs/features/jserrors/instrument/uncaught-error.js +22 -0
  14. package/dist/cjs/features/metrics/aggregate/index.js +8 -0
  15. package/dist/cjs/features/page_view_event/aggregate/initialized-features.js +23 -19
  16. package/dist/cjs/features/session_replay/aggregate/index.js +65 -34
  17. package/dist/cjs/features/session_trace/aggregate/index.js +3 -4
  18. package/dist/cjs/features/spa/aggregate/index.js +1 -1
  19. package/dist/cjs/features/utils/instrument-base.js +6 -8
  20. package/dist/cjs/loaders/agent-base.js +87 -0
  21. package/dist/cjs/loaders/agent.js +41 -1
  22. package/dist/cjs/loaders/api/api.js +1 -1
  23. package/dist/cjs/loaders/api/interaction-types.js +87 -0
  24. package/dist/cjs/loaders/configure/configure.js +3 -1
  25. package/dist/cjs/loaders/micro-agent.js +3 -1
  26. package/dist/esm/common/config/state/init.js +1 -0
  27. package/dist/esm/common/config/state/runtime.js +2 -1
  28. package/dist/esm/common/constants/env.cdn.js +1 -1
  29. package/dist/esm/common/constants/env.npm.js +1 -1
  30. package/dist/esm/common/deny-list/deny-list.js +14 -10
  31. package/dist/esm/common/harvest/harvest.js +7 -22
  32. package/dist/esm/common/util/submit-data.js +4 -35
  33. package/dist/esm/common/wrap/wrap-jsonp.js +12 -6
  34. package/dist/esm/features/ajax/aggregate/index.js +25 -28
  35. package/dist/esm/features/jserrors/aggregate/compute-stack-trace.js +1 -1
  36. package/dist/esm/features/jserrors/constants.js +1 -2
  37. package/dist/esm/features/jserrors/instrument/index.js +78 -87
  38. package/dist/esm/features/jserrors/instrument/uncaught-error.js +15 -0
  39. package/dist/esm/features/metrics/aggregate/index.js +8 -0
  40. package/dist/esm/features/page_view_event/aggregate/initialized-features.js +23 -19
  41. package/dist/esm/features/session_replay/aggregate/index.js +65 -34
  42. package/dist/esm/features/session_trace/aggregate/index.js +3 -4
  43. package/dist/esm/features/spa/aggregate/index.js +1 -1
  44. package/dist/esm/features/utils/instrument-base.js +7 -9
  45. package/dist/esm/loaders/agent-base.js +80 -0
  46. package/dist/esm/loaders/agent.js +41 -1
  47. package/dist/esm/loaders/api/api.js +1 -1
  48. package/dist/esm/loaders/api/interaction-types.js +80 -0
  49. package/dist/esm/loaders/configure/configure.js +5 -3
  50. package/dist/esm/loaders/micro-agent.js +3 -1
  51. package/dist/types/common/config/state/runtime.d.ts.map +1 -1
  52. package/dist/types/common/event-emitter/register-handler.d.ts +1 -1
  53. package/dist/types/common/harvest/harvest.d.ts.map +1 -1
  54. package/dist/types/common/session/session-entity.d.ts +6 -6
  55. package/dist/types/common/util/submit-data.d.ts +2 -20
  56. package/dist/types/common/util/submit-data.d.ts.map +1 -1
  57. package/dist/types/common/window/nreum.d.ts +2 -2
  58. package/dist/types/common/wrap/wrap-jsonp.d.ts.map +1 -1
  59. package/dist/types/features/ajax/aggregate/index.d.ts +5 -5
  60. package/dist/types/features/ajax/aggregate/index.d.ts.map +1 -1
  61. package/dist/types/features/jserrors/constants.d.ts +0 -1
  62. package/dist/types/features/jserrors/constants.d.ts.map +1 -1
  63. package/dist/types/features/jserrors/instrument/index.d.ts +0 -13
  64. package/dist/types/features/jserrors/instrument/index.d.ts.map +1 -1
  65. package/dist/types/features/jserrors/instrument/uncaught-error.d.ts +15 -0
  66. package/dist/types/features/jserrors/instrument/uncaught-error.d.ts.map +1 -0
  67. package/dist/types/features/metrics/aggregate/endpoint-map.d.ts +5 -5
  68. package/dist/types/features/metrics/aggregate/index.d.ts.map +1 -1
  69. package/dist/types/features/page_view_event/aggregate/initialized-features.d.ts.map +1 -1
  70. package/dist/types/features/session_replay/aggregate/index.d.ts +16 -30
  71. package/dist/types/features/session_replay/aggregate/index.d.ts.map +1 -1
  72. package/dist/types/features/session_trace/aggregate/index.d.ts.map +1 -1
  73. package/dist/types/features/utils/instrument-base.d.ts.map +1 -1
  74. package/dist/types/loaders/agent-base.d.ts +59 -0
  75. package/dist/types/loaders/agent-base.d.ts.map +1 -0
  76. package/dist/types/loaders/agent.d.ts +35 -1
  77. package/dist/types/loaders/agent.d.ts.map +1 -1
  78. package/dist/types/loaders/api/interaction-types.d.ts +122 -0
  79. package/dist/types/loaders/api/interaction-types.d.ts.map +1 -0
  80. package/dist/types/loaders/configure/configure.d.ts.map +1 -1
  81. package/dist/types/loaders/features/features.d.ts +9 -9
  82. package/dist/types/loaders/micro-agent.d.ts +3 -2
  83. package/dist/types/loaders/micro-agent.d.ts.map +1 -1
  84. package/package.json +1 -1
  85. package/src/common/config/state/init.js +1 -1
  86. package/src/common/config/state/runtime.js +2 -1
  87. package/src/common/deny-list/deny-list.js +11 -11
  88. package/src/common/deny-list/deny-list.test.js +31 -0
  89. package/src/common/harvest/harvest.js +8 -18
  90. package/src/common/harvest/harvest.test.js +16 -36
  91. package/src/common/util/__mocks__/submit-data.js +0 -1
  92. package/src/common/util/submit-data.js +2 -24
  93. package/src/common/util/submit-data.test.js +0 -56
  94. package/src/common/wrap/wrap-jsonp.js +11 -6
  95. package/src/features/ajax/aggregate/index.js +25 -31
  96. package/src/features/jserrors/aggregate/compute-stack-trace.js +1 -1
  97. package/src/features/jserrors/constants.js +0 -1
  98. package/src/features/jserrors/instrument/index.js +91 -87
  99. package/src/features/jserrors/instrument/uncaught-error.js +15 -0
  100. package/src/features/metrics/aggregate/index.js +8 -0
  101. package/src/features/page_view_event/aggregate/initialized-features.js +18 -14
  102. package/src/features/session_replay/aggregate/index.component-test.js +17 -56
  103. package/src/features/session_replay/aggregate/index.js +47 -28
  104. package/src/features/session_trace/aggregate/index.js +3 -4
  105. package/src/features/spa/aggregate/index.js +1 -1
  106. package/src/features/utils/instrument-base.js +6 -9
  107. package/src/features/utils/instrument-base.test.js +7 -0
  108. package/src/loaders/agent-base.js +81 -0
  109. package/src/loaders/agent.js +42 -1
  110. package/src/loaders/api/api.js +1 -1
  111. package/src/loaders/api/interaction-types.js +80 -0
  112. package/src/loaders/configure/configure.js +14 -4
  113. package/src/loaders/micro-agent.js +4 -1
  114. package/dist/cjs/features/jserrors/instrument/debug.js +0 -40
  115. package/dist/esm/features/jserrors/instrument/debug.js +0 -38
  116. package/dist/types/features/jserrors/instrument/debug.d.ts +0 -2
  117. package/dist/types/features/jserrors/instrument/debug.d.ts.map +0 -1
  118. package/src/features/jserrors/instrument/debug.js +0 -36
@@ -5,64 +5,60 @@
5
5
 
6
6
  import { handle } from '../../../common/event-emitter/handle'
7
7
  import { now } from '../../../common/timing/now'
8
- import { getOrSet } from '../../../common/util/get-or-set'
9
- import { wrapRaf, wrapTimer, wrapEvents, wrapXhr } from '../../../common/wrap'
10
- import './debug'
11
8
  import { InstrumentBase } from '../../utils/instrument-base'
12
- import { FEATURE_NAME, NR_ERR_PROP } from '../constants'
9
+ import { FEATURE_NAME } from '../constants'
13
10
  import { FEATURE_NAMES } from '../../../loaders/features/features'
14
11
  import { globalScope } from '../../../common/constants/runtime'
15
12
  import { eventListenerOpts } from '../../../common/event-listener/event-listener-opts'
16
- import { getRuntime } from '../../../common/config/config'
17
13
  import { stringify } from '../../../common/util/stringify'
14
+ import { UncaughtError } from './uncaught-error'
18
15
 
19
16
  export class Instrument extends InstrumentBase {
20
17
  static featureName = FEATURE_NAME
18
+
19
+ #seenErrors = new Set()
20
+
21
21
  constructor (agentIdentifier, aggregator, auto = true) {
22
22
  super(agentIdentifier, aggregator, FEATURE_NAME, auto)
23
- // skipNext counter to keep track of uncaught
24
- // errors that will be the same as caught errors.
25
- this.skipNext = 0
23
+
26
24
  try {
27
25
  // this try-catch can be removed when IE11 is completely unsupported & gone
28
26
  this.removeOnAbort = new AbortController()
29
27
  } catch (e) {}
30
28
 
31
- const thisInstrument = this
32
- thisInstrument.ee.on('fn-start', function (args, obj, methodName) {
33
- if (thisInstrument.abortHandler) thisInstrument.skipNext += 1
34
- })
35
- thisInstrument.ee.on('fn-err', function (args, obj, err) {
36
- if (thisInstrument.abortHandler && !err[NR_ERR_PROP]) {
37
- getOrSet(err, NR_ERR_PROP, function getVal () {
38
- return true
39
- })
40
- this.thrown = true
41
- handle('err', [err, now()], undefined, FEATURE_NAMES.jserrors, thisInstrument.ee)
42
- }
43
- })
44
- thisInstrument.ee.on('fn-end', function () {
45
- if (!thisInstrument.abortHandler) return
46
- if (!this.thrown && thisInstrument.skipNext > 0) thisInstrument.skipNext -= 1
29
+ // Capture function errors early in case the spa feature is loaded
30
+ this.ee.on('fn-err', (args, obj, error) => {
31
+ if (!this.abortHandler || this.#seenErrors.has(error)) return
32
+ this.#seenErrors.add(error)
33
+
34
+ handle('err', [this.#castError(error), now()], undefined, FEATURE_NAMES.jserrors, this.ee)
47
35
  })
48
- thisInstrument.ee.on('internal-error', function (e) {
49
- handle('ierr', [e, now(), true], undefined, FEATURE_NAMES.jserrors, thisInstrument.ee)
36
+
37
+ this.ee.on('internal-error', (error) => {
38
+ if (!this.abortHandler) return
39
+ handle('ierr', [this.#castError(error), now(), true], undefined, FEATURE_NAMES.jserrors, this.ee)
50
40
  })
51
41
 
52
- // Replace global error handler with our own.
53
- this.origOnerror = globalScope.onerror
54
- globalScope.onerror = this.onerrorHandler.bind(this)
42
+ globalScope.addEventListener('unhandledrejection', (promiseRejectionEvent) => {
43
+ if (!this.abortHandler) return
55
44
 
56
- globalScope.addEventListener('unhandledrejection', (e) => {
57
- /** rejections can contain data of any type -- this is an effort to keep the message human readable */
58
- const err = castReasonToError(e.reason)
59
- handle('err', [err, now(), false, { unhandledPromiseRejection: 1 }], undefined, FEATURE_NAMES.jserrors, this.ee)
45
+ handle('err', [this.#castPromiseRejectionEvent(promiseRejectionEvent), now(), false, { unhandledPromiseRejection: 1 }], undefined, FEATURE_NAMES.jserrors, this.ee)
60
46
  }, eventListenerOpts(false, this.removeOnAbort?.signal))
61
47
 
62
- wrapRaf(this.ee)
63
- wrapTimer(this.ee)
64
- wrapEvents(this.ee)
65
- if (getRuntime(agentIdentifier).xhrWrappable) wrapXhr(this.ee)
48
+ globalScope.addEventListener('error', (errorEvent) => {
49
+ if (!this.abortHandler) return
50
+
51
+ /**
52
+ * If the spa feature is loaded, errors may already have been captured in the `fn-err` listener above.
53
+ * This ensures those errors are not captured twice.
54
+ */
55
+ if (this.#seenErrors.has(errorEvent.error)) {
56
+ this.#seenErrors.delete(errorEvent.error)
57
+ return
58
+ }
59
+
60
+ handle('err', [this.#castErrorEvent(errorEvent), now()], undefined, FEATURE_NAMES.jserrors, this.ee)
61
+ }, eventListenerOpts(false, this.removeOnAbort?.signal))
66
62
 
67
63
  this.abortHandler = this.#abort // we also use this as a flag to denote that the feature is active or on and handling errors
68
64
  this.importAggregator()
@@ -71,67 +67,75 @@ export class Instrument extends InstrumentBase {
71
67
  /** Restoration and resource release tasks to be done if JS error loader is being aborted. Unwind changes to globals. */
72
68
  #abort () {
73
69
  this.removeOnAbort?.abort()
70
+ this.#seenErrors.clear()
74
71
  this.abortHandler = undefined // weakly allow this abort op to run only once
75
72
  }
76
73
 
77
74
  /**
78
- * FF and Android browsers do not provide error info to the 'error' event callback,
79
- * so we must use window.onerror
80
- * @param {string} message
81
- * @param {string} filename
82
- * @param {number} lineno
83
- * @param {number} column
84
- * @param {Error | *} errorObj
85
- * @returns
75
+ * Any value can be used with the `throw` keyword. This function ensures that the value is
76
+ * either a proper Error instance or attempts to convert it to an UncaughtError instance.
77
+ * @param {any} error The value thrown
78
+ * @returns {Error|UncaughtError} The converted error instance
86
79
  */
87
- onerrorHandler (message, filename, lineno, column, errorObj) {
88
- if (typeof this.origOnerror === 'function') this.origOnerror(...arguments)
80
+ #castError (error) {
81
+ if (error instanceof Error) {
82
+ return error
83
+ }
89
84
 
90
- try {
91
- if (this.skipNext) this.skipNext -= 1
92
- else handle('err', [errorObj || new UncaughtException(message, filename, lineno), now()], undefined, FEATURE_NAMES.jserrors, this.ee)
93
- } catch (e) {
94
- try {
95
- handle('ierr', [e, now(), true], undefined, FEATURE_NAMES.jserrors, this.ee)
96
- } catch (err) {
97
- // do nothing
98
- }
85
+ /**
86
+ * The thrown value may contain a message property. If it does, try to treat the thrown
87
+ * value as an Error-like object.
88
+ */
89
+ if (typeof error?.message !== 'undefined') {
90
+ return new UncaughtError(
91
+ error.message,
92
+ error.filename || error.sourceURL,
93
+ error.lineno || error.line,
94
+ error.colno || error.col
95
+ )
99
96
  }
100
- return false // maintain default behavior of the error event of Window
97
+
98
+ return new UncaughtError(typeof error === 'string' ? error : stringify(error))
101
99
  }
102
- }
103
100
 
104
- /**
105
- *
106
- * @param {string} message
107
- * @param {string} filename
108
- * @param {number} lineno
109
- */
110
- function UncaughtException (message, filename, lineno) {
111
- this.message = message || 'Uncaught error with no additional information'
112
- this.sourceURL = filename
113
- this.line = lineno
114
- }
101
+ /**
102
+ * Attempts to convert a PromiseRejectionEvent object to an Error object
103
+ * @param {PromiseRejectionEvent} unhandledRejectionEvent The unhandled promise rejection event
104
+ * @returns {Error} An Error object with the message as the casted reason
105
+ */
106
+ #castPromiseRejectionEvent (promiseRejectionEvent) {
107
+ let prefix = 'Unhandled Promise Rejection: '
115
108
 
116
- /**
117
- * Attempts to cast an unhandledPromiseRejection reason (reject(...)) to an Error object
118
- * @param {*} reason - The reason property from an unhandled promise rejection
119
- * @returns {Error} - An Error object with the message as the casted reason
120
- */
121
- function castReasonToError (reason) {
122
- let prefix = 'Unhandled Promise Rejection: '
123
- if (reason instanceof Error) {
124
- try {
125
- reason.message = prefix + reason.message
126
- return reason
127
- } catch (e) {
128
- return reason
109
+ if (promiseRejectionEvent?.reason instanceof Error) {
110
+ try {
111
+ promiseRejectionEvent.reason.message = prefix + promiseRejectionEvent.reason.message
112
+ return promiseRejectionEvent.reason
113
+ } catch (e) {
114
+ return promiseRejectionEvent.reason
115
+ }
129
116
  }
117
+
118
+ if (typeof promiseRejectionEvent.reason === 'undefined') return new UncaughtError(prefix)
119
+
120
+ const error = this.#castError(promiseRejectionEvent.reason)
121
+ error.message = prefix + error.message
122
+ return error
130
123
  }
131
- if (typeof reason === 'undefined') return new Error(prefix)
132
- try {
133
- return new Error(prefix + stringify(reason))
134
- } catch (err) {
135
- return new Error(prefix)
124
+
125
+ /**
126
+ * Attempts to convert an ErrorEvent object to an Error object
127
+ * @param {ErrorEvent} errorEvent The error event
128
+ * @returns {Error|UncaughtError} The error event converted to an Error object
129
+ */
130
+ #castErrorEvent (errorEvent) {
131
+ if (errorEvent.error instanceof Error) {
132
+ return errorEvent.error
133
+ }
134
+
135
+ /**
136
+ * Older browsers do not contain the `error` property on the ErrorEvent instance.
137
+ * https://caniuse.com/mdn-api_errorevent_error
138
+ */
139
+ return new UncaughtError(errorEvent.message, errorEvent.filename, errorEvent.lineno, errorEvent.colno)
136
140
  }
137
141
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Represents an uncaught non Error type error. This class does
3
+ * not extend the Error class to prevent an invalid stack trace
4
+ * from being created. Use this class to cast thrown errors that
5
+ * do not use the Error class (strings, etc) to an object.
6
+ */
7
+ export class UncaughtError {
8
+ constructor (message, filename, lineno, colno) {
9
+ this.name = 'UncaughtError'
10
+ this.message = message
11
+ this.sourceURL = filename
12
+ this.line = lineno
13
+ this.column = colno
14
+ }
15
+ }
@@ -147,6 +147,14 @@ export class Aggregate extends AggregateBase {
147
147
  // Capture metrics for size of custom attributes
148
148
  const jsAttributes = stringify(info.jsAttributes)
149
149
  this.storeSupportabilityMetrics('PageSession/Feature/CustomData/Bytes', jsAttributes === '{}' ? 0 : jsAttributes.length)
150
+
151
+ // Capture metrics for performance markers and measures
152
+ if (typeof performance !== 'undefined') {
153
+ const markers = performance.getEntriesByType('mark')
154
+ const measures = performance.getEntriesByType('measure')
155
+ this.storeSupportabilityMetrics('Generic/Performance/Mark/Seen', markers.length)
156
+ this.storeSupportabilityMetrics('Generic/Performance/Measure/Seen', measures.length)
157
+ }
150
158
  } catch (e) {
151
159
  // do nothing
152
160
  }
@@ -1,4 +1,5 @@
1
1
  import { FEATURE_NAMES } from '../../../loaders/features/features'
2
+ import { gosNREUM } from '../../../common/window/nreum'
2
3
 
3
4
  /**
4
5
  * Get an array of flags required by downstream (NR UI) based on the features initialized in this agent
@@ -8,21 +9,24 @@ import { FEATURE_NAMES } from '../../../loaders/features/features'
8
9
  */
9
10
  export function getActivatedFeaturesFlags (agentId) {
10
11
  const flagArr = []
12
+ const newrelic = gosNREUM()
11
13
 
12
- Object.keys(newrelic.initializedAgents[agentId].features).forEach(featName => {
13
- switch (featName) {
14
- case FEATURE_NAMES.ajax:
15
- flagArr.push('xhr'); break
16
- case FEATURE_NAMES.jserrors:
17
- flagArr.push('err'); break
18
- case FEATURE_NAMES.pageAction:
19
- flagArr.push('ins'); break
20
- case FEATURE_NAMES.sessionTrace:
21
- flagArr.push('stn'); break
22
- case FEATURE_NAMES.spa:
23
- flagArr.push('spa'); break
24
- }
25
- })
14
+ try {
15
+ Object.keys(newrelic.initializedAgents[agentId].features).forEach(featName => {
16
+ switch (featName) {
17
+ case FEATURE_NAMES.ajax:
18
+ flagArr.push('xhr'); break
19
+ case FEATURE_NAMES.jserrors:
20
+ flagArr.push('err'); break
21
+ case FEATURE_NAMES.pageAction:
22
+ flagArr.push('ins'); break
23
+ case FEATURE_NAMES.sessionTrace:
24
+ flagArr.push('stn'); break
25
+ case FEATURE_NAMES.spa:
26
+ flagArr.push('spa'); break
27
+ }
28
+ })
29
+ } catch (e) {}
26
30
 
27
31
  return flagArr
28
32
  }
@@ -40,6 +40,14 @@ const agentIdentifier = 'abcd'
40
40
  const info = { licenseKey: 1234, applicationID: 9876 }
41
41
  const init = { session_replay: { enabled: true, sampleRate: 1, errorSampleRate: 0 } }
42
42
 
43
+ const anyQuery = {
44
+ browser_monitoring_key: info.licenseKey,
45
+ type: 'SessionReplay',
46
+ app_id: Number(info.applicationID),
47
+ protocol_version: '0',
48
+ attributes: expect.any(String)
49
+ }
50
+
43
51
  describe('Session Replay', () => {
44
52
  beforeEach(async () => {
45
53
  primeSessionAndReplay()
@@ -221,28 +229,11 @@ describe('Session Replay', () => {
221
229
  await wait(1)
222
230
  const harvestContents = sr.getHarvestContents()
223
231
  // query attrs
224
- expect(harvestContents.qs).toMatchObject({
225
- protocol_version: '0',
226
- content_encoding: 'gzip',
227
- browser_monitoring_key: info.licenseKey
228
- })
232
+ expect(harvestContents.qs).toMatchObject(anyQuery)
229
233
 
230
- expect(harvestContents.body).toMatchObject({
231
- type: 'SessionReplay',
232
- appId: info.applicationID,
233
- timestamp: expect.any(Number),
234
- blob: expect.any(String),
235
- attributes: {
236
- session: session.state.value,
237
- hasSnapshot: expect.any(Boolean),
238
- hasError: expect.any(Boolean),
239
- agentVersion: expect.any(String),
240
- isFirstChunk: expect.any(Boolean),
241
- 'nr.rrweb.version': expect.any(String)
242
- }
243
- })
234
+ expect(harvestContents.body).toEqual(expect.any(Array))
244
235
 
245
- expect(JSON.parse(harvestContents.body.blob).length).toBeGreaterThan(0)
236
+ expect(harvestContents.body.length).toBeGreaterThan(0)
246
237
  })
247
238
  })
248
239
 
@@ -253,26 +244,10 @@ describe('Session Replay', () => {
253
244
  sr.ee.emit('rumresp-sr', [true])
254
245
  await wait(1)
255
246
  const [harvestContents] = sr.prepareHarvest()
256
- expect(harvestContents.qs).toMatchObject({
257
- protocol_version: '0',
258
- content_encoding: 'gzip',
259
- browser_monitoring_key: info.licenseKey
260
- })
247
+ expect(harvestContents.qs).toMatchObject(anyQuery)
248
+ expect(harvestContents.qs.attributes.includes('content_encoding=gzip')).toEqual(true)
261
249
  expect(harvestContents.body).toEqual(expect.any(Uint8Array))
262
- expect(JSON.parse(strFromU8(gunzipSync(harvestContents.body)))).toMatchObject({
263
- type: 'SessionReplay',
264
- appId: info.applicationID,
265
- timestamp: expect.any(Number),
266
- blob: expect.any(String),
267
- attributes: {
268
- session: session.state.value,
269
- hasSnapshot: expect.any(Boolean),
270
- hasError: expect.any(Boolean),
271
- agentVersion: expect.any(String),
272
- isFirstChunk: expect.any(Boolean),
273
- 'nr.rrweb.version': expect.any(String)
274
- }
275
- })
250
+ expect(JSON.parse(strFromU8(gunzipSync(harvestContents.body)))).toMatchObject(expect.any(Array))
276
251
  })
277
252
 
278
253
  test('Uncompressed payload is provided to harvester', async () => {
@@ -289,24 +264,10 @@ describe('Session Replay', () => {
289
264
  const [harvestContents] = sr.prepareHarvest()
290
265
  expect(harvestContents.qs).toMatchObject({
291
266
  protocol_version: '0',
292
- // content_encoding is omitted when the payload is not compressed
293
267
  browser_monitoring_key: info.licenseKey
294
268
  })
295
- expect(harvestContents.qs.content_encoding).toBeUndefined()
296
- expect(harvestContents.body).toMatchObject({
297
- type: 'SessionReplay',
298
- appId: info.applicationID,
299
- timestamp: expect.any(Number),
300
- blob: expect.any(String),
301
- attributes: {
302
- session: session.state.value,
303
- hasSnapshot: expect.any(Boolean),
304
- hasError: expect.any(Boolean),
305
- agentVersion: expect.any(String),
306
- isFirstChunk: expect.any(Boolean),
307
- 'nr.rrweb.version': expect.any(String)
308
- }
309
- })
269
+ expect(harvestContents.qs.attributes.includes('content_encoding')).toEqual(false)
270
+ expect(harvestContents.body).toEqual(expect.any(Array))
310
271
  })
311
272
 
312
273
  test('Clears the event buffer when staged for harvesting', async () => {
@@ -363,6 +324,6 @@ function wait (ms = 0) {
363
324
 
364
325
  function primeSessionAndReplay (sess = new SessionEntity({ agentIdentifier, key: 'SESSION', storage: new LocalMemory() })) {
365
326
  session = sess
366
- configure(agentIdentifier, { info, runtime: { session } }, 'test', true)
327
+ configure(agentIdentifier, { info, runtime: { session }, init: {} }, 'test', true)
367
328
  sr = new SessionReplayAgg(agentIdentifier, new Aggregator({}))
368
329
  }
@@ -19,6 +19,7 @@ import { getConfigurationValue, getInfo, getRuntime } from '../../../common/conf
19
19
  import { SESSION_EVENTS, MODE } from '../../../common/session/session-entity'
20
20
  import { AggregateBase } from '../../utils/aggregate-base'
21
21
  import { sharedChannel } from '../../../common/constants/shared-channel'
22
+ import { obj as encodeObj } from '../../../common/url/encode'
22
23
 
23
24
  // would be better to get this dynamically in some way
24
25
  export const RRWEB_VERSION = '2.0.0-alpha.8'
@@ -31,8 +32,8 @@ let recorder, gzipper, u8
31
32
  export const MAX_PAYLOAD_SIZE = 1000000
32
33
  /** Unloading caps around 64kb */
33
34
  export const IDEAL_PAYLOAD_SIZE = 64000
34
- /** Interval between forcing new full snapshots in "error" mode */
35
- const CHECKOUT_MS = 30000
35
+ /** Interval between forcing new full snapshots -- 30 seconds in error mode, 5 minutes in full mode */
36
+ const CHECKOUT_MS = { [MODE.ERROR]: 30000, [MODE.FULL]: 300000, [MODE.OFF]: 0 }
36
37
 
37
38
  export class Aggregate extends AggregateBase {
38
39
  static featureName = FEATURE_NAME
@@ -65,6 +66,9 @@ export class Aggregate extends AggregateBase {
65
66
  /** Payload metadata -- Should indicate that the payload being sent contains an error. Used for query/filter purposes in UI */
66
67
  this.hasError = false
67
68
 
69
+ /** Payload metadata -- Should indicate when a replay blob started recording. Resets each time a harvest occurs. */
70
+ this.timestamp = { first: undefined, last: undefined }
71
+
68
72
  /** A value which increments with every new mutation node reported. Resets after a harvest is sent */
69
73
  this.payloadBytesEstimation = 0
70
74
 
@@ -91,7 +95,7 @@ export class Aggregate extends AggregateBase {
91
95
  })
92
96
 
93
97
  // Bespoke logic for new endpoint. This will change as downstream dependencies become solidified.
94
- this.scheduler = new HarvestScheduler('blob', {
98
+ this.scheduler = new HarvestScheduler('browser/blobs', {
95
99
  onFinished: this.onHarvestFinished.bind(this),
96
100
  retryDelay: this.harvestTimeSeconds,
97
101
  getPayload: this.prepareHarvest.bind(this),
@@ -112,8 +116,7 @@ export class Aggregate extends AggregateBase {
112
116
  this.startRecording()
113
117
  this.scheduler.startTimer(this.harvestTimeSeconds)
114
118
 
115
- const { session } = getRuntime(this.agentIdentifier)
116
- session.state.sessionReplay = this.mode
119
+ this.syncWithSessionManager({ sessionReplay: this.mode })
117
120
  }
118
121
  }
119
122
  }, this.featureName, this.ee)
@@ -187,11 +190,11 @@ export class Aggregate extends AggregateBase {
187
190
 
188
191
  this.isFirstChunk = !!session.isNew
189
192
 
190
- session.state.sessionReplay = this.mode
193
+ this.syncWithSessionManager({ sessionReplay: this.mode })
191
194
  }
192
195
 
193
196
  prepareHarvest () {
194
- if (this.events.length === 0) return
197
+ if (this.events.length === 0 || (this.mode !== MODE.FULL && !this.blocked)) return
195
198
  const payload = this.getHarvestContents()
196
199
 
197
200
  if (this.shouldCompress) {
@@ -199,7 +202,6 @@ export class Aggregate extends AggregateBase {
199
202
  this.scheduler.opts.gzip = true
200
203
  } else {
201
204
  this.scheduler.opts.gzip = false
202
- delete payload.qs.content_encoding
203
205
  }
204
206
  // TODO -- Gracefully handle the buffer for retries.
205
207
  this.clearBuffer()
@@ -211,24 +213,25 @@ export class Aggregate extends AggregateBase {
211
213
  const info = getInfo(this.agentIdentifier)
212
214
  return {
213
215
  qs: {
214
- protocol_version: '0',
215
- content_encoding: 'gzip',
216
- browser_monitoring_key: info.licenseKey
217
- },
218
- body: {
216
+ browser_monitoring_key: info.licenseKey,
219
217
  type: 'SessionReplay',
220
- appId: Number(info.applicationID),
221
- timestamp: Date.now(),
222
- blob: JSON.stringify(this.events), // this needs to be a stringified JSON array of rrweb nodes
223
- attributes: {
218
+ app_id: info.applicationID,
219
+ protocol_version: '0',
220
+ attributes: encodeObj({
221
+ ...(this.shouldCompress && { content_encoding: 'gzip' }),
222
+ 'replay.firstTimestamp': this.timestamp.first,
223
+ 'replay.lastTimestamp': this.timestamp.last,
224
+ 'replay.durationMs': this.timestamp.last - this.timestamp.first,
225
+ agentVersion: agentRuntime.version,
224
226
  session: agentRuntime.session.state.value,
225
227
  hasSnapshot: this.hasSnapshot,
226
228
  hasError: this.hasError,
227
- agentVersion: agentRuntime.version,
228
229
  isFirstChunk: this.isFirstChunk,
230
+ decompressedBytes: this.payloadBytesEstimation,
229
231
  'nr.rrweb.version': RRWEB_VERSION
230
- }
231
- }
232
+ }, MAX_PAYLOAD_SIZE - this.payloadBytesEstimation).substring(1) // remove the leading '&'
233
+ },
234
+ body: this.events
232
235
  }
233
236
  }
234
237
 
@@ -248,6 +251,7 @@ export class Aggregate extends AggregateBase {
248
251
  this.hasSnapshot = false
249
252
  this.hasError = false
250
253
  this.payloadBytesEstimation = 0
254
+ this.clearTimestamps()
251
255
  }
252
256
 
253
257
  /** Begin recording using configured recording lib */
@@ -256,8 +260,8 @@ export class Aggregate extends AggregateBase {
256
260
  warn('Recording library was never imported')
257
261
  return this.abort()
258
262
  }
263
+ this.recording = true
259
264
  const { blockClass, ignoreClass, maskTextClass, blockSelector, maskInputOptions, maskTextSelector, maskAllInputs } = getConfigurationValue(this.agentIdentifier, 'session_replay')
260
- this.hasSnapshot = true
261
265
  // set up rrweb configurations for maximum privacy --
262
266
  // https://newrelic.atlassian.net/wiki/spaces/O11Y/pages/2792293280/2023+02+28+Browser+-+Session+Replay#Configuration-options
263
267
  const stop = recorder({
@@ -269,11 +273,9 @@ export class Aggregate extends AggregateBase {
269
273
  maskInputOptions,
270
274
  maskTextSelector,
271
275
  maskAllInputs,
272
- ...(this.mode === MODE.ERROR && { checkoutEveryNms: CHECKOUT_MS })
276
+ checkoutEveryNms: CHECKOUT_MS[this.mode]
273
277
  })
274
278
 
275
- this.recording = true
276
-
277
279
  this.stopRecording = () => {
278
280
  this.recording = false
279
281
  stop()
@@ -299,12 +301,15 @@ export class Aggregate extends AggregateBase {
299
301
  this.clearBuffer()
300
302
  }
301
303
 
304
+ this.setTimestamps(event)
305
+ if (event.type === 2) this.hasSnapshot = true
306
+
302
307
  this.events.push(event)
303
308
  this.payloadBytesEstimation += eventBytes
304
309
 
305
310
  // We are making an effort to try to keep payloads manageable for unloading. If they reach the unload limit before their interval,
306
311
  // it will send immediately. This often happens on the first snapshot, which can be significantly larger than the other payloads.
307
- if (payloadSize > IDEAL_PAYLOAD_SIZE) {
312
+ if (payloadSize > IDEAL_PAYLOAD_SIZE && this.mode !== MODE.ERROR) {
308
313
  // if we've made it to the ideal size of ~64kb before the interval timer, we should send early.
309
314
  this.scheduler.runHarvest()
310
315
  }
@@ -314,7 +319,16 @@ export class Aggregate extends AggregateBase {
314
319
  takeFullSnapshot () {
315
320
  if (!recorder) return
316
321
  recorder.takeFullSnapshot()
317
- this.hasSnapshot = true
322
+ }
323
+
324
+ setTimestamps (rrwebEvent) {
325
+ if (!rrwebEvent) return
326
+ if (!this.timestamp.first) this.timestamp.first = rrwebEvent.timestamp
327
+ this.timestamp.last = rrwebEvent.timestamp
328
+ }
329
+
330
+ clearTimestamps () {
331
+ this.timestamp = { first: undefined, last: undefined }
318
332
  }
319
333
 
320
334
  /** Estimate the payload size */
@@ -328,9 +342,9 @@ export class Aggregate extends AggregateBase {
328
342
  this.blocked = true
329
343
  this.mode = MODE.OFF
330
344
  this.stopRecording()
345
+ this.syncWithSessionManager({ sessionReplay: this.mode })
346
+ this.clearTimestamps()
331
347
  this.ee.emit('REPLAY_ABORTED')
332
- const { session } = getRuntime(this.agentIdentifier)
333
- session.state.sessionReplay = this.mode
334
348
  }
335
349
 
336
350
  /** Extensive research has yielded about an 88% compression factor on these payloads.
@@ -341,4 +355,9 @@ export class Aggregate extends AggregateBase {
341
355
  if (this.shouldCompress) return data * AVG_COMPRESSION
342
356
  return data
343
357
  }
358
+
359
+ syncWithSessionManager (state = {}) {
360
+ const { session } = getRuntime(this.agentIdentifier)
361
+ session.write(state)
362
+ }
344
363
  }
@@ -97,8 +97,8 @@ export class Aggregate extends AggregateBase {
97
97
  const stopTracePerm = () => {
98
98
  if (sessionEntity.state.sessionTraceMode !== MODE.OFF) sessionEntity.write({ sessionTraceMode: MODE.OFF })
99
99
  operationalGate.permanentlyDecide(false)
100
- this.#scheduler?.stopTimer(true)
101
100
  if (mostRecentModeKnown === MODE.FULL) this.#scheduler?.runHarvest() // allow queued nodes (past opGate) to final harvest, unless they were buffered in other modes
101
+ this.#scheduler?.stopTimer(true) // the 'true' arg here will forcibly block any future call to runHarvest, so the last runHarvest above must be prior
102
102
  this.#scheduler = null
103
103
  }
104
104
 
@@ -122,9 +122,8 @@ export class Aggregate extends AggregateBase {
122
122
  this.ee.on(SESSION_EVENTS.PAUSE, () => mostRecentModeKnown = sessionEntity.state.sessionTraceMode)
123
123
 
124
124
  if (!sessionEntity.isNew) { // inherit the same mode as existing session's Trace
125
- const existingTraceMode = mostRecentModeKnown = sessionEntity.state.sessionTraceMode
126
- if (existingTraceMode === MODE.OFF) this.isStandalone = true
127
- controlTraceOp(existingTraceMode)
125
+ if (sessionEntity.state.sessionReplay === MODE.OFF) this.isStandalone = true
126
+ controlTraceOp(mostRecentModeKnown = sessionEntity.state.sessionTraceMode)
128
127
  } else { // for new sessions, see the truth table associated with NEWRELIC-8662 wrt the new Trace behavior under session management
129
128
  const replayMode = await getSessionReplayMode(agentIdentifier)
130
129
  if (replayMode === MODE.OFF) this.isStandalone = true // without SR, Traces are still subject to old harvest limits
@@ -415,7 +415,7 @@ export class Aggregate extends AggregateBase {
415
415
  register(FETCH_DONE, function (err, res) {
416
416
  var node = this[SPA_NODE]
417
417
  if (node) {
418
- if (err) {
418
+ if (err || !shouldCollectEvent(this.params)) {
419
419
  node.cancel()
420
420
  return
421
421
  }