diff --git a/tools/oversight/charts/experimentsankey.js b/tools/oversight/charts/experimentsankey.js new file mode 100644 index 00000000..35ff504f --- /dev/null +++ b/tools/oversight/charts/experimentsankey.js @@ -0,0 +1,178 @@ +// eslint-disable-next-line import/no-unresolved +import { SankeyController, Flow } from 'chartjs-chart-sankey'; +// eslint-disable-next-line import/no-unresolved +import { Chart, registerables } from 'chartjs'; +import SankeyChart from './sankey.js'; +import { cssVariable, parseConversionSpec } from '../utils.js'; + +Chart.register(SankeyController, Flow, ...registerables); + +const stages = [ + { + label: 'contenttype', + /* + * 1. What kind of content was consumed + * In this case, it is just "experiment" + */ + experiment: { + label: (bundle) => bundle.events + .filter((e) => e.checkpoint === 'experiment') + .map((e) => `experiment:${e.source}`) + .pop(), + color: cssVariable('--spectrum-red-800'), + detect: (bundle) => bundle.events + .filter((e) => e.checkpoint === 'experiment') + .length > 0, + next: ['variant:*'], + }, + }, + { + label: 'variant', + /* + * 2. What variant is selected + * - variant + */ + variant: { + color: cssVariable('--spectrum-fuchsia-300'), + label: (bundle) => bundle.events + .filter((e) => e.checkpoint === 'experiment') + .map((e) => `variant:${e.source} ${e.target}`) + .pop(), + detect: (bundle) => bundle.events + .filter((e) => e.checkpoint === 'experiment') + .length > 0, + next: ['click', 'convert', 'formsubmit', 'nointeraction'], + }, + }, + { + label: 'interaction', + /* + * 3. What kind of interaction happened + * - convert + * - click + * - formsubmit + * - none + */ + convert: { + color: cssVariable('--spectrum-fuchsia-300'), + label: 'Conversion', + detect: (bundle, dataChunks) => { + const conversionSpec = parseConversionSpec(); + if (Object.keys(conversionSpec).length === 0) return false; + if (Object.keys(conversionSpec).length === 1 && conversionSpec.checkpoint && conversionSpec.checkpoint[0] === 'click') return false; + return dataChunks.hasConversion(bundle, conversionSpec, 'every'); + }, + next: [], + }, + click: { + color: cssVariable('--spectrum-green-900'), + label: 'Click', + detect: (bundle) => bundle.events + .filter((e) => e.checkpoint === 'click') + .length > 0, + next: ['intclick', 'extclick', 'media'], + }, + formsubmit: { + color: cssVariable('--spectrum-seafoam-900'), + label: 'Form Submit', + detect: (bundle) => bundle.events + .filter((e) => e.checkpoint === 'formsubmit') + .length > 0, + }, + nointeraction: { + label: 'No Interaction', + color: cssVariable('--spectrum-gray-100'), + detect: (bundle) => bundle.events + .filter((e) => e.checkpoint === 'click' + || e.checkpoint === 'formsubmit') + .length === 0, + }, + }, + { + label: 'clicktarget', + /* + * 4. What's the type of click target + * - external + * - internal + * - media + */ + media: { + color: cssVariable('--spectrum-yellow-1000'), + label: 'Media Click', + next: [], + detect: (bundle) => bundle.events + .filter((e) => e.checkpoint === 'click') + .filter((e) => e.target && e.target.indexOf('media_') > -1) + .length > 0, + }, + extclick: { + label: 'External Click', + color: cssVariable('--spectrum-purple-1000'), + next: ['external:*'], + detect: (bundle) => bundle.events + .filter((e) => e.checkpoint === 'click') + .filter((e) => e.target && e.target.startsWith('http')) + .filter((e) => new URL(e.target).hostname !== new URL(bundle.url).hostname) + .length > 0, + }, + + intclick: { + label: 'Internal Click', + color: cssVariable('--spectrum-green-1000'), + next: ['internal:*'], + detect: (bundle) => bundle.events + .filter((e) => e.checkpoint === 'click') + .filter((e) => !!e.target) + .filter((e) => new URL(e.target).hostname === new URL(bundle.url).hostname) + .length > 0, + }, + }, + { + label: 'exit', + /* + * 5. What's the click target, specifically + */ + internal: { + color: cssVariable('--spectrum-green-1100'), + next: [], + label: (bundle) => bundle.events.filter((e) => e.checkpoint === 'click') + .filter((e) => !!e.target) + .map((e) => new URL(e.target)) + .map((u) => u.pathname) + .map((p) => `internal:${p}`) + .pop(), + detect: (bundle) => bundle.events + .filter((e) => e.checkpoint === 'click') + .filter((e) => !!e.target) + .filter((e) => e.target.indexOf('media_') === -1) + .filter((e) => new URL(e.target).hostname === new URL(bundle.url).hostname) + .length > 0, + }, + external: { + color: cssVariable('--spectrum-purple-1100'), + next: [], + label: (bundle) => bundle.events.filter((e) => e.checkpoint === 'click') + .filter((e) => !!e.target) + .map((e) => new URL(e.target)) + .map((u) => u.hostname) + .map((h) => `external:${h}`) + .pop(), + detect: (bundle) => bundle.events + .filter((e) => e.checkpoint === 'click') + .filter((e) => e.target) + .filter((e) => e.target.indexOf('media_') === -1) + // only external links for now + .filter((e) => new URL(e.target).hostname !== new URL(bundle.url).hostname) + .length > 0, + }, + }, +]; +const allStages = stages.reduce((acc, stage) => ({ ...acc, ...stage }), {}); + +export default class ExperimentSankeyChart extends SankeyChart { + constructor(dataChunks, elems) { + super(dataChunks, elems); + this.stages = stages; + this.allStages = allStages; + } +} diff --git a/tools/oversight/charts/sankey.js b/tools/oversight/charts/sankey.js index ea5a9e8d..3be4480c 100644 --- a/tools/oversight/charts/sankey.js +++ b/tools/oversight/charts/sankey.js @@ -388,6 +388,12 @@ const stages = [ const allStages = stages.reduce((acc, stage) => ({ ...acc, ...stage }), {}); export default class SankeyChart extends AbstractChart { + constructor(dataChunks, elems) { + super(dataChunks, elems); + this.stages = stages; + this.allStages = allStages; + } + async draw() { const params = new URL(window.location.href).searchParams; @@ -411,7 +417,7 @@ export default class SankeyChart extends AbstractChart { .reduce(reclassifyEnter, []); const detectedFlows = []; // go through each stage - stages.forEach((stage) => { + this.stages.forEach((stage) => { let cont = false; // go through each flow step in the stage Object.entries(stage) @@ -455,11 +461,11 @@ export default class SankeyChart extends AbstractChart { const first = pair[0].split(':')[0]; const second = pair[1].split(':')[0]; - if (allStages[first] && Array.isArray(allStages[first].next)) { - if (allStages[first].next.includes(second)) { + if (this.allStages[first] && Array.isArray(this.allStages[first].next)) { + if (this.allStages[first].next.includes(second)) { // console.log('explicit allow', pair[0], '->', pair[1]); return true; - } if (allStages[first].next + } if (this.allStages[first].next .filter((n) => n.endsWith(':*')) .map((n) => n.slice(0, -2)) .includes(second)) { @@ -487,9 +493,9 @@ export default class SankeyChart extends AbstractChart { .flat() .reduce((acc, key) => { const [prefix, suffix] = key.split(':'); - if (allStages[key] && allStages[key].label && typeof allStages[key].label === 'string') { - acc[key] = allStages[key].label; - } else if (allStages[prefix] && allStages[prefix].label) { + if (this.allStages[key] && this.allStages[key].label && typeof this.allStages[key].label === 'string') { + acc[key] = this.allStages[key].label; + } else if (this.allStages[prefix] && this.allStages[prefix].label) { acc[key] = suffix; } else { acc[key] = key; @@ -505,7 +511,7 @@ export default class SankeyChart extends AbstractChart { .map((flow) => flow.split('->')) .flat() .reduce((acc, key) => { - stages.forEach((stage, column) => { + this.stages.forEach((stage, column) => { if (stage[key]) { acc[key] = column + 1; } @@ -535,8 +541,8 @@ export default class SankeyChart extends AbstractChart { label: 'My sankey', data: this.enriched, labels: this.labels, - colorFrom: ({ raw }) => allStages[raw.from.split(':')[0]]?.color || 'purple', - colorTo: ({ raw }) => allStages[raw.to.split(':')[0]]?.color || 'gray', + colorFrom: ({ raw }) => this.allStages[raw.from.split(':')[0]]?.color || 'purple', + colorTo: ({ raw }) => this.allStages[raw.to.split(':')[0]]?.color || 'gray', columns: this.columns, colorMode: 'gradient', // or 'from' or 'to' @@ -549,7 +555,7 @@ export default class SankeyChart extends AbstractChart { // eslint-disable-next-line class-methods-use-this updateDataFacets(dataChunks) { - const facetcandidates = stages.filter((stage) => stage.label); + const facetcandidates = this.stages.filter((stage) => stage.label); facetcandidates.forEach((facet) => { dataChunks.addFacet(facet.label, (bundle) => Object.entries(facet) diff --git a/tools/oversight/elements/facetsidebar.js b/tools/oversight/elements/facetsidebar.js index 329ba677..ddf57a56 100644 --- a/tools/oversight/elements/facetsidebar.js +++ b/tools/oversight/elements/facetsidebar.js @@ -45,7 +45,7 @@ export default class FacetSidebar extends HTMLElement { }); } - updateFacets(mode) { + updateFacets(mode, contenttype) { const filterTags = document.querySelector('.filter-tags'); filterTags.textContent = ''; const addFilterTag = (name, value) => { @@ -74,5 +74,13 @@ export default class FacetSidebar extends HTMLElement { if (facetEl) facetEl.setAttribute('mode', mode || 'default'); if (facetEl) this.elems.facetsElement.append(facetEl); }); + + // for the experiment sankey, hide variant facet if no experiment has been chosen for filtering + const variantFacet = document.getElementsByTagName('variant-list-facet')[0]; + if (!contenttype?.startsWith('experiment:')) { + variantFacet.hidden = true; + } else { + variantFacet.hidden = false; + } } } diff --git a/tools/oversight/elements/list-facet.js b/tools/oversight/elements/list-facet.js index a048f39a..3f1c246f 100644 --- a/tools/oversight/elements/list-facet.js +++ b/tools/oversight/elements/list-facet.js @@ -98,13 +98,18 @@ export default class ListFacet extends HTMLElement { if (this.dataChunks) this.update(); } + // eslint-disable-next-line class-methods-use-this + reorderFacetEntries(facetEntries) { + return facetEntries; + } + update() { const facetName = this.getAttribute('facet'); - const facetEntries = this.dataChunks.facets[facetName]; + let facetEntries = this.dataChunks.facets[facetName]; const enabled = !this.closest('facet-sidebar[aria-disabled="true"]'); + facetEntries = this.reorderFacetEntries(facetEntries); const sort = this.getAttribute('sort') || 'count'; - const optionKeys = facetEntries.map((f) => f.value) .sort((a, b) => { if (sort === 'count') return 0; // keep the order @@ -211,6 +216,7 @@ export default class ListFacet extends HTMLElement { } return acc.slice(0, i); }, entries[0].value); + entries.slice(start, end).forEach((entry) => { const div = document.createElement('div'); const input = document.createElement('input'); @@ -281,21 +287,7 @@ export default class ListFacet extends HTMLElement { label.append(valuespan, countspan, conversionspan); - const ul = document.createElement('ul'); - ul.classList.add('cwv'); - - // display core web vital to facets - // add lcp - const lcpLI = this.createCWVChiclet(entry, 'lcp'); - ul.append(lcpLI); - - // add cls - const clsLI = this.createCWVChiclet(entry, 'cls'); - ul.append(clsLI); - - // add inp - const inpLI = this.createCWVChiclet(entry, 'inp'); - ul.append(inpLI); + const ul = this.addVitalMetrics(entry); div.append(input, label, ul); @@ -423,4 +415,22 @@ export default class ListFacet extends HTMLElement { window.setTimeout(fillEl, 0); return li; } + + addVitalMetrics(entry) { + const ul = document.createElement('ul'); + ul.classList.add('cwv'); + // display core web vital to facets + // add lcp + const lcpLI = this.createCWVChiclet(entry, 'lcp'); + ul.append(lcpLI); + + // add cls + const clsLI = this.createCWVChiclet(entry, 'cls'); + ul.append(clsLI); + + // add inp + const inpLI = this.createCWVChiclet(entry, 'inp'); + ul.append(inpLI); + return ul; + } } diff --git a/tools/oversight/elements/variant-list-facet.js b/tools/oversight/elements/variant-list-facet.js new file mode 100644 index 00000000..567f280f --- /dev/null +++ b/tools/oversight/elements/variant-list-facet.js @@ -0,0 +1,131 @@ +import { zTestTwoProportions } from '../cruncher.js'; +import ListFacet from './list-facet.js'; + +/** + * A custom HTML element based on "list-facet" used to display a list of variant facets + * along with conversion percentage and statistical signficance metrics. + * + * Variant + * + */ +export default class VariantListFacet extends ListFacet { + // eslint-disable-next-line class-methods-use-this + createCWVChiclet(entry) { + const samples = entry.weight; + const conversions = entry.metrics.conversions.sum; + let isControl = false; + const parentSeries = entry.parent.seriesIn; + let controlEntryKey = Object.keys(parentSeries).filter( + (el) => (el.endsWith('control') && el.includes(entry.value.split(' ')[0])), + ); + if (controlEntryKey.length !== 0 && entry.value.endsWith('control')) { + isControl = true; + } else if (controlEntryKey.length === 0) { + controlEntryKey = Object.keys(parentSeries).filter( + (el) => (el.endsWith('challenger-1') && el.includes(entry.value.split(' ')[0])), + ); + if (entry.value.endsWith('challenger-1')) { + isControl = true; + } + } + const controlSamples = parentSeries[controlEntryKey]?.conversions.weight ?? samples; + const controlConversions = parentSeries[controlEntryKey]?.conversions.sum ?? conversions; + const controlConversionRate = controlConversions / controlSamples; + const statSig = 100 - zTestTwoProportions( + controlSamples, + controlConversions, + samples, + conversions, + ) * 100; + return this.createBar(samples, conversions, controlConversionRate, statSig, isControl); + } + + addVitalMetrics(entry) { + // display variant information in chiclets + // add conversion ratio + const conversionStats = this.createCWVChiclet(entry); + return conversionStats; + } + + // eslint-disable-next-line class-methods-use-this + reorderFacetEntries(facetEntries) { + let facets = []; + const variants = new Set(); + facetEntries.forEach((item) => { + const variant = item.value.split(' ')[0]; + variants.add(variant); + }); + Array.from(variants).forEach((variant) => { + const experiment = []; + facetEntries.forEach((facet) => { + if (facet.value.includes(variant)) { + experiment.push(facet); + } + }); + const controlEntries = []; + const otherEntries = []; + let control = 'challenger-1'; + if (experiment.some((str) => str.value.includes('control'))) { + control = 'control'; + } + experiment.forEach((item) => { + if (item.value.endsWith(control)) { + controlEntries.push(item); + } else { + otherEntries.push(item); + } + }); + otherEntries.sort((a, b) => a.value.localeCompare(b.value)); + facets = facets.concat(controlEntries.concat(otherEntries)); + }); + return facets; + } + + // eslint-disable-next-line class-methods-use-this + createBar(samples, conversions, controlConversionRate, statSig, isControl) { + const barContainer = document.createElement('div'); + barContainer.classList.add('stat-bar-container'); + barContainer.title = `${conversions} conversions out of ${samples} samples`; + + // div for the bar fill + const barFill = document.createElement('div'); + barFill.classList.add('stat-bar-fill'); + const percentage = conversions / samples; + barFill.style.width = `${340 * percentage}px`; + + // span for the percentage text + const barText = document.createElement('span'); + barText.classList.add('stat-bar-text'); + + // wrap text in a number-format element + const nf = document.createElement('number-format'); + nf.setAttribute('precision', 3); + nf.setAttribute('fuzzy', 'false'); + nf.textContent = percentage * 100; + barText.append(nf); + + // apply class stylings based on parameters + if (isControl) { + barContainer.classList.add('control'); + barFill.classList.add('control'); + } else { + if (statSig > 95) { + barContainer.classList.add('significant'); + barFill.classList.add('significant'); + barText.style.fontWeight = 'bold'; + } + if (percentage > controlConversionRate) { + barContainer.classList.add('winner'); + barFill.classList.add('winner'); + } else { + barContainer.classList.add('loser'); + barFill.classList.add('loser'); + } + } + + barContainer.appendChild(barFill); + barContainer.appendChild(barText); + + return barContainer; + } +} diff --git a/tools/oversight/experiments.html b/tools/oversight/experiments.html new file mode 100644 index 00000000..f874e4fd --- /dev/null +++ b/tools/oversight/experiments.html @@ -0,0 +1,228 @@ + + + + Real Use Monitoring (RUM) Explorer | AEM Live + + + + + + + + + + + +
+
+
+
+
+
+ www.aem.live +
+ + +
+
+
+
    +
  • +

    Page views

    +

    0

    +
  • +
  • +

    Visits

    +

    0

    +
  • + +

    Conversions

    +

    0

    +
    +
  • +

    LCP

    +

    0

    +
  • +
  • +

    CLS

    +

    0

    +
  • +
  • +

    INP

    +

    0

    +
  • +
