diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fbfec164..03d54f84 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,6 +9,7 @@ # Request review from original author /plugins/Algolia/ @andrewmumblebee /plugins/AutoTask/ @TimWheeler-SQUP +/plugins/ChromeUXReport/ @Daniel-Hodgson-SquaredUp /plugins/Checkmk/ @shawn149 /plugins/CodeRabbit/ @TimWheeler-SQUP /plugins/DattoRMM/ @TimWheeler-SQUP diff --git a/plugins/ChromeUXReport/v1/configValidation.json b/plugins/ChromeUXReport/v1/configValidation.json new file mode 100644 index 00000000..2bb1e687 --- /dev/null +++ b/plugins/ChromeUXReport/v1/configValidation.json @@ -0,0 +1,46 @@ +{ + "steps": [ + { + "displayName": "Check API key", + "dataStream": { + "name": "apiKeyCheck" + }, + "required": true, + "error": "Could not query the Chrome UX Report. Check the API key is valid and the Chrome UX Report API is enabled on its Google Cloud project.", + "success": "API key works." + }, + { + "displayName": "Check sitemap", + "dataStream": { + "name": "sitemapCheck", + "config": { + "errorOnEmptyResults": true + } + }, + "required": true, + "error": "The sitemap could not be read, or lists no pages on your website. Check the Sitemap URL opens in a browser and is plain XML, not gzipped. If the sitemap is on a CDN or another host, set the Website field. Sites that block cloud servers need the data source added via a relay agent.", + "success": "Sitemap found." + }, + { + "displayName": "Check for gzipped sitemaps", + "dataStream": { + "name": "sitemapGzipCheck", + "config": { + "errorOnEmptyResults": true + } + }, + "required": false, + "error": "Some sitemaps listed are gzipped (.xml.gz). They can't be read and will be skipped, so their pages won't be imported. Point Sitemap URL at plain XML sitemaps to include them.", + "success": "No gzipped sitemaps listed." + }, + { + "displayName": "Check website data", + "dataStream": { + "name": "origin" + }, + "required": false, + "error": "The Chrome UX Report holds no data for this website yet. Sites only appear once they have enough real Chrome traffic, so tiles stay empty until then. If your pages are on a different host from the sitemap, set the Website field.", + "success": "Chrome UX Report data found for this website." + } + ] +} diff --git a/plugins/ChromeUXReport/v1/custom_types.json b/plugins/ChromeUXReport/v1/custom_types.json new file mode 100644 index 00000000..c62217e9 --- /dev/null +++ b/plugins/ChromeUXReport/v1/custom_types.json @@ -0,0 +1,23 @@ +[ + { + "name": "origin", + "sourceType": "origin", + "icon": "globe", + "singular": "Website", + "plural": "Websites" + }, + { + "name": "page", + "sourceType": "page", + "icon": "file-lines", + "singular": "Page", + "plural": "Pages" + }, + { + "name": "competitor", + "sourceType": "competitor", + "icon": "flag-checkered", + "singular": "Competitor", + "plural": "Competitors" + } +] diff --git a/plugins/ChromeUXReport/v1/dataStreams/apiKeyCheck.json b/plugins/ChromeUXReport/v1/dataStreams/apiKeyCheck.json new file mode 100644 index 00000000..f8687f6d --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/apiKeyCheck.json @@ -0,0 +1,47 @@ +{ + "name": "apiKeyCheck", + "displayName": "API key check", + "description": "Returns a known origin from the Chrome UX Report to confirm the API key works", + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "baseUrl": "https://chromeuxreport.googleapis.com/v1", + "httpMethod": "post", + "endpointPath": "records:queryRecord", + "postBody": { + "origin": "https://www.google.com", + "metrics": [ + "largest_contentful_paint" + ] + }, + "pathToData": "record.key", + "errorHandling": { + "type": "path", + "realm": "payload", + "path": "error.message" + }, + "getArgs": [ + { + "key": "key", + "value": "{{dataSource.apiKey}}" + } + ], + "headers": [] + }, + "matches": "none", + "timeframes": false, + "visibility": { + "type": "hidden" + }, + "providesPluginDiagnostics": true, + "tags": [ + "Web Performance" + ], + "metadata": [ + { + "name": "origin", + "displayName": "Origin", + "shape": "string", + "role": "label" + } + ] +} diff --git a/plugins/ChromeUXReport/v1/dataStreams/coreWebVitals.json b/plugins/ChromeUXReport/v1/dataStreams/coreWebVitals.json new file mode 100644 index 00000000..606b7f4f --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/coreWebVitals.json @@ -0,0 +1,187 @@ +{ + "name": "coreWebVitals", + "displayName": "Core Web Vitals", + "description": "Returns the 75th percentile and good, needs improvement and poor shares of each Core Web Vital over the last 28 days of real Chrome users", + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "baseUrl": "https://chromeuxreport.googleapis.com/v1", + "httpMethod": "post", + "endpointPath": "records:queryRecord", + "getArgs": [ + { + "key": "key", + "value": "{{dataSource.apiKey}}" + } + ], + "headers": [], + "postBody": "{{JSON.stringify(Object.assign((level === 'origin' || ['origin', 'competitor'].includes(object.sourceType)) ? { origin: (String(object.name).startsWith('http') ? new URL(object.name).origin : object.name) } : { url: object.name }, formFactor && formFactor !== 'ALL' ? { formFactor } : {}, { metrics: ['largest_contentful_paint', 'interaction_to_next_paint', 'cumulative_layout_shift', 'first_contentful_paint', 'experimental_time_to_first_byte'] }))}}", + "postRequestScript": "coreWebVitals.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/coreWebVitals.js" + } + }, + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "origin", + "page", + "competitor" + ] + } + }, + "ui": [ + { + "name": "level", + "type": "radio", + "label": "Level", + "help": "**Page** reports the selected page. **Whole site** reports every page on the selected page's origin together.", + "defaultValue": "page", + "options": [ + { + "value": "page", + "label": "Page" + }, + { + "value": "origin", + "label": "Whole site" + } + ] + }, + { + "name": "formFactor", + "type": "radio", + "label": "Device", + "defaultValue": "ALL", + "options": [ + { + "value": "ALL", + "label": "All devices" + }, + { + "value": "PHONE", + "label": "Phone" + }, + { + "value": "DESKTOP", + "label": "Desktop" + }, + { + "value": "TABLET", + "label": "Tablet" + } + ] + } + ], + "timeframes": false, + "providesPluginDiagnostics": true, + "tags": [ + "Web Performance", + "SEO" + ], + "metadata": [ + { + "name": "target", + "displayName": "Page", + "shape": "url", + "role": "label" + }, + { + "name": "metric", + "displayName": "Metric", + "shape": "string" + }, + { + "name": "metricKey", + "displayName": "Metric key", + "shape": "string", + "visible": false + }, + { + "name": "p75", + "displayName": "75th percentile", + "shape": "number", + "role": "value", + "formatExpression": "{{ $['metricKey'] === 'cumulative_layout_shift' ? Number($['p75']).toFixed(2) : $['p75'] >= 1000 ? ($['p75'] / 1000).toFixed(2) + ' s' : Math.round($['p75']) + ' ms' }}" + }, + { + "name": "rating", + "displayName": "Rating", + "shape": [ + "state", + { + "map": { + "success": [ + "Good" + ], + "warning": [ + "Needs improvement" + ], + "error": [ + "Poor" + ] + } + } + ] + }, + { + "name": "good", + "displayName": "Good", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + }, + { + "name": "needsImprovement", + "displayName": "Needs improvement", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + }, + { + "name": "poor", + "displayName": "Poor", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + }, + { + "name": "isCoreWebVital", + "displayName": "Core Web Vital", + "shape": "boolean" + }, + { + "name": "device", + "displayName": "Device", + "shape": "string" + }, + { + "name": "level", + "displayName": "Level", + "shape": "string" + }, + { + "name": "periodEnd", + "displayName": "Period end", + "shape": [ + "date", + { + "format": "dd MMM yyyy" + } + ] + } + ], + "options": { + "minimumRequestIntervalSeconds": 21600 + } +} diff --git a/plugins/ChromeUXReport/v1/dataStreams/coreWebVitalsHistory.json b/plugins/ChromeUXReport/v1/dataStreams/coreWebVitalsHistory.json new file mode 100644 index 00000000..fd9d3017 --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/coreWebVitalsHistory.json @@ -0,0 +1,197 @@ +{ + "name": "coreWebVitalsHistory", + "displayName": "Core Web Vitals history", + "description": "Returns weekly 28-day rolling Core Web Vitals from real Chrome users, for up to the last 40 weeks", + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "baseUrl": "https://chromeuxreport.googleapis.com/v1", + "httpMethod": "post", + "endpointPath": "records:queryHistoryRecord", + "getArgs": [ + { + "key": "key", + "value": "{{dataSource.apiKey}}" + } + ], + "headers": [], + "postBody": "{{JSON.stringify(Object.assign((level === 'origin' || ['origin', 'competitor'].includes(object.sourceType)) ? { origin: (String(object.name).startsWith('http') ? new URL(object.name).origin : object.name) } : { url: object.name }, formFactor && formFactor !== 'ALL' ? { formFactor } : {}, { metrics: ['largest_contentful_paint', 'interaction_to_next_paint', 'cumulative_layout_shift', 'first_contentful_paint', 'experimental_time_to_first_byte'] }, { collectionPeriodCount: 40 }))}}", + "postRequestScript": "coreWebVitalsHistory.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/coreWebVitals.js" + } + }, + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "origin", + "page", + "competitor" + ] + } + }, + "ui": [ + { + "name": "level", + "type": "radio", + "label": "Level", + "help": "**Page** reports the selected page. **Whole site** reports every page on the selected page's origin together.", + "defaultValue": "page", + "options": [ + { + "value": "page", + "label": "Page" + }, + { + "value": "origin", + "label": "Whole site" + } + ] + }, + { + "name": "formFactor", + "type": "radio", + "label": "Device", + "defaultValue": "ALL", + "options": [ + { + "value": "ALL", + "label": "All devices" + }, + { + "value": "PHONE", + "label": "Phone" + }, + { + "value": "DESKTOP", + "label": "Desktop" + }, + { + "value": "TABLET", + "label": "Tablet" + } + ] + } + ], + "timeframes": [ + "last30days", + "thisMonth", + "lastMonth", + "thisQuarter", + "lastQuarter", + "thisYear" + ], + "supportsNoneTimeframe": true, + "defaultTimeframe": "none", + "providesPluginDiagnostics": true, + "tags": [ + "Web Performance", + "SEO" + ], + "metadata": [ + { + "name": "periodEnd", + "displayName": "Week ending", + "shape": [ + "date", + { + "format": "dd MMM yyyy" + } + ], + "role": "timestamp" + }, + { + "name": "target", + "displayName": "Page", + "shape": "url", + "role": "label" + }, + { + "name": "metric", + "displayName": "Metric", + "shape": "string" + }, + { + "name": "metricKey", + "displayName": "Metric key", + "shape": "string", + "visible": false + }, + { + "name": "p75", + "displayName": "75th percentile", + "shape": "number", + "role": "value", + "formatExpression": "{{ $['metricKey'] === 'cumulative_layout_shift' ? Number($['p75']).toFixed(2) : $['p75'] >= 1000 ? ($['p75'] / 1000).toFixed(2) + ' s' : Math.round($['p75']) + ' ms' }}" + }, + { + "name": "rating", + "displayName": "Rating", + "shape": [ + "state", + { + "map": { + "success": [ + "Good" + ], + "warning": [ + "Needs improvement" + ], + "error": [ + "Poor" + ] + } + } + ] + }, + { + "name": "good", + "displayName": "Good", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + }, + { + "name": "needsImprovement", + "displayName": "Needs improvement", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + }, + { + "name": "poor", + "displayName": "Poor", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + }, + { + "name": "isCoreWebVital", + "displayName": "Core Web Vital", + "shape": "boolean" + }, + { + "name": "device", + "displayName": "Device", + "shape": "string" + }, + { + "name": "level", + "displayName": "Level", + "shape": "string" + } + ], + "options": { + "minimumRequestIntervalSeconds": 86400 + } +} diff --git a/plugins/ChromeUXReport/v1/dataStreams/origin.json b/plugins/ChromeUXReport/v1/dataStreams/origin.json new file mode 100644 index 00000000..ad6d0425 --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/origin.json @@ -0,0 +1,47 @@ +{ + "name": "origin", + "displayName": "Origin", + "description": "Returns the website origin if the Chrome UX Report holds data for it", + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "baseUrl": "https://chromeuxreport.googleapis.com/v1", + "httpMethod": "post", + "endpointPath": "records:queryRecord", + "postBody": { + "origin": "{{new URL(dataSource.website ? (dataSource.website.includes('://') ? dataSource.website.trim() : 'https://' + dataSource.website.trim()) : dataSource.sitemapUrl).origin}}", + "metrics": [ + "largest_contentful_paint" + ] + }, + "pathToData": "record.key", + "errorHandling": { + "type": "path", + "realm": "payload", + "path": "error.message" + }, + "getArgs": [ + { + "key": "key", + "value": "{{dataSource.apiKey}}" + } + ], + "headers": [] + }, + "matches": "none", + "timeframes": false, + "visibility": { + "type": "hidden" + }, + "providesPluginDiagnostics": true, + "tags": [ + "Web Performance" + ], + "metadata": [ + { + "name": "origin", + "displayName": "Origin", + "shape": "string", + "role": "label" + } + ] +} diff --git a/plugins/ChromeUXReport/v1/dataStreams/pageDiagnostics.json b/plugins/ChromeUXReport/v1/dataStreams/pageDiagnostics.json new file mode 100644 index 00000000..3f046c91 --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/pageDiagnostics.json @@ -0,0 +1,142 @@ +{ + "name": "pageDiagnostics", + "displayName": "Page experience diagnostics", + "description": "Returns what makes up the Largest Contentful Paint, the LCP element type, how visitors navigated, their devices and their network round trip time over the last 28 days", + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "baseUrl": "https://chromeuxreport.googleapis.com/v1", + "httpMethod": "post", + "endpointPath": "records:queryRecord", + "getArgs": [ + { + "key": "key", + "value": "{{dataSource.apiKey}}" + } + ], + "headers": [], + "postBody": "{{JSON.stringify(Object.assign((level === 'origin' || ['origin', 'competitor'].includes(object.sourceType)) ? { origin: (String(object.name).startsWith('http') ? new URL(object.name).origin : object.name) } : { url: object.name }, formFactor && formFactor !== 'ALL' ? { formFactor } : {}, { metrics: ['largest_contentful_paint_image_time_to_first_byte', 'largest_contentful_paint_image_resource_load_delay', 'largest_contentful_paint_image_resource_load_duration', 'largest_contentful_paint_image_element_render_delay', 'largest_contentful_paint_resource_type', 'navigation_types', 'form_factors', 'round_trip_time'] }))}}", + "postRequestScript": "pageDiagnostics.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/coreWebVitals.js" + } + }, + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "origin", + "page", + "competitor" + ] + } + }, + "ui": [ + { + "name": "level", + "type": "radio", + "label": "Level", + "help": "**Page** reports the selected page. **Whole site** reports every page on the selected page's origin together.", + "defaultValue": "page", + "options": [ + { + "value": "page", + "label": "Page" + }, + { + "value": "origin", + "label": "Whole site" + } + ] + }, + { + "name": "formFactor", + "type": "radio", + "label": "Device", + "defaultValue": "ALL", + "options": [ + { + "value": "ALL", + "label": "All devices" + }, + { + "value": "PHONE", + "label": "Phone" + }, + { + "value": "DESKTOP", + "label": "Desktop" + }, + { + "value": "TABLET", + "label": "Tablet" + } + ] + } + ], + "timeframes": false, + "providesPluginDiagnostics": true, + "tags": [ + "Web Performance", + "SEO" + ], + "metadata": [ + { + "name": "target", + "displayName": "Page", + "shape": "url", + "role": "label" + }, + { + "name": "group", + "displayName": "Group", + "shape": "string" + }, + { + "name": "item", + "displayName": "Item", + "shape": "string" + }, + { + "name": "value", + "displayName": "Value", + "shape": "number", + "role": "value", + "formatExpression": "{{ $['unit'] === '%' ? $['value'].toFixed(1) + '%' : $['value'] >= 1000 ? ($['value'] / 1000).toFixed(2) + ' s' : Math.round($['value']) + ' ms' }}" + }, + { + "name": "unit", + "displayName": "Unit", + "shape": "string" + }, + { + "name": "order", + "displayName": "Order", + "shape": "number", + "visible": false + }, + { + "name": "device", + "displayName": "Device", + "shape": "string" + }, + { + "name": "level", + "displayName": "Level", + "shape": "string" + }, + { + "name": "periodEnd", + "displayName": "Period end", + "shape": [ + "date", + { + "format": "dd MMM yyyy" + } + ] + } + ], + "options": { + "minimumRequestIntervalSeconds": 21600 + } +} diff --git a/plugins/ChromeUXReport/v1/dataStreams/scripts/coreWebVitals.js b/plugins/ChromeUXReport/v1/dataStreams/scripts/coreWebVitals.js new file mode 100644 index 00000000..3c9acfb5 --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/scripts/coreWebVitals.js @@ -0,0 +1,37 @@ +const metrics = { + largest_contentful_paint: { name: "LCP", good: 2500, poor: 4000, core: true }, + interaction_to_next_paint: { name: "INP", good: 200, poor: 500, core: true }, + cumulative_layout_shift: { name: "CLS", good: 0.1, poor: 0.25, core: true }, + first_contentful_paint: { name: "FCP", good: 1800, poor: 3000, core: false }, + experimental_time_to_first_byte: { name: "TTFB", good: 800, poor: 1800, core: false } +}; +const percent = (density) => (density ?? 0) * 100; +const rate = (value, metric) => (value <= metric.good ? "Good" : value <= metric.poor ? "Needs improvement" : "Poor"); +const toDate = (d) => (d ? new Date(Date.UTC(d.year, d.month - 1, d.day)).toISOString() : null); + +const record = data?.record || {}; +const key = record.key || {}; +const periodEnd = toDate(record.collectionPeriod?.lastDate); + +// A metric without a 75th percentile can't be rated, so it is left out rather than shown as Poor +result = Object.entries(metrics) + .filter(([metricKey]) => Number.isFinite(Number(record.metrics?.[metricKey]?.percentiles?.p75))) + .map(([metricKey, metric]) => { + const values = record.metrics[metricKey]; + const p75 = Number(values.percentiles.p75); + const bins = values.histogram || []; + return { + target: key.url || key.origin, + metric: metric.name, + metricKey, + p75, + rating: rate(p75, metric), + good: percent(bins[0]?.density), + needsImprovement: percent(bins[1]?.density), + poor: percent(bins[2]?.density), + isCoreWebVital: metric.core, + device: key.formFactor || "ALL", + level: key.url ? "Page" : "Whole site", + periodEnd + }; + }); diff --git a/plugins/ChromeUXReport/v1/dataStreams/scripts/coreWebVitalsHistory.js b/plugins/ChromeUXReport/v1/dataStreams/scripts/coreWebVitalsHistory.js new file mode 100644 index 00000000..9730695a --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/scripts/coreWebVitalsHistory.js @@ -0,0 +1,48 @@ +const metrics = { + largest_contentful_paint: { name: "LCP", good: 2500, poor: 4000, core: true }, + interaction_to_next_paint: { name: "INP", good: 200, poor: 500, core: true }, + cumulative_layout_shift: { name: "CLS", good: 0.1, poor: 0.25, core: true }, + first_contentful_paint: { name: "FCP", good: 1800, poor: 3000, core: false }, + experimental_time_to_first_byte: { name: "TTFB", good: 800, poor: 1800, core: false } +}; +const percent = (density) => (density ?? 0) * 100; +const rate = (value, metric) => (value <= metric.good ? "Good" : value <= metric.poor ? "Needs improvement" : "Poor"); +const toDate = (d) => new Date(Date.UTC(d.year, d.month - 1, d.day)); + +const record = data?.record || {}; +const key = record.key || {}; +const periods = record.collectionPeriods || []; +// With no timeframe the platform still sends a range ending now, so ignore it +const timeframe = context.timeframe?.enum === "none" ? {} : context.timeframe || {}; +const start = timeframe.start ? new Date(timeframe.start) : null; +const end = timeframe.end ? new Date(timeframe.end) : null; + +result = Object.entries(metrics) + .filter(([metricKey]) => record.metrics?.[metricKey]) + .flatMap(([metricKey, metric]) => { + const values = record.metrics[metricKey]; + const p75s = values.percentilesTimeseries?.p75s || []; + const bins = values.histogramTimeseries || []; + return periods.map((period, i) => { + const periodEnd = toDate(period.lastDate); + // Weeks with too little traffic come back as null + if (!Number.isFinite(Number(p75s[i])) || p75s[i] === null) return null; + if ((start && periodEnd < start) || (end && periodEnd > end)) return null; + const p75 = Number(p75s[i]); + return { + periodEnd: periodEnd.toISOString(), + target: key.url || key.origin, + metric: metric.name, + metricKey, + p75, + rating: rate(p75, metric), + good: percent(bins[0]?.densities?.[i]), + needsImprovement: percent(bins[1]?.densities?.[i]), + poor: percent(bins[2]?.densities?.[i]), + isCoreWebVital: metric.core, + device: key.formFactor || "ALL", + level: key.url ? "Page" : "Whole site" + }; + }); + }) + .filter(Boolean); diff --git a/plugins/ChromeUXReport/v1/dataStreams/scripts/errorHandling/coreWebVitals.js b/plugins/ChromeUXReport/v1/dataStreams/scripts/errorHandling/coreWebVitals.js new file mode 100644 index 00000000..a4474394 --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/scripts/errorHandling/coreWebVitals.js @@ -0,0 +1,8 @@ +// CrUX answers 400 when the object name isn't a web address (for example another plugin's object) +// and 404 when a page or website has too little Chrome traffic to report +// A 400 about the API key is a setup problem, so its own message is kept +result = response.status === 400 && !/api key/i.test(data?.error?.message || "") + ? "This object isn't a web page address, so the Chrome UX Report can't look it up." + : response.status === 404 + ? "No Chrome UX Report data for this page or website on this device. CrUX only covers pages and websites with enough real Chrome traffic." + : data?.error?.message || "Request failed with HTTP " + response.status; diff --git a/plugins/ChromeUXReport/v1/dataStreams/scripts/errorHandling/sitemapCheck.js b/plugins/ChromeUXReport/v1/dataStreams/scripts/errorHandling/sitemapCheck.js new file mode 100644 index 00000000..7c2121df --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/scripts/errorHandling/sitemapCheck.js @@ -0,0 +1,4 @@ +// Bot protection usually answers 403 with an HTML page, so name the likely cause +result = response.status === 403 + ? "The website refused the request (HTTP 403). Its bot protection may block cloud servers; try adding the data source via a relay agent." + : "Could not read the sitemap (HTTP " + response.status + "). Check the Sitemap URL opens in a browser."; diff --git a/plugins/ChromeUXReport/v1/dataStreams/scripts/pageDiagnostics.js b/plugins/ChromeUXReport/v1/dataStreams/scripts/pageDiagnostics.js new file mode 100644 index 00000000..b046dd69 --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/scripts/pageDiagnostics.js @@ -0,0 +1,52 @@ +// One row per diagnostic, grouped so a tile can filter to the LCP breakdown, the LCP element +// type, how visitors navigated, which devices they used, or their network round trip time +const record = data?.record || {}; +const key = record.key || {}; +const metrics = record.metrics || {}; +const lastDate = record.collectionPeriod?.lastDate; +const periodEnd = lastDate ? new Date(Date.UTC(lastDate.year, lastDate.month - 1, lastDate.day)).toISOString() : null; +const percent = (fraction) => (fraction ?? 0) * 100; + +const lcpParts = [ + ["largest_contentful_paint_image_time_to_first_byte", "TTFB", 1], + ["largest_contentful_paint_image_resource_load_delay", "Load delay", 2], + ["largest_contentful_paint_image_resource_load_duration", "Load time", 3], + ["largest_contentful_paint_image_element_render_delay", "Render delay", 4] +]; +const navigationNames = { + navigate: "Navigate", + navigate_cache: "HTTP cache", + reload: "Reload", + restore: "Restore", + back_forward: "Back/forward", + back_forward_cache: "bfcache", + prerender: "Prerender" +}; +const fractionRows = (metricKey, group, names) => + Object.entries(metrics[metricKey]?.fractions || {}).map(([item, fraction], i) => ({ + group, + item: names?.[item] || item.charAt(0).toUpperCase() + item.slice(1), + value: percent(fraction), + unit: "%", + order: names ? Object.keys(names).indexOf(item) + 1 : i + 1 + })); + +const rows = [ + ...lcpParts + .filter(([metricKey]) => metrics[metricKey]) + .map(([metricKey, item, order]) => ({ group: "LCP breakdown", item, value: Number(metrics[metricKey].percentiles?.p75), unit: "ms", order })), + ...fractionRows("largest_contentful_paint_resource_type", "LCP element"), + ...fractionRows("navigation_types", "Navigation type", navigationNames), + ...fractionRows("form_factors", "Device"), + ...(metrics.round_trip_time + ? [{ group: "Network", item: "Round trip time", value: Number(metrics.round_trip_time.percentiles?.p75), unit: "ms", order: 1 }] + : []) +]; + +result = rows.filter((row) => Number.isFinite(row.value)).map((row) => ({ + target: key.url || key.origin, + level: key.url ? "Page" : "Whole site", + device: key.formFactor || "ALL", + periodEnd, + ...row +})); diff --git a/plugins/ChromeUXReport/v1/dataStreams/scripts/sitemapCheck.js b/plugins/ChromeUXReport/v1/dataStreams/scripts/sitemapCheck.js new file mode 100644 index 00000000..7ea2c0f9 --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/scripts/sitemapCheck.js @@ -0,0 +1,28 @@ +// One row describing the configured sitemap, or no rows when it lists nothing that can be imported +const config = context.dataSources[0]; +const first = (value) => (Array.isArray(value) ? value[0] : value); +const toUrl = (text) => { + try { + return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : "https://" + text); + } catch { + return null; + } +}; +// The same rule as the import: the Website field when it's set, otherwise the sitemap's own origin +const origin = (toUrl(String(config.website || "").trim()) || new URL(config.sitemapUrl)).origin; +const onSite = (url) => url && url.origin === origin; + +const pageUrls = [].concat(data?.urlset?.url || []).map((entry) => toUrl(String(first(entry?.loc) || ""))); +const sitemaps = [].concat(data?.sitemapindex?.sitemap || []).map((entry) => String(first(entry?.loc) || "")); +// The same child rule as the import: the sitemap's own host, or the website's domain and its subdomains +const siteHost = new URL(origin).hostname.replace(/^www\./, ""); +const importable = (loc) => { + const url = toUrl(loc); + return Boolean(url) && !/\.gz$/i.test(url.pathname) + && (url.hostname === new URL(config.sitemapUrl).hostname || url.hostname === siteHost || url.hostname.endsWith("." + siteHost)); +}; +const readableSitemaps = sitemaps.filter(importable).length; +// Pages on another origin usually mean the sitemap is on a CDN or bare domain and Website is needed +const sitePages = pageUrls.filter(onSite).length; + +result = sitePages || readableSitemaps ? [{ pages: sitePages, sitemaps: sitemaps.length, readableSitemaps }] : []; diff --git a/plugins/ChromeUXReport/v1/dataStreams/scripts/sitemapEntries.js b/plugins/ChromeUXReport/v1/dataStreams/scripts/sitemapEntries.js new file mode 100644 index 00000000..e8e180b3 --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/scripts/sitemapEntries.js @@ -0,0 +1,120 @@ +// Walks the sitemap and its child sitemaps one import page at a time, so no single page +// passes the platform's response size limit. The queue of sitemaps still to read rides in +// the next URL's fragment, which is never sent to the website. +const PAGES_PER_CHUNK = 5000; +const MAX_IMPORT_PAGES = 200; + +const config = context.dataSources[0]; +const maxPages = Number(config.maxPages) || 5000; +const configured = new URL(config.sitemapUrl); +const first = (value) => (Array.isArray(value) ? value[0] : value); +const lines = (text) => String(text || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); +const bareHost = (hostname) => hostname.replace(/^www\./, ""); +// A short fingerprint keeps the list of visited sitemaps small enough to carry in the URL +const fingerprint = (text) => { + let hash = 5381; + for (let i = 0; i < text.length; i++) hash = ((hash * 33) ^ text.charCodeAt(i)) >>> 0; + return hash.toString(36); +}; + +const normalize = (text) => { + let value = String(text || "").trim(); + // People often type www.example.com without the scheme + if (value && !/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) value = "https://" + value; + let url; + try { + url = new URL(value); + } catch { + return null; + } + url.pathname = url.pathname.replace(/\/{2,}/g, "/"); + url.hash = ""; + return url; +}; + +// The website is the Website field when it's set (for a sitemap kept on a CDN or another host), +// otherwise the sitemap's own origin. Only its pages are imported, so no subdomain can crowd it out. +const origin = (normalize(config.website) || configured).origin; +// Child sitemaps may sit on any subdomain of the site, such as sitemaps.example.com +const siteHost = bareHost(new URL(origin).hostname); +const onSiteDomain = (url) => url.hostname === siteHost || url.hostname.endsWith("." + siteHost); + +const incoming = context.pagingContext ? String(context.pagingContext) : ""; +// Defaults cover state written by an older version of this script during an upgrade +const state = { + queue: [], + seen: [], + skip: 0, + page: 0, + imported: 0, + ...(incoming.includes("#crux=") ? JSON.parse(decodeURIComponent(incoming.split("#crux=")[1])) : {}) +}; +const current = incoming ? incoming.split("#")[0] : configured.href; +const page = state.page + 1; +const seen = new Set([...(state.seen || []), fingerprint(configured.href), fingerprint(current)]); + +const isIndex = Boolean(data?.sitemapindex); +const entries = [...[].concat(data?.urlset?.url || []), ...[].concat(data?.sitemapindex?.sitemap || [])] + .map((entry) => normalize(first(entry?.loc))) + .filter(Boolean); + +const children = []; +const sitePages = []; +for (const url of entries) { + // In a page list, only files named like sitemaps are sitemaps, so a feed.xml page stays a page + const isSitemap = isIndex || /sitemap[^/]*\.xml(\.gz)?$/i.test(url.pathname); + if (!isSitemap) { + if (url.origin === origin) sitePages.push(url); + continue; + } + const key = fingerprint(url.href); + // Gzipped sitemaps can't be read here, so only plain XML sitemaps are followed + if ((url.hostname === configured.hostname || onSiteDomain(url)) && !/\.gz$/i.test(url.pathname) && !seen.has(key)) { + children.push(url.href); + seen.add(key); + } +} + +const rows = new Map(); +const keyPages = lines(config.keyPages).map(normalize).filter(Boolean); +if (page === 1) { + rows.set(origin, { url: origin, sourceType: "origin" }); + for (const competitor of lines(config.competitors).map(normalize)) { + if (competitor && competitor.origin !== origin) { + rows.set(competitor.origin, { url: competitor.origin, sourceType: "competitor" }); + } + } + // Key pages are imported even when the sitemap doesn't list them, and don't use up Maximum pages + for (const keyPage of keyPages) { + rows.set(keyPage.href, { url: keyPage.href, sourceType: "page", pageGroup: "Key page" }); + } +} + +// Key pages were imported on the first page, so the sitemap's copy is skipped, with or without +// a trailing slash, rather than becoming a second object for the same page +const withoutSlash = (href) => href.replace(/\/(?=$|\?)/, ""); +const keyPageHrefs = new Set(keyPages.map((url) => withoutSlash(url.href))); +// Key pages are dropped before the limit is applied, so a key page the sitemap also lists never uses up Maximum pages +const pagesToImport = sitePages.filter((url) => !keyPageHrefs.has(withoutSlash(url.href))); +const room = Math.max(0, maxPages - state.imported); +const chunk = pagesToImport.slice(state.skip, state.skip + Math.min(PAGES_PER_CHUNK, room)); +for (const url of chunk) { + rows.set(url.href, { url: url.href, sourceType: "page", pageGroup: "Other page" }); +} +result = [...rows.values()]; +const imported = state.imported + chunk.length; + +// Children are queued only on the first chunk of a sitemap, so a split sitemap adds them once +const queue = [...(state.skip === 0 ? children.map((u) => ({ u, skip: 0 })) : []), ...state.queue]; +if (state.skip + PAGES_PER_CHUNK < pagesToImport.length) { + queue.unshift({ u: current, skip: state.skip + PAGES_PER_CHUNK }); +} + +if (queue.length && page < MAX_IMPORT_PAGES && imported < maxPages) { + const [next, ...rest] = queue; + // Only MAX_IMPORT_PAGES sitemaps are ever read, so a longer queue would only bloat the state + const nextState = { queue: rest.slice(0, MAX_IMPORT_PAGES - page), seen: [...seen], skip: next.skip, page, imported }; + pagingContext = next.u + "#crux=" + encodeURIComponent(JSON.stringify(nextState)); +} else { + pagingContext = undefined; +} diff --git a/plugins/ChromeUXReport/v1/dataStreams/scripts/sitemapGzipCheck.js b/plugins/ChromeUXReport/v1/dataStreams/scripts/sitemapGzipCheck.js new file mode 100644 index 00000000..264cf3f5 --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/scripts/sitemapGzipCheck.js @@ -0,0 +1,14 @@ +// One row when the sitemap lists no gzipped sitemaps, none when it does, so setup can warn +const first = (value) => (Array.isArray(value) ? value[0] : value); +const gzipped = [].concat(data?.sitemapindex?.sitemap || [], data?.urlset?.url || []) + .filter((entry) => { + const loc = String(first(entry?.loc) || ""); + // Test the path alone, so a query string after .xml.gz still counts + try { + return /\.xml\.gz$/i.test(new URL(loc).pathname); + } catch { + return /\.xml\.gz$/i.test(loc); + } + }).length; + +result = gzipped ? [] : [{ gzipped }]; diff --git a/plugins/ChromeUXReport/v1/dataStreams/sitemap.json b/plugins/ChromeUXReport/v1/dataStreams/sitemap.json new file mode 100644 index 00000000..ac0e1969 --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/sitemap.json @@ -0,0 +1,47 @@ +{ + "name": "sitemap", + "displayName": "Sitemap", + "description": "Returns the website origin, competitor origins and every page listed in the configured sitemap and its child sitemaps", + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "baseUrl": "{{new URL(dataSource.sitemapUrl).origin}}", + "httpMethod": "get", + "endpointPath": "{{new URL(dataSource.sitemapUrl).pathname.slice(1) + new URL(dataSource.sitemapUrl).search}}", + "getArgs": [], + "headers": [], + "postRequestScript": "sitemapEntries.js", + "paging": { + "mode": "nextUrl", + "in": { + "realm": "payload", + "path": "cruxNextSitemap" + } + } + }, + "matches": "none", + "timeframes": false, + "visibility": { + "type": "hidden" + }, + "tags": [ + "Web Performance" + ], + "metadata": [ + { + "name": "url", + "displayName": "URL", + "shape": "url", + "role": "label" + }, + { + "name": "sourceType", + "displayName": "Type", + "shape": "string" + }, + { + "name": "pageGroup", + "displayName": "Page group", + "shape": "string" + } + ] +} diff --git a/plugins/ChromeUXReport/v1/dataStreams/sitemapCheck.json b/plugins/ChromeUXReport/v1/dataStreams/sitemapCheck.json new file mode 100644 index 00000000..b3d19d48 --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/sitemapCheck.json @@ -0,0 +1,43 @@ +{ + "name": "sitemapCheck", + "displayName": "Sitemap check", + "description": "Returns how many pages and child sitemaps the configured sitemap lists", + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "baseUrl": "{{new URL(dataSource.sitemapUrl).origin}}", + "httpMethod": "get", + "endpointPath": "{{new URL(dataSource.sitemapUrl).pathname.slice(1) + new URL(dataSource.sitemapUrl).search}}", + "getArgs": [], + "headers": [], + "postRequestScript": "sitemapCheck.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/sitemapCheck.js" + } + }, + "matches": "none", + "timeframes": false, + "visibility": { + "type": "hidden" + }, + "tags": [ + "Web Performance" + ], + "metadata": [ + { + "name": "pages", + "displayName": "Pages", + "shape": "number" + }, + { + "name": "sitemaps", + "displayName": "Child sitemaps", + "shape": "number" + }, + { + "name": "readableSitemaps", + "displayName": "Readable child sitemaps", + "shape": "number" + } + ] +} diff --git a/plugins/ChromeUXReport/v1/dataStreams/sitemapGzipCheck.json b/plugins/ChromeUXReport/v1/dataStreams/sitemapGzipCheck.json new file mode 100644 index 00000000..c4960b0a --- /dev/null +++ b/plugins/ChromeUXReport/v1/dataStreams/sitemapGzipCheck.json @@ -0,0 +1,33 @@ +{ + "name": "sitemapGzipCheck", + "displayName": "Sitemap gzip check", + "description": "Returns a row when the configured sitemap lists no gzipped sitemaps", + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "baseUrl": "{{new URL(dataSource.sitemapUrl).origin}}", + "httpMethod": "get", + "endpointPath": "{{new URL(dataSource.sitemapUrl).pathname.slice(1) + new URL(dataSource.sitemapUrl).search}}", + "getArgs": [], + "headers": [], + "postRequestScript": "sitemapGzipCheck.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/sitemapCheck.js" + } + }, + "matches": "none", + "timeframes": false, + "visibility": { + "type": "hidden" + }, + "tags": [ + "Web Performance" + ], + "metadata": [ + { + "name": "gzipped", + "displayName": "Gzipped sitemaps", + "shape": "number" + } + ] +} diff --git a/plugins/ChromeUXReport/v1/defaultContent/competitors.dash.json b/plugins/ChromeUXReport/v1/defaultContent/competitors.dash.json new file mode 100644 index 00000000..e57aa4c9 --- /dev/null +++ b/plugins/ChromeUXReport/v1/defaultContent/competitors.dash.json @@ -0,0 +1,536 @@ +{ + "name": "Competitors", + "schemaVersion": "1.5", + "timeframe": "none", + "variables": [], + "dashboard": { + "_type": "layout/grid", + "columns": 12, + "version": 1, + "contents": [ + { + "i": "d4e8816d-09d7-4a6e-8bde-26dae70717e2", + "x": 0, + "y": 0, + "w": 12, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Your Site vs Competitors", + "description": "Loading speed (LCP), responsiveness (INP) and visual stability (CLS) on phone for your site and each competitor, as met or beaten by 3 in 4 visits, with the share of good visits. Empty until you add competitors to the data source.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT site, kind, assessment, lcp, lcpGood, inp, inpGood, cls, clsGood\nFROM (\n SELECT replace(replace(target, 'https://', ''), 'http://', '') AS site, 'Your site' AS kind, 1 AS kindOrder, * FROM (SELECT target,\n CASE bool_and(rating = 'success') FILTER (WHERE isCoreWebVital) WHEN true THEN 'Passed' WHEN false THEN 'Failed' ELSE 'No data' END AS assessment,\n max(CASE WHEN metric = 'LCP' THEN CASE WHEN metric = 'CLS' THEN printf('%.2f', p75) WHEN p75 >= 1000 THEN printf('%.2f s', p75 / 1000) ELSE printf('%d ms', CAST(round(p75) AS BIGINT)) END END) AS lcp, max(CASE WHEN metric = 'LCP' THEN CAST(good AS DOUBLE) END) AS lcpGood,\n max(CASE WHEN metric = 'INP' THEN CASE WHEN metric = 'CLS' THEN printf('%.2f', p75) WHEN p75 >= 1000 THEN printf('%.2f s', p75 / 1000) ELSE printf('%d ms', CAST(round(p75) AS BIGINT)) END END) AS inp, max(CASE WHEN metric = 'INP' THEN CAST(good AS DOUBLE) END) AS inpGood,\n max(CASE WHEN metric = 'CLS' THEN CASE WHEN metric = 'CLS' THEN printf('%.2f', p75) WHEN p75 >= 1000 THEN printf('%.2f s', p75 / 1000) ELSE printf('%d ms', CAST(round(p75) AS BIGINT)) END END) AS cls, max(CASE WHEN metric = 'CLS' THEN CAST(good AS DOUBLE) END) AS clsGood,\n max(CASE WHEN metric = 'LCP' THEN p75 END) AS lcpSort\n FROM own GROUP BY target)\n UNION ALL\n SELECT replace(replace(target, 'https://', ''), 'http://', '') AS site, 'Competitor' AS kind, 2 AS kindOrder, * FROM (SELECT target,\n CASE bool_and(rating = 'success') FILTER (WHERE isCoreWebVital) WHEN true THEN 'Passed' WHEN false THEN 'Failed' ELSE 'No data' END AS assessment,\n max(CASE WHEN metric = 'LCP' THEN CASE WHEN metric = 'CLS' THEN printf('%.2f', p75) WHEN p75 >= 1000 THEN printf('%.2f s', p75 / 1000) ELSE printf('%d ms', CAST(round(p75) AS BIGINT)) END END) AS lcp, max(CASE WHEN metric = 'LCP' THEN CAST(good AS DOUBLE) END) AS lcpGood,\n max(CASE WHEN metric = 'INP' THEN CASE WHEN metric = 'CLS' THEN printf('%.2f', p75) WHEN p75 >= 1000 THEN printf('%.2f s', p75 / 1000) ELSE printf('%d ms', CAST(round(p75) AS BIGINT)) END END) AS inp, max(CASE WHEN metric = 'INP' THEN CAST(good AS DOUBLE) END) AS inpGood,\n max(CASE WHEN metric = 'CLS' THEN CASE WHEN metric = 'CLS' THEN printf('%.2f', p75) WHEN p75 >= 1000 THEN printf('%.2f s', p75 / 1000) ELSE printf('%d ms', CAST(round(p75) AS BIGINT)) END END) AS cls, max(CASE WHEN metric = 'CLS' THEN CAST(good AS DOUBLE) END) AS clsGood,\n max(CASE WHEN metric = 'LCP' THEN p75 END) AS lcpSort\n FROM competitors GROUP BY target)\n)\nORDER BY kindOrder, lcpSort", + "tables": [ + { + "tableName": "own", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + }, + { + "tableName": "competitors", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Competitors]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "site", + "displayName": "Site", + "shape": "string", + "role": "label" + }, + { + "name": "kind", + "displayName": "Site type", + "shape": "string" + }, + { + "name": "assessment", + "displayName": "Assessment", + "shape": "string" + }, + { + "name": "lcp", + "displayName": "LCP", + "shape": "string" + }, + { + "name": "lcpGood", + "displayName": "LCP good", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + }, + { + "name": "inp", + "displayName": "INP", + "shape": "string" + }, + { + "name": "inpGood", + "displayName": "INP good", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + }, + { + "name": "cls", + "displayName": "CLS", + "shape": "string" + }, + { + "name": "clsGood", + "displayName": "CLS good", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "site", + "title": "Site", + "renderer": { + "type": "text" + } + }, + { + "field": "kind", + "title": "Site type", + "renderer": { + "type": "badge", + "colors": { + "Your site": "#3B82F6", + "Competitor": "#9CA3AF" + } + } + }, + { + "field": "assessment", + "title": "Assessment", + "renderer": { + "type": "badge", + "colors": { + "Passed": "#0CCE6B", + "Failed": "#FF4E42" + } + } + }, + { + "field": "lcp", + "title": "LCP", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "lcpGood", + "title": "LCP good", + "renderer": { + "type": "number", + "decimals": 1, + "suffix": "%" + }, + "align": "right" + }, + { + "field": "inp", + "title": "INP", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "inpGood", + "title": "INP good", + "renderer": { + "type": "number", + "decimals": 1, + "suffix": "%" + }, + "align": "right" + }, + { + "field": "cls", + "title": "CLS", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "clsGood", + "title": "CLS good", + "renderer": { + "type": "number", + "decimals": 1, + "suffix": "%" + }, + "align": "right" + } + ] + } + } + } + } + }, + { + "i": "082475a6-8e23-4d9c-8ba0-59a12f2f234c", + "x": 0, + "y": 3, + "w": 6, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Who Is Improving", + "description": "Loading speed (LCP) on phone for each site now against four weeks earlier, as met or beaten by 3 in 4 visits. Lower is better, and changes under 2% count as the same.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH ranked AS (\n SELECT replace(replace(target, 'https://', ''), 'http://', '') AS site, 1 AS labelOrder, metric, periodEnd, p75, max(CAST(periodEnd AS DATE)) OVER (PARTITION BY target, metric) AS latest FROM own WHERE p75 IS NOT NULL AND metric = 'LCP'\n UNION ALL\n SELECT replace(replace(target, 'https://', ''), 'http://', '') AS site, 2 AS labelOrder, metric, periodEnd, p75, max(CAST(periodEnd AS DATE)) OVER (PARTITION BY target, metric) AS latest FROM competitors WHERE p75 IS NOT NULL AND metric = 'LCP'\n), paired AS (\n SELECT site, labelOrder, metric,\n max(CASE WHEN CAST(periodEnd AS DATE) = latest THEN p75 END) AS now,\n max(CASE WHEN CAST(periodEnd AS DATE) = latest - INTERVAL 28 DAY THEN p75 END) AS before\n FROM ranked GROUP BY ALL\n), diffed AS (SELECT *, now - before AS diff FROM paired WHERE now IS NOT NULL AND before IS NOT NULL)\nSELECT site, metric,\n CASE WHEN metric = 'CLS' THEN printf('%.2f', now)\n WHEN metric = 'INP' THEN printf('%d ms', CAST(round(now) AS BIGINT))\n ELSE printf('%.2f s', now / 1000) END AS nowText,\n CASE WHEN metric = 'CLS' THEN printf('%.2f', before)\n WHEN metric = 'INP' THEN printf('%d ms', CAST(round(before) AS BIGINT))\n ELSE printf('%.2f s', before / 1000) END AS beforeText,\n CASE WHEN metric = 'CLS' THEN printf('%+.2f', diff)\n WHEN metric = 'INP' THEN printf('%+d ms', CAST(round(diff) AS BIGINT))\n ELSE printf('%+.2f s', diff / 1000) END AS change,\n CASE WHEN abs(diff) < 0.02 * nullif(before, 0) OR diff = 0 THEN 'Same' WHEN diff < 0 THEN 'Better' ELSE 'Worse' END AS verdict\nFROM diffed\nORDER BY CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END, labelOrder, site", + "tables": [ + { + "tableName": "own", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + }, + { + "tableName": "competitors", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Competitors]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "site", + "displayName": "Site", + "shape": "string", + "role": "label" + }, + { + "name": "metric", + "displayName": "Metric", + "shape": "string" + }, + { + "name": "nowText", + "displayName": "Now", + "shape": "string" + }, + { + "name": "beforeText", + "displayName": "4 weeks ago", + "shape": "string" + }, + { + "name": "change", + "displayName": "Change", + "shape": "string" + }, + { + "name": "verdict", + "displayName": "Trend", + "shape": "string" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "site", + "title": "Site", + "renderer": { + "type": "text" + } + }, + { + "field": "nowText", + "title": "LCP now", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "beforeText", + "title": "4 weeks ago", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "change", + "title": "Change", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "verdict", + "title": "Trend", + "renderer": { + "type": "badge", + "colors": { + "Better": "#0CCE6B", + "Same": "#9CA3AF", + "Worse": "#FF4E42" + } + } + } + ] + } + } + } + } + }, + { + "i": "20aca1c8-4fb2-4e95-becf-7e663d24880f", + "x": 6, + "y": 3, + "w": 6, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "LCP Trend by Site", + "description": "Largest Contentful Paint (LCP, how long the main content takes to appear) on phone for your site and each competitor, as met or beaten by 3 in 4 visits. Each point is the 28 days ending that week.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT periodEnd, replace(replace(target, 'https://', ''), 'http://', '') AS site, p75 FROM own WHERE metric = 'LCP'\nUNION ALL\nSELECT periodEnd, replace(replace(target, 'https://', ''), 'http://', '') AS site, p75 FROM competitors WHERE metric = 'LCP'\nORDER BY periodEnd, site", + "tables": [ + { + "tableName": "own", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + } + } + }, + { + "tableName": "competitors", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Competitors]}}", + "workspace": "{{workspaceId}}" + } + } + } + ] + }, + "metadata": [ + { + "name": "periodEnd", + "displayName": "Week ending", + "shape": [ + "date", + { + "format": "dd MMM yyyy" + } + ], + "role": "timestamp" + }, + { + "name": "site", + "displayName": "Site", + "shape": "string" + }, + { + "name": "p75", + "displayName": "75th percentile", + "shape": [ + "number", + { + "decimalPlaces": 0 + } + ], + "role": "value" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "line", + "encoding": { + "x": { + "field": "periodEnd", + "type": "temporal", + "title": "Week ending" + }, + "y": { + "field": "p75", + "type": "quantitative", + "title": "75th percentile (ms)" + }, + "series": { + "field": "site", + "type": "nominal", + "title": "Site" + } + }, + "style": { + "legendPosition": "bottom", + "cartesianYAxisDecimalPlaces": 0 + }, + "markLines": [ + { + "value": 2500, + "label": "Good", + "color": "#0CCE6B" + }, + { + "value": 4000, + "label": "Poor", + "color": "#FF4E42" + } + ] + } + } + } + } + ] + } +} diff --git a/plugins/ChromeUXReport/v1/defaultContent/keyPages.dash.json b/plugins/ChromeUXReport/v1/defaultContent/keyPages.dash.json new file mode 100644 index 00000000..f4707293 --- /dev/null +++ b/plugins/ChromeUXReport/v1/defaultContent/keyPages.dash.json @@ -0,0 +1,359 @@ +{ + "name": "Key Pages", + "schemaVersion": "1.5", + "timeframe": "none", + "variables": [], + "dashboard": { + "_type": "layout/grid", + "columns": 12, + "version": 1, + "contents": [ + { + "i": "2a28453a-61ac-49aa-b65d-1665cf93c2c2", + "x": 0, + "y": 0, + "w": 12, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Coverage", + "description": "Every key page with its loading speed (LCP) on phone, colored by rating. Gray pages do not have enough phone traffic for Google to report on yet.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT page,\n coalesce(rating, 'unknown') AS state,\n CASE WHEN p75 IS NULL THEN 'Not enough traffic' ELSE CASE WHEN c.metric = 'CLS' THEN printf('%.2f', c.p75) WHEN c.p75 >= 1000 THEN printf('%.2f s', c.p75 / 1000) ELSE printf('%d ms', CAST(round(c.p75) AS BIGINT)) END END AS lcp\nFROM (SELECT CASE WHEN regexp_replace(k.name, '^https?://[^/]+', '') = '' THEN '/' ELSE regexp_replace(k.name, '^https?://[^/]+', '') END AS page, p.rating, p.p75, p.metric\nFROM keys k\nLEFT JOIN pages p ON rtrim(p.target, '/') = rtrim(k.name, '/') AND p.metric = 'LCP') c\nORDER BY p75 IS NULL, p75 DESC", + "tables": [ + { + "tableName": "keys", + "config": { + "dataStream": { + "id": "datastream-properties", + "name": "properties" + }, + "timeframe": "none", + "scope": { + "scope": "{{scopes.[CrUX Key Pages]}}", + "workspace": "{{workspaceId}}" + } + } + }, + { + "tableName": "pages", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "page", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Key Pages]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "page", + "displayName": "Page", + "shape": "string", + "role": "label" + }, + { + "name": "state", + "displayName": "Rating", + "shape": "string" + }, + { + "name": "lcp", + "displayName": "LCP", + "shape": "string" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "statusGrid", + "encoding": { + "label": { + "field": "page", + "type": "nominal", + "title": "Page" + }, + "status": { + "field": "state", + "type": "nominal", + "title": "Rating" + }, + "value": { + "field": "lcp", + "type": "nominal", + "title": "LCP" + } + }, + "style": { + "statusGridColumnsPerRow": 4 + } + } + } + } + }, + { + "i": "4d4c139c-1a2c-4d73-a6bd-14db587acfcb", + "x": 0, + "y": 2, + "w": 12, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Key Pages", + "description": "Loading speed (LCP), responsiveness (INP) and visual stability (CLS) for each key page on phone, slowest loading first, as met or beaten by 3 in 4 visits. Vs last month compares each page with four weeks earlier; pages without enough phone traffic are listed under Coverage.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH dated AS (\n SELECT target, metric, p75, CAST(periodEnd AS DATE) AS periodEnd, max(CAST(periodEnd AS DATE)) OVER (PARTITION BY target, metric) AS latest FROM history\n), monthly AS (\n SELECT target, metric,\n max(CASE WHEN periodEnd = latest THEN p75 END) AS now,\n max(CASE WHEN periodEnd = latest - INTERVAL 28 DAY THEN p75 END) AS before\n FROM dated GROUP BY ALL\n)\nSELECT CASE WHEN regexp_replace(p.target, '^https?://[^/]+', '') = '' THEN '/' ELSE regexp_replace(p.target, '^https?://[^/]+', '') END AS page,\n max(CASE WHEN p.metric = 'LCP' THEN CASE WHEN p.metric = 'CLS' THEN printf('%.2f', p.p75) WHEN p.p75 >= 1000 THEN printf('%.2f s', p.p75 / 1000) ELSE printf('%d ms', CAST(round(p.p75) AS BIGINT)) END END) AS lcp, max(CASE WHEN p.metric = 'LCP' THEN CASE p.rating WHEN 'success' THEN 'Good' WHEN 'warning' THEN 'Needs improvement' ELSE 'Poor' END END) AS lcpRating, max(CASE WHEN p.metric = 'LCP' THEN CASE\n WHEN h.now IS NULL OR h.before IS NULL THEN NULL\n WHEN abs(h.now - h.before) < 0.02 * nullif(h.before, 0) OR h.now = h.before THEN 'Same'\n WHEN h.now < h.before THEN 'Better' ELSE 'Worse' END END) AS lcpVsLastMonth,\n max(CASE WHEN p.metric = 'INP' THEN CASE WHEN p.metric = 'CLS' THEN printf('%.2f', p.p75) WHEN p.p75 >= 1000 THEN printf('%.2f s', p.p75 / 1000) ELSE printf('%d ms', CAST(round(p.p75) AS BIGINT)) END END) AS inp, max(CASE WHEN p.metric = 'INP' THEN CASE p.rating WHEN 'success' THEN 'Good' WHEN 'warning' THEN 'Needs improvement' ELSE 'Poor' END END) AS inpRating, max(CASE WHEN p.metric = 'INP' THEN CASE\n WHEN h.now IS NULL OR h.before IS NULL THEN NULL\n WHEN abs(h.now - h.before) < 0.02 * nullif(h.before, 0) OR h.now = h.before THEN 'Same'\n WHEN h.now < h.before THEN 'Better' ELSE 'Worse' END END) AS inpVsLastMonth,\n max(CASE WHEN p.metric = 'CLS' THEN CASE WHEN p.metric = 'CLS' THEN printf('%.2f', p.p75) WHEN p.p75 >= 1000 THEN printf('%.2f s', p.p75 / 1000) ELSE printf('%d ms', CAST(round(p.p75) AS BIGINT)) END END) AS cls, max(CASE WHEN p.metric = 'CLS' THEN CASE p.rating WHEN 'success' THEN 'Good' WHEN 'warning' THEN 'Needs improvement' ELSE 'Poor' END END) AS clsRating, max(CASE WHEN p.metric = 'CLS' THEN CASE\n WHEN h.now IS NULL OR h.before IS NULL THEN NULL\n WHEN abs(h.now - h.before) < 0.02 * nullif(h.before, 0) OR h.now = h.before THEN 'Same'\n WHEN h.now < h.before THEN 'Better' ELSE 'Worse' END END) AS clsVsLastMonth\nFROM pages p\nLEFT JOIN monthly h ON rtrim(h.target, '/') = rtrim(p.target, '/') AND h.metric = p.metric\nWHERE p.isCoreWebVital\nGROUP BY p.target\nORDER BY max(CASE WHEN p.metric = 'LCP' THEN p.p75 END) DESC", + "tables": [ + { + "tableName": "pages", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "page", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Key Pages]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + }, + { + "tableName": "history", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "page", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Key Pages]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "page", + "displayName": "Page", + "shape": "string", + "role": "label" + }, + { + "name": "lcp", + "displayName": "LCP", + "shape": "string" + }, + { + "name": "lcpRating", + "displayName": "LCP rating", + "shape": "string" + }, + { + "name": "lcpVsLastMonth", + "displayName": "LCP vs last month", + "shape": "string" + }, + { + "name": "inp", + "displayName": "INP", + "shape": "string" + }, + { + "name": "inpRating", + "displayName": "INP rating", + "shape": "string" + }, + { + "name": "inpVsLastMonth", + "displayName": "INP vs last month", + "shape": "string" + }, + { + "name": "cls", + "displayName": "CLS", + "shape": "string" + }, + { + "name": "clsRating", + "displayName": "CLS rating", + "shape": "string" + }, + { + "name": "clsVsLastMonth", + "displayName": "CLS vs last month", + "shape": "string" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "page", + "title": "Page", + "renderer": { + "type": "text" + } + }, + { + "field": "lcp", + "title": "LCP", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "lcpRating", + "title": "LCP rating", + "renderer": { + "type": "badge", + "colors": { + "Good": "#0CCE6B", + "Needs improvement": "#FFA400", + "Poor": "#FF4E42" + } + } + }, + { + "field": "lcpVsLastMonth", + "title": "LCP vs last month", + "renderer": { + "type": "badge", + "colors": { + "Better": "#0CCE6B", + "Same": "#9CA3AF", + "Worse": "#FF4E42" + } + } + }, + { + "field": "inp", + "title": "INP", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "inpRating", + "title": "INP rating", + "renderer": { + "type": "badge", + "colors": { + "Good": "#0CCE6B", + "Needs improvement": "#FFA400", + "Poor": "#FF4E42" + } + } + }, + { + "field": "inpVsLastMonth", + "title": "INP vs last month", + "renderer": { + "type": "badge", + "colors": { + "Better": "#0CCE6B", + "Same": "#9CA3AF", + "Worse": "#FF4E42" + } + } + }, + { + "field": "cls", + "title": "CLS", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "clsRating", + "title": "CLS rating", + "renderer": { + "type": "badge", + "colors": { + "Good": "#0CCE6B", + "Needs improvement": "#FFA400", + "Poor": "#FF4E42" + } + } + }, + { + "field": "clsVsLastMonth", + "title": "CLS vs last month", + "renderer": { + "type": "badge", + "colors": { + "Better": "#0CCE6B", + "Same": "#9CA3AF", + "Worse": "#FF4E42" + } + } + } + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/ChromeUXReport/v1/defaultContent/lcpDiagnostics.dash.json b/plugins/ChromeUXReport/v1/defaultContent/lcpDiagnostics.dash.json new file mode 100644 index 00000000..8d49ffdd --- /dev/null +++ b/plugins/ChromeUXReport/v1/defaultContent/lcpDiagnostics.dash.json @@ -0,0 +1,570 @@ +{ + "name": "LCP Diagnostics", + "schemaVersion": "1.5", + "timeframe": "none", + "variables": [], + "dashboard": { + "_type": "layout/grid", + "columns": 12, + "version": 1, + "contents": [ + { + "i": "84948bcd-b4cf-4222-9c30-0f93051b007e", + "x": 0, + "y": 0, + "w": 6, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "LCP Breakdown (Phone)", + "description": "How long each stage of loading the main image takes for the site on phone, in load order (each is its own 3 in 4 visits value, so they do not add up to LCP). Large TTFB: check the server, CDN and caching; large load delay: preload the image and do not lazy-load it; large load time: compress, resize or use modern formats; large render delay: cut render-blocking scripts and styles.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[pageDiagnostics]}}", + "name": "pageDiagnostics", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "group", + "operation": "equals", + "value": "LCP breakdown" + } + ] + }, + "sort": { + "by": [ + [ + "order", + "asc" + ] + ] + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "bar", + "encoding": { + "x": { + "field": "item", + "type": "nominal", + "title": "Part" + }, + "y": { + "field": "value", + "type": "quantitative", + "title": "Time (ms)" + } + }, + "style": { + "legendPosition": "none", + "cartesianYAxisDecimalPlaces": 0 + } + } + } + } + }, + { + "i": "88c36774-692c-4662-bdc1-2dc6cd5f57ce", + "x": 6, + "y": 0, + "w": 6, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "LCP Breakdown (Desktop)", + "description": "How long each stage of loading the main image takes for the site on desktop, in load order (each is its own 3 in 4 visits value, so they do not add up to LCP). Large TTFB: check the server, CDN and caching; large load delay: preload the image and do not lazy-load it; large load time: compress, resize or use modern formats; large render delay: cut render-blocking scripts and styles.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[pageDiagnostics]}}", + "name": "pageDiagnostics", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "group", + "operation": "equals", + "value": "LCP breakdown" + } + ] + }, + "sort": { + "by": [ + [ + "order", + "asc" + ] + ] + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "bar", + "encoding": { + "x": { + "field": "item", + "type": "nominal", + "title": "Part" + }, + "y": { + "field": "value", + "type": "quantitative", + "title": "Time (ms)" + } + }, + "style": { + "legendPosition": "none", + "cartesianYAxisDecimalPlaces": 0 + } + } + } + } + }, + { + "i": "4e017e5f-4eb6-4ef6-88d1-61b2e6cf2e10", + "x": 0, + "y": 3, + "w": 3, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "LCP Element", + "description": "Whether the main content (the largest element on screen) was an image or text on phone and desktop. When it is mostly an image, image size and delivery matter most for loading speed.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT device, item, value\nFROM (\n SELECT 'Phone' AS device, 1 AS deviceOrder, item, CAST(value AS DOUBLE) AS value FROM phone WHERE \"group\" = 'LCP element'\n UNION ALL\n SELECT 'Desktop' AS device, 2 AS deviceOrder, item, CAST(value AS DOUBLE) AS value FROM desktop WHERE \"group\" = 'LCP element'\n)\nORDER BY deviceOrder, item", + "tables": [ + { + "tableName": "phone", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[pageDiagnostics]}}", + "name": "pageDiagnostics", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + }, + { + "tableName": "desktop", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[pageDiagnostics]}}", + "name": "pageDiagnostics", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "device", + "displayName": "Device", + "shape": "string", + "role": "label" + }, + { + "name": "item", + "displayName": "Element", + "shape": "string" + }, + { + "name": "value", + "displayName": "Share of visits", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ], + "role": "value" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "bar", + "encoding": { + "x": { + "field": "device", + "type": "nominal", + "title": "Device" + }, + "y": { + "field": "value", + "type": "quantitative", + "title": "Share of visits" + }, + "series": { + "field": "item", + "type": "nominal", + "title": "Element" + } + }, + "style": { + "cartesianStack": "normalize", + "legendPosition": "bottom" + } + } + } + } + }, + { + "i": "0ed04f77-52a4-43be-be96-2743b3ba8655", + "x": 3, + "y": 3, + "w": 4, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Navigation Types", + "description": "How visits arrived over the last 28 days; bfcache (back/forward cache) and prerender loads are near instant. A low bfcache share is usually caused by unload handlers or Cache-Control: no-store, and fixing those makes back and forward visits instant.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT item, CASE WHEN item IN ('bfcache', 'Prerender') THEN 'Instant' ELSE 'Standard' END AS kind, CAST(value AS DOUBLE) AS value\nFROM diag WHERE \"group\" = 'Navigation type'\nORDER BY \"order\"", + "tables": [ + { + "tableName": "diag", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[pageDiagnostics]}}", + "name": "pageDiagnostics", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "ALL" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "item", + "displayName": "Navigation type", + "shape": "string", + "role": "label" + }, + { + "name": "kind", + "displayName": "Speed", + "shape": "string" + }, + { + "name": "value", + "displayName": "Share of visits", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ], + "role": "value" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "bar", + "encoding": { + "x": { + "field": "item", + "type": "nominal", + "title": "Navigation type" + }, + "y": { + "field": "value", + "type": "quantitative", + "title": "Share of visits" + }, + "series": { + "field": "kind", + "type": "nominal", + "title": "Speed" + } + }, + "style": { + "cartesianHorizontal": false, + "cartesianStack": "on", + "legendPosition": "bottom", + "mappedColors": { + "Instant": "#0CCE6B", + "Standard": "#9CA3AF" + } + } + } + } + } + }, + { + "i": "ec493459-ce95-4e48-880b-c23d63421f4f", + "x": 7, + "y": 3, + "w": 5, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Server and Network", + "description": "Time to First Byte (TTFB, how long the server takes to start responding) and network round trip time, as met or beaten by 3 in 4 visits. A slow TTFB with a fast round trip points at your server rather than the networks your visitors are on.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT device, ttfb, ttfbRating, rtt\nFROM (\n SELECT 'Phone' AS device, 1 AS deviceOrder,\n CASE WHEN pv.metric = 'CLS' THEN printf('%.2f', pv.p75) WHEN pv.p75 >= 1000 THEN printf('%.2f s', pv.p75 / 1000) ELSE printf('%d ms', CAST(round(pv.p75) AS BIGINT)) END AS ttfb, CASE pv.rating WHEN 'success' THEN 'Good' WHEN 'warning' THEN 'Needs improvement' ELSE 'Poor' END AS ttfbRating,\n (SELECT printf('%d ms', CAST(round(value) AS BIGINT)) FROM phoneDiag WHERE \"group\" = 'Network') AS rtt\n FROM phoneVitals pv WHERE pv.metric = 'TTFB'\n UNION ALL\n SELECT 'Desktop' AS device, 2 AS deviceOrder,\n CASE WHEN dv.metric = 'CLS' THEN printf('%.2f', dv.p75) WHEN dv.p75 >= 1000 THEN printf('%.2f s', dv.p75 / 1000) ELSE printf('%d ms', CAST(round(dv.p75) AS BIGINT)) END AS ttfb, CASE dv.rating WHEN 'success' THEN 'Good' WHEN 'warning' THEN 'Needs improvement' ELSE 'Poor' END AS ttfbRating,\n (SELECT printf('%d ms', CAST(round(value) AS BIGINT)) FROM desktopDiag WHERE \"group\" = 'Network') AS rtt\n FROM desktopVitals dv WHERE dv.metric = 'TTFB'\n)\nORDER BY deviceOrder", + "tables": [ + { + "tableName": "phoneVitals", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + }, + { + "tableName": "desktopVitals", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + }, + { + "tableName": "phoneDiag", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[pageDiagnostics]}}", + "name": "pageDiagnostics", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + }, + { + "tableName": "desktopDiag", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[pageDiagnostics]}}", + "name": "pageDiagnostics", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "device", + "displayName": "Device", + "shape": "string", + "role": "label" + }, + { + "name": "ttfb", + "displayName": "TTFB", + "shape": "string" + }, + { + "name": "ttfbRating", + "displayName": "TTFB rating", + "shape": "string" + }, + { + "name": "rtt", + "displayName": "Round trip time", + "shape": "string" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "device", + "title": "Device", + "renderer": { + "type": "text" + } + }, + { + "field": "ttfb", + "title": "TTFB", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "ttfbRating", + "title": "TTFB rating", + "renderer": { + "type": "badge", + "colors": { + "Good": "#0CCE6B", + "Needs improvement": "#FFA400", + "Poor": "#FF4E42" + } + } + }, + { + "field": "rtt", + "title": "Round trip time", + "renderer": { + "type": "text" + }, + "align": "right" + } + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/ChromeUXReport/v1/defaultContent/manifest.json b/plugins/ChromeUXReport/v1/defaultContent/manifest.json new file mode 100644 index 00000000..918d81f7 --- /dev/null +++ b/plugins/ChromeUXReport/v1/defaultContent/manifest.json @@ -0,0 +1,28 @@ +{ + "items": [ + { + "name": "overview", + "type": "dashboard" + }, + { + "name": "page", + "type": "dashboard" + }, + { + "name": "trends", + "type": "dashboard" + }, + { + "name": "lcpDiagnostics", + "type": "dashboard" + }, + { + "name": "competitors", + "type": "dashboard" + }, + { + "name": "keyPages", + "type": "dashboard" + } + ] +} diff --git a/plugins/ChromeUXReport/v1/defaultContent/overview.dash.json b/plugins/ChromeUXReport/v1/defaultContent/overview.dash.json new file mode 100644 index 00000000..f2ca1682 --- /dev/null +++ b/plugins/ChromeUXReport/v1/defaultContent/overview.dash.json @@ -0,0 +1,946 @@ +{ + "name": "Overview", + "schemaVersion": "1.5", + "timeframe": "none", + "variables": [], + "dashboard": { + "_type": "layout/grid", + "columns": 12, + "version": 1, + "contents": [ + { + "i": "b8c35782-cdf4-4850-a3d4-0ebafc7609f5", + "x": 0, + "y": 0, + "w": 3, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Data as Of", + "description": "Every tile on this dashboard covers real Chrome visits over the 28 days ending on this date. Google publishes a new 28-day window each day.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT strftime(max(CAST(periodEnd AS TIMESTAMP)), '%d %b %Y') AS asOf FROM vitals", + "tables": [ + { + "tableName": "vitals", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "ALL" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "asOf", + "displayName": "Data as of", + "shape": "string" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "scalar", + "encoding": { + "values": [ + { + "field": "asOf", + "type": "nominal", + "title": "28 days ending" + } + ] + }, + "style": { + "scalarLabelPosition": "bottom" + } + } + } + } + }, + { + "i": "6d5593bf-350b-4544-a6d3-26f61377c41f", + "x": 3, + "y": 0, + "w": 9, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Core Web Vitals Assessment", + "description": "Passed when at least 3 in 4 visits to the site had a good loading speed, responsiveness and visual stability over the last 28 days. This is the test Google Search uses for ranking.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT device, CASE WHEN passed IS NULL THEN 'No data' WHEN passed THEN 'Passed' ELSE 'Failed' END AS result, CASE WHEN passed IS NULL THEN 'unknown' WHEN passed THEN 'success' ELSE 'error' END AS state\nFROM (\n SELECT 'Phone' AS device, 1 AS sortOrder, bool_and(rating = 'success') AS passed FROM phone WHERE isCoreWebVital\n UNION ALL\n SELECT 'Desktop' AS device, 2 AS sortOrder, bool_and(rating = 'success') AS passed FROM desktop WHERE isCoreWebVital\n)\nORDER BY sortOrder", + "tables": [ + { + "tableName": "phone", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + }, + { + "tableName": "desktop", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "device", + "displayName": "Device", + "shape": "string", + "role": "label" + }, + { + "name": "result", + "displayName": "Result", + "shape": "string" + }, + { + "name": "state", + "displayName": "State", + "shape": "string" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "statusGrid", + "encoding": { + "label": { + "field": "device", + "type": "nominal", + "title": "Device" + }, + "status": { + "field": "state", + "type": "nominal", + "title": "Result" + }, + "value": { + "field": "result", + "type": "nominal", + "title": "Result" + } + }, + "style": { + "statusGridColumnsPerRow": 2 + } + } + } + } + }, + { + "i": "fee5c25e-f175-407a-802b-1a5289537e54", + "x": 0, + "y": 2, + "w": 6, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Core Web Vitals (Phone)", + "description": "Largest Contentful Paint (LCP, how long the main content takes to appear), Interaction to Next Paint (INP, how quickly the page responds to taps and clicks) and Cumulative Layout Shift (CLS, how much the page jumps around) for the site on phone. Each value is what 3 in 4 visits met or beat over the last 28 days, colored by its rating.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "isCoreWebVital", + "operation": "equals", + "value": "true" + } + ] + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "statusGrid", + "encoding": { + "label": { + "field": "metric", + "type": "nominal", + "title": "Metric" + }, + "status": { + "field": "rating", + "type": "nominal", + "title": "Rating" + }, + "value": { + "field": "p75", + "type": "quantitative", + "title": "75th percentile" + } + }, + "style": { + "statusGridColumnsPerRow": 3 + } + } + } + } + }, + { + "i": "edf86fc0-e8b2-4d52-9a99-2681005bc3a8", + "x": 6, + "y": 2, + "w": 6, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Core Web Vitals (Desktop)", + "description": "Largest Contentful Paint (LCP, how long the main content takes to appear), Interaction to Next Paint (INP, how quickly the page responds to taps and clicks) and Cumulative Layout Shift (CLS, how much the page jumps around) for the site on desktop. Each value is what 3 in 4 visits met or beat over the last 28 days, colored by its rating.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "isCoreWebVital", + "operation": "equals", + "value": "true" + } + ] + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "statusGrid", + "encoding": { + "label": { + "field": "metric", + "type": "nominal", + "title": "Metric" + }, + "status": { + "field": "rating", + "type": "nominal", + "title": "Rating" + }, + "value": { + "field": "p75", + "type": "quantitative", + "title": "75th percentile" + } + }, + "style": { + "statusGridColumnsPerRow": 3 + } + } + } + } + }, + { + "i": "d9eb6227-6cea-4980-98ff-c2cc66729ecc", + "x": 0, + "y": 4, + "w": 12, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Change Since Last Month", + "description": "Each metric for the latest 28 days against the 28 days that ended four weeks earlier, as met or beaten by 3 in 4 visits. Lower is better, and changes under 2% count as the same.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "WITH ranked AS (\n SELECT 'Phone' AS device, 1 AS labelOrder, metric, periodEnd, p75, max(CAST(periodEnd AS DATE)) OVER (PARTITION BY target, metric) AS latest FROM phone WHERE p75 IS NOT NULL\n UNION ALL\n SELECT 'Desktop' AS device, 2 AS labelOrder, metric, periodEnd, p75, max(CAST(periodEnd AS DATE)) OVER (PARTITION BY target, metric) AS latest FROM desktop WHERE p75 IS NOT NULL\n), paired AS (\n SELECT device, labelOrder, metric,\n max(CASE WHEN CAST(periodEnd AS DATE) = latest THEN p75 END) AS now,\n max(CASE WHEN CAST(periodEnd AS DATE) = latest - INTERVAL 28 DAY THEN p75 END) AS before\n FROM ranked GROUP BY ALL\n), diffed AS (SELECT *, now - before AS diff FROM paired WHERE now IS NOT NULL AND before IS NOT NULL)\nSELECT device, metric,\n CASE WHEN metric = 'CLS' THEN printf('%.2f', now)\n WHEN metric = 'INP' THEN printf('%d ms', CAST(round(now) AS BIGINT))\n ELSE printf('%.2f s', now / 1000) END AS nowText,\n CASE WHEN metric = 'CLS' THEN printf('%.2f', before)\n WHEN metric = 'INP' THEN printf('%d ms', CAST(round(before) AS BIGINT))\n ELSE printf('%.2f s', before / 1000) END AS beforeText,\n CASE WHEN metric = 'CLS' THEN printf('%+.2f', diff)\n WHEN metric = 'INP' THEN printf('%+d ms', CAST(round(diff) AS BIGINT))\n ELSE printf('%+.2f s', diff / 1000) END AS change,\n CASE WHEN abs(diff) < 0.02 * nullif(before, 0) OR diff = 0 THEN 'Same' WHEN diff < 0 THEN 'Better' ELSE 'Worse' END AS verdict\nFROM diffed\nORDER BY CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END, labelOrder, device", + "tables": [ + { + "tableName": "phone", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + }, + { + "tableName": "desktop", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "metric", + "displayName": "Metric", + "shape": "string", + "role": "label" + }, + { + "name": "device", + "displayName": "Device", + "shape": "string" + }, + { + "name": "nowText", + "displayName": "Now", + "shape": "string" + }, + { + "name": "beforeText", + "displayName": "4 weeks ago", + "shape": "string" + }, + { + "name": "change", + "displayName": "Change", + "shape": "string" + }, + { + "name": "verdict", + "displayName": "Trend", + "shape": "string" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "metric", + "title": "Metric", + "renderer": { + "type": "text" + } + }, + { + "field": "device", + "title": "Device", + "renderer": { + "type": "text" + } + }, + { + "field": "nowText", + "title": "Now", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "beforeText", + "title": "4 weeks ago", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "change", + "title": "Change", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "verdict", + "title": "Trend", + "renderer": { + "type": "badge", + "colors": { + "Better": "#0CCE6B", + "Same": "#9CA3AF", + "Worse": "#FF4E42" + } + } + } + ] + } + } + } + } + }, + { + "i": "f1954dfc-8cba-49d4-96a3-99e2e999b8c5", + "x": 0, + "y": 8, + "w": 6, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Distribution (Phone)", + "description": "How many visits to the site on phone had a good, needs improvement or poor experience of each metric over the last 28 days. A metric passes when the good share is 75% or more.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT metric, bucket, share\nFROM (\n SELECT metric, CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END AS metricOrder, 1 AS bucketOrder, 'Good' AS bucket, CAST(good AS DOUBLE) AS share FROM vitals\n UNION ALL\n SELECT metric, CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END AS metricOrder, 2 AS bucketOrder, 'Needs improvement' AS bucket, CAST(needsImprovement AS DOUBLE) AS share FROM vitals\n UNION ALL\n SELECT metric, CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END AS metricOrder, 3 AS bucketOrder, 'Poor' AS bucket, CAST(poor AS DOUBLE) AS share FROM vitals\n)\nORDER BY metricOrder, bucketOrder", + "tables": [ + { + "tableName": "vitals", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "metric", + "displayName": "Metric", + "shape": "string", + "role": "label" + }, + { + "name": "bucket", + "displayName": "Rating", + "shape": "string" + }, + { + "name": "share", + "displayName": "Share", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "bar", + "encoding": { + "x": { + "field": "metric", + "type": "nominal", + "title": "Metric" + }, + "y": { + "field": "share", + "type": "quantitative", + "title": "Share of visits" + }, + "series": { + "field": "bucket", + "type": "nominal", + "title": "Rating" + } + }, + "style": { + "cartesianStack": "normalize", + "legendPosition": "bottom", + "mappedColors": { + "Good": "#0CCE6B", + "Needs improvement": "#FFA400", + "Poor": "#FF4E42" + } + } + } + } + } + }, + { + "i": "78256eb0-2726-457e-b890-ba30ba65d4c1", + "x": 6, + "y": 8, + "w": 6, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Distribution (Desktop)", + "description": "How many visits to the site on desktop had a good, needs improvement or poor experience of each metric over the last 28 days. A metric passes when the good share is 75% or more.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT metric, bucket, share\nFROM (\n SELECT metric, CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END AS metricOrder, 1 AS bucketOrder, 'Good' AS bucket, CAST(good AS DOUBLE) AS share FROM vitals\n UNION ALL\n SELECT metric, CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END AS metricOrder, 2 AS bucketOrder, 'Needs improvement' AS bucket, CAST(needsImprovement AS DOUBLE) AS share FROM vitals\n UNION ALL\n SELECT metric, CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END AS metricOrder, 3 AS bucketOrder, 'Poor' AS bucket, CAST(poor AS DOUBLE) AS share FROM vitals\n)\nORDER BY metricOrder, bucketOrder", + "tables": [ + { + "tableName": "vitals", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "metric", + "displayName": "Metric", + "shape": "string", + "role": "label" + }, + { + "name": "bucket", + "displayName": "Rating", + "shape": "string" + }, + { + "name": "share", + "displayName": "Share", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "bar", + "encoding": { + "x": { + "field": "metric", + "type": "nominal", + "title": "Metric" + }, + "y": { + "field": "share", + "type": "quantitative", + "title": "Share of visits" + }, + "series": { + "field": "bucket", + "type": "nominal", + "title": "Rating" + } + }, + "style": { + "cartesianStack": "normalize", + "legendPosition": "bottom", + "mappedColors": { + "Good": "#0CCE6B", + "Needs improvement": "#FFA400", + "Poor": "#FF4E42" + } + } + } + } + } + }, + { + "i": "dd28bd77-4153-4859-85c9-c20ac9277481", + "x": 0, + "y": 10, + "w": 4, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Device Mix", + "description": "Share of visits from desktop, phone and tablet over the last 28 days, which shows which device to prioritize.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[pageDiagnostics]}}", + "name": "pageDiagnostics", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "ALL" + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "group", + "operation": "equals", + "value": "Device" + } + ] + }, + "sort": { + "by": [ + [ + "value", + "desc" + ] + ] + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "pie", + "encoding": { + "label": { + "field": "item", + "type": "nominal", + "title": "Device" + }, + "value": { + "field": "value", + "type": "quantitative", + "title": "Share of visits" + } + }, + "style": { + "pieInnerRadius": 60, + "pieDonutCenterValue": "none" + } + } + } + } + }, + { + "i": "7d360994-1618-44fb-ad1c-ffbb1adfa8d8", + "x": 4, + "y": 10, + "w": 8, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "FCP and TTFB", + "description": "First Contentful Paint (FCP, when anything first appears) and Time to First Byte (TTFB, how long the server takes to start responding) for the site, as met or beaten by 3 in 4 visits. They are not Core Web Vitals, but a slow TTFB or FCP usually explains slow loading.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT metric, device, p75Text, rating, good\nFROM (\n SELECT phone.metric, 'Phone' AS device, 1 AS deviceOrder, CASE WHEN phone.metric = 'CLS' THEN printf('%.2f', phone.p75) WHEN phone.p75 >= 1000 THEN printf('%.2f s', phone.p75 / 1000) ELSE printf('%d ms', CAST(round(phone.p75) AS BIGINT)) END AS p75Text, CASE phone.rating WHEN 'success' THEN 'Good' WHEN 'warning' THEN 'Needs improvement' ELSE 'Poor' END AS rating, CAST(phone.good AS DOUBLE) AS good FROM phone WHERE NOT phone.isCoreWebVital\n UNION ALL\n SELECT desktop.metric, 'Desktop' AS device, 2 AS deviceOrder, CASE WHEN desktop.metric = 'CLS' THEN printf('%.2f', desktop.p75) WHEN desktop.p75 >= 1000 THEN printf('%.2f s', desktop.p75 / 1000) ELSE printf('%d ms', CAST(round(desktop.p75) AS BIGINT)) END AS p75Text, CASE desktop.rating WHEN 'success' THEN 'Good' WHEN 'warning' THEN 'Needs improvement' ELSE 'Poor' END AS rating, CAST(desktop.good AS DOUBLE) AS good FROM desktop WHERE NOT desktop.isCoreWebVital\n)\nORDER BY metric, deviceOrder", + "tables": [ + { + "tableName": "phone", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + }, + { + "tableName": "desktop", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "metric", + "displayName": "Metric", + "shape": "string", + "role": "label" + }, + { + "name": "device", + "displayName": "Device", + "shape": "string" + }, + { + "name": "p75Text", + "displayName": "75th percentile", + "shape": "string" + }, + { + "name": "rating", + "displayName": "Rating", + "shape": "string" + }, + { + "name": "good", + "displayName": "Good visits", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "metric", + "title": "Metric", + "renderer": { + "type": "text" + } + }, + { + "field": "device", + "title": "Device", + "renderer": { + "type": "text" + } + }, + { + "field": "p75Text", + "title": "75th percentile", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "rating", + "title": "Rating", + "renderer": { + "type": "badge", + "colors": { + "Good": "#0CCE6B", + "Needs improvement": "#FFA400", + "Poor": "#FF4E42" + } + } + }, + { + "field": "good", + "title": "Good visits", + "renderer": { + "type": "number", + "decimals": 1, + "suffix": "%" + }, + "align": "right" + } + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/ChromeUXReport/v1/defaultContent/page.dash.json b/plugins/ChromeUXReport/v1/defaultContent/page.dash.json new file mode 100644 index 00000000..d7b2e506 --- /dev/null +++ b/plugins/ChromeUXReport/v1/defaultContent/page.dash.json @@ -0,0 +1,1163 @@ +{ + "name": "Page", + "schemaVersion": "1.5", + "timeframe": "none", + "variables": [ + "{{variables.[page]}}" + ], + "dashboard": { + "_type": "layout/grid", + "columns": 12, + "version": 1, + "contents": [ + { + "i": "0f6e21f3-fc6a-4c80-8328-2841f857d981", + "x": 0, + "y": 0, + "w": 6, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Core Web Vitals (Phone)", + "description": "Largest Contentful Paint (LCP, how long the main content takes to appear), Interaction to Next Paint (INP, how quickly the page responds to taps and clicks) and Cumulative Layout Shift (CLS, how much the page jumps around) for the page on phone. Each value is what 3 in 4 visits met or beat over the last 28 days, colored by its rating.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "page", + "formFactor": "PHONE" + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "isCoreWebVital", + "operation": "equals", + "value": "true" + } + ] + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "statusGrid", + "encoding": { + "label": { + "field": "metric", + "type": "nominal", + "title": "Metric" + }, + "status": { + "field": "rating", + "type": "nominal", + "title": "Rating" + }, + "value": { + "field": "p75", + "type": "quantitative", + "title": "75th percentile" + } + }, + "style": { + "statusGridColumnsPerRow": 3 + } + } + } + } + }, + { + "i": "43bf381a-bcd8-45e0-8d3b-a3e95ea26507", + "x": 6, + "y": 0, + "w": 6, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Core Web Vitals (Desktop)", + "description": "Largest Contentful Paint (LCP, how long the main content takes to appear), Interaction to Next Paint (INP, how quickly the page responds to taps and clicks) and Cumulative Layout Shift (CLS, how much the page jumps around) for the page on desktop. Each value is what 3 in 4 visits met or beat over the last 28 days, colored by its rating.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "page", + "formFactor": "DESKTOP" + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "isCoreWebVital", + "operation": "equals", + "value": "true" + } + ] + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "statusGrid", + "encoding": { + "label": { + "field": "metric", + "type": "nominal", + "title": "Metric" + }, + "status": { + "field": "rating", + "type": "nominal", + "title": "Rating" + }, + "value": { + "field": "p75", + "type": "quantitative", + "title": "75th percentile" + } + }, + "style": { + "statusGridColumnsPerRow": 3 + } + } + } + } + }, + { + "i": "ed19d331-bbe7-4431-b3c8-04de930a072c", + "x": 0, + "y": 2, + "w": 12, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Page vs Whole Site", + "description": "Each metric for this page against the whole site on all devices over the last 28 days, using the value 3 in 4 visits met or beat. Lower is better, so Worse means this page is slower or less stable than the site.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT\n page.metric,\n CASE WHEN page.metric = 'CLS' THEN printf('%.2f', page.p75) WHEN page.p75 >= 1000 THEN printf('%.2f s', page.p75 / 1000) ELSE printf('%d ms', CAST(round(page.p75) AS BIGINT)) END AS pageP75,\n CASE WHEN site.metric = 'CLS' THEN printf('%.2f', site.p75) WHEN site.p75 >= 1000 THEN printf('%.2f s', site.p75 / 1000) ELSE printf('%d ms', CAST(round(site.p75) AS BIGINT)) END AS siteP75,\n CASE page.rating WHEN 'success' THEN 'Good' WHEN 'warning' THEN 'Needs improvement' ELSE 'Poor' END AS pageRating,\n CASE site.rating WHEN 'success' THEN 'Good' WHEN 'warning' THEN 'Needs improvement' ELSE 'Poor' END AS siteRating,\n CASE\n WHEN round(page.p75, 2) < round(site.p75, 2) THEN 'Better'\n WHEN round(page.p75, 2) > round(site.p75, 2) THEN 'Worse'\n ELSE 'Same'\n END AS comparison\nFROM page\nJOIN site ON page.metric = site.metric\nORDER BY CASE page.metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END", + "tables": [ + { + "tableName": "page", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "page", + "formFactor": "ALL" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ], + "timeframe": "none" + } + }, + { + "tableName": "site", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "ALL" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ], + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "metric", + "displayName": "Metric", + "shape": "string", + "role": "label" + }, + { + "name": "pageP75", + "displayName": "Page", + "shape": "string" + }, + { + "name": "siteP75", + "displayName": "Whole site", + "shape": "string" + }, + { + "name": "pageRating", + "displayName": "Page rating", + "shape": "string" + }, + { + "name": "siteRating", + "displayName": "Site rating", + "shape": "string" + }, + { + "name": "comparison", + "displayName": "Page vs site", + "shape": "string" + } + ] + }, + "variables": [ + "{{variables.[page]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "richTable", + "encoding": { + "columns": [ + { + "field": "metric", + "title": "Metric", + "renderer": { + "type": "text" + } + }, + { + "field": "pageP75", + "title": "Page", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "siteP75", + "title": "Whole site", + "renderer": { + "type": "text" + }, + "align": "right" + }, + { + "field": "pageRating", + "title": "Page rating", + "renderer": { + "type": "badge", + "colors": { + "Good": "#0CCE6B", + "Needs improvement": "#FFA400", + "Poor": "#FF4E42" + } + } + }, + { + "field": "siteRating", + "title": "Site rating", + "renderer": { + "type": "badge", + "colors": { + "Good": "#0CCE6B", + "Needs improvement": "#FFA400", + "Poor": "#FF4E42" + } + } + }, + { + "field": "comparison", + "title": "Page vs site", + "renderer": { + "type": "badge", + "colors": { + "Better": "#0CCE6B", + "Same": "#9CA3AF", + "Worse": "#FF4E42" + } + } + } + ] + } + } + } + } + }, + { + "i": "eb5ccd08-d8fe-49f7-bd0f-62a3a81216aa", + "x": 0, + "y": 5, + "w": 6, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Distribution (Phone)", + "description": "How many visits to the page on phone had a good, needs improvement or poor experience of each metric over the last 28 days. A metric passes when the good share is 75% or more.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT metric, bucket, share\nFROM (\n SELECT metric, CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END AS metricOrder, 1 AS bucketOrder, 'Good' AS bucket, CAST(good AS DOUBLE) AS share FROM vitals\n UNION ALL\n SELECT metric, CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END AS metricOrder, 2 AS bucketOrder, 'Needs improvement' AS bucket, CAST(needsImprovement AS DOUBLE) AS share FROM vitals\n UNION ALL\n SELECT metric, CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END AS metricOrder, 3 AS bucketOrder, 'Poor' AS bucket, CAST(poor AS DOUBLE) AS share FROM vitals\n)\nORDER BY metricOrder, bucketOrder", + "tables": [ + { + "tableName": "vitals", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "page", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ], + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "metric", + "displayName": "Metric", + "shape": "string", + "role": "label" + }, + { + "name": "bucket", + "displayName": "Rating", + "shape": "string" + }, + { + "name": "share", + "displayName": "Share", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + } + ] + }, + "variables": [ + "{{variables.[page]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "bar", + "encoding": { + "x": { + "field": "metric", + "type": "nominal", + "title": "Metric" + }, + "y": { + "field": "share", + "type": "quantitative", + "title": "Share of visits" + }, + "series": { + "field": "bucket", + "type": "nominal", + "title": "Rating" + } + }, + "style": { + "cartesianStack": "normalize", + "legendPosition": "bottom", + "mappedColors": { + "Good": "#0CCE6B", + "Needs improvement": "#FFA400", + "Poor": "#FF4E42" + } + } + } + } + } + }, + { + "i": "6057bb5b-e1d3-4207-931a-9195c6862ece", + "x": 6, + "y": 5, + "w": 6, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Distribution (Desktop)", + "description": "How many visits to the page on desktop had a good, needs improvement or poor experience of each metric over the last 28 days. A metric passes when the good share is 75% or more.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT metric, bucket, share\nFROM (\n SELECT metric, CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END AS metricOrder, 1 AS bucketOrder, 'Good' AS bucket, CAST(good AS DOUBLE) AS share FROM vitals\n UNION ALL\n SELECT metric, CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END AS metricOrder, 2 AS bucketOrder, 'Needs improvement' AS bucket, CAST(needsImprovement AS DOUBLE) AS share FROM vitals\n UNION ALL\n SELECT metric, CASE metric WHEN 'LCP' THEN 1 WHEN 'INP' THEN 2 WHEN 'CLS' THEN 3 WHEN 'FCP' THEN 4 ELSE 5 END AS metricOrder, 3 AS bucketOrder, 'Poor' AS bucket, CAST(poor AS DOUBLE) AS share FROM vitals\n)\nORDER BY metricOrder, bucketOrder", + "tables": [ + { + "tableName": "vitals", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitals]}}", + "name": "coreWebVitals", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "page", + "formFactor": "DESKTOP" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ], + "timeframe": "none" + } + } + ] + }, + "metadata": [ + { + "name": "metric", + "displayName": "Metric", + "shape": "string", + "role": "label" + }, + { + "name": "bucket", + "displayName": "Rating", + "shape": "string" + }, + { + "name": "share", + "displayName": "Share", + "shape": [ + "percent", + { + "decimalPlaces": 1 + } + ] + } + ] + }, + "variables": [ + "{{variables.[page]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "bar", + "encoding": { + "x": { + "field": "metric", + "type": "nominal", + "title": "Metric" + }, + "y": { + "field": "share", + "type": "quantitative", + "title": "Share of visits" + }, + "series": { + "field": "bucket", + "type": "nominal", + "title": "Rating" + } + }, + "style": { + "cartesianStack": "normalize", + "legendPosition": "bottom", + "mappedColors": { + "Good": "#0CCE6B", + "Needs improvement": "#FFA400", + "Poor": "#FF4E42" + } + } + } + } + } + }, + { + "i": "950bfaf2-9acc-4d58-8556-9e2d423b0424", + "x": 0, + "y": 7, + "w": 8, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "LCP Breakdown", + "description": "How long each stage of loading the main image takes for the page on all devices, in load order (each is its own 3 in 4 visits value, so they do not add up to LCP). Large TTFB: check the server, CDN and caching; large load delay: preload the image and do not lazy-load it; large load time: compress, resize or use modern formats; large render delay: cut render-blocking scripts and styles.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[pageDiagnostics]}}", + "name": "pageDiagnostics", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "page", + "formFactor": "ALL" + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "group", + "operation": "equals", + "value": "LCP breakdown" + } + ] + }, + "sort": { + "by": [ + [ + "order", + "asc" + ] + ] + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "bar", + "encoding": { + "x": { + "field": "item", + "type": "nominal", + "title": "Part" + }, + "y": { + "field": "value", + "type": "quantitative", + "title": "Time (ms)" + } + }, + "style": { + "legendPosition": "none", + "cartesianYAxisDecimalPlaces": 0 + } + } + } + } + }, + { + "i": "2dfc03a0-6976-4b4c-92ed-b43edbe49dc4", + "x": 8, + "y": 7, + "w": 4, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "LCP Element", + "description": "Whether the main content (the largest element on screen) was an image or text, as a share of visits to the page over the last 28 days.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[pageDiagnostics]}}", + "name": "pageDiagnostics", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "page", + "formFactor": "ALL" + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "group", + "operation": "equals", + "value": "LCP element" + } + ] + }, + "sort": { + "by": [ + [ + "value", + "desc" + ] + ] + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "pie", + "encoding": { + "label": { + "field": "item", + "type": "nominal", + "title": "Element" + }, + "value": { + "field": "value", + "type": "quantitative", + "title": "Share of visits" + } + }, + "style": { + "pieInnerRadius": 60, + "pieDonutCenterValue": "none" + } + } + } + } + }, + { + "i": "c83dbe34-9321-438e-b1f8-9a3d58762da4", + "x": 0, + "y": 9, + "w": 4, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "LCP Trend", + "description": "Largest Contentful Paint (LCP, how long the main content takes to appear) for the page against its whole site on phone, as met or beaten by 3 in 4 visits in the 28 days ending each week. The lines mark the good and poor thresholds.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT periodEnd, 'Page' AS series, p75 FROM page WHERE metric = 'LCP'\nUNION ALL\nSELECT periodEnd, 'Whole site' AS series, p75 FROM site WHERE metric = 'LCP'\nORDER BY periodEnd, series", + "tables": [ + { + "tableName": "page", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "page", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ] + } + }, + { + "tableName": "site", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ] + } + } + ] + }, + "metadata": [ + { + "name": "periodEnd", + "displayName": "Week ending", + "shape": [ + "date", + { + "format": "dd MMM yyyy" + } + ], + "role": "timestamp" + }, + { + "name": "series", + "displayName": "Series", + "shape": "string" + }, + { + "name": "p75", + "displayName": "75th percentile", + "shape": [ + "number", + { + "decimalPlaces": 0 + } + ], + "role": "value" + } + ] + }, + "variables": [ + "{{variables.[page]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "line", + "encoding": { + "x": { + "field": "periodEnd", + "type": "temporal", + "title": "Week ending" + }, + "y": { + "field": "p75", + "type": "quantitative", + "title": "75th percentile (ms)" + }, + "series": { + "field": "series", + "type": "nominal", + "title": "Series" + } + }, + "style": { + "legendPosition": "bottom", + "cartesianYAxisDecimalPlaces": 0 + }, + "markLines": [ + { + "value": 2500, + "label": "Good", + "color": "#0CCE6B" + }, + { + "value": 4000, + "label": "Poor", + "color": "#FF4E42" + } + ] + } + } + } + }, + { + "i": "e70c7dfe-9d16-4e51-a7c6-791875d07810", + "x": 4, + "y": 9, + "w": 4, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "INP Trend", + "description": "Interaction to Next Paint (INP, how quickly the page responds to taps and clicks) for the page against its whole site on phone, as met or beaten by 3 in 4 visits in the 28 days ending each week. To improve it, break up long JavaScript tasks and trim third-party scripts.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT periodEnd, 'Page' AS series, p75 FROM page WHERE metric = 'INP'\nUNION ALL\nSELECT periodEnd, 'Whole site' AS series, p75 FROM site WHERE metric = 'INP'\nORDER BY periodEnd, series", + "tables": [ + { + "tableName": "page", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "page", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ] + } + }, + { + "tableName": "site", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ] + } + } + ] + }, + "metadata": [ + { + "name": "periodEnd", + "displayName": "Week ending", + "shape": [ + "date", + { + "format": "dd MMM yyyy" + } + ], + "role": "timestamp" + }, + { + "name": "series", + "displayName": "Series", + "shape": "string" + }, + { + "name": "p75", + "displayName": "75th percentile", + "shape": [ + "number", + { + "decimalPlaces": 0 + } + ], + "role": "value" + } + ] + }, + "variables": [ + "{{variables.[page]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "line", + "encoding": { + "x": { + "field": "periodEnd", + "type": "temporal", + "title": "Week ending" + }, + "y": { + "field": "p75", + "type": "quantitative", + "title": "75th percentile (ms)" + }, + "series": { + "field": "series", + "type": "nominal", + "title": "Series" + } + }, + "style": { + "legendPosition": "bottom", + "cartesianYAxisDecimalPlaces": 0 + }, + "markLines": [ + { + "value": 200, + "label": "Good", + "color": "#0CCE6B" + }, + { + "value": 500, + "label": "Poor", + "color": "#FF4E42" + } + ] + } + } + } + }, + { + "i": "6d149162-6065-47c4-aac2-7aa303e26366", + "x": 8, + "y": 9, + "w": 4, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "CLS Trend", + "description": "Cumulative Layout Shift (CLS, how much the page jumps around while loading) for the page against its whole site on phone, as met or beaten by 3 in 4 visits in the 28 days ending each week. To improve it, reserve space for images, ads and embeds.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT periodEnd, 'Page' AS series, p75 FROM page WHERE metric = 'CLS'\nUNION ALL\nSELECT periodEnd, 'Whole site' AS series, p75 FROM site WHERE metric = 'CLS'\nORDER BY periodEnd, series", + "tables": [ + { + "tableName": "page", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "page", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ] + } + }, + { + "tableName": "site", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Pages]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[page]}}" + }, + "variables": [ + "{{variables.[page]}}" + ] + } + } + ] + }, + "metadata": [ + { + "name": "periodEnd", + "displayName": "Week ending", + "shape": [ + "date", + { + "format": "dd MMM yyyy" + } + ], + "role": "timestamp" + }, + { + "name": "series", + "displayName": "Series", + "shape": "string" + }, + { + "name": "p75", + "displayName": "75th percentile", + "shape": [ + "number", + { + "decimalPlaces": 2 + } + ], + "role": "value" + } + ] + }, + "variables": [ + "{{variables.[page]}}" + ], + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "line", + "encoding": { + "x": { + "field": "periodEnd", + "type": "temporal", + "title": "Week ending" + }, + "y": { + "field": "p75", + "type": "quantitative", + "title": "75th percentile" + }, + "series": { + "field": "series", + "type": "nominal", + "title": "Series" + } + }, + "style": { + "legendPosition": "bottom" + }, + "markLines": [ + { + "value": 0.1, + "label": "Good", + "color": "#0CCE6B" + }, + { + "value": 0.25, + "label": "Poor", + "color": "#FF4E42" + } + ] + } + } + } + } + ] + } +} diff --git a/plugins/ChromeUXReport/v1/defaultContent/scopes.json b/plugins/ChromeUXReport/v1/defaultContent/scopes.json new file mode 100644 index 00000000..6c7600f3 --- /dev/null +++ b/plugins/ChromeUXReport/v1/defaultContent/scopes.json @@ -0,0 +1,56 @@ +[ + { + "name": "CrUX Websites", + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "origin" + ] + } + } + }, + { + "name": "CrUX Competitors", + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "competitor" + ] + } + } + }, + { + "name": "CrUX Key Pages", + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "page" + ] + }, + "pageGroup": { + "type": "equals", + "value": "Key page" + } + } + }, + { + "name": "CrUX Pages", + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "page" + ] + } + }, + "variable": { + "name": "page", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + } +] diff --git a/plugins/ChromeUXReport/v1/defaultContent/trends.dash.json b/plugins/ChromeUXReport/v1/defaultContent/trends.dash.json new file mode 100644 index 00000000..b15326ec --- /dev/null +++ b/plugins/ChromeUXReport/v1/defaultContent/trends.dash.json @@ -0,0 +1,697 @@ +{ + "name": "Trends", + "schemaVersion": "1.5", + "timeframe": "none", + "variables": [], + "dashboard": { + "_type": "layout/grid", + "columns": 12, + "version": 1, + "contents": [ + { + "i": "aae952df-0ec6-4968-a9d6-6ab7012dba8c", + "x": 0, + "y": 0, + "w": 4, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "LCP Trend", + "description": "Largest Contentful Paint (LCP, how long the main content takes to appear) for the site, phone against desktop, as met or beaten by 3 in 4 visits in the 28 days ending each week. The lines mark the good and poor thresholds.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT periodEnd, 'Phone' AS device, p75 FROM phone WHERE metric = 'LCP'\nUNION ALL\nSELECT periodEnd, 'Desktop' AS device, p75 FROM desktop WHERE metric = 'LCP'\nORDER BY periodEnd, device", + "tables": [ + { + "tableName": "phone", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + } + } + }, + { + "tableName": "desktop", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + } + } + } + ] + }, + "metadata": [ + { + "name": "periodEnd", + "displayName": "Week ending", + "shape": [ + "date", + { + "format": "dd MMM yyyy" + } + ], + "role": "timestamp" + }, + { + "name": "device", + "displayName": "Device", + "shape": "string" + }, + { + "name": "p75", + "displayName": "75th percentile", + "shape": [ + "number", + { + "decimalPlaces": 0 + } + ], + "role": "value" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "line", + "encoding": { + "x": { + "field": "periodEnd", + "type": "temporal", + "title": "Week ending" + }, + "y": { + "field": "p75", + "type": "quantitative", + "title": "75th percentile (ms)" + }, + "series": { + "field": "device", + "type": "nominal", + "title": "Device" + } + }, + "style": { + "legendPosition": "bottom", + "cartesianYAxisDecimalPlaces": 0 + }, + "markLines": [ + { + "value": 2500, + "label": "Good", + "color": "#0CCE6B" + }, + { + "value": 4000, + "label": "Poor", + "color": "#FF4E42" + } + ] + } + } + } + }, + { + "i": "e9837ee2-2cd9-4b0a-8ec6-a2a41f344c46", + "x": 4, + "y": 0, + "w": 4, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "INP Trend", + "description": "Interaction to Next Paint (INP, how quickly the page responds to taps and clicks) for the site, phone against desktop, as met or beaten by 3 in 4 visits in the 28 days ending each week. To improve it, break up long JavaScript tasks and trim third-party scripts.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT periodEnd, 'Phone' AS device, p75 FROM phone WHERE metric = 'INP'\nUNION ALL\nSELECT periodEnd, 'Desktop' AS device, p75 FROM desktop WHERE metric = 'INP'\nORDER BY periodEnd, device", + "tables": [ + { + "tableName": "phone", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + } + } + }, + { + "tableName": "desktop", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + } + } + } + ] + }, + "metadata": [ + { + "name": "periodEnd", + "displayName": "Week ending", + "shape": [ + "date", + { + "format": "dd MMM yyyy" + } + ], + "role": "timestamp" + }, + { + "name": "device", + "displayName": "Device", + "shape": "string" + }, + { + "name": "p75", + "displayName": "75th percentile", + "shape": [ + "number", + { + "decimalPlaces": 0 + } + ], + "role": "value" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "line", + "encoding": { + "x": { + "field": "periodEnd", + "type": "temporal", + "title": "Week ending" + }, + "y": { + "field": "p75", + "type": "quantitative", + "title": "75th percentile (ms)" + }, + "series": { + "field": "device", + "type": "nominal", + "title": "Device" + } + }, + "style": { + "legendPosition": "bottom", + "cartesianYAxisDecimalPlaces": 0 + }, + "markLines": [ + { + "value": 200, + "label": "Good", + "color": "#0CCE6B" + }, + { + "value": 500, + "label": "Poor", + "color": "#FF4E42" + } + ] + } + } + } + }, + { + "i": "e870cabf-2ba9-477d-9a4a-fe6d018023ce", + "x": 8, + "y": 0, + "w": 4, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "CLS Trend", + "description": "Cumulative Layout Shift (CLS, how much the page jumps around while loading) for the site, phone against desktop, as met or beaten by 3 in 4 visits in the 28 days ending each week. To improve it, reserve space for images, ads and embeds.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT periodEnd, 'Phone' AS device, p75 FROM phone WHERE metric = 'CLS'\nUNION ALL\nSELECT periodEnd, 'Desktop' AS device, p75 FROM desktop WHERE metric = 'CLS'\nORDER BY periodEnd, device", + "tables": [ + { + "tableName": "phone", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + } + } + }, + { + "tableName": "desktop", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + } + } + } + ] + }, + "metadata": [ + { + "name": "periodEnd", + "displayName": "Week ending", + "shape": [ + "date", + { + "format": "dd MMM yyyy" + } + ], + "role": "timestamp" + }, + { + "name": "device", + "displayName": "Device", + "shape": "string" + }, + { + "name": "p75", + "displayName": "75th percentile", + "shape": [ + "number", + { + "decimalPlaces": 2 + } + ], + "role": "value" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "line", + "encoding": { + "x": { + "field": "periodEnd", + "type": "temporal", + "title": "Week ending" + }, + "y": { + "field": "p75", + "type": "quantitative", + "title": "75th percentile" + }, + "series": { + "field": "device", + "type": "nominal", + "title": "Device" + } + }, + "style": { + "legendPosition": "bottom" + }, + "markLines": [ + { + "value": 0.1, + "label": "Good", + "color": "#0CCE6B" + }, + { + "value": 0.25, + "label": "Poor", + "color": "#FF4E42" + } + ] + } + } + } + }, + { + "i": "7dd3dc4e-5c65-4025-85c2-9aca6282c9c7", + "x": 0, + "y": 3, + "w": 6, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "FCP Trend", + "description": "First Contentful Paint (FCP, when anything first appears) for the site, phone against desktop, as met or beaten by 3 in 4 visits in the 28 days ending each week. The lines mark the good and poor thresholds.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT periodEnd, 'Phone' AS device, p75 FROM phone WHERE metric = 'FCP'\nUNION ALL\nSELECT periodEnd, 'Desktop' AS device, p75 FROM desktop WHERE metric = 'FCP'\nORDER BY periodEnd, device", + "tables": [ + { + "tableName": "phone", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + } + } + }, + { + "tableName": "desktop", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + } + } + } + ] + }, + "metadata": [ + { + "name": "periodEnd", + "displayName": "Week ending", + "shape": [ + "date", + { + "format": "dd MMM yyyy" + } + ], + "role": "timestamp" + }, + { + "name": "device", + "displayName": "Device", + "shape": "string" + }, + { + "name": "p75", + "displayName": "75th percentile", + "shape": [ + "number", + { + "decimalPlaces": 0 + } + ], + "role": "value" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "line", + "encoding": { + "x": { + "field": "periodEnd", + "type": "temporal", + "title": "Week ending" + }, + "y": { + "field": "p75", + "type": "quantitative", + "title": "75th percentile (ms)" + }, + "series": { + "field": "device", + "type": "nominal", + "title": "Device" + } + }, + "style": { + "legendPosition": "bottom", + "cartesianYAxisDecimalPlaces": 0 + }, + "markLines": [ + { + "value": 1800, + "label": "Good", + "color": "#0CCE6B" + }, + { + "value": 3000, + "label": "Poor", + "color": "#FF4E42" + } + ] + } + } + } + }, + { + "i": "93cf969a-2b86-4517-8995-60566125474e", + "x": 6, + "y": 3, + "w": 6, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "TTFB Trend", + "description": "Time to First Byte (TTFB, how long the server takes to start responding) for the site, phone against desktop, as met or beaten by 3 in 4 visits in the 28 days ending each week. A slow TTFB points to the server, CDN or caching.", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-sql", + "dataSourceConfig": { + "version": "2.0", + "sql": "SELECT periodEnd, 'Phone' AS device, p75 FROM phone WHERE metric = 'TTFB'\nUNION ALL\nSELECT periodEnd, 'Desktop' AS device, p75 FROM desktop WHERE metric = 'TTFB'\nORDER BY periodEnd, device", + "tables": [ + { + "tableName": "phone", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "PHONE" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + } + } + }, + { + "tableName": "desktop", + "config": { + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[coreWebVitalsHistory]}}", + "name": "coreWebVitalsHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "level": "origin", + "formFactor": "DESKTOP" + } + }, + "scope": { + "scope": "{{scopes.[CrUX Websites]}}", + "workspace": "{{workspaceId}}" + } + } + } + ] + }, + "metadata": [ + { + "name": "periodEnd", + "displayName": "Week ending", + "shape": [ + "date", + { + "format": "dd MMM yyyy" + } + ], + "role": "timestamp" + }, + { + "name": "device", + "displayName": "Device", + "shape": "string" + }, + { + "name": "p75", + "displayName": "75th percentile", + "shape": [ + "number", + { + "decimalPlaces": 0 + } + ], + "role": "value" + } + ] + }, + "vizSpec": { + "version": "1.0", + "visualization": { + "chartType": "line", + "encoding": { + "x": { + "field": "periodEnd", + "type": "temporal", + "title": "Week ending" + }, + "y": { + "field": "p75", + "type": "quantitative", + "title": "75th percentile (ms)" + }, + "series": { + "field": "device", + "type": "nominal", + "title": "Device" + } + }, + "style": { + "legendPosition": "bottom", + "cartesianYAxisDecimalPlaces": 0 + }, + "markLines": [ + { + "value": 800, + "label": "Good", + "color": "#0CCE6B" + }, + { + "value": 1800, + "label": "Poor", + "color": "#FF4E42" + } + ] + } + } + } + } + ] + } +} diff --git a/plugins/ChromeUXReport/v1/docs/README.md b/plugins/ChromeUXReport/v1/docs/README.md new file mode 100644 index 00000000..4ad816a5 --- /dev/null +++ b/plugins/ChromeUXReport/v1/docs/README.md @@ -0,0 +1,155 @@ +Monitor the real-user Core Web Vitals of a website and every page in its sitemap using the [Chrome UX Report (CrUX) API](https://developer.chrome.com/docs/crux/api). CrUX is Google's public dataset of how real Chrome users experience a site, and it's the same field data that Search Console and PageSpeed Insights report. + +## Prerequisites + +- A Google Cloud Project +- A website with a public XML sitemap +- Optional: the Google Search Console plugin, to see search performance and Core Web Vitals together + +## Enabling the Chrome UX Report API + +1. Go to the [Google Cloud Console](https://console.cloud.google.com/) + +2. Select the project you wish to use + +3. Navigate to **APIs & Services** > **Library** + +4. Search for **Chrome UX Report API** + +5. Select it and click **Enable** + +The API is free and doesn't need billing. + +## Creating an API key + +1. From the [Google Cloud Console](https://console.cloud.google.com/), navigate to **APIs & Services** > **Credentials** + +2. Click **+ Create credentials** and select **API key** + +3. Optional: click **Edit API key**, and under **API restrictions** select **Restrict key** and choose **Chrome UX Report API** + +4. Copy the key and save it somewhere secure + +## Finding your sitemap + +Most sites list their sitemap in `robots.txt`, for example at `https://www.example.com/robots.txt`. Open that file, find the `Sitemap:` line and copy the URL after it, for example `https://www.example.com/sitemap.xml`. Don't use the `robots.txt` address itself. If the line points to a sitemap index, use the index, because every sitemap it lists is followed. + +## Configuring the plugin + +Populate the following fields when configuring the plugin: + +| Field | Description | +|---------|---------| +| Google API key | The API key created in Google Cloud | +| Sitemap URL | The full URL of your website's XML sitemap or sitemap index. Every page it lists on your website is imported | +| Website | Optional. Your website's origin, for example `https://www.example.com`. Only needed when the sitemap is on a different host from your pages, such as a CDN, a `sitemaps.` subdomain, or the domain without `www` | +| Key pages | Optional. The full URL of each page to track closely, one per line, such as your home page, top landing pages and checkout. They appear on the **Key Pages** dashboard and don't count toward **Maximum pages** | +| Competitor websites | Optional. The origin of each competitor website to benchmark against, one per line, for example `https://www.competitor.com`. They appear on the **Competitors** dashboard | +| Maximum pages | Optional. The most pages to import from the sitemap, 5,000 by default. CrUX only has data for pages with enough Chrome traffic, so most sites don't need more | + +On save, the plugin runs four checks: + +- **API key:** a bad key or a disabled API stops setup. +- **Sitemap:** it must open and list at least one page on your website or a plain XML sitemap, or setup stops with the reason, such as a 404, a website that blocks cloud servers, or pages on another host that need the **Website** field. +- **Gzipped sitemaps:** if the sitemap itself lists `.xml.gz` sitemaps, you get a warning that their pages will be skipped. Gzipped files listed deeper, inside child sitemaps, are skipped without a warning. +- **Website data:** if CrUX holds no data for your website yet, you only get a warning, because new or quiet sites appear in CrUX once they have enough Chrome traffic. + +## Example configuration + +| Setting | Example | +|---------|---------| +| Google API key | The key copied from Google Cloud | +| Sitemap URL | `https://www.example.com/sitemap.xml` | +| Website | `https://www.example.com` | +| Key pages | `https://www.example.com/` and `https://www.example.com/pricing`, one per line | +| Competitor websites | `https://www.competitor.com` | +| Maximum pages | `5000` | + +## Use with Google Search Console + +If you also use the Google Search Console plugin, you can merge its pages with the pages this plugin imports, so each page has its search performance and its Core Web Vitals in one place. Add this rule once: + +1. Go to **Settings** > **Correlation** and click **Add correlation rule**. +2. Select **Merge**. +3. Set both nodes' **Type** to `page`, then click **Next**. +4. Set both properties to `name`, then click **Preview** and **Save and run**. + +Both plugins name a page by its full URL, so pages listed the same way in your sitemap and in Search Console merge. The CrUX dashboards and the Search Console dashboards then open the same page object. + +## What this plugin monitors + +- **Core Web Vitals:** Largest Contentful Paint (LCP), Interaction to Next Paint (INP) and Cumulative Layout Shift (CLS), the three metrics Google uses to assess page experience. +- **Supporting metrics:** First Contentful Paint (FCP) and Time to First Byte (TTFB), which usually explain a slow LCP. +- **For each metric:** the 75th percentile value Google rates, its good, needs improvement or poor rating, and the share of visits in each band, for all devices, phone, desktop or tablet. +- **Trends:** weekly values for up to the last 40 weeks. +- **Pages and the whole site:** any page from the sitemap, or every page on the website together. +- **Why LCP is slow:** how the Largest Contentful Paint splits into time to first byte, resource load delay, load duration and render delay for visits where the largest element is an image, and how often it's an image or text. +- **Audience and navigation:** the device mix, network round trip time, and how many visits use the back/forward cache or prerendering. +- **Competitors:** the same Core Web Vitals for any competitor websites you add. + +The out-of-the-box dashboards are: + +- **Overview:** the latest 28-day snapshot, with the Core Web Vitals pass or fail assessment and what changed since last month. +- **Page:** a perspective on any single page, against its whole website. +- **Trends:** 40 weeks of every metric. +- **LCP Diagnostics:** why LCP is slow and what to fix first. +- **Competitors:** your website against each competitor on phone, and who is improving on Largest Contentful Paint. It shows only your own website until you add competitor websites. +- **Key Pages:** a leaderboard of your key pages, showing which ones got better or worse since last month and which have too little traffic to report. It uses phone data, and stays empty until you add key pages. + +## Data streams + +- **Core Web Vitals:** each metric's 75th percentile, rating and good, needs improvement and poor shares over the last 28 days, for each selected website or page. Choose **Page** or **Whole site** and the device. +- **Core Web Vitals history:** the same values as a weekly time series, for up to the last 40 weeks. Set the timeframe to **None** for all 40 weeks. +- **Page experience diagnostics:** the LCP breakdown, the LCP element type, navigation types, device mix and round trip time over the last 28 days, for each selected website or page. + +## What gets indexed + +| Object type | API source | Represents | +| ----------- | ---------- | ---------- | +| **origin** | The **Website** field, or otherwise the sitemap's own origin | The website, used by the whole-site tiles. | +| **page** | Each `` in the sitemap and every sitemap it links to, plus any **Key pages** | A page on the website. Key pages have the property `pageGroup` set to `Key page`. | +| **competitor** | **Competitor websites** | A competitor website. | + +## Known limitations + +- **Pages need Chrome traffic:** CrUX only reports pages and websites with enough real Chrome visits. Tiles for quieter pages show "No Chrome UX Report data for this page or website on this device", and a page can have phone data but no desktop data. +- **Data is a 28-day rolling window, not live:** current values cover the 28 days ending about two days ago, and history updates once a week. A fix shows fully after about four weeks, so pair this plugin with real-user monitoring or lab tests to see changes as they happen. +- **No country breakdown:** the CrUX API doesn't split data by country. +- **Up to 40 weeks of history:** the CrUX History API keeps 40 weekly periods, so longer timeframes aren't offered. +- **No all-pages leaderboard:** CrUX has no way to ask for every page at once, and it reports no traffic figures, so ranking thousands of pages would take thousands of requests. The **Key Pages** dashboard covers the pages you choose instead, and the **Page** dashboard opens any single page. +- **One request per page:** each page in a tile is one API request, and CrUX allows about 150 requests a minute for each API key, so avoid tiles that cover hundreds of pages at once. Results are cached for 6 hours (24 hours for history), because CrUX only publishes new data daily and weekly, so busy dashboards don't add requests. +- **Sitemaps:** + - Only pages on your website, meaning the **Website** origin or otherwise the sitemap's own origin, are imported, up to **Maximum pages**. Pages on other subdomains, `http://` or the domain without `www` are left out, so a large blog or help site can't crowd out the main one. Add any of those pages you care about as **Key pages**, which don't count toward the limit and are imported whatever their address. + - Child and nested sitemaps are followed when they're on the sitemap's own host or any subdomain of your website, such as `sitemaps.example.com`. Others are skipped, and each sitemap is read only once. + - If your sitemap is on a different host from your pages, such as a CDN or the domain without `www`, set the **Website** field. Otherwise setup stops, because none of the listed pages are on your website. + - Gzipped sitemaps (`.xml.gz`) can't be read and are skipped. If your index lists gzipped files, point **Sitemap URL** at a plain XML sitemap. + - One import makes at most about 200 requests. Each sitemap file is one request, and so is every 5,000 pages of a large file. A site split into hundreds of small sitemaps is only partly imported, so point **Sitemap URL** at the sitemap that lists your most important pages instead. + - If a sitemap the import reaches returns an error, such as a 404 for a child sitemap that no longer exists, that import fails. Pages from the last successful import are kept, along with any pages read before the error, and nothing is removed until an import succeeds. Fix or remove the broken sitemap, or point **Sitemap URL** at a working one. +- **Sites that block cloud servers:** some websites' bot protection refuses requests from cloud providers, so the sitemap fails with "Access Denied". Add the data source **Via relay agent** so requests come from your own network, which the website already trusts, or allowlist SquaredUp on the website's CDN. +- **Changing the sitemap:** pages from the previous sitemap can stay listed for a short time after the next import, until SquaredUp clears them. +- **Read-only:** the plugin only reads public CrUX data and your sitemap. + +## Troubleshooting + +### The API key check fails + +Verify that: + +- The Chrome UX Report API is enabled on the Google Cloud project that owns the key +- The key is copied in full, with no spaces +- If the key is restricted, **Chrome UX Report API** is one of the allowed APIs + +### The sitemap can't be read + +Verify that: + +- The **Sitemap URL** opens in a browser and shows plain XML, not a gzipped file +- If the website refuses the request with **Access Denied** or an HTTP 403, its bot protection is probably blocking cloud servers. Add the data source **Via relay agent** so requests come from your own network, or allowlist SquaredUp on the website's CDN +- If the sitemap is on a different host from your pages, **Website** is set to the origin of your pages + +### Tiles show no data + +Verify that: + +- The page or website has enough real Chrome traffic. CrUX doesn't report quiet pages, and a page can have phone data but no desktop data +- The data source finished indexing, so the page you selected exists diff --git a/plugins/ChromeUXReport/v1/icon.svg b/plugins/ChromeUXReport/v1/icon.svg new file mode 100644 index 00000000..efc23a2b --- /dev/null +++ b/plugins/ChromeUXReport/v1/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/plugins/ChromeUXReport/v1/indexDefinitions/default.json b/plugins/ChromeUXReport/v1/indexDefinitions/default.json new file mode 100644 index 00000000..b25521d2 --- /dev/null +++ b/plugins/ChromeUXReport/v1/indexDefinitions/default.json @@ -0,0 +1,19 @@ +{ + "steps": [ + { + "name": "sitemap", + "dataStream": { + "name": "sitemap" + }, + "timeframe": "none", + "objectMapping": { + "id": "url", + "name": "url", + "type": "sourceType", + "properties": [ + "pageGroup" + ] + } + } + ] +} diff --git a/plugins/ChromeUXReport/v1/metadata.json b/plugins/ChromeUXReport/v1/metadata.json new file mode 100644 index 00000000..d7b5e4b6 --- /dev/null +++ b/plugins/ChromeUXReport/v1/metadata.json @@ -0,0 +1,58 @@ +{ + "name": "chrome-ux-report", + "displayName": "Chrome UX Report", + "version": "1.0.0", + "author": { + "name": "@Daniel-Hodgson-SquaredUp", + "type": "community" + }, + "description": "Monitor real-user Core Web Vitals (LCP, INP, CLS, FCP and TTFB) for a website and its pages from the Chrome UX Report (CrUX).", + "category": "Analytics", + "type": "hybrid", + "schemaVersion": "2.1", + "importNotSupported": false, + "restrictedToPlatforms": [], + "keywords": [ + "crux", + "core web vitals", + "lcp", + "inp", + "cls", + "page speed", + "web performance", + "seo", + "chrome" + ], + "objectTypes": [ + "origin", + "page", + "competitor" + ], + "links": [ + { + "category": "documentation", + "url": "https://github.com/squaredup/plugins/blob/main/plugins/ChromeUXReport/v1/docs/README.md", + "label": "Help adding this plugin" + }, + { + "category": "source", + "url": "https://github.com/squaredup/plugins/tree/main/plugins/ChromeUXReport/v1", + "label": "Repository" + } + ], + "base": { + "plugin": "WebAPI", + "majorVersion": "1", + "config": { + "baseUrl": "https://chromeuxreport.googleapis.com/v1", + "authMode": "none", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "queryArgs": [] + } + } +} diff --git a/plugins/ChromeUXReport/v1/ui.json b/plugins/ChromeUXReport/v1/ui.json new file mode 100644 index 00000000..60db8d49 --- /dev/null +++ b/plugins/ChromeUXReport/v1/ui.json @@ -0,0 +1,63 @@ +[ + { + "name": "apiKey", + "type": "password", + "label": "Google API key", + "help": "Enter an API key from a Google Cloud project with the Chrome UX Report API enabled. Create one under **APIs & Services** > **Credentials** in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials).", + "validation": { + "required": true + }, + "placeholder": "Enter the Google API key" + }, + { + "name": "sitemapUrl", + "type": "text", + "label": "Sitemap URL", + "help": "Enter the full URL of your website's XML sitemap or sitemap index (e.g. https://www.example.com/sitemap.xml). Every page it lists on your website is imported.", + "placeholder": "https://www.example.com/sitemap.xml", + "validation": { + "required": true, + "pattern": { + "value": "^https?://[^\\s]+$", + "message": "Enter a full URL starting with http:// or https://" + } + } + }, + { + "name": "website", + "type": "text", + "label": "Website", + "help": "Enter the website's origin when the sitemap is on a different host from the pages, such as a CDN, a `sitemaps.` subdomain, or the domain without www (e.g. https://www.example.com).", + "placeholder": "https://www.example.com" + }, + { + "name": "keyPages", + "type": "textarea", + "label": "Key pages", + "rows": 5, + "help": "Enter the full URL of each page you want to track closely, one per line, such as your home page, top landing pages and checkout. Fills the **Key Pages** dashboard, which stays empty until you add some. Key pages don't count toward **Maximum pages**.", + "placeholder": "https://www.example.com/\nhttps://www.example.com/pricing" + }, + { + "name": "competitors", + "type": "textarea", + "label": "Competitor websites", + "rows": 3, + "help": "Enter the origin of each competitor website to benchmark against, one per line, with no path (e.g. https://www.competitor.com). Fills the **Competitors** dashboard, which shows only your own website until you add some.", + "placeholder": "https://www.competitor.com" + }, + { + "name": "maxPages", + "type": "number", + "label": "Maximum pages", + "help": "Limit how many sitemap pages are imported, not counting key pages. CrUX only has data for pages with enough Chrome traffic, so a lower limit keeps the import quick.", + "defaultValue": 5000, + "validation": { + "min": { + "value": 1, + "message": "Enter at least 1" + }, + "valueAsNumber": true + } + } +]