Skip to content

Commit 33892c5

Browse files
authored
feat: add attributions for core web vitals: LCP, CLS, and FID (#432)
* chore: update LCP, CLS, and FID schemas with attributions * feat: collect attributions for LCP, CLS, and FID * chore: remove attribution double check * chore: add intetgration test for attribution
1 parent 95f3cc1 commit 33892c5

File tree

6 files changed

+246
-41
lines changed

6 files changed

+246
-41
lines changed

src/event-schemas/cumulative-layout-shift-event.json

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,36 @@
1111
},
1212
"value": {
1313
"type": "number",
14-
"description": "Value of the cls metric"
14+
"description": "Largest burst of unexpected layout shifts during a page's lifespan"
15+
},
16+
"attribution": {
17+
"type": "object",
18+
"description": "Attributions for CLS",
19+
"properties": {
20+
"largestShiftTarget": {
21+
"type": "string",
22+
"description": "First element in the largest layout shift contributing to CLS score"
23+
},
24+
"largestShiftValue": {
25+
"type": "number",
26+
"description": "Value of CLS' single largest shift"
27+
},
28+
"largestShiftTime": {
29+
"type": "number",
30+
"description": "DOMHighResTimeStamp of CLS' single largest shift"
31+
},
32+
"loadState": {
33+
"type": "string",
34+
"enum": [
35+
"loading",
36+
"dom-interactive",
37+
"dom-content-loaded",
38+
"complete"
39+
],
40+
"description": "LoadState during CLS' single largest shift"
41+
}
42+
},
43+
"additionalProperties": false
1544
}
1645
},
1746
"additionalProperties": false,

src/event-schemas/first-input-delay-event.json

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,37 @@
1111
},
1212
"value": {
1313
"type": "number",
14-
"description": "Value of the fid metric"
14+
"description": "Time from first user interaction until the main thread is next idle"
15+
},
16+
"attribution": {
17+
"type": "object",
18+
"description": "Attributions for FID",
19+
"properties": {
20+
"eventTarget": {
21+
"type": "string",
22+
"description": "Selector of the element targeted by first user interaction"
23+
},
24+
"eventType": {
25+
"type": "string",
26+
"description": "Type of event dispatched by first user interaction"
27+
},
28+
"eventTime": {
29+
"type": "number",
30+
"description": "Timestamp of user first user interaction"
31+
},
32+
"loadState": {
33+
"type": "string",
34+
"enum": [
35+
"loading",
36+
"dom-interactive",
37+
"dom-content-loaded",
38+
"complete"
39+
],
40+
"description": "LoadState of the document during first user interaction"
41+
}
42+
},
43+
"required": ["eventTarget", "eventType", "eventTime", "loadState"],
44+
"additionalProperties": false
1545
}
1646
},
1747
"additionalProperties": false,

