UNPKG

@cnaught/analytics-consent-wrapper-cookieyes

Version:

Segment analytics consent wrapper for CookieYes

665 lines (643 loc) 25.9 kB
/** * 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); export { withCookieYes };