@newrelic/browser-agent 1.249.0 → 1.250.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 (74) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/cjs/common/config/state/init.js +1 -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/harvest/harvest-scheduler.js +2 -2
  6. package/dist/cjs/common/harvest/harvest.js +4 -3
  7. package/dist/cjs/common/ids/unique-id.js +1 -1
  8. package/dist/cjs/common/session/constants.js +20 -2
  9. package/dist/cjs/common/session/session-entity.js +8 -26
  10. package/dist/cjs/common/url/encode.js +2 -0
  11. package/dist/cjs/features/metrics/aggregate/index.js +3 -1
  12. package/dist/cjs/features/session_replay/aggregate/index.js +114 -277
  13. package/dist/cjs/features/session_replay/constants.js +57 -2
  14. package/dist/cjs/features/session_replay/instrument/index.js +38 -16
  15. package/dist/cjs/features/session_replay/shared/recorder-events.js +31 -0
  16. package/dist/cjs/features/session_replay/shared/recorder.js +155 -0
  17. package/dist/cjs/features/session_replay/{replay-mode.js → shared/replay-mode.js} +5 -5
  18. package/dist/cjs/features/session_trace/aggregate/index.js +25 -25
  19. package/dist/esm/common/config/state/init.js +1 -1
  20. package/dist/esm/common/constants/env.cdn.js +1 -1
  21. package/dist/esm/common/constants/env.npm.js +1 -1
  22. package/dist/esm/common/harvest/harvest-scheduler.js +1 -1
  23. package/dist/esm/common/harvest/harvest.js +4 -3
  24. package/dist/esm/common/ids/unique-id.js +1 -1
  25. package/dist/esm/common/session/constants.js +16 -1
  26. package/dist/esm/common/session/session-entity.js +2 -16
  27. package/dist/esm/common/url/encode.js +2 -0
  28. package/dist/esm/features/metrics/aggregate/index.js +3 -1
  29. package/dist/esm/features/session_replay/aggregate/index.js +95 -254
  30. package/dist/esm/features/session_replay/constants.js +49 -1
  31. package/dist/esm/features/session_replay/instrument/index.js +24 -1
  32. package/dist/esm/features/session_replay/shared/recorder-events.js +24 -0
  33. package/dist/esm/features/session_replay/shared/recorder.js +148 -0
  34. package/dist/esm/features/session_replay/{replay-mode.js → shared/replay-mode.js} +4 -4
  35. package/dist/esm/features/session_trace/aggregate/index.js +2 -2
  36. package/dist/types/common/harvest/harvest.d.ts +1 -1
  37. package/dist/types/common/harvest/harvest.d.ts.map +1 -1
  38. package/dist/types/common/session/constants.d.ts +15 -0
  39. package/dist/types/common/session/session-entity.d.ts +0 -15
  40. package/dist/types/common/session/session-entity.d.ts.map +1 -1
  41. package/dist/types/common/url/encode.d.ts +1 -1
  42. package/dist/types/common/url/encode.d.ts.map +1 -1
  43. package/dist/types/features/metrics/aggregate/index.d.ts.map +1 -1
  44. package/dist/types/features/session_replay/aggregate/index.d.ts +7 -63
  45. package/dist/types/features/session_replay/aggregate/index.d.ts.map +1 -1
  46. package/dist/types/features/session_replay/constants.d.ts +55 -0
  47. package/dist/types/features/session_replay/constants.d.ts.map +1 -1
  48. package/dist/types/features/session_replay/instrument/index.d.ts +2 -0
  49. package/dist/types/features/session_replay/instrument/index.d.ts.map +1 -1
  50. package/dist/types/features/session_replay/shared/recorder-events.d.ts +21 -0
  51. package/dist/types/features/session_replay/shared/recorder-events.d.ts.map +1 -0
  52. package/dist/types/features/session_replay/shared/recorder.d.ts +40 -0
  53. package/dist/types/features/session_replay/shared/recorder.d.ts.map +1 -0
  54. package/dist/types/features/session_replay/shared/replay-mode.d.ts.map +1 -0
  55. package/dist/types/features/session_trace/aggregate/index.d.ts.map +1 -1
  56. package/package.json +1 -1
  57. package/src/common/config/state/init.js +1 -1
  58. package/src/common/harvest/harvest-scheduler.js +1 -1
  59. package/src/common/harvest/harvest.js +4 -3
  60. package/src/common/ids/unique-id.js +1 -1
  61. package/src/common/session/__mocks__/session-entity.js +0 -6
  62. package/src/common/session/constants.js +18 -0
  63. package/src/common/session/session-entity.js +1 -17
  64. package/src/common/url/encode.js +2 -1
  65. package/src/features/metrics/aggregate/index.js +3 -1
  66. package/src/features/session_replay/aggregate/index.js +88 -246
  67. package/src/features/session_replay/constants.js +45 -0
  68. package/src/features/session_replay/instrument/index.js +18 -1
  69. package/src/features/session_replay/shared/recorder-events.js +25 -0
  70. package/src/features/session_replay/shared/recorder.js +145 -0
  71. package/src/features/session_replay/{replay-mode.js → shared/replay-mode.js} +4 -4
  72. package/src/features/session_trace/aggregate/index.js +2 -2
  73. package/dist/types/features/session_replay/replay-mode.d.ts.map +0 -1
  74. /package/dist/types/features/session_replay/{replay-mode.d.ts → shared/replay-mode.d.ts} +0 -0
