diff --git a/README.md b/README.md index f9517a29..bdf05be8 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ If you prefer using `https` links you'd replace `git@github.com:adobe/aem-experi ## Project instrumentation +:warning: The plugin requires that you have a recent RUM instrumentation from the AEM boilerplate that supports `sampleRUM.always`. If you are getting errors that `.on` cannot be called on an `undefined` object, please apply the changes from https://github.com/adobe/aem-boilerplate/pull/247/files to your `lib-franklin.js`. + ### On top of the plugin system The easiest way to add the plugin is if your project is set up with the plugin system extension in the boilerplate. @@ -52,9 +54,9 @@ window.hlx.plugins.add('experimentation', { }); ``` -### Without the plugin system +### On top of a regular boilerplate project -To properly connect and configure the plugin for your project, you'll need to edit your `scripts.js` in your AEM project and add the following: +Typically, you'd know you don't have the plugin system if you don't see a reference to `window.hlx.plugins` in your `scripts.js`. In that case, you can still manually instrument this plugin in your project by falling back to a more manual instrumentation. To properly connect and configure the plugin for your project, you'll need to edit your `scripts.js` in your AEM project and add the following: 1. at the start of the file: ```js @@ -131,13 +133,16 @@ There are various aspects of the plugin that you can configure via options you a You have already seen the `audiences` option in the examples above, but here is the full list we support: ```js -runEager.call(pluginContext, { +runEager.call(document, { // Overrides the base path if the plugin was installed in a sub-directory basePath: '', - // Lets you configure if we are in a prod environment or not + + // Lets you configure the prod environment. // (prod environments do not get the pill overlay) + prodHost: 'www.my-website.com', + // if you have several, or need more complex logic to toggle pill overlay, you can use isProd: () => window.location.hostname.endsWith('hlx.page') - || window.location.hostname === ('localhost') + || window.location.hostname === ('localhost'), /* Generic properties */ // RUM sampling rate on regular AEM pages is 1 out of 100 page views @@ -146,6 +151,10 @@ runEager.call(pluginContext, { // short durations of those campaigns/experiments rumSamplingRate: 10, + // the storage type used to persist data between page views + // (for instance to remember what variant in an experiment the user was served) + storage: window.SessionStorage, + /* Audiences related properties */ // See more details on the dedicated Audiences page linked below audiences: {}, @@ -163,7 +172,7 @@ runEager.call(pluginContext, { experimentsConfigFile: 'manifest.json', experimentsMetaTag: 'experiment', experimentsQueryParameter: 'experiment', -}); +}, pluginContext); ``` For detailed implementation instructions on the different features, please read the dedicated pages we have on those topics: diff --git a/src/index.js b/src/index.js index 6d1a34bf..5cbda603 100644 --- a/src/index.js +++ b/src/index.js @@ -431,6 +431,10 @@ export async function runExperiment(document, options, context) { console.debug(`running experiment (${window.hlx.experiment.id}) -> ${window.hlx.experiment.selectedVariant}`); if (experimentConfig.selectedVariant === experimentConfig.variantNames[0]) { + context.sampleRUM('experiment', { + source: experimentConfig.id, + target: experimentConfig.selectedVariant, + }); return false; } @@ -447,14 +451,14 @@ export async function runExperiment(document, options, context) { } // Fullpage content experiment - document.body.classList.add(`experiment-${experimentConfig.id}`); + document.body.classList.add(`experiment-${context.toClassName(experimentConfig.id)}`); const result = await replaceInner(pages[index], document.querySelector('main')); experimentConfig.servedExperience = result || currentPath; if (!result) { // eslint-disable-next-line no-console console.debug(`failed to serve variant ${window.hlx.experiment.selectedVariant}. Falling back to ${experimentConfig.variantNames[0]}.`); } - document.body.classList.add(`variant-${result ? experimentConfig.selectedVariant : experimentConfig.variantNames[0]}`); + document.body.classList.add(`variant-${context.toClassName(result ? experimentConfig.selectedVariant : experimentConfig.variantNames[0])}`); context.sampleRUM('experiment', { source: experimentConfig.id, target: result ? experimentConfig.selectedVariant : experimentConfig.variantNames[0], @@ -573,7 +577,7 @@ export async function serveAudience(document, options, context) { } } -window.hlx.patchBlockConfig.push((config) => { +window.hlx.patchBlockConfig?.push((config) => { const { experiment } = window.hlx; // No experiment is running @@ -674,11 +678,16 @@ export async function loadLazy(document, options, context) { ...DEFAULT_OPTIONS, ...(options || {}), }; - if (window.location.hostname.endsWith('hlx.page') - || window.location.hostname === ('localhost') - || (typeof options.isProd === 'function' && !options.isProd())) { - // eslint-disable-next-line import/no-cycle - const preview = await import('./preview.js'); - preview.default(document, pluginOptions, { ...context, getResolvedAudiences }); - } + // do not show the experimentation pill on prod domains + if (window.location.hostname.endsWith('.live') + || (typeof options.isProd === 'function' && options.isProd()) + || (options.prodHost + && (options.prodHost === window.location.host + || options.prodHost === window.location.hostname + || options.prodHost === window.location.origin))) { + return; + } + // eslint-disable-next-line import/no-cycle + const preview = await import('./preview.js'); + preview.default(document, pluginOptions, { ...context, getResolvedAudiences }); } diff --git a/src/preview.css b/src/preview.css index 8269cfa0..ea402f4e 100644 --- a/src/preview.css +++ b/src/preview.css @@ -112,6 +112,7 @@ background-color: #222; border-radius: 16px 16px 0 0; padding: 24px 16px; + gap: 0 16px; } .hlx-popup-header-label { @@ -132,6 +133,7 @@ .hlx-popup h5 { font-family: system-ui, sans-serif; margin: 0; + color: inherit; } .hlx-popup h4 { @@ -188,6 +190,7 @@ margin: 16px; padding: 16px; border-radius: 16px; + gap: 0 16px; } .hlx-popup-item-label { @@ -217,5 +220,5 @@ .hlx-preview-overlay { flex-flow: row-reverse wrap-reverse; justify-content: flex-start; - } + } } diff --git a/src/preview.js b/src/preview.js index 89750bf9..b11da0f8 100644 --- a/src/preview.js +++ b/src/preview.js @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +const DOMAIN_KEY_NAME = 'aem-domainkey'; + function createPreviewOverlay(cls) { const overlay = document.createElement('div'); overlay.className = cls; @@ -27,7 +29,9 @@ function createButton(label) { function createPopupItem(item) { const actions = typeof item === 'object' - ? item.actions.map((action) => `
${action.label}
`) + ? item.actions.map((action) => (action.href + ? `
${action.label}
` + : `
${action.label}
`)) : []; const div = document.createElement('div'); div.className = `hlx-popup-item${item.isSelected ? ' is-selected' : ''}`; @@ -35,12 +39,20 @@ function createPopupItem(item) {
${typeof item === 'object' ? item.label : item}
${item.description ? `
${item.description}
` : ''} ${actions.length ? `
${actions}
` : ''}`; + const buttons = [...div.querySelectorAll('.hlx-button a')]; + item.actions.forEach((action, index) => { + if (action.onclick) { + buttons[index].addEventListener('click', action.onclick); + } + }); return div; } function createPopupDialog(header, items = []) { const actions = typeof header === 'object' - ? (header.actions || []).map((action) => `
${action.label}
`) + ? (header.actions || []).map((action) => (action.href + ? `
${action.label}
` + : `
${action.label}
`)) : []; const popup = document.createElement('div'); popup.className = 'hlx-popup hlx-hidden'; @@ -55,6 +67,12 @@ function createPopupDialog(header, items = []) { items.forEach((item) => { list.append(createPopupItem(item)); }); + const buttons = [...popup.querySelectorAll('.hlx-popup-header-actions .hlx-button a')]; + header.actions.forEach((action, index) => { + if (action.onclick) { + buttons[index].addEventListener('click', action.onclick); + } + }); return popup; } @@ -144,13 +162,27 @@ function createVariant(experiment, variantName, config, options) { } async function fetchRumData(experiment, options) { + if (!options.domainKey) { + // eslint-disable-next-line no-console + console.warn('Cannot show RUM data. No `domainKey` configured.'); + return null; + } + if (!options.prodHost && (typeof options.isProd !== 'function' || !options.isProd())) { + // eslint-disable-next-line no-console + console.warn('Cannot show RUM data. No `prodHost` configured or custom `isProd` method provided.'); + return null; + } + // the query is a bit slow, so I'm only fetching the results when the popup is opened - const resultsURL = new URL('https://helix-pages.anywhere.run/helix-services/run-query@v2/rum-experiments'); - resultsURL.searchParams.set(options.experimentsQueryParameter, experiment); - if (window.hlx.sidekickConfig && window.hlx.sidekickConfig.host) { - // restrict results to the production host, this also reduces query cost - resultsURL.searchParams.set('domain', window.hlx.sidekickConfig.host); + const resultsURL = new URL('https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-experiments'); + // restrict results to the production host, this also reduces query cost + if (typeof options.isProd === 'function' && options.isProd()) { + resultsURL.searchParams.set('url', window.location.host); + } else if (options.prodHost) { + resultsURL.searchParams.set('url', options.prodHost); } + resultsURL.searchParams.set('domainkey', options.domainKey); + resultsURL.searchParams.set('experiment', experiment); const response = await fetch(resultsURL.href); if (!response.ok) { @@ -158,7 +190,8 @@ async function fetchRumData(experiment, options) { } const { results } = await response.json(); - if (!results.length) { + const { data } = results; + if (!data.length) { return null; } @@ -168,7 +201,7 @@ async function fetchRumData(experiment, options) { return o; }, {}); - const variantsAsNums = results.map(numberify); + const variantsAsNums = data.map(numberify); const totals = Object.entries( variantsAsNums.reduce((o, v) => { Object.entries(v).forEach(([k, val]) => { @@ -185,7 +218,7 @@ async function fetchRumData(experiment, options) { const vkey = k.replace(/^(variant|control)_/, 'variant_'); const ckey = k.replace(/^(variant|control)_/, 'control_'); const tkey = k.replace(/^(variant|control)_/, 'total_'); - if (o[ckey] && o[vkey]) { + if (!Number.isNaN(o[ckey]) && !Number.isNaN(o[vkey])) { o[tkey] = o[ckey] + o[vkey]; } return o; @@ -282,6 +315,7 @@ async function decorateExperimentPill(overlay, options, context) { // eslint-disable-next-line no-console console.log('preview experiment', experiment); + const domainKey = window.localStorage.getItem(DOMAIN_KEY_NAME); const pill = createPopupButton( `Experiment: ${config.id}`, { @@ -296,7 +330,32 @@ async function decorateExperimentPill(overlay, options, context) { ${config.variants[config.variantNames[0]].blocks.join(',')}
How is it going?
`, - actions: config.manifest ? [{ label: 'Manifest', href: config.manifest }] : [], + actions: [ + ...config.manifest ? [{ label: 'Manifest', href: config.manifest }] : [], + { + label: '', + onclick: async () => { + // eslint-disable-next-line no-alert + const key = window.prompt( + 'Please enter your domain key:', + window.localStorage.getItem(DOMAIN_KEY_NAME) || '', + ); + if (key && key.match(/[a-f0-9-]+/)) { + window.localStorage.setItem(DOMAIN_KEY_NAME, key); + const performanceMetrics = await fetchRumData(experiment, { + ...options, + domainKey: key, + }); + if (performanceMetrics === null) { + return; + } + populatePerformanceMetrics(pill, config, performanceMetrics); + } else if (key === '') { + window.localStorage.removeItem(DOMAIN_KEY_NAME); + } + }, + }, + ], }, config.variantNames.map((vname) => createVariant(experiment, vname, config, options)), ); @@ -305,7 +364,7 @@ async function decorateExperimentPill(overlay, options, context) { } overlay.append(pill); - const performanceMetrics = await fetchRumData(experiment, options); + const performanceMetrics = await fetchRumData(experiment, { ...options, domainKey }); if (performanceMetrics === null) { return; }