From 9f0461c01eccc237e9fdc4ec65bffc8881ff92bc Mon Sep 17 00:00:00 2001 From: Billy Date: Tue, 22 Aug 2023 15:28:21 -0700 Subject: [PATCH] feat: collect attributions for LCP, CLS, and FID --- src/plugins/event-plugins/WebVitalsPlugin.ts | 80 ++++++++++++--- .../__tests__/WebVitalsPlugin.test.ts | 98 +++++++++++++++---- 2 files changed, 144 insertions(+), 34 deletions(-) diff --git a/src/plugins/event-plugins/WebVitalsPlugin.ts b/src/plugins/event-plugins/WebVitalsPlugin.ts index 1b09e72c..b7163aff 100644 --- a/src/plugins/event-plugins/WebVitalsPlugin.ts +++ b/src/plugins/event-plugins/WebVitalsPlugin.ts @@ -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 { - LCP_EVENT_TYPE, + CLSMetricWithAttribution, + FIDMetricWithAttribution, + LCPMetricWithAttribution, + Metric, + onCLS, + onFID, + onLCP +} from 'web-vitals/attribution'; +import { + CLS_EVENT_TYPE, FID_EVENT_TYPE, - CLS_EVENT_TYPE + LCP_EVENT_TYPE } from '../utils/constant'; export const WEB_VITAL_EVENT_PLUGIN_ID = 'web-vitals'; @@ -25,20 +33,62 @@ 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 lcpEvent: LargestContentfulPaintEvent = { version: '1.0.0', - value: webVitalData.value + value: metric.value }; - this.context?.record(eventType, webVitalEvent); + if ('attribution' in metric) { + const a = (metric as LCPMetricWithAttribution).attribution; + lcpEvent.attribution = { + element: a.element, + url: a.url, + timeToFirstByte: a.timeToFirstByte, + resourceLoadDelay: a.resourceLoadDelay, + resourceLoadTime: a.resourceLoadTime, + elementRenderDelay: a.elementRenderDelay + }; + } + this.context?.record(LCP_EVENT_TYPE, lcpEvent); } - 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 clsEvent: CumulativeLayoutShiftEvent = { + version: '1.0.0', + value: metric.value + }; + if ('attribution' in metric) { + const a = (metric as CLSMetricWithAttribution).attribution; + clsEvent.attribution = { + largestShiftTarget: a.largestShiftTarget, + largestShiftValue: a.largestShiftValue, + largestShiftTime: a.largestShiftTime, + loadState: a.loadState + }; + } + this.context?.record(CLS_EVENT_TYPE, clsEvent); + } + + handleFID(metric: FIDMetricWithAttribution | Metric) { + const fidEvent: FirstInputDelayEvent = { + version: '1.0.0', + value: metric.value + }; + if ('attribution' in metric) { + const a = (metric as FIDMetricWithAttribution).attribution; + fidEvent.attribution = { + eventTarget: a.eventTarget, + eventType: a.eventType, + eventTime: a.eventTime, + loadState: a.loadState + }; + } + this.context?.record(FID_EVENT_TYPE, fidEvent); } } diff --git a/src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts b/src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts index 932559be..c304d20d 100644 --- a/src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts +++ b/src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts @@ -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)) }; }); @@ -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(); @@ -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 + } }) ); });