@newrelic/browser-agent 1.324.1-rc.2 → 1.324.1-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,7 @@ exports.VERSION = exports.RRWEB_VERSION = exports.RRWEB_PACKAGE_NAME = exports.D
17
17
  /**
18
18
  * Exposes the version of the agent
19
19
  */
20
- const VERSION = exports.VERSION = "1.324.1-rc.2";
20
+ const VERSION = exports.VERSION = "1.324.1-rc.3";
21
21
 
22
22
  /**
23
23
  * Exposes the build type of the agent
@@ -17,7 +17,7 @@ exports.VERSION = exports.RRWEB_VERSION = exports.RRWEB_PACKAGE_NAME = exports.D
17
17
  /**
18
18
  * Exposes the version of the agent
19
19
  */
20
- const VERSION = exports.VERSION = "1.324.1-rc.2";
20
+ const VERSION = exports.VERSION = "1.324.1-rc.3";
21
21
 
22
22
  /**
23
23
  * Exposes the build type of the agent
@@ -4,10 +4,12 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.VitalMetric = void 0;
7
+ var _now = require("../timing/now");
7
8
  /**
8
9
  * Copyright 2020-2026 New Relic, Inc. All rights reserved.
9
10
  * SPDX-License-Identifier: Apache-2.0
10
11
  */
