Skip to content

Commit

Permalink
feat: check user opt-out when blocking trackers
Browse files Browse the repository at this point in the history
Adds check for user manual tracking opt out to tracking module
  • Loading branch information
jakemhiller authored Oct 27, 2022
1 parent 3f394f5 commit 64e4be8
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 2 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => Object.keys(v))
.flat();
expect(dataLayerVars).toEqual([OPT_OUT_DATALAYER_VAR]);
});
});
});
38 changes: 38 additions & 0 deletions packages/tracking/src/integrations/getConsentDecision.ts
Original file line number Diff line number Diff line change
@@ -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;
};
15 changes: 14 additions & 1 deletion packages/tracking/src/integrations/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
*/
Expand All @@ -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
Expand All @@ -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,
});

Expand Down
2 changes: 1 addition & 1 deletion packages/tracking/src/integrations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ export interface SegmentAnalyticsOptions {
export interface TrackingWindow {
analytics?: SegmentAnalytics;
dataLayer?: unknown[];
OnetrustActiveGroups?: Consent[];
OnetrustActiveGroups?: Consent[] | string;
OptanonWrapper?: () => void;
}

0 comments on commit 64e4be8

Please sign in to comment.