Skip to content

Commit

Permalink
feat: Aggregate UserActions (#1195)
Browse files Browse the repository at this point in the history
  • Loading branch information
metal-messiah authored Oct 1, 2024
1 parent 9c89ccd commit b5e4c6d
Show file tree
Hide file tree
Showing 14 changed files with 520 additions and 28 deletions.
4 changes: 4 additions & 0 deletions src/common/dom/iframe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export function isIFrameWindow (windowObject) {
if (!windowObject) return false
return windowObject.self !== windowObject.top
}
45 changes: 45 additions & 0 deletions src/common/dom/selector-path.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Generates a CSS selector path for the given element, if possible
* @param {HTMLElement} elem
* @param {boolean} includeId
* @param {boolean} includeClass
* @returns {string|undefined}
*/
export const generateSelectorPath = (elem) => {
if (!elem) return

const getNthOfTypeIndex = (node) => {
try {
let i = 1
const { tagName } = node
while (node.previousElementSibling) {
if (node.previousElementSibling.tagName === tagName) i++
node = node.previousElementSibling
}
return i
} catch (err) {
// do nothing for now. An invalid child count will make the path selector not return a nth-of-type selector statement
}
}

let pathSelector = ''
let index = getNthOfTypeIndex(elem)

try {
while (elem?.tagName) {
const { id, localName } = elem
const selector = [
localName,
id ? `#${id}` : '',
pathSelector ? `>${pathSelector}` : ''
].join('')

pathSelector = selector
elem = elem.parentNode
}
} catch (err) {
// do nothing for now
}

return pathSelector ? index ? `${pathSelector}:nth-of-type(${index})` : pathSelector : undefined
}
65 changes: 50 additions & 15 deletions src/features/generic_events/aggregate/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getInfo } from '../../../common/config/info'
import { getConfiguration } from '../../../common/config/init'
import { getRuntime } from '../../../common/config/runtime'
import { FEATURE_NAME } from '../constants'
import { isBrowserScope } from '../../../common/constants/runtime'
import { initialLocation, isBrowserScope } from '../../../common/constants/runtime'
import { AggregateBase } from '../../utils/aggregate-base'
import { warn } from '../../../common/util/console'
import { now } from '../../../common/timing/now'
Expand All @@ -19,6 +19,8 @@ import { SUPPORTABILITY_METRIC_CHANNEL } from '../../metrics/constants'
import { EventBuffer } from '../../utils/event-buffer'
import { applyFnToProps } from '../../../common/util/traverse'
import { IDEAL_PAYLOAD_SIZE } from '../../../common/constants/agent-constants'
import { UserActionsAggregator } from './user-actions/user-actions-aggregator'
import { isIFrameWindow } from '../../../common/dom/iframe'

