Skip to content

Commit

Permalink
feat: add attributions for core web vitals: LCP, CLS, and FID (#432)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
williazz authored Sep 11, 2023
1 parent 95f3cc1 commit 33892c5
Show file tree
Hide file tree
Showing 6 changed files with 246 additions and 41 deletions.
31 changes: 30 additions & 1 deletion src/event-schemas/cumulative-layout-shift-event.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,36 @@
},
"value": {
"type": "number",
"description": "Value of the cls metric"
"description": "Largest burst of unexpected layout shifts during a page's lifespan"
},
"attribution": {
"type": "object",
"description": "Attributions for CLS",
"properties": {
"largestShiftTarget": {
"type": "string",
"description": "First element in the largest layout shift contributing to CLS score"
},
"largestShiftValue": {
"type": "number",
"description": "Value of CLS' single largest shift"
},
"largestShiftTime": {
"type": "number",
"description": "DOMHighResTimeStamp of CLS' single largest shift"
},
"loadState": {
"type": "string",
"enum": [
"loading",
"dom-interactive",
"dom-content-loaded",
"complete"
],
"description": "LoadState during CLS' single largest shift"
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
Expand Down
32 changes: 31 additions & 1 deletion src/event-schemas/first-input-delay-event.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,37 @@
},
"value": {
"type": "number",
"description": "Value of the fid metric"
"description": "Time from first user interaction until the main thread is next idle"
},
"attribution": {
"type": "object",
"description": "Attributions for FID",
"properties": {
"eventTarget": {
"type": "string",
"description": "Selector of the element targeted by first user interaction"
},
"eventType": {
"type": "string",
"description": "Type of event dispatched by first user interaction"
},
"eventTime": {
"type": "number",
"description": "Timestamp of user first user interaction"
},
"loadState": {
"type": "string",
"enum": [
"loading",
"dom-interactive",
"dom-content-loaded",
"complete"
],
"description": "LoadState of the document during first user interaction"
}
},
"required": ["eventTarget", "eventType", "eventTime", "loadState"],
"additionalProperties": false
}
},
"additionalProperties": false,
Expand Down
39 changes: 38 additions & 1 deletion src/event-schemas/largest-contentful-paint-event.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,44 @@
},
"value": {
"type": "number",
"description": "Value of the lcp metric"
"description": "Time until the largest element before first user interaction is rendered"
},
"attribution": {
"type": "object",
"description": "Attributions for CLS",
"properties": {
"element": {
"type": "string",
"description": "CSS selector of LCP resource"
},
"url": {
"type": "string",
"description": "URL source of the LCP resource's image, if any"
},
"timeToFirstByte": {
"type": "number",
"description": "Duration until first byte of response"
},
"resourceLoadDelay": {
"type": "number",
"description": "Duration after TTFP until LCP resource begins loading"
},
"resourceLoadTime": {
"type": "number",
"description": "Duration loading the LCP resource"
},
"elementRenderDelay": {
"type": "number",
"description": "Duration rendering the LCP resource"
}
},
"required": [
"timeToFirstByte",
"resourceLoadDelay",
"resourceLoadTime",
"elementRenderDelay"
],
"additionalProperties": false
}
},
"additionalProperties": false,
Expand Down
73 changes: 57 additions & 16 deletions src/plugins/event-plugins/WebVitalsPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { InternalPlugin } from '../InternalPlugin';
import { LargestContentfulPaintEvent } from '../../events/largest-contentful-paint-event';
import { FirstInputDelayEvent } from '../../events/first-input-delay-event';
import { CumulativeLayoutShiftEvent } from '../../events/cumulative-layout-shift-event';
import { Metric, onCLS, onFID, onLCP } from 'web-vitals';
import { FirstInputDelayEvent } from '../../events/first-input-delay-event';
import {
CLSMetricWithAttribution,
FIDMetricWithAttribution,
LCPMetricWithAttribution,
Metric,
onCLS,
onFID,
onLCP
} from 'web-vitals/attribution';
import {
LCP_EVENT_TYPE,
CLS_EVENT_TYPE,
FID_EVENT_TYPE,
CLS_EVENT_TYPE
LCP_EVENT_TYPE
} from '../utils/constant';

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

