@applicaster/zapp-react-native-utils
Version:
Applicaster Zapp React Native utilities package
318 lines (258 loc) • 8.6 kB
text/typescript
import * as R from "ramda";
import { isEmptyOrNil } from "@applicaster/zapp-react-native-utils/cellUtils";
import { ContextKeysManager } from "@applicaster/zapp-react-native-utils/appUtils/contextKeysManager";
import { log_error, log_verbose, log_warning } from "./logger";
export type Params = {
[targetParamName: string]: ParamRule; // need to check
};
export type ParamRule = { optional?: boolean | null; source?: string | null };
export type EventParams = {
[key: string]: string;
};
export enum Strategy {
allowUnlisted = "allowUnlisted",
warnUnlisted = "warnUnlisted",
blockUnlisted = "blockUnlisted",
}
enum sourceType {
default = "", // just rename the input param
ctx = "ctx", // create new key and and pull value from storages using dot namespace notation
analyticsCustomProperties = "analyticsCustomProperties", // create new key for analyticsCustomProperties
}
// Not sure I get this enum that takes an arg of (prefix)
const ValueSourceType = (prefix: string | null = null) => {
switch (prefix) {
case "ctx":
return sourceType.ctx;
case "analyticsCustomProperties":
return sourceType.analyticsCustomProperties;
default:
return sourceType.default;
}
};
class StateHolder {
public pluckedParams: Map<string, any>;
private _cap: Map<string, any> | null | undefined = undefined;
private event: AnalyticsEvent;
constructor(event: AnalyticsEvent) {
this.event = event;
this.pluckedParams = R.clone(event.params); // deep clone, no references
}
public popValue(key: string, required: boolean): string | null {
const value = this.pluckedParams[key];
if (isEmptyOrNil(value)) {
if (required) {
log_error(`Event ${this.event.name} has missing parameter ${key}`);
}
return null;
}
delete this.pluckedParams[key];
return value as string;
}
private acp(prefix: string, suffix: string): string | null {
if (this._cap === undefined) {
this._cap = this.pluckedParams[prefix]
? JSON.parse(this.pluckedParams[prefix])
: null;
delete this.pluckedParams[prefix];
}
return this._cap?.[suffix];
}
private async getFromLocalAndSession(
key: string,
namespace: string | null = null
): Promise<string | null> {
const args = namespace ? { key, namespace } : key;
const value = await ContextKeysManager.instance.getKey(args);
return value?.toString() || null;
}
private async resolveContext(
context: string,
compositeKey: string,
required: boolean
): Promise<string | null> {
if (ValueSourceType(context) !== context) {
log_error(
`Event ${this.event.name} failed to resolve parameter ${compositeKey}, unsupported source type ${context}`
);
return null;
}
const [namespace, key] = compositeKey.includes(".")
? compositeKey.split(".")
: [null, compositeKey];
const contextValue = await this.getFromLocalAndSession(key, namespace);
if (required && contextValue === null) {
log_error(
`Event ${this.event.name} failed to resolve parameter ${compositeKey}`
);
return null;
}
return contextValue;
}
public async resolveSource(
source: string,
required: boolean
): Promise<string | null> {
const CTX_DELIM = "/";
// maybe use find
const ctx = source.indexOf(CTX_DELIM);
const prefix = source.substring(0, ctx);
const suffix = source.substring(ctx + 1);
if (prefix === sourceType.analyticsCustomProperties) {
return this.acp(prefix, suffix);
} else if (ctx === -1) {
return this.popValue(source, required);
} else {
return await this.resolveContext(
source.substring(0, ctx),
source.substring(ctx + 1),
required
);
}
}
}
export type EventRule = {
event?: string | null;
ignore?: boolean | null;
params?: Params | null;
rename: string | null;
excludeParams?: String[] | null;
regex?: string | null;
matcher?: RegExp | null;
strategy?: string | null;
};
export type MapperConfiguration = {
rules: EventRule[];
strategy: string;
};
export type AnalyticsEvent = {
name: string;
params: EventParams;
};
export class Mapper {
private configuration: MapperConfiguration | null = null;
setConfiguration(config: MapperConfiguration) {
this.configuration = R.clone(config);
const rules = this.configuration?.rules;
Array.isArray(rules) &&
rules.forEach((rule) => {
rule.matcher = (rule.regex && new RegExp(rule.regex, "gi")) || null;
});
}
async map(event: AnalyticsEvent): Promise<AnalyticsEvent | null> {
const rules = this.configuration?.rules || null;
if (isEmptyOrNil(rules)) {
log_verbose(
"No analytics mapping rules found, skipping.",
this.configuration
);
return event;
}
const rule = rules!.find((r) =>
r.matcher ? r.matcher.test(event.name) : r.event === event.name
);
if (rule) {
return await this.applyEventRule(rule, event, rule.matcher);
}
switch (this.configuration!.strategy) {
case Strategy.allowUnlisted:
return event;
case Strategy.warnUnlisted:
log_warning(`Unlisted event ${event.name}`, event);
return event;
case Strategy.blockUnlisted:
return null;
default:
log_error(
`Unsupported strategy was provided: ${this.configuration!.strategy}`
);
return event;
}
}
private async applyEventRule(
rule: EventRule,
event: AnalyticsEvent,
matcher?: RegExp | null
): Promise<AnalyticsEvent | null> {
if (rule.ignore) {
return null;
}
const excludedKeys = rule?.excludeParams ? rule?.excludeParams : [];
const state = new StateHolder(event);
// remove excluded keys
// Omit accepts null values just requires it to be an array
state.pluckedParams = R.omit(excludedKeys, state.pluckedParams);
// We could also use object since benefits of map not really used here
const resultParams = new Map<string, any>();
// perform param mapping
rule.params &&
(await Promise.all(
Object.entries(rule.params).map(async (entry) => {
const [key, val] = entry;
// apply rule
const modifiedValue = await this.applyParamRule(
key, // outname: string
val, // rule: rule value?
state // original params: map?
);
// assign rule
if (!isEmptyOrNil(modifiedValue)) {
resultParams.set(key, modifiedValue);
}
})
));
let params = resultParams.size ? Object.fromEntries(resultParams) : {};
// deal with the remaining params
if (!isEmptyOrNil(state.pluckedParams)) {
if (rule.strategy) {
switch (rule.strategy) {
case Strategy.allowUnlisted:
params = { ...state.pluckedParams, ...params };
break;
case Strategy.warnUnlisted:
log_warning(
`Event ${event.name} has excess params`,
state.pluckedParams
);
params = { ...state.pluckedParams, ...params };
break;
// block
case Strategy.blockUnlisted:
break;
}
}
}
// rename if needed and return
if (isEmptyOrNil(rule.rename)) {
return this.AnalyticsEvent(event.name, params);
}
if (isEmptyOrNil(matcher)) {
return this.AnalyticsEvent(rule.rename!, params);
} else {
// If we have a regex value
// Check if event name matches regex pattern
// if so return renamed event
// If we wanted to use regex groups like this "Screen viewed: (?<screen>.*)"
// and utilize the <screen> group we have to use
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Groups_and_Ranges
const newName = !isEmptyOrNil(matcher!.test(event.name))
? event.name.replace(matcher!, rule.rename!)
: rule.rename;
return this.AnalyticsEvent(newName!, params);
}
}
AnalyticsEvent(name: string, params: EventParams): AnalyticsEvent {
return { name, params };
}
private async applyParamRule(
outName: string,
rule: ParamRule,
state: StateHolder
): Promise<string | null> {
if (isEmptyOrNil(rule.source)) {
return state.popValue(outName, rule.optional === false);
} else {
return await state.resolveSource(rule.source!, rule.optional === false);
}
}
}