diff --git a/package-lock.json b/package-lock.json index a2b18d089..95ac31545 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9108,9 +9108,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001533", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001533.tgz", - "integrity": "sha512-9aY/b05NKU4Yl2sbcJhn4A7MsGwR1EPfW/nrqsnqVA0Oq50wpmPaGI+R1Z0UKlUl96oxUkGEOILWtOHck0eCWw==", + "version": "1.0.30001534", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001534.tgz", + "integrity": "sha512-vlPVrhsCS7XaSh2VvWluIQEzVhefrUQcEsQWSS5A5V+dM07uv1qHeQzAOTGIMy9i3e9bH15+muvI/UHojVgS/Q==", "dev": true, "funding": [ { diff --git a/src/cdn/experimental.js b/src/cdn/experimental.js index a54eb6a30..849412cd1 100644 --- a/src/cdn/experimental.js +++ b/src/cdn/experimental.js @@ -17,7 +17,8 @@ import { Instrument as InstrumentErrors } from '../features/jserrors/instrument' import { Instrument as InstrumentXhr } from '../features/ajax/instrument' import { Instrument as InstrumentSessionTrace } from '../features/session_trace/instrument' import { Instrument as InstrumentSessionReplay } from '../features/session_replay/instrument' -import { Instrument as InstrumentSpa } from '../features/spa/instrument' +// import { Instrument as InstrumentSpa } from '../features/spa/instrument' +import { Instrument as InstrumentBasicSpa } from '../features/basic_spa/instrument' import { Instrument as InstrumentPageAction } from '../features/page_action/instrument' new Agent({ @@ -30,7 +31,8 @@ new Agent({ InstrumentMetrics, InstrumentPageAction, InstrumentErrors, - InstrumentSpa + // InstrumentSpa, + InstrumentBasicSpa ], loaderType: 'experimental' }) diff --git a/src/cdn/spa.js b/src/cdn/spa.js index 8a0b0f622..fe43c790f 100644 --- a/src/cdn/spa.js +++ b/src/cdn/spa.js @@ -11,7 +11,7 @@ import { Instrument as InstrumentErrors } from '../features/jserrors/instrument' import { Instrument as InstrumentXhr } from '../features/ajax/instrument' import { Instrument as InstrumentSessionTrace } from '../features/session_trace/instrument' import { Instrument as InstrumentSessionReplay } from '../features/session_replay/instrument' -import { Instrument as InstrumentSpa } from '../features/spa/instrument' +import { Instrument as InstrumentBasicSpa } from '../features/basic_spa/instrument' import { Instrument as InstrumentPageAction } from '../features/page_action/instrument' new Agent({ @@ -24,7 +24,7 @@ new Agent({ InstrumentMetrics, InstrumentPageAction, InstrumentErrors, - InstrumentSpa + InstrumentBasicSpa ], loaderType: 'spa' }) diff --git a/src/common/aggregate/aggregator.js b/src/common/aggregate/aggregator.js index 3e175da33..4161a6d64 100644 --- a/src/common/aggregate/aggregator.js +++ b/src/common/aggregate/aggregator.js @@ -3,7 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { FEATURE_NAMES } from '../../loaders/features/features' import { SharedContext } from '../context/shared-context' +import { getFeatureState } from '../util/feature-state' import { mapOwn } from '../util/map-own' export class Aggregator extends SharedContext { @@ -82,12 +84,36 @@ export class Aggregator extends SharedContext { var hasData = false for (var i = 0; i < types.length; i++) { type = types[i] - results[type] = toArray(this.aggregatedData[type]) + results[type] = this.toArray(type, this.aggregatedData[type]) + if (results[type].length) hasData = true delete this.aggregatedData[type] } return hasData ? results : null } + + toArray (type, obj) { + if (typeof obj !== 'object') return [] + + return mapOwn(obj, (key, value) => this.getValue(key, value, type)) + } + + getValue (key, value, type) { + // err type can be decorated with an interaction ID if present + if (type !== 'err') return value + const shouldHold = this.checkInteractions(value) // add an interaction ID if exists + if (shouldHold) return + return value + } + + checkInteractions (value) { + const spaFeature = getFeatureState({ agentIdentifier: this.sharedContext.agentIdentifier, featureName: FEATURE_NAMES.basicSpa }) + const { shouldHold, interactions } = spaFeature?.hasInteraction?.({ timestamp: value?.metrics?.time?.t }) || {} + if (shouldHold) return true + let browserInteractionId = interactions?.[0]?.id.replace('\'', '') + if (browserInteractionId) value.params.browserInteractionId = browserInteractionId + return false + } } function aggregateMetrics (newMetrics, oldMetrics) { @@ -159,13 +185,3 @@ function createMetricObject (value) { c: 1 } } - -function toArray (obj) { - if (typeof obj !== 'object') return [] - - return mapOwn(obj, getValue) -} - -function getValue (key, value) { - return value -} diff --git a/src/common/serialize/bel-serializer.js b/src/common/serialize/bel-serializer.js index 8b65d46a6..7328ed67e 100644 --- a/src/common/serialize/bel-serializer.js +++ b/src/common/serialize/bel-serializer.js @@ -44,7 +44,7 @@ export function getAddStringContext (agentIdentifier) { } } -export function addCustomAttributes (attrs, addString) { +export function addCustomAttributes (attrs, addString, shouldSerialize) { var attrParts = [] mapOwn(attrs, function (key, val) { @@ -83,7 +83,7 @@ export function addCustomAttributes (attrs, addString) { attrParts.push([type, key + (serializedValue ? ',' + serializedValue : '')]) }) - return attrParts + return shouldSerialize ? attrParts.map(v => ({ serialize: () => v.join(',') })) : attrParts } var escapable = /([,\\;])/g diff --git a/src/common/timing/now.js b/src/common/timing/now.js index 677032f47..73c405e5b 100644 --- a/src/common/timing/now.js +++ b/src/common/timing/now.js @@ -5,5 +5,5 @@ // This is our own layer around performance.now. It's not strictly necessary, but we keep it in case of future mod-ing of the value for refactor purpose. export function now () { - return Math.round(performance.now()) + return Math.floor(performance.now()) } diff --git a/src/common/timing/time-to-interactive.js b/src/common/timing/time-to-interactive.js new file mode 100644 index 000000000..6a915b12f --- /dev/null +++ b/src/common/timing/time-to-interactive.js @@ -0,0 +1,38 @@ +import { longTask } from '../vitals/long-task' + +export class TimeToInteractive { + #ltTimer + #latestLT + #ltUnsub + #startTimestamp + #resolver + #rejector + #longTasks = 0 + + start ({ startTimestamp, buffered = false }) { + this.#startTimestamp = startTimestamp + this.#ltUnsub = longTask.subscribe(({ name, value, attrs }) => { + this.#latestLT = attrs.ltStart + value + this.#longTasks++ + this.#setLtTimer() + }, buffered) + return new Promise((resolve, reject) => { + this.#resolver = resolve + this.#rejector = reject + }) + } + + cancel () { + clearTimeout(this.#ltTimer) + this.#ltUnsub() + // this.#rejector() + } + + #setLtTimer () { + clearTimeout(this.#ltTimer) + this.#ltTimer = setTimeout(() => { + this.#resolver({ value: Math.round(Math.max(this.#startTimestamp, this.#latestLT)), attrs: { startTimestamp: this.#startTimestamp, longTasks: this.#longTasks } }) + this.#ltUnsub() + }, 5000) + } +} diff --git a/src/common/util/feature-flags.js b/src/common/util/feature-flags.js index 1ca547e67..5674c5fb0 100644 --- a/src/common/util/feature-flags.js +++ b/src/common/util/feature-flags.js @@ -10,7 +10,7 @@ const bucketMap = { stn: [FEATURE_NAMES.sessionTrace], err: [FEATURE_NAMES.jserrors, FEATURE_NAMES.metrics], ins: [FEATURE_NAMES.pageAction], - spa: [FEATURE_NAMES.spa], + spa: [FEATURE_NAMES.spa, FEATURE_NAMES.basicSpa], sr: [FEATURE_NAMES.sessionReplay, FEATURE_NAMES.sessionTrace] } diff --git a/src/common/util/feature-flags.test.js b/src/common/util/feature-flags.test.js index eb3975d03..c8c92fc0f 100644 --- a/src/common/util/feature-flags.test.js +++ b/src/common/util/feature-flags.test.js @@ -53,8 +53,8 @@ test('emits the right events when feature flag = 1', () => { const sharedEE = jest.mocked(eventEmitterModule.ee.get).mock.results[0].value - // each flag gets emitted to each of its mapped features, and a feat- AND a rumresp- for every emit, so (1+2+1+1+2)*2 = 14 - expect(handleModule.handle).toHaveBeenCalledTimes(14) + // each flag gets emitted to each of its mapped features, and a feat- AND a rumresp- for every emit, so (1+2+1+1+2+2)*2 = 16 + expect(handleModule.handle).toHaveBeenCalledTimes(16) expect(handleModule.handle).toHaveBeenNthCalledWith(1, 'feat-stn', [], undefined, FEATURE_NAMES.sessionTrace, sharedEE) expect(handleModule.handle).toHaveBeenLastCalledWith('rumresp-sr', [true], undefined, FEATURE_NAMES.sessionTrace, sharedEE) @@ -69,8 +69,8 @@ test('emits the right events when feature flag = 0', () => { const sharedEE = jest.mocked(eventEmitterModule.ee.get).mock.results[0].value - // each flag gets emitted to each of its mapped features, and a block- AND a rumresp- for every emit, so (1+2+1+1+2)*2 = 14 - expect(handleModule.handle).toHaveBeenCalledTimes(14) + // each flag gets emitted to each of its mapped features, and a block- AND a rumresp- for every emit, so (1+2+1+1+2+2)*2 = 16 + expect(handleModule.handle).toHaveBeenCalledTimes(16) expect(handleModule.handle).toHaveBeenNthCalledWith(1, 'block-stn', [], undefined, FEATURE_NAMES.sessionTrace, sharedEE) expect(handleModule.handle).toHaveBeenLastCalledWith('rumresp-sr', [false], undefined, FEATURE_NAMES.sessionTrace, sharedEE) diff --git a/src/common/util/feature-state.js b/src/common/util/feature-state.js new file mode 100644 index 000000000..54c541108 --- /dev/null +++ b/src/common/util/feature-state.js @@ -0,0 +1,13 @@ +import { gosNREUM } from '../window/nreum' + +export const FEATURE_TYPE = { + AGGREGATE: 0, + INSTRUMENT: 1 +} + +export function getFeatureState ({ agentIdentifier, featureName, featureType = FEATURE_TYPE.AGGREGATE }) { + const nr = gosNREUM() + if (featureType === FEATURE_TYPE.AGGREGATE) return nr.initializedAgents[agentIdentifier]?.features?.[featureName]?.featAggregate || {} + if (featureType === FEATURE_TYPE.INSTRUMENT) return nr.initializedAgents[agentIdentifier]?.features?.[featureName] || {} + return {} +} diff --git a/src/common/vitals/constants.js b/src/common/vitals/constants.js index 79564eb0b..61d90bf28 100644 --- a/src/common/vitals/constants.js +++ b/src/common/vitals/constants.js @@ -6,5 +6,6 @@ export const VITAL_NAMES = { CUMULATIVE_LAYOUT_SHIFT: 'cls', INTERACTION_TO_NEXT_PAINT: 'inp', LONG_TASK: 'lt', - TIME_TO_FIRST_BYTE: 'ttfb' + TIME_TO_FIRST_BYTE: 'ttfb', + TIME_TO_INTERACTIVE: 'tti' } diff --git a/src/common/window/load.js b/src/common/window/load.js index f474e5ccd..935744dd0 100644 --- a/src/common/window/load.js +++ b/src/common/window/load.js @@ -1,6 +1,6 @@ import { windowAddEventListener, documentAddEventListener } from '../event-listener/event-listener-opts' -function checkState () { +export function checkState () { return (typeof document === 'undefined' || document.readyState === 'complete') } diff --git a/src/features/ajax/aggregate/index.js b/src/features/ajax/aggregate/index.js index b5eb1f352..52a19e03c 100644 --- a/src/features/ajax/aggregate/index.js +++ b/src/features/ajax/aggregate/index.js @@ -13,6 +13,8 @@ import { FEATURE_NAME } from '../constants' import { FEATURE_NAMES } from '../../../loaders/features/features' import { SUPPORTABILITY_METRIC_CHANNEL } from '../../metrics/constants' import { AggregateBase } from '../../utils/aggregate-base' +import { getFeatureState } from '../../../common/util/feature-state' +import { AjaxNode } from '../../basic_spa/aggregate/ajax-node' export class Aggregate extends AggregateBase { static featureName = FEATURE_NAME @@ -43,21 +45,6 @@ export class Aggregate extends AggregateBase { this.prepareHarvest = prepareHarvest this.getStoredEvents = function () { return { ajaxEvents, spaAjaxEvents } } - ee.on('interactionSaved', (interaction) => { - if (!spaAjaxEvents[interaction.id]) return - // remove from the spaAjaxEvents buffer, and let spa harvest it - delete spaAjaxEvents[interaction.id] - }) - ee.on('interactionDiscarded', (interaction) => { - if (!spaAjaxEvents[interaction.id]) return - - spaAjaxEvents[interaction.id].forEach(function (item) { - // move it from the spaAjaxEvents buffer to the ajaxEvents buffer for harvesting here - ajaxEvents.push(item) - }) - delete spaAjaxEvents[interaction.id] - }) - const scheduler = new HarvestScheduler('events', { onFinished: onEventsHarvestFinished, getPayload: prepareHarvest @@ -119,11 +106,19 @@ export class Aggregate extends AggregateBase { event.spanTimestamp = xhrContext.dt.timestamp } - // if the ajax happened inside an interaction, hold it until the interaction finishes - if (this.spaNode) { - var interactionId = this.spaNode.interaction.id - spaAjaxEvents[interactionId] = spaAjaxEvents[interactionId] || [] - spaAjaxEvents[interactionId].push(event) + const spaFeature = getFeatureState({ agentIdentifier, featureName: FEATURE_NAMES.basicSpa }) + const { + interactions + } = spaFeature?.hasInteraction?.({ timestamp: event.startTime }) || {} + + // if the ajax happened inside an ixn window (found a match), add it to the ixn + if (interactions?.length) { + interactions.forEach(interaction => { + const node = new AjaxNode(agentIdentifier, event) + interaction.addChild(node) + // add the ajax event back to the ajax feature queue if the ixn cancels + node.on('cancelled', () => { ajaxEvents.push(event) }) // this needs to be fixed later to avoid duplicates + }) } else { ajaxEvents.push(event) } @@ -200,6 +195,9 @@ export class Aggregate extends AggregateBase { for (var i = 0; i < events.length; i++) { var event = events[i] + + // if (interaction) console.log('ajax', event, 'has FOUND AJAX INTERACTION!', interaction) + var fields = [ numeric(event.startTime), numeric(event.endTime - event.startTime), diff --git a/src/features/basic_spa/aggregate/ajax-node.js b/src/features/basic_spa/aggregate/ajax-node.js new file mode 100644 index 000000000..5c788d3d9 --- /dev/null +++ b/src/features/basic_spa/aggregate/ajax-node.js @@ -0,0 +1,64 @@ +import { getAddStringContext, nullable, numeric } from '../../../common/serialize/bel-serializer' +import { TYPE_IDS } from '../constants' +import { BelNode } from './bel-node' + +export class AjaxNode extends BelNode { + method + status + domain + path + txSize // request body size + rxSize // response body size + requestedWith // isFetch (1) | isJSONP (2) | XMLHttpRequest ('') + spanId + traceId + spanTimestamp + + constructor (agentIdentifier, ajaxEvent) { + super(agentIdentifier) + this.belType = TYPE_IDS.AJAX + this.method = ajaxEvent.method + this.status = ajaxEvent.status + this.domain = ajaxEvent.domain + this.path = ajaxEvent.path + this.txSize = ajaxEvent.requestSize + this.rxSize = ajaxEvent.responseSize + this.requestedWith = ajaxEvent.type === 'fetch' ? 1 : ajaxEvent.type === 'jsonp' ? 2 : '' // does this actually work? + this.spanId = ajaxEvent.spanId + this.traceId = ajaxEvent.traceId + this.spanTimestamp = ajaxEvent.spanTimestamp + + this.start = ajaxEvent.startTime + this.end = ajaxEvent.endTime + this.callbackDuration = ajaxEvent.callbackDuration + this.callbackEnd = this.callbackDuration + } + + serialize (startTimestamp) { + const addString = getAddStringContext(this.agentIdentifier) + const nodeList = [] + const fields = [ + numeric(this.belType), + this.children.length, + numeric(this.start - startTimestamp), // start relative to first seen (parent interaction) + numeric(this.end - this.start), // end is relative to start + numeric(this.callbackEnd), + numeric(this.callbackDuration), + addString(this.method), + numeric(this.status), + addString(this.domain), + addString(this.path), + numeric(this.txSize), + numeric(this.rxSize), + this.requestedWith, + addString(this.nodeId), + nullable(this.spanId, addString, true), + nullable(this.traceId, addString, true), + nullable(this.spanTimestamp, numeric, true) + ] + + nodeList.push(fields) + + return nodeList.join(';') + } +} diff --git a/src/features/basic_spa/aggregate/bel-node.js b/src/features/basic_spa/aggregate/bel-node.js new file mode 100644 index 000000000..c38f493f6 --- /dev/null +++ b/src/features/basic_spa/aggregate/bel-node.js @@ -0,0 +1,56 @@ +import { now } from '../../../common/timing/now' + +let nodesSeen = 0 + +export class BelNode { + subscribers = new Map() + emitted = false + + belType + children = [] + start = now() + end + callbackEnd = 0 + callbackDuration = 0 + nodeId = String(++nodesSeen) + + constructor (agentIdentifier) { + this.agentIdentifier = agentIdentifier + } + + containsEvent (timestamp) { + if (!this.end) return this.start <= timestamp + return (this.start <= timestamp && this.end >= timestamp) + } + + addChild (child) { + this.children.push(child) + } + + on (event, cb) { + if (typeof cb !== 'function') throw new Error('Must supply function as callback') + const cbs = this.subscribers.get(event) || [] + cbs.push(cb) + this.subscribers.set(event, cbs) + } + + cancel () { + this.cancelled = true + if (this.ttiTracker) this.ttiTracker?.cancel() + if (this.emitted) return + clearTimeout(this.timer) + if (this.children) this.children.forEach(child => child?.cancel()) + for (let [evt, cbs] of this.subscribers) { + if (evt === 'cancelled') cbs.forEach(cb => cb(this)) + } + } + + validateChildren () { + this.children.forEach(child => { + if (child.start < this.start || child.end > this.end) { + child?.cancel() + } else child?.validateChildren() + }) + this.children = this.children.filter(c => !c.cancelled) + } +} diff --git a/src/features/basic_spa/aggregate/index.js b/src/features/basic_spa/aggregate/index.js new file mode 100644 index 000000000..0d6ad258c --- /dev/null +++ b/src/features/basic_spa/aggregate/index.js @@ -0,0 +1,92 @@ +import { getConfigurationValue } from '../../../common/config/config' +import { registerHandler } from '../../../common/event-emitter/register-handler' +import { HarvestScheduler } from '../../../common/harvest/harvest-scheduler' +import { AggregateBase } from '../../utils/aggregate-base' +import { CATEGORY, FEATURE_NAME } from '../constants' +import { InitialPageLoadInteraction } from './initial-page-load-interaction' +import { Interaction } from './interaction' + +export class Aggregate extends AggregateBase { + static featureName = FEATURE_NAME + constructor (agentIdentifier, aggregator) { + super(agentIdentifier, aggregator, FEATURE_NAME) + + this.harvestTimeSeconds = getConfigurationValue(agentIdentifier, 'spa.harvestTimeSeconds') || 10 + + this.initialPageLoadInteraction = new InitialPageLoadInteraction(this.agentIdentifier) + this.initialPageLoadInteraction.on('finished', ixn => { + if (!ixn) return + this.interactionsToHarvest.push(ixn) + this.scheduler.scheduleHarvest(0.1) + }) + + this.interactionInProgress = null + this.interactionsToHarvest = [] + this.interactionsSent = [] + + this.blocked = false + + // const tracerEE = this.ee.get('tracer') // used to get API-driven interactions + + this.scheduler = new HarvestScheduler('events', { + onFinished: this.onHarvestFinished.bind(this), + retryDelay: this.harvestTimeSeconds + }, { agentIdentifier, ee: this.ee }) + this.scheduler.harvest.on('events', this.onHarvestStarted.bind(this)) + + registerHandler('newInteraction', (timestamp, trigger, category) => this.startInteraction({ category, trigger, startedAt: timestamp }), this.featureName, this.ee) + registerHandler('newURL', (timestamp, url, type) => this.interactionInProgress?.updateHistory(timestamp, url), this.featureName, this.ee) + registerHandler('newDom', timestamp => this.interactionInProgress?.updateDom(timestamp), this.featureName, this.ee) + + this.drain() + } + + onHarvestStarted (options) { + if (this.interactionsToHarvest.length === 0 || this.blocked) return {} + const ixn = this.interactionsToHarvest.shift() + const payload = `bel.7;${ixn.serialize('bel')}` + + this.interactionsSent.push(ixn) + + if (this.interactionsToHarvest.length) this.scheduler.scheduleHarvest(0.1) + + return { body: { e: payload } } + } + + onHarvestFinished (result) { + if (result.sent && result.retry && this.interactionsSent.length > 0) { + this.interactionsSent.forEach((interaction) => { + this.interactionsToHarvest.unshift(interaction) + }) + this.interactionsSent = [] + } + } + + startInteraction ({ trigger, category, startedAt }) { + console.log('START IXN', trigger, category, startedAt) + this.interactionInProgress?.cancel() + this.interactionInProgress = new Interaction(this.agentIdentifier) + this.interactionInProgress.on('finished', this.completeInteraction.bind(this)) + this.interactionInProgress.on('cancelled', this.cancelInteraction.bind(this)) + if (trigger) this.interactionInProgress.trigger = trigger + if (category) this.interactionInProgress.category = CATEGORY.ROUTE_CHANGE + if (startedAt) this.interactionInProgress.start = startedAt + } + + cancelInteraction () { + this.interactionInProgress = null + } + + completeInteraction (ixn) { + if (!ixn) return + this.interactionsToHarvest.push(ixn) + this.interactionInProgress = null + this.scheduler.scheduleHarvest(0.1) + } + + hasInteraction ({ timestamp }) { + if (!timestamp) return { interaction: undefined } + const interactions = [this.initialPageLoadInteraction, this.interactionInProgress, ...this.interactionsToHarvest].filter(ixn => !!ixn && ixn.containsEvent(timestamp)) + return { interactions } + } +} diff --git a/src/features/basic_spa/aggregate/initial-page-load-interaction.js b/src/features/basic_spa/aggregate/initial-page-load-interaction.js new file mode 100644 index 000000000..688bf2740 --- /dev/null +++ b/src/features/basic_spa/aggregate/initial-page-load-interaction.js @@ -0,0 +1,59 @@ +import { CATEGORY } from '../constants' +import { navTimingValues } from '../../../common/timing/nav-timing' +import { Interaction } from './interaction' +import { globalScope, initialLocation } from '../../../common/constants/runtime' +import { nullable, numeric } from '../../../common/serialize/bel-serializer' +import { firstPaint } from '../../../common/vitals/first-paint' +import { firstContentfulPaint } from '../../../common/vitals/first-contentful-paint' +import { TimeToInteractive } from '../../../common/timing/time-to-interactive' + +export class InitialPageLoadInteraction extends Interaction { + constructor (...args) { + super(...args) + const pageUrl = initialLocation + this.initialPageURL = pageUrl + this.oldURL = pageUrl + this.newURL = pageUrl + this.trigger = 'initialPageLoad' + this.start = 0 + this.category = CATEGORY.INITIAL_PAGE_LOAD + + this.ttiTracker = new TimeToInteractive().start({ + startTimestamp: globalScope?.performance?.getEntriesByType('navigation')?.[0]?.loadEventEnd || globalScope?.performance?.timing?.loadEventEnd - globalScope?.performance?.timeOrigin, + buffered: true + }).then(({ value }) => { + this.finish(value) + }) + } + + get firstPaint () { return nullable(firstPaint.current.value, numeric, true) } + get firstContentfulPaint () { return nullable(firstContentfulPaint.current.value, numeric, true) } + + get navTiming () { + if (!navTimingValues) return + let seperator = ',' + let navTimingNode = 'b' + let prev = 0 + + navTimingValues.slice(1, 21).forEach(v => { + if (v !== undefined) { + navTimingNode += seperator + numeric(v - prev) + seperator = ',' + prev = v + } else { + navTimingNode += seperator + '!' + seperator = '' + } + }) + return navTimingNode + } + + serialize () { + let serializedIxn = super.serialize() + // fp & fcp need to go in the first block of nodes, with the base node list + serializedIxn += `,${this.firstPaint}` + serializedIxn += this.firstContentfulPaint + serializedIxn += `;${this.navTiming}` + return serializedIxn + } +} diff --git a/src/features/basic_spa/aggregate/interaction.js b/src/features/basic_spa/aggregate/interaction.js new file mode 100644 index 000000000..98d984e1c --- /dev/null +++ b/src/features/basic_spa/aggregate/interaction.js @@ -0,0 +1,126 @@ +import { getInfo } from '../../../common/config/config' +import { globalScope, initialLocation } from '../../../common/constants/runtime' +import { generateUuid } from '../../../common/ids/unique-id' +import { addCustomAttributes, getAddStringContext, nullable, numeric } from '../../../common/serialize/bel-serializer' +import { now } from '../../../common/timing/now' +import { TimeToInteractive } from '../../../common/timing/time-to-interactive' +import { cleanURL } from '../../../common/url/clean-url' +import { debounce } from '../../../common/util/invoke' +import { TYPE_IDS } from '../constants' +import { BelNode } from './bel-node' + +/** + * link https://github.com/newrelic/nr-querypack/blob/main/schemas/bel/7.qpschema + **/ +export class Interaction extends BelNode { + id = generateUuid() + trigger + initialPageURL = initialLocation + oldURL = '' + globalScope?.location + newURL = '' + globalScope?.location + customName + category + queueTime + appTime + oldRoute + newRoute + previousRouteName + targetRouteName + + ttiTracker + + constructor (agentIdentifier) { + super(agentIdentifier) + if (!agentIdentifier) throw new Error('Interaction is missing core attributes') + this.initialPageURL = initialLocation + this.oldURL = '' + globalScope?.location + this.belType = TYPE_IDS.INTERACTION + + this.domTimestamp = 0 + this.historyTimestamp = 0 + + this.timer = setTimeout(() => { + // make this interaction invalid as to not hold up any other events + this.cancel() + }, 30000) + + this.ttiTracker = new TimeToInteractive().start({ + startTimestamp: now() + }).then(({ value }) => { + this.tti = value + this.checkFinished() + }) + } + + finish (end) { + if (this.emitted) return + clearTimeout(this.timer) + this.end = (end || Math.max(this.domTimestamp, this.historyTimestamp, this.tti)) - this.start + this.callbackDuration = this.trigger === 'initialPageLoad' ? 0 : (this.tti - Math.max(this.domTimestamp, this.historyTimestamp)) + this.callbackEnd = this.end - this.callbackDuration + for (let [evt, cbs] of this.subscribers) { + if (evt === 'finished') cbs.forEach(cb => cb(this)) + } + console.log('finished...', performance.now(), this) + } + + updateDom (timestamp) { + this.domTimestamp = (timestamp || now()) + this.checkFinished() + } + + updateHistory (timestamp, url) { + this.newURL = url || '' + globalScope?.location + this.historyTimestamp = (timestamp || now()) + this.checkFinished() + } + + checkFinished = debounce(() => { + // console.log(performance.now(), 'checking finish for', this.#id, !!this.domTimestamp, !!this.historyTimestamp) + if (!!this.domTimestamp && !!this.historyTimestamp && !!this.tti) this.finish() + }, 60) + + serialize () { + const addString = getAddStringContext(this.agentIdentifier) + const nodeList = [] + const fields = [ + numeric(this.belType), + this.children.length, + numeric(this.start), // relative to first node (this in interaction) + numeric(this.end), // end -- relative to start + numeric(this.callbackEnd), // cbEnd -- relative to start + numeric(this.callbackDuration), // not relative + addString(this.trigger), + addString(cleanURL(this.initialPageURL, true)), + addString(cleanURL(this.oldURL, true)), + addString(cleanURL(this.newURL, true)), + addString(this.customName), + this.category, + nullable(this.queueTime, numeric, true) + + nullable(this.appTime, numeric, true) + + nullable(this.oldRoute, addString, true) + + nullable(this.newRoute, addString, true) + + addString(this.id), + addString(this.nodeId) + ] + + const customAttrs = addCustomAttributes(getInfo(this.agentIdentifier).jsAttributes || {}, addString, true) + // const customAttrs = [] + const metadataAttrs = this.domTimestamp && this.historyTimestamp + ? addCustomAttributes({ + domTimestamp: this.domTimestamp, + historyTimestamp: this.historyTimestamp + }, addString, true) + : [] + + this.validateChildren() + + const childrenAndAttrs = metadataAttrs.concat(customAttrs).concat(this.children) + fields[1] = childrenAndAttrs.length + nodeList.push(fields) + + childrenAndAttrs.forEach(node => nodeList.push(node.serialize(this.start))) + + return nodeList.join(';') + } +} diff --git a/src/features/basic_spa/constants.js b/src/features/basic_spa/constants.js new file mode 100644 index 000000000..a4b7651aa --- /dev/null +++ b/src/features/basic_spa/constants.js @@ -0,0 +1,24 @@ +import { FEATURE_NAMES } from '../../loaders/features/features' + +export const INTERACTION_EVENTS = [ + 'click', + 'submit', + 'keypress', + 'keydown', + 'keyup', + 'change' +] + +export const FEATURE_NAME = FEATURE_NAMES.basicSpa + +export const CATEGORY = { + INITIAL_PAGE_LOAD: 0, + ROUTE_CHANGE: 1, + CUSTOM: 2 +} + +export const TYPE_IDS = { + INTERACTION: 1, + AJAX: 2, + CUSTOM_TRACER: 4 +} diff --git a/src/features/basic_spa/index.js b/src/features/basic_spa/index.js new file mode 100644 index 000000000..774fdfa4e --- /dev/null +++ b/src/features/basic_spa/index.js @@ -0,0 +1 @@ +export { Instrument as BasicSpa } from './instrument/index' diff --git a/src/features/basic_spa/instrument/index.js b/src/features/basic_spa/instrument/index.js new file mode 100644 index 000000000..3a11849ba --- /dev/null +++ b/src/features/basic_spa/instrument/index.js @@ -0,0 +1,60 @@ +import { originals } from '../../../common/config/config' +import { isBrowserScope } from '../../../common/constants/runtime' +import { handle } from '../../../common/event-emitter/handle' +import { windowAddEventListener } from '../../../common/event-listener/event-listener-opts' +import { now } from '../../../common/timing/now' +import { debounce } from '../../../common/util/invoke' +import { wrapEvents, wrapHistory } from '../../../common/wrap' +import { InstrumentBase } from '../../utils/instrument-base' +import { CATEGORY, FEATURE_NAME, INTERACTION_EVENTS } from '../constants' + +export class Instrument extends InstrumentBase { + static featureName = FEATURE_NAME + constructor (agentIdentifier, aggregator, auto = true) { + super(agentIdentifier, aggregator, FEATURE_NAME, auto) + if (!isBrowserScope) return // SPA not supported outside web env + try { + this.removeOnAbort = new AbortController() + } catch (e) {} + + const historyEE = wrapHistory(this.ee) + const eventsEE = wrapEvents(this.ee) + + const trackURLChange = (args) => handle('newURL', [now(), '' + window.location, args?.type], undefined, this.featureName, this.ee) + + historyEE.on('pushState-end', trackURLChange) + historyEE.on('replaceState-end', trackURLChange) + + windowAddEventListener('hashchange', trackURLChange, true, this.removeOnAbort?.signal) + windowAddEventListener('load', trackURLChange, true, this.removeOnAbort?.signal) + windowAddEventListener('popstate', trackURLChange, true, this.removeOnAbort?.signal) + + const debouncedIxn = debounce((trigger) => { + handle('newInteraction', [now(), trigger, CATEGORY.ROUTE_CHANGE], undefined, this.featureName, this.ee) + }, 60, { leading: true }) + + eventsEE.on('fn-end', (evts) => { + if (INTERACTION_EVENTS.includes(evts?.[0]?.type)) { + debouncedIxn(evts?.[0]?.type) + } + }) + + if (originals.MO) { + const domObserver = new originals.MO((mutation) => { + requestAnimationFrame(() => { + handle('newDom', [now()], undefined, this.featureName, this.ee) + }) + }) + domObserver.observe(document.documentElement || document.body, { attributes: true, childList: true, subtree: true, characterData: true }) + } + + this.abortHandler = this.#abort + this.importAggregator() + } + + /** Restoration and resource release tasks to be done if SPA loader is being aborted. Unwind changes to globals and subscription to DOM events. */ + #abort () { + this.removeOnAbort?.abort() + this.abortHandler = undefined // weakly allow this abort op to run only once + } +} diff --git a/src/features/page_view_event/aggregate/index.js b/src/features/page_view_event/aggregate/index.js index 5954b30e9..58b3d67ac 100644 --- a/src/features/page_view_event/aggregate/index.js +++ b/src/features/page_view_event/aggregate/index.js @@ -109,6 +109,7 @@ export class Aggregate extends AggregateBase { activateFeatures(JSON.parse(responseText), this.agentIdentifier) this.drain() } catch (err) { + console.error(err) this.ee.abort() warn('RUM call failed. Agent shutting down.') } diff --git a/src/features/spa/aggregate/index.js b/src/features/spa/aggregate/index.js index 7ae49b20a..8ee075946 100644 --- a/src/features/spa/aggregate/index.js +++ b/src/features/spa/aggregate/index.js @@ -720,6 +720,8 @@ export class Aggregate extends AggregateBase { } baseEE.emit('interactionSaved', [interaction]) state.interactionsToHarvest.push(interaction) + + // console.log('Real spa -- ', state.interactionsToHarvest[0]) scheduler.scheduleHarvest(0) } diff --git a/src/features/utils/lazy-feature-loader.js b/src/features/utils/lazy-feature-loader.js index 8abdc5de4..799b46ee3 100644 --- a/src/features/utils/lazy-feature-loader.js +++ b/src/features/utils/lazy-feature-loader.js @@ -32,6 +32,8 @@ export function lazyFeatureLoader (featureName, featurePart) { return import(/* webpackChunkName: "session_trace-aggregate" */ '../session_trace/aggregate') case FEATURE_NAMES.spa: return import(/* webpackChunkName: "spa-aggregate" */ '../spa/aggregate') + case FEATURE_NAMES.basicSpa: + return import(/* webpackChunkName: "basic-spa-aggregate" */ '../basic_spa/aggregate') default: throw new Error(`Attempted to load unsupported agent feature: ${featureName} ${featurePart}`) } diff --git a/src/loaders/features/features.js b/src/loaders/features/features.js index f32b843c2..fbc94db4b 100644 --- a/src/loaders/features/features.js +++ b/src/loaders/features/features.js @@ -1,5 +1,6 @@ export const FEATURE_NAMES = { ajax: 'ajax', + basicSpa: 'basic_spa', jserrors: 'jserrors', metrics: 'metrics', pageAction: 'page_action', @@ -23,5 +24,6 @@ export const featurePriority = { [FEATURE_NAMES.sessionTrace]: 6, [FEATURE_NAMES.pageAction]: 7, [FEATURE_NAMES.spa]: 8, - [FEATURE_NAMES.sessionReplay]: 9 + [FEATURE_NAMES.sessionReplay]: 9, + [FEATURE_NAMES.basicSpa]: 10 } diff --git a/tests/assets/spa/xhr-before-load.html b/tests/assets/spa/xhr-before-load.html new file mode 100644 index 000000000..f0e7e1cd8 --- /dev/null +++ b/tests/assets/spa/xhr-before-load.html @@ -0,0 +1,28 @@ + + + + + RUM Unit Test + {init} + {config} + {loader} + + + +
This page fires an XHR and calls pushState when clicked
+ + + diff --git a/tests/assets/spa/xhr.html b/tests/assets/spa/xhr.html index de87e2fe9..9ec3f967b 100644 --- a/tests/assets/spa/xhr.html +++ b/tests/assets/spa/xhr.html @@ -9,19 +9,43 @@ {init} {config} {loader} +
This page fires an XHR and calls pushState when clicked
+ + diff --git a/tools/browsers-lists/browsers-supported.json b/tools/browsers-lists/browsers-supported.json index 0b8dbe126..4ec490644 100644 --- a/tools/browsers-lists/browsers-supported.json +++ b/tools/browsers-lists/browsers-supported.json @@ -4,15 +4,15 @@ "browserName": "chrome", "platformName": "Windows 11", "platform": "Windows 11", - "version": "107", - "browserVersion": "107" + "version": "108", + "browserVersion": "108" }, { "browserName": "chrome", "platformName": "Windows 11", "platform": "Windows 11", - "version": "110", - "browserVersion": "110" + "version": "111", + "browserVersion": "111" }, { "browserName": "chrome",