Skip to content

Commit d410937

Browse files
feat: Capture Nearest UserAction Fields (#1267)
1 parent 98ffff6 commit d410937

File tree

16 files changed

+103
-44
lines changed

16 files changed

+103
-44
lines changed

.github/actions/build-ab/templates/experiments.js

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ window.NREUM.init.proxy = {} // Proxy won't work for experiments
77
window.NREUM.init.session_replay.enabled = false // disabled for now to not double wrap the page which can cause extra processing burden
88
window.NREUM.init.session_trace.enabled = false // disabled for now to not double wrap the page which can cause extra processing burden
99
window.NREUM.init.feature_flags = ['ajax_metrics_deny_list','soft_nav']
10+
window.NREUM.init.user_actions = {elementAttributes: ['id', 'className', 'tagName', 'type', 'innerText', 'textContent', 'ariaLabel', 'alt', 'title']}
1011

1112
{{#if experimentScripts}}
1213
{{#each experimentScripts}}

.github/actions/build-ab/templates/latest.js

+1
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ window.NREUM.init.proxy.assets = 'https://staging-js-agent.newrelic.com/dev'
77
window.NREUM.init.feature_flags = ['soft_nav','ajax_metrics_deny_list']
88
window.NREUM.init.session_replay.enabled = false // disabled for now to not double wrap the page which can cause extra processing burden
99
window.NREUM.init.session_trace.enabled = false // disabled for now to not double wrap the page which can cause extra processing burden
10+
window.NREUM.init.user_actions = {elementAttributes: ['id', 'className', 'tagName', 'type', 'innerText', 'textContent', 'ariaLabel', 'alt', 'title']}
1011

1112
{{{latestScript}}}

.github/actions/build-ab/templates/postamble.js

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ window.NREUM.info.licenseKey = '{{{args.licenseKey}}}'
77
window.NREUM.init.proxy = {}
88
window.NREUM.init.session_replay.enabled = true
99
window.NREUM.init.session_trace.enabled = true
10+
window.NREUM.init.user_actions = {elementAttributes: ['id', 'className', 'tagName', 'type', 'innerText', 'textContent', 'ariaLabel', 'alt', 'title']}
1011

1112
// Session replay entitlements check
1213
try {

.github/actions/build-ab/templates/released.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ window.NREUM={
3333
first_party_domains: ['dev-one.nr-assets.net', 'staging-one.nr-assets.net', 'one.nr-assets.net', 'nr-assets.net']
3434
}
3535
},
36-
proxy: {}
36+
proxy: {},
37+
user_actions: {elementAttributes: ['id', 'className', 'tagName', 'type', 'innerText', 'textContent', 'ariaLabel', 'alt', 'title']}
3738
},
3839
loader_config: {
3940
accountID: '1',

.github/workflows/publish-experiment.yml

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ jobs:
2626
shell: bash
2727
steps:
2828
- uses: actions/checkout@v4
29+
with:
30+
ref: ${{ github.ref }}
2931
- uses: actions/setup-node@v4
3032
with:
3133
node-version: 22.11.0 # See package.json for the stable node version that works with our testing. Do not change this unless you know what you are doing as some node versions do not play nicely with our testing server.
@@ -84,6 +86,8 @@ jobs:
8486
shell: bash
8587
steps:
8688
- uses: actions/checkout@v4
89+
with:
90+
ref: ${{ github.ref }}
8791
- uses: actions/setup-node@v4
8892
with:
8993
node-version: 22.11.0 # See package.json for the stable node version that works with our testing. Do not change this unless you know what you are doing as some node versions do not play nicely with our testing server.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@
172172
"start": "npm-run-all --parallel cdn:watch test-server",
173173
"lint": "eslint -c .eslintrc.js --ext .js,.cjs,.mjs .",
174174
"lint:fix": "npm run lint -- --fix",
175-
"test": "jest",
175+
"test": " NODE_OPTIONS=--max-old-space-size=8192 jest",
176176
"test:unit": "jest --selectProjects unit",
177177
"test:component": "jest --selectProjects component",
178178
"test:types": "tsd -f ./tests/dts/**/*.ts",

src/common/config/init.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ const model = () => {
131131
soft_navigations: { enabled: true, autoStart: true },
132132
spa: { enabled: true, autoStart: true },
133133
ssl: undefined,
134-
user_actions: { enabled: true }
134+
user_actions: { enabled: true, elementAttributes: ['id', 'className', 'tagName', 'type'] }
135135
}
136136
}
137137

src/common/dom/selector-path.js

+15-3
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
* @param {boolean} includeClass
1111
* @returns {string|undefined}
1212
*/
13-
export const generateSelectorPath = (elem) => {
14-
if (!elem) return
13+
export const generateSelectorPath = (elem, targetFields = []) => {
14+
if (!elem) return { path: undefined, nearestFields: {} }
1515

1616
const getNthOfTypeIndex = (node) => {
1717
try {
@@ -30,9 +30,13 @@ export const generateSelectorPath = (elem) => {
3030
let pathSelector = ''
3131
let index = getNthOfTypeIndex(elem)
3232

33+
const nearestFields = {}
3334
try {
3435
while (elem?.tagName) {
3536
const { id, localName } = elem
37+
targetFields.forEach(field => {
38+
nearestFields[nearestAttrName(field)] ||= elem[field]
39+
})
3640
const selector = [
3741
localName,
3842
id ? `#${id}` : '',
@@ -46,5 +50,13 @@ export const generateSelectorPath = (elem) => {
4650
// do nothing for now
4751
}
4852

49-
return pathSelector ? index ? `${pathSelector}:nth-of-type(${index})` : pathSelector : undefined
53+
const path = pathSelector ? index ? `${pathSelector}:nth-of-type(${index})` : pathSelector : undefined
54+
return { path, nearestFields }
55+
56+
function nearestAttrName (originalFieldName) {
57+
/** preserve original renaming structure for pre-existing field maps */
58+
if (originalFieldName === 'tagName') originalFieldName = 'tag'
59+
if (originalFieldName === 'className') originalFieldName = 'class'
60+
return `nearest${originalFieldName.charAt(0).toUpperCase() + originalFieldName.slice(1)}`
61+
}
5062
}

src/features/generic_events/aggregate/index.js

+21-6
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export class Aggregate extends AggregateBase {
6060
}, this.featureName, this.ee)
6161
}
6262

63-
let addUserAction
63+
let addUserAction = () => { /** no-op */ }
6464
if (isBrowserScope && agentRef.init.user_actions.enabled) {
6565
this.userActionAggregator = new UserActionsAggregator()
6666
this.harvestOpts.beforeUnload = () => addUserAction?.(this.userActionAggregator.aggregationEvent)
@@ -81,12 +81,27 @@ export class Aggregate extends AggregateBase {
8181
rageClick: aggregatedUserAction.rageClick,
8282
target: aggregatedUserAction.selectorPath,
8383
...(isIFrameWindow(window) && { iframe: true }),
84-
...(canTrustTargetAttribute('id') && { targetId: target.id }),
85-
...(canTrustTargetAttribute('tagName') && { targetTag: target.tagName }),
86-
...(canTrustTargetAttribute('type') && { targetType: target.type }),
87-
...(canTrustTargetAttribute('className') && { targetClass: target.className })
84+
...(this.agentRef.init.user_actions.elementAttributes.reduce((acc, field) => {
85+
/** prevent us from capturing an obscenely long value */
86+
if (canTrustTargetAttribute(field)) acc[targetAttrName(field)] = String(target[field]).trim().slice(0, 128)
87+
return acc
88+
}, {})),
89+
...aggregatedUserAction.nearestTargetFields
8890
})
8991

92+
/**
93+
* Returns the original target field name with `target` prepended and camelCased
94+
* @param {string} originalFieldName
95+
* @returns {string} the target field name
96+
*/
97+
function targetAttrName (originalFieldName) {
98+
/** preserve original renaming structure for pre-existing field maps */
99+
if (originalFieldName === 'tagName') originalFieldName = 'tag'
100+
if (originalFieldName === 'className') originalFieldName = 'class'
101+
/** return the original field name, cap'd and prepended with target to match formatting */
102+
return `target${originalFieldName.charAt(0).toUpperCase() + originalFieldName.slice(1)}`
103+
}
104+
90105
/**
91106
* Only trust attributes that exist on HTML element targets, which excludes the window and the document targets
92107
* @param {string} attribute The attribute to check for on the target element
@@ -103,7 +118,7 @@ export class Aggregate extends AggregateBase {
103118

104119
registerHandler('ua', (evt) => {
105120
/** the processor will return the previously aggregated event if it has been completed by processing the current event */
106-
addUserAction(this.userActionAggregator.process(evt))
121+
addUserAction(this.userActionAggregator.process(evt, this.agentRef.init.user_actions.elementAttributes))
107122
}, this.featureName, this.ee)
108123
}
109124

src/features/generic_events/aggregate/user-actions/aggregated-user-action.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
import { RAGE_CLICK_THRESHOLD_EVENTS, RAGE_CLICK_THRESHOLD_MS } from '../../constants'
66

77
export class AggregatedUserAction {
8-
constructor (evt, selectorPath) {
8+
constructor (evt, selectorPath, nearestTargetFields) {
99
this.event = evt
1010
this.count = 1
1111
this.originMs = Math.floor(evt.timeStamp)
1212
this.relativeMs = [0]
1313
this.selectorPath = selectorPath
1414
this.rageClick = undefined
15+
this.nearestTargetFields = nearestTargetFields
1516
}
1617

1718
/**

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

+11-7
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ export class UserActionsAggregator {
2626
* @param {Event} evt The event supplied by the addEventListener callback
2727
* @returns {AggregatedUserAction|undefined} The previous aggregation set if it has been completed by processing the current event
2828
*/
29-
process (evt) {
29+
process (evt, targetFields) {
3030
if (!evt) return
31-
const selectorPath = getSelectorPath(evt)
31+
const { selectorPath, nearestTargetFields } = getSelectorPath(evt, targetFields)
3232
const aggregationKey = getAggregationKey(evt, selectorPath)
3333
if (!!aggregationKey && aggregationKey === this.#aggregationKey) {
3434
// an aggregation exists already, so lets just continue to increment
@@ -38,7 +38,7 @@ export class UserActionsAggregator {
3838
const finishedEvent = this.#aggregationEvent
3939
// then set as this new event aggregation
4040
this.#aggregationKey = aggregationKey
41-
this.#aggregationEvent = new AggregatedUserAction(evt, selectorPath)
41+
this.#aggregationEvent = new AggregatedUserAction(evt, selectorPath, nearestTargetFields)
4242
return finishedEvent
4343
}
4444
}
@@ -50,14 +50,18 @@ export class UserActionsAggregator {
5050
* @param {Event} evt
5151
* @returns {string}
5252
*/
53-
function getSelectorPath (evt) {
54-
let selectorPath
53+
function getSelectorPath (evt, targetFields) {
54+
let selectorPath; let nearestTargetFields = {}
5555
if (OBSERVED_WINDOW_EVENTS.includes(evt.type) || evt.target === window) selectorPath = 'window'
5656
else if (evt.target === document) selectorPath = 'document'
5757
// if still no selectorPath, generate one from target tree that includes elem ids
58-
else selectorPath = generateSelectorPath(evt.target)
58+
else {
59+
const { path, nearestFields } = generateSelectorPath(evt.target, targetFields)
60+
selectorPath = path
61+
nearestTargetFields = nearestFields
62+
}
5963
// if STILL no selectorPath, it will return undefined which will skip aggregation for this event
60-
return selectorPath
64+
return { selectorPath, nearestTargetFields }
6165
}
6266

6367
/**

tests/assets/user-actions.html

+6-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
{loader}
1919
</head>
2020
<body style="height: 200vh; overflow: scroll;">
21-
<button id="pay-btn" class="btn-cart-add flex-grow container" type="submit">Create click user action</button>
22-
<input type="text" id="textbox"/>
21+
<div aria-label="Pay submit button">
22+
<button style="padding: 20px;" id="pay-btn" class="btn-cart-add flex-grow container" type="submit">
23+
<span>Create click user action</span>
24+
</button>
25+
<input type="text" id="textbox"/>
26+
</div>
2327
</body>
2428
</html>

tests/specs/ins/harvesting.e2e.js

+13-12
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,14 @@ describe('ins harvesting', () => {
8181

8282
const [insHarvests] = await Promise.all([
8383
insightsCapture.waitForResult({ timeout: 7500 }),
84-
$('#pay-btn').click().then(async () => {
84+
$('#textbox').click().then(async () => {
8585
// rage click
8686
await browser.execute(function () {
8787
for (let i = 0; i < 5; i++) {
88-
document.querySelector('#textbox').click()
88+
document.querySelector('span').click()
8989
}
9090
})
91-
// stop aggregating textbox clicks
91+
// stop aggregating clicks
9292
await $('body').click()
9393
})
9494
])
@@ -102,11 +102,10 @@ describe('ins harvesting', () => {
102102
actionCount: 1,
103103
actionDuration: 0,
104104
actionMs: '[0]',
105-
target: 'html>body>button#pay-btn:nth-of-type(1)',
106-
targetId: 'pay-btn',
107-
targetTag: 'BUTTON',
108-
targetType: 'submit',
109-
targetClass: 'btn-cart-add flex-grow container',
105+
target: 'html>body>div>input#textbox:nth-of-type(1)',
106+
targetId: 'textbox',
107+
targetTag: 'INPUT',
108+
targetType: 'text',
110109
pageUrl: expect.any(String),
111110
timestamp: expect.any(Number)
112111
})
@@ -116,11 +115,13 @@ describe('ins harvesting', () => {
116115
actionCount: 5,
117116
actionDuration: expect.any(Number),
118117
actionMs: expect.any(String),
118+
nearestId: 'pay-btn',
119+
nearestTag: 'SPAN',
120+
nearestType: 'submit',
121+
nearestClass: 'btn-cart-add flex-grow container',
119122
rageClick: true,
120-
target: 'html>body>input#textbox:nth-of-type(1)',
121-
targetId: 'textbox',
122-
targetTag: 'INPUT',
123-
targetType: 'text',
123+
target: 'html>body>div>button#pay-btn>span:nth-of-type(1)',
124+
targetTag: 'SPAN',
124125
pageUrl: expect.any(String),
125126
timestamp: expect.any(Number)
126127
})

tests/unit/common/config/init.test.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ test('init props exist and return expected defaults', () => {
7373
enabled: true
7474
})
7575
expect(config.user_actions).toEqual({
76-
enabled: true
76+
enabled: true,
77+
elementAttributes: ['id', 'className', 'tagName', 'type']
7778
})
7879
expect(config.page_view_event).toEqual({
7980
autoStart: true,
@@ -153,7 +154,8 @@ test('init props exist and return expected defaults', () => {
153154
})
154155
expect(config.ssl).toEqual(undefined)
155156
expect(config.user_actions).toEqual({
156-
enabled: true
157+
enabled: true,
158+
elementAttributes: ['id', 'className', 'tagName', 'type']
157159
})
158160
})
159161

+19-7
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import { generateSelectorPath } from '../../../../src/common/dom/selector-path'
22

3+
const targetFields = ['id', 'className', 'tagName', 'type']
4+
35
describe('generateSelectorPath', () => {
46
let span, p
57

68
beforeEach(() => {
79
document.body.innerHTML = `
810
<html>
911
<body>
10-
<div id="container">
12+
<div id="container" class="container">
1113
<div class="child">
1214
<span class="grandchild">Text</span>
1315
</div>
14-
<div class="child">
16+
<div>
1517
<p id="target">Paragraph</p>
1618
</div>
1719
</div>
@@ -24,31 +26,41 @@ describe('generateSelectorPath', () => {
2426
})
2527

2628
test('should generate selector path including id', () => {
27-
const selectorWithId = generateSelectorPath(p)
29+
const { path: selectorWithId } = generateSelectorPath(p, targetFields)
2830
expect(selectorWithId).toBe('html>body>div#container>div>p#target:nth-of-type(1)')
2931

30-
const selectorWithoutId = generateSelectorPath(span)
32+
const { path: selectorWithoutId } = generateSelectorPath(span)
33+
expect(selectorWithoutId).toBe('html>body>div#container>div>span:nth-of-type(1)')
34+
})
35+
36+
test('should generate nearestFields', () => {
37+
// should get the id from <p>, tagName from <p> and class from <div>
38+
const { nearestFields } = generateSelectorPath(p, targetFields)
39+
expect(nearestFields).toMatchObject({ nearestId: 'target', nearestTag: 'P', nearestClass: 'container' })
40+
41+
const { path: selectorWithoutId } = generateSelectorPath(span)
3142
expect(selectorWithoutId).toBe('html>body>div#container>div>span:nth-of-type(1)')
3243
})
3344

3445
test('should return undefined for null element', () => {
35-
const selector = generateSelectorPath(null)
46+
const { path: selector } = generateSelectorPath(null)
3647
expect(selector).toBeUndefined()
3748
})
3849

3950
test('should handle elements with siblings', () => {
4051
const sibling = document.createElement('div')
4152
document.body.appendChild(sibling)
42-
const selector = generateSelectorPath(sibling, { includeId: false, includeClass: false })
53+
const { path: selector } = generateSelectorPath(sibling, targetFields)
4354
expect(selector).toBe('html>body>div:nth-of-type(2)')
4455
document.body.removeChild(sibling)
4556
})
4657

4758
test('should handle elements without siblings', () => {
4859
const singleTable = document.createElement('table')
4960
document.body.appendChild(singleTable)
50-
const selector = generateSelectorPath(singleTable, { includeId: false, includeClass: false })
61+
const { path: selector, nearestFields } = generateSelectorPath(singleTable)
5162
expect(selector).toBe('html>body>table:nth-of-type(1)')
63+
expect(nearestFields).toEqual({})
5264
document.body.removeChild(singleTable)
5365
})
5466
})

tools/testing-server/constants.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -77,5 +77,5 @@ module.exports.defaultInitBlock = {
7777
ssl: false,
7878
soft_navigations: enabledFeature,
7979
spa: enabledFeature,
80-
user_actions: { enabled: true }
80+
user_actions: { enabled: true, elementAttributes: ['id', 'className', 'tagName', 'type'] }
8181
}

0 commit comments

Comments
 (0)