Skip to content

Commit b5e4c6d

Browse files
feat: Aggregate UserActions (#1195)
1 parent 9c89ccd commit b5e4c6d

File tree

14 files changed

+520
-28
lines changed

14 files changed

+520
-28
lines changed

src/common/dom/iframe.js

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export function isIFrameWindow (windowObject) {
2+
if (!windowObject) return false
3+
return windowObject.self !== windowObject.top
4+
}

src/common/dom/selector-path.js

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Generates a CSS selector path for the given element, if possible
3+
* @param {HTMLElement} elem
4+
* @param {boolean} includeId
5+
* @param {boolean} includeClass
6+
* @returns {string|undefined}
7+
*/
8+
export const generateSelectorPath = (elem) => {
9+
if (!elem) return
10+
11+
const getNthOfTypeIndex = (node) => {
12+
try {
13+
let i = 1
14+
const { tagName } = node
15+
while (node.previousElementSibling) {
16+
if (node.previousElementSibling.tagName === tagName) i++
17+
node = node.previousElementSibling
18+
}
19+
return i
20+
} catch (err) {
21+
// do nothing for now. An invalid child count will make the path selector not return a nth-of-type selector statement
22+
}
23+
}
24+
25+
let pathSelector = ''
26+
let index = getNthOfTypeIndex(elem)
27+
28+
try {
29+
while (elem?.tagName) {
30+
const { id, localName } = elem
31+
const selector = [
32+
localName,
33+
id ? `#${id}` : '',
34+
pathSelector ? `>${pathSelector}` : ''
35+
].join('')
36+
37+
pathSelector = selector
38+
elem = elem.parentNode
39+
}
40+
} catch (err) {
41+
// do nothing for now
42+
}
43+
44+
return pathSelector ? index ? `${pathSelector}:nth-of-type(${index})` : pathSelector : undefined
45+
}

src/features/generic_events/aggregate/index.js

+50-15
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getInfo } from '../../../common/config/info'
99
import { getConfiguration } from '../../../common/config/init'
1010
import { getRuntime } from '../../../common/config/runtime'
1111
import { FEATURE_NAME } from '../constants'
12-
import { isBrowserScope } from '../../../common/constants/runtime'
12+
import { initialLocation, isBrowserScope } from '../../../common/constants/runtime'
1313
import { AggregateBase } from '../../utils/aggregate-base'
1414
import { warn } from '../../../common/util/console'
1515
import { now } from '../../../common/timing/now'
@@ -19,6 +19,8 @@ import { SUPPORTABILITY_METRIC_CHANNEL } from '../../metrics/constants'
1919
import { EventBuffer } from '../../utils/event-buffer'
2020
import { applyFnToProps } from '../../../common/util/traverse'
2121
import { IDEAL_PAYLOAD_SIZE } from '../../../common/constants/agent-constants'
22+
import { UserActionsAggregator } from './user-actions/user-actions-aggregator'
23+
import { isIFrameWindow } from '../../../common/dom/iframe'
2224

