Skip to content

Commit d93f3f3

Browse files
feat: Make soft navigations feature the default SPA (#1638)
Co-authored-by: Jordan Porter <insomniacrampage@hotmail.com> Co-authored-by: Jordan Porter <silentsummitsoftware@gmail.com>
1 parent a429d42 commit d93f3f3

File tree

69 files changed

+264
-541
lines changed

Some content is hidden

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

69 files changed

+264
-541
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ window.NREUM.info.licenseKey = '{{{args.abLicenseKey}}}'
66
window.NREUM.init.proxy = {} // Proxy won't work for experiments
77
window.NREUM.init.session_replay.enabled = true // feature is enabled, but the app settings will have sampling at 0. We can proactively enable SR for certain test cases through app settings
88
window.NREUM.init.session_trace.enabled = true // feature is enabled, but the app settings will have sampling at 0. We can proactively enable SR for certain test cases through app settings
9-
window.NREUM.init.feature_flags = ['ajax_metrics_deny_list','soft_nav']
9+
window.NREUM.init.feature_flags = ['ajax_metrics_deny_list']
1010
window.NREUM.init.user_actions = {elementAttributes: ['id', 'className', 'tagName', 'type', 'ariaLabel', 'alt', 'title']}
1111

1212
{{#if experimentScripts}}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ window.NREUM.loader_config.licenseKey = '{{{args.abLicenseKey}}}'
44
window.NREUM.info.applicationID = '{{{args.abAppId}}}'
55
window.NREUM.info.licenseKey = '{{{args.abLicenseKey}}}'
66
window.NREUM.init.proxy.assets = 'https://staging-js-agent.newrelic.com/dev'
7-
window.NREUM.init.feature_flags = ['soft_nav','ajax_metrics_deny_list', 'register', 'register.jserrors', 'websockets']
7+
window.NREUM.init.feature_flags = ['ajax_metrics_deny_list', 'register', 'register.jserrors', 'websockets']
88
window.NREUM.init.session_replay.enabled = true // feature is enabled, but the app settings will have sampling at 0. We can proactively enable SR for certain test cases through app settings
99
window.NREUM.init.session_trace.enabled = true // feature is enabled, but the app settings will have sampling at 0. We can proactively enable SR for certain test cases through app settings
1010
window.NREUM.init.user_actions = {elementAttributes: ['id', 'className', 'tagName', 'type', 'ariaLabel', 'alt', 'title']}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// config
22
window.NREUM={
33
init: {
4-
feature_flags: ['soft_nav', 'register'], // add jserrors and generic events once the consumer(s) support it
4+
feature_flags: ['register'], // add jserrors and generic events once the consumer(s) support it
55
distributed_tracing: {
66
enabled: true
77
},

docs/warning-codes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,5 @@
133133
`Consent API argument must be boolean or undefined`
134134
### 66
135135
`A new agent session has started`
136+
### 67
137+
`The "spa" feature has been deprecated and disabled. Please use/import "soft_navigations" instead for tracking of BrowserInteraction data.`

src/cdn/spa.js

Lines changed: 2 additions & 4 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

@@ -17,7 +17,6 @@ import { Instrument as InstrumentXhr } from '../features/ajax/instrument'
1717
import { Instrument as InstrumentSessionTrace } from '../features/session_trace/instrument'
1818
import { Instrument as InstrumentSessionReplay } from '../features/session_replay/instrument'
1919
import { Instrument as InstrumentSoftNav } from '../features/soft_navigations/instrument'
20-
import { Instrument as InstrumentSpa } from '../features/spa/instrument'
2120
import { Instrument as InstrumentGenericEvents } from '../features/generic_events/instrument'
2221
import { Instrument as InstrumentLogs } from '../features/logging/instrument'
2322

@@ -32,8 +31,7 @@ new Agent({
3231
InstrumentErrors,
3332
InstrumentGenericEvents,
3433
InstrumentLogs,
35-
InstrumentSoftNav,
36-
InstrumentSpa // either the softnav or the old spa will be used (not both), but we still need to pack both to avoid dynamic import for instrument files
34+
InstrumentSoftNav
3735
],
3836
loaderType: 'spa'
3937
})

src/common/harvest/harvester.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export class Harvester {
7979
featureName: aggregateInst.featureName,
8080
endpointVersion: output.endpointVersion
8181
})
82-
output.ranSend = true
82+
output.ranSend = true // Set to true if we attempted to send (even if send() returned false due to missing errorBeacon in tests)
8383

8484
return output
8585

src/features/ajax/aggregate/index.js

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ export class Aggregate extends AggregateBase {
1919
constructor (agentRef) {
2020
super(agentRef, FEATURE_NAME)
2121
setDenyList(agentRef.runtime.denyList)
22-
this.underSpaEvents = {}
2322
const classThis = this
2423

2524
if (!agentRef.init.ajax.block_internal) {
@@ -29,19 +28,8 @@ export class Aggregate extends AggregateBase {
2928
super.customAttributesAreSeparate = true
3029
}
3130

32-
// --- v Used by old spa feature
33-
this.ee.on('interactionDone', (interaction, wasSaved) => {
34-
if (!this.underSpaEvents[interaction.id]) return
35-
36-
if (!wasSaved) { // if the ixn was saved, then its ajax reqs are part of the payload whereas if it was discarded, it should still be harvested in the ajax feature itself
37-
this.underSpaEvents[interaction.id].forEach((item) => this.events.add(item))
38-
}
39-
delete this.underSpaEvents[interaction.id]
40-
})
41-
// --- ^
42-
// --- v Used by new soft nav
4331
registerHandler('returnAjax', event => this.events.add(event), this.featureName, this.ee)
44-
// --- ^
32+
4533
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.
4634
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
4735
}, this.featureName, this.ee)
@@ -121,12 +109,8 @@ export class Aggregate extends AggregateBase {
121109
if (event.gql) this.reportSupportabilityMetric('Ajax/Events/GraphQL/Bytes-Added', stringify(event.gql).length)
122110

123111
const softNavInUse = Boolean(this.agentRef.features?.[FEATURE_NAMES.softNav])
124-
if (softNavInUse) { // For newer soft nav (when running), pass the event w/ info to it for evaluation -- either part of an interaction or is given back
112+
if (softNavInUse) { // when SN is running, pass the event w/ info to it for evaluation -- either part of an interaction or is given back
125113
handle('ajax', [event, ctx], undefined, FEATURE_NAMES.softNav, this.ee)
126-
} else if (ctx.spaNode) { // For old spa (when running), if the ajax happened inside an interaction, hold it until the interaction finishes
127-
const interactionId = ctx.spaNode.interaction.id
128-
this.underSpaEvents[interactionId] ??= []
129-
this.underSpaEvents[interactionId].push(event)
130114
} else {
131115
this.events.add(event)
132116
}

src/features/jserrors/aggregate/index.js

Lines changed: 8 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,11 @@ export class Aggregate extends AggregateBase {
3636
this.stackReported = {}
3737
this.observedAt = {}
3838
this.pageviewReported = {}
39-
this.bufferedErrorsUnderSpa = {}
4039
this.errorOnPage = false
4140

42-
// this will need to change to match whatever ee we use in the instrument
43-
this.ee.on('interactionDone', (interaction, wasSaved) => this.onInteractionDone(interaction, wasSaved))
44-
4541
register('err', (...args) => this.storeError(...args), this.featureName, this.ee)
4642
register('ierr', (...args) => this.storeError(...args), this.featureName, this.ee)
47-
register('softNavFlush', (interactionId, wasFinished, softNavAttrs, interactionEndTime) =>
48-
this.onSoftNavNotification(interactionId, wasFinished, softNavAttrs, interactionEndTime), this.featureName, this.ee) // when an ixn is done or cancelled
43+
register('returnJserror', (jsErrorEvent, softNavAttrs) => this.#storeJserrorForHarvest(jsErrorEvent, softNavAttrs), this.featureName, this.ee)
4944

5045
// 0 == off, 1 == on
5146
this.waitForFlags(['err']).then(([errFlag]) => {
@@ -202,45 +197,27 @@ export class Aggregate extends AggregateBase {
202197

203198
if (this.shouldAllowMainAgentToCapture(target)) {
204199
const softNavInUse = Boolean(this.agentRef.features?.[FEATURE_NAMES.softNav])
205-
// Note: the following are subject to potential race cond wherein if the other feature aren't fully initialized, it'll be treated as there being no associated interaction.
206-
// They each will also tack on their respective properties to the params object as part of the decision flow.
207-
if (softNavInUse) handle('jserror', [params, time], undefined, FEATURE_NAMES.softNav, this.ee)
208-
else handle('spa-jserror', jsErrorEvent, undefined, FEATURE_NAMES.spa, this.ee)
209-
210-
if (params.browserInteractionId && !params._softNavFinished) { // hold onto the error until the in-progress interaction is done, eithered saved or discarded
211-
this.bufferedErrorsUnderSpa[params.browserInteractionId] ??= []
212-
this.bufferedErrorsUnderSpa[params.browserInteractionId].push(jsErrorEvent)
213-
} else if (params._interactionId != null) { // same as above, except tailored for the way old spa does it
214-
this.bufferedErrorsUnderSpa[params._interactionId] = this.bufferedErrorsUnderSpa[params._interactionId] || []
215-
this.bufferedErrorsUnderSpa[params._interactionId].push(jsErrorEvent)
200+
if (softNavInUse) { // pass the error to soft nav for evaluation - it will return it via 'returnJserror' when interaction is resolved
201+
handle('jserror', [jsErrorEvent], undefined, FEATURE_NAMES.softNav, this.ee)
216202
} else {
217-
// Either there is no interaction (then all these params properties will be undefined) OR there's a related soft navigation that's already completed.
218-
// The old spa does not look up completed interactions at all, so there's no need to consider it.
219-
this.#storeJserrorForHarvest(jsErrorEvent, params.browserInteractionId !== undefined, params._softNavAttributes)
203+
this.#storeJserrorForHarvest(jsErrorEvent, false)
220204
}
221205
}
222206

223207
// always add directly if scoped to a sub-entity, the other pathways above will be deterministic if the main agent should procede
224208
if (target) this.#storeJserrorForHarvest([...jsErrorEvent, target], false, params._softNavAttributes)
225209
}
226210

227-
#storeJserrorForHarvest (errorInfoArr, softNavOccurredFinished, softNavCustomAttrs = {}) {
211+
#storeJserrorForHarvest (errorInfoArr, softNavCustomAttrs = {}) {
228212
let [type, bucketHash, params, newMetrics, localAttrs, target] = errorInfoArr
229213
const allCustomAttrs = {
230214
/** MFE specific attributes if in "multiple" mode (ie consumer version 2) */
231215
...getVersion2Attributes(target, this)
232216
}
233217

234-
if (softNavOccurredFinished) {
235-
Object.entries(softNavCustomAttrs).forEach(([k, v]) => setCustom(k, v)) // when an ixn finishes, it'll include stuff in jsAttributes + attrs specific to the ixn
236-
bucketHash += params.browserInteractionId
237-
238-
delete params._softNavAttributes // cleanup temp properties from synchronous evaluation; this is harmless when async from soft nav (properties DNE)
239-
delete params._softNavFinished
240-
} else { // interaction was cancelled -> error should not be associated OR there was no interaction
241-
Object.entries(this.agentRef.info.jsAttributes).forEach(([k, v]) => setCustom(k, v))
242-
delete params.browserInteractionId
243-
}
218+
Object.entries(this.agentRef.info.jsAttributes).forEach(([k, v]) => setCustom(k, v))
219+
Object.entries(softNavCustomAttrs).forEach(([k, v]) => setCustom(k, v)) // when an ixn finishes, it'll pass attrs specific to the ixn; if no associated ixn, this defaults to empty
220+
if (params.browserInteractionId) bucketHash += params.browserInteractionId
244221
if (localAttrs) Object.entries(localAttrs).forEach(([k, v]) => setCustom(k, v)) // local custom attrs are applied in either case with the highest precedence
245222

246223
const jsAttributesHash = stringHashCode(stringify(allCustomAttrs))
@@ -262,49 +239,4 @@ export class Aggregate extends AggregateBase {
262239
shouldAllowMainAgentToCapture (target) {
263240
return (!target || this.agentRef.init.api.duplicate_registered_data)
264241
}
265-
266-
// TO-DO: Remove this function when old spa is taken out. #storeJserrorForHarvest handles the work with the softnav feature.
267-
onInteractionDone (interaction, wasSaved) {
268-
if (!this.bufferedErrorsUnderSpa[interaction.id] || this.blocked) return
269-
270-
this.bufferedErrorsUnderSpa[interaction.id].forEach((item) => {
271-
var allCustomAttrs = {}
272-
const localCustomAttrs = item[4]
273-
274-
Object.entries(interaction.root.attrs.custom || {}).forEach(setCustom) // tack on custom attrs from the interaction
275-
Object.entries(localCustomAttrs || {}).forEach(setCustom)
276-
277-
var params = item[2]
278-
if (wasSaved) {
279-
params.browserInteractionId = interaction.root.attrs.id
280-
if (params._interactionNodeId) params.parentNodeId = params._interactionNodeId.toString()
281-
}
282-
delete params._interactionId
283-
delete params._interactionNodeId
284-
285-
var hash = wasSaved ? item[1] + interaction.root.attrs.id : item[1]
286-
var jsAttributesHash = stringHashCode(stringify(allCustomAttrs))
287-
var aggregateHash = hash + ':' + jsAttributesHash
288-
289-
this.events.add([item[0], aggregateHash, params, item[3], allCustomAttrs], item[5])
290-
291-
function setCustom ([key, val]) {
292-
allCustomAttrs[key] = (val && typeof val === 'object' ? stringify(val) : val)
293-
}
294-
})
295-
delete this.bufferedErrorsUnderSpa[interaction.id]
296-
}
297-
298-
onSoftNavNotification (interactionId, wasFinished, softNavAttrs, interactionEndTime) {
299-
if (this.blocked) return
300-
301-
this.bufferedErrorsUnderSpa[interactionId]?.forEach(jsErrorEvent => { // this should not modify the re-used softNavAttrs contents
302-
if (!wasFinished) return this.#storeJserrorForHarvest(jsErrorEvent, false, softNavAttrs)
303-
304-
const startTime = jsErrorEvent[3].time // in storeError fn, the newMetrics obj contains the time passed to & used by SN to seek the ixn
305-
if (startTime > interactionEndTime) return this.#storeJserrorForHarvest(jsErrorEvent, false, softNavAttrs) // disassociate any error that ultimately falls outside the final ixn span
306-
return this.#storeJserrorForHarvest(jsErrorEvent, true, softNavAttrs)
307-
})
308-
delete this.bufferedErrorsUnderSpa[interactionId] // wipe the list of jserrors so they aren't duplicated by another call to the same id
309-
}
310242
}

src/features/soft_navigations/aggregate/index.js

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*/
55
import { handle } from '../../../common/event-emitter/handle'
66
import { registerHandler } from '../../../common/event-emitter/register-handler'
7-
import { single } from '../../../common/util/invoke'
87
import { loadTime } from '../../../common/vitals/load-time'
98
import { FEATURE_NAMES } from '../../../loaders/features/features'
109
import { AggregateBase } from '../../utils/aggregate-base'
@@ -192,27 +191,36 @@ export class Aggregate extends AggregateBase {
192191
}
193192

194193
/**
195-
* Decorate the passed-in params obj with properties relating to any associated interaction at the time of the timestamp.
196-
* @param {Object} params reference to the local var instance in Jserrors feature's storeError
197-
* @param {DOMHighResTimeStamp} timestamp time the jserror occurred
194+
* Handles or redirects jserror event based on the interaction, if any, that it's tied to.
195+
* @param {Array} jsErrorEvent the error event array from jserrors feature
198196
*/
199-
#handleJserror (params, timestamp) {
197+
#handleJserror (jsErrorEvent) {
198+
const timestamp = jsErrorEvent[3].time // in storeError fn, the newMetrics obj contains the time of the error
200199
const associatedInteraction = this.getInteractionFor(timestamp)
201-
if (!associatedInteraction) return // do not need to decorate this jserror params
200+
if (!associatedInteraction) {
201+
// No interaction was happening when this error occurred, so give it back to jserrors feature for processing
202+
return handle('returnJserror', [jsErrorEvent], undefined, FEATURE_NAMES.jserrors, this.ee)
203+
}
202204

203-
// Whether the interaction is in-progress or already finished, the id will let jserror buffer it under its index, until it gets the next step instruction.
204-
params.browserInteractionId = associatedInteraction.id
205+
// Store the error info to be returned when interaction finishes
205206
if (associatedInteraction.status === INTERACTION_STATUS.FIN) {
206-
// This information cannot be relayed back via handle() that flushes buffered errs because this is being called by a jserror's handle() per se and before the err is buffered.
207-
params._softNavFinished = true // instead, signal that this err can be processed right away without needing to be buffered aka wait for an in-progress ixn
208-
params._softNavAttributes = associatedInteraction.customAttributes
207+
// Interaction already finished, return immediately with the interaction ID and attributes
208+
processJserror.call(this, jsErrorEvent, associatedInteraction)
209209
} else {
210-
// These callbacks may be added multiple times for an ixn, but just a single run will deal with all jserrors associated with the interaction.
211-
// As such, be cautious not to use the params object since that's tied to one specific jserror and won't affect the rest of them.
212-
associatedInteraction.on('finished', single(() =>
213-
handle('softNavFlush', [associatedInteraction.id, true, associatedInteraction.customAttributes, associatedInteraction.end], undefined, FEATURE_NAMES.jserrors, this.ee)))
214-
associatedInteraction.on('cancelled', single(() =>
215-
handle('softNavFlush', [associatedInteraction.id, false, undefined], undefined, FEATURE_NAMES.jserrors, this.ee))) // don't take custom attrs from cancelled ixns
210+
// Interaction still in progress, wait for it to finish or be cancelled
211+
associatedInteraction.on('finished', () => processJserror.call(this, jsErrorEvent, associatedInteraction))
212+
associatedInteraction.on('cancelled', () => handle('returnJserror', [jsErrorEvent], undefined, FEATURE_NAMES.jserrors, this.ee))
213+
}
214+
215+
function processJserror (jsErrorEvent, parentInteraction) {
216+
const finalEnd = parentInteraction.end
217+
if (timestamp > finalEnd) { // check if error falls within the final interaction span, after any & all long task extension(s) are considered
218+
return handle('returnJserror', [jsErrorEvent], undefined, FEATURE_NAMES.jserrors, this.ee)
219+
}
220+
// Error is within the interaction span, return with the correct interaction ID set, plus any custom attributes from the interaction
221+
const params = jsErrorEvent[2]
222+
params.browserInteractionId = parentInteraction.id
223+
handle('returnJserror', [jsErrorEvent, parentInteraction.customAttributes], undefined, FEATURE_NAMES.jserrors, this.ee)
216224
}
217225
}
218226

@@ -227,7 +235,7 @@ export class Aggregate extends AggregateBase {
227235
if (this.associatedInteraction?.trigger === IPL_TRIGGER_NAME) this.associatedInteraction = null // the api get-interaction method cannot target IPL
228236
if (!this.associatedInteraction) {
229237
// This new api-driven interaction will be the target of any subsequent .interaction() call, until it is closed by EITHER .end() OR the regular url>dom change process.
230-
this.associatedInteraction = thisClass.interactionInProgress = new Interaction(API_TRIGGER_NAME, time, thisClass.latestRouteSetByApi)
238+
this.associatedInteraction = thisClass.interactionInProgress = new Interaction(API_TRIGGER_NAME, Math.floor(time), thisClass.latestRouteSetByApi)
231239
thisClass.domObserver.observe(document.body, { attributes: true, childList: true, subtree: true, characterData: true }) // start observing for DOM changes like a regular UI-driven interaction
232240
thisClass.setClosureHandlers()
233241
}

src/features/utils/aggregate-base.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export class AggregateBase extends FeatureBase {
141141
}
142142

143143
preHarvestChecks (opts) {
144-
return !this.blocked
144+
return !this.blocked && !this.ee.aborted
145145
}
146146

147147
/**

0 commit comments

Comments
 (0)