UNPKG

unomi-analytics

Version:

The Apache Unomi analytics.js integration.

627 lines (557 loc) 22 kB
/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; var integration = require('@segment/analytics.js-integration'); var extend = require('extend'); var Unomi = (module.exports = integration('Apache Unomi') .global('cxs') .assumesPageview() .readyOnLoad() .option('scope', 'systemscope') .option('url', 'http://localhost:8181') .option('timeoutInMilliseconds', 3000) .option('sessionCookieName', 'unomiSessionId') .option('sessionId')); /** * Initialize. * * @api public */ Unomi.prototype.initialize = function() { var self = this; console.info('[UNOMI] Initializing...', arguments); this.analytics.on('invoke', function(msg) { var action = msg.action(); var listener = 'on' + msg.action(); self.debug('%s %o', action, msg); if (self[listener]) self[listener](msg); }); this.analytics.personalize = function(personalization, callback) { this.emit('invoke', {action:function() {return "personalize"}, personalization:personalization, callback:callback}); }; // Standard to check if cookies are enabled in this browser if (!navigator.cookieEnabled) { this.executeFallback(); return; } // digitalData come from a standard so we can keep the logic around it which can allow complex website to load more complex data window.digitalData = window.digitalData || { scope: self.options.scope }; window.digitalData.page = window.digitalData.page || { path: location.pathname + location.hash, pageInfo: { pageName: document.title, pageID : location.pathname + location.hash, pagePath : location.pathname + location.hash, destinationURL: location.href } } var unomiPage = window.digitalData.page; if (!unomiPage) { unomiPage = window.digitalData.page = { pageInfo:{} } } if (self.options.initialPageProperties) { var props = self.options.initialPageProperties; this.fillPageData(unomiPage, props); } window.digitalData.events = window.digitalData.events || []; window.digitalData.events.push(this.buildEvent('view', this.buildPage(unomiPage), this.buildSource(this.options.scope, 'site'))) if (!self.options.sessionId) { var cookie = require('component-cookie'); self.sessionId = cookie(self.options.sessionCookieName); // so we should not need to implement our own if (!self.sessionId || self.sessionId === '') { self.sessionId = self.generateGuid(); cookie(self.options.sessionCookieName, self.sessionId); } } else { this.sessionId = self.options.sessionId; } setTimeout(self.loadContext.bind(self), 0); }; /** * Loaded. * * @api private * @return {boolean} */ Unomi.prototype.loaded = function() { return !!window.cxs; }; /** * Page. * * @api public * @param {Page} page */ Unomi.prototype.page = function(page) { var unomiPage = { }; this.fillPageData(unomiPage, page.json().properties); this.collectEvent(this.buildEvent('view', this.buildPage(unomiPage), this.buildSource(this.options.scope, 'site'))); }; Unomi.prototype.fillPageData = function(unomiPage, props) { unomiPage.attributes = []; unomiPage.consentTypes = []; unomiPage.interests = props.interests || {}; unomiPage.pageInfo = extend({}, unomiPage.pageInfo, props.pageInfo); unomiPage.pageInfo.pageName = unomiPage.pageInfo.pageName || props.title; unomiPage.pageInfo.pageID = unomiPage.pageInfo.pageID || props.path; unomiPage.pageInfo.pagePath = unomiPage.pageInfo.pagePath || props.path; unomiPage.pageInfo.destinationURL = unomiPage.pageInfo.destinationURL || props.url; unomiPage.pageInfo.referringURL = unomiPage.pageInfo.referringURL || props.referrer; this.processReferrer(); }; Unomi.prototype.processReferrer = function() { var referrerURL = document.referrer; if (referrerURL) { // parse referrer URL var referrer = document.createElement('a'); referrer.href = referrerURL; // only process referrer if it's not coming from the same site as the current page var local = document.createElement('a'); local.href = document.URL; if (referrer.host !== local.host) { // get search element if it exists and extract search query if available var search = referrer.search; var query = undefined; if (search && search != '') { // parse parameters var queryParams = [], param; var queryParamPairs = search.slice(1).split('&'); for (var i = 0; i < queryParamPairs.length; i++) { param = queryParamPairs[i].split('='); queryParams.push(param[0]); queryParams[param[0]] = param[1]; } // try to extract query: q is Google-like (most search engines), p is Yahoo query = queryParams.q || queryParams.p; query = decodeURIComponent(query).replace(/\+/g, ' '); } // add data to digitalData if (window.digitalData && window.digitalData.page && window.digitalData.page.pageInfo) { window.digitalData.page.pageInfo.referrerHost = referrer.host; window.digitalData.page.pageInfo.referrerQuery = query; } // register referrer event this.registerEvent(this.buildEvent('viewFromReferrer', this.buildTargetPage())); } } }; /** * Identify. * * @api public * @param {Identify} identify */ Unomi.prototype.identify = function(identify) { this.collectEvent(this.buildEvent("identify", this.buildTarget(identify.userId(), "analyticsUser", identify.traits()), this.buildSource(this.options.scope, 'site', identify.context()))); }; /** * ontrack. * * @api private * @param {Track} track */ Unomi.prototype.track = function(track) { // we use the track event name to know that we are submitted a form because Analytics.js trackForm method doesn't give // us another way of knowing that we are processing a form. if (track.event() && track.event().indexOf("form") === 0) { var form = document.forms[track.properties().formName]; var formEvent = this.buildFormEvent(form.name); formEvent.properties = this.extractFormData(form); this.collectEvent(formEvent); } else { this.collectEvent(this.buildEvent(track.event(), this.buildTargetPage(), this.buildSource(this.options.scope, 'site', track.context()), track.properties() )); } }; /** * This function is used to load the current context in the page * * @param {boolean} [skipEvents=false] Should we send the events * @param {boolean} [invalidate=false] Should we invalidate the current context */ Unomi.prototype.loadContext = function (skipEvents, invalidate) { this.contextLoaded = true; var jsonData = { requiredProfileProperties: ['j:nodename'], source: this.buildPage(window.digitalData.page) }; if (!skipEvents) { jsonData.events = window.digitalData.events } if (window.digitalData.personalizationCallback) { jsonData.personalizations = window.digitalData.personalizationCallback.map(function (x) { return x.personalization }) } jsonData.sessionId = this.sessionId; var contextUrl = this.options.url + '/context.json'; if (invalidate) { contextUrl += '?invalidateSession=true&invalidateProfile=true'; } var self = this; var onSuccess = function (xhr) { window.cxs = JSON.parse(xhr.responseText); self.ready(); if (window.digitalData.loadCallbacks) { console.info('[UNOMI] Found context server load callbacks, calling now...'); for (var i = 0; i < window.digitalData.loadCallbacks.length; i++) { window.digitalData.loadCallbacks[i](digitalData); } } if (window.digitalData.personalizationCallback) { console.info('[UNOMI] Found context server personalization, calling now...'); for (var i = 0; i < window.digitalData.personalizationCallback.length; i++) { window.digitalData.personalizationCallback[i].callback(cxs.personalizations[window.digitalData.personalizationCallback[i].personalization.id]); } } }; this.ajax({ url: contextUrl, type: 'POST', async: true, contentType: 'text/plain;charset=UTF-8', // Use text/plain to avoid CORS preflight jsonData: jsonData, dataType: 'application/json', invalidate: invalidate, success: onSuccess, error: this.executeFallback }); console.info('[UNOMI] Context loading...'); }; Unomi.prototype.onpersonalize = function (msg) { if (this.contextLoaded) { console.error('[UNOMI] Already loaded, too late...'); return; } window.digitalData = window.digitalData || { scope: this.options.scope }; window.digitalData.personalizationCallback = window.digitalData.personalizationCallback || []; window.digitalData.personalizationCallback.push({personalization: msg.personalization, callback: msg.callback}); }; /** * This function return the basic structure for an event, it must be adapted to your need * * @param {string} eventType The name of your event * @param {object} [target] The target object for your event can be build with this.buildTarget(targetId, targetType, targetProperties) * @param {object} [source] The source object for your event can be build with this.buildSource(sourceId, sourceType, sourceProperties) * @param {object} [properties] a map of properties for the event * @returns {{eventType: *, scope}} */ Unomi.prototype.buildEvent = function (eventType, target, source, properties) { var event = { eventType: eventType, scope: window.digitalData.scope }; if (target) { event.target = target; } if (source) { event.source = source; } if (properties) { event.properties = properties; } return event; }; /** * This function return an event of type form * * @param {string} formName The HTML name of id of the form to use in the target of the event * @returns {*|{eventType: *, scope, source: {scope, itemId: string, itemType: string, properties: {}}, target: {scope, itemId: string, itemType: string, properties: {}}}} */ Unomi.prototype.buildFormEvent = function (formName) { return this.buildEvent('form', this.buildTarget(formName, 'form'), this.buildSourcePage()); }; /** * This function return the source object for a source of type page * * @returns {*|{scope, itemId: *, itemType: *}} */ Unomi.prototype.buildTargetPage = function () { return this.buildTarget(window.digitalData.page.pageInfo.pageID, 'page', window.digitalData.page); }; /** * This function return the source object for a source of type page * * @returns {*|{scope, itemId: *, itemType: *}} */ Unomi.prototype.buildSourcePage = function () { return this.buildSource(window.digitalData.page.pageInfo.pageID, 'page', window.digitalData.page); }; /** * This function return the source object for a source of type page * * @returns {*|{scope, itemId: *, itemType: *}} */ Unomi.prototype.buildPage = function (page) { return this.buildSource(page.pageInfo.pageID, 'page', page); }; /** * This function return the basic structure for the target of your event * * @param {string} targetId The ID of the target * @param {string} targetType The type of the target * @param {object} [targetProperties] The optional properties of the target * @returns {{scope, itemId: *, itemType: *}} */ Unomi.prototype.buildTarget = function (targetId, targetType, targetProperties) { return this.buildObject(targetId, targetType, targetProperties); }; /** * This function return the basic structure for the source of your event * * @param {string} sourceId The ID of the source * @param {string} sourceType The type of the source * @param {object} [sourceProperties] The optional properties of the source * @returns {{scope, itemId: *, itemType: *}} */ Unomi.prototype.buildSource = function (sourceId, sourceType, sourceProperties) { return this.buildObject(sourceId, sourceType, sourceProperties); }; /** * This function will send an event to Apache Unomi * @param {object} event The event object to send, you can build it using this.buildEvent(eventType, target, source) * @param {function} successCallback will be executed in case of success * @param {function} errorCallback will be executed in case of error */ Unomi.prototype.collectEvent = function (event, successCallback, errorCallback) { this.collectEvents({events: [event]}, successCallback, errorCallback); }; /** * This function will send the events to Apache Unomi * * @param {object} events Javascript object { events: [event1, event2] } * @param {function} successCallback will be executed in case of success * @param {function} errorCallback will be executed in case of error */ Unomi.prototype.collectEvents = function (events, successCallback, errorCallback) { events.sessionId = this.sessionId; var data = JSON.stringify(events); this.ajax({ url: this.options.url + '/eventcollector', type: 'POST', async: true, contentType: 'text/plain;charset=UTF-8', // Use text/plain to avoid CORS preflight data: data, dataType: 'application/json', success: successCallback, error: errorCallback }); }; /*******************************/ /* Private Function under this */ /*******************************/ Unomi.prototype.registerEvent = function (event) { if (window.digitalData) { if (window.cxs) { console.error('[UNOMI] already loaded, too late...'); } else { window.digitalData.events = window.digitalData.events || []; window.digitalData.events.push(event); } } else { window.digitalData = {}; window.digitalData.events = window.digitalData.events || []; window.digitalData.events.push(event); } }; Unomi.prototype.registerCallback = function (onLoadCallback) { if (window.digitalData) { if (window.cxs) { console.info('[UNOMI] digitalData object loaded, calling on load callback immediately and registering update callback...'); if (onLoadCallback) { onLoadCallback(window.digitalData); } } else { console.info('[UNOMI] digitalData object present but not loaded, registering load callback...'); if (onLoadCallback) { window.digitalData.loadCallbacks = window.digitalData.loadCallbacks || []; window.digitalData.loadCallbacks.push(onLoadCallback); } } } else { console.info('[UNOMI] No digital data object found, creating and registering update callback...'); window.digitalData = {}; if (onLoadCallback) { window.digitalData.loadCallbacks = []; window.digitalData.loadCallbacks.push(onLoadCallback); } } }; /** * This is an utility function to generate a new UUID * * @returns {string} */ Unomi.prototype.generateGuid = function () { function s4() { return Math.floor((1 + Math.random()) * 0x10000) .toString(16) .substring(1); } return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); }; Unomi.prototype.buildObject = function (itemId, itemType, properties) { var object = { scope: window.digitalData.scope, itemId: itemId, itemType: itemType }; if (properties) { object.properties = properties; } return object; }; /** * This is an utility function to execute AJAX call * * @param {object} ajaxOptions */ Unomi.prototype.ajax = function (ajaxOptions) { var xhr = new XMLHttpRequest(); if ('withCredentials' in xhr) { xhr.open(ajaxOptions.type, ajaxOptions.url, ajaxOptions.async); xhr.withCredentials = true; } else if (typeof XDomainRequest != 'undefined') { xhr = new XDomainRequest(); xhr.open(ajaxOptions.type, ajaxOptions.url); } if (ajaxOptions.contentType) { xhr.setRequestHeader('Content-Type', ajaxOptions.contentType); } if (ajaxOptions.dataType) { xhr.setRequestHeader('Accept', ajaxOptions.dataType); } if (ajaxOptions.responseType) { xhr.responseType = ajaxOptions.responseType; } var requestExecuted = false; if (this.options.timeoutInMilliseconds !== -1) { setTimeout(function () { if (!requestExecuted) { console.error('[UNOMI] XML request timeout, url: ' + ajaxOptions.url); requestExecuted = true; if (ajaxOptions.error) { ajaxOptions.error(xhr); } } }, this.options.timeoutInMilliseconds); } xhr.onreadystatechange = function () { if (!requestExecuted) { if (xhr.readyState === 4) { if (xhr.status === 200 || xhr.status === 204 || xhr.status === 304) { if (xhr.responseText != null) { requestExecuted = true; if (ajaxOptions.success) { ajaxOptions.success(xhr); } } } else { requestExecuted = true; if (ajaxOptions.error) { ajaxOptions.error(xhr); } console.error('[UNOMI] XML request error: ' + xhr.statusText + ' (' + xhr.status + ')'); } } } }; if (ajaxOptions.jsonData) { xhr.send(JSON.stringify(ajaxOptions.jsonData)); } else if (ajaxOptions.data) { xhr.send(ajaxOptions.data); } else { xhr.send(); } }; Unomi.prototype.executeFallback = function () { console.warn('[UNOMI] execute fallback'); window.cxs = {}; for (var index in window.digitalData.loadCallbacks) { window.digitalData.loadCallbacks[index](); } if (window.digitalData.personalizationCallback) { for (var i = 0; i < window.digitalData.personalizationCallback.length; i++) { window.digitalData.personalizationCallback[i].callback([window.digitalData.personalizationCallback[i].personalization.strategyOptions.fallback]); } } }; Unomi.prototype.extractFormData = function (form) { var params = {}; for (var i = 0; i < form.elements.length; i++) { var e = form.elements[i]; if (typeof(e.name) != 'undefined') { switch (e.nodeName) { case 'TEXTAREA': case 'INPUT': switch (e.type) { case 'checkbox': var checkboxes = document.querySelectorAll('input[name="' + e.name + '"]'); if (checkboxes.length > 1) { if (!params[e.name]) { params[e.name] = []; } if (e.checked) { params[e.name].push(e.value); } } break; case 'radio': if (e.checked) { params[e.name] = e.value; } break; default: if (!e.value || e.value === '') { // ignore element if no value is provided break; } params[e.name] = e.value; } break; case 'SELECT': if (e.options && e.options[e.selectedIndex]) { if (e.multiple) { params[e.name] = []; for (var j = 0; j < e.options.length; j++) { if (e.options[j].selected) { params[e.name].push(e.options[j].value); } } } else { params[e.name] = e.options[e.selectedIndex].value; } } break; default: console.warn("[UNOMI] " + e.nodeName + " form element type not implemented and will not be tracked."); } } } return params; };