src/event-schemas/largest-contentful-paint-event.json

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,44 @@
1111
},
1212
"value": {
1313
"type": "number",
14-
"description": "Value of the lcp metric"
14+
"description": "Time until the largest element before first user interaction is rendered"
15+
},
16+
"attribution": {
17+
"type": "object",
18+
"description": "Attributions for CLS",
19+
"properties": {
20+
"element": {
21+
"type": "string",
22+
"description": "CSS selector of LCP resource"
23+
},
24+
"url": {
25+
"type": "string",
26+
"description": "URL source of the LCP resource's image, if any"
27+
},
28+
"timeToFirstByte": {
29+
"type": "number",
30+
"description": "Duration until first byte of response"
31+
},
32+
"resourceLoadDelay": {
33+
"type": "number",
34+
"description": "Duration after TTFP until LCP resource begins loading"
35+
},
36+
"resourceLoadTime": {
37+
"type": "number",
38+
"description": "Duration loading the LCP resource"
39+
},
40+
"elementRenderDelay": {
41+
"type": "number",
42+
"description": "Duration rendering the LCP resource"
43+
}
44+
},
45+
"required": [
46+
"timeToFirstByte",
47+
"resourceLoadDelay",
48+
"resourceLoadTime",
49+
"elementRenderDelay"
50+
],
51+
"additionalProperties": false
1552
}
1653
},
1754
"additionalProperties": false,
Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import { InternalPlugin } from '../InternalPlugin';
22
import { LargestContentfulPaintEvent } from '../../events/largest-contentful-paint-event';
3-
import { FirstInputDelayEvent } from '../../events/first-input-delay-event';
43
import { CumulativeLayoutShiftEvent } from '../../events/cumulative-layout-shift-event';
5-
import { Metric, onCLS, onFID, onLCP } from 'web-vitals';
4+
import { FirstInputDelayEvent } from '../../events/first-input-delay-event';
5+
import {
6+
CLSMetricWithAttribution,
7+
FIDMetricWithAttribution,
8+
LCPMetricWithAttribution,
9+
Metric,
10+
onCLS,
11+
onFID,
12+
onLCP
13+
} from 'web-vitals/attribution';
614
import {
7-
LCP_EVENT_TYPE,
15+
CLS_EVENT_TYPE,
816
FID_EVENT_TYPE,
9-
CLS_EVENT_TYPE
17+
LCP_EVENT_TYPE
1018
} from '../utils/constant';
1119

