Skip to content

Commit

Permalink
Merge pull request #104 from Financial-Times/switch-to-web-vitals
Browse files Browse the repository at this point in the history
Switch from Perfume.js to Google Web Vitals
  • Loading branch information
rowanmanning authored Sep 5, 2022
2 parents 22601b2 + 205545c commit 93574da
Show file tree
Hide file tree
Showing 7 changed files with 101 additions and 74 deletions.
1 change: 0 additions & 1 deletion .npmrc

This file was deleted.

2 changes: 1 addition & 1 deletion docs/real-user-performance-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ if (flags.get('realUserMonitoringForPerformance')) {
}
```

This script will detect whether the browser supports performance timing, sample users based on their allocated Spoor ID, and initialise [Perfume.js](https://www.npmjs.com/package/perfume.js) to calculate the metrics.
This script will detect whether the browser supports performance timing, sample users based on their allocated Spoor ID, and initialise [web-vitals](https://www.npmjs.com/package/web-vitals) to calculate the metrics.

Once all metrics have been collected a `page:performance` event will be triggered and sent to Spoor.

Expand Down
33 changes: 14 additions & 19 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"jest": "^25.0.0",
"jest-environment-jsdom-fifteen": "^1.0.0",
"jsdom": "^15.1.0",
"perfume.js": "^6.3.0",
"react": "^16.9.0",
"rollup": "^1.31.0",
"rollup-plugin-babel": "^4.3.3",
Expand All @@ -40,7 +39,8 @@
"@financial-times/o-grid": "^5.0.0",
"@financial-times/o-tracking": "^4.0.0",
"@financial-times/o-viewport": "^4.0.0",
"ready-state": "^2.0.5"
"ready-state": "^2.0.5",
"web-vitals": "^3.0.0"
},
"peerDependencies": {
"react": ">=16.9.0 <19.0.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`src/client/trackers/realUserMonitoringForPerformance .realUserMonitoringForPerformance() analyticsTracker(options) when all metrics are sent broadcasts an o-tracking event with the formatted metrics data 1`] = `
exports[`src/client/trackers/realUserMonitoringForPerformance .realUserMonitoringForPerformance() recordMetric(metric) when all metrics are sent broadcasts an o-tracking event with the formatted metrics data 1`] = `
Object {
"action": "performance",
"category": "page",
"cumulativeLayoutShift": 13.7,
"cumulativeLayoutShift": 13.7654,
"domComplete": 123,
"domInteractive": 679,
"firstContentfulPaint": 14,
"firstInputDelay": 14,
"firstPaint": 14,
"largestContentfulPaint": 14,
"timeToFirstByte": 14,
"totalBlockingTime": 14,
"url": "http://localhost/",
}
`;
99 changes: 67 additions & 32 deletions src/client/trackers/__test__/realUserMonitoring.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@ jest.mock('../../broadcast', () => ({
}));
import {broadcast} from '../../broadcast';

// Mock Perfume
jest.mock('perfume.js', () => {
return jest.fn();
});
import Perfume from 'perfume.js';
// Mock web-vitals
jest.mock('web-vitals', () => ({
onCLS: jest.fn(),
onFCP: jest.fn(),
onFID: jest.fn(),
onLCP: jest.fn(),
onTTFB: jest.fn()
}));
import {onCLS, onFCP, onFID, onLCP, onTTFB} from 'web-vitals';

