From 64e4be8bf5d7f17e4d32affa6246c94fbb08eee6 Mon Sep 17 00:00:00 2001 From: Jake Hiller Date: Thu, 27 Oct 2022 17:04:30 -0400 Subject: [PATCH] feat: check user opt-out when blocking trackers Adds check for user manual tracking opt out to tracking module --- package.json | 1 + .../__tests__/getConsentDecision.test.ts | 73 +++++++++++++++++++ .../src/integrations/getConsentDecision.ts | 38 ++++++++++ packages/tracking/src/integrations/index.ts | 15 +++- packages/tracking/src/integrations/types.ts | 2 +- 5 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 packages/tracking/src/integrations/__tests__/getConsentDecision.test.ts create mode 100644 packages/tracking/src/integrations/getConsentDecision.ts diff --git a/package.json b/package.json index d459b38ac..7a7702ab2 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "prettier": "prettier --ignore-path .prettierignore \"./**/*.{js,ts,tsx,json}\"", "format": "yarn lint:fix && yarn prettier --write", "format:verify": "yarn prettier --check", + "compile": "yarn verify", "verify": "turbo run verify --concurrency=3", "verify-all": "yarn verify", "clear-modules": "lerna clean -y && rm -rf node_modules", diff --git a/packages/tracking/src/integrations/__tests__/getConsentDecision.test.ts b/packages/tracking/src/integrations/__tests__/getConsentDecision.test.ts new file mode 100644 index 000000000..bc7bab46a --- /dev/null +++ b/packages/tracking/src/integrations/__tests__/getConsentDecision.test.ts @@ -0,0 +1,73 @@ +import { Consent } from '../consent'; +import { + getConsentDecision, + OPT_OUT_DATALAYER_VAR, +} from '../getConsentDecision'; +import { TrackingWindow } from '../types'; + +const MINIMUM_CONSENT = [Consent.StrictlyNecessary]; + +const FULL_CONSENT = [ + Consent.StrictlyNecessary, + Consent.Functional, + Consent.Performance, + Consent.Targeting, +]; + +const FULL_CONSENT_STRING = [',', ...FULL_CONSENT].join(','); + +describe('getConsentDecision', () => { + it('converts a stringified consent decision into an array', () => { + const result = getConsentDecision({ + scope: { + OnetrustActiveGroups: FULL_CONSENT_STRING, + }, + }); + expect(result).toEqual(FULL_CONSENT); + }); + + it('does not modify an array formatted consent decision', () => { + const result = getConsentDecision({ + scope: { + OnetrustActiveGroups: FULL_CONSENT, + }, + }); + expect(result).toEqual(FULL_CONSENT); + }); + + describe('optedOutExternalTracking', () => { + it('reduces the consent decision to necessary and functional for opted out users', () => { + const result = getConsentDecision({ + scope: { + OnetrustActiveGroups: FULL_CONSENT, + }, + optedOutExternalTracking: true, + }); + expect(result).toEqual([Consent.StrictlyNecessary, Consent.Functional]); + }); + + it('does not add Functional tracking if the user has opted out of it', () => { + const result = getConsentDecision({ + scope: { + OnetrustActiveGroups: MINIMUM_CONSENT, + }, + optedOutExternalTracking: true, + }); + expect(result).toEqual(MINIMUM_CONSENT); + }); + + it('triggers the opt out datalayer variable', () => { + const scope: TrackingWindow = { + OnetrustActiveGroups: FULL_CONSENT, + }; + getConsentDecision({ + scope, + optedOutExternalTracking: true, + }); + const dataLayerVars = scope.dataLayer + ?.map((v: Record) => Object.keys(v)) + .flat(); + expect(dataLayerVars).toEqual([OPT_OUT_DATALAYER_VAR]); + }); + }); +}); diff --git a/packages/tracking/src/integrations/getConsentDecision.ts b/packages/tracking/src/integrations/getConsentDecision.ts new file mode 100644 index 000000000..2bfa88f33 --- /dev/null +++ b/packages/tracking/src/integrations/getConsentDecision.ts @@ -0,0 +1,38 @@ +import { Consent } from './consent'; +import { TrackingWindow } from './types'; + +export interface ConsentDecisionOptions { + scope: TrackingWindow; + optedOutExternalTracking?: boolean; +} + +export const OPT_OUT_DATALAYER_VAR = 'user_opted_out_external_tracking'; + +export const getConsentDecision = ({ + scope, + optedOutExternalTracking, +}: ConsentDecisionOptions) => { + let consentDecision: Consent[] = []; + + if (typeof scope.OnetrustActiveGroups === 'string') { + consentDecision = scope.OnetrustActiveGroups.split(',').filter( + Boolean + ) as Consent[]; + } else if (scope.OnetrustActiveGroups) { + consentDecision = scope.OnetrustActiveGroups; + } + + if (optedOutExternalTracking) { + /** + * If user has already opted out of everything but the essentials + * don't force them to consent to Functional trackers + */ + if (consentDecision.length > 1) { + consentDecision = [Consent.StrictlyNecessary, Consent.Functional]; + } + scope.dataLayer ??= []; + scope.dataLayer.push({ [OPT_OUT_DATALAYER_VAR]: true }); + } + + return consentDecision; +}; diff --git a/packages/tracking/src/integrations/index.ts b/packages/tracking/src/integrations/index.ts index 7416da1e4..46479cc8a 100644 --- a/packages/tracking/src/integrations/index.ts +++ b/packages/tracking/src/integrations/index.ts @@ -1,5 +1,6 @@ import { conditionallyLoadAnalytics } from './conditionallyLoadAnalytics'; import { fetchDestinationsForWriteKey } from './fetchDestinationsForWriteKey'; +import { getConsentDecision } from './getConsentDecision'; import { mapDestinations } from './mapDestinations'; import { initializeOneTrust } from './onetrust'; import { runSegmentSnippet } from './runSegmentSnippet'; @@ -26,6 +27,11 @@ export type TrackingIntegrationsSettings = { */ user?: UserIntegrationSummary; + /** + * Whether user has opted out or is excluded from external tracking + */ + optedOutExternalTracking?: boolean; + /** * Segment write key. */ @@ -40,6 +46,7 @@ export const initializeTrackingIntegrations = async ({ production, scope, user, + optedOutExternalTracking, writeKey, }: TrackingIntegrationsSettings) => { // 1. Wait 1000ms to allow any other post-hydration logic to run first @@ -56,13 +63,19 @@ export const initializeTrackingIntegrations = async ({ onError, writeKey, }); + if (!destinations) { return; } + const consentDecision = getConsentDecision({ + scope, + optedOutExternalTracking, + }); + // 5. Those integrations are compared against the user's consent decisions into a list of allowed destinations const { destinationPreferences, identifyPreferences } = mapDestinations({ - consentDecision: scope.OnetrustActiveGroups, + consentDecision, destinations, }); diff --git a/packages/tracking/src/integrations/types.ts b/packages/tracking/src/integrations/types.ts index bb655496d..8c70e5aba 100644 --- a/packages/tracking/src/integrations/types.ts +++ b/packages/tracking/src/integrations/types.ts @@ -28,6 +28,6 @@ export interface SegmentAnalyticsOptions { export interface TrackingWindow { analytics?: SegmentAnalytics; dataLayer?: unknown[]; - OnetrustActiveGroups?: Consent[]; + OnetrustActiveGroups?: Consent[] | string; OptanonWrapper?: () => void; }