Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/sdkClient/__tests__/client.spec.ts
Original file line number Diff line number Diff line change
@@ -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

});
12 changes: 7 additions & 5 deletions src/sdkClient/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
};

Expand All @@ -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;
};

Expand Down Expand Up @@ -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 {
Expand Down
42 changes: 42 additions & 0 deletions src/sync/__tests__/syncManagerOnline.spec.ts
Original file line number Diff line number Diff line change
@@ -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);

});
8 changes: 6 additions & 2 deletions src/sync/submitters/eventsSyncTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src/sync/submitters/impressionsSyncTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 17 additions & 5 deletions src/sync/syncManagerOnline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 */

Expand Down Expand Up @@ -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) {
Expand All @@ -90,29 +102,29 @@ 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() {
return running;
},

flush() {
if (submitter) return submitter.execute();
if (isUserConsentGranted()) return submitter.execute();
else return Promise.resolve();
},

Expand Down
5 changes: 4 additions & 1 deletion src/sync/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Input extends any[] = []> {
Expand Down Expand Up @@ -43,7 +44,9 @@ export interface ITimeTracker {

export interface ISyncManager extends ITask {
flush(): Promise<any>,
pushManager?: IPushManager
pushManager?: IPushManager,
pollingManager?: IPollingManager,
submitter?: ISyncTask
}

export interface ISyncManagerCS extends ISyncManager {
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -111,6 +116,7 @@ export interface ISettings {
},
readonly log: ILogger
readonly impressionListener?: unknown
readonly userConsent?: ConsentStatus
}
/**
* Log levels.
Expand Down
5 changes: 5 additions & 0 deletions src/utils/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion src/utils/settingsValidation/__tests__/settings.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ export const fullSettings: ISettings = {
auth: 'auth',
streaming: 'streaming'
},
log: loggerMock
log: loggerMock,
userConsent: undefined
};

export const fullSettingsServerSide = {
Expand Down