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