Skip to content

Commit ed8e90c

Browse files
metal-messiahptang-nrcwli24
authored
feat: Automatically Detect MFE AJAX (#1722)
Co-authored-by: ptang-nr <ptang@newrelic.com> Co-authored-by: Chunwai Li <cli@newrelic.com>
1 parent a2d7408 commit ed8e90c

45 files changed

Lines changed: 1534 additions & 1443 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/publish-experiment.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959
window.NREUM.init.proxy = {} // Proxy won't work for experiments
6060
window.NREUM.init.session_replay.enabled = true
6161
window.NREUM.init.session_trace.enabled = true
62-
window.NREUM.init.feature_flags = ['ajax_metrics_deny_list']
62+
window.NREUM.init.feature_flags = ['ajax_metrics_deny_list', 'register']
6363
window.NREUM.init.user_actions = {elementAttributes: ['id', 'className', 'tagName', 'type', 'ariaLabel', 'alt', 'title']}
6464
EOF
6565
- name: Upload experiment
Lines changed: 4 additions & 5 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 { FEATURE_NAMES } from '../../loaders/features/features'
@@ -11,8 +11,7 @@ export const SESSION_ERROR = 'SESSION_ERROR'
1111

1212
export const SUPPORTS_REGISTERED_ENTITIES = {
1313
[FEATURE_NAMES.logging]: true,
14-
// flip other features here when they are supported by DEM consumers
15-
[FEATURE_NAMES.genericEvents]: false,
16-
[FEATURE_NAMES.jserrors]: false,
17-
[FEATURE_NAMES.ajax]: false
14+
[FEATURE_NAMES.genericEvents]: true,
15+
[FEATURE_NAMES.jserrors]: true,
16+
[FEATURE_NAMES.ajax]: true
1817
}

src/common/util/script-tracker.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ if (globalScope.PerformanceObserver?.supportedEntryTypes.includes('resource')) {
5454
* @param {string} stack The error stack trace
5555
* @returns {string[]} Array of cleaned URLs found in the stack trace
5656
*/
57-
function extractUrlsFromStack (stack) {
57+
export function extractUrlsFromStack (stack) {
5858
if (!stack || typeof stack !== 'string') return []
5959

6060
const urls = new Set()
@@ -80,7 +80,7 @@ function extractUrlsFromStack (stack) {
8080
* Returns a deep stack trace by temporarily increasing the stack trace limit.
8181
* @returns {Error.stack | undefined}
8282
*/
83-
function getDeepStackTrace () {
83+
export function getDeepStackTrace () {
8484
let stack
8585
try {
8686
const originalStackLimit = Error.stackTraceLimit

src/common/util/v2.js

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import { extractUrlsFromStack, getDeepStackTrace } from './script-tracker'
7+
68
/**
79
* @enum {string}
810
* @readonly
@@ -14,6 +16,30 @@ export const V2_TYPES = {
1416
BA: 'BA'
1517
}
1618

19+
/**
20+
* Returns the registered target associated with a given ID. Returns undefined if not found.
21+
* @param {string|number} id
22+
* @param {*} agentRef the agent reference
23+
* @returns {import("../../interfaces/registered-entity").RegisterAPIMetadataTarget[]}
24+
*/
25+
export function getRegisteredTargetsFromId (id, agentRef) {
26+
if (!id || !agentRef?.init.api.allow_registered_children) return []
27+
const registeredEntities = agentRef.runtime.registeredEntities
28+
return registeredEntities?.filter(entity => String(entity.metadata.target.id) === String(id)).map(entity => entity.metadata.target) || []
29+
}
30+
31+
/**
32+
* Returns the registered target(s) associated with a given filename if found in the resource timing API during registration. Returns an empty array if not found.
33+
* @param {string} filename
34+
* @param {*} agentRef
35+
* @returns {import("../../interfaces/registered-entity").RegisterAPIMetadataTarget[]}
36+
*/
37+
export function getRegisteredTargetsFromFilename (filename, agentRef) {
38+
if (!filename || !agentRef?.init.api.allow_registered_children) return []
39+
const registeredEntities = agentRef.runtime.registeredEntities
40+
return registeredEntities?.filter(entity => entity.metadata.timings?.asset?.endsWith(filename)).map(entity => entity.metadata.target) || []
41+
}
42+
1743
/**
1844
* When given a valid target, returns an object with the V2 payload attributes. Returns an empty object otherwise.
1945
* @note Field names may change as the schema is finalized
@@ -33,11 +59,36 @@ export function getVersion2Attributes (target, aggregateInstance) {
3359
}
3460
}
3561
/** otherwise, the data belongs to the target (MFE) and should be attributed as such */
36-
return {
37-
'source.id': target.id,
38-
'source.name': target.name,
39-
'source.type': target.type,
40-
'parent.id': target.parent?.id || containerAgentEntityGuid,
41-
'parent.type': target.parent?.type || V2_TYPES.BA
62+
return target.attributes
63+
}
64+
65+
/**
66+
* Returns the attributes used for duplicating data in version 2 of the harvest endpoint. If not valid for duplication, returns an empty object.
67+
* @param {import("../../interfaces/registered-entity").RegisterAPIMetadataTarget} target
68+
* @param {*} aggregateInstance the aggregate instance calling the method
69+
* @returns {Object}
70+
*/
71+
export function getVersion2DuplicationAttributes (target, aggregateInstance) {
72+
if (aggregateInstance?.harvestEndpointVersion !== 2 || !shouldDuplicate(target, aggregateInstance?.agentRef)) return {}
73+
return { 'child.id': target.id, 'child.type': target.type, ...getVersion2Attributes(undefined, aggregateInstance) }
74+
}
75+
76+
export function shouldDuplicate (target, agentRef) {
77+
return !!target && agentRef.init.api.duplicate_registered_data
78+
}
79+
80+
export function findTargetsFromStackTrace (agentRef) {
81+
if (!agentRef?.init.api.allow_registered_children) return []
82+
83+
const targets = []
84+
try {
85+
var urls = extractUrlsFromStack(getDeepStackTrace())
86+
let iterator = urls.length - 1
87+
while (urls[iterator]) {
88+
targets.push(...getRegisteredTargetsFromFilename(urls[iterator--], agentRef))
89+
}
90+
} catch (err) {
91+
// Silent catch to prevent errors from propagating
4292
}
93+
return targets
4394
}

src/common/wrap/wrap-fetch.js

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010
import { ee as baseEE, contextId } from '../event-emitter/contextual-ee'
1111
import { globalScope } from '../constants/runtime'
12+
import { findTargetsFromStackTrace } from '../util/v2'
1213

1314
var prefix = 'fetch-'
1415
var bodyPrefix = prefix + 'body-'
@@ -27,7 +28,7 @@ const wrapped = {}
2728
* event emitter will be based.
2829
* @returns {Object} Scoped event emitter with a debug ID of `fetch`.
2930
*/
30-
export function wrapFetch (sharedEE) {
31+
export function wrapFetch (sharedEE, agentRef) {
3132
const ee = scopedEE(sharedEE)
3233
if (!(Req && Res && globalScope.fetch)) {
3334
return ee
@@ -44,13 +45,16 @@ export function wrapFetch (sharedEE) {
4445
})
4546
wrapPromiseMethod(globalScope, 'fetch', prefix)
4647

47-
ee.on(prefix + 'end', function (err, res) {
48+
ee.on(prefix + 'end', function (err, res, targets) {
4849
var ctx = this
50+
// undefined target reports to container
51+
ctx.targets = targets || [undefined]
4952
if (res) {
5053
var size = res.headers.get('content-length')
5154
if (size !== null) {
5255
ctx.rxSize = size
5356
}
57+
5458
ee.emit(prefix + 'done', [null, res], ctx)
5559
} else {
5660
ee.emit(prefix + 'done', [err], ctx)
@@ -67,11 +71,16 @@ export function wrapFetch (sharedEE) {
6771
*/
6872
function wrapPromiseMethod (target, name, prefix) {
6973
var fn = target[name]
74+
7075
if (typeof fn === 'function') {
7176
target[name] = function () {
7277
var args = [...arguments]
7378

74-
var ctx = {}
79+
const ctx = {}
80+
const targets = findTargetsFromStackTrace(agentRef)
81+
// undefined target reports to container
82+
if (!targets.length) targets.push(undefined)
83+
7584
// we are wrapping args in an array so we can preserve the reference
7685
ee.emit(prefix + 'before-start', [args], ctx)
7786
var dtPayload
@@ -83,10 +92,10 @@ export function wrapFetch (sharedEE) {
8392

8493
// Note we need to cast the returned (orig) Promise from native APIs into the current global Promise, which may or may not be our WrappedPromise.
8594
return origPromiseFromFetch.then(function (val) {
86-
ee.emit(prefix + 'end', [null, val], origPromiseFromFetch)
95+
ee.emit(prefix + 'end', [null, val, targets], origPromiseFromFetch)
8796
return val
8897
}, function (err) {
89-
ee.emit(prefix + 'end', [err], origPromiseFromFetch)
98+
ee.emit(prefix + 'end', [err, undefined, targets], origPromiseFromFetch)
9099
throw err
91100
})
92101
}

src/common/wrap/wrap-function.js

Lines changed: 1 addition & 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

src/common/wrap/wrap-xhr.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { eventListenerOpts } from '../event-listener/event-listener-opts'
1414
import { createWrapperWithEmitter as wfn } from './wrap-function'
1515
import { globalScope } from '../constants/runtime'
1616
import { warn } from '../util/console'
17+
import { findTargetsFromStackTrace } from '../util/v2'
1718

1819
const wrapped = {}
1920
const XHR_PROPS = ['open', 'send'] // these are the specific funcs being wrapped on all XMLHttpRequests(.prototype)
@@ -25,7 +26,7 @@ const XHR_PROPS = ['open', 'send'] // these are the specific funcs being wrapped
2526
* @returns {Object} Scoped event emitter with a debug ID of `xhr`.
2627
*/
2728
// eslint-disable-next-line
28-
export function wrapXhr (sharedEE) {
29+
export function wrapXhr (sharedEE, agentRef) {
2930
var baseEE = sharedEE || contextualEE
3031
const ee = scopedEE(baseEE)
3132

@@ -54,6 +55,9 @@ export function wrapXhr (sharedEE) {
5455
function newXHR (opts) {
5556
const xhr = new OrigXHR(opts)
5657
const context = ee.context(xhr)
58+
context.targets = findTargetsFromStackTrace(agentRef)
59+
// undefined target reports to container
60+
if (!context.targets.length) context.targets.push(undefined)
5761

5862
try {
5963
ee.emit('new-xhr', [xhr], context)

src/features/ajax/aggregate/index.js

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { AggregateBase } from '../../utils/aggregate-base'
1212
import { parseGQL } from './gql'
1313
import { nullable, numeric, getAddStringContext, addCustomAttributes } from '../../../common/serialize/bel-serializer'
1414
import { gosNREUMOriginals } from '../../../common/window/nreum'
15+
import { getVersion2Attributes, getVersion2DuplicationAttributes, shouldDuplicate } from '../../../common/util/v2'
1516

1617
export class Aggregate extends AggregateBase {
1718
static featureName = FEATURE_NAME
@@ -30,8 +31,8 @@ export class Aggregate extends AggregateBase {
3031

3132
registerHandler('returnAjax', event => this.events.add(event), this.featureName, this.ee)
3233

33-
registerHandler('xhr', function () { // the EE-drain system not only switches "this" but also passes a new EventContext with info. Should consider platform refactor to another system which passes a mutable context around separately and predictably to avoid problems like this.
34-
classThis.storeXhr(...arguments, this) // this switches the context back to the class instance while passing the NR context as an argument -- see "ctx" in storeXhr
34+
registerHandler('xhr', function (params, metrics, startTime, endTime, type, target) { // the EE-drain system not only switches "this" but also passes a new EventContext with info. Should consider platform refactor to another system which passes a mutable context around separately and predictably to avoid problems like this.
35+
classThis.storeXhr(params, metrics, startTime, endTime, type, target, this) // this switches the context back to the class instance while passing the NR context as an argument -- see "ctx" in storeXhr
3536
}, this.featureName, this.ee)
3637

3738
this.ee.on('long-task', (task, originator) => {
@@ -44,7 +45,7 @@ export class Aggregate extends AggregateBase {
4445
this.waitForFlags(([])).then(() => this.drain())
4546
}
4647

47-
storeXhr (params, metrics, startTime, endTime, type, ctx) {
48+
storeXhr (params, metrics, startTime, endTime, type, target, ctx) {
4849
metrics.time = startTime
4950

5051
// send to session traces
@@ -108,11 +109,21 @@ export class Aggregate extends AggregateBase {
108109
})
109110
if (event.gql) this.reportSupportabilityMetric('Ajax/Events/GraphQL/Bytes-Added', stringify(event.gql).length)
110111

112+
/** make a copy of the event for the MFE target if it exists */
113+
if (target) {
114+
this.events.add({ ...event, targetAttributes: getVersion2Attributes(target, this) })
115+
if (shouldDuplicate(target, this.agentRef)) this.reportContainerEvent({ ...event, targetAttributes: getVersion2DuplicationAttributes(target, this) }, ctx)
116+
} else {
117+
this.reportContainerEvent(event, ctx)
118+
}
119+
}
120+
121+
reportContainerEvent (evt, ctx) {
111122
const softNavInUse = Boolean(this.agentRef.features?.[FEATURE_NAMES.softNav])
112123
if (softNavInUse) { // when SN is running, pass the event w/ info to it for evaluation -- either part of an interaction or is given back
113-
handle('ajax', [event, ctx], undefined, FEATURE_NAMES.softNav, this.ee)
124+
handle('ajax', [evt, ctx], undefined, FEATURE_NAMES.softNav, this.ee)
114125
} else {
115-
this.events.add(event)
126+
this.events.add(evt)
116127
}
117128
}
118129

@@ -154,7 +165,12 @@ export class Aggregate extends AggregateBase {
154165

155166
// add custom attributes
156167
// gql decorators are added as custom attributes to alleviate need for new BEL schema
157-
const attrParts = addCustomAttributes({ ...(jsAttributes || {}), ...(event.gql || {}) }, addString)
168+
const attrParts = addCustomAttributes({
169+
...(jsAttributes || {}),
170+
...(event.gql || {}),
171+
...(event.targetAttributes || {}) // used to supply the version 2 attributes, either MFE target or duplication attributes for the main agent app
172+
}, addString)
173+
158174
fields.unshift(numeric(attrParts.length))
159175

160176
insert += fields.join(',')

src/features/ajax/instrument/index.js

Lines changed: 13 additions & 10 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 { gosNREUMOriginals } from '../../../common/window/nreum'
@@ -59,8 +59,8 @@ export class Instrument extends InstrumentBase {
5959
// do nothing
6060
}
6161

62-
wrapFetch(this.ee)
63-
wrapXhr(this.ee)
62+
wrapFetch(this.ee, agentRef)
63+
wrapXhr(this.ee, agentRef)
6464
subscribeToEvents(agentRef, this.ee, this.handler, this.dt)
6565

6666
this.importAggregator(agentRef, () => import(/* webpackChunkName: "ajax-aggregate" */ '../aggregate/index.js'))
@@ -314,14 +314,11 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
314314
this.startTime = now()
315315
this.dt = dtPayload
316316

317-
if (fetchArguments.length >= 1) this.target = fetchArguments[0]
318-
if (fetchArguments.length >= 2) this.opts = fetchArguments[1]
317+
let [target, opts = {}] = fetchArguments
319318

320-
var opts = this.opts || {}
321-
var target = this.target
322319
addUrl(this, extractUrl(target))
323320

324-
var method = ('' + ((target && target instanceof origRequest && target.method) ||
321+
const method = ('' + ((target && target instanceof origRequest && target.method) ||
325322
opts.method || 'GET')).toUpperCase()
326323
this.params.method = method
327324
this.body = opts.body
@@ -350,7 +347,8 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
350347
duration: now() - this.startTime
351348
}
352349

353-
handler('xhr', [this.params, metrics, this.startTime, this.endTime, 'fetch'], this, FEATURE_NAMES.ajax)
350+
const payload = [this.params, metrics, this.startTime, this.endTime, 'fetch']
351+
this.targets.forEach(target => reportToAgg(payload, this, target))
354352
}
355353

356354
// Create report for XHR request that has finished
@@ -377,7 +375,12 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
377375
// Always send cbTime, even if no noticeable time was taken.
378376
metrics.cbTime = this.cbTime
379377

380-
handler('xhr', [params, metrics, this.startTime, this.endTime, 'xhr'], this, FEATURE_NAMES.ajax)
378+
const payload = [params, metrics, this.startTime, this.endTime, 'xhr']
379+
this.targets.forEach(target => reportToAgg(payload, this, target))
380+
}
381+
382+
function reportToAgg (payload, context, target) {
383+
handler('xhr', [...payload, target], context, FEATURE_NAMES.ajax)
381384
}
382385

383386
function captureXhrData (ctx, xhr) {

src/features/generic_events/instrument/index.js

Lines changed: 3 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

@@ -48,8 +48,8 @@ export class Instrument extends InstrumentBase {
4848
let historyEE, websocketsEE
4949
if (websocketsEnabled) websocketsEE = wrapWebSocket(this.ee)
5050
if (isBrowserScope) {
51-
wrapFetch(this.ee)
52-
wrapXhr(this.ee)
51+
wrapFetch(this.ee, agentRef)
52+
wrapXhr(this.ee, agentRef)
5353
historyEE = wrapHistory(this.ee)
5454

5555
if (agentRef.init.user_actions.enabled) {

0 commit comments

Comments
 (0)