@cnaught/analytics-consent-wrapper-cookieyes
Version:
Segment analytics consent wrapper for CookieYes
667 lines (644 loc) • 25.9 kB
JavaScript
;
/**
* Base Consent Error
*/
class AnalyticsConsentError extends Error {
/**
*
* @param name - Pass the name explicitly to work around the limitation that 'name' is automatically set to the parent class.
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/extends#using_extends
* @param message - Error message
*/
constructor(name, message) {
super(message);
this.name = name;
}
}
class ValidationError extends AnalyticsConsentError {
constructor(message, received) {
if (arguments.length === 2) {
// to ensure that explicitly passing undefined as second argument still works
message += ` (Received: ${JSON.stringify(received)})`;
}
super('ValidationError', `[Validation] ${message}`);
}
}
function assertIsFunction(val, variableName) {
if (typeof val !== 'function') {
throw new ValidationError(`${variableName} is not a function`, val);
}
}
function assertIsObject(val, variableName) {
if (val === null || typeof val !== 'object') {
throw new ValidationError(`${variableName} is not an object`, val);
}
}
function validateCategories(ctgs) {
let hasError = true;
if (ctgs && typeof ctgs === 'object' && !Array.isArray(ctgs)) {
hasError = false;
for (const k in ctgs) {
if (typeof ctgs[k] !== 'boolean') {
hasError = true;
break;
}
}
}
if (hasError) {
throw new ValidationError(`Consent Categories should be {[categoryName: string]: boolean}`, ctgs);
}
}
function validateSettings(options) {
if (typeof options !== 'object' || !options) {
throw new ValidationError('Options should be an object', options);
}
assertIsFunction(options.getCategories, 'getCategories');
options.shouldLoadSegment &&
assertIsFunction(options.shouldLoadSegment, 'shouldLoadSegment');
options.shouldEnableIntegration &&
assertIsFunction(options.shouldEnableIntegration, 'shouldEnableIntegration');
options.shouldDisableSegment &&
assertIsFunction(options.shouldDisableSegment, 'shouldDisableSegment');
options.integrationCategoryMappings &&
assertIsObject(options.integrationCategoryMappings, 'integrationCategoryMappings');
options.registerOnConsentChanged &&
assertIsFunction(options.registerOnConsentChanged, 'registerOnConsentChanged');
}
function validateAnalyticsInstance(analytics) {
assertIsObject(analytics, 'analytics');
if ('load' in analytics &&
'addSourceMiddleware' in analytics &&
'addDestinationMiddleware' in analytics &&
'track' in analytics) {
return;
}
throw new ValidationError('analytics is not an Analytics instance', analytics);
}
/**
* Everyday variadic pipe function (reverse of 'compose')
* @example pipe(fn1, fn2, fn3)(value) // fn3(fn2(fn1(value)))
*/
const pipe = (fn, ...fns) => {
const piped = fns.reduce((prevFn, nextFn) => (value) => nextFn(prevFn(value)), (value) => value);
return (...args) => piped(fn(...args));
};
/**
*
* @param condition - predicate function that, if true, will cause the promise to resolve.
* @param delayMs - The frequency, in milliseconds (thousandths of a second), to check the condition.
* @example
* ```ts
* // check 'readyState' every 500ms
* await resolveWhen(() => document.readyState === 'complete', 500)
* ```
*/
async function resolveWhen(condition, delayMs) {
return new Promise((resolve, _reject) => {
if (condition()) {
resolve();
return;
}
const check = () => setTimeout(() => {
if (condition()) {
resolve();
}
else {
check();
}
}, delayMs);
check();
});
}
/**
* This function removes duplicates from an array
*/
const uniq = (arr) => Array.from(new Set(arr));
/**
* @example
* pick({ a: 1, b: 2, c: 3 }, ['a', 'c']) => { a: 1, c: 3 }
*/
const pick = (obj, keys) => {
return keys.reduce((acc, k) => {
if (k in obj) {
acc[k] = obj[k];
}
return acc;
}, {});
};
/**
* Helper function for exhaustive checks of discriminated unions.
*/
function assertNever(value) {
throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}
// hacky debug.ts, can be replaced with a proper logging solution
// @ts-ignore
class Logger {
get debugLoggingEnabled() {
return window['SEGMENT_CONSENT_WRAPPER_DEBUG_MODE'] === true;
}
enableDebugLogging() {
window['SEGMENT_CONSENT_WRAPPER_DEBUG_MODE'] = true;
}
debug(...args) {
if (this.debugLoggingEnabled) {
console.log('[consent wrapper debug]', ...args);
}
}
}
const logger = new Logger();
class LoadContext {
constructor() {
this.isAbortCalled = false;
this.isLoadCalled = false;
this.abortLoadOptions = {
loadSegmentNormally: true,
};
this.loadOptions = {
/**
* Opt-in consent
*/
consentModel: 'opt-in',
};
}
/**
* Allow analytics.js to be initialized with the consent wrapper.
* Note: load just means 'allowed to load' -- we still wait for analytics.load to be explicitly called.
*/
load(options) {
this.isLoadCalled = true;
this.loadOptions = { ...this.loadOptions, ...options };
logger.debug('ctx.load called', this.loadOptions);
}
/**
* Abort the _consent-wrapped_ analytics.js initialization
*/
abort(options) {
this.isAbortCalled = true;
this.abortLoadOptions = { ...this.abortLoadOptions, ...options };
logger.debug('Abort consent wrapper', this.loadOptions);
}
validate() {
if (this.isAbortCalled && this.isLoadCalled) {
throw new ValidationError('both abort and load should not be called');
}
}
}
/**
* Wrapper for shouldLoadSegment fn
*/
const normalizeShouldLoadSegment = (shouldLoadSegment) => {
return async () => {
const loadCtx = new LoadContext();
await shouldLoadSegment?.(loadCtx);
loadCtx.validate();
return loadCtx;
};
};
/**
* Return a promise that can be externally resolved
*/
var createDeferred = function () {
var resolve;
var reject;
var settled = false;
var promise = new Promise(function (_resolve, _reject) {
resolve = function () {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
settled = true;
_resolve.apply(void 0, args);
};
reject = function () {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
settled = true;
_reject.apply(void 0, args);
};
});
return {
resolve: resolve,
reject: reject,
promise: promise,
isSettled: function () { return settled; },
};
};
/**
* Parse list of categories from `cdnSettings.integration.myIntegration` object
* @example
* returns ["Analytics", "Advertising"]
*/
const parseConsentCategories = (integration) => {
if (integration &&
typeof integration === 'object' &&
'consentSettings' in integration &&
typeof integration.consentSettings === 'object' &&
integration.consentSettings &&
'categories' in integration.consentSettings &&
Array.isArray(integration.consentSettings.categories)) {
return integration.consentSettings.categories || undefined;
}
return undefined;
};
const parseAllCategories = (integrationCategoryMappings) => {
return uniq(Object.values(integrationCategoryMappings).reduce((p, n) => p.concat(n)));
};
/**
* @returns whether or not analytics.js should be completely disabled (never load, or drop cookies)
*/
const segmentShouldBeDisabled = (categories, consentSettings) => {
if (!consentSettings || consentSettings.hasUnmappedDestinations) {
return false;
}
// disable if _all_ of the the consented categories are irrelevant to segment
return Object.keys(categories)
.filter((c) => categories[c])
.every((c) => !consentSettings.allCategories.includes(c));
};
const isIntegrationCategoryEnabled = (categories, userCategories) => {
const isMissingCategories = !categories || !categories.length;
// Enable integration by default if it contains no consent categories data (most likely because consent has not been configured).
if (isMissingCategories) {
return true;
}
// Enable integration by default if it contains no consent categories data (most likely because consent has not been configured).
const isEnabled = categories.every((c) => userCategories[c]);
return isEnabled;
};
const shouldEnableIntegrationHelper = (creationName, cdnSettings, userCategories, filterSettings = {}) => {
const { integrationCategoryMappings, shouldEnableIntegration } = filterSettings;
const categories = (integrationCategoryMappings
? integrationCategoryMappings[creationName]
: parseConsentCategories(cdnSettings.integrations[creationName])) ?? [];
// allow user to customize consent logic if needed
if (shouldEnableIntegration) {
return shouldEnableIntegration(categories, userCategories, {
creationName,
});
}
return isIntegrationCategoryEnabled(categories, userCategories);
};
/**
* For opt-in tracking, ensure that device mode destinations that are not consented to are not loaded at all..
* This means that the destinations are never loaded. By disabling them here, they can never drop their own cookies or track users.
* On the downside, it means that the user will not be able to opt-in to these destinations without a page refresh.
*/
const filterDeviceModeDestinationsForOptIn = (cdnSettings, consentedCategories, filterSettings) => {
const { remotePlugins, integrations } = cdnSettings;
const { integrationCategoryMappings, shouldEnableIntegration } = filterSettings;
const cdnSettingsCopy = {
...cdnSettings,
remotePlugins: [...(remotePlugins || [])],
integrations: { ...integrations },
};
const _shouldEnableIntegrationHelper = (creationName) => {
return shouldEnableIntegrationHelper(creationName, cdnSettings, consentedCategories, {
integrationCategoryMappings,
shouldEnableIntegration,
});
};
for (const creationName in integrations) {
if (!_shouldEnableIntegrationHelper(creationName)) {
logger.debug(`Disabled (opt-in): ${creationName}`);
cdnSettingsCopy.remotePlugins = remotePlugins?.filter((p) => p.creationName !== creationName);
// remove disabled classic destinations and locally-installed action destinations
delete cdnSettingsCopy.integrations[creationName];
}
else {
logger.debug(`Enabled (opt-in): ${creationName}`);
}
}
return cdnSettingsCopy;
};
// Block all device mode destinations
const addBlockingMiddleware = (cdnSettingsP, analyticsInstance, filterSettings) => {
const blockDeviceMode = async ({ integration: creationName, payload, next, }) => {
const cdnSettings = await cdnSettingsP;
const eventCategoryPreferences = payload.obj.context.consent.categoryPreferences;
const disabled = !shouldEnableIntegrationHelper(creationName, cdnSettings, eventCategoryPreferences, filterSettings);
logger.debug(`Destination middleware called: ${creationName}`, {
DROPPED: disabled,
categoryPreferences: eventCategoryPreferences,
payload: payload.obj,
filterSettings,
});
if (disabled)
return null;
next(payload);
};
analyticsInstance.addDestinationMiddleware('*', blockDeviceMode);
// Block segment itself (Segment.io isn't currently allowed in addDestinationMiddleware)
const blockSegmentAndEverythingElse = async ({ payload, next, }) => {
const cdnSettings = await cdnSettingsP;
const eventCategoryPreferences = payload.obj.context.consent.categoryPreferences;
const consentSettings = filterSettings.integrationCategoryMappings
? {
hasUnmappedDestinations: false,
allCategories: parseAllCategories(filterSettings.integrationCategoryMappings),
}
: cdnSettings.consentSettings;
const disabled = segmentShouldBeDisabled(eventCategoryPreferences, consentSettings);
logger.debug('Source middleware called', {
DROPPED: disabled,
categoryPreferences: eventCategoryPreferences,
payload: payload.obj,
filterSettings,
consentSettings,
});
if (disabled)
return null;
next(payload);
};
analyticsInstance.addSourceMiddleware(blockSegmentAndEverythingElse);
};
/**
* Create analytics addSourceMiddleware fn that stamps each event
*/
const createConsentStampingMiddleware = (getCategories) => {
const fn = async ({ payload, next }) => {
payload.obj.context.consent = {
...payload.obj.context.consent,
categoryPreferences: await getCategories(),
};
next(payload);
};
return fn;
};
const getPrunedCategories = (categories, allCategories) => {
if (!allCategories.length) {
// No configured integrations found, so no categories will be sent (should not happen unless there's a configuration error)
throw new ValidationError('Invariant: No consent categories defined in Segment', []);
}
return pick(categories, allCategories);
};
/**
* This class is a wrapper around the analytics.js library.
*/
class AnalyticsService {
get analytics() {
return getInitializedAnalytics(this.uninitializedAnalytics);
}
get cdnSettings() {
return this.cdnSettingsDeferred.promise;
}
async getAllCategories() {
const allCategories = this.settings.integrationCategoryMappings
? parseAllCategories(this.settings.integrationCategoryMappings)
: (await this.cdnSettings).consentSettings?.allCategories;
return allCategories ?? [];
}
async getCategories() {
const categories = await this.settings.getCategories();
validateCategories(categories);
return categories;
}
constructor(analytics, options) {
this.cdnSettingsDeferred = createDeferred();
/**
* Allow for gracefully passing a custom disable function (without clobbering the default behavior)
*/
this.createDisableOption = (categories, disable) => {
if (disable === true) {
return true;
}
return (cdnSettings) => {
return (segmentShouldBeDisabled(categories, cdnSettings.consentSettings) ||
(typeof disable === 'function' ? disable(cdnSettings) : false));
};
};
validateAnalyticsInstance(analytics);
this.settings = options;
this.uninitializedAnalytics = analytics;
this.ogAnalyticsLoad = analytics.load.bind(analytics);
}
async loadWithFilteredDeviceModeDestinations(...[settings, options]) {
const initialCategories = await this.getCategories();
const _filterDeviceModeDestinationsForOptIn = (cdnSettings) => filterDeviceModeDestinationsForOptIn(cdnSettings, initialCategories, {
shouldEnableIntegration: this.settings.shouldEnableIntegration,
integrationCategoryMappings: this.settings.integrationCategoryMappings,
});
return this.load(settings, {
...options,
updateCDNSettings: pipe(_filterDeviceModeDestinationsForOptIn, options?.updateCDNSettings ?? ((id) => id)),
disable: this.createDisableOption(initialCategories, options?.disable),
});
}
/**
* The orignal analytics load function, but also stores the CDN settings on the instance.
*/
load(...[settings, options]) {
return this.ogAnalyticsLoad(settings, {
...options,
updateCDNSettings: pipe(options?.updateCDNSettings || ((id) => id), (cdnSettings) => {
logger.debug('CDN settings loaded', cdnSettings);
// extract the CDN settings from this call and store it on the instance.
this.cdnSettingsDeferred.resolve(cdnSettings);
return cdnSettings;
}),
});
}
/**
* Replace the load fn with a new one
*/
replaceLoadMethod(loadFn) {
this.analytics.load = loadFn;
}
page() {
this.analytics.page();
}
configureBlockingMiddlewareForOptOut() {
addBlockingMiddleware(this.cdnSettings, this.analytics, {
integrationCategoryMappings: this.settings.integrationCategoryMappings,
shouldEnableIntegration: this.settings.shouldEnableIntegration,
});
}
configureConsentStampingMiddleware() {
const { pruneUnmappedCategories } = this.settings;
// normalize getCategories pruning is turned on or off
const getCategoriesForConsentStamping = async () => {
const categories = await this.getCategories();
if (pruneUnmappedCategories) {
return getPrunedCategories(categories, await this.getAllCategories());
}
return categories;
};
const MW = createConsentStampingMiddleware(getCategoriesForConsentStamping);
this.analytics.addSourceMiddleware(MW);
}
/**
* Dispatch an event that looks like:
* ```ts
* {
* "type": "track",
* "event": "Segment Consent Preference Updated",
* "context": {
* "consent": {
* "categoryPreferences" : {
* "C0001": true,
* "C0002": false,
* }
* }
* ...
* ```
*/
consentChange(categories) {
logger.debug('Consent change', categories);
try {
validateCategories(categories);
}
catch (e) {
// not sure if there's a better way to handle this
return console.error(e);
}
const CONSENT_CHANGED_EVENT = 'Segment Consent Preference Updated';
this.analytics.track(CONSENT_CHANGED_EVENT, undefined, {
consent: { categoryPreferences: categories },
});
}
}
/**
* Get possibly-initialized analytics.
*
* Reason:
* There is a known bug for people who attempt to to wrap the library: the analytics reference does not get updated when the analytics.js library loads.
* Thus, we need to proxy events to the global reference instead.
*
* There is a universal fix here: however, many users may not have updated it:
* https://github.com/segmentio/snippet/commit/081faba8abab0b2c3ec840b685c4ff6d6cccf79c
*/
const getInitializedAnalytics = (analytics) => {
const isSnippetUser = Array.isArray(analytics);
if (isSnippetUser) {
const opts = analytics._loadOptions ?? {};
const globalAnalytics = window[opts?.globalAnalyticsKey ?? 'analytics'];
// we could probably skip this check and always return globalAnalytics, since they _should_ be set to the same thing at this point
// however, it is safer to keep buffering.
if (globalAnalytics?.initialized) {
return globalAnalytics;
}
}
return analytics;
};
const createWrapper = (...[createWrapperSettings]) => {
validateSettings(createWrapperSettings);
const { shouldDisableSegment, getCategories, shouldLoadSegment, integrationCategoryMappings, shouldEnableIntegration, registerOnConsentChanged, shouldLoadWrapper, enableDebugLogging, } = createWrapperSettings;
return (analytics) => {
const analyticsService = new AnalyticsService(analytics, {
integrationCategoryMappings,
shouldEnableIntegration,
getCategories,
});
if (enableDebugLogging) {
logger.enableDebugLogging();
}
const loadWrapper = shouldLoadWrapper?.() || Promise.resolve();
void loadWrapper.then(() => {
// Call this function as early as possible. OnConsentChanged events can happen before .load is called.
registerOnConsentChanged?.((categories) =>
// whenever consent changes, dispatch a new event with the latest consent information
analyticsService.consentChange(categories));
});
// Create new load method to handle consent that will replace the original on the analytics instance
const loadWithConsent = async (settings, options) => {
// Prevent stale page context by handling initialPageview ourself.
// By calling page() here early, the current page context (url, etc) gets stored in the pre-init buffer.
// We then set initialPageView to false when we call the underlying analytics library, so page() doesn't get called twice.
if (options?.initialPageview) {
analyticsService.page();
options = { ...options, initialPageview: false };
}
// do not load anything -- segment included
if (await shouldDisableSegment?.()) {
return;
}
await loadWrapper;
const loadCtx = await normalizeShouldLoadSegment(shouldLoadSegment)();
// if abort is called, we can either load segment normally or not load at all
// if user must opt-out of tracking, we load as usual and then rely on the consent blocking middleware to block events
if (loadCtx.isAbortCalled) {
if (loadCtx.abortLoadOptions?.loadSegmentNormally === true) {
analyticsService.load(settings, options);
}
return undefined;
}
// register listener to stamp all events with latest consent information
analyticsService.configureConsentStampingMiddleware();
// if opt-out, we load as usual and then rely on the consent blocking middleware to block events
// if opt-in, we remove all destinations that are not explicitly consented to so they never load in the first place
if (loadCtx.loadOptions.consentModel === 'opt-in') {
await analyticsService.loadWithFilteredDeviceModeDestinations(settings, options);
return undefined;
}
else if (loadCtx.loadOptions.consentModel === 'opt-out') {
analyticsService.configureBlockingMiddlewareForOptOut();
analyticsService.load(settings, options);
return undefined;
}
else {
// for typescript exhaustiveness
assertNever(loadCtx.loadOptions.consentModel);
}
};
analyticsService.replaceLoadMethod(loadWithConsent);
return analytics;
};
};
function getCkyConsent() {
var _a;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain
return (_a = window.getCkyConsent) === null || _a === void 0 ? void 0 : _a.call(window);
}
function activeLawToConsentModel(activeLaw) {
switch (activeLaw) {
case 'ccpa':
return 'opt-out';
case 'gdpr':
default:
return 'opt-in';
}
}
function onCkyConsentUpdate(fn) {
document.addEventListener('cookieyes_consent_update', fn);
}
// Helpful but difficult to find CookieYes docs
// - https://www.cookieyes.com/documentation/retrieving-consent-data-using-api-getckyconsent/
// - https://www.cookieyes.com/documentation/events-on-cookie-banner-interactions/
// Segment wrapper example: https://github.com/segmentio/analytics-next/tree/master/packages/consent/consent-tools#quick-start
/**
* Segment analytics wrapper for CookieYes CMP
*/
const withCookieYes = (analyticsInstance, settings = {}) => createWrapper({
shouldLoadWrapper: async () => {
await resolveWhen(() => !!window.getCkyConsent, 500);
settings.enableDebugLogging && console.log('Will load wrapper');
},
shouldLoadSegment: async (ctx) => {
var _a, _b;
const { activeLaw, isUserActionCompleted, categories } = getCkyConsent();
const consentModel = (_b = (_a = settings.consentModel) === null || _a === void 0 ? void 0 : _a.call(settings)) !== null && _b !== void 0 ? _b : activeLawToConsentModel(activeLaw);
if (consentModel === 'opt-in') {
await resolveWhen(() => isUserActionCompleted &&
Object.values(categories).some((v) => v), 500);
settings.enableDebugLogging && console.log('Will load segment');
}
return ctx.load({ consentModel });
},
getCategories: () => getCkyConsent().categories,
registerOnConsentChanged: settings.disableConsentChangedEvent
? undefined
: (setCategories) => {
onCkyConsentUpdate((eventData) => {
const categories = {};
eventData.detail.accepted.forEach((c) => (categories[c] = true));
eventData.detail.rejected.forEach((c) => (categories[c] = false));
setCategories(categories);
});
},
integrationCategoryMappings: settings.integrationCategoryMappings,
enableDebugLogging: settings.enableDebugLogging
})(analyticsInstance);
exports.withCookieYes = withCookieYes;