diff --git a/404.md b/404.md index 60a1bc88477..a12aba77051 100644 --- a/404.md +++ b/404.md @@ -5,6 +5,7 @@ layout: default heading_anchors: false nav_exclude: true --- +{% include ubi.html %} ## Oops, this isn't the page you're looking for. diff --git a/_includes/ubi.html b/_includes/ubi.html new file mode 100644 index 00000000000..431c1711bc9 --- /dev/null +++ b/_includes/ubi.html @@ -0,0 +1,19 @@ + + + \ No newline at end of file diff --git a/_layouts/default.html b/_layouts/default.html index 8ba6bd47032..48866a180cd 100755 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -254,11 +254,13 @@

Related articles

anchors.add().remove('.subfooter h1, .subfooter h2'); {% endif %} + + {% if site.search_enabled == false and site.use_custom_search == true %} - + {% endif %} - + diff --git a/_layouts/home.html b/_layouts/home.html index 0b13e44e23e..9ce7b62b1e5 100644 --- a/_layouts/home.html +++ b/_layouts/home.html @@ -18,7 +18,6 @@ {% include header.html %} -
{{ content }} @@ -30,7 +29,18 @@
{% include footer.html %} - + + + + + diff --git a/assets/js/listener.js b/assets/js/listener.js index 029e042419c..40ce701bc71 100644 --- a/assets/js/listener.js +++ b/assets/js/listener.js @@ -1,3 +1,5 @@ +import * as UBI from "./ubi.js"; + const yesButton = document.getElementById('yes'); const noButton = document.getElementById('no'); const numCharsLabel = document.getElementById('num-chars'); @@ -48,7 +50,7 @@ function updateTextArea() { } // calculate the number of characters remaining - counter = 350 - commentTextArea.value.length; + const counter = 350 - commentTextArea.value.length; numCharsLabel.innerText = counter + " characters left"; } @@ -68,6 +70,22 @@ function sendFeedback() { if (helpful === 'none' && comment === 'none') return; + try{ + let e = new UBI.UbiEvent('user_feedback', { + message: `Relevance: ${helpful}, Comment: ${comment}`, + event_attributes:{ + url:location.pathname, + helpful:helpful, + comment:comment + } + }); + e.message_type = 'USER'; + UBI.logEvent(e); + + } catch(e){ + console.warn(`UBI Error: ${e}`) + } + // split the comment into 100-char parts because of GA limitation on custom dimensions const commentLines = ["", "", "", ""]; for (let i = 0; i <= (comment.length - 1)/100; i++) { diff --git a/assets/js/search.js b/assets/js/search.js index 37de270ebdd..61cb024bde7 100644 --- a/assets/js/search.js +++ b/assets/js/search.js @@ -1,4 +1,7 @@ +import * as UBI from "./ubi.js"; + (() => { + document.addEventListener('DOMContentLoaded', () => { // // Search field behaviors @@ -66,6 +69,10 @@ highlightResult(e.target?.closest('.top-banner-search--field-with-results--field--wrapper--search-component--search-results--result')); }, true); + elResults.addEventListener('click', e => { + clickResult(e.target?.closest('.top-banner-search--field-with-results--field--wrapper--search-component--search-results--result')); + }, true); + const debounceInput = () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(doSearch, 300); @@ -97,6 +104,7 @@ lastQuery = query; abortPreviousCalls(); + UBI.clearCache(); elSpinner?.classList.add(CLASSNAME_SPINNING); if (!_showingResults) document.documentElement.classList.add('search-active'); @@ -118,10 +126,18 @@ if (!Array.isArray(data?.results) || data.results.length === 0) { return showNoResults(); } + const [qid, result_ids] = UBI.cacheQueryResults(data.results); + let ubi_event = makeUbiEvent('search', 'search_results', { + id:UBI.hash(query), + query:query, + result_ids:result_ids + }); + UBI.logEvent(ubi_event); + const chunks = data.results.map(result => result ? `
- + ${getBreadcrumbs(result)} ${sanitizeText(result.title || 'Unnamed Document')} @@ -206,6 +222,8 @@ const searchResultClassName = 'top-banner-search--field-with-results--field--wrapper--search-component--search-results--result'; if (!node || !_showingResults || node.classList.contains(CLASSNAME_HIGHLIGHTED)) return; + // ToDo: UBI item_hover can go here...hover, but no click implies irrelevance in results + elResults.querySelectorAll(`.${searchResultClassName}.highlighted`).forEach(el => { el.classList.remove(CLASSNAME_HIGHLIGHTED); }); @@ -247,14 +265,102 @@ } }; + const clickResult = node => { + const searchResultClassName = 'top-banner-search--field-with-results--field--wrapper--search-component--search-results--result'; + if (!node || !_showingResults) return; + + const link = node.querySelector('a'); + if(link){ + logUbiEvent('item_click', link); + } + + return true; + }; + const navToHighlightedResult = () => { const searchResultClassName = 'top-banner-search--field-with-results--field--wrapper--search-component--search-results--result'; elResults.querySelector(`.${searchResultClassName}.highlighted a[href]`)?.click?.(); }; + /** + * Find item and position clicked + * Modifies the ubi event data if the item is found + * @param {UbiEventData} ubiEvent - UBI.UbiEventData object + * @param {*} link - link clicked + * @returns + */ + const setUbiClickData = (ubiEvent, link) => { + ubiEvent.event_attributes.position = new UBI.UbiPosition({x:link.offsetLeft, y:link.offsetTop}); + + if(link.hasAttribute('id')){ + let id = link.id; + //try to find the item ordinal within the result list + let resultIds = sessionStorage.getItem('result_ids'); + if(resultIds != null && resultIds.length > 0){ + resultIds = resultIds.split(','); + let ordinal = resultIds.findIndex( i => i===id ); + //if found, ordinal starts at 1 + if(ordinal != -1){ + ubiEvent.event_attributes.position.ordinal = ordinal + 1; + if(ubiEvent.message == undefined || ubi_event.message == null){ + ubiEvent.message = `Clicked item ${ordinal+1} out of ${resultIds.length}` + } + + try{ + let searchResults = JSON.parse(sessionStorage.getItem('search_results')); + let obj = searchResults[id]; + if(obj != null){ + ubiEvent.event_attributes.object = obj; + ubiEvent.event_attributes.position.trail = getBreadcrumbs(obj); + } + }catch(e){ + console.warn(e); + } + } + } + } + return ubiEvent; + }; + + + /** + * Helper function to populate the event structure for common + * event data elements + * @param {*} name - name of the event to build + * @param {*} event_type - type of event to tease out event parameters + * @param {*} data - an object associated with the event + * @returns + */ + const makeUbiEvent = (name, event_type, data=null) => { + let e = new UBI.UbiEvent(name); + + if(name == 'search'){ + e.message_type = 'QUERY'; + e.message = data.search_term; + e.event_attributes.object = data; + } else if(name == 'item_click') { + e = setUbiClickData(e, data); + } else if(e.event_attributes.object == null){ + e.event_attributes.object = data; + } + return e; + } + + + /** + * A method to retrofit and funnel gtag logging to ubi + * @param {*} name + * @param {*} data + */ + const logUbiEvent = (name, data) => { + let event = makeUbiEvent(name, 'default', data) + UBI.logEvent(event); + }; + const recordEvent = (name, data) => { try { gtag?.('event', name, data); + logUbiEvent(name, data); } catch (e) { // Do nothing } diff --git a/assets/js/timeme.min.js b/assets/js/timeme.min.js new file mode 100644 index 00000000000..7574bc81b30 --- /dev/null +++ b/assets/js/timeme.min.js @@ -0,0 +1,2 @@ +// from https://github.com/jasonzissman/TimeMe.js/blob/master/timeme.js +(()=>{((a,b)=>{if("undefined"!=typeof module&&module.exports)return module.exports=b();return"function"==typeof define&&define.amd?void define([],()=>a.TimeMe=b()):a.TimeMe=b()})(this,()=>{let a={startStopTimes:{},idleTimeoutMs:30000,currentIdleTimeMs:0,checkIdleStateRateMs:250,isUserCurrentlyOnPage:!0,isUserCurrentlyIdle:!1,currentPageName:"default-page-name",timeElapsedCallbacks:[],userLeftCallbacks:[],userReturnCallbacks:[],trackTimeOnElement:b=>{let c=document.getElementById(b);c&&(c.addEventListener("mouseover",()=>{a.startTimer(b)}),c.addEventListener("mousemove",()=>{a.startTimer(b)}),c.addEventListener("mouseleave",()=>{a.stopTimer(b)}),c.addEventListener("keypress",()=>{a.startTimer(b)}),c.addEventListener("focus",()=>{a.startTimer(b)}))},getTimeOnElementInSeconds:b=>{let c=a.getTimeOnPageInSeconds(b);return c?c:0},startTimer:(b,c)=>{if(b||(b=a.currentPageName),void 0===a.startStopTimes[b])a.startStopTimes[b]=[];else{let c=a.startStopTimes[b],d=c[c.length-1];if(void 0!==d&&void 0===d.stopTime)return}a.startStopTimes[b].push({startTime:c||new Date,stopTime:void 0})},stopAllTimers:()=>{let b=Object.keys(a.startStopTimes);for(let c=0;c{b||(b=a.currentPageName);let d=a.startStopTimes[b];void 0===d||0===d.length||d[d.length-1].stopTime===void 0&&(d[d.length-1].stopTime=c||new Date)},getTimeOnCurrentPageInSeconds:()=>a.getTimeOnPageInSeconds(a.currentPageName),getTimeOnPageInSeconds:b=>{let c=a.getTimeOnPageInMilliseconds(b);return void 0===c?void 0:c/1e3},getTimeOnCurrentPageInMilliseconds:()=>a.getTimeOnPageInMilliseconds(a.currentPageName),getTimeOnPageInMilliseconds:b=>{let c=0,d=a.startStopTimes[b];if(void 0===d)return;let e=0;for(let a=0;a{let b=[],c=Object.keys(a.startStopTimes);for(let d=0;d{let c=parseFloat(b);if(!1===isNaN(c))a.idleTimeoutMs=1e3*b;else throw{name:"InvalidDurationException",message:"An invalid duration time ("+b+") was provided."}},setCurrentPageName:b=>{a.currentPageName=b},resetRecordedPageTime:b=>{delete a.startStopTimes[b]},resetAllRecordedPageTimes:()=>{let b=Object.keys(a.startStopTimes);for(let c=0;c{a.isUserCurrentlyIdle&&a.triggerUserHasReturned(),a.resetIdleCountdown()},resetIdleCountdown:()=>{a.isUserCurrentlyIdle=!1,a.currentIdleTimeMs=0},callWhenUserLeaves:(b,c)=>{a.userLeftCallbacks.push({callback:b,numberOfTimesToInvoke:c})},callWhenUserReturns:(b,c)=>{a.userReturnCallbacks.push({callback:b,numberOfTimesToInvoke:c})},triggerUserHasReturned:()=>{if(!a.isUserCurrentlyOnPage){a.isUserCurrentlyOnPage=!0,a.resetIdleCountdown();for(let b=0;b{if(a.isUserCurrentlyOnPage){a.isUserCurrentlyOnPage=!1;for(let b=0;b{a.timeElapsedCallbacks.push({timeInSeconds:b,callback:c,pending:!0})},checkIdleState:()=>{for(let b=0;ba.timeElapsedCallbacks[b].timeInSeconds&&(a.timeElapsedCallbacks[b].callback(),a.timeElapsedCallbacks[b].pending=!1);!1===a.isUserCurrentlyIdle&&a.currentIdleTimeMs>a.idleTimeoutMs?(a.isUserCurrentlyIdle=!0,a.triggerUserHasLeftPageOrGoneIdle()):a.currentIdleTimeMs+=a.checkIdleStateRateMs},visibilityChangeEventName:void 0,hiddenPropName:void 0,listenForVisibilityEvents:(b,c)=>{b&&a.listenForUserLeavesOrReturnsEvents(),c&&a.listForIdleEvents()},listenForUserLeavesOrReturnsEvents:()=>{"undefined"==typeof document.hidden?"undefined"==typeof document.mozHidden?"undefined"==typeof document.msHidden?"undefined"!=typeof document.webkitHidden&&(a.hiddenPropName="webkitHidden",a.visibilityChangeEventName="webkitvisibilitychange"):(a.hiddenPropName="msHidden",a.visibilityChangeEventName="msvisibilitychange"):(a.hiddenPropName="mozHidden",a.visibilityChangeEventName="mozvisibilitychange"):(a.hiddenPropName="hidden",a.visibilityChangeEventName="visibilitychange"),document.addEventListener(a.visibilityChangeEventName,()=>{document[a.hiddenPropName]?a.triggerUserHasLeftPageOrGoneIdle():a.triggerUserHasReturned()},!1),window.addEventListener("blur",()=>{a.triggerUserHasLeftPageOrGoneIdle()}),window.addEventListener("focus",()=>{a.triggerUserHasReturned()})},listForIdleEvents:()=>{document.addEventListener("mousemove",()=>{a.userActivityDetected()}),document.addEventListener("keyup",()=>{a.userActivityDetected()}),document.addEventListener("touchstart",()=>{a.userActivityDetected()}),window.addEventListener("scroll",()=>{a.userActivityDetected()}),setInterval(()=>{!0!==a.isUserCurrentlyIdle&&a.checkIdleState()},a.checkIdleStateRateMs)},websocket:void 0,websocketHost:void 0,setUpWebsocket:b=>{if(window.WebSocket&&b){let c=b.websocketHost;try{a.websocket=new WebSocket(c),window.onbeforeunload=()=>{a.sendCurrentTime(b.appId)},a.websocket.onopen=()=>{a.sendInitWsRequest(b.appId)},a.websocket.onerror=a=>{console&&console.log("Error occurred in websocket connection: "+a)},a.websocket.onmessage=a=>{console&&console.log(a.data)}}catch(a){console&&console.error("Failed to connect to websocket host. Error:"+a)}}},websocketSend:b=>{a.websocket.send(JSON.stringify(b))},sendCurrentTime:b=>{let c=a.getTimeOnCurrentPageInMilliseconds(),d={type:"INSERT_TIME",appId:b,timeOnPageMs:c,pageName:a.currentPageName};a.websocketSend(d)},sendInitWsRequest:b=>{a.websocketSend({type:"INIT",appId:b})},initialize:b=>{let c,d,e=a.idleTimeoutMs||30,f=a.currentPageName||"default-page-name",g=!0,h=!0;b&&(e=b.idleTimeoutInSeconds||e,f=b.currentPageName||f,c=b.websocketOptions,d=b.initialStartTime,!1===b.trackWhenUserLeavesPage&&(g=!1),!1===b.trackWhenUserGoesIdle&&(h=!1)),a.setIdleDurationInSeconds(e),a.setCurrentPageName(f),a.setUpWebsocket(c),a.listenForVisibilityEvents(g,h),a.startTimer(void 0,d)}};return a})}).call(this); \ No newline at end of file diff --git a/assets/js/ubi.js b/assets/js/ubi.js new file mode 100644 index 00000000000..7108ac886aa --- /dev/null +++ b/assets/js/ubi.js @@ -0,0 +1,371 @@ + +function genGuid() { + let id = '123456-insecure'; + try { + id = crypto.randomUUID(); + } + catch(error){ + console.warn('tried to generate a guid in an insecure context'); + id ='10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)); + } + return id; +}; + +export function hash(str, seed=42) { + let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; + for(let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +}; + + +/** + * In place of true authentication, this makes a hash out of the user's cookie, + * which at the moment is _ga... + * + * NOTE: if this function is called, but client_id starts with 'U-', + * the function below did not complete successfully, + * and userError() was called instead + * @returns + */ +export async function initialize(){ + let i = 1; + + try { + + if(!sessionStorage.hasOwnProperty('session_id')) { + sessionStorage.setItem('session_id', 'S-' + genGuid()); + } + + if(sessionStorage.hasOwnProperty('client_id')){ + console.log('Already initialized UBI'); + return; + } + + // currently, the only cookie is gtag's client_id et al. + if(document.cookie && document.cookie.length > 0){ + setClientId(hash(document.cookie)); + return; + } else { + //back up client_id method + userError(); + } + + } catch(error){ + console.log(error) + } +} + +/** +* Back up method to make a user individual +* Note that this is basically the same as a session id since it would +* generate each time a user lands on the site +* @returns +*/ +function userError(){ + let client_id = genGuid(); + setClientId(client_id); + return client_id; +} + +export function genQueryId(){ + const qid = 'Q-' + genGuid(); + sessionStorage.setItem('query_id', qid); + return qid; +} +export function getQueryId(){ + return sessionStorage.getItem('query_id'); +} + +/** + * Save explicitly, if conditions are right + */ +export function setQueryId(query_id){ + sessionStorage.setItem('query_id', query_id); +} + +export function clearCache() { + sessionStorage.removeItem('search_results'); + sessionStorage.removeItem('result_ids'); +} + +export function cacheQueryResults(results){ + let qid = genQueryId(); + setQueryId(qid); + + if(results.length > 0){ + let search_results = {}; + for(var res of results){ + if(!res.hasOwnProperty('id')){ + res.id = hash(res.url) + } + search_results[res.id] = res; + } + let result_ids = Object.keys(search_results); + sessionStorage.setItem('search_results', JSON.stringify(search_results)); + sessionStorage.setItem('result_ids', result_ids); + return [qid, result_ids]; + } + return [qid, []]; +} + +export function setClientId(client_id){ + sessionStorage.setItem('client_id', 'U-' + client_id); +} + +export function getClientId(){ + if(sessionStorage.hasOwnProperty('client_id')){ + return sessionStorage.getItem('client_id'); + } + return userError(); +} + +export function getSessionId(){ + if(sessionStorage.hasOwnProperty('session_id')){ + return sessionStorage.getItem('session_id'); + } + + let session_id = genGuid(); + sessionStorage.setItem('session_id', session_id); + return session_id; +} + +export function getPageId(){ + return location.pathname; +} + +function getTrail(){ + try { + var trail = sessionStorage.getItem('trail'); + if(trail && trail.length > 0){ + trail = trail.split(','); + + if (Array.isArray(trail)) + return trail; + } + } catch (ex) { /* Do nothing */ } + + return []; +} + +function setTrail(){ + var trail = getTrail(); + // No need to add the current pathname if it is already the last element in trail + if (trail.length && trail[trail.length - 1] === window.location.pathname) + return trail; + + trail = trail.concat(window.location.pathname); + sessionStorage.setItem('trail', trail); + + return trail; +} + +window.addEventListener("DOMContentLoaded", function (e) { + try{ + initialize(); + TimeMe.initialize({ + currentPageName: window.location.href, + idleTimeoutInSeconds: 5 + }); + setTrail(); + TimeMe.startTimer(window.location.pathname); + } catch(error){ + console.warn(error); + } +}); + +window.addEventListener("beforeunload", function (e) { + try{ + TimeMe.stopTimer(window.location.pathname); + logDwellTime('page_exit', window.location.pathname, + TimeMe.getTimeOnPageInSeconds(window.location.pathname)); + } catch(error){ + console.warn(error); + } +}); + +export async function logDwellTime(action_name, page, seconds){ + let e = new UbiEvent(action_name, { + message:`On page ${page} for ${seconds} seconds`, + event_attributes:{dwell_time:seconds}, + data_object:TimeMe + }); + logEvent(e); +} + + +export async function logUbiMessage(event_type, message_type, message){ + let e = new UbiEvent(event_type, { + message_type:message_type, + message:message + }); + logEvent(e); +} +//expose globally with Ubi moniker @see: ubi.html +window.logUbiMessage = logUbiMessage; + +/** + * + * @param {UbiEvent} event + */ +export async function logEvent(event){ + try { + //=>146.190.147.150 + //w.i.p. dev + fetch('http://localhost:9200/ubi_events/_doc', { + //fetch('http://146.190.147.150:9200/ubi_events/_doc', { + method: 'POST', + headers: { + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json' + }, + body: event.toJson() + }).then(res => res.json()) + .then(res => console.log(res)) + .catch((error) => { + console.warn(error) + }); + } catch (e) { + console.warn('Ubi error: ' + JSON.stringify(e)); + } +} + +/********************************************************************************************* + * Ubi Event data structures + * The following structures help ensure adherence to the UBI event schema + *********************************************************************************************/ + +export class UbiEventData { + constructor(object_type, id=null, description=null, details=null) { + this.object_id_field = object_type; + this.object_id = id; + this.description = description; + this.object_detail = details; + } +} +export class UbiPosition{ + constructor({ordinal=null, x=null, y=null, trail=null}={}) { + this.ordinal = ordinal; + this.x = x; + this.y = y; + if(trail) + this.trail = trail; + else { + const trail = getTrail(); + if(trail && trail.length > 0) + this.trail = trail; + } + } +} + + +export class UbiEventAttributes { + /** + * Tries to prepopulate common event attributes + * The developer can add an `object` that the user interacted with and + * the site `position` information relevant to the event + * + * Attributes, other than `object` or `position` can be added in the form: + * attributes['item1'] = 1 + * attributes['item2'] = '2' + * + * @param {*} attributes: object with general event attributes + * @param {*} object: the data object the user interacted with + * @param {*} position: the site position information + */ + constructor({attributes={}, object=null, position=null}={}) { + if(attributes != null){ + Object.assign(this, attributes); + } + if(object != null && Object.keys(object).length > 0){ + this.object = object; + } + if(position != null && Object.keys(position).length > 0){ + this.position = position; + } + this.setDefaultValues(); + } + + setDefaultValues(){ + try{ + if(!this.hasOwnProperty('dwell_time') && typeof TimeMe !== 'undefined'){ + this.dwell_time = TimeMe.getTimeOnPageInSeconds(window.location.pathname); + } + + if(!this.hasOwnProperty('browser')){ + this.browser = window.navigator.userAgent; + } + + if(!this.hasOwnProperty('page_id')){ + this.page_id = window.location.pathname; + } + if(!this.hasOwnProperty('session_id')){ + this.session_id = getSessionId(); + } + + if(!this.hasOwnProperty('page_id')){ + this.page_id = getPageId(); + } + + + if(!this.hasOwnProperty('position') || this.position == null){ + const trail = getTrail(); + if(trail.length > 0){ + this.position = new UbiPosition({trail:trail}); + } + } + // ToDo: set IP + } + catch(error){ + console.log(error); + } + } +} + + + +export class UbiEvent { + constructor(action_name, {message_type='INFO', message=null, event_attributes={}, data_object={}}={}) { + this.action_name = action_name; + this.client_id = getClientId(); + this.query_id = getQueryId(); + this.timestamp = Date.now(); + + this.message_type = message_type; + if( message ) + this.message = message; + + this.event_attributes = new UbiEventAttributes({attributes:event_attributes, object:data_object}); + } + + /** + * Use to suppress null objects in the json output + * @param key + * @param value + * @returns + */ + static replacer(key, value){ + if(value == null || + (value.constructor == Object && Object.keys(value).length === 0)) { + return undefined; + } + return value; + } + + /** + * + * @returns json string + */ + toJson() { + return JSON.stringify(this, UbiEvent.replacer); + } +}