src/packageLogs.js (258 lines of code) (raw):

/* * 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. */ import { detect } from 'detect-browser'; const browser = detect(); export let logs; let config; // Interval Logging Globals let intervalID; let intervalType; let intervalPath; let intervalTimer; let intervalCounter; let intervalLog; export let filterHandler = null; export let mapHandler = null; export let cbHandlers = {}; /** * Assigns a handler to filter logs out of the queue. * @deprecated Use addCallbacks and removeCallbacks instead * @param {Function} callback The handler to invoke when logging. */ export function setLogFilter(callback) { console.warn("setLogFilter() is deprecated and will be removed in a futre release"); filterHandler = callback; } /** * Assigns a handler to transform logs from their default structure. * @deprecated Use addCallbacks and removeCallbacks instead * @param {Function} callback The handler to invoke when logging. */ export function setLogMapper(callback) { console.warn("setLogMapper() is deprecated and will be removed in a futre release"); mapHandler = callback; } /** * Adds named callbacks to be executed when logging. * @param {Object } newCallbacks An object containing named callback functions. */ export function addCallbacks(...newCallbacks) { newCallbacks.forEach((source) => { const descriptors = Object.keys(source).reduce((descriptors, key) => { descriptors[key] = Object.getOwnPropertyDescriptor(source, key); return descriptors; }, {}); Object.getOwnPropertySymbols(source).forEach((sym) => { const descriptor = Object.getOwnPropertyDescriptor(source, sym); if (descriptor.enumerable) { descriptors[sym] = descriptor; } }); Object.defineProperties(cbHandlers, descriptors); }); return cbHandlers; } /** * Removes callbacks by name. * @param {String[]} targetKeys A list of names of functions to remove. */ export function removeCallbacks(targetKeys) { targetKeys.forEach(key => { if(Object.hasOwn(cbHandlers, key)) { delete cbHandlers[key]; } }); } /** * Assigns the config and log container to be used by the logging functions. * @param {Array} newLogs Log container. * @param {Object} newConfig Configuration to use while logging. */ export function initPackager(newLogs, newConfig) { logs = newLogs; config = newConfig; cbHandlers = []; intervalID = null; intervalType = null; intervalPath = null; intervalTimer = null; intervalCounter = 0; intervalLog = null; } /** * Transforms the provided HTML event into a log and appends it to the log queue. * @param {Object} e The event to be logged. * @param {Function} detailFcn The function to extract additional log parameters from the event. * @return {boolean} Whether the event was logged. */ export function packageLog(e, detailFcn) { if (!config.on) { return false; } let details = null; if (detailFcn) { details = detailFcn(e); } const timeFields = extractTimeFields( (e.timeStamp && e.timeStamp > 0) ? config.time(e.timeStamp) : Date.now() ); let log = { 'target' : getSelector(e.target), 'path' : buildPath(e), 'pageUrl': window.location.href, 'pageTitle': document.title, 'pageReferrer': document.referrer, 'browser': detectBrowser(), 'clientTime' : timeFields.milli, 'microTime' : timeFields.micro, 'location' : getLocation(e), 'scrnRes' : getSreenRes(), 'type' : e.type, 'logType': 'raw', 'userAction' : true, 'details' : details, 'userId' : config.userId, 'toolVersion' : config.version, 'toolName' : config.toolName, 'useraleVersion': config.useraleVersion, 'sessionID': config.sessionID, }; if ((typeof filterHandler === 'function') && !filterHandler(log)) { return false; } if (typeof mapHandler === 'function') { log = mapHandler(log, e); } for (const func of Object.values(cbHandlers)) { if (typeof func === 'function') { log = func(log, e); if(!log) { return false; } } } logs.push(log); return true; } /** * Packages the provided customLog to include standard meta data and appends it to the log queue. * @param {Object} customLog The behavior to be logged. * @param {Function} detailFcn The function to extract additional log parameters from the event. * @param {boolean} userAction Indicates user behavior (true) or system behavior (false) * @return {boolean} Whether the event was logged. */ export function packageCustomLog(customLog, detailFcn, userAction) { if (!config.on) { return false; } let details = null; if (detailFcn) { details = detailFcn(); } const metaData = { 'pageUrl': window.location.href, 'pageTitle': document.title, 'pageReferrer': document.referrer, 'browser': detectBrowser(), 'clientTime' : Date.now(), 'scrnRes' : getSreenRes(), 'logType': 'custom', 'userAction' : userAction, 'details' : details, 'userId' : config.userId, 'toolVersion' : config.version, 'toolName' : config.toolName, 'useraleVersion': config.useraleVersion, 'sessionID': config.sessionID }; let log = Object.assign(metaData, customLog); if ((typeof filterHandler === 'function') && !filterHandler(log)) { return false; } if (typeof mapHandler === 'function') { log = mapHandler(log); } for (const func of Object.values(cbHandlers)) { if (typeof func === 'function') { log = func(log, null); if(!log) { return false; } } } logs.push(log); return true; } /** * Extract the millisecond and microsecond portions of a timestamp. * @param {Number} timeStamp The timestamp to split into millisecond and microsecond fields. * @return {Object} An object containing the millisecond * and microsecond portions of the timestamp. */ export function extractTimeFields(timeStamp) { return { milli: Math.floor(timeStamp), micro: Number((timeStamp % 1).toFixed(3)), }; } /** * Track intervals and gather details about it. * @param {Object} e * @return boolean */ export function packageIntervalLog(e) { const target = getSelector(e.target); const path = buildPath(e); const type = e.type; const timestamp = Math.floor((e.timeStamp && e.timeStamp > 0) ? config.time(e.timeStamp) : Date.now()); // Init - this should only happen once on initialization if (intervalID == null) { intervalID = target; intervalType = type; intervalPath = path; intervalTimer = timestamp; intervalCounter = 0; } if (intervalID !== target || intervalType !== type) { // When to create log? On transition end // @todo Possible for intervalLog to not be pushed in the event the interval never ends... intervalLog = { 'target': intervalID, 'path': intervalPath, 'pageUrl': window.location.href, 'pageTitle': document.title, 'pageReferrer': document.referrer, 'browser': detectBrowser(), 'count': intervalCounter, 'duration': timestamp - intervalTimer, // microseconds 'startTime': intervalTimer, 'endTime': timestamp, 'type': intervalType, 'logType': 'interval', 'targetChange': intervalID !== target, 'typeChange': intervalType !== type, 'userAction': false, 'userId': config.userId, 'toolVersion': config.version, 'toolName': config.toolName, 'useraleVersion': config.useraleVersion, 'sessionID': config.sessionID }; if (typeof filterHandler === 'function' && !filterHandler(intervalLog)) { return false; } if (typeof mapHandler === 'function') { intervalLog = mapHandler(intervalLog, e); } for (const func of Object.values(cbHandlers)) { if (typeof func === 'function') { intervalLog = func(intervalLog, null); if(!intervalLog) { return false; } } } logs.push(intervalLog); // Reset intervalID = target; intervalType = type; intervalPath = path; intervalTimer = timestamp; intervalCounter = 0; } // Interval is still occuring, just update counter if (intervalID == target && intervalType == type) { intervalCounter = intervalCounter + 1; } return true; } /** * Extracts coordinate information from the event * depending on a few browser quirks. * @param {Object} e The event to extract coordinate information from. * @return {Object} An object containing nullable x and y coordinates for the event. */ export function getLocation(e) { if (e.pageX != null) { return { 'x' : e.pageX, 'y' : e.pageY }; } else if (e.clientX != null) { return { 'x' : document.documentElement.scrollLeft + e.clientX, 'y' : document.documentElement.scrollTop + e.clientY }; } else { return { 'x' : null, 'y' : null }; } } /** * Extracts innerWidth and innerHeight to provide estimates of screen resolution * @return {Object} An object containing the innerWidth and InnerHeight */ export function getSreenRes() { return { 'width': window.innerWidth, 'height': window.innerHeight}; } /** * Builds a string CSS selector from the provided element * @param {HTMLElement} ele The element from which the selector is built. * @return {string} The CSS selector for the element, or Unknown if it can't be determined. */ export function getSelector(ele) { if (ele.localName) { return ele.localName + (ele.id ? ('#' + ele.id) : '') + (ele.className ? ('.' + ele.className) : ''); } else if (ele.nodeName) { return ele.nodeName + (ele.id ? ('#' + ele.id) : '') + (ele.className ? ('.' + ele.className) : ''); } else if (ele && ele.document && ele.location && ele.alert && ele.setInterval) { return "Window"; } else { return "Unknown"; } } /** * Builds an array of elements from the provided event target, to the root element. * @param {Object} e Event from which the path should be built. * @return {HTMLElement[]} Array of elements, starting at the event target, ending at the root element. */ export function buildPath(e) { if (e instanceof window.Event) { const path = e.composedPath(); return selectorizePath(path); } } /** * Builds a CSS selector path from the provided list of elements. * @param {HTMLElement[]} path Array of HTMLElements from which the path should be built. * @return {string[]} Array of string CSS selectors. */ export function selectorizePath(path) { let i = 0; let pathEle; const pathSelectors = []; while (pathEle = path[i]) { pathSelectors.push(getSelector(pathEle)); ++i; } return pathSelectors; } export function detectBrowser() { return { 'browser': browser ? browser.name : '', 'version': browser ? browser.version : '' }; }