+ +
+
+
+ +
+
+
+ + +
+
+
+ + + + Device Type and Operating System +
+
desktop
+
All Desktop
+
desktop:windows
+
Windows Desktop
+
desktop:mac
+
Mac Desktop
+
desktop:linux
+
Linux Desktop
+
desktop:chromeos
+
Chrome OS Desktop
+
mobile
+
All Mobile
+
mobile:android
+
Android Mobile
+
mobile:ios
+
iOS Mobile
+
mobile:ipados
+
iPad Mobile
+
bot
+
All Bots
+
bot:seo
+
SEO Bot
+
bot:search
+
Search Engine Crawler
+
bot:ads
+
Ad Bot
+
bot:social
+
Social Media Bot
+
+
+ + URL + + + Checkpoints +
+
enter
+
Visit Entry
+
loadresource
+
Fragment Loaded
+
404
+
Not Found
+
viewblock
+
Block Viewed
+
viewmedia
+
Media Viewed
+
click
+
Clicked
+
error
+
JavaScript Error
+
paid
+
Marketing Campaigns
+
consent
+
Consent
+
navigate
+
Internal Navigation
+
experiment
+
Experiment
+
+
+ + Traffic Source + + + Traffic Type + + + Entry Event + + + Load Type + + + Page Type + + + Experiment + + + Variant + + + Interaction + + + Click Target Type + + + Exit Link + + + Experience Quality + + + LCP Image + + + LCP Element + +
+
+
+
+ + + + + + \ No newline at end of file diff --git a/tools/oversight/flow.html b/tools/oversight/flow.html index c05cd245..a50f6ef9 100644 --- a/tools/oversight/flow.html +++ b/tools/oversight/flow.html @@ -167,6 +167,8 @@