getWebVitalData(webVitalData: Metric, eventType: string): void {
const webVitalEvent:
| LargestContentfulPaintEvent
| FirstInputDelayEvent
| CumulativeLayoutShiftEvent = {
protected onload(): void {
onLCP((metric) => this.handleLCP(metric));
onFID((metric) => this.handleFID(metric));
onCLS((metric) => this.handleCLS(metric));
}

handleLCP(metric: LCPMetricWithAttribution | Metric) {
const a = (metric as LCPMetricWithAttribution).attribution;
this.context?.record(LCP_EVENT_TYPE, {
version: '1.0.0',
value: webVitalData.value
};
this.context?.record(eventType, webVitalEvent);
value: metric.value,
attribution: {
element: a.element,
url: a.url,
timeToFirstByte: a.timeToFirstByte,
resourceLoadDelay: a.resourceLoadDelay,
resourceLoadTime: a.resourceLoadTime,
elementRenderDelay: a.elementRenderDelay
}
} as LargestContentfulPaintEvent);
}

protected onload(): void {
onLCP((data) => this.getWebVitalData(data, LCP_EVENT_TYPE));
onFID((data) => this.getWebVitalData(data, FID_EVENT_TYPE));
onCLS((data) => this.getWebVitalData(data, CLS_EVENT_TYPE));
handleCLS(metric: CLSMetricWithAttribution | Metric) {
const a = (metric as CLSMetricWithAttribution).attribution;
this.context?.record(CLS_EVENT_TYPE, {
version: '1.0.0',
value: metric.value,
attribution: {
largestShiftTarget: a.largestShiftTarget,
largestShiftValue: a.largestShiftValue,
largestShiftTime: a.largestShiftTime,
loadState: a.loadState
}
} as CumulativeLayoutShiftEvent);
}

handleFID(metric: FIDMetricWithAttribution | Metric) {
const a = (metric as FIDMetricWithAttribution).attribution;
this.context?.record(FID_EVENT_TYPE, {
version: '1.0.0',
value: metric.value,
attribution: {
eventTarget: a.eventTarget,
eventType: a.eventType,
eventTime: a.eventTime,
loadState: a.loadState
}
} as FirstInputDelayEvent);
}
}
14 changes: 11 additions & 3 deletions src/plugins/event-plugins/__integ__/WebVitalsPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
RESPONSE_STATUS
} from '../../../test-utils/integ-test-utils';
import { Selector } from 'testcafe';
import { CLS_EVENT_TYPE, LCP_EVENT_TYPE } from '../../utils/constant';
import {
CLS_EVENT_TYPE,
FID_EVENT_TYPE,
LCP_EVENT_TYPE
} from '../../utils/constant';

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