2325
export class Aggregate extends AggregateBase {
2426
#agentRuntime
@@ -43,6 +45,8 @@ export class Aggregate extends AggregateBase {
4345
return
4446
}
4547

48+
const preHarvestMethods = []
49+
4650
if (agentInit.page_action.enabled) {
4751
registerHandler('api-addPageAction', (timestamp, name, attributes) => {
4852
this.addEvent({
@@ -52,7 +56,6 @@ export class Aggregate extends AggregateBase {
5256
timeSinceLoad: timestamp / 1000,
5357
actionName: name,
5458
referrerUrl: this.referrerUrl,
55-
currentUrl: cleanURL('' + location),
5659
...(isBrowserScope && {
5760
browserWidth: window.document.documentElement?.clientWidth,
5861
browserHeight: window.document.documentElement?.clientHeight
@@ -61,22 +64,53 @@ export class Aggregate extends AggregateBase {
6164
}, this.featureName, this.ee)
6265
}
6366

64-
if (agentInit.user_actions.enabled) {
67+
if (isBrowserScope && agentInit.user_actions.enabled) {
68+
this.userActionAggregator = new UserActionsAggregator()
69+
70+
this.addUserAction = (aggregatedUserAction) => {
71+
try {
72+
/** The aggregator process only returns an event when it is "done" aggregating -
73+
* so we still need to validate that an event was given to this method before we try to add */
74+
if (aggregatedUserAction?.event) {
75+
const { target, timeStamp, type } = aggregatedUserAction.event
76+
this.addEvent({
77+
eventType: 'UserAction',
78+
timestamp: Math.floor(this.#agentRuntime.timeKeeper.correctRelativeTimestamp(timeStamp)),
79+
action: type,
80+
actionCount: aggregatedUserAction.count,
81+
duration: aggregatedUserAction.relativeMs[aggregatedUserAction.relativeMs.length - 1],
82+
rageClick: aggregatedUserAction.rageClick,
83+
relativeMs: aggregatedUserAction.relativeMs,
84+
target: aggregatedUserAction.selectorPath,
85+
...(isIFrameWindow(window) && { iframe: true }),
86+
...(target?.id && { targetId: target.id }),
87+
...(target?.tagName && { targetTag: target.tagName }),
88+
...(target?.type && { targetType: target.type }),
89+
...(target?.className && { targetClass: target.className })
90+
})
91+
}
92+
} catch (e) {
93+
// do nothing for now
94+
}
95+
}
96+
6597
registerHandler('ua', (evt) => {
66-
this.addEvent({
67-
eventType: 'UserAction',
68-
timestamp: Math.floor(this.#agentRuntime.timeKeeper.correctRelativeTimestamp(evt.timeStamp)),
69-
action: evt.type,
70-
targetId: evt.target?.id,
71-
targetTag: evt.target?.tagName,
72-
targetType: evt.target?.type,
73-
targetClass: evt.target?.className
74-
})
98+
/** the processor will return the previously aggregated event if it has been completed by processing the current event */
99+
this.addUserAction(this.userActionAggregator.process(evt))
75100
}, this.featureName, this.ee)
101+
102+
preHarvestMethods.push((options = {}) => {
103+
/** send whatever UserActions have been aggregated up to this point
104+
* if we are in a final harvest. By accessing the aggregationEvent, the aggregation is then force-cleared */
105+
if (options.isFinalHarvest) this.addUserAction(this.userActionAggregator.aggregationEvent)
106+
})
76107
}
77108

78109
this.harvestScheduler = new HarvestScheduler('ins', { onFinished: (...args) => this.onHarvestFinished(...args) }, this)
79-
this.harvestScheduler.harvest.on('ins', (...args) => this.onHarvestStarted(...args))
110+
this.harvestScheduler.harvest.on('ins', (...args) => {
111+
preHarvestMethods.forEach(fn => fn(...args))
112+
return this.onHarvestStarted(...args)
113+
})
80114
this.harvestScheduler.startTimer(this.harvestTimeSeconds, 0)
81115

82116
this.drain()
@@ -99,8 +133,9 @@ export class Aggregate extends AggregateBase {
99133
const defaultEventAttributes = {
100134
/** should be overridden by the event-specific attributes, but just in case -- set it to now() */
101135
timestamp: Math.floor(this.#agentRuntime.timeKeeper.correctRelativeTimestamp(now())),
102-
/** all generic events require a pageUrl */
103-
pageUrl: cleanURL(getRuntime(this.agentIdentifier).origin)
136+
/** all generic events require pageUrl(s) */
137+
pageUrl: cleanURL('' + initialLocation),
138+
currentUrl: cleanURL('' + location)
104139
}
105140

106141
const eventAttributes = {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { RAGE_CLICK_THRESHOLD_EVENTS, RAGE_CLICK_THRESHOLD_MS } from '../../constants'
2+
3+
export class AggregatedUserAction {
4+
constructor (evt, selectorPath) {
5+
this.event = evt
6+
this.count = 1
7+
this.originMs = Math.floor(evt.timeStamp)
8+
this.relativeMs = [0]
9+
this.selectorPath = selectorPath
10+
this.rageClick = undefined
11+
}
12+
13+
/**
14+
* Aggregates the count and maintains the relative MS array for matching events
15+
* Will determine if a rage click was observed as part of the aggregation
16+
* @param {Event} evt
17+
* @returns {void}
18+
*/
19+
aggregate (evt) {
20+
this.count++
21+
this.relativeMs.push(Math.floor(evt.timeStamp - this.originMs))
22+
if (this.isRageClick()) this.rageClick = true
23+
}
24+
25+
/**
26+
* Determines if the current set of relative ms values constitutes a rage click
27+
* @returns {boolean}
28+
*/
29+
isRageClick () {
30+
const len = this.relativeMs.length
31+
return (this.event.type === 'click' && len >= RAGE_CLICK_THRESHOLD_EVENTS && this.relativeMs[len - 1] - this.relativeMs[len - RAGE_CLICK_THRESHOLD_EVENTS] < RAGE_CLICK_THRESHOLD_MS)
32+
}
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { generateSelectorPath } from '../../../../common/dom/selector-path'
2+
import { OBSERVED_WINDOW_EVENTS } from '../../constants'
3+
import { AggregatedUserAction } from './aggregated-user-action'
4+
5+
export class UserActionsAggregator {
6+
/** @type {AggregatedUserAction=} */
7+
#aggregationEvent = undefined
8+
#aggregationKey = ''
9+
10+
get aggregationEvent () {
11+
// if this is accessed externally, we need to be done aggregating on it
12+
// to prevent potential mutability and duplication issues, so the state is cleared upon returning.
13+
// This value may need to be accessed during an unload harvest.
14+
const finishedEvent = this.#aggregationEvent
15+
this.#aggregationKey = ''
16+
this.#aggregationEvent = undefined
17+
return finishedEvent
18+
}
19+
20+
/**
21+
* Process the event and determine if a new aggregation set should be made or if it should increment the current aggregation
22+
* @param {Event} evt The event supplied by the addEventListener callback
23+
* @returns {AggregatedUserAction|undefined} The previous aggregation set if it has been completed by processing the current event
24+
*/
25+
process (evt) {
26+
if (!evt) return
27+
const selectorPath = getSelectorPath(evt)
28+
const aggregationKey = getAggregationKey(evt, selectorPath)
29+
if (!!aggregationKey && aggregationKey === this.#aggregationKey) {
30+
// an aggregation exists already, so lets just continue to increment
31+
this.#aggregationEvent.aggregate(evt)
32+
} else {
33+
// return the prev existing one (if there is one)
34+
const finishedEvent = this.#aggregationEvent
35+
// then set as this new event aggregation
36+
this.#aggregationKey = aggregationKey
37+
this.#aggregationEvent = new AggregatedUserAction(evt, selectorPath)
38+
return finishedEvent
39+
}
40+
}
41+
}
42+
43+
/**
44+
* Generates a selector path for the event, starting with simple cases like window or document and getting more complex for dom-tree traversals as needed.
45+
* Will return a random selector path value if no other path can be determined, to force the aggregator to skip aggregation for this event.
46+
* @param {Event} evt
47+
* @returns {string}
48+
*/
49+
function getSelectorPath (evt) {
50+
let selectorPath
51+
if (OBSERVED_WINDOW_EVENTS.includes(evt.type) || evt.target === window) selectorPath = 'window'
52+
else if (evt.target === document) selectorPath = 'document'
53+
// if still no selectorPath, generate one from target tree that includes elem ids
54+
else selectorPath = generateSelectorPath(evt.target)
55+
// if STILL no selectorPath, it will return undefined which will skip aggregation for this event
56+
return selectorPath
57+
}
58+
59+
/**
60+
* Returns an aggregation key based on the event type and the selector path of the event's target.
61+
* Scrollend events are aggregated into one set, no matter what.
62+
* @param {Event} evt
63+
* @param {string} selectorPath
64+
* @returns {string}
65+
*/
66+
function getAggregationKey (evt, selectorPath) {
67+
let aggregationKey = evt.type
68+
/** aggregate all scrollends into one set (if sequential), no matter what their target is
69+
* the aggregation group's selector path with be reflected as the first one observed
70+
* due to the way the aggregation logic works (by storing the initial value and aggregating it) */
71+
if (evt.type !== 'scrollend') aggregationKey += '-' + selectorPath
72+
return aggregationKey
73+
}

src/features/generic_events/constants.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@ export const FEATURE_NAME = FEATURE_NAMES.genericEvents
44
export const IDEAL_PAYLOAD_SIZE = 64000
55
export const MAX_PAYLOAD_SIZE = 1000000
66

7-
export const OBSERVED_EVENTS = ['auxclick', 'click', 'copy', 'input', 'keydown', 'paste', 'scrollend']
7+
export const OBSERVED_EVENTS = ['auxclick', 'click', 'copy', 'keydown', 'paste', 'scrollend']
88
export const OBSERVED_WINDOW_EVENTS = ['focus', 'blur']
9+
10+
export const RAGE_CLICK_THRESHOLD_EVENTS = 4
11+
export const RAGE_CLICK_THRESHOLD_MS = 1000

src/features/metrics/aggregate/index.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { windowAddEventListener } from '../../../common/event-listener/event-lis
1010
import { isBrowserScope, isWorkerScope } from '../../../common/constants/runtime'
1111
import { AggregateBase } from '../../utils/aggregate-base'
1212
import { deregisterDrain } from '../../../common/drain/drain'
13+
import { isIFrameWindow } from '../../../common/dom/iframe'
1314
// import { WEBSOCKET_TAG } from '../../../common/wrap/wrap-websocket'
1415
// import { handleWebsocketEvents } from './websocket-detection'
1516

@@ -101,7 +102,7 @@ export class Aggregate extends AggregateBase {
101102
if (proxy.beacon) this.storeSupportabilityMetrics('Config/BeaconUrl/Changed')
102103

103104
if (isBrowserScope && window.MutationObserver) {
104-
if (window.self !== window.top) { this.storeSupportabilityMetrics('Generic/Runtime/IFrame/Detected') }
105+
if (isIFrameWindow(window)) { this.storeSupportabilityMetrics('Generic/Runtime/IFrame/Detected') }
105106
const preExistingVideos = window.document.querySelectorAll('video').length
106107
if (preExistingVideos) this.storeSupportabilityMetrics('Generic/VideoElement/Added', preExistingVideos)
107108
const preExistingIframes = window.document.querySelectorAll('iframe').length

tests/assets/user-actions.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
<script>NREUM.init.ssl = true</script> -->
1818
{loader}
1919
</head>
20-
<body>
20+
<body style="height: 200vh; overflow: scroll;">
2121
<button id="pay-btn" class="btn-cart-add flex-grow container" type="submit">Create click user action</button>
2222
<input type="text" id="textbox"/>
2323
</body>

tests/components/generic_events/aggregate/index.test.js

+76-3
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,87 @@ describe('sub-features', () => {
147147

148148
test('should record user actions when enabled', () => {
149149
getInfo(agentSetup.agentIdentifier).jsAttributes = { globalFoo: 'globalBar' }
150-
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 123456, type: 'click', target: { id: 'myBtn', tagName: 'button' } }])
150+
const target = document.createElement('button')
151+
target.id = 'myBtn'
152+
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 123456, type: 'click', target }])
153+
// blur event to trigger aggregation to stop and add to harvest buffer
154+
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 234567, type: 'blur', target: window }])
155+
156+
const harvest = genericEventsAggregate.onHarvestStarted({ isFinalHarvest: true }) // force it to put the aggregation into the event buffer
157+
expect(harvest.body.ins[0]).toMatchObject({
158+
eventType: 'UserAction',
159+
timestamp: expect.any(Number),
160+
action: 'click',
161+
actionCount: 1,
162+
duration: 0,
163+
target: 'button#myBtn:nth-of-type(1)',
164+
targetId: 'myBtn',
165+
targetTag: 'BUTTON',
166+
globalFoo: 'globalBar'
167+
})
168+
})
151169

152-
expect(genericEventsAggregate.events.buffer[0]).toMatchObject({
170+
test('should aggregate user actions when matching target', () => {
171+
getInfo(agentSetup.agentIdentifier).jsAttributes = { globalFoo: 'globalBar' }
172+
const target = document.createElement('button')
173+
target.id = 'myBtn'
174+
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 100, type: 'click', target }])
175+
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 200, type: 'click', target }])
176+
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 300, type: 'click', target }])
177+
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 400, type: 'click', target }])
178+
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 500, type: 'click', target }])
179+
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 600, type: 'click', target }])
180+
// blur event to trigger aggregation to stop and add to harvest buffer
181+
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 234567, type: 'blur', target: window }])
182+
183+
const harvest = genericEventsAggregate.onHarvestStarted({ isFinalHarvest: true }) // force it to put the aggregation into the event buffer
184+
expect(harvest.body.ins[0]).toMatchObject({
185+
eventType: 'UserAction',
186+
timestamp: expect.any(Number),
187+
action: 'click',
188+
actionCount: 6,
189+
duration: 500,
190+
target: 'button#myBtn:nth-of-type(1)',
191+
targetId: 'myBtn',
192+
targetTag: 'BUTTON',
193+
globalFoo: 'globalBar'
194+
})
195+
})
196+
test('should NOT aggregate user actions when targets are not identical', () => {
197+
getInfo(agentSetup.agentIdentifier).jsAttributes = { globalFoo: 'globalBar' }
198+
const target = document.createElement('button')
199+
target.id = 'myBtn'
200+
document.body.appendChild(target)
201+
const target2 = document.createElement('button')
202+
target2.id = 'myBtn'
203+
document.body.appendChild(target2)
204+
/** even though target1 and target2 have the same tag (button) and id (myBtn), it should still NOT aggregate them because they have different nth-of-type paths */
205+
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 100, type: 'click', target }])
206+
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 200, type: 'click', target: target2 }])
207+
// blur event to trigger aggregation to stop and add to harvest buffer
208+
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 234567, type: 'blur', target: window }])
209+
210+
const harvest = genericEventsAggregate.onHarvestStarted({ isFinalHarvest: true }) // force it to put the aggregation into the event buffer
211+
expect(harvest.body.ins[0]).toMatchObject({
212+
eventType: 'UserAction',
213+
timestamp: expect.any(Number),
214+
action: 'click',
215+
actionCount: 1,
216+
duration: 0,
217+
target: 'html>body>button#myBtn:nth-of-type(1)',
218+
targetId: 'myBtn',
219+
targetTag: 'BUTTON',
220+
globalFoo: 'globalBar'
221+
})
222+
expect(harvest.body.ins[1]).toMatchObject({
153223
eventType: 'UserAction',
154224
timestamp: expect.any(Number),
155225
action: 'click',
226+
actionCount: 1,
227+
duration: 0,
228+
target: 'html>body>button#myBtn:nth-of-type(2)',
156229
targetId: 'myBtn',
157-
targetTag: 'button',
230+
targetTag: 'BUTTON',
158231
globalFoo: 'globalBar'
159232
})
160233
})

0 commit comments

Comments
 (0)