diff --git a/README.md b/README.md
index 41d7f45..52dc573 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
+
+
+Recommended: Integrate in experiment-loader.js
+
+```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);
+ }
+
+ // 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);
+ }
+
+ // 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!
+
+
+
+
+Integrate in scripts.js
+
+```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);
+}
+
+async function loadEager(doc) {
+ document.documentElement.lang = 'en';
+ decorateTemplateAndTheme();
+
+ // Initialize consent BEFORE running experiments
+ setupOneTrustConsent();
+
+ await runExperimentation(doc, experimentationConfig);
+
+ // ... rest of your code
+}
+```
+
+
+
+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:
@@ -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" },
diff --git a/documentation/experiments.md b/documentation/experiments.md
index bf2b0ac..2e43f3e 100644
--- a/documentation/experiments.md
+++ b/documentation/experiments.md
@@ -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);
+}
+```
+
+##### 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 | |
diff --git a/src/index.js b/src/index.js
index caf210f..d23ee21 100644
--- a/src/index.js
+++ b/src/index.js
@@ -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
@@ -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")
@@ -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,
@@ -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) {