Skip to content

Commit

Permalink
fix: Obtain FirstInteraction directly from performance API (#1410)
Browse files Browse the repository at this point in the history
Co-authored-by: Pik Tang <[email protected]>
  • Loading branch information
metal-messiah and ptang-nr authored Mar 11, 2025
1 parent 6660c44 commit 22ef4ff
Show file tree
Hide file tree
Showing 10 changed files with 183 additions and 175 deletions.
36 changes: 36 additions & 0 deletions src/common/util/event-origin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Copyright 2020-2025 New Relic, Inc. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Returns a string representing the origin of an event target. Used by SessionTrace and PageViewTiming features to assign a "better" target to events
* @param {*} t The target to derive the origin from.
* @param {*} [target] A known target to compare to. If supplied, and a derived origin could not be reached, this will be referenced.
* @param {*} [ee] An event emitter instance to use for context retrieval, which only applies to XMLHttpRequests.
* @returns {string} The derived origin of the event target.
*/
export function eventOrigin (t, target, ee) {
let origin = 'unknown'

if (t && t instanceof XMLHttpRequest) {
const params = ee.context(t).params
if (!params || !params.status || !params.method || !params.host || !params.pathname) return 'xhrOriginMissing'
origin = params.status + ' ' + params.method + ': ' + params.host + params.pathname
} else if (t && typeof (t.tagName) === 'string') {
origin = t.tagName.toLowerCase()
if (t.id) origin += '#' + t.id
if (t.className) {
for (let i = 0; i < t.classList.length; i++) origin += '.' + t.classList[i]
}
}

if (origin === 'unknown') {
if (typeof target === 'string') origin = target
else if (target === document) origin = 'document'
else if (target === window) origin = 'window'
else if (target instanceof FileReader) origin = 'FileReader'
}

return origin
}
21 changes: 1 addition & 20 deletions src/common/vitals/interaction-to-next-paint.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,11 @@
import { onINP } from 'web-vitals/attribution'
import { VitalMetric } from './vital-metric'
import { VITAL_NAMES } from './constants'
import { initiallyHidden, isBrowserScope } from '../constants/runtime'
import { isBrowserScope } from '../constants/runtime'

export const interactionToNextPaint = new VitalMetric(VITAL_NAMES.INTERACTION_TO_NEXT_PAINT)
// Note: First Interaction is a legacy NR timing event, not an actual CWV metric
// ('fi' used to be detected via FID. It is now represented by the first INP)
export const firstInteraction = new VitalMetric(VITAL_NAMES.FIRST_INTERACTION)

if (isBrowserScope) {
const recordFirstInteraction = (attribution) => {
firstInteraction.update({
value: attribution.interactionTime,
attrs: {
type: attribution.interactionType,
eventTarget: attribution.interactionTarget,
loadState: attribution.loadState
}
})
}

/* Interaction-to-Next-Paint */
onINP(({ value, attribution, id }) => {
const attrs = {
Expand All @@ -40,10 +26,5 @@ if (isBrowserScope) {
loadState: attribution.loadState
}
interactionToNextPaint.update({ value, attrs })

// preserve the original behavior where FID is not reported if the page is hidden before the first interaction
if (!firstInteraction.isValid && !initiallyHidden) {
recordFirstInteraction(attribution)
}
})
}
38 changes: 34 additions & 4 deletions src/features/page_view_timing/aggregate/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import { AggregateBase } from '../../utils/aggregate-base'
import { cumulativeLayoutShift } from '../../../common/vitals/cumulative-layout-shift'
import { firstContentfulPaint } from '../../../common/vitals/first-contentful-paint'
import { firstPaint } from '../../../common/vitals/first-paint'
import { firstInteraction, interactionToNextPaint } from '../../../common/vitals/interaction-to-next-paint'
import { interactionToNextPaint } from '../../../common/vitals/interaction-to-next-paint'
import { largestContentfulPaint } from '../../../common/vitals/largest-contentful-paint'
import { timeToFirstByte } from '../../../common/vitals/time-to-first-byte'
import { subscribeToVisibilityChange } from '../../../common/window/page-visibility'
import { VITAL_NAMES } from '../../../common/vitals/constants'
import { initiallyHidden } from '../../../common/constants/runtime'
import { eventOrigin } from '../../../common/util/event-origin'

export class Aggregate extends AggregateBase {
static featureName = FEATURE_NAME
Expand All @@ -28,6 +30,7 @@ export class Aggregate extends AggregateBase {
constructor (agentRef) {
super(agentRef, FEATURE_NAME)
this.curSessEndRecorded = false
this.firstIxnRecorded = false

registerHandler('docHidden', msTimestamp => this.endCurrentSession(msTimestamp), this.featureName, this.ee)
// Add the time of _window pagehide event_ firing to the next PVT harvest == NRDB windowUnload attr:
Expand All @@ -37,7 +40,6 @@ export class Aggregate extends AggregateBase {
firstPaint.subscribe(this.#handleVitalMetric)
firstContentfulPaint.subscribe(this.#handleVitalMetric)
largestContentfulPaint.subscribe(this.#handleVitalMetric)
firstInteraction.subscribe(this.#handleVitalMetric)
interactionToNextPaint.subscribe(this.#handleVitalMetric)
timeToFirstByte.subscribe(({ attrs }) => {
this.addTiming('load', Math.round(attrs.navigationEntry.loadEventEnd))
Expand Down Expand Up @@ -82,13 +84,36 @@ export class Aggregate extends AggregateBase {
attrs.cls = cumulativeLayoutShift.current.value
}

this.events.add({
const timing = {
name,
value,
attrs
})
}
this.events.add(timing)

handle('pvtAdded', [name, value, attrs], undefined, FEATURE_NAMES.sessionTrace, this.ee)

this.checkForFirstInteraction()

// makes testing easier
return timing
}

/**
* Checks the performance API to see if the agent can set a first interaction event value
* @returns {void}
*/
checkForFirstInteraction () {
// preserve the original behavior where FID is not reported if the page is hidden before the first interaction
if (this.firstIxnRecorded || initiallyHidden || !performance) return
const firstInput = performance.getEntriesByType('first-input')[0]
if (!firstInput) return
this.firstIxnRecorded = true
this.addTiming('fi', firstInput.startTime, {
type: firstInput.name,
eventTarget: eventOrigin(firstInput.target),
loadState: document.readyState
})
}

appendGlobalCustomAttributes (timing) {
Expand All @@ -103,6 +128,11 @@ export class Aggregate extends AggregateBase {
})
}

preHarvestChecks () {
this.checkForFirstInteraction()
return super.preHarvestChecks()
}

// serialize array of timing data
serializer (eventBuffer) {
var addString = getAddStringContext(this.agentIdentifier)
Expand Down
32 changes: 4 additions & 28 deletions src/features/session_trace/aggregate/trace/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { globalScope } from '../../../../common/constants/runtime'
import { MODE } from '../../../../common/session/constants'
import { now } from '../../../../common/timing/now'
import { parseUrl } from '../../../../common/url/parse-url'
import { eventOrigin } from '../../../../common/util/event-origin'
import { MAX_NODES_PER_HARVEST } from '../../constants'
import { TraceNode } from './node'

Expand Down Expand Up @@ -173,15 +174,15 @@ export class TraceStorage {
try {
// webcomponents-lite.js can trigger an exception on currentEvent.target getter because
// it does not check currentEvent.currentTarget before calling getRootNode() on it
evt.o = this.evtOrigin(currentEvent.target, target)
evt.o = eventOrigin(currentEvent.target, target, this.parent.ee)
} catch (e) {
evt.o = this.evtOrigin(null, target)
evt.o = eventOrigin(null, target, this.parent.ee)
}
this.storeSTN(evt)
}

shouldIgnoreEvent (event, target) {
const origin = this.evtOrigin(event.target, target)
const origin = eventOrigin(event.target, target, this.parent.ee)
if (event.type in ignoredEvents.global) return true
if (!!ignoredEvents[origin] && ignoredEvents[origin].ignoreAll) return true
return !!(!!ignoredEvents[origin] && event.type in ignoredEvents[origin])
Expand Down Expand Up @@ -213,31 +214,6 @@ export class TraceStorage {
}
}

evtOrigin (t, target) {
let origin = 'unknown'

if (t && t instanceof XMLHttpRequest) {
const params = this.parent.ee.context(t).params
if (!params || !params.status || !params.method || !params.host || !params.pathname) return 'xhrOriginMissing'
origin = params.status + ' ' + params.method + ': ' + params.host + params.pathname
} else if (t && typeof (t.tagName) === 'string') {
origin = t.tagName.toLowerCase()
if (t.id) origin += '#' + t.id
if (t.className) {
for (let i = 0; i < t.classList.length; i++) origin += '.' + t.classList[i]
}
}

if (origin === 'unknown') {
if (typeof target === 'string') origin = target
else if (target === document) origin = 'document'
else if (target === window) origin = 'window'
else if (target instanceof FileReader) origin = 'FileReader'
}

return origin
}

// Tracks when the window history API specified by wrap-history is used.
storeHist (path, old, time) {
this.storeSTN(new TraceNode('history.pushState', time, time, path, old))
Expand Down
23 changes: 22 additions & 1 deletion tests/components/page_view_timing/aggregate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,25 @@ beforeAll(async () => {
downlink: 700
}

Object.defineProperty(performance, 'getEntriesByType', {
value: jest.fn().mockImplementation(entryType => {
return [
{
cancelable: true,
duration: 17,
entryType,
name: 'pointer',
processingEnd: 8860,
processingStart: 8859,
startTime: 8853,
target: { tagName: 'button' }
}
]
}),
configurable: true,
writable: true
})

mainAgent = setupAgent()
})

Expand Down Expand Up @@ -86,7 +105,7 @@ test('LCP event with CLS attribute', () => {
}
})

test('sends expected FI attributes when available', () => {
test('sends expected FI *once* with attributes when available', () => {
expect(timingsAggregate.events.get()[0].data.length).toBeGreaterThanOrEqual(1)
const fiPayload = timingsAggregate.events.get()[0].data.find(x => x.name === 'fi')
expect(fiPayload.value).toEqual(8853) // event time data is sent in ms
Expand All @@ -96,6 +115,8 @@ test('sends expected FI attributes when available', () => {
cls: 0.1119,
...expectedNetworkInfo
}))

expect(timingsAggregate.events.get()[0].data.filter(x => x.name === 'fi').length).toEqual(1)
})

test('sends CLS node with right val on vis change', () => {
Expand Down
19 changes: 19 additions & 0 deletions tests/components/page_view_timing/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ const expectedNetworkInfo = {
'net-dlink': expect.any(Number)
}

Object.defineProperty(performance, 'getEntriesByType', {
value: jest.fn().mockImplementation(entryType => {
return [
{
cancelable: true,
duration: 17,
entryType,
name: 'pointer',
processingEnd: 8860,
processingStart: 8859,
startTime: 8853,
target: { tagName: 'button' }
}
]
}),
configurable: true,
writable: true
})

let pvtAgg
const agentIdentifier = 'abcd'
describe('pvt aggregate tests', () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/specs/pvt/timings.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('pvt timings tests', () => {
expect(fi.value).toBeGreaterThanOrEqual(0)
expect(fi.value).toBeLessThan(Date.now() - start)

const isClickInteractionType = type => type === 'pointer'
const isClickInteractionType = type => type === 'mousedown' || type === 'pointerdown'
const fiType = fi.attributes.find(attr => attr.key === 'type')
expect(isClickInteractionType(fiType.value)).toEqual(true)
expect(fiType.type).toEqual('stringAttribute')
Expand Down
41 changes: 38 additions & 3 deletions tests/unit/common/vitals/first-contentful-paint.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,19 @@ describe('fcp', () => {
initiallyHidden: false,
isBrowserScope: true
}))
global.performance.getEntriesByType = jest.fn(() => [{ name: 'first-contentful-paint', startTime: 1 }])

Object.defineProperty(performance, 'getEntriesByType', {
value: jest.fn().mockImplementation(entryType => {
return [
{
name: 'first-contentful-paint',
startTime: 1
}
]
}),
configurable: true,
writable: true
})

getFreshFCPImport(firstContentfulPaint => firstContentfulPaint.subscribe(({ value }) => {
expect(value).toEqual(1)
Expand Down Expand Up @@ -66,7 +78,19 @@ describe('fcp', () => {
initiallyHidden: false,
isBrowserScope: true
}))
global.performance.getEntriesByType = jest.fn(() => [{ name: 'other-timing-name', startTime: 1 }])

Object.defineProperty(performance, 'getEntriesByType', {
value: jest.fn().mockImplementation(entryType => {
return [
{
name: 'other-timing-name',
startTime: 1
}
]
}),
configurable: true,
writable: true
})

getFreshFCPImport(firstContentfulPaint => {
firstContentfulPaint.subscribe(() => {
Expand All @@ -84,7 +108,18 @@ describe('fcp', () => {
initiallyHidden: true,
isBrowserScope: true
}))
global.performance.getEntriesByType = jest.fn(() => [{ name: 'first-contentful-paint', startTime: 1 }])
Object.defineProperty(performance, 'getEntriesByType', {
value: jest.fn().mockImplementation(entryType => {
return [
{
name: 'first-contentful-paint',
startTime: 1
}
]
}),
configurable: true,
writable: true
})

getFreshFCPImport(firstContentfulPaint => {
firstContentfulPaint.subscribe(() => {
Expand Down
Loading

0 comments on commit 22ef4ff

Please sign in to comment.