diff --git a/src/sdkClient/__tests__/client.spec.ts b/src/sdkClient/__tests__/client.spec.ts new file mode 100644 index 00000000..4112d7d5 --- /dev/null +++ b/src/sdkClient/__tests__/client.spec.ts @@ -0,0 +1,38 @@ +import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; +import { clientFactory } from '../client'; + +test('client should track or not events and impressions depending on user consent status', () => { + const sdkReadinessManager = { readinessManager: { isReady: () => true } }; + const storage = { splits: { trafficTypeExists: () => true } }; + + const settings = { ...fullSettings }; + const eventTracker = { track: jest.fn() }; + const impressionsTracker = { track: jest.fn() }; + + // @ts-ignore + const client = clientFactory({ settings, eventTracker, impressionsTracker, sdkReadinessManager, storage }); + + client.getTreatment('some_key', 'some_split'); + expect(impressionsTracker.track).toBeCalledTimes(1); // impression should be tracked if userConsent is undefined + client.track('some_key', 'some_tt', 'some_event'); + expect(eventTracker.track).toBeCalledTimes(1); // event should be tracked if userConsent is undefined + + settings.userConsent = 'UNKNOWN'; + client.getTreatment('some_key', 'some_split'); + expect(impressionsTracker.track).toBeCalledTimes(2); // impression should be tracked if userConsent is unknown + client.track('some_key', 'some_tt', 'some_event'); + expect(eventTracker.track).toBeCalledTimes(2); // event should be tracked if userConsent is unknown + + settings.userConsent = 'GRANTED'; + client.getTreatment('some_key', 'some_split'); + expect(impressionsTracker.track).toBeCalledTimes(3); // impression should be tracked if userConsent is granted + client.track('some_key', 'some_tt', 'some_event'); + expect(eventTracker.track).toBeCalledTimes(3); // event should be tracked if userConsent is granted + + settings.userConsent = 'DECLINED'; + client.getTreatment('some_key', 'some_split'); + expect(impressionsTracker.track).toBeCalledTimes(3); // impression should not be tracked if userConsent is declined + client.track('some_key', 'some_tt', 'some_event'); + expect(eventTracker.track).toBeCalledTimes(3); // event should not be tracked if userConsent is declined + +}); diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index 5b0b7de2..7d5ab72c 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -4,7 +4,7 @@ import { getMatching, getBucketing } from '../utils/key'; import { validateSplitExistance } from '../utils/inputValidation/splitExistance'; import { validateTrafficTypeExistance } from '../utils/inputValidation/trafficTypeExistance'; import { SDK_NOT_READY } from '../utils/labels'; -import { CONTROL } from '../utils/constants'; +import { CONSENT_DECLINED, CONTROL } from '../utils/constants'; import { IClientFactoryParams } from './types'; import { IEvaluationResult } from '../evaluator/types'; import { SplitIO, ImpressionDTO } from '../types'; @@ -16,13 +16,14 @@ import { IMPRESSION, IMPRESSION_QUEUEING } from '../logger/constants'; */ // @TODO missing time tracking to collect telemetry export function clientFactory(params: IClientFactoryParams): SplitIO.IClient | SplitIO.IAsyncClient { - const { sdkReadinessManager: { readinessManager }, storage, settings: { log, mode }, impressionsTracker, eventTracker } = params; + const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker } = params; + const { log, mode } = settings; function getTreatment(key: SplitIO.SplitKey, splitName: string, attributes: SplitIO.Attributes | undefined, withConfig = false) { const wrapUp = (evaluationResult: IEvaluationResult) => { const queue: ImpressionDTO[] = []; const treatment = processEvaluation(evaluationResult, splitName, key, attributes, withConfig, `getTreatment${withConfig ? 'withConfig' : ''}`, queue); - impressionsTracker.track(queue, attributes); + if (settings.userConsent !== CONSENT_DECLINED) impressionsTracker.track(queue, attributes); return treatment; }; @@ -42,7 +43,7 @@ export function clientFactory(params: IClientFactoryParams): SplitIO.IClient | S Object.keys(evaluationResults).forEach(splitName => { treatments[splitName] = processEvaluation(evaluationResults[splitName], splitName, key, attributes, withConfig, `getTreatments${withConfig ? 'withConfig' : ''}`, queue); }); - impressionsTracker.track(queue, attributes); + if (settings.userConsent !== CONSENT_DECLINED) impressionsTracker.track(queue, attributes); return treatments; }; @@ -115,7 +116,8 @@ export function clientFactory(params: IClientFactoryParams): SplitIO.IClient | S // This may be async but we only warn, we don't actually care if it is valid or not in terms of queueing the event. validateTrafficTypeExistance(log, readinessManager, storage.splits, mode, trafficTypeName, 'track'); - return eventTracker.track(eventData, size); + if (settings.userConsent !== CONSENT_DECLINED) return eventTracker.track(eventData, size); + else return false; } return { diff --git a/src/sync/__tests__/syncManagerOnline.spec.ts b/src/sync/__tests__/syncManagerOnline.spec.ts new file mode 100644 index 00000000..c47cad86 --- /dev/null +++ b/src/sync/__tests__/syncManagerOnline.spec.ts @@ -0,0 +1,42 @@ +import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; +import { syncTaskFactory } from './syncTask.mock'; + +jest.mock('../submitters/submitterManager', () => { + return { + submitterManagerFactory: syncTaskFactory + }; +}); + +import { syncManagerOnlineFactory } from '../syncManagerOnline'; + +test('syncManagerOnline should start or not the submitter depending on user consent status', () => { + const settings = { ...fullSettings }; + + // @ts-ignore + const syncManager = syncManagerOnlineFactory()({ settings }); + const submitter = syncManager.submitter!; + + syncManager.start(); + expect(submitter.start).toBeCalledTimes(1); // Submitter should be started if userConsent is undefined + syncManager.stop(); + expect(submitter.stop).toBeCalledTimes(1); + + settings.userConsent = 'UNKNOWN'; + syncManager.start(); + expect(submitter.start).toBeCalledTimes(1); // Submitter should not be started if userConsent is unknown + syncManager.stop(); + expect(submitter.stop).toBeCalledTimes(2); + + settings.userConsent = 'GRANTED'; + syncManager.start(); + expect(submitter.start).toBeCalledTimes(2); // Submitter should be started if userConsent is granted + syncManager.stop(); + expect(submitter.stop).toBeCalledTimes(3); + + settings.userConsent = 'DECLINED'; + syncManager.start(); + expect(submitter.start).toBeCalledTimes(2); // Submitter should not be started if userConsent is declined + syncManager.stop(); + expect(submitter.stop).toBeCalledTimes(4); + +}); diff --git a/src/sync/submitters/eventsSyncTask.ts b/src/sync/submitters/eventsSyncTask.ts index 151607ea..b87ee24e 100644 --- a/src/sync/submitters/eventsSyncTask.ts +++ b/src/sync/submitters/eventsSyncTask.ts @@ -38,8 +38,12 @@ export function eventsSyncTaskFactory( // register events submitter to be executed when events cache is full eventsCache.setOnFullQueueCb(() => { - log.info(SUBMITTERS_PUSH_FULL_QUEUE, [DATA_NAME]); - syncTask.execute(); + if (syncTask.isRunning()) { + log.info(SUBMITTERS_PUSH_FULL_QUEUE, [DATA_NAME]); + syncTask.execute(); + } + // If submitter is stopped (e.g., user consent declined or unknown, or app state offline), we don't send the data. + // Data will be sent when submitter is resumed. }); return syncTask; diff --git a/src/sync/submitters/impressionsSyncTask.ts b/src/sync/submitters/impressionsSyncTask.ts index 5947c40c..f3fdb20c 100644 --- a/src/sync/submitters/impressionsSyncTask.ts +++ b/src/sync/submitters/impressionsSyncTask.ts @@ -57,8 +57,12 @@ export function impressionsSyncTaskFactory( // register impressions submitter to be executed when impressions cache is full impressionsCache.setOnFullQueueCb(() => { - log.info(SUBMITTERS_PUSH_FULL_QUEUE, [DATA_NAME]); - syncTask.execute(); + if (syncTask.isRunning()) { + log.info(SUBMITTERS_PUSH_FULL_QUEUE, [DATA_NAME]); + syncTask.execute(); + } + // If submitter is stopped (e.g., user consent declined or unknown, or app state offline), we don't send the data. + // Data will be sent when submitter is resumed. }); return syncTask; diff --git a/src/sync/syncManagerOnline.ts b/src/sync/syncManagerOnline.ts index 1bbfe948..425b9d9f 100644 --- a/src/sync/syncManagerOnline.ts +++ b/src/sync/syncManagerOnline.ts @@ -6,6 +6,7 @@ import { IPushManager } from './streaming/types'; import { IPollingManager, IPollingManagerCS } from './polling/types'; import { PUSH_SUBSYSTEM_UP, PUSH_SUBSYSTEM_DOWN } from './streaming/constants'; import { SYNC_START_POLLING, SYNC_CONTINUE_POLLING, SYNC_STOP_POLLING } from '../logger/constants'; +import { CONSENT_GRANTED } from '../utils/constants'; /** * Online SyncManager factory. @@ -39,6 +40,11 @@ export function syncManagerOnlineFactory( // It is not inyected as push and polling managers, because at the moment it is required const submitter = submitterManagerFactory(params); + function isUserConsentGranted() { + const userConsent = params.settings.userConsent; + // undefined userConsent is handled as granted to avoid a breaking change with users that don't use this features + return !userConsent || userConsent === CONSENT_GRANTED; + } /** Sync Manager logic */ @@ -69,12 +75,18 @@ export function syncManagerOnlineFactory( let startFirstTime = true; // flag to distinguish calling the `start` method for the first time, to support pausing and resuming the synchronization return { + // Exposed for fine-grained control of synchronization. + // E.g.: user consent, app state changes (Page hide, Foreground/Background, Online/Offline). + pollingManager, pushManager, + submitter, /** * Method used to start the syncManager for the first time, or resume it after being stopped. */ start() { + running = true; + // start syncing splits and segments if (pollingManager) { if (pushManager) { @@ -90,21 +102,21 @@ export function syncManagerOnlineFactory( } // start periodic data recording (events, impressions, telemetry). - if (submitter) submitter.start(); - running = true; + if (isUserConsentGranted()) submitter.start(); }, /** * Method used to stop/pause the syncManager. */ stop() { + running = false; + // stop syncing if (pushManager) pushManager.stop(); if (pollingManager && pollingManager.isRunning()) pollingManager.stop(); // stop periodic data recording (events, impressions, telemetry). - if (submitter) submitter.stop(); - running = false; + submitter.stop(); }, isRunning() { @@ -112,7 +124,7 @@ export function syncManagerOnlineFactory( }, flush() { - if (submitter) return submitter.execute(); + if (isUserConsentGranted()) return submitter.execute(); else return Promise.resolve(); }, diff --git a/src/sync/types.ts b/src/sync/types.ts index 0f0588d9..1a70a5c6 100644 --- a/src/sync/types.ts +++ b/src/sync/types.ts @@ -3,6 +3,7 @@ import { IPlatform } from '../sdkFactory/types'; import { ISplitApi } from '../services/types'; import { IStorageSync } from '../storages/types'; import { ISettings } from '../types'; +import { IPollingManager } from './polling/types'; import { IPushManager } from './streaming/types'; export interface ITask { @@ -43,7 +44,9 @@ export interface ITimeTracker { export interface ISyncManager extends ITask { flush(): Promise, - pushManager?: IPushManager + pushManager?: IPushManager, + pollingManager?: IPollingManager, + submitter?: ISyncTask } export interface ISyncManagerCS extends ISyncManager { diff --git a/src/types.ts b/src/types.ts index 1e3654a8..c449ff37 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,6 +54,11 @@ type EventConsts = { * @typedef {string} SDKMode */ export type SDKMode = 'standalone' | 'consumer' | 'localhost' | 'consumer_partial'; +/** + * User consent status. + * @typedef {string} ConsentStatus + */ +export type ConsentStatus = 'GRANTED' | 'DECLINED' | 'UNKNOWN'; /** * Settings interface. This is a representation of the settings the SDK expose, that's why * most of it's props are readonly. Only features should be rewritten when localhost mode is active. @@ -111,6 +116,7 @@ export interface ISettings { }, readonly log: ILogger readonly impressionListener?: unknown + readonly userConsent?: ConsentStatus } /** * Log levels. diff --git a/src/utils/constants/index.ts b/src/utils/constants/index.ts index 4a4d8303..243accd6 100644 --- a/src/utils/constants/index.ts +++ b/src/utils/constants/index.ts @@ -32,3 +32,8 @@ export const STORAGE_MEMORY: StorageType = 'MEMORY'; export const STORAGE_LOCALSTORAGE: StorageType = 'LOCALSTORAGE'; export const STORAGE_REDIS: StorageType = 'REDIS'; export const STORAGE_PLUGGABLE: StorageType = 'PLUGGABLE'; + +// User consent +export const CONSENT_GRANTED = 'GRANTED'; // The user has granted consent for tracking events and impressions +export const CONSENT_DECLINED = 'DECLINED'; // The user has declined consent for tracking events and impressions +export const CONSENT_UNKNOWN = 'UNKNOWN'; // The user has neither granted nor declined consent for tracking events and impressions diff --git a/src/utils/settingsValidation/__tests__/settings.mocks.ts b/src/utils/settingsValidation/__tests__/settings.mocks.ts index 3cb458cb..ff160297 100644 --- a/src/utils/settingsValidation/__tests__/settings.mocks.ts +++ b/src/utils/settingsValidation/__tests__/settings.mocks.ts @@ -88,7 +88,8 @@ export const fullSettings: ISettings = { auth: 'auth', streaming: 'streaming' }, - log: loggerMock + log: loggerMock, + userConsent: undefined }; export const fullSettingsServerSide = {