TTFB

Consent
navigate
Internal Navigation
+
experiment
+
Experiment
diff --git a/tools/oversight/rum-slicer.css b/tools/oversight/rum-slicer.css index 9de2b0f4..5240e174 100644 --- a/tools/oversight/rum-slicer.css +++ b/tools/oversight/rum-slicer.css @@ -234,6 +234,54 @@ main .key-metrics #inp number-format[trend="falling"] { display: none; } +.stat-bar-container { + margin-bottom: 10px; + height: 40px; + width: 340px; + border: 1px solid #000; + border-radius: 16px; + overflow: hidden; + background-color: #e0e0e0; + position: relative; +} + +.stat-bar-container.winner { + border-color: var(--dark-green); +} + +.stat-bar-container.loser { + border-color: var(--dark-red); +} + +.stat-bar-container.significant { + border-width: 3px; +} + +.stat-bar-fill { + position: absolute; + height: 100%; + background-color: lightgray; + border-right: 2px solid gray; +} + +.stat-bar-fill.winner { + background-color: var(--light-green); +} + +.stat-bar-fill.loser { + background-color: var(--light-red); +} + +.stat-bar-text { + position: absolute; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: #000; +} + /* conversions custom element */ conversion-tracker { diff --git a/tools/oversight/slicer.js b/tools/oversight/slicer.js index 29d9c645..6e3aba00 100644 --- a/tools/oversight/slicer.js +++ b/tools/oversight/slicer.js @@ -325,7 +325,8 @@ export async function draw() { updateKeyMetrics(); const mode = params.get('metrics'); - elems.sidebar.updateFacets(mode); + const contenttype = params.get('contenttype'); + elems.sidebar.updateFacets(mode, contenttype); // eslint-disable-next-line no-console console.log(`full ui updated in ${new Date() - startTime}ms`); diff --git a/tools/oversight/utils.js b/tools/oversight/utils.js index f53e7c2b..3f2413d2 100644 --- a/tools/oversight/utils.js +++ b/tools/oversight/utils.js @@ -62,6 +62,8 @@ export function isKnownFacet(key) { 'vitals', // facets from checkpoints ...checkpoints, + // for experimentation + 'variant', ]; const suffixes = [