// Mock global performance metrics
Performance.prototype.getEntriesByType = jest.fn().mockReturnValue([
Expand All @@ -39,10 +43,6 @@ Performance.prototype.getEntriesByType = jest.fn().mockReturnValue([
}
]);

// Mock requestIdleCallback so we can capture metrics
// sent by Perfume
window.requestIdleCallback = jest.fn();

import {realUserMonitoringForPerformance} from '../realUserMonitoringForPerformance';

describe('src/client/trackers/realUserMonitoringForPerformance', () => {
Expand All @@ -53,23 +53,38 @@ describe('src/client/trackers/realUserMonitoringForPerformance', () => {
realUserMonitoringForPerformance();
});

it('creates a Perfume instance with a custom analytics tracker', () => {
expect(Perfume).toHaveBeenCalledTimes(1);
const perfumeOptions = Perfume.mock.calls[0][0];
expect(typeof perfumeOptions).toBe('object');
expect(typeof perfumeOptions.analyticsTracker).toBe('function');
it('listens to metrics with the web-vitals package', () => {
expect(onCLS).toHaveBeenCalledTimes(1);
expect(typeof onCLS.mock.calls[0][0]).toBe('function');
expect(onCLS.mock.calls[0][1]).toEqual({ reportAllChanges: true });
expect(onFCP).toHaveBeenCalledTimes(1);
expect(typeof onFCP.mock.calls[0][0]).toBe('function');
expect(onFID).toHaveBeenCalledTimes(1);
expect(typeof onFID.mock.calls[0][0]).toBe('function');
expect(onLCP).toHaveBeenCalledTimes(1);
expect(typeof onLCP.mock.calls[0][0]).toBe('function');
expect(onTTFB).toHaveBeenCalledTimes(1);
expect(typeof onTTFB.mock.calls[0][0]).toBe('function');
});

it('uses the same handler for all metrics', () => {
const clsHandler = onCLS.mock.calls[0][0];
expect(onFCP.mock.calls[0][0]).toStrictEqual(clsHandler);
expect(onFID.mock.calls[0][0]).toStrictEqual(clsHandler);
expect(onLCP.mock.calls[0][0]).toStrictEqual(clsHandler);
expect(onTTFB.mock.calls[0][0]).toStrictEqual(clsHandler);
});

describe('analyticsTracker(options)', () => {
let analyticsTracker;
describe('recordMetric(metric)', () => {
let recordMetric;
let oldConsole;

beforeEach(() => {
// We mock the console here so that our library's console.logs don't
// make it hard to read the test output
oldConsole = global.console;
global.console = {log: jest.fn()};
analyticsTracker = Perfume.mock.calls[0][0].analyticsTracker;
recordMetric = onCLS.mock.calls[0][0];
});

afterEach(() => {
Expand All @@ -78,7 +93,7 @@ describe('src/client/trackers/realUserMonitoringForPerformance', () => {

describe('when only one metric type is sent', () => {
beforeEach(() => {
analyticsTracker({ metricName: 'fid', data: 13.7 });
recordMetric({ name: 'FID', value: 13.7 });
});

it('does not broadcast an o-tracking event', () => {
Expand All @@ -88,13 +103,11 @@ describe('src/client/trackers/realUserMonitoringForPerformance', () => {

describe('when all metrics are sent', () => {
beforeEach(() => {
analyticsTracker({ metricName: 'fid', data: 13.7 });
analyticsTracker({ metricName: 'lcp', data: 13.7 });
analyticsTracker({ metricName: 'ttfb', data: 13.7 });
analyticsTracker({ metricName: 'fp', data: 13.7 });
analyticsTracker({ metricName: 'fcp', data: 13.7 });
analyticsTracker({ metricName: 'cls', data: 13.7 });
analyticsTracker({ metricName: 'tbt', data: 13.7 });
recordMetric({ name: 'FID', value: 13.7 });
recordMetric({ name: 'LCP', value: 13.7 });
recordMetric({ name: 'TTFB', value: 13.7 });
recordMetric({ name: 'FCP', value: 13.7 });
recordMetric({ name: 'CLS', value: 13.76539 });
});

it('broadcasts an o-tracking event with the formatted metrics data', () => {
Expand All @@ -104,9 +117,15 @@ describe('src/client/trackers/realUserMonitoringForPerformance', () => {
expect(broadcastArguments[1]).toMatchSnapshot();
});

it('rounds the CLS metric to four decimal places', () => {
expect(broadcast).toHaveBeenCalledTimes(1);
const {cumulativeLayoutShift} = broadcast.mock.calls[0][1];
expect(cumulativeLayoutShift).toBe(13.7654);
});

describe('when any one of the metrics are sent a second time', () => {
beforeEach(() => {
analyticsTracker({ metricName: 'fid', data: 13.7 });
recordMetric({ name: 'FID', value: 13.7 });
});

it('does not broadcast a second o-tracking event', () => {
Expand All @@ -120,27 +139,43 @@ describe('src/client/trackers/realUserMonitoringForPerformance', () => {
describe('when the seed is not in the sample', () => {

beforeEach(() => {
Perfume.mockReset();
onCLS.mockReset();
onFCP.mockReset();
onFID.mockReset();
onLCP.mockReset();
onTTFB.mockReset();
seedIsInSample.mockReturnValue(false);
realUserMonitoringForPerformance();
});

it('does not create a Perfume instance', () => {
expect(Perfume).toHaveBeenCalledTimes(0);
it('does not listen for performance metrics', () => {
expect(onCLS).toHaveBeenCalledTimes(0);
expect(onFCP).toHaveBeenCalledTimes(0);
expect(onFID).toHaveBeenCalledTimes(0);
expect(onLCP).toHaveBeenCalledTimes(0);
expect(onTTFB).toHaveBeenCalledTimes(0);
});

});

describe('when no "navigation" performance entries are available', () => {

beforeEach(() => {
Perfume.mockReset();
onCLS.mockReset();
onFCP.mockReset();
onFID.mockReset();
onLCP.mockReset();
onTTFB.mockReset();
Performance.prototype.getEntriesByType.mockReturnValue([]);
realUserMonitoringForPerformance();
});

it('does not create a Perfume instance', () => {
expect(Perfume).toHaveBeenCalledTimes(0);
it('does not listen for performance metrics', () => {
expect(onCLS).toHaveBeenCalledTimes(0);
expect(onFCP).toHaveBeenCalledTimes(0);
expect(onFID).toHaveBeenCalledTimes(0);
expect(onLCP).toHaveBeenCalledTimes(0);
expect(onTTFB).toHaveBeenCalledTimes(0);
});

});
Expand Down
30 changes: 15 additions & 15 deletions src/client/trackers/realUserMonitoringForPerformance.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Perfume from 'perfume.js';
import {onCLS, onFCP, onFID, onLCP, onTTFB} from 'web-vitals';
import readyState from 'ready-state';
import { broadcast } from '../broadcast';
import { seedIsInSample } from '../utils/seedIsInSample';
Expand All @@ -9,12 +9,10 @@ const requiredMetrics = [
'domInteractive',
'domComplete',
'timeToFirstByte',
'firstPaint',
'largestContentfulPaint',
'firstInputDelay',
'cumulativeLayoutShift',
'firstContentfulPaint',
'totalBlockingTime'
'firstContentfulPaint'
];

const defaultSampleRate = 10;
Expand Down Expand Up @@ -52,32 +50,33 @@ export const realUserMonitoringForPerformance = ({ sampleRate } = {}) => {
});

/**
* analyticsTracker()
* recordMetric()
*
* This function is called every time one of the performance events occurs.
* The "final" event should be `firstInputDelay`, which is triggered by any "input" event (most likely to be a click.)
* Once all the metrics are present, it fires a broadcast() to the Spoor API.
*/
let hasAlreadyBroadcast = false;

const analyticsTracker = (({ metricName, data}) => {
const recordMetric = ((metric) => {
if (hasAlreadyBroadcast) return;

// @see https://github.com/GoogleChrome/web-vitals#metric
// for available properties of `metric`
const metricName = metric.name.toLowerCase();
const data = metric.value;

if (metricName === 'fid') {
context.firstInputDelay = Math.round(data);
} else if (metricName === 'lcp') {
//This fires twice, we are collecting the 'first firing' - see PR notes for more context https://github.com/Financial-Times/n-tracking/pull/86
context.largestContentfulPaint = Math.round(data);
} else if (metricName === 'ttfb') {
context.timeToFirstByte = Math.round(data);
} else if (metricName === 'fp') {
context.firstPaint = Math.round(data);
} else if (metricName === 'fcp'){
context.firstContentfulPaint = Math.round(data);
} else if (metricName === 'cls') {
context.cumulativeLayoutShift = data;
} else if (metricName === 'tbt') {
context.totalBlockingTime = Math.round(data);
context.cumulativeLayoutShift = Number(data.toFixed(4));
}

context.url = window.document.location.href || null;
Expand All @@ -95,8 +94,9 @@ export const realUserMonitoringForPerformance = ({ sampleRate } = {}) => {
}
});

new Perfume({
analyticsTracker,
logging: false
});
onCLS(recordMetric, { reportAllChanges: true });
onFCP(recordMetric);
onFID(recordMetric);
onLCP(recordMetric);
onTTFB(recordMetric);
};

0 comments on commit 93574da

Please sign in to comment.