Skip to content

Commit cf96ab5

Browse files
committed
Merge branch 'NR-443294-navigations' into tmp-user-frustrations
2 parents 38e3592 + 2c98e0d commit cf96ab5

File tree

8 files changed

+94
-26
lines changed

8 files changed

+94
-26
lines changed

src/features/generic_events/aggregate/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export class Aggregate extends AggregateBase {
5959

6060
let addUserAction = () => { /** no-op */ }
6161
if (isBrowserScope && agentRef.init.user_actions.enabled) {
62-
this.userActionAggregator = new UserActionsAggregator()
62+
this.userActionAggregator = new UserActionsAggregator(agentRef.init.feature_flags.includes('user_frustrations'))
6363
this.harvestOpts.beforeUnload = () => addUserAction?.(this.userActionAggregator.aggregationEvent)
6464

6565
addUserAction = (aggregatedUserAction) => {

src/features/generic_events/aggregate/user-actions/user-actions-aggregator.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,16 @@ export class UserActionsAggregator {
1111
/** @type {AggregatedUserAction=} */
1212
#aggregationEvent = undefined
1313
#aggregationKey = ''
14+
#ufEnabled = false
1415
#deadClickTimer = undefined
15-
#domObserver = {
16-
instance: undefined
17-
}
16+
#domObserver = undefined
1817

1918
#errorClickTimer = undefined
2019

21-
constructor () {
22-
if (MutationObserver) {
23-
this.#domObserver.instance = new MutationObserver(this.treatAsLiveClick.bind(this))
20+
constructor (userFrustrationsEnabled) {
21+
if (userFrustrationsEnabled && MutationObserver) {
22+
this.#domObserver = new MutationObserver(this.treatAsLiveClick.bind(this))
23+
this.#ufEnabled = true
2424
}
2525
}
2626

@@ -49,13 +49,13 @@ export class UserActionsAggregator {
4949
} else {
5050
// return the prev existing one (if there is one)
5151
const finishedEvent = this.#aggregationEvent
52-
this.#deadClickCleanup()
53-
this.#errorClickCleanup()
52+
this.#ufEnabled && this.#deadClickCleanup()
53+
this.#ufEnabled && this.#errorClickCleanup()
5454

5555
// then start new event aggregation
5656
this.#aggregationKey = aggregationKey
5757
this.#aggregationEvent = new AggregatedUserAction(evt, selectorInfo)
58-
if (evt.type === 'click' && (selectorInfo.hasButton || selectorInfo.hasLink)) {
58+
if (this.#ufEnabled && evt.type === 'click' && (selectorInfo.hasButton || selectorInfo.hasLink)) {
5959
this.#deadClickSetup(this.#aggregationEvent)
6060
this.#errorClickSetup()
6161
}
@@ -95,14 +95,14 @@ export class UserActionsAggregator {
9595
}
9696

9797
#deadClickCleanup () {
98-
this.#domObserver.instance?.disconnect()
98+
this.#domObserver?.disconnect()
9999
this.#deadClickTimer?.clear()
100100
this.#deadClickTimer = undefined
101101
}
102102

103103
#startObserver () {
104-
if (!this.isEvaluatingDeadClick() && this.#domObserver.instance) {
105-
this.#domObserver.instance.observe(document, {
104+
if (!this.isEvaluatingDeadClick() && this.#domObserver) {
105+
this.#domObserver.observe(document, {
106106
attributes: true,
107107
characterData: true,
108108
childList: true,

src/features/session_replay/instrument/index.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { handle } from '../../../common/event-emitter/handle'
1010
import { DEFAULT_KEY, MODE, PREFIX } from '../../../common/session/constants'
1111
import { InstrumentBase } from '../../utils/instrument-base'
1212
import { hasReplayPrerequisite, isPreloadAllowed } from '../shared/utils'
13-
import { FEATURE_NAME, SR_EVENT_EMITTER_TYPES } from '../constants'
13+
import { FEATURE_NAME, SR_EVENT_EMITTER_TYPES, TRIGGERS } from '../constants'
1414
import { setupRecordReplayAPI } from '../../../loaders/api/recordReplay'
1515
import { setupPauseReplayAPI } from '../../../loaders/api/pauseReplay'
1616

@@ -69,7 +69,7 @@ export class Instrument extends InstrumentBase {
6969
/**
7070
* This func is use for early pre-load recording prior to replay feature (agg) being loaded onto the page. It should only setup once, including if already called and in-progress.
7171
*/
72-
async #preloadStartRecording () {
72+
async #preloadStartRecording (trigger) {
7373
if (this.#alreadyStarted) return
7474
this.#alreadyStarted = true
7575

@@ -78,7 +78,7 @@ export class Instrument extends InstrumentBase {
7878

7979
// If startReplay() has been used by this point, we must record in full mode regardless of session preload:
8080
// Note: recorder starts here with w/e the mode is at this time, but this may be changed later (see #apiStartOrRestartReplay else-case)
81-
this.recorder ??= new Recorder({ ...this, mode: this.#mode, agentRef: this.#agentRef, timeKeeper: this.#agentRef.runtime.timeKeeper }) // if TK exists due to deferred state, pass it
81+
this.recorder ??= new Recorder({ ...this, mode: this.#mode, agentRef: this.#agentRef, trigger, timeKeeper: this.#agentRef.runtime.timeKeeper }) // if TK exists due to deferred state, pass it
8282
this.recorder.startRecording()
8383
this.abortHandler = this.recorder.stopRecording
8484
} catch (err) {
@@ -95,7 +95,7 @@ export class Instrument extends InstrumentBase {
9595
if (this.featAggregate.mode !== MODE.FULL) this.featAggregate.initializeRecording(MODE.FULL, true)
9696
} else { // pre-load
9797
this.#mode = MODE.FULL
98-
this.#preloadStartRecording()
98+
this.#preloadStartRecording(TRIGGERS.API)
9999
// There's a race here wherein either:
100100
// a. Recorder has not been initialized, and we've set the enforced mode, so we're good, or;
101101
// b. Record has been initialized, possibly with the "wrong" mode, so we have to correct that + restart.

src/features/utils/aggregate-base.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ export class AggregateBase extends FeatureBase {
3535
/** @type {Boolean} indicates if custom attributes are combined in each event payload for size estimation purposes. this is set to true in derived classes that need to evaluate custom attributes separately from the event payload */
3636
this.customAttributesAreSeparate = false
3737
/** @type {Boolean} indicates if the feature can harvest early. This is set to false in derived classes that need to block early harvests, like ajax under certain conditions */
38-
this.canHarvestEarly = true // this is set to false in derived classes that need to block early harvests, like ajax under certain conditions
38+
this.canHarvestEarly = true
39+
/** @type {Boolean} indicates if the feature is actively in a retry deferral period */
40+
this.isRetrying = false
3941

4042
this.harvestOpts = {} // features aggregate classes can define custom opts for when their harvest is called
4143

@@ -82,7 +84,7 @@ export class AggregateBase extends FeatureBase {
8284
* @returns void
8385
*/
8486
decideEarlyHarvest () {
85-
if (!this.canHarvestEarly) return
87+
if (!this.canHarvestEarly || this.blocked || this.isRetrying) return
8688
const estimatedSize = this.events.byteSize() + (this.customAttributesAreSeparate ? this.agentRef.runtime.jsAttributesMetadata.bytes : 0)
8789
if (estimatedSize > IDEAL_PAYLOAD_SIZE) {
8890
this.agentRef.runtime.harvester.triggerHarvestFor(this)
@@ -169,8 +171,8 @@ export class AggregateBase extends FeatureBase {
169171
* @param {boolean=} result.retry - whether the harvest should be retried
170172
*/
171173
postHarvestCleanup (result = {}) {
172-
const harvestFailed = result.sent && result.retry
173-
if (harvestFailed) this.events.reloadSave(this.harvestOpts, result.targetApp?.entityGuid)
174+
this.isRetrying = result.sent && result.retry
175+
if (this.isRetrying) this.events.reloadSave(this.harvestOpts, result.targetApp?.entityGuid)
174176
this.events.clearSave(this.harvestOpts, result.targetApp?.entityGuid)
175177
}
176178

tests/assets/harvest-early-block-internal.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
newrelic.setCustomAttribute("foo", "bar".repeat(16000));
2020
newrelic.addPageAction("test");
2121
newrelic.log("test");
22-
2322
window.sendAjax = function () {
2423
fetch("https://pokeapi.co/api/v2/pokemon/moltres");
2524
};

tests/specs/harvesting/harvest-early.e2e.js

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1-
import { testAjaxEventsRequest, testInsRequest, testInteractionEventsRequest, testLogsRequest, testBlobTraceRequest } from '../../../tools/testing-server/utils/expect-tests'
1+
import { testAjaxEventsRequest, testInsRequest, testInteractionEventsRequest, testLogsRequest, testMetricsRequest, testBlobTraceRequest } from '../../../tools/testing-server/utils/expect-tests'
22

33
describe('should harvest early', () => {
44
let ajaxEventsCapture
55
let insightsCapture
66
let interactionEventsCapture
77
let loggingEventsCapture
8+
let metricsCapture
89
let testBlobTraceCapture
910

1011
beforeEach(async () => {
11-
;[ajaxEventsCapture, insightsCapture, interactionEventsCapture, loggingEventsCapture, testBlobTraceCapture] = await browser.testHandle.createNetworkCaptures('bamServer', [
12+
[ajaxEventsCapture, insightsCapture, interactionEventsCapture, loggingEventsCapture, metricsCapture, testBlobTraceCapture] = await browser.testHandle.createNetworkCaptures('bamServer', [
1213
{ test: testAjaxEventsRequest },
1314
{ test: testInsRequest },
1415
{ test: testInteractionEventsRequest },
1516
{ test: testLogsRequest },
17+
{ test: testMetricsRequest },
1618
{ test: testBlobTraceRequest }
1719
])
1820
})
@@ -36,6 +38,30 @@ describe('should harvest early', () => {
3638
expect(Date.now() - timeStart).toBeLessThan(30000) // should have harvested early before 30 seconds
3739
})
3840

41+
it('should NOT re-attempt to harvest early when rate limited', async () => {
42+
await browser.testHandle.scheduleReply('bamServer', {
43+
test: testInsRequest,
44+
statusCode: 429
45+
})
46+
47+
await browser.url(await browser.testHandle.assetURL('harvest-early-block-internal.html', { init: { harvest: { interval: 30 } } }))
48+
.then(() => browser.waitForAgentLoad())
49+
50+
/** not harvesting early in retry mode */
51+
const [smHarvest] = await Promise.all([
52+
metricsCapture.waitForResult({ totalCount: 1 }),
53+
browser.execute(function () {
54+
newrelic.addPageAction('test')
55+
})
56+
.then(() => browser.pause(10000))
57+
.then(() => browser.refresh())
58+
])
59+
60+
const insHarvestEarlySeen = smHarvest[0].request.body.sm.find(sm => sm.params.name === 'generic_events/Harvest/Early/Seen')
61+
// count (c) only exists if the same label is called more than once. It should have only early harvested once (on page load), which caused it to be denied by 429 by the scheduleReply. It should NOT try to early harvest twice since it is in retry mode.
62+
expect(insHarvestEarlySeen.stats.c).toBeUndefined()
63+
})
64+
3965
/** if we track internal and spawn early requests, we can potentially create a feedback loop that goes on forever with large ajax requests describing themselves */
4066
it('should not harvest AJAX early when agent is tracking internal calls', async () => {
4167
await browser.url(await browser.testHandle.assetURL('harvest-early.html'))

tests/specs/npm/index.e2e.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ describe('basic npm agent', () => {
9595
expect(agentSession.localStorage).toEqual({})
9696
})
9797

98+
// Note: the order of this test and the "session manager cannot be imported" test is important
9899
it('vite-react-wrapper sends basic calls', async () => {
99100
const [rumHarvests] = await Promise.all([
100101
rumCapture.waitForResult({ totalCount: 1 }),

tests/unit/features/generic_events/aggregate/user-actions/user-actions-aggregator.test.js

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,16 +80,32 @@ describe('UserActionsAggregator - Dead Clicks', () => {
8080
let aggregator
8181
beforeEach(() => {
8282
jest.useFakeTimers()
83-
aggregator = new UserActionsAggregator()
83+
aggregator = new UserActionsAggregator(true)
8484
})
8585
afterEach(() => {
8686
jest.useRealTimers()
8787
})
88+
89+
test('should NOT set deadClick if user frustrations is disabled', () => {
90+
aggregator = new UserActionsAggregator(false)
91+
const link = document.createElement('a')
92+
document.body.appendChild(link)
93+
const evt = { type: 'click', target: link }
94+
aggregator.process(evt)
95+
expect(aggregator.isEvaluatingDeadClick()).toBe(false)
96+
97+
jest.advanceTimersByTime(2000)
98+
99+
const userAction = aggregator.aggregationEvent
100+
expect(userAction.deadClick).toBe(false)
101+
})
102+
88103
test('should set deadClick to true if no change detected after 2 seconds - buttons', () => {
89104
const btn = document.createElement('button')
90105
document.body.appendChild(btn)
91106
const evt = { type: 'click', target: btn }
92107
aggregator.process(evt)
108+
expect(aggregator.isEvaluatingDeadClick()).toBe(true)
93109

94110
jest.advanceTimersByTime(2000)
95111

@@ -102,6 +118,7 @@ describe('UserActionsAggregator - Dead Clicks', () => {
102118
document.body.appendChild(link)
103119
const evt = { type: 'click', target: link }
104120
aggregator.process(evt)
121+
expect(aggregator.isEvaluatingDeadClick()).toBe(true)
105122

106123
jest.advanceTimersByTime(2000)
107124

@@ -114,6 +131,7 @@ describe('UserActionsAggregator - Dead Clicks', () => {
114131
document.body.appendChild(span)
115132
const evt = { type: 'click', target: span }
116133
aggregator.process(evt)
134+
expect(aggregator.isEvaluatingDeadClick()).toBe(false)
117135

118136
jest.advanceTimersByTime(2000)
119137

@@ -126,6 +144,7 @@ describe('UserActionsAggregator - Dead Clicks', () => {
126144
document.body.appendChild(btn)
127145
const evt = { type: 'click', target: btn }
128146
aggregator.process(evt)
147+
expect(aggregator.isEvaluatingDeadClick()).toBe(true)
129148

130149
// Simulate a DOM mutation before the timer ends
131150
btn.setAttribute('data-test', 'mutated')
@@ -159,6 +178,7 @@ describe('UserActionsAggregator - Dead Clicks', () => {
159178
const keydownEvt = { type: 'keydown', target: btn }
160179

161180
aggregator.process(clickEvt)
181+
expect(aggregator.isEvaluatingDeadClick()).toBe(true)
162182
const finishedEvent = aggregator.process(keydownEvt) // Ends aggregation before timer
163183

164184
jest.advanceTimersByTime(2000)
@@ -171,11 +191,31 @@ describe('UserActionsAggregator - Error Clicks', () => {
171191
let aggregator
172192
beforeEach(() => {
173193
jest.useFakeTimers()
174-
aggregator = new UserActionsAggregator()
194+
aggregator = new UserActionsAggregator(true)
175195
})
176196
afterEach(() => {
177197
jest.useRealTimers()
178198
})
199+
test('should NOT set errorClick if user frustrations is disabled', () => {
200+
aggregator = new UserActionsAggregator(false)
201+
const btn = document.createElement('button')
202+
btn.onclick = () => {
203+
console.log('Simulating an error')
204+
throw new Error('Simulated error')
205+
}
206+
document.body.appendChild(btn)
207+
const evt = {
208+
type: 'click',
209+
target: btn
210+
}
211+
aggregator.process(evt, ['id', 'className', 'tagName', 'type'])
212+
jest.advanceTimersByTime(1999)
213+
aggregator.markAsErrorClick() // Simulate the error click
214+
jest.advanceTimersByTime(1)
215+
216+
const userAction = aggregator.aggregationEvent
217+
expect(userAction.errorClick).toBe(false)
218+
})
179219
test('should set errorClick to true if an error is detected within 2 seconds - buttons', () => {
180220
const btn = document.createElement('button')
181221
btn.onclick = () => {

0 commit comments

Comments
 (0)