/*!
* Convert JS SDK
* Version 1.0.0
* Copyright(c) 2020 Convert Insights, Inc
* License Apache-2.0
*/
import {arrayNotEmpty} from './utils/array-utils';
import {objectDeepValue, objectNotEmpty} from './utils/object-utils';
import {ApiManagerInterface} from './interfaces/api-manager';
import {BucketingManagerInterface} from './interfaces/bucketing-manager';
import {DataStoreManagerInterface} from './interfaces/data-store-manager';
import {DataManagerInterface} from './interfaces/data-manager';
import {EventManagerInterface} from './interfaces/event-manager';
import {LogManagerInterface} from './interfaces/log-manager';
import {RuleManagerInterface} from './interfaces/rule-manager';
import {Entity} from './types/Entity';
import {Variation} from './types/Variation';
import {Id} from './types/Id';
import {Audience} from './types/Audience';
import {Location} from './types/Location';
import {Config, ConfigData} from './types/Config';
import {DATA_ENTITIES} from './enums/data-enitites';
import {ERROR_MESSAGES, MESSAGES} from './enums/dictionary';
import {EventType} from './enums/event-type';
import {Experience} from './types/Experience';
import {IdentityField} from './types/IndentityField';
import {BucketedVariation} from './types/BucketedVariation';
import {StoreData} from './types/StoreData';
import {BucketingEvent} from './types/tracking/BucketingEvent';
import {VisitorEvent} from './types/tracking/VisitorEvent';
import {DataStoreManager} from './data-store-manager';
import {ConversionEvent} from './types/tracking/ConversionEvent';
import {Goal} from './types/Goal';
import {GoalDataKey} from './enums/goal-data-key';
import {SegmentsData} from './types/SegmentsData';
import {Project} from './types/Project';
const LOCAL_STORE_LIMIT = 10000;
/**
* Provides logic for data. Stores bucket with help of dataStore if it's provided
* @category Modules
* @constructor
* @implements {DataManagerInterface}
*/
export class DataManager implements DataManagerInterface {
private _data: ConfigData;
private _accountId: Id;
private _projectId: Id;
private _config: Config;
private _bucketingManager: BucketingManagerInterface;
private _loggerManager: LogManagerInterface;
private _eventManager: EventManagerInterface;
private _dataStoreManager: DataStoreManagerInterface;
private _apiManager: ApiManagerInterface;
private _ruleManager: RuleManagerInterface;
private _dataEntities = DATA_ENTITIES;
private _localStoreLimit = LOCAL_STORE_LIMIT;
private _bucketedVisitors = new Map();
private _environment: string;
/**
* @param {Config} config
* @param {Object} dependencies
* @param {ApiManagerInterface} dependencies.apiManager
* @param {BucketingManagerInterface} dependencies.bucketingManager
* @param {RuleManagerInterface} dependencies.ruleManager
* @param {LogManagerInterface} dependencies.loggerManager
*/
constructor(
config: Config,
{
bucketingManager,
ruleManager,
eventManager,
apiManager,
loggerManager
}: {
bucketingManager: BucketingManagerInterface;
ruleManager: RuleManagerInterface;
eventManager: EventManagerInterface;
apiManager: ApiManagerInterface;
loggerManager?: LogManagerInterface;
}
) {
this._environment = config?.environment;
this._apiManager = apiManager;
this._bucketingManager = bucketingManager;
this._ruleManager = ruleManager;
this._loggerManager = loggerManager;
this._eventManager = eventManager;
this._config = config;
this._data = objectDeepValue(config, 'data');
this._accountId = this._data?.account_id;
this._projectId = this._data?.project?.id;
this.dataStoreManager = objectDeepValue(config, 'dataStore');
this._loggerManager?.trace?.(MESSAGES.DATA_CONSTRUCTOR, this);
}
set data(data: ConfigData) {
if (this.isValidConfigData(data)) {
this._data = data;
this._accountId = data?.account_id;
this._projectId = data?.project?.id;
} else {
this._loggerManager?.error?.(ERROR_MESSAGES.CONFIG_DATA_NOT_VALID);
}
}
/**
* data getter
*/
get data(): ConfigData {
return this._data;
}
/**
* dataStoreManager setter
* @param {any=} dataStore
*/
set dataStoreManager(dataStore: any) {
this._dataStoreManager = null;
this._dataStoreManager = new DataStoreManager(this._config, {
dataStore: dataStore,
eventManager: this._eventManager,
loggerManager: this._loggerManager
});
}
/**
* dataStoreManager getter
*/
get dataStoreManager(): DataStoreManagerInterface {
return this._dataStoreManager;
}
/**
* Retrieve variation for visitor
* @param {string} visitorId
* @param {string|Id} identity Value of the field which name is provided in identityField
* @param {Record<string, any>} visitorProperties
* @param {string} locationProperties
* @param {IdentityField=} identityField Defaults to 'key'
* @param {string=} environment
* @return {BucketedVariation|null}
* @private
*/
private _getBucketingByField(
visitorId: string,
identity: string | Id,
visitorProperties: Record<string, any>,
locationProperties: Record<string, any>,
identityField: IdentityField = 'key',
environment: string = this._environment
): BucketedVariation | null {
this._loggerManager?.trace?.('DataManager._getBucketingByField()', {
visitorId: visitorId,
identity: identity,
visitorProperties: visitorProperties,
locationProperties: locationProperties,
identityField: identityField,
environment: environment
});
// Retrieve the experience
const experience = this._getEntityByField(
identity,
'experiences',
identityField
) as Experience;
// Retrieve archived experiences
const archivedExperiences = this.getEntitiesList(
'archived_experiences'
) as Array<Id>;
// Check whether the experience is archived
const isArchivedExperience = !!archivedExperiences.find(
(id) => experience?.id == id
);
// Check environment
const isEnvironmentMatch = Array.isArray(experience?.environments)
? !experience.environments.length || // skip if empty
experience.environments.includes(environment)
: true; // skip if no environments
if (experience && !isArchivedExperience && isEnvironmentMatch) {
let locationMatched = false;
if (experience?.locations) {
// Get attached locations
const locations = this.getItemsByIds(
experience?.locations,
'locations'
) as Array<Location>;
// Validate locationProperties against locations rules
const matchedLocations = this.filterMatchedRecordsWithRule(
locations,
locationProperties
);
// If there are some matched locations
locationMatched = Boolean(
!locationProperties || matchedLocations.length
);
} else if (experience?.site_area) {
locationMatched = this._ruleManager.isRuleMatched(
locationProperties,
experience?.site_area
);
}
// Validate locationProperties against site area rules
if (!locationProperties || locationMatched) {
let audiences = [],
matchedAudiences = [];
if (experience?.audiences) {
// Get attached audiences
audiences = this.getItemsByIds(
experience?.audiences,
'audiences'
) as Array<Audience>;
// Validate visitorProperties against audiences rules
matchedAudiences = this.filterMatchedRecordsWithRule(
audiences,
visitorProperties
);
}
// If there are some matched audiences
if (
!visitorProperties ||
matchedAudiences.length ||
!audiences.length // Empty audiences list means there's no restriction for the audience
) {
// And experience has variations
if (experience?.variations && experience?.variations?.length) {
return this._retrieveBucketing(visitorId, experience);
} else {
this._loggerManager?.debug?.(MESSAGES.VARIATIONS_NOT_FOUND, {
visitorProperties: visitorProperties,
audiences: audiences
});
}
} else {
this._loggerManager?.debug?.(MESSAGES.RULES_NOT_MATCH, {
visitorProperties: visitorProperties,
audiences: audiences
});
}
} else {
this._loggerManager?.debug?.(MESSAGES.LOCATION_NOT_MATCH, {
locationProperties: locationProperties,
[experience?.locations
? 'experiences[].variations[].locations'
: 'experiences[].variations[].site_area']:
experience?.locations || experience?.site_area || ''
});
}
}
return null;
}
/**
* Retrieve bucketing for Visitor
* @param {Id} visitorId
* @param {Experience} experience
* @return {BucketedVariation | null}
* @private
*/
private _retrieveBucketing(
visitorId: Id,
experience: Experience
): BucketedVariation | null {
if (!visitorId || !experience) return null;
if (!experience?.id) return null;
let variation = null;
let bucketedVariation = null;
const storeKey = this.getStoreKey(visitorId);
// Check that visitor id already bucketed and stored and skip bucketing logic
const {
bucketing: {[experience.id.toString()]: variationId} = {},
segments,
goals
} = this.getLocalStore(visitorId) || {};
if (
variationId &&
(variation = this.retrieveVariation(experience.id, variationId))
) {
// If it's found log debug info. The return value will be formed next step
this._loggerManager?.debug?.(MESSAGES.BUCKETED_VISITOR_FOUND, {
storeKey: storeKey,
visitorId: visitorId,
variationId: variationId
});
} else {
// Try to find a bucketed visitor in dataStore
let {bucketing: {[experience.id.toString()]: variationId} = {}} =
this.dataStoreManager?.get?.(storeKey) || {};
if (
variationId &&
(variation = this.retrieveVariation(experience.id, variationId))
) {
// Store the data in local variable
this.putLocalStore(visitorId, {
bucketing: {[experience.id.toString()]: variationId},
...(segments ? {segments} : {}),
...(goals ? {goals} : {})
});
// If it's found log debug info. The return value will be formed next step
this._loggerManager?.debug?.(MESSAGES.BUCKETED_VISITOR_FOUND, {
storeKey: storeKey,
visitorId: visitorId,
variationId: variationId
});
} else {
// Build buckets where key is variation id and value is traffic distribution
const buckets = experience.variations.reduce((bucket, variation) => {
if (variation?.id)
bucket[variation.id] = variation?.traffic_allocation || 100.0;
return bucket;
}, {});
// Select bucket based for provided visitor id
variationId = this._bucketingManager.getBucketForVisitor(
buckets,
visitorId
) as Id;
if (variationId) {
// Store the data in local variable
const storeData: StoreData = {
bucketing: {[experience.id.toString()]: variationId},
...(segments ? {segments} : {}),
...(goals ? {goals} : {})
};
this.putLocalStore(visitorId, storeData);
// Enqueue to store in dataStore
this.dataStoreManager.enqueue(storeKey, storeData);
// Enqueue bucketing event to api
const bucketingEvent: BucketingEvent = {
experienceId: experience.id,
variationId
};
const visitorEvent: VisitorEvent = {
eventType: EventType.BUCKETING,
data: bucketingEvent
};
this._apiManager.enqueue(visitorId, visitorEvent, segments);
this._loggerManager?.trace?.('DataManager._retrieveBucketing()', {
visitorEvent
});
// Retrieve and return variation
variation = this.retrieveVariation(experience.id, variationId);
} else {
this._loggerManager?.error?.(
ERROR_MESSAGES.UNABLE_TO_SELECT_BUCKET_FOR_VISITOR,
{
visitorId: visitorId,
experience: experience
}
);
}
}
}
// Build the response as bucketed variation object
if (variation) {
bucketedVariation = {
...{
experienceId: experience?.id,
experienceName: experience?.name,
experienceKey: experience?.key
},
...variation
};
}
return bucketedVariation as BucketedVariation;
}
/**
* @param {Id} experienceId
* @param {Id} variationId
* @return {Variation}
* @private
*/
private retrieveVariation(experienceId: Id, variationId: Id): Variation {
return this.getSubItem(
'experiences',
experienceId,
'variations',
variationId,
'id',
'id'
) as Variation;
}
/**
* @param {Id} visitorId
* @param {StoreData} storeData
* @private
*/
putLocalStore(visitorId: Id, storeData: StoreData) {
const storeKey = this.getStoreKey(visitorId);
this._bucketedVisitors.set(storeKey, storeData);
if (this._bucketedVisitors.size > this._localStoreLimit) {
// Delete one of the oldest record
for (const [key, value] of this._bucketedVisitors) {
this._bucketedVisitors.delete(key);
break;
}
}
}
/**
* @param {Id} visitorId
* @return {StoreData | null} variation id
* @private
*/
getLocalStore(visitorId: Id): StoreData | null {
const storeKey = this.getStoreKey(visitorId);
return this._bucketedVisitors.get(storeKey) || null;
}
/**
* @param {Id} visitorId
* @return {string} storeKey
* @private
*/
getStoreKey(visitorId: Id): string {
return `${this._accountId}-${this._projectId}-${visitorId}`;
}
/**
* Retrieve variation for visitor
* @param {string} visitorId
* @param {string} key
* @param {Record<string, any>} visitorProperties
* @param {string} locationProperties
* @param {string=} environment
* @return {BucketedVariation | null}
*/
getBucketing(
visitorId: string,
key: string,
visitorProperties: Record<string, any>,
locationProperties: Record<string, any>,
environment: string = this._environment
): BucketedVariation | null {
return this._getBucketingByField(
visitorId,
key,
visitorProperties,
locationProperties,
'key',
environment
);
}
/**
* Retrieve variation for Visitor
* @param {string} visitorId
* @param {Id} id
* @param {Record<string, any>} visitorProperties
* @param {string} locationProperties
* @param {string=} environment
* @return {BucketedVariation | null}
*/
getBucketingById(
visitorId: string,
id: Id,
visitorProperties: Record<string, any>,
locationProperties: Record<string, any>,
environment: string = this._environment
): BucketedVariation | null {
return this._getBucketingByField(
visitorId,
id,
visitorProperties,
locationProperties,
'id',
environment
);
}
/**
* Process conversion event
* @param {Id} visitorId
* @param {Id} goalId
* @param {Record<string, any>=} goalRule An object of key-value pairs that are used for goal matching
* @param {Array<Record<GoalDataKey, number>>} goalData An array of object of key-value pairs
* @param {SegmentsData} segments
*/
convert(
visitorId: Id,
goalId: Id,
goalRule?: Record<string, any>,
goalData?: Array<Record<GoalDataKey, number>>,
segments?: SegmentsData
): boolean {
const goal =
typeof goalId === 'string'
? (this.getEntity(goalId as string, 'goals') as Goal)
: (this.getEntityById(goalId, 'goals') as Goal);
if (!goal?.id) {
this._loggerManager?.error?.(MESSAGES.GOAL_NOT_FOUND);
return;
}
if (goalRule) {
if (!goal?.rules) return;
const ruleMatched = this._ruleManager.isRuleMatched(
goalRule,
goal?.rules
);
if (!ruleMatched) {
this._loggerManager?.error?.(MESSAGES.GOAL_RULE_NOT_MATCH);
return;
}
}
// Check that goal id already triggred and stored and skip tracking conversion event
const storeKey = this.getStoreKey(visitorId);
const {
bucketing: bucketingData,
segments: segmentsData,
goals: {[goalId.toString()]: goalTriggered} = {}
} = this.getLocalStore(visitorId) || {};
if (goalTriggered) {
this._loggerManager?.debug?.(MESSAGES.GOAL_FOUND, {
storeKey: storeKey,
visitorId: visitorId,
goalId: goalId
});
return;
} else {
// Try to find a triggered goal in dataStore
const {goals: {[goalId.toString()]: goalTriggered} = {}} =
this.dataStoreManager?.get?.(storeKey) || {};
if (goalTriggered) {
this._loggerManager?.debug?.(MESSAGES.GOAL_FOUND, {
storeKey: storeKey,
visitorId: visitorId,
goalId: goalId
});
return;
}
}
// Store the data in local variable
const storeData: StoreData = {
...(bucketingData ? {bucketing: bucketingData} : {}),
...(segmentsData ? {segments: segmentsData} : {}),
goals: {[goalId.toString()]: true}
};
this.putLocalStore(visitorId, storeData);
// Enqueue to store in dataStore
this.dataStoreManager.enqueue(storeKey, storeData);
const data: ConversionEvent = {
goalId: goal?.id
};
if (bucketingData) data.bucketingData = bucketingData;
const event: VisitorEvent = {
eventType: EventType.CONVERSION,
data
};
this._apiManager.enqueue(visitorId, event, segments);
// Split transaction events
if (goalData) {
const data: ConversionEvent = {
goalId: goal?.id,
goalData
};
if (bucketingData) data.bucketingData = bucketingData;
const event: VisitorEvent = {
eventType: EventType.CONVERSION,
data
};
this._apiManager.enqueue(visitorId, event, segments);
}
this._loggerManager?.trace?.('DataManager.convert()', {
event
});
return true;
}
/**
* Get audiences that meet the visitorProperties
* @param {Array<Record<any, any>>} items
* @param {Object} visitorProperties
* @return {Array<Record<string, any>>}
*/
filterMatchedRecordsWithRule(
items: Array<Record<string, any>>,
visitorProperties: Record<string, any>
): Array<Record<string, any>> {
this._loggerManager?.trace?.('DataManager.filterMatchedRecordsWithRule()', {
items: items,
visitorProperties: visitorProperties
});
const matchedRecords = [];
if (arrayNotEmpty(items)) {
for (let i = 0, length = items.length; i < length; i++) {
if (!items?.[i]?.rules) continue;
if (
this._ruleManager.isRuleMatched(visitorProperties, items?.[i]?.rules)
) {
matchedRecords.push(items[i]);
}
}
}
this._loggerManager?.debug?.('DataManager.filterMatchedRecordsWithRule()', {
matchedRecords: matchedRecords
});
return matchedRecords;
}
/**
* Get list of data entities
* @param {string} entityType
* @return {Array<Entity | Id>}
*/
getEntitiesList(entityType: string): Array<Entity | Id> {
let list = [];
if (this._dataEntities.indexOf(entityType) !== -1) {
list = objectDeepValue(this._data, entityType) || [];
}
this._loggerManager?.trace?.('DataManager.getEntitiesList()', {
entityType: entityType,
list: list
});
return list;
}
/**
* Get list of data entities grouped by field
* @param {string} entityType
* @param {IdentityField=} field
* @return {Record<string, Entity>}
*/
getEntitiesListObject(
entityType: string,
field: IdentityField = 'id'
): Record<string, Entity> {
return this.getEntitiesList(entityType).reduce((target, entity) => {
target[entity[field]] = entity;
return target;
}, {});
}
/**
*
* @param {string|Id} identity Value of the field which name is provided in identityField
* @param {string} entityType
* @param {IdentityField=} identityField Defaults to 'key'
* @return {Entity}
* @private
*/
private _getEntityByField(
identity: string | Id,
entityType: string,
identityField: IdentityField = 'key'
): Entity {
this._loggerManager?.trace?.('DataManager._getEntityByField()', {
identity: identity,
entityType: entityType,
identityField: identityField
});
const list = this.getEntitiesList(entityType) as Array<Entity>;
if (arrayNotEmpty(list)) {
for (let i = 0, length = list.length; i < length; i++) {
if (list[i] && list[i]?.[identityField] === identity) {
return list[i];
}
}
}
return null;
}
/**
* Find the entity in list by id
* @param {string} key
* @param {string} entityType
* @return {Entity|null}
*/
getEntity(key: string, entityType: string): Entity {
return this._getEntityByField(key, entityType, 'key');
}
/**
* Find the entity in list by keys
* @param {Array<string>} keys
* @param {string} entityType
* @return {Array<Entity>}
*/
getEntities(keys: Array<string>, entityType: string): Array<Entity> {
return this.getItemsByKeys(keys, entityType) as Array<Entity>;
}
/**
* Find the entity in list by id
* @param {Id} id
* @param {string} entityType
* @return {Entity|null}
*/
getEntityById(id: Id, entityType: string): Entity {
return this._getEntityByField(id, entityType, 'id');
}
/**
* Find the entity in list by ids
* @param {Array<Id>} ids
* @param {string} entityType
* @return {Array<Entity>}
*/
getEntitiesByIds(ids: Array<Id>, entityType: string): Array<Entity> {
return this.getItemsByIds(ids, entityType) as Array<Entity>;
}
/**
* Find the items in list by keys
* @param {Array<string>} keys
* @param {string} path
* @return {Array<Record<string, any>>}
*/
getItemsByKeys(
keys: Array<string>,
path: string
): Array<Record<string, any>> {
const list = this.getEntitiesList(path) as Array<Entity>;
const items = [];
if (arrayNotEmpty(list)) {
for (let i = 0, length = list.length; i < length; i++) {
if (keys.indexOf(list[i]?.key) !== -1) {
items.push(list[i]);
}
}
}
return items;
}
/**
* Find the items in list by ids
* @param {Array<Id>} ids
* @param {String} path
* @return {Array<Record<string, any>>}
*/
getItemsByIds(ids: Array<Id>, path: string): Array<Record<string, any>> {
this._loggerManager?.trace?.('DataManager.getItemsByIds()', {
ids: ids,
path: path
});
const items = [];
if (arrayNotEmpty(ids)) {
const list = this.getEntitiesList(path) as Array<Entity>;
if (arrayNotEmpty(list)) {
for (let i = 0, length = list.length; i < length; i++) {
if (ids.indexOf(list[i]?.id) !== -1) {
items.push(list[i]);
}
}
}
}
return items;
}
/**
* Find nested item
* @param {string} entityType
* @param {string|number} entityIdentity
* @param {string} subEntityType
* @param {string|number} subEntityIdentity
* @param {IdentityField} identityField
* @param {IdentityField} subIdentityField
* @return {Record<any, any> | null}
*/
getSubItem(
entityType: string,
entityIdentity: string | number,
subEntityType: string,
subEntityIdentity: string | number,
identityField: IdentityField,
subIdentityField: IdentityField
): Record<any, any> | null {
const entity = this._getEntityByField(
entityIdentity,
entityType,
identityField
);
for (const k in entity[subEntityType]) {
if (entity[subEntityType][k]?.[subIdentityField] == subEntityIdentity) {
return entity[subEntityType][k];
}
}
return null;
}
/**
* Validates data object
* @param data
* @return {boolean}
*/
isValidConfigData(data: ConfigData): boolean {
return objectNotEmpty(data) && !!data?.account_id && !!data?.project?.id;
}
}
Source