export class Aggregate extends AggregateBase {
#agentRuntime
Expand All @@ -43,6 +45,8 @@ export class Aggregate extends AggregateBase {
return
}

const preHarvestMethods = []

if (agentInit.page_action.enabled) {
registerHandler('api-addPageAction', (timestamp, name, attributes) => {
this.addEvent({
Expand All @@ -52,7 +56,6 @@ export class Aggregate extends AggregateBase {
timeSinceLoad: timestamp / 1000,
actionName: name,
referrerUrl: this.referrerUrl,
currentUrl: cleanURL('' + location),
...(isBrowserScope && {
browserWidth: window.document.documentElement?.clientWidth,
browserHeight: window.document.documentElement?.clientHeight
Expand All @@ -61,22 +64,53 @@ export class Aggregate extends AggregateBase {
}, this.featureName, this.ee)
}

if (agentInit.user_actions.enabled) {
if (isBrowserScope && agentInit.user_actions.enabled) {
this.userActionAggregator = new UserActionsAggregator()

this.addUserAction = (aggregatedUserAction) => {
try {
/** The aggregator process only returns an event when it is "done" aggregating -
* so we still need to validate that an event was given to this method before we try to add */
if (aggregatedUserAction?.event) {
const { target, timeStamp, type } = aggregatedUserAction.event
this.addEvent({
eventType: 'UserAction',
timestamp: Math.floor(this.#agentRuntime.timeKeeper.correctRelativeTimestamp(timeStamp)),
action: type,
actionCount: aggregatedUserAction.count,
duration: aggregatedUserAction.relativeMs[aggregatedUserAction.relativeMs.length - 1],
rageClick: aggregatedUserAction.rageClick,
relativeMs: aggregatedUserAction.relativeMs,
target: aggregatedUserAction.selectorPath,
...(isIFrameWindow(window) && { iframe: true }),
...(target?.id && { targetId: target.id }),
...(target?.tagName && { targetTag: target.tagName }),
...(target?.type && { targetType: target.type }),
...(target?.className && { targetClass: target.className })
})
}
} catch (e) {
// do nothing for now
}
}

registerHandler('ua', (evt) => {
this.addEvent({
eventType: 'UserAction',
timestamp: Math.floor(this.#agentRuntime.timeKeeper.correctRelativeTimestamp(evt.timeStamp)),
action: evt.type,
targetId: evt.target?.id,
targetTag: evt.target?.tagName,
targetType: evt.target?.type,
targetClass: evt.target?.className
})
/** the processor will return the previously aggregated event if it has been completed by processing the current event */
this.addUserAction(this.userActionAggregator.process(evt))
}, this.featureName, this.ee)

preHarvestMethods.push((options = {}) => {
/** send whatever UserActions have been aggregated up to this point
* if we are in a final harvest. By accessing the aggregationEvent, the aggregation is then force-cleared */
if (options.isFinalHarvest) this.addUserAction(this.userActionAggregator.aggregationEvent)
})
}

this.harvestScheduler = new HarvestScheduler('ins', { onFinished: (...args) => this.onHarvestFinished(...args) }, this)
this.harvestScheduler.harvest.on('ins', (...args) => this.onHarvestStarted(...args))
this.harvestScheduler.harvest.on('ins', (...args) => {
preHarvestMethods.forEach(fn => fn(...args))
return this.onHarvestStarted(...args)
})
this.harvestScheduler.startTimer(this.harvestTimeSeconds, 0)

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

const eventAttributes = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { RAGE_CLICK_THRESHOLD_EVENTS, RAGE_CLICK_THRESHOLD_MS } from '../../constants'

export class AggregatedUserAction {
constructor (evt, selectorPath) {
this.event = evt
this.count = 1
this.originMs = Math.floor(evt.timeStamp)
this.relativeMs = [0]
this.selectorPath = selectorPath
this.rageClick = undefined
}

/**
* Aggregates the count and maintains the relative MS array for matching events
* Will determine if a rage click was observed as part of the aggregation
* @param {Event} evt
* @returns {void}
*/
aggregate (evt) {
this.count++
this.relativeMs.push(Math.floor(evt.timeStamp - this.originMs))
if (this.isRageClick()) this.rageClick = true
}

/**
* Determines if the current set of relative ms values constitutes a rage click
* @returns {boolean}
*/
isRageClick () {
const len = this.relativeMs.length
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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { generateSelectorPath } from '../../../../common/dom/selector-path'
import { OBSERVED_WINDOW_EVENTS } from '../../constants'
import { AggregatedUserAction } from './aggregated-user-action'

export class UserActionsAggregator {
/** @type {AggregatedUserAction=} */
#aggregationEvent = undefined
#aggregationKey = ''

get aggregationEvent () {
// if this is accessed externally, we need to be done aggregating on it
// to prevent potential mutability and duplication issues, so the state is cleared upon returning.
// This value may need to be accessed during an unload harvest.
const finishedEvent = this.#aggregationEvent
this.#aggregationKey = ''
this.#aggregationEvent = undefined
return finishedEvent
}

/**
* Process the event and determine if a new aggregation set should be made or if it should increment the current aggregation
* @param {Event} evt The event supplied by the addEventListener callback
* @returns {AggregatedUserAction|undefined} The previous aggregation set if it has been completed by processing the current event
*/
process (evt) {
if (!evt) return
const selectorPath = getSelectorPath(evt)
const aggregationKey = getAggregationKey(evt, selectorPath)
if (!!aggregationKey && aggregationKey === this.#aggregationKey) {
// an aggregation exists already, so lets just continue to increment
this.#aggregationEvent.aggregate(evt)
} else {
// return the prev existing one (if there is one)
const finishedEvent = this.#aggregationEvent
// then set as this new event aggregation
this.#aggregationKey = aggregationKey
this.#aggregationEvent = new AggregatedUserAction(evt, selectorPath)
return finishedEvent
}
}
}

/**
* 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.
* Will return a random selector path value if no other path can be determined, to force the aggregator to skip aggregation for this event.
* @param {Event} evt
* @returns {string}
*/
function getSelectorPath (evt) {
let selectorPath
if (OBSERVED_WINDOW_EVENTS.includes(evt.type) || evt.target === window) selectorPath = 'window'
else if (evt.target === document) selectorPath = 'document'
// if still no selectorPath, generate one from target tree that includes elem ids
else selectorPath = generateSelectorPath(evt.target)
// if STILL no selectorPath, it will return undefined which will skip aggregation for this event
return selectorPath
}

/**
* Returns an aggregation key based on the event type and the selector path of the event's target.
* Scrollend events are aggregated into one set, no matter what.
* @param {Event} evt
* @param {string} selectorPath
* @returns {string}
*/
function getAggregationKey (evt, selectorPath) {
let aggregationKey = evt.type
/** aggregate all scrollends into one set (if sequential), no matter what their target is
* the aggregation group's selector path with be reflected as the first one observed
* due to the way the aggregation logic works (by storing the initial value and aggregating it) */
if (evt.type !== 'scrollend') aggregationKey += '-' + selectorPath
return aggregationKey
}
5 changes: 4 additions & 1 deletion src/features/generic_events/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ export const FEATURE_NAME = FEATURE_NAMES.genericEvents
export const IDEAL_PAYLOAD_SIZE = 64000
export const MAX_PAYLOAD_SIZE = 1000000

export const OBSERVED_EVENTS = ['auxclick', 'click', 'copy', 'input', 'keydown', 'paste', 'scrollend']
export const OBSERVED_EVENTS = ['auxclick', 'click', 'copy', 'keydown', 'paste', 'scrollend']
export const OBSERVED_WINDOW_EVENTS = ['focus', 'blur']

export const RAGE_CLICK_THRESHOLD_EVENTS = 4
export const RAGE_CLICK_THRESHOLD_MS = 1000
3 changes: 2 additions & 1 deletion src/features/metrics/aggregate/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { windowAddEventListener } from '../../../common/event-listener/event-lis
import { isBrowserScope, isWorkerScope } from '../../../common/constants/runtime'
import { AggregateBase } from '../../utils/aggregate-base'
import { deregisterDrain } from '../../../common/drain/drain'
import { isIFrameWindow } from '../../../common/dom/iframe'
// import { WEBSOCKET_TAG } from '../../../common/wrap/wrap-websocket'
// import { handleWebsocketEvents } from './websocket-detection'

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

if (isBrowserScope && window.MutationObserver) {
if (window.self !== window.top) { this.storeSupportabilityMetrics('Generic/Runtime/IFrame/Detected') }
if (isIFrameWindow(window)) { this.storeSupportabilityMetrics('Generic/Runtime/IFrame/Detected') }
const preExistingVideos = window.document.querySelectorAll('video').length
if (preExistingVideos) this.storeSupportabilityMetrics('Generic/VideoElement/Added', preExistingVideos)
const preExistingIframes = window.document.querySelectorAll('iframe').length
Expand Down
2 changes: 1 addition & 1 deletion tests/assets/user-actions.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<script>NREUM.init.ssl = true</script> -->
{loader}
</head>
<body>
<body style="height: 200vh; overflow: scroll;">
<button id="pay-btn" class="btn-cart-add flex-grow container" type="submit">Create click user action</button>
<input type="text" id="textbox"/>
</body>
Expand Down
79 changes: 76 additions & 3 deletions tests/components/generic_events/aggregate/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,87 @@ describe('sub-features', () => {

test('should record user actions when enabled', () => {
getInfo(agentSetup.agentIdentifier).jsAttributes = { globalFoo: 'globalBar' }
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 123456, type: 'click', target: { id: 'myBtn', tagName: 'button' } }])
const target = document.createElement('button')
target.id = 'myBtn'
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 123456, type: 'click', target }])
// blur event to trigger aggregation to stop and add to harvest buffer
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 234567, type: 'blur', target: window }])

const harvest = genericEventsAggregate.onHarvestStarted({ isFinalHarvest: true }) // force it to put the aggregation into the event buffer
expect(harvest.body.ins[0]).toMatchObject({
eventType: 'UserAction',
timestamp: expect.any(Number),
action: 'click',
actionCount: 1,
duration: 0,
target: 'button#myBtn:nth-of-type(1)',
targetId: 'myBtn',
targetTag: 'BUTTON',
globalFoo: 'globalBar'
})
})

expect(genericEventsAggregate.events.buffer[0]).toMatchObject({
test('should aggregate user actions when matching target', () => {
getInfo(agentSetup.agentIdentifier).jsAttributes = { globalFoo: 'globalBar' }
const target = document.createElement('button')
target.id = 'myBtn'
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 100, type: 'click', target }])
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 200, type: 'click', target }])
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 300, type: 'click', target }])
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 400, type: 'click', target }])
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 500, type: 'click', target }])
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 600, type: 'click', target }])
// blur event to trigger aggregation to stop and add to harvest buffer
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 234567, type: 'blur', target: window }])