12
+
11
13
  class VitalMetric {
12
14
  #subscribers = new Set();
13
15
  history = [];
@@ -16,16 +18,29 @@ class VitalMetric {
16
18
  this.attrs = {};
17
19
  this.roundingMethod = typeof roundingMethod === 'function' ? roundingMethod : Math.floor;
18
20
  }
21
+
22
+ /**
23
+ * Records a new value for this metric and notifies subscribers.
24
+ * @param {object} params
25
+ * @param {number} params.value The metric's value. For most metrics this is itself a ms offset from page origin
26
+ * (i.e. the metric's own real time of occurrence); for score-based metrics like CLS it isn't a time at all.
27
+ * @param {object} [params.attrs] Metric-specific attribution attrs.
28
+ */
19
29
  update({
20
30
  value,
21
31
  attrs = {}
22
32
  }) {
23
33
  if (value === undefined || value === null || value < 0) return;
34
+ const metricTimestamps = {
35
+ inp: attrs.interactionTime ?? (0, _now.now)(),
36
+ cls: attrs.largestShiftTime ?? (0, _now.now)()
37
+ };
24
38
  const state = {
25
39
  value: this.roundingMethod(value),
26
40
  name: this.name,
27
41
  // optional web-vitals attribution fields are omitted rather than being serialized as null attributes
28
- attrs: Object.fromEntries(Object.entries(attrs).filter(([, attrValue]) => attrValue !== undefined))
42
+ attrs: Object.fromEntries(Object.entries(attrs).filter(([, attrValue]) => attrValue !== undefined)),
43
+ relativeTimestamp: metricTimestamps[this.name] ?? value
29
44
  };
30
45
  this.history.push(state);
31
46
  this.#subscribers.forEach(cb => {
@@ -35,9 +35,10 @@ class Aggregate extends _aggregateBase.AggregateBase {
35
35
  #handleVitalMetric = ({
36
36
  name,
37
37
  value,
38
- attrs
38
+ attrs,
39
+ relativeTimestamp
39
40
  }) => {
40
- this.addTiming(name, value, attrs);
41
+ this.addTiming(name, value, attrs, relativeTimestamp);
41
42
  };
42
43
  constructor(agentRef) {
43
44
  super(agentRef, _constants.FEATURE_NAME);
@@ -49,7 +50,7 @@ class Aggregate extends _aggregateBase.AggregateBase {
49
50
  this.obfuscator = new _obfuscate.Obfuscator(agentRef, _events.EVENT_TYPES.PVT);
50
51
  (0, _registerHandler.registerHandler)('docHidden', msTimestamp => this.endCurrentSession(msTimestamp), this.featureName, this.ee);
51
52
  // Add the time of _window pagehide event_ firing to the next PVT harvest == NRDB windowUnload attr:
52
- (0, _registerHandler.registerHandler)('winPagehide', msTimestamp => this.addTiming('unload', msTimestamp, null), this.featureName, this.ee);
53
+ (0, _registerHandler.registerHandler)('winPagehide', msTimestamp => this.addTiming('unload', msTimestamp), this.featureName, this.ee);
53
54
  this.waitForFlags([]).then(() => {
54
55
  _firstPaint.firstPaint.subscribe(this.#handleVitalMetric);
55
56
  _firstContentfulPaint.firstContentfulPaint.subscribe(this.#handleVitalMetric);
@@ -68,10 +69,11 @@ class Aggregate extends _aggregateBase.AggregateBase {
68
69
  const {
69
70
  name,
70
71
  value,
71
- attrs
72
+ attrs,
73
+ relativeTimestamp
72
74
  } = _cumulativeLayoutShift.cumulativeLayoutShift.current;
73
75
  if (value === undefined) return;
74
- this.addTiming(name, value * 1000, attrs);
76
+ this.addTiming(name, value * 1000, attrs, relativeTimestamp);
75
77
  }, true, true); // CLS node should only report on vis change rather than on every change.
76
78
  /* NOTE: the capture=true is required -- empirically verified against a real browser (Chrome) for CLS
77
79
  timing node to reliably be in the same final harvest as the rest, alongside Harvester#startTimer's
@@ -89,12 +91,25 @@ class Aggregate extends _aggregateBase.AggregateBase {
89
91
  endCurrentSession(timestamp) {
90
92
  if (!this.curSessEndRecorded) {
91
93
  // TO DO: stage 2 - we don't want to capture this timing twice on page navigating away, but it should run again if we return to page and away *again*
92
- this.addTiming('pageHide', timestamp, null);
94
+ this.addTiming('pageHide', timestamp);
93
95
  this.curSessEndRecorded = true;
94
96
  }
95
97
  }
96
- addTiming(name, value, attrs) {
97
- attrs = attrs || {};
98
+
99
+ /**
100
+ * Records a page view timing node, decorating it with system attributes (page URL, connection info,
101
+ * CLS-so-far, webdriver detection, and a server-corrected absolute timestamp) before buffering it for harvest.
102
+ * @param {string} name The timing node's name (e.g. 'fp', 'fcp', 'lcp', 'cls', 'fi', 'pageHide', 'unload').
103
+ * @param {number} value The timing node's value; a ms offset from page origin for most nodes, a duration for
104
+ * 'inp', and a unitless score for 'cls'.
105
+ * @param {object} [attrs] Additional custom attributes to attach to the node.
106
+ * @param {number} [relativeTimestamp] The ms offset from page origin at which the underlying event occurred. It is
107
+ * server-corrected into the node's absolute `timestamp` attribute, which is omitted if the time keeper isn't ready
108
+ * or this isn't a finite number. Supplied by VitalMetric for nodes whose `value` isn't itself the event's time
109
+ * (e.g. 'inp' uses the interaction time, 'cls' uses the largest shift time). Defaults to `value`.
110
+ * @returns {{name: string, value: number, attrs: object}} The recorded timing node.
111
+ */
112
+ addTiming(name, value, attrs = {}, relativeTimestamp = value) {
98
113
  attrs.pageUrl = (0, _cleanUrl.cleanURL)((0, _runtime.getNavigationEntry)()?.name || _runtime.initialLocation);
99
114
  addConnectionAttributes(attrs); // network conditions may differ from the actual for VitalMetrics when they were captured
100
115
 
@@ -110,6 +125,10 @@ class Aggregate extends _aggregateBase.AggregateBase {
110
125
  attrs.cls = _cumulativeLayoutShift.cumulativeLayoutShift.current.value;
111
126
  }
112
127
  attrs.webdriverDetected = _webdriverDetection.webdriverDetected;
128
+ const timeKeeper = this.agentRef.runtime.timeKeeper;
129
+ if (timeKeeper?.ready && Number.isFinite(relativeTimestamp)) {
130
+ attrs.timestamp = Math.floor(timeKeeper.correctRelativeTimestamp(relativeTimestamp));
131
+ }
113
132
  const timing = {
114
133
  name,
115
134
  value,
@@ -140,7 +159,7 @@ class Aggregate extends _aggregateBase.AggregateBase {
140
159
  });
141
160
  }
142
161
  #getGlobalCustomAttributes() {
143
- const reservedAttributes = ['size', 'eid', 'cls', 'type', 'fid', 'elTag', 'elUrl', 'net-type', 'net-etype', 'net-rtt', 'net-dlink', 'webdriverDetected'];
162
+ const reservedAttributes = ['size', 'eid', 'cls', 'type', 'fid', 'elTag', 'elUrl', 'net-type', 'net-etype', 'net-rtt', 'net-dlink', 'webdriverDetected', 'timestamp'];
144
163
  return Object.fromEntries(Object.entries(this.agentRef.info.jsAttributes || {}).filter(([key]) => !reservedAttributes.includes(key)));
145
164
  }
146
165
  preHarvestChecks() {
@@ -11,7 +11,7 @@
11
11
  /**
12
12
  * Exposes the version of the agent
13
13
  */
14
- export const VERSION = "1.324.1-rc.2";
14
+ export const VERSION = "1.324.1-rc.3";
15
15
 
16
16
  /**
17
17
  * Exposes the build type of the agent
@@ -11,7 +11,7 @@
11
11
  /**
12
12
  * Exposes the version of the agent
13
13
  */
14
- export const VERSION = "1.324.1-rc.2";
14
+ export const VERSION = "1.324.1-rc.3";
15
15
 
16
16
  /**
17
17
  * Exposes the build type of the agent
@@ -2,6 +2,7 @@
2
2
  * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
+ import { now } from '../timing/now';
5
6
  export class VitalMetric {
6
7
  #subscribers = new Set();
7
8
  history = [];
@@ -10,16 +11,29 @@ export class VitalMetric {
10
11
  this.attrs = {};
11
12
  this.roundingMethod = typeof roundingMethod === 'function' ? roundingMethod : Math.floor;
12
13
  }
14
+
15
+ /**
16
+ * Records a new value for this metric and notifies subscribers.
17
+ * @param {object} params
18
+ * @param {number} params.value The metric's value. For most metrics this is itself a ms offset from page origin
19
+ * (i.e. the metric's own real time of occurrence); for score-based metrics like CLS it isn't a time at all.
20
+ * @param {object} [params.attrs] Metric-specific attribution attrs.
21
+ */
13
22
  update({
14
23
  value,
15
24
  attrs = {}
16
25
  }) {
17
26
  if (value === undefined || value === null || value < 0) return;
27
+ const metricTimestamps = {
28
+ inp: attrs.interactionTime ?? now(),
29
+ cls: attrs.largestShiftTime ?? now()
30
+ };
18
31
  const state = {
19
32
  value: this.roundingMethod(value),
20
33
  name: this.name,
21
34
  // optional web-vitals attribution fields are omitted rather than being serialized as null attributes
22
- attrs: Object.fromEntries(Object.entries(attrs).filter(([, attrValue]) => attrValue !== undefined))
35
+ attrs: Object.fromEntries(Object.entries(attrs).filter(([, attrValue]) => attrValue !== undefined)),
36
+ relativeTimestamp: metricTimestamps[this.name] ?? value
23
37
  };
24
38
  this.history.push(state);
25
39
  this.#subscribers.forEach(cb => {
@@ -29,9 +29,10 @@ export class Aggregate extends AggregateBase {
29
29
  #handleVitalMetric = ({
30
30
  name,
31
31
  value,
32
- attrs
32
+ attrs,
33
+ relativeTimestamp
33
34
  }) => {
34
- this.addTiming(name, value, attrs);
35
+ this.addTiming(name, value, attrs, relativeTimestamp);
35
36
  };
36
37
  constructor(agentRef) {
37
38
  super(agentRef, FEATURE_NAME);
@@ -43,7 +44,7 @@ export class Aggregate extends AggregateBase {
43
44
  this.obfuscator = new Obfuscator(agentRef, EVENT_TYPES.PVT);
44
45
  registerHandler('docHidden', msTimestamp => this.endCurrentSession(msTimestamp), this.featureName, this.ee);
45
46
  // Add the time of _window pagehide event_ firing to the next PVT harvest == NRDB windowUnload attr:
46
- registerHandler('winPagehide', msTimestamp => this.addTiming('unload', msTimestamp, null), this.featureName, this.ee);
47
+ registerHandler('winPagehide', msTimestamp => this.addTiming('unload', msTimestamp), this.featureName, this.ee);
47
48
  this.waitForFlags([]).then(() => {
48
49
  firstPaint.subscribe(this.#handleVitalMetric);
49
50
  firstContentfulPaint.subscribe(this.#handleVitalMetric);
@@ -62,10 +63,11 @@ export class Aggregate extends AggregateBase {
62
63
  const {
63
64
  name,
64
65
  value,
65
- attrs
66
+ attrs,
67
+ relativeTimestamp
66
68
  } = cumulativeLayoutShift.current;
67
69
  if (value === undefined) return;
68
- this.addTiming(name, value * 1000, attrs);
70
+ this.addTiming(name, value * 1000, attrs, relativeTimestamp);
69
71
  }, true, true); // CLS node should only report on vis change rather than on every change.
70
72
  /* NOTE: the capture=true is required -- empirically verified against a real browser (Chrome) for CLS
71
73
  timing node to reliably be in the same final harvest as the rest, alongside Harvester#startTimer's
@@ -83,12 +85,25 @@ export class Aggregate extends AggregateBase {
83
85
  endCurrentSession(timestamp) {
84
86
  if (!this.curSessEndRecorded) {
85
87
  // TO DO: stage 2 - we don't want to capture this timing twice on page navigating away, but it should run again if we return to page and away *again*
86
- this.addTiming('pageHide', timestamp, null);
88
+ this.addTiming('pageHide', timestamp);
87
89
  this.curSessEndRecorded = true;
88
90
  }
89
91
  }
90
- addTiming(name, value, attrs) {
91
- attrs = attrs || {};
92
+
93
+ /**
94
+ * Records a page view timing node, decorating it with system attributes (page URL, connection info,
95
+ * CLS-so-far, webdriver detection, and a server-corrected absolute timestamp) before buffering it for harvest.
96
+ * @param {string} name The timing node's name (e.g. 'fp', 'fcp', 'lcp', 'cls', 'fi', 'pageHide', 'unload').
97
+ * @param {number} value The timing node's value; a ms offset from page origin for most nodes, a duration for
98
+ * 'inp', and a unitless score for 'cls'.
99
+ * @param {object} [attrs] Additional custom attributes to attach to the node.
100
+ * @param {number} [relativeTimestamp] The ms offset from page origin at which the underlying event occurred. It is
101
+ * server-corrected into the node's absolute `timestamp` attribute, which is omitted if the time keeper isn't ready
102
+ * or this isn't a finite number. Supplied by VitalMetric for nodes whose `value` isn't itself the event's time
103
+ * (e.g. 'inp' uses the interaction time, 'cls' uses the largest shift time). Defaults to `value`.
104
+ * @returns {{name: string, value: number, attrs: object}} The recorded timing node.
105
+ */
106
+ addTiming(name, value, attrs = {}, relativeTimestamp = value) {
92
107
  attrs.pageUrl = cleanURL(getNavigationEntry()?.name || initialLocation);
93
108
  addConnectionAttributes(attrs); // network conditions may differ from the actual for VitalMetrics when they were captured
94
109
 
@@ -104,6 +119,10 @@ export class Aggregate extends AggregateBase {
104
119
  attrs.cls = cumulativeLayoutShift.current.value;
105
120
  }
106
121
  attrs.webdriverDetected = webdriverDetected;
122
+ const timeKeeper = this.agentRef.runtime.timeKeeper;
123
+ if (timeKeeper?.ready && Number.isFinite(relativeTimestamp)) {
124
+ attrs.timestamp = Math.floor(timeKeeper.correctRelativeTimestamp(relativeTimestamp));
125
+ }
107
126
  const timing = {
108
127
  name,
109
128
  value,
@@ -134,7 +153,7 @@ export class Aggregate extends AggregateBase {
134
153
  });
135
154
  }
136
155
  #getGlobalCustomAttributes() {
137
- const reservedAttributes = ['size', 'eid', 'cls', 'type', 'fid', 'elTag', 'elUrl', 'net-type', 'net-etype', 'net-rtt', 'net-dlink', 'webdriverDetected'];
156
+ const reservedAttributes = ['size', 'eid', 'cls', 'type', 'fid', 'elTag', 'elUrl', 'net-type', 'net-etype', 'net-rtt', 'net-dlink', 'webdriverDetected', 'timestamp'];
138
157
  return Object.fromEntries(Object.entries(this.agentRef.info.jsAttributes || {}).filter(([key]) => !reservedAttributes.includes(key)));
139
158
  }
140
159
  preHarvestChecks() {
@@ -1,16 +1,19 @@
1
- /**
2
- * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
- * SPDX-License-Identifier: Apache-2.0
4
- */
5
1
  export class VitalMetric {
6
2
  constructor(name: any, roundingMethod: any);
7
3
  history: any[];
8
4
  name: any;
9
5
  attrs: {};
10
6
  roundingMethod: any;
7
+ /**
8
+ * Records a new value for this metric and notifies subscribers.
9
+ * @param {object} params
10
+ * @param {number} params.value The metric's value. For most metrics this is itself a ms offset from page origin
11
+ * (i.e. the metric's own real time of occurrence); for score-based metrics like CLS it isn't a time at all.
12
+ * @param {object} [params.attrs] Metric-specific attribution attrs.
13
+ */
11
14
  update({ value, attrs }: {
12
- value: any;
13
- attrs?: {} | undefined;
15
+ value: number;
16
+ attrs?: object | undefined;
14
17
  }): void;
15
18
  get current(): any;
16
19
  get isValid(): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"vital-metric.d.ts","sourceRoot":"","sources":["../../../../src/common/vitals/vital-metric.js"],"names":[],"mappings":"AAAA;;;GAGG;AACH;IAIE,4CAIC;IAND,eAAY;IAGV,UAAgB;IAChB,UAAe;IACf,oBAAwF;IAG1F;;;aAiBC;IAED,mBAMC;IAED,uBAEC;IAED,uEAMC;;CACF"}
1
+ {"version":3,"file":"vital-metric.d.ts","sourceRoot":"","sources":["../../../../src/common/vitals/vital-metric.js"],"names":[],"mappings":"AAMA;IAIE,4CAIC;IAND,eAAY;IAGV,UAAgB;IAChB,UAAe;IACf,oBAAwF;IAG1F;;;;;;OAMG;IACH,yBAJG;QAAuB,KAAK,EAApB,MAAM;QAEU,KAAK;KAC/B,QAoBA;IAED,mBAMC;IAED,uBAEC;IAED,uEAMC;;CACF"}
@@ -9,10 +9,23 @@ export class Aggregate extends AggregateBase {
9
9
  * @param {number} timestamp
10
10
  */
11
11
  endCurrentSession(timestamp: number): void;
12
- addTiming(name: any, value: any, attrs: any): {
13
- name: any;
14
- value: any;
15
- attrs: any;
12
+ /**
13
+ * Records a page view timing node, decorating it with system attributes (page URL, connection info,
14
+ * CLS-so-far, webdriver detection, and a server-corrected absolute timestamp) before buffering it for harvest.
15
+ * @param {string} name The timing node's name (e.g. 'fp', 'fcp', 'lcp', 'cls', 'fi', 'pageHide', 'unload').
16
+ * @param {number} value The timing node's value; a ms offset from page origin for most nodes, a duration for
17
+ * 'inp', and a unitless score for 'cls'.
18
+ * @param {object} [attrs] Additional custom attributes to attach to the node.
19
+ * @param {number} [relativeTimestamp] The ms offset from page origin at which the underlying event occurred. It is
20
+ * server-corrected into the node's absolute `timestamp` attribute, which is omitted if the time keeper isn't ready
21
+ * or this isn't a finite number. Supplied by VitalMetric for nodes whose `value` isn't itself the event's time
22
+ * (e.g. 'inp' uses the interaction time, 'cls' uses the largest shift time). Defaults to `value`.
23
+ * @returns {{name: string, value: number, attrs: object}} The recorded timing node.
24
+ */
25
+ addTiming(name: string, value: number, attrs?: object, relativeTimestamp?: number): {
26
+ name: string;
27
+ value: number;
28
+ attrs: object;
16
29
  };
17
30
  /**
18
31
  * Checks the performance API to see if the agent can set a first interaction event value
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/features/page_view_timing/aggregate/index.js"],"names":[],"mappings":"AA2BA;IACE,2BAAiC;IAMjC,2BAmCC;IAjCC,4BAA+B;IAC/B,0BAA6B;IAK7B,uBAA2D;IA6B7D;;;OAGG;IACH,6BAFW,MAAM,QAOhB;IAED;;;;MAiCC;IAED;;;OAGG;IACH,4BAFa,IAAI,CAahB;IAWD,4BAGC;IAGD,qCA8BC;;CACF;8BA1K6B,4BAA4B;2BAC/B,gCAAgC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/features/page_view_timing/aggregate/index.js"],"names":[],"mappings":"AA2BA;IACE,2BAAiC;IAMjC,2BAmCC;IAjCC,4BAA+B;IAC/B,0BAA6B;IAK7B,uBAA2D;IA6B7D;;;OAGG;IACH,6BAFW,MAAM,QAOhB;IAED;;;;;;;;;;;;OAYG;IACH,gBAVW,MAAM,SACN,MAAM,UAEN,MAAM,sBACN,MAAM,GAIJ;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAC,CAuCxD;IAED;;;OAGG;IACH,4BAFa,IAAI,CAahB;IAWD,4BAGC;IAGD,qCA8BC;;CACF;8BA3L6B,4BAA4B;2BAC/B,gCAAgC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newrelic/browser-agent",
3
- "version": "1.324.1-rc.2",
3
+ "version": "1.324.1-rc.3",
4
4
  "private": false,
5
5
  "author": "New Relic Browser Agent Team <browser-agent@newrelic.com>",
6
6
  "description": "New Relic Browser Agent",
@@ -2,6 +2,8 @@
2
2
  * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
+ import { now } from '../timing/now'
6
+
5
7
  export class VitalMetric {
6
8
  #subscribers = new Set()
7
9
  history = []
@@ -12,13 +14,22 @@ export class VitalMetric {
12
14
  this.roundingMethod = typeof roundingMethod === 'function' ? roundingMethod : Math.floor
13
15
  }
14
16
 
17
+ /**
18
+ * Records a new value for this metric and notifies subscribers.
19
+ * @param {object} params
20
+ * @param {number} params.value The metric's value. For most metrics this is itself a ms offset from page origin
21
+ * (i.e. the metric's own real time of occurrence); for score-based metrics like CLS it isn't a time at all.
22
+ * @param {object} [params.attrs] Metric-specific attribution attrs.
23
+ */
15
24
  update ({ value, attrs = {} }) {
16
25
  if (value === undefined || value === null || value < 0) return
26
+ const metricTimestamps = { inp: attrs.interactionTime ?? now(), cls: attrs.largestShiftTime ?? now() }
17
27
  const state = {
18
28
  value: this.roundingMethod(value),
19
29
  name: this.name,
20
30
  // optional web-vitals attribution fields are omitted rather than being serialized as null attributes
21
- attrs: Object.fromEntries(Object.entries(attrs).filter(([, attrValue]) => attrValue !== undefined))
31
+ attrs: Object.fromEntries(Object.entries(attrs).filter(([, attrValue]) => attrValue !== undefined)),
32
+ relativeTimestamp: metricTimestamps[this.name] ?? value
22
33
  }
23
34
 
24
35
  this.history.push(state)
@@ -28,8 +28,8 @@ import { createStringAdders } from '../../../common/payloads/payloads'
28
28
  export class Aggregate extends AggregateBase {
29
29
  static featureName = FEATURE_NAME
30
30
 
31
- #handleVitalMetric = ({ name, value, attrs }) => {
32
- this.addTiming(name, value, attrs)
31
+ #handleVitalMetric = ({ name, value, attrs, relativeTimestamp }) => {
32
+ this.addTiming(name, value, attrs, relativeTimestamp)
33
33
  }
34
34
 
35
35
  constructor (agentRef) {
@@ -44,7 +44,7 @@ export class Aggregate extends AggregateBase {
44
44
 
45
45
  registerHandler('docHidden', msTimestamp => this.endCurrentSession(msTimestamp), this.featureName, this.ee)
46
46
  // Add the time of _window pagehide event_ firing to the next PVT harvest == NRDB windowUnload attr:
47
- registerHandler('winPagehide', msTimestamp => this.addTiming('unload', msTimestamp, null), this.featureName, this.ee)
47
+ registerHandler('winPagehide', msTimestamp => this.addTiming('unload', msTimestamp), this.featureName, this.ee)
48
48
 
49
49
  this.waitForFlags(([])).then(() => {
50
50
  firstPaint.subscribe(this.#handleVitalMetric)
@@ -56,9 +56,9 @@ export class Aggregate extends AggregateBase {
56
56
  /* Downstream, the event consumer interprets all timing node value as ms-unit and converts it to seconds via division by 1000. CLS is unitless so this normally is a problem.
57
57
  bel.6 schema also doesn't support decimal values, of which cls within [0,1). However, the two nicely cancels out, and we can multiply cls by 1000 to both negate the division
58
58
  and send an integer > 1. We effectively lose some precision down to 3 decimal places for this workaround. E.g. (real) 0.749132... -> 749.132...-> 749 -> 0.749 (final) */
59
- const { name, value, attrs } = cumulativeLayoutShift.current
59
+ const { name, value, attrs, relativeTimestamp } = cumulativeLayoutShift.current
60
60
  if (value === undefined) return
61
- this.addTiming(name, value * 1000, attrs)
61
+ this.addTiming(name, value * 1000, attrs, relativeTimestamp)
62
62
  }, true, true) // CLS node should only report on vis change rather than on every change.
63
63
  /* NOTE: the capture=true is required -- empirically verified against a real browser (Chrome) for CLS
64
64
  timing node to reliably be in the same final harvest as the rest, alongside Harvester#startTimer's
@@ -75,13 +75,25 @@ export class Aggregate extends AggregateBase {
75
75
  */
76
76
  endCurrentSession (timestamp) {
77
77
  if (!this.curSessEndRecorded) { // TO DO: stage 2 - we don't want to capture this timing twice on page navigating away, but it should run again if we return to page and away *again*
78
- this.addTiming('pageHide', timestamp, null)
78
+ this.addTiming('pageHide', timestamp)
79
79
  this.curSessEndRecorded = true
80
80
  }
81
81
  }
82
82
 
83
- addTiming (name, value, attrs) {
84
- attrs = attrs || {}
83
+ /**
84
+ * Records a page view timing node, decorating it with system attributes (page URL, connection info,
85
+ * CLS-so-far, webdriver detection, and a server-corrected absolute timestamp) before buffering it for harvest.
86
+ * @param {string} name The timing node's name (e.g. 'fp', 'fcp', 'lcp', 'cls', 'fi', 'pageHide', 'unload').
87
+ * @param {number} value The timing node's value; a ms offset from page origin for most nodes, a duration for
88
+ * 'inp', and a unitless score for 'cls'.
89
+ * @param {object} [attrs] Additional custom attributes to attach to the node.
90
+ * @param {number} [relativeTimestamp] The ms offset from page origin at which the underlying event occurred. It is
91
+ * server-corrected into the node's absolute `timestamp` attribute, which is omitted if the time keeper isn't ready
92
+ * or this isn't a finite number. Supplied by VitalMetric for nodes whose `value` isn't itself the event's time
93
+ * (e.g. 'inp' uses the interaction time, 'cls' uses the largest shift time). Defaults to `value`.
94
+ * @returns {{name: string, value: number, attrs: object}} The recorded timing node.
95
+ */
96
+ addTiming (name, value, attrs = {}, relativeTimestamp = value) {
85
97
  attrs.pageUrl = cleanURL(getNavigationEntry()?.name || initialLocation)
86
98
 
87
99
  addConnectionAttributes(attrs) // network conditions may differ from the actual for VitalMetrics when they were captured
@@ -100,6 +112,11 @@ export class Aggregate extends AggregateBase {
100
112
 
101
113
  attrs.webdriverDetected = webdriverDetected
102
114
 
115
+ const timeKeeper = this.agentRef.runtime.timeKeeper
116
+ if (timeKeeper?.ready && Number.isFinite(relativeTimestamp)) {
117
+ attrs.timestamp = Math.floor(timeKeeper.correctRelativeTimestamp(relativeTimestamp))
118
+ }
119
+
103
120
  const timing = {
104
121
  name,
105
122
  value,
@@ -134,7 +151,7 @@ export class Aggregate extends AggregateBase {
134
151
 
135
152
  #getGlobalCustomAttributes () {
136
153
  const reservedAttributes = ['size', 'eid', 'cls', 'type', 'fid', 'elTag', 'elUrl', 'net-type',
137
- 'net-etype', 'net-rtt', 'net-dlink', 'webdriverDetected']
154
+ 'net-etype', 'net-rtt', 'net-dlink', 'webdriverDetected', 'timestamp']
138
155
 
139
156
  return Object.fromEntries(
140
157
  Object.entries(this.agentRef.info.jsAttributes || {}).filter(([key]) => !reservedAttributes.includes(key))