diff --git a/libs/utils/logWebVitals.js b/libs/utils/logWebVitals.js new file mode 100644 index 0000000000..8c092fb5e4 --- /dev/null +++ b/libs/utils/logWebVitals.js @@ -0,0 +1,95 @@ +const LANA_CLIENT_ID = 'pageperf'; + +let lanaSent; + +function sendToLana(lanaData) { + if (lanaSent) return; + const ua = window.navigator.userAgent; + + Object.assign(lanaData, { + chromeVer: ua.match(/Chrome\/(\d+\.\d+\.\d+\.\d+)/)?.[1] || '', + country: sessionStorage.getItem('akamai') || '', + // eslint-disable-next-line compat/compat + downlink: window.navigator?.connection?.downlink || '', + loggedIn: window.adobeIMS?.isSignedInUser() || false, + os: (ua.match(/Windows/) && 'win') + || (ua.match(/Mac/) && 'mac') + || (ua.match(/Android/) && 'android') + || (ua.match(/Linux/) && 'linux') + || '', + windowHeight: window.innerHeight, + windowWidth: window.innerWidth, + url: `${window.location.host}${window.location.pathname}`, + }); + + lanaData.cls ||= 0; + const lanaDataStr = Object.entries(lanaData) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`) + .join(','); + + window.lana.log(lanaDataStr, { + clientId: LANA_CLIENT_ID, + sampleRate: 100, + }); + + lanaSent = true; +} + +function observeCLS(lanaData) { + let cls = 0; + /* c8 ignore start */ + new PerformanceObserver((entryList) => { + for (const entry of entryList.getEntries()) { + if (!entry.hadRecentInput) cls += entry.value; + } + lanaData.cls = cls.toPrecision(4); + }).observe({ type: 'layout-shift', buffered: true }); +} + +function getElementInfo(el) { + const elSrc = el.src || el.currentSrc || el.href || el.poster; + if (elSrc) { + try { + const srcUrl = new URL(elSrc); + return srcUrl.origin === window.location.origin ? srcUrl.pathname : srcUrl.href; + } catch { + // fall through + } + } + const elHtml = el.outerHTML.replaceAll(',', ''); + if (elHtml.length <= 100) return elHtml; + return `${el.outerHTML.substring(0, 100)}...`; +} + +function observeLCP(lanaData, delay) { + new PerformanceObserver((list) => { + const entries = list.getEntries(); + const lastEntry = entries[entries.length - 1]; // Use the latest LCP candidate + lanaData.lcp = parseInt(lastEntry.startTime, 10); + const lcpEl = lastEntry.element; + lanaData.lcpElType = lcpEl.nodeName.toLowerCase(); + lanaData.lcpEl = getElementInfo(lcpEl); + + setTimeout(() => { + sendToLana(lanaData); + }, parseInt(delay, 10)); + }).observe({ type: 'largest-contentful-paint', buffered: true }); + /* c8 ignore stop */ +} + +function logMepExperiments(lanaData, mep) { + mep?.experiments?.forEach((exp, idx) => { + // only log manifests that affect the page + if (exp.selectedVariantName === 'default') return; + lanaData[`manifest${idx + 1}path`] = exp.manifestPath; + lanaData[`manifest${idx + 1}selected`] = exp.selectedVariantName; + }); +} + +export default function webVitals(mep, { delay = 1000 } = {}) { + const lanaData = {}; + logMepExperiments(lanaData, mep); + observeCLS(lanaData); + observeLCP(lanaData, delay); +} diff --git a/libs/utils/utils.js b/libs/utils/utils.js index 5feb6d18c7..e897310fec 100644 --- a/libs/utils/utils.js +++ b/libs/utils/utils.js @@ -998,6 +998,18 @@ export function scrollToHashedElement(hash) { }); } +function logPagePerf() { + if (getMetadata('pageperf') !== 'on') return; + const isChrome = () => { + const nav = window.navigator; + return nav.userAgent.includes('Chrome') && nav.vendor.includes('Google'); + }; + const sampleRate = parseInt(getMetadata('pageperf-rate'), 10) || 50; + if (!isChrome() || Math.random() * 100 > sampleRate) return; + import('./logWebVitals.js') + .then((mod) => mod.default(getConfig().mep, getMetadata('pageperf-delay') || 1000)); +} + export async function loadDeferred(area, blocks, config) { const event = new Event(MILO_EVENTS.DEFERRED); area.dispatchEvent(event); @@ -1025,6 +1037,8 @@ export async function loadDeferred(area, blocks, config) { sampleRUM.observe(blocks); sampleRUM.observe(area.querySelectorAll('picture > img')); }); + + logPagePerf(); } function initSidekick() { diff --git a/test/utils/logWebVitals.test.js b/test/utils/logWebVitals.test.js new file mode 100644 index 0000000000..01414945b7 --- /dev/null +++ b/test/utils/logWebVitals.test.js @@ -0,0 +1,53 @@ +/* eslint-disable no-use-before-define */ +import { expect } from '@esm-bundle/chai'; +import { readFile } from '@web/test-runner-commands'; +import logWebVitals from '../../libs/utils/logWebVitals.js'; + +document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + +describe('Log Web Vitals', () => { + it('Logs data to lana', (done) => { + window.lana = { + log: (logStr, logOpts) => { + const vitals = logStr.split(',').reduce((acc, pair) => { + const [key, value] = pair.split('='); + acc[key] = value; + return acc; + }, {}); + + expect(vitals).to.have.property('chromeVer'); + const cls = parseFloat(vitals.cls); + expect(cls).to.be.within(0, 1); + expect(vitals).to.have.property('country'); + const downlink = parseFloat(vitals.downlink); + expect(downlink).to.be.within(0, 10); + expect(parseInt(vitals.lcp, 10)).to.be.greaterThan(1); + expect(vitals.lcpEl).to.be.equal('/test/utils/mocks/media_.png'); + expect(vitals.lcpElType).to.be.equal('img'); + expect(vitals.loggedIn).to.equal('false'); + expect(vitals.manifest3path).to.equal('/cc-shared/fragments/promos/2024/americas/cci-all-apps-q3/cci-all-apps-q3.json'); + expect(vitals.manifest3selected).to.equal('all'); + expect(vitals.manifest4path).to.equal('/cc-shared/fragments/tests/2024/q2/ace0875/ace0875.json'); + expect(vitals.manifest4selected).to.equal('target-var-marqueelink'); + expect(vitals.os).to.be.oneOf(['mac', 'win', 'android', 'linux', '']); + expect(vitals.url).to.equal('localhost:2000/'); + expect(parseInt(vitals.windowHeight, 10)).to.be.greaterThan(200); + expect(parseInt(vitals.windowWidth, 10)).to.be.greaterThan(200); + + expect(logOpts.clientId).to.equal('pageperf'); + expect(logOpts.sampleRate).to.equal(100); + + done(); + }, + }; + logWebVitals(mepObject, { delay: 0 }); + }); +}); + +// Sample log string: +// eslint-disable-next-line max-len +// chromeVer=127.0.6533.17,cls=0.1842,country=,downlink=10,lcp=82,loggedIn=false,manifest3path=/cc-shared/fragments/promos/2024/americas/cci-all-apps-q3/cci-all-apps-q3.json,manifest3selected=all,manifest4path=/cc-shared/fragments/tests/2024/q2/ace0875/ace0875.json,manifest4selected=target-var-marqueelink,os=mac,url=localhost:2000/,windowHeight=600,windowWidth=800'); + +const mepObject = JSON.parse(` +{"preview":true,"variantOverride":{"/products/illustrator.json":"default","/cc-shared/fragments/promos/2024/americas/ste-back-to-school-q3/ste-back-to-school-q3.json":"default","/cc-shared/fragments/promos/2024/americas/cci-all-apps-q3/cci-all-apps-q3.json":"all","/cc-shared/fragments/tests/2024/q2/ace0875/ace0875.json":"target-var-marqueelink"},"highlight":false,"targetEnabled":true,"experiments":[{"variantNames":["target-edu_pzn","target-b2b_pzn","target-cpro_pzn","phone & cc-all-apps-any","cc-all-apps-any","illustrator-any"],"manifestOverrideName":"","manifestType":"personalization","executionOrder":"1-0","manifestPath":"/products/illustrator.json","selectedVariantName":"default","name":"PZN | US | Illustrator","manifest":"https://main--cc--adobecom.hlx.live/products/illustrator.json"},{"variantNames":["all"],"manifestOverrideName":"","manifestType":"promo","executionOrder":"1-1","manifestPath":"/cc-shared/fragments/promos/2024/americas/ste-back-to-school-q3/ste-back-to-school-q3.json","selectedVariantName":"default","selectedVariant":"default","manifest":"https://main--cc--adobecom.hlx.page/cc-shared/fragments/promos/2024/americas/ste-back-to-school-q3/ste-back-to-school-q3.json","disabled":true,"event":{"name":"ste-bts-americas","start":"2024-08-19T14:00:00.000Z","end":"2024-09-03T14:00:00.000Z"}},{"variantNames":["all"],"manifestOverrideName":"","manifestType":"promo","executionOrder":"1-1","manifestPath":"/cc-shared/fragments/promos/2024/americas/cci-all-apps-q3/cci-all-apps-q3.json","run":true,"selectedVariantName":"all","selectedVariant":{"commands":[{"action":"replace","selector":".marquee","pageFilter":"**/products/illustrator","target":"https://main--cc--adobecom.hlx.page/cc-shared/fragments/promos/2024/americas/cci-all-apps-q3/products/illustrator/marquee-gen-ai","selectorType":"css","manifestId":"cci-all-apps-q3.json","targetManifestId":false},{"action":"insertbefore","selector":"main > div","pageFilter":"**/products/illustrator","target":"https://main--cc--adobecom.hlx.page/cc-shared/fragments/promos/2024/americas/cci-all-apps-q3/shared/creativecloud/individual/modal#modal-hash:delay=1","selectorType":"css","manifestId":"cci-all-apps-q3.json","targetManifestId":false}],"fragments":[{"selector":"/cc-shared/fragments/merch/products/illustrator/mini-compare/creativecloud/individual/default","val":"/cc-shared/fragments/promos/2024/americas/cci-all-apps-q3/products/illustrator/creativecloud/individual/mini-compare","action":"replace","manifestId":"cci-all-apps-q3.json","targetManifestId":false}]},"manifest":"https://main--cc--adobecom.hlx.page/cc-shared/fragments/promos/2024/americas/cci-all-apps-q3/cci-all-apps-q3.json","disabled":false,"event":{"name":"cci-all-apps-q3","start":"2024-07-22T14:00:00.000Z","end":"2024-08-04T14:00:00.000Z"}},{"variantNames":["target-var-marqueelink"],"manifestOverrideName":"","manifestType":"test","executionOrder":"1-2","manifestPath":"/cc-shared/fragments/tests/2024/q2/ace0875/ace0875.json","run":true,"selectedVariantName":"target-var-marqueelink","selectedVariant":{"commands":[{"action":"replace","selector":".marquee","pageFilter":"","target":"https://www.adobe.com/cc-shared/fragments/tests/2024/q2/ace0875/ace0875","selectorType":"css","manifestId":"ace0875.json","targetManifestId":"ace0875"}],"fragments":[{"selector":"/cc-shared/fragments/tests/2024/q2/ace0758/illustrator/marquee-default","val":"/cc-shared/fragments/tests/2024/q2/ace0875/ace0875","action":"replace","manifestId":"ace0875.json","targetManifestId":"ace0875"}]},"manifest":"https://www.adobe.com/cc-shared/fragments/tests/2024/q2/ace0875/ace0875.json"}],"blocks":{},"fragments":{"/cc-shared/fragments/merch/products/illustrator/mini-compare/creativecloud/individual/default":{"action":"replace","fragment":"/cc-shared/fragments/promos/2024/americas/cci-all-apps-q3/products/illustrator/creativecloud/individual/mini-compare","selector":"/cc-shared/fragments/merch/products/illustrator/mini-compare/creativecloud/individual/default","manifestPath":"/cc-shared/fragments/promos/2024/americas/cci-all-apps-q3/cci-all-apps-q3.json","manifestId":"cci-all-apps-q3.json","targetManifestId":false},"/cc-shared/fragments/tests/2024/q2/ace0758/illustrator/marquee-default":{"action":"replace","fragment":"/cc-shared/fragments/tests/2024/q2/ace0875/ace0875","selector":"/cc-shared/fragments/tests/2024/q2/ace0758/illustrator/marquee-default","manifestPath":"/cc-shared/fragments/tests/2024/q2/ace0875/ace0875.json","manifestId":"ace0875.json","targetManifestId":"ace0875"}},"commands":[{"action":"replace","selector":".marquee","pageFilter":"**/products/illustrator","target":"https://main--cc--adobecom.hlx.page/cc-shared/fragments/promos/2024/americas/cci-all-apps-q3/products/illustrator/marquee-gen-ai","selectorType":"css","manifestId":"cci-all-apps-q3.json","targetManifestId":false},{"action":"insertbefore","selector":"main > div","pageFilter":"**/products/illustrator","target":"https://main--cc--adobecom.hlx.page/cc-shared/fragments/promos/2024/americas/cci-all-apps-q3/shared/creativecloud/individual/modal#modal-hash:delay=1","selectorType":"css","manifestId":"cci-all-apps-q3.json","targetManifestId":false},{"action":"replace","selector":".marquee","pageFilter":"","target":"https://www.adobe.com/cc-shared/fragments/tests/2024/q2/ace0875/ace0875","selectorType":"css","manifestId":"ace0875.json","targetManifestId":"ace0875"}],"martech":"|nopzn|illustrator"} +`); diff --git a/test/utils/logWebVitalsUtils.test.js b/test/utils/logWebVitalsUtils.test.js new file mode 100644 index 0000000000..82afe1eb73 --- /dev/null +++ b/test/utils/logWebVitalsUtils.test.js @@ -0,0 +1,51 @@ +/* eslint-disable no-use-before-define */ +import { expect } from '@esm-bundle/chai'; +import { readFile } from '@web/test-runner-commands'; +import { getConfig, loadDeferred } from '../../libs/utils/utils.js'; + +document.head.innerHTML = ` + '; + '; + '; +`; + +document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + +describe('Log Web Vitals', () => { + it('Logs data to lana', (done) => { + window.lana = { + log: (logStr, logOpts) => { + const vitals = logStr.split(',').reduce((acc, pair) => { + const [key, value] = pair.split('='); + acc[key] = value; + return acc; + }, {}); + + expect(vitals).to.have.property('chromeVer'); + const cls = parseFloat(vitals.cls); + expect(cls).to.be.within(0, 1); + expect(vitals).to.have.property('country'); + const downlink = parseFloat(vitals.downlink); + expect(downlink).to.be.within(0, 10); + expect(parseInt(vitals.lcp, 10)).to.be.greaterThan(1); + expect(vitals.lcpEl).to.be.equal('/test/utils/mocks/media_.png'); + expect(vitals.lcpElType).to.be.equal('img'); + expect(vitals.loggedIn).to.equal('false'); + expect(vitals.os).to.be.oneOf(['mac', 'win', 'android', 'linux', '']); + expect(vitals.url).to.equal('localhost:2000/'); + expect(parseInt(vitals.windowHeight, 10)).to.be.greaterThan(200); + expect(parseInt(vitals.windowWidth, 10)).to.be.greaterThan(200); + + expect(logOpts.clientId).to.equal('pageperf'); + expect(logOpts.sampleRate).to.equal(100); + + done(); + }, + }; + loadDeferred(document, undefined, getConfig()); + }).timeout(5000); +}); + +// Sample log string: +// eslint-disable-next-line max-len +// chromeVer=127.0.6533.17,cls=0.1842,country=,downlink=10,lcp=82,loggedIn=false,manifest3path=/cc-shared/fragments/promos/2024/americas/cci-all-apps-q3/cci-all-apps-q3.json,manifest3selected=all,manifest4path=/cc-shared/fragments/tests/2024/q2/ace0875/ace0875.json,manifest4selected=target-var-marqueelink,os=mac,url=localhost:2000/,windowHeight=600,windowWidth=800');