@@ -4,7 +4,7 @@ import { stringify } from '../util/stringify'
4
4
  import { ee } from '../event-emitter/contextual-ee'
5
5
  import { Timer } from '../timer/timer'
6
6
  import { isBrowserScope } from '../constants/runtime'
7
- import { DEFAULT_EXPIRES_MS, DEFAULT_INACTIVE_MS, PREFIX } from './constants'
7
+ import { DEFAULT_EXPIRES_MS, DEFAULT_INACTIVE_MS, MODE, PREFIX, SESSION_EVENTS, SESSION_EVENT_TYPES } from './constants'
8
8
  import { InteractionTimer } from '../timer/interaction-timer'
9
9
  import { wrapEvents } from '../wrap'
10
10
  import { getModeledObject } from '../config/state/configurable'
@@ -13,11 +13,6 @@ import { SUPPORTABILITY_METRIC_CHANNEL } from '../../features/metrics/constants'
13
13
  import { FEATURE_NAMES } from '../../loaders/features/features'
14
14
  import { windowAddEventListener } from '../event-listener/event-listener-opts'
15
15
 
16
- export const MODE = {
17
- OFF: 0,
18
- FULL: 1,
19
- ERROR: 2
20
- }
21
16
  // this is what can be stored in local storage (not enforced but probably should be)
22
17
  // these values should sync between local storage and the parent class props