1220
export const WEB_VITAL_EVENT_PLUGIN_ID = 'web-vitals';
@@ -25,20 +33,53 @@ export class WebVitalsPlugin extends InternalPlugin {
2533
// eslint-disable-next-line @typescript-eslint/no-empty-function
2634
configure(config: any): void {}
2735

28-
getWebVitalData(webVitalData: Metric, eventType: string): void {
29-
const webVitalEvent:
30-
| LargestContentfulPaintEvent
31-
| FirstInputDelayEvent
32-
| CumulativeLayoutShiftEvent = {
36+
protected onload(): void {
37+
onLCP((metric) => this.handleLCP(metric));
38+
onFID((metric) => this.handleFID(metric));
39+
onCLS((metric) => this.handleCLS(metric));
40+
}
41+
42+
handleLCP(metric: LCPMetricWithAttribution | Metric) {
43+
const a = (metric as LCPMetricWithAttribution).attribution;
44+
this.context?.record(LCP_EVENT_TYPE, {
3345
version: '1.0.0',
34-
value: webVitalData.value
35-
};
36-
this.context?.record(eventType, webVitalEvent);
46+
value: metric.value,
47+
attribution: {
48+
element: a.element,
49+
url: a.url,
50+
timeToFirstByte: a.timeToFirstByte,
51+
resourceLoadDelay: a.resourceLoadDelay,
52+
resourceLoadTime: a.resourceLoadTime,
53+
elementRenderDelay: a.elementRenderDelay
54+
}
55+
} as LargestContentfulPaintEvent);
3756
}
3857

39-
protected onload(): void {
40-
onLCP((data) => this.getWebVitalData(data, LCP_EVENT_TYPE));
41-
onFID((data) => this.getWebVitalData(data, FID_EVENT_TYPE));
42-
onCLS((data) => this.getWebVitalData(data, CLS_EVENT_TYPE));
58+
handleCLS(metric: CLSMetricWithAttribution | Metric) {
59+
const a = (metric as CLSMetricWithAttribution).attribution;
60+
this.context?.record(CLS_EVENT_TYPE, {
61+
version: '1.0.0',
62+
value: metric.value,
63+
attribution: {
64+
largestShiftTarget: a.largestShiftTarget,
65+
largestShiftValue: a.largestShiftValue,
66+
largestShiftTime: a.largestShiftTime,
67+
loadState: a.loadState
68+
}
69+
} as CumulativeLayoutShiftEvent);
70+
}
71+
72+
handleFID(metric: FIDMetricWithAttribution | Metric) {
73+
const a = (metric as FIDMetricWithAttribution).attribution;
74+
this.context?.record(FID_EVENT_TYPE, {
75+
version: '1.0.0',
76+
value: metric.value,
77+
attribution: {
78+
eventTarget: a.eventTarget,
79+
eventType: a.eventType,
80+
eventTime: a.eventTime,
81+
loadState: a.loadState
82+
}
83+
} as FirstInputDelayEvent);
4384
}
4485
}

src/plugins/event-plugins/__integ__/WebVitalsPlugin.test.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import {
44
RESPONSE_STATUS
55
} from '../../../test-utils/integ-test-utils';
66
import { Selector } from 'testcafe';
7-
import { CLS_EVENT_TYPE, LCP_EVENT_TYPE } from '../../utils/constant';
7+
import {
8+
CLS_EVENT_TYPE,
9+
FID_EVENT_TYPE,
10+
LCP_EVENT_TYPE
11+
} from '../../utils/constant';
812

913
const testButton: Selector = Selector(`#testButton`);
1014
const makePageHidden: Selector = Selector(`#makePageHidden`);
@@ -17,7 +21,7 @@ fixture('WebVitalEvent Plugin').page(
1721
// "FID is not reported if the user never interacts with the page."
1822
// It doesn't seem like TestCafe actions are registered as user interactions, so cannot test FID
1923

20-
test('WebVitalEvent records lcp and cls events', async (t: TestController) => {
24+
test('WebVitalEvent records lcp and cls events on chrome', async (t: TestController) => {
2125
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
2226
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
2327
const browser = t.browser.name;
@@ -51,5 +55,9 @@ test('WebVitalEvent records lcp and cls events', async (t: TestController) => {
5155
.expect(lcpEventDetails.value)
5256
.typeOf('number')
5357
.expect(clsEventDetails.value)
54-
.typeOf('number');
58+
.typeOf('number')
59+
.expect(lcpEventDetails.attribution)
60+
.typeOf('object')
61+
.expect(clsEventDetails.attribution)
62+
.typeOf('object');
5563
});

src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts

Lines changed: 79 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,40 +10,52 @@ const mockLCPData = {
1010
delta: 239.51,
1111
id: 'v1-1621403597701-7933189041053',
1212
name: 'LCP',
13-
value: 239.51
13+
value: 239.51,
14+
attribution: {
15+
element: '#root>div>div>div>img',
16+
url: 'example.com/source.png',
17+
timeToFirstByte: 1000,
18+
resourceLoadDelay: 250,
19+
resourceLoadTime: 1000,
20+
elementRenderDelay: 250
21+
}
1422
};
1523

1624
const mockFIDData = {
1725
delta: 1.2799999676644802,
1826
id: 'v1-1621403597702-6132885858466',
1927
name: 'FID',
20-
value: 1.2799999676644802
28+
value: 1.2799999676644802,
29+
attribution: {
30+
eventTime: 300,
31+
eventTarget: '#root>div>div>div>img',
32+
eventType: 'keydown',
33+
loadState: 'dom-interactive'
34+
}
2135
};
2236

2337
const mockCLSData = {
2438
delta: 0,
2539
id: 'v1-1621403597702-8740659462223',
2640
name: 'CLS',
27-
value: 0.037451866876684094
41+
value: 0.037451866876684094,
42+
attribution: {
43+
largestShiftTarget: '#root>div>div>div>img',
44+
largestShiftValue: 0.03076529149893375,
45+
largestShiftTime: 3447485.600000024,
46+
loadState: 'dom-interactive'
47+
}
2848
};
2949

30-
jest.mock('web-vitals', () => {
50+
jest.mock('web-vitals/attribution', () => {
3151
return {
3252
onLCP: jest
3353
.fn()
34-
.mockImplementation((callback) =>
35-
callback(mockLCPData, LCP_EVENT_TYPE)
36-
),
54+
.mockImplementation((callback) => callback(mockLCPData)),
3755
onFID: jest
3856
.fn()
39-
.mockImplementation((callback) =>
40-
callback(mockFIDData, FID_EVENT_TYPE)
41-
),
42-
onCLS: jest
43-
.fn()
44-
.mockImplementation((callback) =>
45-
callback(mockCLSData, CLS_EVENT_TYPE)
46-
)
57+
.mockImplementation((callback) => callback(mockFIDData)),
58+
onCLS: jest.fn().mockImplementation((callback) => callback(mockCLSData))
4759
};
4860
});
4961

@@ -52,7 +64,7 @@ describe('WebVitalsPlugin tests', () => {
5264
record.mockClear();
5365
});
5466

55-
test('When web vitals are present then events are recorded', async () => {
67+
test('When web vitals are present then LCP is recorded with attributions', async () => {
5668
// Setup
5769
const plugin: WebVitalsPlugin = new WebVitalsPlugin();
5870

@@ -67,23 +79,71 @@ describe('WebVitalsPlugin tests', () => {
6779
expect(record.mock.calls[0][1]).toEqual(
6880
expect.objectContaining({
6981
version: '1.0.0',
70-
value: mockLCPData.value
82+
value: mockLCPData.value,
83+
attribution: {
84+
element: mockLCPData.attribution.element,
85+
url: mockLCPData.attribution.url,
86+
timeToFirstByte: mockLCPData.attribution.timeToFirstByte,
87+
resourceLoadDelay:
88+
mockLCPData.attribution.resourceLoadDelay,
89+
resourceLoadTime: mockLCPData.attribution.resourceLoadTime,
90+
elementRenderDelay:
91+
mockLCPData.attribution.elementRenderDelay
92+
}
7193
})
7294
);
95+
});
96+
97+
test('When web vitals are present then FID is recorded with attribution', async () => {
98+
// Setup
99+
const plugin: WebVitalsPlugin = new WebVitalsPlugin();
100+
101+
// Run
102+
plugin.load(context);
103+
window.dispatchEvent(new Event('load'));
104+
105+
// Assert
106+
expect(record).toHaveBeenCalledTimes(3);
73107

74108
expect(record.mock.calls[1][0]).toEqual(FID_EVENT_TYPE);
75109
expect(record.mock.calls[1][1]).toEqual(
76110
expect.objectContaining({
77111
version: '1.0.0',
78-
value: mockFIDData.value
112+
value: mockFIDData.value,
113+
attribution: {
114+
eventTarget: mockFIDData.attribution.eventTarget,
115+
eventType: mockFIDData.attribution.eventType,
116+
eventTime: mockFIDData.attribution.eventTime,
117+
loadState: mockFIDData.attribution.loadState
118+
}
79119
})
80120
);
121+
});
122+
123+
test('When web vitals are present then CLS is recorded with attribution', async () => {
124+
// Setup
125+
const plugin: WebVitalsPlugin = new WebVitalsPlugin();
126+
127+
// Run
128+
plugin.load(context);
129+
window.dispatchEvent(new Event('load'));
130+
131+
// Assert
132+
expect(record).toHaveBeenCalledTimes(3);
81133

82134
expect(record.mock.calls[2][0]).toEqual(CLS_EVENT_TYPE);
83135
expect(record.mock.calls[2][1]).toEqual(
84136
expect.objectContaining({
85137
version: '1.0.0',
86-
value: mockCLSData.value
138+
value: mockCLSData.value,
139+
attribution: {
140+
largestShiftTarget:
141+
mockCLSData.attribution.largestShiftTarget,
142+
largestShiftValue:
143+
mockCLSData.attribution.largestShiftValue,
144+
largestShiftTime: mockCLSData.attribution.largestShiftTime,
145+
loadState: mockCLSData.attribution.loadState
146+
}
87147
})
88148
);
89149
});

0 commit comments

Comments
 (0)