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
+
+
+ Week
+ Month
+ Year
+
+
+
+
+
+
+
+ Page views
+ 0
+
+
+ Visits
+ 0
+
+
+ Conversions
+ 0
+
+
+ LCP
+ 0
+
+
+ CLS
+ 0
+
+
+ INP
+ 0
+
+
+
+
+
+
+
+
+
+
+ small sample size, accuracy
+ reduced.
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+ Rows copied to clipboard, ready to paste into spreadsheet
+
+
+ Link copied to clipboard, ready to share
+
+
+
+
\ 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 = [