Skip to content

Instantly share code, notes, and snippets.

@herablog
Last active January 10, 2024 09:01
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save herablog/1bdd1523de7862ab3f5114a98f3bfb62 to your computer and use it in GitHub Desktop.
Save herablog/1bdd1523de7862ab3f5114a98f3bfb62 to your computer and use it in GitHub Desktop.
CWV (Core Web Vitals) Report to Slack with CrUX API
/**
* This is an example code for Google App Script
* You can run this script with time-driven triggers
* @see https://developers.google.com/apps-script/guides/triggers/installable#time-driven_triggers
*/
// Settings
const CRUX_ORIGINS = []; // e.g. https://example.com
const CRUX_METRICS = ['first_input_delay', 'largest_contentful_paint', 'cumulative_layout_shift']; // first_contentful_paint, first_input_delay, largest_contentful_paint, cumulative_layout_shift
const CRUX_FORM_FACTOR = ['PHONE', 'DESKTOP']; // ALL_FORM_FACTORS, PHONE, DESKTOP, TABLET
const SLACK_WEBHOOK_URL = '';
/**
* @typedef {Map<string, {histogram: Array<{ start: number, end?: number, density: number}>}>} CrUXMetrics
* @typedef {{record: {key: Object, metrics: CrUXMetrics, urlNormalizationDetails: Object}} CrUXResult
*/
/**
* @see https://developers.google.com/web/tools/chrome-user-experience-report/api/guides/getting-started
*/
const CrUXUtil = {
/**
* @param {{origin: string, formFactor?: string, metrics?: Array<string>}} payload
* @param {string} apiKey
* @param {object} headers
* @return {CrUXResult}
*/
query: (payload, apiKey, headers) => {
const url = 'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=' + apiKey;
const options = {
method: 'POST',
contentType: 'application/json',
payload: JSON.stringify(payload)
};
if (headers) {
options.headers = headers;
}
const response = UrlFetchApp.fetch(url, options);
return JSON.parse(response);
}
};
const slackUtil = {
/**
* Send message to Slack via webhook
* @param {object} message - We can check the message format at https://api.slack.com/docs/messages/builder
* @return {void}
*/
send: (message) => {
const options = {
method: 'POST',
contentType: 'application/json',
payload: JSON.stringify(message)
};
UrlFetchApp.fetch(SLACK_WEBHOOK_URL, options);
}
};
/**
* Collect CrUX data and send it to Slack
*/
function collect() {
const apiKey = PropertiesService.getScriptProperties().getProperty('API_KEY');
// Comment out if you want to protect API key with HTTP referer
// You can set this restriction in GCP console
// const referer = PropertiesService.getScriptProperties().getProperty('API_REFERER');
CRUX_ORIGINS.forEach(function(origin) {
CRUX_FORM_FACTOR.forEach(function (formFactor) {
const params = {
origin,
metrics: CRUX_METRICS,
formFactor
};
const headers = {
// referer
}
const result = CrUXUtil.query(params, apiKey, headers);
const message = createMessage(result);
slackUtil.send(message);
});
});
}
/**
* Create message for Slack incoming webhooks
* @param {CrUXResult} CrUXResult
* @return {{ username: string, icon_emoji: string, text: string, attachments: Array }} message
*/
function createMessage(CrUXResult) {
const labeledMetrics = labelMetricData(CrUXResult.record.metrics);
const attachments = labeledMetrics.map(function (data) {
return {
fallback: data.acronym + ' value',
color: data.labeledBins[0].percentage >= 75 ? '#237b31' : '#d91c0b',
title: data.acronym,
text: data.labeledBins.map(bin => bin.label + ': ' + bin.percentage.toFixed(2) + '%').join(', ')
};
});
const heading = [
Object.entries(CrUXResult.record.key).map(entry => entry.join(': ')).join(', '),
'The field data is collected over the last 28 days.'
].join('\n');
const message = {
username: 'Core Web Vitals',
'icon_emoji': ':google:',
text: heading,
attachments
};
return message;
}
/**
* Label CrUX metrics
* @param {CrUXMetrics} metrics
* @return {{acronym: string, name: string, labelsBins: Array<{label: 'good'|'needs improvement'|'poor', percentage: number, start: number, end?: number, density: number}>}}
*/
function labelMetricData(metrics) {
const nameToAcronymMap = {
first_contentful_paint: 'FCP',
largest_contentful_paint: 'LCP',
first_input_delay: 'FID',
cumulative_layout_shift: 'CLS',
};
return Object.entries(metrics).map(function ([metricName, metricData]) {
const standardBinLabels = ['good', 'needs improvement', 'poor'];
const metricBins = metricData.histogram;
// We assume there are 3 histogram bins and they're in order of: good => poor
const labeledBins = metricBins.map((bin, i) => {
// Assign a good/poor label, calculate a percentage, and add retain all existing bin properties
return {
label: standardBinLabels[i],
percentage: bin.density * 100,
...bin,
};
});
return {
acronym: nameToAcronymMap[metricName],
name: metricName,
labeledBins,
};
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment