Skip to content

Commit

Permalink
Add web vital attribution data (#1584)
Browse files Browse the repository at this point in the history
## What are you changing?
Updates web-vitals to 

1. Send attribution data with CLS, LCP and INP
2. A new endpoint. The data will now go to the [events
collector](https://github.com/guardian/events-collector). The events
collector does not have separate endpoints for CODE and PROD
environments. Instead, this stage is determined by the isDev parameter
and added to the payload to allow us to separate the dataset by stage in
big query.

## Why?
Additional attribution data will help with diagnosing web vital issues
on DCR. As well as this, the events collector provides a more agnostic
schema with a focus on anonymity of data and is a more fitting pipeline
for this dataset.
  • Loading branch information
abeddow91 authored Jul 2, 2024
2 parents a5498b8 + 4de52e7 commit a0c21ad
Show file tree
Hide file tree
Showing 6 changed files with 1,253 additions and 368 deletions.
5 changes: 5 additions & 0 deletions .changeset/shiny-squids-protect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@guardian/core-web-vitals': major
---

This major change adds attribution data on 3 core web vital metrics; CLS, INP, and LCP. It also updates the endpoint so that this data will now be sent to a new table in big query. We now send the stage as a value to big query, rather than using separate endpoints. In addition, null values have been removed in favour of undefined.
4 changes: 2 additions & 2 deletions libs/@guardian/core-web-vitals/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@
"ts-jest": "29.1.1",
"tslib": "2.6.2",
"typescript": "5.3.3",
"web-vitals": "3.5.0",
"web-vitals": "4.2.0",
"wireit": "0.14.4"
},
"peerDependencies": {
"@guardian/libs": "^16.0.0",
"tslib": "^2.6.2",
"typescript": "~5.3.3",
"web-vitals": "^3.5.0"
"web-vitals": "^4.2.0"
},
"peerDependenciesMeta": {
"typescript": {
Expand Down
20 changes: 12 additions & 8 deletions libs/@guardian/core-web-vitals/src/@types/CoreWebVitalsPayload.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
export type CoreWebVitalsPayload = {
page_view_id: string | null;
browser_id: string | null;
fid: null | number;
cls: null | number;
lcp: null | number;
fcp: null | number;
ttfb: null | number;
inp: null | number;
page_view_id: string;
browser_id: string;
cls: number;
cls_target: string;
inp: number;
inp_target: string;
lcp: number;
lcp_target: string;
fid: number;
fcp: number;
ttfb: number;
stage: 'CODE' | 'PROD';
};
160 changes: 110 additions & 50 deletions libs/@guardian/core-web-vitals/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,123 @@
import type { MetricType, ReportHandler } from 'web-vitals';
import type {
CLSMetricWithAttribution,
FCPMetric,
FIDMetric,
INPMetricWithAttribution,
LCPMetricWithAttribution,
TTFBMetric,
} from 'web-vitals/attribution';
import type { CoreWebVitalsPayload } from './@types/CoreWebVitalsPayload';
import { _, bypassCoreWebVitalsSampling, initCoreWebVitals } from './index';

const { coreWebVitalsPayload, reset } = _;

const defaultCoreWebVitalsPayload: CoreWebVitalsPayload = {
const defaultCoreWebVitalsPayload = {
page_view_id: '123456',
browser_id: 'abcdef',
stage: 'PROD',
cls: 0.01,
cls_target: 'ad',
inp: 180.3,
inp_target: 'adSlot',
lcp: 150,
lcp_target: 'mainMedia',
fid: 50.5,
fcp: 100.1,
lcp: 150,
ttfb: 9.99,
cls: 0.01,
inp: 180.3,
};
} satisfies CoreWebVitalsPayload;

const browserId = defaultCoreWebVitalsPayload.browser_id;
const pageViewId = defaultCoreWebVitalsPayload.page_view_id;

jest.mock('web-vitals', () => ({
onTTFB: (onReport: ReportHandler) => {
onReport({
value: defaultCoreWebVitalsPayload.ttfb,
name: 'TTFB',
} as MetricType);
},
onFCP: (onReport: ReportHandler) => {
onReport({
value: defaultCoreWebVitalsPayload.fcp,
name: 'FCP',
} as MetricType);
},
onCLS: (onReport: ReportHandler) => {
jest.mock('web-vitals/attribution', () => ({
onCLS: (onReport: (metric: CLSMetricWithAttribution) => void) => {
onReport({
value: defaultCoreWebVitalsPayload.cls,
name: 'CLS',
} as MetricType);
},
onFID: (onReport: ReportHandler) => {
onReport({
value: defaultCoreWebVitalsPayload.fid,
name: 'FID',
} as MetricType);
id: 'cls',
attribution: {
largestShiftTarget: 'ad',
},
entries: [],
navigationType: 'navigate',
rating: 'good',
delta: defaultCoreWebVitalsPayload.cls,
} satisfies CLSMetricWithAttribution);
},
onLCP: (onReport: ReportHandler) => {
onLCP: (onReport: (metric: LCPMetricWithAttribution) => void) => {
onReport({
value: defaultCoreWebVitalsPayload.lcp,
name: 'LCP',
} as MetricType);
id: 'lcp',
attribution: {
element: 'mainMedia',
timeToFirstByte: 0,
resourceLoadDelay: 0,
elementRenderDelay: 0,
resourceLoadDuration: 0,
},
entries: [],
navigationType: 'navigate',
rating: 'good',
delta: defaultCoreWebVitalsPayload.lcp,
} satisfies LCPMetricWithAttribution);
},
onINP: (onReport: ReportHandler) => {
onINP: (onReport: (metric: INPMetricWithAttribution) => void) => {
onReport({
value: defaultCoreWebVitalsPayload.inp,
name: 'INP',
} as MetricType);
id: 'inp',
attribution: {
interactionTarget: 'adSlot',
interactionTargetElement: undefined,
interactionTime: 0,
nextPaintTime: 0,
interactionType: 'pointer',
processedEventEntries: [],
longAnimationFrameEntries: [],
inputDelay: 0,
processingDuration: 0,
presentationDelay: 0,
loadState: 'loading',
},
entries: [],
navigationType: 'navigate',
rating: 'good',
delta: defaultCoreWebVitalsPayload.inp,
} satisfies INPMetricWithAttribution);
},
onTTFB: (onReport: (metric: TTFBMetric) => void) => {
onReport({
value: defaultCoreWebVitalsPayload.ttfb,
name: 'TTFB',
id: 'ttfb',
entries: [],
navigationType: 'navigate',
rating: 'good',
delta: defaultCoreWebVitalsPayload.ttfb,
} satisfies TTFBMetric);
},
onFCP: (onReport: (metric: FCPMetric) => void) => {
onReport({
value: defaultCoreWebVitalsPayload.fcp,
name: 'FCP',
id: 'fcp',
entries: [],
navigationType: 'navigate',
rating: 'good',
delta: defaultCoreWebVitalsPayload.fcp,
} satisfies FCPMetric);
},
onFID: (onReport: (metric: FIDMetric) => void) => {
onReport({
value: defaultCoreWebVitalsPayload.fid,
name: 'FID',
id: 'fid',
entries: [],
rating: 'good',
navigationType: 'navigate',
delta: defaultCoreWebVitalsPayload.cls,
} satisfies FIDMetric);
},
}));

Expand Down Expand Up @@ -115,14 +179,16 @@ describe('coreWebVitals', () => {
global.dispatchEvent(new Event('pagehide'));

expect(mockBeacon).toHaveBeenCalledTimes(0);
expect(coreWebVitalsPayload).toMatchObject({
fid: null,
fcp: null,
lcp: null,
ttfb: null,
cls: null,
inp: null,
});
expect(coreWebVitalsPayload).toEqual(
expect.not.objectContaining({
fid: expect.anything(),
fcp: expect.anything(),
lcp: expect.anything(),
ttfb: expect.anything(),
cls: expect.anything(),
inp: expect.anything(),
}),
);
});

it('sends a beacon if sampling at 0% but bypassed via hash', async () => {
Expand Down Expand Up @@ -206,7 +272,7 @@ describe('Warnings', () => {
expect(mockConsoleWarn).toHaveBeenCalledWith(
'browserId or pageViewId missing from Core Web Vitals.',
expect.any(String),
expect.objectContaining({ browserId: null }),
expect.objectContaining({ browserId: undefined }),
);
});

Expand All @@ -216,7 +282,7 @@ describe('Warnings', () => {
expect(mockConsoleWarn).toHaveBeenCalledWith(
'browserId or pageViewId missing from Core Web Vitals.',
expect.any(String),
expect.objectContaining({ pageViewId: null }),
expect.objectContaining({ pageViewId: undefined }),
);
});

Expand Down Expand Up @@ -286,10 +352,7 @@ describe('Endpoints', () => {

global.dispatchEvent(new Event('pagehide'));

expect(mockBeacon).toHaveBeenCalledWith(
_.Endpoints.CODE,
expect.any(String),
);
expect(_.coreWebVitalsPayload.stage).toBe('CODE');
});

it('should use PROD URL if isDev is false', async () => {
Expand All @@ -298,10 +361,7 @@ describe('Endpoints', () => {

global.dispatchEvent(new Event('pagehide'));

expect(mockBeacon).toHaveBeenCalledWith(
_.Endpoints.PROD,
expect.any(String),
);
expect(_.coreWebVitalsPayload.stage).toBe('PROD');
});
});

Expand All @@ -315,7 +375,7 @@ describe('web-vitals', () => {
const isDev = true;
await initCoreWebVitals({ browserId, pageViewId, isDev, sampling: 1 });

_.coreWebVitalsPayload.fcp = null; // simulate a failing FCP
_.coreWebVitalsPayload.fcp = undefined; // simulate a failing FCP

setVisibilityState('hidden');
global.dispatchEvent(new Event('visibilitychange'));
Expand Down
Loading

0 comments on commit a0c21ad

Please sign in to comment.