23
18
  const model = {
@@ -31,17 +26,6 @@ const model = {
31
26
  traceHarvestStarted: false,
32
27
  custom: {}
33
28
  }
34
- export const SESSION_EVENTS = {
35
- PAUSE: 'session-pause',
36
- RESET: 'session-reset',
37
- RESUME: 'session-resume',
38
- UPDATE: 'session-update'
39
- }
40
-
41
- export const SESSION_EVENT_TYPES = {
42
- SAME_TAB: 'same-tab',
43
- CROSS_TAB: 'cross-tab'
44
- }
45
29
 
46
30
  export class SessionEntity {
47
31
  /**
@@ -67,7 +67,8 @@ export function obj (payload, maxBytes) {
67
67
  }
68
68
 
69
69
  // Constructs an HTTP parameter to add to the BAM router URL
70
- export function param (name, value) {
70
+ export function param (name, value, base = {}) {
71
+ if (Object.keys(base).includes(name)) return '' // we assume if feature supplied a matching qp to the base, we should honor what the feature sent over the default
71
72
  if (value && typeof (value) === 'string') {
72
73
  return '&' + name + '=' + qs(value)
73
74
  }
@@ -90,9 +90,11 @@ export class Aggregate extends AggregateBase {
90
90
  if (rules.length > 0 && !validateRules(rules)) this.storeSupportabilityMetrics('Generic/Obfuscate/Invalid')
91
91
 
92
92
  // Check if proxy for either chunks or beacon is being used
93
- const { proxy } = getConfiguration(this.agentIdentifier)
93
+ const { proxy, privacy } = getConfiguration(this.agentIdentifier)
94
94
  if (proxy.assets) this.storeSupportabilityMetrics('Config/AssetsUrl/Changed')
95
95
  if (proxy.beacon) this.storeSupportabilityMetrics('Config/BeaconUrl/Changed')
96
+
97
+ if (!(isBrowserScope && privacy.cookies_enabled)) this.storeSupportabilityMetrics('Config/SessionTracking/Disabled')
96
98
  }
97
99
 
98
100
  eachSessionChecks () {
@@ -12,10 +12,8 @@
12
12
 
13
13
  import { registerHandler } from '../../../common/event-emitter/register-handler'
14
14
  import { HarvestScheduler } from '../../../common/harvest/harvest-scheduler'
15
- import { FEATURE_NAME } from '../constants'
16
- import { stringify } from '../../../common/util/stringify'
15
+ import { ABORT_REASONS, FEATURE_NAME, MAX_PAYLOAD_SIZE, QUERY_PARAM_PADDING, RRWEB_EVENT_TYPES } from '../constants'
17
16
  import { getConfigurationValue, getInfo, getRuntime } from '../../../common/config/config'
18
- import { SESSION_EVENTS, MODE, SESSION_EVENT_TYPES } from '../../../common/session/session-entity'
19
17
  import { AggregateBase } from '../../utils/aggregate-base'
20
18
  import { sharedChannel } from '../../../common/constants/shared-channel'
21
19
  import { obj as encodeObj } from '../../../common/url/encode'
@@ -26,131 +24,65 @@ import { handle } from '../../../common/event-emitter/handle'
26
24
  import { FEATURE_NAMES } from '../../../loaders/features/features'
27
25
  import { RRWEB_VERSION } from '../../../common/constants/env'
28
26
  import { now } from '../../../common/timing/now'
27
+ import { MODE, SESSION_EVENTS, SESSION_EVENT_TYPES } from '../../../common/session/constants'
28
+ import { stringify } from '../../../common/util/stringify'
29
29
 
30
- export const AVG_COMPRESSION = 0.12
31
-
32
- export const RRWEB_EVENT_TYPES = {
33
- DomContentLoaded: 0,
34
- Load: 1,
35
- FullSnapshot: 2,
36
- IncrementalSnapshot: 3,
37
- Meta: 4,
38
- Custom: 5
39
- }
40
-
41
- const ABORT_REASONS = {
42
- RESET: {
43
- message: 'Session was reset',
44
- sm: 'Reset'
45
- },
46
- IMPORT: {
47
- message: 'Recorder failed to import',
48
- sm: 'Import'
49
- },
50
- TOO_MANY: {
51
- message: '429: Too Many Requests',
52
- sm: 'Too-Many'
53
- },
54
- TOO_BIG: {
55
- message: 'Payload was too large',
56
- sm: 'Too-Big'
57
- },
58
- CROSS_TAB: {
59
- message: 'Session Entity was set to OFF on another tab',
60
- sm: 'Cross-Tab'
61
- }
62
- }
63
-
64
- let recorder, gzipper, u8
65
-
66
- /** Vortex caps payload sizes at 1MB */
67
- export const MAX_PAYLOAD_SIZE = 1000000
68
- /** Unloading caps around 64kb */
69
- export const IDEAL_PAYLOAD_SIZE = 64000
70
- /** Reserved room for query param attrs */
71
- const QUERY_PARAM_PADDING = 5000
72
- /** Interval between forcing new full snapshots -- 15 seconds in error mode (x2), 5 minutes in full mode */
73
- const CHECKOUT_MS = { [MODE.ERROR]: 15000, [MODE.FULL]: 300000, [MODE.OFF]: 0 }
30
+ let gzipper, u8
74
31
 
75
32
  export class Aggregate extends AggregateBase {
76
33
  static featureName = FEATURE_NAME
77
- constructor (agentIdentifier, aggregator) {
34
+ // pass the recorder into the aggregator
35
+ constructor (agentIdentifier, aggregator, args) {
78
36
  super(agentIdentifier, aggregator, FEATURE_NAME)
79
- /** Each page mutation or event will be stored (raw) in this array. This array will be cleared on each harvest */
80
- this.events = []
81
- /** Backlog used for a 2-part sliding window to guarantee a 15-30s buffer window */
82
- this.backloggedEvents = []
83
37
  /** The interval to harvest at. This gets overridden if the size of the payload exceeds certain thresholds */
84
38
  this.harvestTimeSeconds = getConfigurationValue(this.agentIdentifier, 'session_replay.harvestTimeSeconds') || 60
85
39
  /** Set once the recorder has fully initialized after flag checks and sampling */
86
40
  this.initialized = false
87
- /** Set once an error has been detected on the page. Never unset */
88
- this.errorNoticed = false
89
- /** The "mode" to record in. Defaults to "OFF" until flags and sampling are checked. See "MODE" constant. */
90
- this.mode = MODE.OFF
91
41
  /** Set once the feature has been "aborted" to prevent other side-effects from continuing */
92
42
  this.blocked = false
93
- /** True when actively recording, false when paused or stopped */
94
- this.recording = false
95
43
  /** can shut off efforts to compress the data */
96
44
  this.shouldCompress = true
97
-
98
- /** Payload metadata -- Should indicate that the payload being sent has a full DOM snapshot. This can happen
99
- * -- When the recording library begins recording, it starts by taking a DOM snapshot
100
- * -- When visibility changes from "hidden" -> "visible", it must capture a full snapshot for the replay to work correctly across tabs
101
- */
102
- this.hasSnapshot = false
103
- /** Payload metadata -- Should indicate that the payload being sent has a meta node. The meta node should always precede a snapshot node. */
104
- this.hasMeta = false
105
- /** Payload metadata -- Should indicate that the payload being sent contains an error. Used for query/filter purposes in UI */
106
- this.hasError = false
107
-
108
- /** Payload metadata -- Should indicate when a replay blob started recording. Resets each time a harvest occurs.
109
- * cycle timestamps are used as fallbacks if event timestamps cannot be used
110
- */
111
- this.cycleTimestamp = undefined
112
-
113
- /** A value which increments with every new mutation node reported. Resets after a harvest is sent */
114
- this.payloadBytesEstimation = 0
115
-
116
- /** 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 */
117
- this.lastMeta = undefined
45
+ /** the mode to start in. Defaults to off */
46
+ const { session } = getRuntime(this.agentIdentifier)
47
+ this.mode = session.state.sessionReplayMode || MODE.OFF
118
48
 
119
49
  /** set by BCS response */
120
50
  this.entitled = false
121
51
 
52
+ this.recorder = args?.recorder
53
+ if (this.recorder) this.recorder.parent = this
54
+
122
55
  const shouldSetup = (
123
56
  getConfigurationValue(agentIdentifier, 'privacy.cookies_enabled') === true &&
124
57
  getConfigurationValue(agentIdentifier, 'session_trace.enabled') === true
125
58
  )
126
59
 
127
- /** The method to stop recording. This defaults to a noop, but is overwritten once the recording library is imported and initialized */
128
- this.stopRecording = () => { /* no-op until set by rrweb initializer */ }
129
-
130
60
  if (shouldSetup) {
131
61
  // 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.
132
62
  this.ee.on(SESSION_EVENTS.RESET, () => {
63
+ this.scheduler.runHarvest()
133
64
  this.abort(ABORT_REASONS.RESET)
134
65
  })
135
66
 
136
67
  // The SessionEntity class can emit a message indicating the session was paused (visibility change). This feature must stop recording if that occurs.
137
- this.ee.on(SESSION_EVENTS.PAUSE, () => { this.stopRecording() })
68
+ this.ee.on(SESSION_EVENTS.PAUSE, () => { this.recorder?.stopRecording() })
138
69
  // The SessionEntity class can emit a message indicating the session was resumed (visibility change). This feature must start running again (if already running) if that occurs.
139
70
  this.ee.on(SESSION_EVENTS.RESUME, () => {
71
+ if (!this.recorder) return
140
72
  // if the mode changed on a different tab, it needs to update this instance to match
141
73
  const { session } = getRuntime(this.agentIdentifier)
142
74
  this.mode = session.state.sessionReplayMode
143
75
  if (!this.initialized || this.mode === MODE.OFF) return
144
- this.startRecording()
76
+ this.recorder?.startRecording()
145
77
  })
146
78
 
147
79
  this.ee.on(SESSION_EVENTS.UPDATE, (type, data) => {
148
- if (!this.initialized || this.blocked || type !== SESSION_EVENT_TYPES.CROSS_TAB) return
80
+ if (!this.recorder || !this.initialized || this.blocked || type !== SESSION_EVENT_TYPES.CROSS_TAB) return
149
81
  if (this.mode !== MODE.OFF && data.sessionReplayMode === MODE.OFF) this.abort(ABORT_REASONS.CROSS_TAB)
150
82
  this.mode = data.sessionReplay
151
83
  })
152
84
 
153
- // Bespoke logic for new endpoint. This will change as downstream dependencies become solidified.
85
+ // Bespoke logic for blobs endpoint.
154
86
  this.scheduler = new HarvestScheduler('browser/blobs', {
155
87
  onFinished: this.onHarvestFinished.bind(this),
156
88
  retryDelay: this.harvestTimeSeconds,
@@ -162,7 +94,7 @@ export class Aggregate extends AggregateBase {
162
94
  // if it has aborted or BCS returned bad entitlements, do not allow
163
95
  if (this.blocked || !this.entitled) return
164
96
  // if it isnt already (fully) initialized... initialize it
165
- if (!recorder) this.initializeRecording(false, true, true)
97
+ if (!this.recorder) this.initializeRecording(false, true, true)
166
98
  // its been initialized and imported the recorder but its not recording (mode === off || error)
167
99
  else if (this.mode !== MODE.FULL) this.switchToFull()
168
100
  // if it gets all the way to here, that means a full session is already recording... do nothing
@@ -175,8 +107,8 @@ export class Aggregate extends AggregateBase {
175
107
  // Wait for an error to be reported. This currently is wrapped around the "Error" feature. This is a feature-feature dependency.
176
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
177
109
  registerHandler('errorAgg', (e) => {
178
- this.hasError = true
179
110
  this.errorNoticed = true
111
+ if (this.recorder) this.recorder.currentBufferTarget.hasError = true
180
112
  // run once
181
113
  if (this.mode === MODE.ERROR && globalScope?.document.visibilityState === 'visible') {
182
114
  this.switchToFull()
@@ -185,6 +117,7 @@ export class Aggregate extends AggregateBase {
185
117
 
186
118
  this.waitForFlags(['sr']).then(([flagOn]) => {
187
119
  this.entitled = flagOn
120
+ if (!this.entitled && this.recorder?.recording) this.recorder.abort(ABORT_REASONS.ENTITLEMENTS)
188
121
  this.initializeRecording(
189
122
  (Math.random() * 100) < getConfigurationValue(this.agentIdentifier, 'session_replay.error_sampling_rate'),
190
123
  (Math.random() * 100) < getConfigurationValue(this.agentIdentifier, 'session_replay.sampling_rate')
@@ -198,9 +131,9 @@ export class Aggregate extends AggregateBase {
198
131
  switchToFull () {
199
132
  this.mode = MODE.FULL
200
133
  // if the error was noticed AFTER the recorder was already imported....
201
- if (recorder && this.initialized) {
202
- this.stopRecording()
203
- this.startRecording()
134
+ if (this.recorder && this.initialized) {
135
+ this.recorder.stopRecording()
136
+ this.recorder.startRecording()
204
137
 
205
138
  this.scheduler.startTimer(this.harvestTimeSeconds)
206
139
 
@@ -218,15 +151,15 @@ export class Aggregate extends AggregateBase {
218
151
  */
219
152
  async initializeRecording (errorSample, fullSample, ignoreSession) {
220
153
  this.initialized = true
221
- if (!this.entitled || this.recording) return
154
+ if (!this.entitled) return
222
155
 
223
- const { session } = getRuntime(this.agentIdentifier)
224
156
  // if theres an existing session replay in progress, there's no need to sample, just check the entitlements response
225
157
  // if not, these sample flags need to be checked
226
158
  // if this isnt the FIRST load of a session AND
227
159
  // we are not actively recording SR... DO NOT import or run the recording library
228
160
  // session replay samples can only be decided on the first load of a session
229
161
  // session replays can continue if already in progress
162
+ const { session } = getRuntime(this.agentIdentifier)
230
163
  if (!session.isNew && !ignoreSession) { // inherit the mode of the existing session
231
164
  this.mode = session.state.sessionReplayMode
232
165
  } else {
@@ -234,7 +167,20 @@ export class Aggregate extends AggregateBase {
234
167
  if (fullSample) this.mode = MODE.FULL // full mode has precedence over error mode
235
168
  else if (errorSample) this.mode = MODE.ERROR
236
169
  // If neither are selected, then don't record (early return)
237
- else return
170
+ else {
171
+ return
172
+ }
173
+ }
174
+
175
+ if (!this.recorder) {
176
+ try {
177
+ // Do not change the webpackChunkName or it will break the webpack nrba-chunking plugin
178
+ const { Recorder } = (await import(/* webpackChunkName: "recorder" */'../shared/recorder'))
179
+ this.recorder = new Recorder(this)
180
+ this.recorder.currentBufferTarget.hasError = this.errorNoticed
181
+ } catch (err) {
182
+ return this.abort(ABORT_REASONS.IMPORT)
183
+ }
238
184
  }
239
185
 
240
186
  // If an error was noticed before the mode could be set (like in the early lifecycle of the page), immediately set to FULL mode
@@ -242,17 +188,10 @@ export class Aggregate extends AggregateBase {
242
188
  this.mode = MODE.FULL
243
189
  }
244
190
 
245
- try {
246
- // Do not change the webpackChunkName or it will break the webpack nrba-chunking plugin
247
- recorder = (await import(/* webpackChunkName: "recorder" */'rrweb')).record
248
- } catch (err) {
249
- return this.abort(ABORT_REASONS.IMPORT)
250
- }
251
-
252
191
  // FULL mode records AND reports from the beginning, while ERROR mode only records (but does not report).
253
192
  // ERROR mode will do this until an error is thrown, and then switch into FULL mode.
254
193
  // If an error happened in ERROR mode before we've gotten to this stage, it will have already set the mode to FULL
255
- if (this.mode === MODE.FULL) {
194
+ if (this.mode === MODE.FULL && !this.scheduler.started) {
256
195
  // We only report (harvest) in FULL mode
257
196
  this.scheduler.startTimer(this.harvestTimeSeconds)
258
197
  }
@@ -266,63 +205,79 @@ export class Aggregate extends AggregateBase {
266
205
  // compressor failed to load, but we can still record without compression as a last ditch effort
267
206
  this.shouldCompress = false
268
207
  }
269
- this.startRecording()
208
+ if (!this.recorder.recording) this.recorder.startRecording()
270
209
 
271
210
  this.syncWithSessionManager({ sessionReplayMode: this.mode })
272
211
  }
273
212
 
274
213
  prepareHarvest () {
275
- if (this.events.length === 0 || (this.mode !== MODE.FULL && !this.blocked)) return
276
- const payload = this.getHarvestContents()
214
+ if (!this.recorder) return
215
+ const recorderEvents = this.recorder.getEvents()
216
+ // get the event type and use that to trigger another harvest if needed
217
+ if (!recorderEvents.events.length || (this.mode !== MODE.FULL) || this.blocked) return
218
+
219
+ const payload = this.getHarvestContents(recorderEvents)
277
220
  if (!payload.body.length) {
278
- this.clearBuffer()
221
+ this.recorder.clearBuffer()
279
222
  return
280
223
  }
224
+
225
+ let len = 0
281
226
  if (this.shouldCompress) {
282
- payload.body = gzipper(u8(stringify(payload.body)))
227
+ payload.body = gzipper(u8(`[${payload.body.map(e => e.__serialized).join(',')}]`))
228
+ len = payload.body.length
283
229
  this.scheduler.opts.gzip = true
284
230
  } else {
231
+ payload.body = payload.body.map(({ __serialized, ...node }) => node)
232
+ len = stringify(payload.body).length
285
233
  this.scheduler.opts.gzip = false
286
234
  }
235
+
236
+ if (len > MAX_PAYLOAD_SIZE) {
237
+ this.abort(ABORT_REASONS.TOO_BIG)
238
+ return
239
+ }
287
240
  // TODO -- Gracefully handle the buffer for retries.
288
241
  const { session } = getRuntime(this.agentIdentifier)
289
242
  if (!session.state.sessionReplaySentFirstChunk) this.syncWithSessionManager({ sessionReplaySentFirstChunk: true })
290
- this.clearBuffer()
243
+ this.recorder.clearBuffer()
244
+ if (recorderEvents.type === 'preloaded') this.scheduler.runHarvest()
291
245
  return [payload]
292
246
  }
293
247
 
294
- getHarvestContents () {
248
+ getHarvestContents (recorderEvents) {
249
+ recorderEvents ??= this.recorder.getEvents()
250
+ let events = recorderEvents.events
295
251
  const agentRuntime = getRuntime(this.agentIdentifier)
296
252
  const info = getInfo(this.agentIdentifier)
297
253
  const endUserId = info.jsAttributes?.['enduser.id']
298
254
 
299
- if (this.backloggedEvents.length) this.events = [...this.backloggedEvents, ...this.events]
300
-
301
255
  // do not let the first node be a full snapshot node, since this NEEDS to be preceded by a meta node
302
256
  // we will manually inject it if this happens
303
- const payloadStartsWithFullSnapshot = this.events[0]?.type === RRWEB_EVENT_TYPES.FullSnapshot
304
- if (payloadStartsWithFullSnapshot && !!this.lastMeta) {
305
- this.hasMeta = true
306
- this.events.unshift(this.lastMeta) // --> pushed the meta from a previous payload into newer payload... but it still has old timestamps
307
- this.lastMeta = undefined
257
+ const payloadStartsWithFullSnapshot = events?.[0]?.type === RRWEB_EVENT_TYPES.FullSnapshot
258
+ if (payloadStartsWithFullSnapshot && !!this.recorder.lastMeta) {
259
+ recorderEvents.hasMeta = true
260
+ events.unshift(this.recorder.lastMeta) // --> pushed the meta from a previous payload into newer payload... but it still has old timestamps
261
+ this.recorder.lastMeta = undefined
308
262
  }
309
263
 
310
264
  // do not let the last node be a meta node, since this NEEDS to precede a snapshot
311
265
  // we will manually inject it later if we find a payload that is missing a meta node
312
- const payloadEndsWithMeta = this.events[this.events.length - 1]?.type === RRWEB_EVENT_TYPES.Meta
266
+ const payloadEndsWithMeta = events[events.length - 1]?.type === RRWEB_EVENT_TYPES.Meta
313
267
  if (payloadEndsWithMeta) {
314
- this.lastMeta = this.events[this.events.length - 1]
315
- this.events = this.events.slice(0, this.events.length - 1)
316
- this.hasMeta = !!this.events.find(x => x.type === RRWEB_EVENT_TYPES.Meta)
268
+ this.recorder.lastMeta = events[events.length - 1]
269
+ events = events.slice(0, events.length - 1)
270
+ recorderEvents.hasMeta = !!events.find(x => x.type === RRWEB_EVENT_TYPES.Meta)
317
271
  }
318
272
 
319
273
  const agentOffset = getRuntime(this.agentIdentifier).offset
320
274
  const relativeNow = now()
321
275
 
322
- const firstEventTimestamp = this.events[0]?.timestamp // from rrweb node
323
- const lastEventTimestamp = this.events[this.events.length - 1]?.timestamp // from rrweb node
324
- const firstTimestamp = firstEventTimestamp || this.cycleTimestamp
276
+ const firstEventTimestamp = events[0]?.timestamp // from rrweb node
277
+ const lastEventTimestamp = events[events.length - 1]?.timestamp // from rrweb node
278
+ const firstTimestamp = firstEventTimestamp || recorderEvents.cycleTimestamp
325
279
  const lastTimestamp = lastEventTimestamp || agentOffset + relativeNow
280
+
326
281
  return {
327
282
  qs: {
328
283
  browser_monitoring_key: info.licenseKey,
@@ -337,23 +292,23 @@ export class Aggregate extends AggregateBase {
337
292
  'replay.firstTimestampOffset': firstTimestamp - agentOffset,
338
293
  'replay.lastTimestamp': lastTimestamp,
339
294
  'replay.durationMs': lastTimestamp - firstTimestamp,
340
- 'replay.nodes': this.events.length,
295
+ 'replay.nodes': events.length,
341
296
  'session.durationMs': agentRuntime.session.getDuration(),
342
297
  agentVersion: agentRuntime.version,
343
298
  session: agentRuntime.session.state.value,
344
299
  rst: relativeNow,
345
- hasMeta: this.hasMeta,
346
- hasSnapshot: this.hasSnapshot,
347
- hasError: this.hasError,
300
+ hasMeta: recorderEvents.hasMeta || false,
301
+ hasSnapshot: recorderEvents.hasSnapshot || false,
302
+ hasError: recorderEvents.hasError || false,
348
303
  isFirstChunk: agentRuntime.session.state.sessionReplaySentFirstChunk === false,
349
- decompressedBytes: this.payloadBytesEstimation,
304
+ decompressedBytes: recorderEvents.payloadBytesEstimation,
350
305
  'rrweb.version': RRWEB_VERSION,
351
306
  // customer-defined data should go last so that if it exceeds the query param padding limit it will be truncated instead of important attrs
352
307
  ...(endUserId && { 'enduser.id': endUserId })
353
308
  // The Query Param is being arbitrarily limited in length here. It is also applied when estimating the size of the payload in getPayloadSize()
354
309
  }, QUERY_PARAM_PADDING).substring(1) // remove the leading '&'
355
310
  },
356
- body: this.events
311
+ body: events
357
312
  }
358
313
  }
359
314
 
@@ -366,111 +321,6 @@ export class Aggregate extends AggregateBase {
366
321
  if (this.blocked) this.scheduler.stopTimer(true)
367
322
  }
368
323
 
369
- /** Clears the buffer (this.events), and resets all payload metadata properties */
370
- clearBuffer () {
371
- if (this.mode === MODE.ERROR) this.backloggedEvents = this.events
372
- else this.backloggedEvents = []
373
- this.events = []
374
- this.hasSnapshot = false
375
- this.hasMeta = false
376
- this.hasError = false
377
- this.payloadBytesEstimation = 0
378
- this.clearTimestamps()
379
- }
380
-
381
- /** Begin recording using configured recording lib */
382
- startRecording () {
383
- if (!recorder) {
384
- warn('Recording library was never imported')
385
- return this.abort(ABORT_REASONS.IMPORT)
386
- }
387
- this.recording = true
388
- const { block_class, ignore_class, mask_text_class, block_selector, mask_input_options, mask_text_selector, mask_all_inputs, inline_images, inline_stylesheet, collect_fonts } = getConfigurationValue(this.agentIdentifier, 'session_replay')
389
- // set up rrweb configurations for maximum privacy --
390
- // https://newrelic.atlassian.net/wiki/spaces/O11Y/pages/2792293280/2023+02+28+Browser+-+Session+Replay#Configuration-options
391
- const stop = recorder({
392
- emit: this.store.bind(this),
393
- blockClass: block_class,
394
- ignoreClass: ignore_class,
395
- maskTextClass: mask_text_class,
396
- blockSelector: block_selector,
397
- maskInputOptions: mask_input_options,
398
- maskTextSelector: mask_text_selector,
399
- maskAllInputs: mask_all_inputs,
400
- inlineImages: inline_images,
401
- inlineStylesheet: inline_stylesheet,
402
- collectFonts: collect_fonts,
403
- checkoutEveryNms: CHECKOUT_MS[this.mode]
404
- })
405
-
406
- this.stopRecording = () => {
407
- this.recording = false
408
- stop()
409
- }
410
- }
411
-
412
- /** Store a payload in the buffer (this.events). This should be the callback to the recording lib noticing a mutation */
413
- store (event, isCheckout) {
414
- this.setTimestamps()
415
- if (this.blocked) return
416
- const eventBytes = stringify(event).length
417
- /** The estimated size of the payload after compression */
418
- const payloadSize = this.getPayloadSize(eventBytes)
419
- // Vortex will block payloads at a certain size, we might as well not send.
420
- if (payloadSize > MAX_PAYLOAD_SIZE) {
421
- this.clearBuffer()
422
- return this.abort(ABORT_REASONS.TOO_BIG)
423
- }
424
- // Checkout events are flags by the recording lib that indicate a fullsnapshot was taken every n ms. These are important
425
- // to help reconstruct the replay later and must be included. While waiting and buffering for errors to come through,
426
- // each time we see a new checkout, we can drop the old data.
427
- // we need to check for meta because rrweb will flag it as checkout twice, once for meta, then once for snapshot
428
- if (this.mode === MODE.ERROR && isCheckout && event.type === RRWEB_EVENT_TYPES.Meta) {
429
- // we are still waiting for an error to throw, so keep wiping the buffer over time
430
- this.clearBuffer()
431
- }
432
-
433
- // meta event
434
- if (event.type === RRWEB_EVENT_TYPES.Meta) {
435
- this.hasMeta = true
436
- }
437
- // snapshot event
438
- if (event.type === RRWEB_EVENT_TYPES.FullSnapshot) {
439
- this.hasSnapshot = true
440
- }
441
-
442
- this.events.push(event)
443
- this.payloadBytesEstimation += eventBytes
444
-
445
- // We are making an effort to try to keep payloads manageable for unloading. If they reach the unload limit before their interval,
446
- // it will send immediately. This often happens on the first snapshot, which can be significantly larger than the other payloads.
447
- if (payloadSize > IDEAL_PAYLOAD_SIZE && this.mode !== MODE.ERROR) {
448
- // if we've made it to the ideal size of ~64kb before the interval timer, we should send early.
449
- this.scheduler.runHarvest()
450
- }
451
- }
452
-
453
- /** force the recording lib to take a full DOM snapshot. This needs to occur in certain cases, like visibility changes */
454
- takeFullSnapshot () {
455
- if (!recorder) return
456
- recorder.takeFullSnapshot()
457
- }
458
-
459
- setTimestamps () {
460
- // fallbacks if timestamps cannot be derived from rrweb events
461
- if (!this.cycleTimestamp) this.cycleTimestamp = getRuntime(this.agentIdentifier).offset + globalScope.performance.now()
462
- }
463
-
464
- clearTimestamps () {
465
- this.cycleTimestamp = undefined
466
- }
467
-
468
- /** Estimate the payload size */
469
- getPayloadSize (newBytes = 0) {
470
- // the query param padding constant gives us some padding for the other metadata to be safely injected
471
- return this.estimateCompression(this.payloadBytesEstimation + newBytes) + QUERY_PARAM_PADDING
472
- }
473
-
474
324
  /**
475
325
  * Forces the agent into OFF mode so that changing tabs or navigating
476
326
  * does not restart the recording. This is used when the customer calls
@@ -479,7 +329,7 @@ export class Aggregate extends AggregateBase {
479
329
  forceStop (forceHarvest) {
480
330
  if (forceHarvest) this.scheduler.runHarvest()
481
331
  this.mode = MODE.OFF
482
- this.stopRecording()
332
+ this.recorder?.stopRecording?.()
483
333
  this.syncWithSessionManager({ sessionReplayMode: this.mode })
484
334
  }
485
335
 
@@ -489,19 +339,11 @@ export class Aggregate extends AggregateBase {
489
339
  handle(SUPPORTABILITY_METRIC_CHANNEL, [`SessionReplay/Abort/${reason.sm}`], undefined, FEATURE_NAMES.metrics, this.ee)
490
340
  this.blocked = true
491
341
  this.mode = MODE.OFF
492
- this.stopRecording()
342
+ this.recorder?.stopRecording?.()
493
343
  this.syncWithSessionManager({ sessionReplayMode: this.mode })
494
- this.clearTimestamps()
344
+ this.recorder?.clearTimestamps?.()
495
345
  this.ee.emit('REPLAY_ABORTED')
496
- }
497
-
498
- /** Extensive research has yielded about an 88% compression factor on these payloads.
499
- * This is an estimation using that factor as to not cause performance issues while evaluating
500
- * https://staging.onenr.io/037jbJWxbjy
501
- * */
502
- estimateCompression (data) {
503
- if (this.shouldCompress) return data * AVG_COMPRESSION
504
- return data
346
+ this.recorder?.clearBuffer?.()
505
347
  }
506
348
 
507
349
  syncWithSessionManager (state = {}) {
@@ -1,3 +1,48 @@
1
+ import { MODE } from '../../common/session/constants'
1
2
  import { FEATURE_NAMES } from '../../loaders/features/features'
2
3
 
3
4
  export const FEATURE_NAME = FEATURE_NAMES.sessionReplay
5
+
6
+ export const AVG_COMPRESSION = 0.12
7
+ export const RRWEB_EVENT_TYPES = {
8
+ DomContentLoaded: 0,
9
+ Load: 1,
10
+ FullSnapshot: 2,
11
+ IncrementalSnapshot: 3,
12
+ Meta: 4,
13
+ Custom: 5
14
+ }
15
+ /** Vortex caps payload sizes at 1MB */
16
+ export const MAX_PAYLOAD_SIZE = 1000000
17
+ /** Unloading caps around 64kb */
18
+ export const IDEAL_PAYLOAD_SIZE = 64000
19
+ /** Interval between forcing new full snapshots -- 15 seconds in error mode (x2), 5 minutes in full mode */
20
+ export const CHECKOUT_MS = { [MODE.ERROR]: 15000, [MODE.FULL]: 300000, [MODE.OFF]: 0 }
21
+ export const ABORT_REASONS = {
22
+ RESET: {
23
+ message: 'Session was reset',
24
+ sm: 'Reset'
25
+ },
26
+ IMPORT: {
27
+ message: 'Recorder failed to import',
28
+ sm: 'Import'
29
+ },
30
+ TOO_MANY: {
31
+ message: '429: Too Many Requests',
32
+ sm: 'Too-Many'
33
+ },
34
+ TOO_BIG: {
35
+ message: 'Payload was too large',
36
+ sm: 'Too-Big'
37
+ },
38
+ CROSS_TAB: {
39
+ message: 'Session Entity was set to OFF on another tab',
40
+ sm: 'Cross-Tab'
41
+ },
42
+ ENTITLEMENTS: {
43
+ message: 'Session Replay is not allowed and will not be started',
44
+ sm: 'Entitlement'
45
+ }
46
+ }
47
+ /** Reserved room for query param attrs */
48
+ export const QUERY_PARAM_PADDING = 5000