test('WebVitalEvent records lcp and cls events', async (t: TestController) => {
test('WebVitalEvent records lcp and cls events on chrome', async (t: TestController) => {
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
const browser = t.browser.name;
Expand Down Expand Up @@ -51,5 +55,9 @@ test('WebVitalEvent records lcp and cls events', async (t: TestController) => {
.expect(lcpEventDetails.value)
.typeOf('number')
.expect(clsEventDetails.value)
.typeOf('number');
.typeOf('number')
.expect(lcpEventDetails.attribution)
.typeOf('object')
.expect(clsEventDetails.attribution)
.typeOf('object');
});
98 changes: 79 additions & 19 deletions src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,40 +10,52 @@ const mockLCPData = {
delta: 239.51,
id: 'v1-1621403597701-7933189041053',
name: 'LCP',
value: 239.51
value: 239.51,
attribution: {
element: '#root>div>div>div>img',
url: 'example.com/source.png',
timeToFirstByte: 1000,
resourceLoadDelay: 250,
resourceLoadTime: 1000,
elementRenderDelay: 250
}
};

const mockFIDData = {
delta: 1.2799999676644802,
id: 'v1-1621403597702-6132885858466',
name: 'FID',
value: 1.2799999676644802
value: 1.2799999676644802,
attribution: {
eventTime: 300,
eventTarget: '#root>div>div>div>img',
eventType: 'keydown',
loadState: 'dom-interactive'
}
};

const mockCLSData = {
delta: 0,
id: 'v1-1621403597702-8740659462223',
name: 'CLS',
value: 0.037451866876684094
value: 0.037451866876684094,
attribution: {
largestShiftTarget: '#root>div>div>div>img',
largestShiftValue: 0.03076529149893375,
largestShiftTime: 3447485.600000024,
loadState: 'dom-interactive'
}
};

jest.mock('web-vitals', () => {
jest.mock('web-vitals/attribution', () => {
return {
onLCP: jest
.fn()
.mockImplementation((callback) =>
callback(mockLCPData, LCP_EVENT_TYPE)
),
.mockImplementation((callback) => callback(mockLCPData)),
onFID: jest
.fn()
.mockImplementation((callback) =>
callback(mockFIDData, FID_EVENT_TYPE)
),
onCLS: jest
.fn()
.mockImplementation((callback) =>
callback(mockCLSData, CLS_EVENT_TYPE)
)
.mockImplementation((callback) => callback(mockFIDData)),
onCLS: jest.fn().mockImplementation((callback) => callback(mockCLSData))
};
});

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

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

Expand All @@ -67,23 +79,71 @@ describe('WebVitalsPlugin tests', () => {
expect(record.mock.calls[0][1]).toEqual(
expect.objectContaining({
version: '1.0.0',
value: mockLCPData.value
value: mockLCPData.value,
attribution: {
element: mockLCPData.attribution.element,
url: mockLCPData.attribution.url,
timeToFirstByte: mockLCPData.attribution.timeToFirstByte,
resourceLoadDelay:
mockLCPData.attribution.resourceLoadDelay,
resourceLoadTime: mockLCPData.attribution.resourceLoadTime,
elementRenderDelay:
mockLCPData.attribution.elementRenderDelay
}
})
);
});

test('When web vitals are present then FID is recorded with attribution', async () => {
// Setup
const plugin: WebVitalsPlugin = new WebVitalsPlugin();

// Run
plugin.load(context);
window.dispatchEvent(new Event('load'));

// Assert
expect(record).toHaveBeenCalledTimes(3);

expect(record.mock.calls[1][0]).toEqual(FID_EVENT_TYPE);
expect(record.mock.calls[1][1]).toEqual(
expect.objectContaining({
version: '1.0.0',
value: mockFIDData.value
value: mockFIDData.value,
attribution: {
eventTarget: mockFIDData.attribution.eventTarget,
eventType: mockFIDData.attribution.eventType,
eventTime: mockFIDData.attribution.eventTime,
loadState: mockFIDData.attribution.loadState
}
})
);
});

test('When web vitals are present then CLS is recorded with attribution', async () => {
// Setup
const plugin: WebVitalsPlugin = new WebVitalsPlugin();

// Run
plugin.load(context);
window.dispatchEvent(new Event('load'));

// Assert
expect(record).toHaveBeenCalledTimes(3);

expect(record.mock.calls[2][0]).toEqual(CLS_EVENT_TYPE);
expect(record.mock.calls[2][1]).toEqual(
expect.objectContaining({
version: '1.0.0',
value: mockCLSData.value
value: mockCLSData.value,
attribution: {
largestShiftTarget:
mockCLSData.attribution.largestShiftTarget,
largestShiftValue:
mockCLSData.attribution.largestShiftValue,
largestShiftTime: mockCLSData.attribution.largestShiftTime,
loadState: mockCLSData.attribution.loadState
}
})
);
});
Expand Down

0 comments on commit 33892c5

Please sign in to comment.