Skip to content

Commit 847b6a7

Browse files
authored
feat: Attribute original page URL to all PageViewTiming nodes (#1716)
1 parent 38fae07 commit 847b6a7

13 files changed

Lines changed: 133 additions & 77 deletions

File tree

src/common/constants/runtime.js

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

@@ -84,4 +84,18 @@ export const ffVersion = (() => {
8484
*/
8585
export const originTime = Date.now() - now()
8686

87-
export const supportsNavTimingL2 = () => typeof PerformanceNavigationTiming !== 'undefined' && globalScope?.performance?.getEntriesByType('navigation')?.[0]?.responseStart
87+
/**
88+
* Gets the first navigation entry from the Performance Timeline API.
89+
* Returns undefined if the entry is not available or invalid.
90+
* Matches web-vitals validation: checks that responseStart exists, is positive, and is not larger than current time.
91+
* See: https://github.com/GoogleChrome/web-vitals/issues/137
92+
* @returns {PerformanceNavigationTiming | undefined}
93+
*/
94+
export const getNavigationEntry = () => {
95+
const navigationEntry = globalScope?.performance?.getEntriesByType?.('navigation')?.[0]
96+
if (navigationEntry &&
97+
navigationEntry.responseStart > 0 &&
98+
navigationEntry.responseStart < globalScope.performance.now()) {
99+
return navigationEntry
100+
}
101+
}

src/common/util/script-tracker.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export function findScriptTimings () {
121121
const timings = { registeredAt: now(), reportedAt: undefined, fetchStart: 0, fetchEnd: 0, asset: undefined, type: 'unknown' }
122122
const stack = getDeepStackTrace()
123123
if (!stack) return timings
124-
const navUrl = globalScope.performance?.getEntriesByType('navigation')?.find(entry => entry.initiatorType === 'navigation')?.name || ''
124+
const navUrl = globalScope.performance?.getEntriesByType('navigation')?.[0]?.name || ''
125125

126126
try {
127127
const urls = extractUrlsFromStack(stack)

src/common/vitals/largest-contentful-paint.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ if (isBrowserScope) {
2929
if (lcpEntry.element?.tagName) attrs.elTag = lcpEntry.element.tagName
3030
}
3131
if (attribution.element) attrs.element = attribution.element
32-
if (attribution.navigationEntry) attrs.pageUrl = cleanURL(attribution.navigationEntry.name) // used to ensure the LCP gets the correct URL at harvest time if a soft nav has occurred before page load
3332
if (attribution.url) attrs.elUrl = cleanURL(attribution.url)
3433

3534
largestContentfulPaint.update({ value, attrs })

src/common/vitals/load-time.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
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
*/
5-
import { globalScope, isBrowserScope, originTime, supportsNavTimingL2 } from '../constants/runtime'
5+
import { globalScope, isBrowserScope, originTime, getNavigationEntry } from '../constants/runtime'
66
import { onWindowLoad } from '../window/load'
77
import { VITAL_NAMES } from './constants'
88
import { VitalMetric } from './vital-metric'
@@ -13,8 +13,9 @@ if (isBrowserScope) {
1313
const perf = globalScope.performance
1414
const handler = () => {
1515
if (!loadTime.isValid && perf) {
16+
const navEntry = getNavigationEntry()
1617
loadTime.update({
17-
value: supportsNavTimingL2() ? perf.getEntriesByType('navigation')?.[0]?.loadEventEnd : perf.timing?.loadEventEnd - originTime
18+
value: navEntry ? navEntry.loadEventEnd : (perf.timing?.loadEventEnd - originTime)
1819
})
1920
}
2021
}

src/common/vitals/time-to-first-byte.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
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
*/
5-
import { globalScope, isBrowserScope, isiOS, originTime, supportsNavTimingL2 } from '../constants/runtime'
5+
import { globalScope, isBrowserScope, isiOS, originTime, getNavigationEntry } from '../constants/runtime'
66
import { VITAL_NAMES } from './constants'
77
import { VitalMetric } from './vital-metric'
88
import { onTTFB } from 'web-vitals/attribution'
@@ -17,7 +17,7 @@ export const timeToFirstByte = new VitalMetric(VITAL_NAMES.TIME_TO_FIRST_BYTE)
1717
* - cross-origin iframes specifically in firefox and safari
1818
* - onTTFB relies on a truthy `responseStart` value, should ensure that exists before relying on it (seen to be falsy in certain Electron.js cases for instance)
1919
*/
20-
if (isBrowserScope && supportsNavTimingL2() && !isiOS && window === window.parent) {
20+
if (isBrowserScope && getNavigationEntry() && !isiOS && window === window.parent) {
2121
onTTFB(({ value, attribution }) => {
2222
if (timeToFirstByte.isValid) return
2323
timeToFirstByte.update({ value, attrs: { navigationEntry: attribution.navigationEntry } })

src/features/page_view_event/aggregate/index.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Copyright 2020-2026 New Relic, Inc. All rights reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
5-
import { globalScope, isBrowserScope, originTime, supportsNavTimingL2 } from '../../../common/constants/runtime'
5+
import { globalScope, isBrowserScope, originTime, getNavigationEntry } from '../../../common/constants/runtime'
66
import { addPT, addPN } from '../../../common/timing/nav-timing'
77
import { stringify } from '../../../common/util/stringify'
88
import { isValid } from '../../../common/config/info'
@@ -91,8 +91,8 @@ export class Aggregate extends AggregateBase {
9191
let body = applyFnToProps({ ja: { ...customAttributes, webdriverDetected } }, this.obfuscator.obfuscateString.bind(this.obfuscator), 'string')
9292

9393
if (globalScope.performance) {
94-
if (supportsNavTimingL2()) { // Navigation Timing level 2 API that replaced PerformanceTiming & PerformanceNavigation
95-
const navTimingEntry = globalScope?.performance?.getEntriesByType('navigation')?.[0]
94+
const navTimingEntry = getNavigationEntry()
95+
if (navTimingEntry) { // Navigation Timing level 2 API that replaced PerformanceTiming & PerformanceNavigation
9696
const perf = ({
9797
timing: addPT(originTime, navTimingEntry, {}),
9898
navigation: addPN(navTimingEntry, {})

src/features/page_view_timing/aggregate/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ import { interactionToNextPaint } from '../../../common/vitals/interaction-to-ne
1616
import { largestContentfulPaint } from '../../../common/vitals/largest-contentful-paint'
1717
import { subscribeToVisibilityChange } from '../../../common/window/page-visibility'
1818
import { VITAL_NAMES } from '../../../common/vitals/constants'
19-
import { initiallyHidden } from '../../../common/constants/runtime'
19+
import { initiallyHidden, getNavigationEntry, initialLocation } from '../../../common/constants/runtime'
2020
import { eventOrigin } from '../../../common/util/event-origin'
2121
import { loadTime } from '../../../common/vitals/load-time'
2222
import { webdriverDetected } from '../../../common/util/webdriver-detection'
23+
import { cleanURL } from '../../../common/url/clean-url'
2324

2425
export class Aggregate extends AggregateBase {
2526
static featureName = FEATURE_NAME
@@ -71,6 +72,8 @@ export class Aggregate extends AggregateBase {
7172

7273
addTiming (name, value, attrs) {
7374
attrs = attrs || {}
75+
attrs.pageUrl = cleanURL(getNavigationEntry()?.name || initialLocation)
76+
7477
addConnectionAttributes(attrs) // network conditions may differ from the actual for VitalMetrics when they were captured
7578

7679
// If cls was set to another value by `onCLS`, then it's supported and is attached onto any timing but is omitted until such time.

src/features/session_trace/aggregate/index.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
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 { registerHandler } from '../../../common/event-emitter/register-handler'
66
import { FEATURE_NAME } from '../constants'
77
import { AggregateBase } from '../../utils/aggregate-base'
88
import { TraceStorage } from './trace/storage'
99
import { obj as encodeObj } from '../../../common/url/encode'
10-
import { globalScope, supportsNavTimingL2 } from '../../../common/constants/runtime'
10+
import { globalScope, getNavigationEntry } from '../../../common/constants/runtime'
1111
import { MODE, SESSION_EVENTS } from '../../../common/session/constants'
1212
import { applyFnToProps } from '../../../common/util/traverse'
1313
import { cleanURL } from '../../../common/url/clean-url'
@@ -62,8 +62,9 @@ export class Aggregate extends AggregateBase {
6262
if (this.sessionId !== sessionState.value || (eventType === 'cross-tab' && sessionState.sessionTraceMode === MODE.OFF)) this.abort(2)
6363
})
6464

65-
if (supportsNavTimingL2()) {
66-
this.traceStorage.storeTiming(globalScope.performance?.getEntriesByType?.('navigation')[0])
65+
const navEntry = getNavigationEntry()
66+
if (navEntry) {
67+
this.traceStorage.storeTiming(navEntry)
6768
} else {
6869
this.traceStorage.storeTiming(globalScope.performance?.timing, true)
6970
}

tests/unit/common/util/script-tracker.test.js

Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ let performanceObserverCallback
1313
let scriptTrackerModule
1414
// Allows tests to control Error.stack content
1515
let mockStack = null
16+
// Control what getNavigationEntry returns
17+
let mockNavigationEntry = null
1618

1719
// Helper to construct error with custom stack
1820
const OriginalError = global.Error
@@ -26,12 +28,18 @@ class MockError extends OriginalError {
2628
}
2729
}
2830

31+
jest.mock('../../../../src/common/constants/runtime', () => ({
32+
globalScope: global.window,
33+
getNavigationEntry: () => mockNavigationEntry
34+
}))
35+
2936
beforeEach(() => {
3037
// Reset module state to ensure clean test isolation
3138
jest.resetModules()
3239
jest.clearAllMocks()
3340
performanceObserverCallback = null
3441
mockStack = null
42+
mockNavigationEntry = null
3543

3644
// Setup custom Error class to control stack traces
3745
global.Error = MockError
@@ -97,9 +105,14 @@ describe('script-tracker', () => {
97105

98106
test('identifies inline script when URL matches navigation', () => {
99107
// Setup: Mock navigation entry with page URL
108+
mockNavigationEntry = { name: 'https://example.com/page.html' }
109+
100110
global.performance.getEntriesByType = jest.fn((type) => {
101111
if (type === 'navigation') {
102-
return [{ initiatorType: 'navigation', name: 'https://example.com/page.html' }]
112+
return [mockNavigationEntry]
113+
}
114+
if (type === 'resource') {
115+
return []
103116
}
104117
return []
105118
})
@@ -127,10 +140,9 @@ describe('script-tracker', () => {
127140
responseEnd: 250.8
128141
}
129142

143+
mockNavigationEntry = { name: 'https://example.com/' }
144+
130145
global.performance.getEntriesByType = jest.fn((type) => {
131-
if (type === 'navigation') {
132-
return [{ initiatorType: 'navigation', name: 'https://example.com/' }]
133-
}
134146
if (type === 'resource') {
135147
return [mockResourceEntry]
136148
}
@@ -163,13 +175,9 @@ describe('script-tracker', () => {
163175
}
164176

165177
// Setup: Script not in static buffer, only in observer
178+
mockNavigationEntry = { name: 'https://example.com/' }
179+
166180
global.performance.getEntriesByType = jest.fn((type) => {
167-
if (type === 'navigation') {
168-
return [{ initiatorType: 'navigation', name: 'https://example.com/' }]
169-
}
170-
if (type === 'resource') {
171-
return [] // Not in static buffer
172-
}
173181
return []
174182
})
175183

@@ -209,10 +217,9 @@ describe('script-tracker', () => {
209217
responseEnd: 120.5
210218
}
211219

220+
mockNavigationEntry = { name: 'https://example.com/' }
221+
212222
global.performance.getEntriesByType = jest.fn((type) => {
213-
if (type === 'navigation') {
214-
return [{ initiatorType: 'navigation', name: 'https://example.com/' }]
215-
}
216223
return []
217224
})
218225

@@ -254,10 +261,9 @@ describe('script-tracker', () => {
254261
return []
255262
})
256263

264+
mockNavigationEntry = { name: 'https://example.com/' }
265+
257266
global.performance.getEntriesByType = jest.fn((type) => {
258-
if (type === 'navigation') {
259-
return [{ initiatorType: 'navigation', name: 'https://example.com/' }]
260-
}
261267
return []
262268
})
263269

@@ -294,10 +300,9 @@ describe('script-tracker', () => {
294300
return []
295301
})
296302

303+
mockNavigationEntry = { name: 'https://example.com/' }
304+
297305
global.performance.getEntriesByType = jest.fn((type) => {
298-
if (type === 'navigation') {
299-
return [{ initiatorType: 'navigation', name: 'https://example.com/' }]
300-
}
301306
return []
302307
})
303308

@@ -426,10 +431,9 @@ describe('script-tracker', () => {
426431
responseEnd: 200
427432
}
428433

434+
mockNavigationEntry = { name: 'https://example.com/' }
435+
429436
global.performance.getEntriesByType = jest.fn((type) => {
430-
if (type === 'navigation') {
431-
return [{ initiatorType: 'navigation', name: 'https://example.com/' }]
432-
}
433437
if (type === 'resource') {
434438
return [mockResourceEntry]
435439
}
@@ -458,10 +462,9 @@ describe('script-tracker', () => {
458462
responseEnd: 200
459463
}
460464

465+
mockNavigationEntry = { name: 'https://example.com/' }
466+
461467
global.performance.getEntriesByType = jest.fn((type) => {
462-
if (type === 'navigation') {
463-
return [{ initiatorType: 'navigation', name: 'https://example.com/' }]
464-
}
465468
if (type === 'resource') {
466469
return [mockResourceEntry]
467470
}
@@ -487,10 +490,9 @@ describe('script-tracker', () => {
487490
responseEnd: 180.6
488491
}
489492

493+
mockNavigationEntry = { name: 'https://example.com/' }
494+
490495
global.performance.getEntriesByType = jest.fn((type) => {
491-
if (type === 'navigation') {
492-
return [{ initiatorType: 'navigation', name: 'https://example.com/' }]
493-
}
494496
if (type === 'resource') {
495497
return [mockResourceEntry]
496498
}
@@ -519,10 +521,9 @@ init@https://cdn.example.com/gecko-app.js:20:10`
519521
responseEnd: 210.4
520522
}
521523

524+
mockNavigationEntry = { name: 'https://example.com/' }
525+
522526
global.performance.getEntriesByType = jest.fn((type) => {
523-
if (type === 'navigation') {
524-
return [{ initiatorType: 'navigation', name: 'https://example.com/' }]
525-
}
526527
if (type === 'resource') {
527528
return [mockResourceEntry]
528529
}
@@ -552,10 +553,9 @@ init@https://cdn.example.com/gecko-app.js:20:10`
552553
responseEnd: 150
553554
}
554555

556+
mockNavigationEntry = { name: 'https://example.com/' }
557+
555558
global.performance.getEntriesByType = jest.fn((type) => {
556-
if (type === 'navigation') {
557-
return [{ initiatorType: 'navigation', name: 'https://example.com/' }]
558-
}
559559
if (type === 'resource') {
560560
return [mockResourceEntry]
561561
}

0 commit comments

Comments
 (0)