@mparticle/web-sdk
Version:
mParticle core SDK for web applications
533 lines (475 loc) • 22 kB
text/typescript
import { convertEvent } from './sdkToEventsApiConverter';
import { SDKEvent, MParticleWebSDK, KitBlockerDataPlan, SDKProduct } from './sdkRuntimeModels';
import { BaseEvent, EventTypeEnum, CommerceEvent, ScreenViewEvent, CustomEvent } from '@mparticle/event-models';
import Types from './types'
import { DataPlanPoint } from '@mparticle/data-planning-models';
import { IMParticleWebSDKInstance } from './mp-instance';
/*
TODO: Including this as a workaround because attempting to import it from
@mparticle/data-planning-models directly creates a build error.
*/
const DataPlanMatchType = {
ScreenView: "screen_view",
CustomEvent: "custom_event",
Commerce: "commerce",
UserAttributes: "user_attributes",
UserIdentities: "user_identities",
ProductAction: "product_action",
PromotionAction: "promotion_action",
ProductImpression: "product_impression"
}
/*
inspiration from https://github.com/mParticle/data-planning-node/blob/master/src/data_planning/data_plan_event_validator.ts
but modified to only include commerce events, custom events, screen views, and removes validation
The purpose of the KitBlocker class is to parse a data plan and determine what events, event/user/product attributes, and user identities should be blocked from downstream forwarders.
KitBlocker is instantiated with a data plan on mParticle initialization. KitBlocker.kitBlockingEnabled is false if no data plan is passed.
It parses the data plan by creating a `dataPlanMatchLookups` object in the following manner:
1. For all events and user attributes/identities, it generates a `matchKey` in the shape of `typeOfEvent:eventType:nameOfEvent`
a. The matchKeys' value will return `true` if additionalProperties for the custom attributes/identities is `true`, otherwise it will return an object of planned attribute/identities
2. For commerce events, after step 1 and 1a, a second `matchKey` is included that appends `Products`. This is used to determine productAttributes blocked
When an event is logged in mParticle, it is sent to our server and then calls `KitBlocker.createBlockedEvent` before passing the event to each forwarder.
If the event is blocked, it will not send to the forwarder. If the event is not blocked, event/user/product attributes and user identities will be removed from the returned event if blocked.
*/
export default class KitBlocker {
dataPlanMatchLookups: { [key: string]: {} } = {};
blockEvents = false;
blockEventAttributes = false;
blockUserAttributes = false;
blockUserIdentities = false;
kitBlockingEnabled = false;
mpInstance: IMParticleWebSDKInstance;
constructor(dataPlan: KitBlockerDataPlan, mpInstance: IMParticleWebSDKInstance) {
// if data plan is not requested, the data plan is {document: null}
if (dataPlan && !dataPlan.document) {
this.kitBlockingEnabled = false;
return;
}
this.kitBlockingEnabled = true;
this.mpInstance = mpInstance;
this.blockEvents = dataPlan?.document?.dtpn?.blok?.ev;
this.blockEventAttributes = dataPlan?.document?.dtpn?.blok?.ea;
this.blockUserAttributes = dataPlan?.document?.dtpn?.blok?.ua;
this.blockUserIdentities = dataPlan?.document?.dtpn?.blok?.id;
const versionDocument = dataPlan?.document?.dtpn?.vers?.version_document;
const dataPoints = versionDocument?.data_points;
if (versionDocument) {
try {
if (dataPoints?.length > 0) {
dataPoints.forEach(point => this.addToMatchLookups(point));
}
}
catch(e) {
this.mpInstance.Logger.error('There was an issue with the data plan: ' + e);
}
}
}
addToMatchLookups(point: DataPlanPoint) {
if (!point.match || !point.validator) {
this.mpInstance.Logger.warning(`Data Plan Point is not valid' + ${point}`);
return;
}
// match keys for non product custom attribute related data points
let matchKey: string = this.generateMatchKey(point.match);
let properties: null | boolean | {[key: string]: true} = this.getPlannedProperties(point.match.type, point.validator)
this.dataPlanMatchLookups[matchKey] = properties;
// match keys for product custom attribute related data points
if (point?.match?.type === DataPlanMatchType.ProductImpression ||
point?.match?.type === DataPlanMatchType.ProductAction ||
point?.match?.type === DataPlanMatchType.PromotionAction) {
matchKey = this.generateProductAttributeMatchKey(point.match);
properties = this.getProductProperties(point.match.type, point.validator)
this.dataPlanMatchLookups[matchKey] = properties;
}
}
generateMatchKey(match): string | null {
const criteria = match.criteria || '';
switch (match.type) {
case DataPlanMatchType.CustomEvent:
const customEventCriteria = criteria;
return [
DataPlanMatchType.CustomEvent,
customEventCriteria.custom_event_type,
customEventCriteria.event_name,
].join(':');
case DataPlanMatchType.ScreenView:
const screenViewCriteria = criteria;
return [
DataPlanMatchType.ScreenView,
'',
screenViewCriteria.screen_name,
].join(':');
case DataPlanMatchType.ProductAction:
const productActionMatch = criteria;
return [match.type as string, productActionMatch.action].join(':');
case DataPlanMatchType.PromotionAction:
const promoActionMatch = criteria;
return [match.type as string, promoActionMatch.action].join(':');
case DataPlanMatchType.ProductImpression:
const productImpressionActionMatch = criteria;
return [match.type as string, productImpressionActionMatch.action].join(':');
case DataPlanMatchType.UserIdentities:
case DataPlanMatchType.UserAttributes:
return [match.type].join(':');
default:
return null;
}
}
generateProductAttributeMatchKey(match): string | null {
const criteria = match.criteria || '';
switch (match.type) {
case DataPlanMatchType.ProductAction:
const productActionMatch = criteria;
return [match.type as string, productActionMatch.action, 'ProductAttributes'].join(':');
case DataPlanMatchType.PromotionAction:
const promoActionMatch = criteria;
return [match.type as string, promoActionMatch.action, 'ProductAttributes'].join(':');
case DataPlanMatchType.ProductImpression:
return [match.type as string, 'ProductAttributes'].join(':');
default:
return null;
}
}
getPlannedProperties(type, validator): boolean | {[key: string]: true} | null {
let customAttributes;
let userAdditionalProperties;
switch (type) {
case DataPlanMatchType.CustomEvent:
case DataPlanMatchType.ScreenView:
case DataPlanMatchType.ProductAction:
case DataPlanMatchType.PromotionAction:
case DataPlanMatchType.ProductImpression:
customAttributes = validator?.definition?.properties?.data?.properties?.custom_attributes;
if (customAttributes) {
if (customAttributes.additionalProperties === true || customAttributes.additionalProperties === undefined) {
return true;
} else {
const properties = {};
for (const property of Object.keys(customAttributes.properties)) {
properties[property] = true;
}
return properties;
}
} else {
if (validator?.definition?.properties?.data?.additionalProperties === false) {
return {};
} else {
return true;
}
}
case DataPlanMatchType.UserAttributes:
case DataPlanMatchType.UserIdentities:
userAdditionalProperties = validator?.definition?.additionalProperties;
if (userAdditionalProperties === true || userAdditionalProperties === undefined) {
return true;
} else {
const properties = {};
const userProperties = validator.definition.properties
for (const property of Object.keys(userProperties)) {
properties[property] = true;
}
return properties;
}
default:
return null;
}
}
getProductProperties(type, validator): boolean | {[key: string]: true} | null {
let productCustomAttributes;
switch (type) {
case DataPlanMatchType.ProductImpression:
productCustomAttributes = validator?.definition?.properties?.data?.properties?.product_impressions?.items?.properties?.products?.items?.properties?.custom_attributes
//product item attributes
if (productCustomAttributes?.additionalProperties === false) {
const properties = {};
for (const property of Object.keys(productCustomAttributes?.properties)) {
properties[property] = true;
}
return properties;
}
return true;
case DataPlanMatchType.ProductAction:
case DataPlanMatchType.PromotionAction:
productCustomAttributes = validator?.definition?.properties?.data?.properties?.product_action?.properties?.products?.items?.properties?.custom_attributes
//product item attributes
if (productCustomAttributes) {
if (productCustomAttributes.additionalProperties === false) {
const properties = {};
for (const property of Object.keys(productCustomAttributes?.properties)) {
properties[property] = true;
}
return properties;
}
}
return true;
default:
return null;
}
}
getMatchKey(eventToMatch: BaseEvent): string | null {
switch (eventToMatch.event_type) {
case EventTypeEnum.screenView:
const screenViewEvent = eventToMatch as ScreenViewEvent;
if (screenViewEvent.data) {
return [
'screen_view',
'',
screenViewEvent.data.screen_name,
].join(':');
}
return null;
case EventTypeEnum.commerceEvent:
const commerceEvent = eventToMatch as CommerceEvent;
const matchKey: string[] = [];
if (commerceEvent && commerceEvent.data) {
const {
product_action,
product_impressions,
promotion_action,
} = commerceEvent.data;
if (product_action) {
matchKey.push(DataPlanMatchType.ProductAction);
matchKey.push(product_action.action);
} else if (promotion_action) {
matchKey.push(DataPlanMatchType.PromotionAction);
matchKey.push(promotion_action.action);
} else if (product_impressions) {
matchKey.push(DataPlanMatchType.ProductImpression);
}
}
return matchKey.join(':');
case EventTypeEnum.customEvent:
const customEvent = eventToMatch as CustomEvent;
if (customEvent.data) {
return [
'custom_event',
customEvent.data.custom_event_type,
customEvent.data.event_name,
].join(':');
}
return null;
default:
return null;
}
}
getProductAttributeMatchKey(eventToMatch: BaseEvent): string | null {
switch (eventToMatch.event_type) {
case EventTypeEnum.commerceEvent:
const commerceEvent = eventToMatch as CommerceEvent;
const matchKey: string[] = [];
const {
product_action,
product_impressions,
promotion_action,
} = commerceEvent.data;
if (product_action) {
matchKey.push(DataPlanMatchType.ProductAction);
matchKey.push(product_action.action);
matchKey.push('ProductAttributes');
} else if (promotion_action) {
matchKey.push(DataPlanMatchType.PromotionAction);
matchKey.push(promotion_action.action);
matchKey.push('ProductAttributes');
} else if (product_impressions) {
matchKey.push(DataPlanMatchType.ProductImpression);
matchKey.push('ProductAttributes');
}
return matchKey.join(':');
default:
return null;
}
}
createBlockedEvent(event: SDKEvent): SDKEvent {
/*
return a transformed event based on event/event attributes,
then product attributes if applicable, then user attributes,
then the user identities
*/
try {
if (event) {
event = this.transformEventAndEventAttributes(event)
}
if (event && event.EventDataType === Types.MessageType.Commerce) {
event = this.transformProductAttributes(event);
}
if (event) {
event = this.transformUserAttributes(event);
event = this.transformUserIdentities(event);
}
return event;
} catch(e) {
return event;
}
}
transformEventAndEventAttributes(event: SDKEvent): SDKEvent {
const clonedEvent = {...event};
const baseEvent: BaseEvent = convertEvent(clonedEvent);
const matchKey: string = this.getMatchKey(baseEvent);
const matchedEvent = this.dataPlanMatchLookups[matchKey];
if (this.blockEvents) {
/*
If the event is not planned, it doesn't exist in dataPlanMatchLookups
and should be blocked (return null to not send anything to forwarders)
*/
if (!matchedEvent) {
return null;
}
}
if (this.blockEventAttributes) {
/*
matchedEvent is set to `true` if additionalProperties is `true`
otherwise, delete attributes that exist on event.EventAttributes
that aren't on
*/
if (matchedEvent === true) {
return clonedEvent;
}
if (matchedEvent) {
for (const key of Object.keys(clonedEvent.EventAttributes)) {
if (!matchedEvent[key]) {
delete clonedEvent.EventAttributes[key];
}
}
return clonedEvent;
} else {
return clonedEvent;
}
}
return clonedEvent;
}
transformProductAttributes(event: SDKEvent): SDKEvent {
const clonedEvent = {...event};
const baseEvent: BaseEvent = convertEvent(clonedEvent);
const matchKey: string = this.getProductAttributeMatchKey(baseEvent);
const matchedEvent = this.dataPlanMatchLookups[matchKey];
function removeAttribute(matchedEvent: { [key: string]: string }, productList: SDKProduct[]): void {
productList.forEach(product => {
for (const productKey of Object.keys(product.Attributes)) {
if (!matchedEvent[productKey]) {
delete product.Attributes[productKey];
}
}
});
}
if (this.blockEvents) {
/*
If the event is not planned, it doesn't exist in dataPlanMatchLookups
and should be blocked (return null to not send anything to forwarders)
*/
if (!matchedEvent) {
return null;
}
}
if (this.blockEventAttributes) {
/*
matchedEvent is set to `true` if additionalProperties is `true`
otherwise, delete attributes that exist on event.EventAttributes
that aren't on
*/
if (matchedEvent === true) {
return clonedEvent;
}
if (matchedEvent) {
switch (event.EventCategory) {
case Types.CommerceEventType.ProductImpression:
clonedEvent.ProductImpressions.forEach(impression=> {
removeAttribute(matchedEvent, impression?.ProductList)
});
break;
case Types.CommerceEventType.ProductPurchase:
removeAttribute(matchedEvent, clonedEvent.ProductAction?.ProductList)
break;
default:
this.mpInstance.Logger.warning('Product Not Supported ')
}
return clonedEvent;
} else {
return clonedEvent;
}
}
return clonedEvent;
}
transformUserAttributes(event: SDKEvent) {
const clonedEvent = {...event};
if (this.blockUserAttributes) {
/*
If the user attribute is not found in the matchedAttributes
then remove it from event.UserAttributes as it is blocked
*/
const matchedAttributes = this.dataPlanMatchLookups['user_attributes'];
if (this.mpInstance._Helpers.isObject(matchedAttributes)) {
for (const ua of Object.keys(clonedEvent.UserAttributes)) {
if (!matchedAttributes[ua]) {
delete clonedEvent.UserAttributes[ua]
}
}
}
}
return clonedEvent
}
isAttributeKeyBlocked(key: string) {
/* used when an attribute is added to the user */
if (!this.blockUserAttributes) {
return false
}
const matchedAttributes = this.dataPlanMatchLookups['user_attributes'];
// When additionalProperties is set to true, matchedAttributes
// will be a boolean, otherwise it will return an object
if (typeof matchedAttributes === 'boolean' && matchedAttributes) {
return false
}
if (typeof matchedAttributes === "object") {
if (matchedAttributes[key] === true) {
return false;
} else {
return true;
}
}
// When "Block unplanned user attributes" is enabled and "Allow unplanned user
// attributes" is also enabled in the UI, allowing unplanned user attributes will be prioritized
return false;
}
isIdentityBlocked(key: string) {
/* used when an attribute is added to the user */
if (!this.blockUserIdentities) {
return false
}
if (this.blockUserIdentities) {
const matchedIdentities = this.dataPlanMatchLookups['user_identities'];
if (matchedIdentities === true) {
return false
}
if (!matchedIdentities[key]) {
return true
}
} else {
return false
}
return false
}
transformUserIdentities(event: SDKEvent) {
/*
If the user identity is not found in matchedIdentities
then remove it from event.UserIdentities as it is blocked.
event.UserIdentities is of type [{Identity: 'id1', Type: 7}, ...]
and so to compare properly in matchedIdentities, each Type needs
to be converted to an identityName
*/
const clonedEvent = {...event};
if (this.blockUserIdentities) {
const matchedIdentities = this.dataPlanMatchLookups['user_identities'];
if (this.mpInstance._Helpers.isObject(matchedIdentities)) {
if (clonedEvent?.UserIdentities?.length) {
clonedEvent.UserIdentities.forEach((uiByType, i) => {
const identityName = Types.IdentityType.getIdentityName(
this.mpInstance._Helpers.parseNumber(uiByType.Type)
);
if (!matchedIdentities[identityName]) {
clonedEvent.UserIdentities.splice(i, 1);
}
});
}
}
}
return clonedEvent;
}
}