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 = {