@newrelic/browser-agent 1.236.0 → 1.237.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.
- package/dist/cjs/common/config/state/init.js +1 -0
- package/dist/cjs/common/config/state/runtime.js +2 -1
- package/dist/cjs/common/constants/env.cdn.js +1 -1
- package/dist/cjs/common/constants/env.npm.js +1 -1
- package/dist/cjs/common/deny-list/deny-list.js +14 -10
- package/dist/cjs/common/harvest/harvest.js +7 -20
- package/dist/cjs/common/util/submit-data.js +4 -36
- package/dist/cjs/common/wrap/wrap-jsonp.js +12 -6
- package/dist/cjs/features/ajax/aggregate/index.js +24 -27
- package/dist/cjs/features/jserrors/aggregate/compute-stack-trace.js +1 -1
- package/dist/cjs/features/jserrors/constants.js +2 -4
- package/dist/cjs/features/jserrors/instrument/index.js +79 -88
- package/dist/cjs/features/jserrors/instrument/uncaught-error.js +22 -0
- package/dist/cjs/features/page_view_event/aggregate/initialized-features.js +23 -19
- package/dist/cjs/features/session_replay/aggregate/index.js +65 -34
- package/dist/cjs/features/session_trace/aggregate/index.js +3 -4
- package/dist/cjs/features/utils/instrument-base.js +6 -8
- package/dist/cjs/loaders/agent-base.js +87 -0
- package/dist/cjs/loaders/agent.js +41 -1
- package/dist/cjs/loaders/api/api.js +1 -1
- package/dist/cjs/loaders/api/interaction-types.js +87 -0
- package/dist/cjs/loaders/configure/configure.js +2 -1
- package/dist/cjs/loaders/micro-agent.js +3 -1
- package/dist/esm/common/config/state/init.js +1 -0
- package/dist/esm/common/config/state/runtime.js +2 -1
- package/dist/esm/common/constants/env.cdn.js +1 -1
- package/dist/esm/common/constants/env.npm.js +1 -1
- package/dist/esm/common/deny-list/deny-list.js +14 -10
- package/dist/esm/common/harvest/harvest.js +6 -20
- package/dist/esm/common/util/submit-data.js +4 -35
- package/dist/esm/common/wrap/wrap-jsonp.js +12 -6
- package/dist/esm/features/ajax/aggregate/index.js +25 -28
- package/dist/esm/features/jserrors/aggregate/compute-stack-trace.js +1 -1
- package/dist/esm/features/jserrors/constants.js +1 -2
- package/dist/esm/features/jserrors/instrument/index.js +78 -87
- package/dist/esm/features/jserrors/instrument/uncaught-error.js +15 -0
- package/dist/esm/features/page_view_event/aggregate/initialized-features.js +23 -19
- package/dist/esm/features/session_replay/aggregate/index.js +65 -34
- package/dist/esm/features/session_trace/aggregate/index.js +3 -4
- package/dist/esm/features/utils/instrument-base.js +7 -9
- package/dist/esm/loaders/agent-base.js +80 -0
- package/dist/esm/loaders/agent.js +41 -1
- package/dist/esm/loaders/api/api.js +1 -1
- package/dist/esm/loaders/api/interaction-types.js +80 -0
- package/dist/esm/loaders/configure/configure.js +3 -2
- package/dist/esm/loaders/micro-agent.js +3 -1
- package/dist/types/common/config/state/runtime.d.ts.map +1 -1
- package/dist/types/common/event-emitter/register-handler.d.ts +1 -1
- package/dist/types/common/harvest/harvest.d.ts.map +1 -1
- package/dist/types/common/session/session-entity.d.ts +6 -6
- package/dist/types/common/util/submit-data.d.ts +2 -20
- package/dist/types/common/util/submit-data.d.ts.map +1 -1
- package/dist/types/common/window/nreum.d.ts +2 -2
- package/dist/types/common/wrap/wrap-jsonp.d.ts.map +1 -1
- package/dist/types/features/ajax/aggregate/index.d.ts +5 -5
- package/dist/types/features/ajax/aggregate/index.d.ts.map +1 -1
- package/dist/types/features/jserrors/constants.d.ts +0 -1
- package/dist/types/features/jserrors/constants.d.ts.map +1 -1
- package/dist/types/features/jserrors/instrument/index.d.ts +0 -13
- package/dist/types/features/jserrors/instrument/index.d.ts.map +1 -1
- package/dist/types/features/jserrors/instrument/uncaught-error.d.ts +15 -0
- package/dist/types/features/jserrors/instrument/uncaught-error.d.ts.map +1 -0
- package/dist/types/features/metrics/aggregate/endpoint-map.d.ts +5 -5
- package/dist/types/features/page_view_event/aggregate/initialized-features.d.ts.map +1 -1
- package/dist/types/features/session_replay/aggregate/index.d.ts +16 -30
- package/dist/types/features/session_replay/aggregate/index.d.ts.map +1 -1
- package/dist/types/features/session_trace/aggregate/index.d.ts.map +1 -1
- package/dist/types/features/utils/instrument-base.d.ts.map +1 -1
- package/dist/types/loaders/agent-base.d.ts +59 -0
- package/dist/types/loaders/agent-base.d.ts.map +1 -0
- package/dist/types/loaders/agent.d.ts +35 -1
- package/dist/types/loaders/agent.d.ts.map +1 -1
- package/dist/types/loaders/api/interaction-types.d.ts +122 -0
- package/dist/types/loaders/api/interaction-types.d.ts.map +1 -0
- package/dist/types/loaders/configure/configure.d.ts.map +1 -1
- package/dist/types/loaders/features/features.d.ts +9 -9
- package/dist/types/loaders/micro-agent.d.ts +3 -2
- package/dist/types/loaders/micro-agent.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/common/config/state/init.js +1 -1
- package/src/common/config/state/runtime.js +2 -1
- package/src/common/deny-list/deny-list.js +11 -11
- package/src/common/deny-list/deny-list.test.js +31 -0
- package/src/common/harvest/harvest.js +7 -16
- package/src/common/harvest/harvest.test.js +16 -36
- package/src/common/util/__mocks__/submit-data.js +0 -1
- package/src/common/util/submit-data.js +2 -24
- package/src/common/util/submit-data.test.js +0 -56
- package/src/common/wrap/wrap-jsonp.js +11 -6
- package/src/features/ajax/aggregate/index.js +25 -31
- package/src/features/jserrors/aggregate/compute-stack-trace.js +1 -1
- package/src/features/jserrors/constants.js +0 -1
- package/src/features/jserrors/instrument/index.js +91 -87
- package/src/features/jserrors/instrument/uncaught-error.js +15 -0
- package/src/features/page_view_event/aggregate/initialized-features.js +18 -14
- package/src/features/session_replay/aggregate/index.component-test.js +17 -56
- package/src/features/session_replay/aggregate/index.js +47 -28
- package/src/features/session_trace/aggregate/index.js +3 -4
- package/src/features/utils/instrument-base.js +6 -9
- package/src/features/utils/instrument-base.test.js +7 -0
- package/src/loaders/agent-base.js +81 -0
- package/src/loaders/agent.js +42 -1
- package/src/loaders/api/api.js +1 -1
- package/src/loaders/api/interaction-types.js +80 -0
- package/src/loaders/configure/configure.js +4 -3
- package/src/loaders/micro-agent.js +4 -1
- package/dist/cjs/features/jserrors/instrument/debug.js +0 -40
- package/dist/esm/features/jserrors/instrument/debug.js +0 -38
- package/dist/types/features/jserrors/instrument/debug.d.ts +0 -2
- package/dist/types/features/jserrors/instrument/debug.d.ts.map +0 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
globalScope.onerror = this.onerrorHandler.bind(this)
|
|
42
|
+
globalScope.addEventListener('unhandledrejection', (promiseRejectionEvent) => {
|
|
43
|
+
if (!this.abortHandler) return
|
|
55
44
|
|
|
56
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* @param {
|
|
81
|
-
* @
|
|
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
|
-
|
|
88
|
-
if (
|
|
80
|
+
#castError (error) {
|
|
81
|
+
if (error instanceof Error) {
|
|
82
|
+
return error
|
|
83
|
+
}
|
|
89
84
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
97
|
+
|
|
98
|
+
return new UncaughtError(typeof error === 'string' ? error : stringify(error))
|
|
101
99
|
}
|
|
102
|
-
}
|
|
103
100
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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).
|
|
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(
|
|
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
|
-
|
|
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).
|
|
296
|
-
expect(harvestContents.body).
|
|
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
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
content_encoding: 'gzip',
|
|
216
|
-
browser_monitoring_key: info.licenseKey
|
|
217
|
-
},
|
|
218
|
-
body: {
|
|
216
|
+
browser_monitoring_key: info.licenseKey,
|
|
219
217
|
type: 'SessionReplay',
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
@@ -10,7 +10,7 @@ import { onWindowLoad } from '../../common/window/load'
|
|
|
10
10
|
import { isBrowserScope } from '../../common/constants/runtime'
|
|
11
11
|
import { warn } from '../../common/util/console'
|
|
12
12
|
import { FEATURE_NAMES } from '../../loaders/features/features'
|
|
13
|
-
import { getConfigurationValue } from '../../common/config/config'
|
|
13
|
+
import { getConfigurationValue, originals } from '../../common/config/config'
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Base class for instrumenting a feature.
|
|
@@ -55,7 +55,7 @@ export class InstrumentBase extends FeatureBase {
|
|
|
55
55
|
importAggregator (argsObjFromInstrument = {}) {
|
|
56
56
|
if (this.featAggregate || !this.auto) return
|
|
57
57
|
const enableSessionTracking = isBrowserScope && getConfigurationValue(this.agentIdentifier, 'privacy.cookies_enabled') === true
|
|
58
|
-
let loadedSuccessfully
|
|
58
|
+
let loadedSuccessfully
|
|
59
59
|
this.onAggregateImported = new Promise(resolve => {
|
|
60
60
|
loadedSuccessfully = resolve
|
|
61
61
|
})
|
|
@@ -78,6 +78,7 @@ export class InstrumentBase extends FeatureBase {
|
|
|
78
78
|
try {
|
|
79
79
|
if (!this.shouldImportAgg(this.featureName, session)) {
|
|
80
80
|
drain(this.agentIdentifier, this.featureName)
|
|
81
|
+
loadedSuccessfully(false) // aggregate module isn't loaded at all
|
|
81
82
|
return
|
|
82
83
|
}
|
|
83
84
|
const { lazyFeatureLoader } = await import(/* webpackChunkName: "lazy-feature-loader" */ './lazy-feature-loader')
|
|
@@ -105,15 +106,11 @@ export class InstrumentBase extends FeatureBase {
|
|
|
105
106
|
* @returns
|
|
106
107
|
*/
|
|
107
108
|
shouldImportAgg (featureName, session) {
|
|
108
|
-
// if this isnt the FIRST load of a session AND
|
|
109
|
-
// we are not actively recording SR... DO NOT run the aggregator
|
|
110
|
-
// session replay samples can only be decided on the first load of a session
|
|
111
|
-
// session replays can continue if in progress
|
|
112
109
|
if (featureName === FEATURE_NAMES.sessionReplay) {
|
|
113
|
-
if (
|
|
114
|
-
return
|
|
110
|
+
if (!originals.MO) return false // Session Replay cannot work without Mutation Observer
|
|
111
|
+
if (getConfigurationValue(this.agentIdentifier, 'session_trace.enabled') === false) return false // Session Replay as of now is tightly coupled with Session Trace in the UI
|
|
112
|
+
return !!session?.isNew || !!session?.state.sessionReplay // Session Replay should only try to run if already running from a previous page, or at the beginning of a session
|
|
115
113
|
}
|
|
116
|
-
// todo -- add case like above for session trace
|
|
117
114
|
return true
|
|
118
115
|
}
|
|
119
116
|
}
|