Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The AEM Experimentation plugin supports:
- :busts_in_silhouette: serving different content variations to different audiences, including custom audience definitions for your project that can be either resolved directly in-browser or against a trusted backend API.
- :money_with_wings: serving different content variations based on marketing campaigns you are running, so that you can easily track email and/or social campaigns
- :chart_with_upwards_trend: running A/B test experiments on a set of variants to measure and improve the conversion on your site. This works particularly with our :chart: [RUM conversion tracking plugin](https://github.com/adobe/franklin-rum-conversion).
- :shield: privacy-compliant experimentation with built-in consent management support for GDPR, CCPA, and other privacy regulations
- :rocket: easy simulation of each experience and basic reporting leveraging in-page overlays

## Installation
Expand Down Expand Up @@ -200,6 +201,175 @@ The plugin exposes experiment data through two mechanisms:

### Available APIs

#### Consent Management

The plugin provides consent management APIs for privacy compliance. Experiments can be configured to require user consent before running.

**APIs:**

```javascript
import {
isUserConsentGiven,
updateUserConsent
} from './plugins/experimentation/src/index.js';

// Check if user has consented to experimentation
const hasConsent = isUserConsentGiven();

// Integrate this with your consent management platform events to track the user's choice
updateUserConsent(true); // or false to revoke consent
```

**Requiring consent for an experiment:**

Add the `Experiment Requires Consent` metadata property:

| Metadata | |
|-----------------------|--------------------------------------------------------------|
| Experiment | Hero Test |
| Experiment Variants | /variant-1, /variant-2 |
| Experiment Requires Consent | true |

**Implementation:**

You can integrate consent management in two ways:

1. **In your `experiment-loader.js`** (recommended) - keeps all experimentation code together
2. **In your `scripts.js`** - if you need consent for other purposes beyond experimentation

<details>
<summary>Recommended: Integrate in experiment-loader.js</summary>

```javascript
// experiment-loader.js
import {
updateUserConsent,
isUserConsentGiven,
} from '../plugins/experimentation/src/index.js';

/**
* Initialize consent management
* Choose ONE of the setup functions based on your CMP (Consent Management Platform)
*
* IMPORTANT: These are example implementations. Please:
* 1. Verify the consent categories match your OneTrust/Cookiebot configuration
* 2. Test thoroughly in your environment
* 3. Consult with your legal/privacy team about consent requirements
*/
function initConsent() {
// OPTION 1: OneTrust
function setupOneTrustConsent() {
// Step 1: Bridge OneTrust's callback to dispatch a custom event
window.OptanonWrapper = function() {
const activeGroups = window.OnetrustActiveGroups || '';
const groups = activeGroups.split(',').filter(g => g);
window.dispatchEvent(new CustomEvent('consent.onetrust', {
detail: groups
}));
};

// Step 2: Listen for the custom event
function consentEventHandler(ev) {
const groups = ev.detail;
const hasConsent = groups.includes('C0003') // Functional Cookies
|| groups.includes('C0004'); // Targeting Cookies
updateUserConsent(hasConsent);
}
window.addEventListener('consent.onetrust', consentEventHandler);
}
Comment thread
FentPams marked this conversation as resolved.

// OPTION 2: Cookiebot
function setupCookiebotConsent() {
function handleCookiebotConsent() {
const preferences = window.Cookiebot?.consent?.preferences || false;
const marketing = window.Cookiebot?.consent?.marketing || false;
updateUserConsent(preferences || marketing);
}
window.addEventListener('CookiebotOnConsentReady', handleCookiebotConsent);
window.addEventListener('CookiebotOnAccept', handleCookiebotConsent);
}
Comment on lines +281 to +290
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one! We could add this to the martech plugin as well so it's consistent!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// OPTION 3: Custom Consent Banner
function setupCustomConsent() {
document.addEventListener('consent-updated', (event) => {
updateUserConsent(event.detail.experimentation);
});
}

// Choose ONE:
setupOneTrustConsent(); // or setupCookiebotConsent() or setupCustomConsent()
}

export async function runExperimentation(document, config) {
if (!isExperimentationEnabled()) {
return null;
}

// Initialize consent BEFORE loading experimentation
initConsent();

const { loadEager } = await import('../plugins/experimentation/src/index.js');
return loadEager(document, config);
}

// Export consent functions for use elsewhere if needed
export { updateUserConsent, isUserConsentGiven };
```

Your `scripts.js` stays clean - no consent code needed there!

</details>

<details>
<summary>Integrate in scripts.js</summary>

```javascript
// scripts.js
import {
updateUserConsent,
isUserConsentGiven,
} from '../plugins/experimentation/src/index.js';

import { runExperimentation } from './experiment-loader.js';

// Setup consent (choose ONE based on your CMP)
function setupOneTrustConsent() {
// Step 1: Bridge OneTrust's callback to dispatch a custom event
window.OptanonWrapper = function() {
const activeGroups = window.OnetrustActiveGroups || '';
const groups = activeGroups.split(',').filter(g => g);
window.dispatchEvent(new CustomEvent('consent.onetrust', {
detail: groups
}));
};

// Step 2: Listen for the custom event
function consentEventHandler(ev) {
const groups = ev.detail;
const hasConsent = groups.includes('C0003') // Functional Cookies
|| groups.includes('C0004'); // Targeting Cookies
updateUserConsent(hasConsent);
}
window.addEventListener('consent.onetrust', consentEventHandler);
}
Comment thread
FentPams marked this conversation as resolved.

async function loadEager(doc) {
document.documentElement.lang = 'en';
decorateTemplateAndTheme();

// Initialize consent BEFORE running experiments
setupOneTrustConsent();

await runExperimentation(doc, experimentationConfig);

// ... rest of your code
}
```

</details>

For detailed usage instructions and more examples, see the [Experiments documentation](/documentation/experiments.md#consent-based-experiments).

#### Events

Listen for the `aem:experimentation` event to react when experiments, campaigns, or audiences are applied:
Expand Down Expand Up @@ -508,6 +678,7 @@ Here's the complete experiment config structure available in `window.hlx.experim
variantNames: ["control", "challenger-1"],
audiences: ["mobile", "desktop"],
resolvedAudiences: ["mobile"],
requiresConsent: false, // whether this experiment requires user consent
run: true,
variants: {
control: { percentageSplit: "0.5", pages: ["/current"], label: "Control" },
Expand Down
101 changes: 101 additions & 0 deletions documentation/experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,107 @@ Start and end dates are in the flexible JS [Date Time String Format](https://tc3

So you can both use generic dates, like `2024-01-31` or `2024/01/31`, and time-specific dates like `2024-01-31T13:37` or `2024/01/31 1:37 pm`. You can even enforce a specific timezone so your experiment activates when, say, it's 2am GMT+1 by using `2024/1/31 2:00 pm GMT+1` or similar notations.

#### Consent-based experiments

For compliance with privacy regulations like GDPR, CCPA, and others, experiments can be configured to require user consent before running. This ensures that personalization and experimentation only occur when users have explicitly agreed to it.

##### Enabling consent requirement

To require consent for an experiment, add the `Experiment Requires Consent` metadata property:

| Metadata | |
|-----------------------|--------------------------------------------------------------|
| Experiment | Hero Test |
| Experiment Variants | [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-1](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-2]() |
| Experiment Requires Consent | true |

When this property is set to `true`, the experiment will only run if the user has provided consent for experimentation. If set to `false` or omitted, the experiment will run according to its other configuration rules (existing behavior).

##### Managing consent status

The experimentation runtime provides JavaScript APIs to manage user consent:

**Check current consent status:**
```javascript
import { isUserConsentGiven } from './path/to/experimentation/src/index.js';

const isConsented = isUserConsentGiven();
console.log('User has consented to experimentation:', isConsented);
```

**Update consent status:**
```javascript
import { updateUserConsent } from './path/to/experimentation/src/index.js';

// Update consent (call this when your CMP sends a consent event)
updateUserConsent(true); // or false to revoke consent
```

##### Integrating with consent management platforms

Connect your consent management system (CMS) to track user consent. Call `updateUserConsent` when your CMS sends a consent event.

> **💡 Tip**: For cleaner code organization, we recommend placing consent integration in your `experiment-loader.js` file (if you have one) rather than in `scripts.js`. This keeps all experimentation-related code together. See the [README](/README.md#consent-management) for complete implementation examples.

**Example: OneTrust integration**
```javascript
import { updateUserConsent } from './path/to/experimentation/src/index.js';

function handleOneTrustConsent() {
const activeGroups = window.OnetrustActiveGroups || '';
const hasConsent = activeGroups.includes('C0003') // Functional Cookies
|| activeGroups.includes('C0004'); // Targeting Cookies
updateUserConsent(hasConsent);
}

// Hook into OneTrust callback
window.OptanonWrapper = function() {
handleOneTrustConsent();
};
```

**Example: Cookiebot integration**
```javascript
import { updateUserConsent } from './path/to/experimentation/src/index.js';

function handleCookiebotConsent() {
const preferences = window.Cookiebot?.consent?.preferences || false;
const marketing = window.Cookiebot?.consent?.marketing || false;
updateUserConsent(preferences || marketing);
}

window.addEventListener('CookiebotOnConsentReady', handleCookiebotConsent);
window.addEventListener('CookiebotOnAccept', handleCookiebotConsent);
```

**Example: Custom consent banner**
```javascript
import { updateUserConsent } from './path/to/experimentation/src/index.js';

// When user accepts/rejects consent
function onConsentChange(accepted) {
updateUserConsent(accepted);
}
```
Comment thread
FentPams marked this conversation as resolved.

##### Storage mechanism

Consent status is stored locally in the browser's `localStorage` under the key `experimentation-consented`. This ensures consent preferences persist across browser sessions while remaining privacy-compliant by staying local to the user's device.

##### Behavior when consent is required but not given

- Experiments requiring consent will not run if consent has not been provided
- The control experience (original page content) will be served
- No RUM events related to the skipped experiment will be fired
- The experiment configuration will still be available programmatically, but `config.run` will be `false`

##### Best practices

1. **Obtain consent early**: Implement consent collection as early as possible in the user journey
2. **Respect consent changes**: Re-evaluate running experiments when consent status changes
3. **Provide clear opt-out**: Ensure users can easily revoke consent if previously given
4. **Document consent requirements**: Clearly indicate which experiments require consent in your authoring guidelines

#### Redirect page experiments
For the use case that fully redirect to the target URL instead of just replacing the content (our default behavior), you could add a new property `Experiment Resolution | redirect` in page metadata:
| Metadata | |
Expand Down
61 changes: 61 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export const DEFAULT_OPTIONS = {
decorateFunction: () => {},
};

const CONSENT_STORAGE_KEY = 'experimentation-consented';

/**
* Converts a given comma-seperate string to an array.
* @param {String|String[]} str The string to convert
Expand Down Expand Up @@ -86,6 +88,61 @@ async function onPageActivation(cb) {
}
}

/**
* Reads the current consent status from localStorage.
* @returns {Boolean} true if consent is given, false otherwise
*/
function getConsentFromStorage() {
try {
return localStorage.getItem(CONSENT_STORAGE_KEY) === 'true';
} catch (error) {
debug('Failed to read consent from localStorage:', error);
return false;
}
}

/**
* Writes the consent status to localStorage.
* Only stores consent when explicitly given (true).
* Removes the key when consent is denied or revoked (false).
* @param {Boolean} consented Whether the user has consented
*/
function setConsentInStorage(consented) {
try {
if (consented) {
localStorage.setItem(CONSENT_STORAGE_KEY, 'true');
} else {
localStorage.removeItem(CONSENT_STORAGE_KEY);
}
} catch (error) {
debug('Failed to save consent to localStorage:', error);
}
}

/**
* Checks if user has given consent for experimentation.
* @returns {Boolean} true if consent is given, false otherwise
*/
export function isUserConsentGiven() {
return getConsentFromStorage();
}

/**
* Sets the user consent status for experimentation.
* - If consent is given (true): stores the decision in localStorage
* - If consent is denied or revoked (false): removes any stored consent
* @param {Boolean} consented Whether the user has consented to experimentation
*/
export function updateUserConsent(consented) {
if (consented) {
setConsentInStorage(true);
debug('Experimentation consent granted and stored');
} else {
setConsentInStorage(false);
debug('Experimentation consent denied or revoked - storage cleared');
}
}

/**
* Fires a Real User Monitoring (RUM) event based on the provided type and configuration.
* @param {string} type - the type of event to be fired ("experiment", "campaign", or "audience")
Expand Down Expand Up @@ -684,12 +741,14 @@ async function getExperimentConfig(pluginOptions, metadata, overrides) {

const startDate = metadata.startDate ? new Date(metadata.startDate) : null;
const endDate = metadata.endDate ? new Date(metadata.endDate) : null;
const requiresConsent = metadata.requiresConsent === 'true';

const config = {
id,
label: `Experiment ${metadata.value || metadata.experiment}`,
status: metadata.status || 'active',
audiences,
requiresConsent,
endDate,
resolvedAudiences,
startDate,
Expand All @@ -706,6 +765,8 @@ async function getExperimentConfig(pluginOptions, metadata, overrides) {
&& (!overrides.audience || audiences.includes(overrides.audience))
&& (!startDate || startDate <= Date.now())
&& (!endDate || endDate > Date.now())
// experiment has consent if required
&& (!requiresConsent || isUserConsentGiven())
);

if (!config.run) {
Expand Down