Skip to content

Commit 2c2024e

Browse files
feat: Automatically Detect MFE User Actions (#1723)
1 parent 9874ac5 commit 2c2024e

5 files changed

Lines changed: 201 additions & 47 deletions

File tree

src/common/dom/selector-path.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
/**
2-
* Copyright 2020-2025 New Relic, Inc. All rights reserved.
2+
* Copyright 2020-2026 New Relic, Inc. All rights reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import { getRegisteredTargetsFromId } from '../v2/utils'
7+
68
/**
79
* Generates a CSS selector path for the given element, if possible.
810
* Also gather metadata about the element's nearest fields, and whether there are any links or buttons in the path.
@@ -12,10 +14,11 @@
1214
*
1315
* @param {HTMLElement} elem
1416
* @param {Array<string>} [targetFields=[]] specifies which fields to gather from the nearest element in the path
15-
* @returns {{path: (undefined|string), nearestFields: {}, hasButton: boolean, hasLink: boolean}}
17+
* @returns {{path: (undefined|string), nearestFields: {}, targets: Array, hasButton: boolean, hasLink: boolean}}
1618
*/
17-
export const analyzeElemPath = (elem, targetFields = []) => {
18-
const result = { path: undefined, nearestFields: {}, hasButton: false, hasLink: false }
19+
export const analyzeElemPath = (elem, targetFields = [], agentRef) => {
20+
const targets = []
21+
const result = { path: undefined, nearestFields: {}, get targets () { return targets.length ? targets : [undefined] }, hasButton: false, hasLink: false }
1922
if (!elem) return result
2023
if (elem === window) { result.path = 'window'; return result }
2124
if (elem === document) { result.path = 'document'; return result }
@@ -30,6 +33,12 @@ export const analyzeElemPath = (elem, targetFields = []) => {
3033
result.hasButton ||= tagName === 'button' || (tagName === 'input' && elem.type.toLowerCase() === 'button')
3134

3235
targetFields.forEach(field => { result.nearestFields[nearestAttrName(field)] ||= (elem[field]?.baseVal || elem[field]) })
36+
37+
const dataAttrs = elem?.dataset
38+
if (dataAttrs.nrMfeId) {
39+
targets.push(...getRegisteredTargetsFromId(dataAttrs.nrMfeId, agentRef))
40+
}
41+
3342
pathSelector = buildPathSelector(elem, pathSelector)
3443
elem = elem.parentNode
3544
}

src/features/generic_events/aggregate/index.js

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { applyFnToProps } from '../../../common/util/traverse'
1414
import { UserActionsAggregator } from './user-actions/user-actions-aggregator'
1515
import { isIFrameWindow } from '../../../common/dom/iframe'
1616
import { isPureObject } from '../../../common/util/type-check'
17-
import { getVersion2Attributes } from '../../../common/v2/utils'
17+
import { getVersion2Attributes, getVersion2DuplicationAttributes, shouldDuplicate } from '../../../common/v2/utils'
1818

1919
export class Aggregate extends AggregateBase {
2020
static featureName = FEATURE_NAME
@@ -61,7 +61,7 @@ export class Aggregate extends AggregateBase {
6161

6262
let addUserAction = () => { /** no-op */ }
6363
if (isBrowserScope && agentRef.init.user_actions.enabled) {
64-
this.#userActionAggregator = new UserActionsAggregator()
64+
this.#userActionAggregator = new UserActionsAggregator(this.agentRef)
6565
this.harvestOpts.beforeUnload = () => addUserAction?.(this.#userActionAggregator.aggregationEvent)
6666

6767
addUserAction = (aggregatedUserAction) => {
@@ -70,50 +70,54 @@ export class Aggregate extends AggregateBase {
7070
* so we still need to validate that an event was given to this method before we try to add */
7171
if (aggregatedUserAction?.event) {
7272
const { target, timeStamp, type } = aggregatedUserAction.event
73-
const userActionEvent = {
74-
eventType: 'UserAction',
75-
timestamp: this.#toEpoch(timeStamp),
76-
action: type,
77-
actionCount: aggregatedUserAction.count,
78-
actionDuration: aggregatedUserAction.relativeMs[aggregatedUserAction.relativeMs.length - 1],
79-
actionMs: aggregatedUserAction.relativeMs,
80-
rageClick: aggregatedUserAction.rageClick,
81-
target: aggregatedUserAction.selectorPath,
82-
currentUrl: aggregatedUserAction.currentUrl,
83-
...(isIFrameWindow(window) && { iframe: true }),
84-
...(this.agentRef.init.user_actions.elementAttributes.reduce((acc, field) => {
73+
74+
aggregatedUserAction.targets.forEach(mfeTarget => {
75+
const userActionEvent = {
76+
eventType: 'UserAction',
77+
timestamp: this.#toEpoch(timeStamp),
78+
action: type,
79+
actionCount: aggregatedUserAction.count,
80+
actionDuration: aggregatedUserAction.relativeMs[aggregatedUserAction.relativeMs.length - 1],
81+
actionMs: aggregatedUserAction.relativeMs,
82+
rageClick: aggregatedUserAction.rageClick,
83+
target: aggregatedUserAction.selectorPath,
84+
currentUrl: aggregatedUserAction.currentUrl,
85+
...(isIFrameWindow(window) && { iframe: true }),
86+
...(this.agentRef.init.user_actions.elementAttributes.reduce((acc, field) => {
8587
/** 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,
90-
...(aggregatedUserAction.deadClick && { deadClick: true }),
91-
...(aggregatedUserAction.errorClick && { errorClick: true })
92-
}
93-
this.addEvent(userActionEvent)
94-
this.#trackUserActionSM(userActionEvent)
95-
96-
/**
88+
if (canTrustTargetAttribute(field)) acc[targetAttrName(field)] = String(target[field]).trim().slice(0, 128)
89+
return acc
90+
}, {})),
91+
...aggregatedUserAction.nearestTargetFields,
92+
...(aggregatedUserAction.deadClick && { deadClick: true }),
93+
...(aggregatedUserAction.errorClick && { errorClick: true })
94+
}
95+
this.addEvent(userActionEvent, mfeTarget)
96+
97+
this.#trackUserActionSM(userActionEvent)
98+
99+
/**
97100
* Returns the original target field name with `target` prepended and camelCased
98101
* @param {string} originalFieldName
99102
* @returns {string} the target field name
100103
*/
101-
function targetAttrName (originalFieldName) {
104+
function targetAttrName (originalFieldName) {
102105
/** preserve original renaming structure for pre-existing field maps */
103-
if (originalFieldName === 'tagName') originalFieldName = 'tag'
104-
if (originalFieldName === 'className') originalFieldName = 'class'
105-
/** return the original field name, cap'd and prepended with target to match formatting */
106-
return `target${originalFieldName.charAt(0).toUpperCase() + originalFieldName.slice(1)}`
107-
}
106+
if (originalFieldName === 'tagName') originalFieldName = 'tag'
107+
if (originalFieldName === 'className') originalFieldName = 'class'
108+
/** return the original field name, cap'd and prepended with target to match formatting */
109+
return `target${originalFieldName.charAt(0).toUpperCase() + originalFieldName.slice(1)}`
110+
}
108111

109-
/**
112+
/**
110113
* Only trust attributes that exist on HTML element targets, which excludes the window and the document targets
111114
* @param {string} attribute The attribute to check for on the target element
112115
* @returns {boolean} Whether the target element has the attribute and can be trusted
113116
*/
114-
function canTrustTargetAttribute (attribute) {
115-
return !!(aggregatedUserAction.selectorPath !== 'window' && aggregatedUserAction.selectorPath !== 'document' && target instanceof HTMLElement && target?.[attribute])
116-
}
117+
function canTrustTargetAttribute (attribute) {
118+
return !!(aggregatedUserAction.selectorPath !== 'window' && aggregatedUserAction.selectorPath !== 'document' && target instanceof HTMLElement && target?.[attribute])
119+
}
120+
})
117121
}
118122
} catch (e) {
119123
// do nothing for now
@@ -314,9 +318,7 @@ export class Aggregate extends AggregateBase {
314318
timestamp: this.#toEpoch(now()),
315319
/** all generic events require pageUrl(s) */
316320
pageUrl: cleanURL('' + initialLocation),
317-
currentUrl: cleanURL('' + location),
318-
/** Specific attributes only supplied if harvesting to endpoint version 2 */
319-
...(getVersion2Attributes(target, this))
321+
currentUrl: cleanURL('' + location)
320322
}
321323

322324
const eventAttributes = {
@@ -328,7 +330,8 @@ export class Aggregate extends AggregateBase {
328330
...obj
329331
}
330332

331-
this.events.add(eventAttributes)
333+
this.events.add({ ...eventAttributes, ...getVersion2Attributes(target, this) })
334+
if (shouldDuplicate(target, this.agentRef)) this.addEvent({ ...eventAttributes, ...getVersion2DuplicationAttributes(target, this) })
332335
}
333336

334337
serializer (eventBuffer) {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright 2020-2025 New Relic, Inc. All rights reserved.
2+
* Copyright 2020-2026 New Relic, Inc. All rights reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
import { RAGE_CLICK_THRESHOLD_EVENTS, RAGE_CLICK_THRESHOLD_MS } from '../../constants'
@@ -17,6 +17,7 @@ export class AggregatedUserAction {
1717
this.currentUrl = cleanURL('' + location)
1818
this.deadClick = false
1919
this.errorClick = false
20+
this.targets = selectorInfo.targets
2021
}
2122

2223
/**

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright 2020-2025 New Relic, Inc. All rights reserved.
2+
* Copyright 2020-2026 New Relic, Inc. All rights reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
import { analyzeElemPath } from '../../../../common/dom/selector-path'
@@ -16,7 +16,8 @@ export class UserActionsAggregator {
1616
#domObserver = undefined
1717
#errorClickTimer = undefined
1818

19-
constructor () {
19+
constructor (agentRef) {
20+
this.agentRef = agentRef
2021
if (gosNREUMOriginals().o.MO) {
2122
this.#domObserver = new MutationObserver(this.isLiveClick.bind(this))
2223
}
@@ -40,7 +41,7 @@ export class UserActionsAggregator {
4041
process (evt, targetFields) {
4142
if (!evt) return
4243
const targetElem = OBSERVED_WINDOW_EVENTS.includes(evt.type) ? window : evt.target
43-
const selectorInfo = analyzeElemPath(targetElem, targetFields)
44+
const selectorInfo = analyzeElemPath(targetElem, targetFields, this.agentRef)
4445

4546
// if selectorInfo.path is undefined, aggregation will be skipped for this event
4647
const aggregationKey = getAggregationKey(evt, selectorInfo.path)
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import {
2+
testMFEInsRequest
3+
} from '../../../../tools/testing-server/utils/expect-tests'
4+
5+
describe('Register API - Auto-Detection - User Actions', () => {
6+
beforeEach(async () => {
7+
await browser.enableLogging()
8+
})
9+
10+
afterEach(async () => {
11+
await browser.destroyAgentSession()
12+
})
13+
14+
async function interactWithPage () {
15+
// Click the button in the main MFE (id: vite-main-mfe)
16+
const button = await $('#mfe-main-button')
17+
await button.click().catch(() => {})
18+
19+
// Click the div created by 2nd-mfe (id: vite-second-mfe)
20+
const secondMfeDiv = await $('#second-mfe-div')
21+
await secondMfeDiv.click()
22+
23+
// Wait for lazy content to load, then click it
24+
async function clickLazyButton () {
25+
const lazyButton = await $('#lazy-button')
26+
if (await lazyButton.isExisting()) {
27+
await lazyButton.click()
28+
} else {
29+
await browser.pause(500)
30+
await clickLazyButton()
31+
}
32+
}
33+
await clickLazyButton()
34+
await browser.pause(1000)
35+
await browser.refresh() // force any pending useractions or bIxn requests to release and harvest
36+
}
37+
38+
it('should auto-detect multiple MFEs from different script sources for UserAction events', async () => {
39+
const [insCapture] = await browser.testHandle.createNetworkCaptures('bamServer', [
40+
{ test: testMFEInsRequest }
41+
])
42+
43+
await browser.url(await browser.testHandle.assetURL('test-builds/vite-react-mfe/index.html', {
44+
init: {
45+
api: {
46+
allow_registered_children: true
47+
},
48+
logging: {
49+
enabled: true
50+
}
51+
}
52+
}))
53+
54+
await browser.waitForAgentLoad()
55+
56+
// trigger all the events
57+
await interactWithPage()
58+
59+
const [insHarvests] = await Promise.all([
60+
insCapture.waitForResult({ timeout: 10000 })
61+
])
62+
63+
// Verify UserAction events from both MFEs (INS endpoint)
64+
const allUserActions = insHarvests.flatMap(harvest => (harvest.request.body.ins || []))
65+
expect(allUserActions.length).toBeGreaterThan(0)
66+
67+
// 2nd-mfe UserAction
68+
const secondMfeUserAction = allUserActions.find(e => e['source.id'] === 'vite-second-mfe' && e.eventType === 'UserAction')
69+
expect(secondMfeUserAction).toBeDefined()
70+
expect(secondMfeUserAction['source.name']).toEqual('2nd-mfe')
71+
expect(secondMfeUserAction['source.type']).toEqual('MFE')
72+
73+
// main MFE UserAction
74+
const mainMfeUserAction = allUserActions.find(e => e['source.id'] === 'vite-main-mfe' && e.eventType === 'UserAction')
75+
expect(mainMfeUserAction).toBeDefined()
76+
expect(mainMfeUserAction['source.name']).toEqual('Main MFE')
77+
expect(mainMfeUserAction['source.type']).toEqual('MFE')
78+
79+
// lazy loaded UserAction (should also report as vite-main-mfe)
80+
const lazyUserAction = allUserActions.find(e => e['source.id'] === 'vite-main-mfe' && e.eventType === 'UserAction' && e.targetId === 'lazy-button')
81+
expect(lazyUserAction).toBeDefined()
82+
expect(lazyUserAction['source.name']).toEqual('Main MFE')
83+
expect(lazyUserAction['source.type']).toEqual('MFE')
84+
})
85+
86+
it('should support duplicate_registered_data with auto-detection for UserAction events', async () => {
87+
const [mfeInsCapture] = await browser.testHandle.createNetworkCaptures('bamServer', [
88+
{ test: testMFEInsRequest }
89+
])
90+
91+
await browser.url(await browser.testHandle.assetURL('test-builds/vite-react-mfe/index.html', {
92+
init: {
93+
api: {
94+
allow_registered_children: true,
95+
duplicate_registered_data: true
96+
}
97+
},
98+
loader: 'spa'
99+
}))
100+
101+
await browser.waitForAgentLoad()
102+
103+
// trigger all the events
104+
await interactWithPage()
105+
106+
// Verify MFE events exist at /events/2/ endpoint
107+
const [mfeInsHarvests] = await Promise.all([
108+
mfeInsCapture.waitForResult({ timeout: 10000 })
109+
])
110+
111+
// MFE UserAction events (/ins/2/)
112+
const allMfeUserActions = mfeInsHarvests.flatMap(harvest => (harvest.request.body.ins || []))
113+
expect(allMfeUserActions.length).toBeGreaterThan(0)
114+
115+
// UserAction duplication
116+
const secondMfeUserAction = allMfeUserActions.find(e => e['source.id'] === 'vite-second-mfe' && e.eventType === 'UserAction')
117+
expect(secondMfeUserAction).toBeDefined()
118+
expect(secondMfeUserAction['source.name']).toEqual('2nd-mfe')
119+
expect(secondMfeUserAction['source.type']).toEqual('MFE')
120+
const duplicatedSecondMfeUserAction = allMfeUserActions.find(e => e['child.id'] === 'vite-second-mfe' && e.eventType === 'UserAction')
121+
expect(duplicatedSecondMfeUserAction['entity.guid']).toBeDefined()
122+
expect(duplicatedSecondMfeUserAction['child.type']).toEqual('MFE')
123+
124+
const mainMfeUserAction = allMfeUserActions.find(e => e['source.id'] === 'vite-main-mfe' && e.eventType === 'UserAction')
125+
expect(mainMfeUserAction).toBeDefined()
126+
expect(mainMfeUserAction['source.name']).toEqual('Main MFE')
127+
expect(mainMfeUserAction['source.type']).toEqual('MFE')
128+
const duplicatedMainMfeUserAction = allMfeUserActions.find(e => e['child.id'] === 'vite-main-mfe' && e.eventType === 'UserAction')
129+
expect(duplicatedMainMfeUserAction['entity.guid']).toBeDefined()
130+
expect(duplicatedMainMfeUserAction['child.type']).toEqual('MFE')
131+
132+
const lazyUserAction = allMfeUserActions.find(e => e['source.id'] === 'vite-main-mfe' && e.eventType === 'UserAction' && e.targetId === 'lazy-button')
133+
expect(lazyUserAction).toBeDefined()
134+
expect(lazyUserAction['source.name']).toEqual('Main MFE')
135+
expect(lazyUserAction['source.type']).toEqual('MFE')
136+
const duplicatedLazyUserAction = allMfeUserActions.find(e => e['child.id'] === 'vite-main-mfe' && e.eventType === 'UserAction' && e.targetId === 'lazy-button')
137+
expect(duplicatedLazyUserAction['entity.guid']).toBeDefined()
138+
expect(duplicatedLazyUserAction['child.type']).toEqual('MFE')
139+
})
140+
})

0 commit comments

Comments
 (0)