const harvest = genericEventsAggregate.onHarvestStarted({ isFinalHarvest: true }) // force it to put the aggregation into the event buffer
expect(harvest.body.ins[0]).toMatchObject({
eventType: 'UserAction',
timestamp: expect.any(Number),
action: 'click',
actionCount: 6,
duration: 500,
target: 'button#myBtn:nth-of-type(1)',
targetId: 'myBtn',
targetTag: 'BUTTON',
globalFoo: 'globalBar'
})
})
test('should NOT aggregate user actions when targets are not identical', () => {
getInfo(agentSetup.agentIdentifier).jsAttributes = { globalFoo: 'globalBar' }
const target = document.createElement('button')
target.id = 'myBtn'
document.body.appendChild(target)
const target2 = document.createElement('button')
target2.id = 'myBtn'
document.body.appendChild(target2)
/** 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 */
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 100, type: 'click', target }])
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 200, type: 'click', target: target2 }])
// blur event to trigger aggregation to stop and add to harvest buffer
genericEventsAggregate.ee.emit('ua', [{ timeStamp: 234567, type: 'blur', target: window }])

const harvest = genericEventsAggregate.onHarvestStarted({ isFinalHarvest: true }) // force it to put the aggregation into the event buffer
expect(harvest.body.ins[0]).toMatchObject({
eventType: 'UserAction',
timestamp: expect.any(Number),
action: 'click',
actionCount: 1,
duration: 0,
target: 'html>body>button#myBtn:nth-of-type(1)',
targetId: 'myBtn',
targetTag: 'BUTTON',
globalFoo: 'globalBar'
})
expect(harvest.body.ins[1]).toMatchObject({
eventType: 'UserAction',
timestamp: expect.any(Number),
action: 'click',
actionCount: 1,
duration: 0,
target: 'html>body>button#myBtn:nth-of-type(2)',
targetId: 'myBtn',
targetTag: 'button',
targetTag: 'BUTTON',
globalFoo: 'globalBar'
})
})
Expand Down
Loading

0 comments on commit b5e4c6d

Please sign in to comment.