UNPKG

@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
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;