@evolv-delivery/metrics
Version:
Allows Metrics (audience attributes and events) to be populated/emitted from dom, query parameters, datalayer, cookie, local storage, and session storage.
1,315 lines (1,149 loc) • 37.5 kB
JavaScript
function extractMacroDefinitions(config){
return config.apply.reduce((a,c)=>{
let {defmacros} = c;
if (defmacros){
return {...a, ...defmacros};
} else {
return a;
}
},{});
}
function applyMacros(config, macros, level){
let nextLevel = level-1;
if (level <= 0) return config;
if (Array.isArray(config)) return config.map(c=>applyMacros(c,macros, level));
if (typeof config != 'object') return config;
let {macro, ...baseConfig} = config;
let transformedConfig = Object.keys(baseConfig).reduce((a,k)=> (
{...a, [k]:applyMacros(config[k], macros, nextLevel)}
),{});
return macro
? {...macros[macro], ...transformedConfig}
: transformedConfig;
}
function prepareMacros(config){
let macros = extractMacroDefinitions(config);
let macroKeys = Object.keys(macros);
if (macroKeys.length === 0) return config;
return applyMacros(config, macros, 3);
}
function mergeConfigs(config){
var baseConfigKeys = Object.keys(config).filter(s=>/^_/.test(s));
if (baseConfigKeys.length > 0){
let topConfigKeys = Object.keys(config).filter(s=>!/^_/.test(s));
let topConfig = topConfigKeys.reduce((a,k)=>({...a, [k]: config[k]}),{});
return {
apply: baseConfigKeys.reduce((a,k)=>[...a, config[k]],[topConfig])
};
} else {
return config;
}
}
function prepareConfig(config){
return prepareMacros(mergeConfigs(config));
}
function initializeTracking(){
window.evolv.metrics = window.evolv.metrics || {executed: [], evaluating: []};
}
function trackEvaluating(metric){
window.evolv.metrics.evaluating.push(metric);
}
function trackExecuted(metric){
window.evolv.metrics.executed.push(metric);
}
function trackWarning(warn){
window.evolv.metrics.warnings = window.evolv?.metrics?.warnings || [];
window.evolv.metrics.warnings.push(warn);
}
function resetTracking(){
window.evolv.metrics = {executed: [], evaluating: []};
}
//Don't fire the same event more than once during EventInterval ms.
let eventTimestamp = {};
const EventInterval = 500;
function emitEvent(tag, metric, context){
var lastTime = eventTimestamp[tag];
var newTimeStamp = new Date().getTime();
// console.info()
if (lastTime && (lastTime > newTimeStamp-EventInterval)) return;
evolv.client.emit(tag);
trackExecuted({tag, event: metric, context});
eventTimestamp[tag] = newTimeStamp;
}
const clearsKey = [
'source',
// 'when'
];
function mergeMetric(context, metric){
let {key, apply, value, comment, combination, when, ...baseContext} = context;
if (Object.keys(metric).some(k=> clearsKey.includes(k))){
return {...baseContext, ...metric}
} else {
return {...baseContext, key, ...metric}
}
}
function parseExpression(input) {
let tokens = [];
let currentToken = '';
let inMacro = false;
let inString = false;
let inNumericExpression = false;
function nestTokens(){
let currentStack = tokens;
tokens = [];
tokens.wrapperTokens = currentStack;
currentStack.push(tokens);
}
function unestTokens(){
const nestedStack = tokens;
tokens = tokens.wrapperTokens;
delete nestedStack.wrapperTokens;
}
for (const char of input) {
if (inString) {
currentToken += char;
if (char === "'") inString = false;
} else if (char === "'") {
inString = true;
currentToken += char;
} else if (char === '(' || char === ')') {
if (currentToken) tokens.push(currentToken);
if (char === '(' && !inMacro) {
nestTokens();
inNumericExpression = true;
nestTokens();
}
if (char === ')'){
unestTokens();
if (inNumericExpression){
inNumericExpression = false;
unestTokens();
}
}
inMacro = false;
currentToken = '';
} else if (char === '.') {
if (currentToken) tokens.push(currentToken);
if (inMacro){
unestTokens();
inMacro = false;
}
currentToken = '';
} else if (char === ',') {
if (currentToken) {
tokens.push(currentToken);
} else {
tokens.push(',');
}
if (inMacro){
unestTokens();
inMacro = false;
}
currentToken = '';
} else if (char === ':') {
if (currentToken) tokens.push(currentToken);
if (inMacro){
unestTokens();
inMacro = false;
}
nestTokens();
currentToken = '' + char;
inMacro = true;
} else if ((char === '+' || char === '-' || char === '*' || char === '/')
&& !inString) {
if (currentToken) tokens.push(currentToken);
if (inNumericExpression){
unestTokens();
tokens.push(char);
nestTokens();
currentToken = '';
} else {
currentToken = '' + char;
}
} else {
currentToken += char;
}
}
if (inMacro) {
tokens.push(currentToken);
currentToken = '';
unestTokens();
}
if (currentToken) tokens.push(currentToken);
return tokens;
}
var OperatorSet = {
//array operators
join: function(context, tokens, params){
let array = context;
if (!array) return undefined;
var delim = params[0] || '|';
return array
.map(n=>processExpression(tokens, n))
.filter(x=>x)
.join(delim);
},
values: function(context, tokens, params){
let obj = context;
if (!obj) return null;
var array = Object.values(obj);
return array
},
at: function(context, tokens, params){
let array = Array.from(context);
let index = Number(params.join(''));
if (!array) return null;
return processExpression(tokens, array.at(index));
},
sum: function(context, tokens){
let array = context;
if (!array) return undefined;
return array.reduce((a,n)=>
a + (processExpression(tokens, n) || 0),
0
);
},
count: function(context, tokens){
let array = context || [];
return array.reduce((a,n)=>
a + ((processExpression(tokens, n) && 1) || 0),
0
);
},
negate: function(context, tokens){
},
filter: function(context, tokens, params){
// var array = token ? context[token] : context;
let array = context || [];
if (!array) return undefined;
let [key, value] = params;
if (value){
const valueMatch = value.match(/'(.*)'/) || value.match(/"(.*)"/);
if (valueMatch?.[1]){
value = valueMatch[1];
}
}
const valRegex = new RegExp(value );
function testValue(n){
return n[key] && !!valRegex.test(adapters.getExpressionValue(key, n))
}
const filteredArray = array.filter(testValue);
if (/^:/.test(tokens[0])){
return filteredArray;
} else {
return filteredArray
.map(n=>processExpression(tokens, n))
.filter(x=>x);
}
},
promise: function(context, tokens, param){
// var fnc = context[token];
var fnc = context;
if (!fnc) return undefined;
let noop = x=>x;
let promiseHandler = param || 'then';
return (ev,cb)=>{
if (promiseHandler === 'then'){
fnc.apply(context, [ev]).then(cb).catch(noop);
} else {
fnc.apply(context, [ev]).then(noop).catch(cb);
}
}
}
};
function tokenizeExp(exp){
return Array.isArray(exp) ? exp : exp.split('.');
}
function getDistribution(){
return Math.floor(Math.random()*100);
}
function processOperator(exp, result, tokens){
var [operatorToken, ...params] = exp;
operatorToken = operatorToken.slice(1);
var operator = OperatorSet[operatorToken];//worry about parens later
try {
if (!operator){
console.warn('Metrics expression macro not available', operatorToken);
return undefined;
}
return operator(result, tokens, params);
} catch(e){
// console.warn('fetching value failed', e);
return undefined;
}
}
function processInfix(exp, context, tokens){
try {
if (!Array.isArray(exp) ) return;
let [operand1, operator, operand2] = exp;
let p1 = processExpression(operand1, context);
let p2 = processExpression(operand2, context);
switch(operator){
case '*':
return p1 * p2;
case '/':
return p1 / p2;
case '+':
return p1 + p2;
case '-':
return p1 - p2;
}
} catch(e){
// console.warn('fetching value failed', e);
return undefined;
}
}
function processExpression(tokens, context){
var result = context || window;
let inMacro = false;
if (tokens[0] === 'window') tokens = tokens.slice(1);
while(tokens.length > 0 && result){
var token = tokens[0];
tokens = tokens.slice(1);
if (inMacro && (!Array.isArray(token) || token[0].indexOf(':') !== 0)){
return result;
}
if (Array.isArray(token)){
let list = token;
if (list[0].indexOf(':') === 0){
result = processOperator(list, result, tokens);
inMacro = true;
} else {
result = processInfix(list, result);
}
} else {
let context = result;
let params = tokens[0];
result = result[token];
if (typeof result === 'function'){
if (Array.isArray(params) && Array.isArray(params[0])){
let fnc = result;
result = fnc.apply(context, params[0] ||[]);
tokens = tokens.slice(1);
} else {
result = result.bind(context);
}
} // else it is a simple chained value
}
}
return result;
}
const adapters = {
getExpressionValue(exp, context){
let parsedExpression = parseExpression(exp);
return processExpression(parsedExpression, context);
},
setExpressionValue: function(exp, values, append){
var tokens = tokenizeExp(exp);
var key = tokens.pop();
var obj = adapters.getExpressionValue(tokens);
if (!obj) return;
if (append){
obj[key] += values;
} else {
obj[key] = values;
}
},
getFetchValue: function(url) {
let fetchData = {};
var method = fetchData.method || 'POST';
var data = fetchData.data || {};
return fetch(url, {
method: method,
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json'
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
body: JSON.stringify(data)
})
.then(function(response){return response.json();})
.then(function(response){
var data = response.data;
return data;
})
},
getCookieValue: function(name) {
var cookie = document.cookie.split(';')
.find(function(item) {return item.trim().split('=')[0] === name });
if (!cookie) return null;
return cookie.split('=')[1];
},
getLocalStorageValue: function(name) {
return localStorage.getItem(name);
},
getSessionStorageValue: function(name) {
return sessionStorage.getItem(name);
},
//#todo switch found to boolean
getDomValue: function(sel) {
return document.querySelector(sel) && 'found';
},
//#remove
getJqDomValue: function(sel) {
console.warn('Evolv Metrics warning: Support for "source": "jqdom" is deprecated and will be removed on next major release. Please use "dom" as source.');
return window.$ && ($(sel).length > 0) && 'found';
},
getQueryValue: function(name) {
try{
return new URL(location.href).searchParams.get(name);
} catch(e){ return null;}
},
getExtensionValue: function(name){
switch (name) {
case 'distribution':
return getDistribution();
case 'page':
return getDomValue('html');
default:
trackWarning({name, message:"No audience extension called"});
}
}
};
function extractNumber(val){
return typeof val === 'string'
? val.replace(/[^0-9\.]/g, '')
: val
}
function convertValue(val, type){
if (val === undefined || val === null){
return val;
}
switch(type){
case 'float': return parseFloat(extractNumber(val));
case 'int': return parseInt(extractNumber(val));
case 'number': return Number(extractNumber(val));
case 'boolean': return /^true$/i.test(val);
case 'array': return (Array.isArray(val) ? val : [val]);
default: return val.toString();
}
}
function getCookieDomain(){
const parsedDomain = location.host.match('([a-zA-Z0-9]+.[a-zA-Z0-9]+)$');
return `.${parsedDomain[0]}`;
}
function cookieStore(domain, durationInMinutes) {
if (!domain){
domain = getCookieDomain();
}
if (typeof durationInMinutes !== "number") {
durationInMinutes = 60*14;
}
return {
getItem(name){
var foundCookie = document.cookie.split(';')
.find(item => item.trim().split('=')[0] === name);
if (!foundCookie) return null;
return decodeURIComponent(foundCookie.split('=')[1]);
},
setItem(name, value){
const val = encodeURIComponent(value);
const age = durationInMinutes*60; //age is session - todo: add option
document.cookie = `${name}=${val}; max-age="${age}"; path=/; domain=${domain}`;
}
}
}
const storePrefix = 'evolv:';
const Storage = {
'session': window.sessionStorage,
'local': window.localStorage,
'cookie': cookieStore(),
'default': window.sessionStorage
};
function marshalValue(valueType, value){
switch(valueType){
case 'float': return value;
case 'int': return value;
case 'number': return value;
case 'boolean': return value;
case 'array': return JSON.stringify(value);
default: return value.toString();
}
}
function unmarshalValue(valueType, value){
if (value === null) return null;
switch(valueType){
case 'float': return parseFloat(value);
case 'int': return parseInt(value);
case 'number': return Number(value);
case 'boolean': return /^true$/i.test(value);
case 'array': return JSON.parse(value);
default: return value.toString();
}
}
function getKey(storage){
return `${storePrefix}${storage.key}`;
}
function getStore(storage){
return Storage[storage.type || 'default'];
}
function setStoreValue(storage, valueType, value){
getStore(storage).setItem(getKey(storage), marshalValue(valueType, value));
}
function getStoreValue(storage, valueType){
return unmarshalValue(valueType, getStore(storage).getItem(getKey(storage)));
}
function resolveStoreValue(storage, valueType, value, storeValue){
if (storeValue === null) return value;
let resolveWith = storage.resolveWith;
if (valueType === 'array'){
if (typeof storeValue === 'string'){
storeValue = JSON.parse(storeValue);
}
switch(resolveWith) {
case 'cached': return storeValue;
case 'new': return value;
case 'union': return [...(new Set([...storeValue, ...(Array.isArray(value) ? value : [value])]))];
case 'concat': return [...storeValue, ...(Array.isArray(value) ? value : [value])];
default: return [...(new Set([...storeValue, ...(Array.isArray(value) ? value : [value])]))];
}
} else if (valueType === 'number') {
switch(resolveWith) {
case 'max': return Math.max(storeValue, value);
case 'min': return Math.min(storeValue, value);
case 'sum': return storeValue + value;
case 'cached': return storeValue;
case 'new': return value;
default: return value;
}
} else if (valueType === 'boolean') {
switch(resolveWith) {
case 'or': return storeValue || value;
case 'and': return storeValue && value;
case 'cached': return storeValue;
case 'new': return value;
default: return value;
}
} else {
switch(resolveWith) {
case 'cached': return storeValue;
default: return value;
}
}
}
function validateStorage(storage){
if (!storage.key){
trackWarning({storage, message: 'No key for storage'});
return false;
}
return true;
}
function resolveValue(val, metric){
const storage = metric.storage;
const valueType = metric.type || 'string';
const value = (val !== undefined && val !== null)
? convertValue(val, valueType)
: val;
if (!validateStorage(storage)){
return value;
}
let storeValue = getStoreValue(storage, valueType);
let result = resolveStoreValue(storage, valueType, value, storeValue);
if (storeValue !== result) {
setStoreValue(storage, valueType, result);
}
return result;
}
function getActiveValue(source, key){
switch(source){
case 'expression': return adapters.getExpressionValue(key);
case 'on-async': return adapters.getExpressionValue(key); //delegating to expression
case 'fetch': return adapters.getFetchValue(key);
case 'dom': return adapters.getDomValue(key);
case 'jqdom': return adapters.getJqDomValue(key);
case 'cookie': return adapters.getCookieValue(key);
case 'localStorage': return adapters.getLocalStorageValue(key);
case 'sessionStorage': return adapters.getSessionStorageValue(key);
case 'query': return adapters.getQueryValue(key);
case 'extension': return adapters.getExtensionValue(key);
}
return trackWarning({metric: {source,key}, "message": `source "${source}" is invalid`});
}
function applyMap(val, metric){
let {map, match = 'first'} = metric;
function getValue(option) {
return option.default || option.value;
}
var fallback;
if (match === 'first'){
var results = map.find(function(mapOption){
if (!mapOption.when) {
return mapOption.default || mapOption.value;
}
var pattern = new RegExp(mapOption.when, 'i');
return pattern.test(val);
});
if (results){
return getValue(results);
} else {
return metric.default;
}
} else {
var results = map.filter(function(mapOption){
if (!mapOption.when) {
fallback = mapOption;
return null;
}
var pattern = new RegExp(mapOption.when,'i');
return pattern.test(val);
});
if (results.length === 0 && fallback) return getValue(fallback)
if (results.length === 1) return getValue(results[0]);
return null;
}
}
function applyCombination(val, baseMetric){
let { operator, metric } = baseMetric.combination;
let { source, key, type } = baseMetric;
let secondaryMetric = {source,key,type, ...metric};
let secondaryValue = getValue(secondaryMetric);
if (Array.isArray(val) && Array.isArray(secondaryValue)){
switch(operator){
case 'subset': return secondaryValue.every(e => val.includes(e));
case 'superset': return val.every(e => secondaryValue.includes(e));
case 'proper-subset': return secondaryValue.every(e => val.includes(e)) && secondaryValue.length != val.length;
case 'proper-superset': return val.every(e => secondaryValue.includes(e)) && val.length != secondaryValue.length;
default:
trackWarning({
metric: secondaryMetric,
"message": `operator ${operator} for combination, is invalid`
});
return val;
}
} else if (typeof val === "number" && typeof secondaryValue === "number") {
switch(operator){
case 'product': return val * secondaryValue;
case 'sum': return val + secondaryValue;
case 'min': return Math.min(val, secondaryValue);
case 'max': return Math.max(val, secondaryValue);
default:
trackWarning({
metric: secondaryMetric,
"message": `operator ${operator} for combination, is invalid`
});
return val;
}
} else {
trackWarning({
metric: secondaryMetric,
"message": `value ${val} or ${secondaryValue} is invalid for operator ${operator}`
});
return;
}
}
function getValue(metric, data){
var val = data || getActiveValue(metric.source, metric.key);
// var val = data;
let {extract, value} = metric;
if (extract){
if (extract.attribute){
var extracted = data?.getAttribute(extract.attribute);
val = extract.parse
? extracted.match(new RegExp(extract.parse,'i'))[0]
: extracted;
} else if (extract.expression){
if (typeof val !== 'function'){ //otherwise, we leave val alone
data = data || val;
data = (typeof data === "string") ? JSON.parse(data) : data;
var extracted = adapters.getExpressionValue(extract.expression, data);
val = extract.parse
? extracted.match(new RegExp(extract.parse,'i'))[0]
: extracted;
}
} else if (extract.parse && typeof val === 'string'){
var regex = new RegExp(extract.parse,'i');
var results = val.match(regex);
val = results && results[0];
} else {
trackWarning({metric, "message": "extract did not include attribute or expression"});
}
}
if (value !== undefined && value !== null && !(value === 'number' && isNaN(value))){
val = value;
}
return metric.storage && !metric.apply
? resolveValue(val, metric)
: val;
}
const NumberTypes = ['number', 'int', 'float'];
function checkRichWhen(when, val, type){
let {operator, value} = when;
val = convertValue(val, type);
if (NumberTypes.includes(type)){
switch (operator) {
case '<':
return (val < value);
case '<=':
return (val <= value);
case '>':
return (val > value);
case '>=':
return (val >= value);
default:
console.info('evolv metrics: unsupported when operator for number', when);
}
} else if (type === 'string'){
return (new RegExp(value, 'i')).test(val);
} else {
console.info('evolv metrics: complex when not operating on numbers', when);
}
return false;
}
function checkWhen(when, context, target){
if (when == null) return true;
var val = context.value;
if (val == null) {
val = getValue(context, target || context.data);
}
if (typeof when === 'object'){
return checkRichWhen(when, val, context.type)
} else if (typeof when === 'string'){
return (new RegExp(when, 'i')).test(val);
} else if (typeof when === 'boolean'){
return when === val
} else if (typeof when === 'number'){
return when === val
} else {
console.info('evolv metrics: invalid when', when);
return false
}
}
function genName(){
return `metrics-${new Date().getTime()}`;
}
let collect = null;
let mutate = null;
function isValidValue(val){
return (val !== undefined && val !== null);
}
function initializeObservables(){
var scope = window.evolv.collect.scope(genName());
collect = scope.collect;
mutate = scope.mutate;
}
function resetObservables(){
clearPoll();
mutateQueue.forEach(m=>m.revert());
mutateQueue = [];
}
function supportPolling(metric){
return (metric.poll || metric.subscribe)
&& metric.source !== 'dom'
&& metric.source !== 'query';
}
let pollingQueue = [];
function removePoll(poll){
clearInterval(poll);
pollingQueue = pollingQueue.filter(p=> p!==poll);
}
function clearPoll(){
pollingQueue.forEach(p=>clearInterval(p));
pollingQueue = [];
}
function addPoll(poll){
pollingQueue.push(poll);
}
let mutateQueue = [];
let collectCache = {};
function getMutate(metric){
let collectName = collectCache[metric.key];
if (!collectName){
collectName = genUniqueName(metric.tag);
collectCache[metric.key] = collectName;
collect(metric.key, collectName);
}
var mut = mutate(collectName);
mutateQueue.push(mut);
return mut;
}
const ExtendedEvents = {
'iframe': (metric, fnc, param) =>{
getMutate(metric).customMutation((state, el)=> {
if (param !== 'focus') return trackWarning({metric, message:`Listening to iframe:${param} not supported, did you intend iframe:focus`});
window.addEventListener('blur', function (e) {
if (document.activeElement == el) {
fnc(null,el);
}
});
});
},
'scroll': (metric, fnc, option, percent=10) =>{
let isSpeed = option === 'speed';
let threshold = Number(isSpeed ?percent :(percent || option));
let lastScrollTop = window.scrollY;
window.addEventListener("scroll", () => {
let scrollTop = window.scrollY;
let docHeight = document.body.offsetHeight;
let winHeight = window.innerHeight;
let scrollPercent = scrollTop / (docHeight - winHeight);
if (isSpeed){
let ySpeed = scrollTop - lastScrollTop;
lastScrollTop = scrollTop;
if (threshold < 0
?(threshold > ySpeed)
:(threshold < ySpeed)
){
fnc(null, window);
}
} else if (100*scrollPercent >= threshold){
fnc(null, window);
}
});
},
'mouseexit': (metric, fnc, area, distance=0) =>{
let isTop = area === 'top';
let threshold = Number(isTop ?distance :(distance || area)) || 0;
window.addEventListener('mouseout', (e)=> {
if (isTop){
if (e.clientY <= 0 || (e.clientY <= threshold && e.clientX <= 0) || (e.clientY <= threshold && e.clientX >= window.innerWidth)){
fnc(null,e.target);
}
} else if (e.clientY <= 0 || e.clientY >= window.innerHeight || e.clientX <= 0 || e.clientX >= window.innerWidth){
fnc(null,e.target);
}
});
},
'idle': (metric, fnc, param=60000)=>{
let idleTimer;
function idle(){
fnc(null, {});
}
function resetIdleTimer(){
clearTimeout(idleTimer);
idleTimer = setTimeout(idle, param);
}
['mousemove', 'touchstart', 'keydown'].forEach(event => {
window.addEventListener(event, resetIdleTimer);
});
resetIdleTimer();
},
'wait': (metric, fnc, param=5000) =>{
setTimeout(
()=>fnc(null, window),
Math.max(Number(param) - (performance?.now() || 0), 0)
);
}
};
let inc = 0;
function genUniqueName(tag=''){
return `metric-${tag}-${inc++}`
}
function defaultObservable(metric, context){
function startListening(fnc){
if (checkWhen(metric.when, context)){
var val = getValue(metric);
if (isValidValue(val) && !metric.subscribe){
fnc(val, null);
return;
}
}
if (!supportPolling(metric)){
if (isValidValue(metric.default)){
fnc(metric.default, metric.default);
}
return;
} else {
var pollingCount = 0;
var foundValue = false;
var cachedValue;
var poll = setInterval(function(){
try{
if (!checkWhen(metric.when, context)) return;
var val = getValue(metric);
pollingCount++;
if (isValidValue(val) && cachedValue !== val){
cachedValue = val;
foundValue = true;
fnc(val, val);
if (!metric?.subscribe) removePoll(poll);
}
} catch(e){trackWarning({metric, error: e, message:'metric processing exception'});}
}, metric?.subscribe?.interval || metric?.poll?.interval || 50);
addPoll(poll);
setTimeout(function(){
removePoll(poll);
if (!foundValue && metric.default) {
fnc(metric.default, metric.default);
}
}, metric?.subscribe?.duration || metric?.poll?.duration || 250);
}
}
return {
subscribe: startListening
}
}
const Observables = {
dom(metric){
function listenForDOM(fnc){
if (metric.on){
const isExtended = t=> t.includes(':');
let eventTokens = metric.on.split(' ');
let extendedEventTokens = eventTokens.filter(t=> isExtended(t));
let normalEvents = eventTokens.filter(t=> !isExtended(t));
//mutate lib can only handle one event at a time
normalEvents.forEach(ev=> getMutate(metric).listen(ev, el=> fnc(null, el.target)));
extendedEventTokens.forEach(t=>{
let tokens = t.split(':');
let extendedEvent = ExtendedEvents[tokens[0]];
if (tokens.length >= 2 && extendedEvent){
extendedEvent(metric, fnc, tokens[1], tokens[2]);
} else {
trackWarning({metric, message: `event ${t} is an invalid extended event`});
}
});
} else {
let mutation = (state, el)=> fnc(null, el);
getMutate(metric).customMutation(mutation, mutation);
}
}
return {
subscribe: listenForDOM
};
},
onAsync(metric){
function listen(fnc){
if (!metric.on ){
if (metric.data){//We already had event
return fnc(null, metric.data);
} else {
return trackWarning({metric, message: "on-async requires attribute 'on'"});
}
}
let obj = adapters.getExpressionValue(metric.key);
if (!obj.on || typeof obj.on != 'function') return trackWarning({metric, message: "on-async object from '${metric.key}' did not have method 'on'"});
function handler(){
fnc(null, {params:arguments});
}
obj.on(metric.on, handler);
}
return {
subscribe: listen
}
},
expression(metric, context){
let base = defaultObservable(metric, context);
function listen(fnc){
base.subscribe(val=>{
if (typeof val === 'function') {
let asyncFnc = val;
if (!metric.on ){
if (metric.data){//We already had event
return fnc(null, metric.data);
} else {
return trackWarning({metric, message: "async function requires attribute 'on'"});
}
}
function handler(...args){
fnc(null, {params:args});
}
asyncFnc(metric.on, handler);
} else {
//default syncrounous expression
fnc(val, val);
}
});
}
return {
subscribe: listen
}
}
};
function observeSource(metric, context={}){
const {source, key} = metric;
switch(source){
case 'dom': return Observables.dom(metric, context);
case 'expression': return Observables.expression(metric, context);
// case 'on-async': return Observables.onAsync(metric, context);
case 'on-async': return Observables.expression(
{...metric,
source: 'expression',
key: `${metric.key}.on`
},
context);
case 'extension': if (metric.key === 'page') return Observables.dom({...metric, key:"html"}, context);
default: return Observables[source]
? Observables[source](metric, context)
: defaultObservable(metric, context);
}
}
function parseTemplateString(str, context) {
var tokenize = /((\${([^}]*)})|([^${}])+)/g;
var extract = /\${([^}]*)}/;
var tokens = str.match(tokenize);
if (!context) return str;
var instantiateTokens = function (accum, str) {
var parsed = str.match(extract);
return (
accum + (parsed ? adapters.getExpressionValue(parsed[1], context) : str)
);
}.bind(this);
return tokens.reduce(instantiateTokens, "");
}
let processedMetrics = [];
function processMetric(metric, context) {
if (!checkWhen(metric.when, context)) return;
// if (processedMetrics.includes(metric)) return;
processedMetrics.push(metric);
let mergedMetric = mergeMetric(context, metric);
trackEvaluating(mergedMetric);
if (metric.apply) {
if (hasKeysChanged(mergedMetric, context)) {
connectAbstractMetric(metric, mergedMetric, context);
} else {
metric.data = context.data;
processApplyList(metric.apply, mergedMetric);
}
} else if (isComplete(mergedMetric)) {
applyConcreteMetric(mergedMetric, context);
} else if (!metric.comment) {
trackWarning({
metric: mergedMetric,
message: "Evolv Audience - Metric was incomplete: ",
});
}
}
function hasKeysChanged(metric, context) {
return metric.key && metric.key !== context.key;
}
function processApplyList(applyList, context) {
if (!Array.isArray(applyList))
return trackWarning({
applyList,
context,
message: "Evolv Audience warning: Apply list is not array",
});
applyList.forEach((metric) => processMetric(metric, context));
}
function applyConcreteMetric(metric, context) {
if (metric.action === "event") {
connectEvent(metric.tag, metric, context);
} else {
addAudience(metric.tag, metric, context);
}
}
function connectAbstractMetric(bm, metric, context) {
let observer = observeSource(metric, context);
observer.subscribe((val, data) => {
metric.data = bm.data = metric.combination ?applyCombination(data, metric) : data;
let { on, when, ...cleanedMetric } = metric;
if (!bm.key) metric.key = bm.key;
processApplyList(bm.apply, cleanedMetric);
});
}
function connectEvent(tag, metric, context) {
var fired = false;
observeSource(metric, context).subscribe((value, data) => {
if (fired) return;
fired = true;
let finalTag = parseTemplateString(tag, data);
try{
emitEvent(finalTag, metric, data, context);
} catch (e){
//evolv was not setup completly yet, so use setTimeout - this can happen during pageload events
setTimeout(() => emitEvent(finalTag, metric, data), 0);
}
});
}
function addAudience(tag, metric, context) {
try {
observeSource(metric, context).subscribe((value, data) => {
if (value === null || value === undefined) {
value = getValue(metric, data);
}
if (value !== null && value !== undefined) {
bindAudienceValue(tag, value, metric);
}
});
} catch (e) {
trackWarning({ metric, tag, message: `Unable to add audience for: ${e}` });
}
}
function bindAudienceValue(tag, val, metric) {
const audienceContext = window.evolv.context;
let newVal;
if (metric.default === val) {
newVal = val;
} else if (metric.map) {
newVal = applyMap(val, metric);
if (!newVal && (!metric.type || metric.type === "string")) return;
} else if (metric.combination) {
newVal = applyCombination(val, metric);
} else {
newVal = convertValue(val, metric.type);
}
if (audienceContext.get(tag) === newVal) return false;
audienceContext.set(tag, newVal);
trackExecuted({ tag, bind: metric, value: newVal });
return true;
}
function isComplete(metric) {
return (
!!metric.source &&
typeof metric.source === "string" &&
// && !!metric.key && typeof metric.key === 'string'
!!metric.tag &&
typeof metric.tag === "string"
);
}
function clearMetricData(baseMetric) {
function clearData(metric) {
metric.data = undefined;
(metric.apply || []).forEach(clearData);
}
processedMetrics = [];
clearData(baseMetric);
}
function instrumentSpaEvent(tag) {
window.history.pushState = ((f) =>
function pushState() {
var url = window.location.href;
var ret = f.apply(this, arguments);
if (url != window.location.href) {
window.dispatchEvent(new Event(tag));
}
return ret;
})(window.history.pushState);
window.history.replaceState = ((f) =>
function replaceState() {
var url = window.location.href;
var ret = f.apply(this, arguments);
if (url != window.location.href) {
window.dispatchEvent(new Event(tag));
}
return ret;
})(window.history.replaceState);
window.addEventListener("popstate", () => {
window.dispatchEvent(new Event(tag));
});
}
const DefaultContext = {"source": "expression", "key": "location.pathname"};
let cachedconfig = {};
function processConfig(json){
try{
initializeObservables();
initializeTracking();
if (!json) return trackWarning({json, message:'Evolv Audience warning: Apply list is not array'});
cachedconfig = prepareConfig(json);
processMetric(cachedconfig, DefaultContext);
} catch(e){
trackWarning({error:e, message:'Evolv Audience error: Unable to process config'});
}
}
function initSpaListener(){
function eventHandler(){
resetObservables();
resetTracking();
requestAnimationFrame(()=>{
clearMetricData(cachedconfig);
processMetric(cachedconfig, DefaultContext);
});
}
const SpaTag = 'evolv_metrics_spaChange';
instrumentSpaEvent(SpaTag);
window.addEventListener(SpaTag, eventHandler);
}
initSpaListener();
module.exports = processConfig;