From ae09fc79baa4aace107d1b944e5fccd2de3154f4 Mon Sep 17 00:00:00 2001 From: Nick Hallman Date: Thu, 15 Sep 2022 16:09:00 -0400 Subject: [PATCH 1/2] initial commit --- src/mixins/telemetry/TelemetryEvent.js | 14 ++++++ src/mixins/telemetry/TelemetryMixin.js | 64 ++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 src/mixins/telemetry/TelemetryEvent.js create mode 100644 src/mixins/telemetry/TelemetryMixin.js diff --git a/src/mixins/telemetry/TelemetryEvent.js b/src/mixins/telemetry/TelemetryEvent.js new file mode 100644 index 00000000..59ae5d02 --- /dev/null +++ b/src/mixins/telemetry/TelemetryEvent.js @@ -0,0 +1,14 @@ + +export class TelemetryEvent { + static dispatch(elm, action, property, value) { + elm.dispatchEvent( + new CustomEvent('d2l-telemetry-event', { + detail: { + action, + property, + value + } + }) + ) + } +} diff --git a/src/mixins/telemetry/TelemetryMixin.js b/src/mixins/telemetry/TelemetryMixin.js new file mode 100644 index 00000000..905f4044 --- /dev/null +++ b/src/mixins/telemetry/TelemetryMixin.js @@ -0,0 +1,64 @@ +// Generic of https://github.com/Brightspace/discovery-fra/blob/master/src/mixins/telemetry-mixin.js + +export const TelemetryMixin = options => superClass => class TelemetryMixinClass extends superClass { + get actions() { + return options.actions; + } + + get properties() { + return options.properties; + } + + constructor() { + super(); + const requiredKeys = [ + "sourceId", + "actions", + "properties", + "endpoint" + ]; + + if(!requiredKeys.every(key => Object.keys(options).includes(key))){ + const foundMissing = requiredKeys.find(key => !Object.keys(options).includes(key)); + throw new Error(`Telemetry options must have all required keys. Missing ${foundMissing}`); + } + if( !options.actions.every(action => typeof action === 'symbol') ) { + throw new Error(`Telemetry actions must be symbols`); + } + if( !options.properties.every(prop => typeof prop === 'symbol') ) { + throw new Error(`Telemetry properties must be symbols`); + } + + this.client = new d2lTelemetryBrowserClient.Client({ + endpoint: options.endpoint + }); + } + + _handleTelemetryEvent(e) { + const {action, property, value} = e.detail; + if (action === undefined || property === undefined) { + throw new Error('Telemetry events require an action and a property') + } + if(this.actions.includes(action) && this.properties.includes(property)) { + + const eventBody = d2lTelemetryBrowserClient.EventBody() + .setAction(action) + .setObject(encodeURIComponent(value), property, window.location.href, value); + + const event = new d2lTelemetryBrowserClient.TelemetryEvent() + .setDate(new Date()) + .setType("TelemetryEvent") + .setSourceId(options.sourceId) + .setBody(eventBody); + + this.client.logUserEvent(event); + } else { + throw new Error("Telemetry event actions and properties must be from the defined symbol list"); + } + } + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('d2l-telemetry-event', this._handleTelemetryEvent); + } +} From bd64910df2b2e55ac030a168c06c70c25759b1e8 Mon Sep 17 00:00:00 2001 From: Nick Hallman Date: Thu, 22 Sep 2022 14:28:11 -0400 Subject: [PATCH 2/2] initial push --- src/mixins/telemetry/README.md | 73 ++++++++++++++++++++++++++ src/mixins/telemetry/TelemetryMixin.js | 49 +++++++++++++++-- 2 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 src/mixins/telemetry/README.md diff --git a/src/mixins/telemetry/README.md b/src/mixins/telemetry/README.md new file mode 100644 index 00000000..45d1ef07 --- /dev/null +++ b/src/mixins/telemetry/README.md @@ -0,0 +1,73 @@ +This builds off of https://github.com/Brightspace/discovery-fra/blob/master/src/mixins/telemetry-mixin.js and uses https://github.com/Brightspace/d2l-telemetry-browser-client + +# Telemetry Mixin + +Developers can use the telemetry mixin by defining their actions, properties and sourceId in an object and passing that object to the telemetry mixin. This mixin will add a custom event listener to the element which will listen for events with the 'd2l-telementry-event' type. The TelemetryEvent is a helper object to dispatch the event which will bubble up to the Mixin and get fired. + +## Design Choices + +A bubbling event design is used to allow developers to define multiple telemetry mixins for SPA's which may have different source id's per page. For example an application where each tool is a different page may want to track different events but keep a top level telemetry mixin for navigation events. + +## Example + +#### telemetryConfig.js +```js +// symbols are used to verify that events use the same actions and properties defined in the options. +const telemetryOptions = { + sourceId: "insightsAdoption", + actions: { + filtered: Symbol('Filtered'), + focused: Symbol('Focused'), + zoomed: Symbol('Zoomed'), + drilled: Symbol('Drilled') + }, + properties: { + numRoles: Symbol('NumRoles'), + numTools: Symbol('NumTools'), + numOrgs: Symbol('NumOrgs'), + chart: Symbol('Chart') + }, + // potential optional configurations + debounce: 5000, // milliseconds + fireOnClose: false, // onUnload event dispatch instead of sending request per event + middleware: telemetryEvent => {}, // modify the final event object before it is sent to the telemetry service. +} +``` +### app.js +top level component, could be a SPA router or the application container +```js +import {telemetryConfig} from '../telemetryConfig.js' +import {TelemetryMixin} from '@d2l/telemetry' + +class AdoptionDashboard extends TelemetryMixin(telemetryOptions)(LitElement) { + render() { + return html`` + } +} +customElement.define('d2l-adoption-dashboard', AdoptionDashboard) +``` + +#### myComponent.js +```js + +import {TelemetryEvent} from '@d2l/telemetry' +import {telemetryOptions} from '../telemetryConfig.js' + +class MyComponent extends LitElement() { + + handleClick(e) { + // telemetry mixin will handle verifying that these actions match the symbols defined + // during initialization + TelemetryEvent.dispatch(this, { + action: telemetryOptions.actions.filtered, + property: telemetryOptions.properties.numRoles, + value: e.target.value + } + } + + render() { + return html`` + } +} +customElement.define('my-component', MyComponent); +``` diff --git a/src/mixins/telemetry/TelemetryMixin.js b/src/mixins/telemetry/TelemetryMixin.js index 905f4044..26610681 100644 --- a/src/mixins/telemetry/TelemetryMixin.js +++ b/src/mixins/telemetry/TelemetryMixin.js @@ -9,6 +9,14 @@ export const TelemetryMixin = options => superClass => class TelemetryMixinClass return options.properties; } + get fireOnClose() { + return options.fireOnClose; + } + + get debounce() { + return options.debounce; + } + constructor() { super(); const requiredKeys = [ @@ -22,16 +30,20 @@ export const TelemetryMixin = options => superClass => class TelemetryMixinClass const foundMissing = requiredKeys.find(key => !Object.keys(options).includes(key)); throw new Error(`Telemetry options must have all required keys. Missing ${foundMissing}`); } - if( !options.actions.every(action => typeof action === 'symbol') ) { + if(!options.actions.every(action => typeof action === 'symbol') ) { throw new Error(`Telemetry actions must be symbols`); } - if( !options.properties.every(prop => typeof prop === 'symbol') ) { + if(!options.properties.every(prop => typeof prop === 'symbol') ) { throw new Error(`Telemetry properties must be symbols`); } this.client = new d2lTelemetryBrowserClient.Client({ endpoint: options.endpoint }); + + if(this.fireOnClose === true) { + this.eventQueue = []; + } } _handleTelemetryEvent(e) { @@ -51,14 +63,45 @@ export const TelemetryMixin = options => superClass => class TelemetryMixinClass .setSourceId(options.sourceId) .setBody(eventBody); - this.client.logUserEvent(event); + if(options.middleware) { + options.middleware(event); + } + + if(!this.fireOnClose || !this.debounce || this.debounce === 0) { + this.client.logUserEvent(event); + } else { + this._storeEventAndDebounce(event) + } + } else { throw new Error("Telemetry event actions and properties must be from the defined symbol list"); } } + _storeEventAndDebounce(event) { + this.eventQueue.push(event); + if(this.debounce && this.debounce !== 0) { + if(this.debounceTimeout) clearTimeout(this.debounceTimeout); + this.debounceTimeout = setTimeout( + () => this.eventQueue.forEach(storedEvent => this.client.logUserEvent(storedEvent)), + this.debounce + ); + } + } + + _handleVisibilityChange(e) { + if(e.visibilityState === "hidden"){ + this.eventQueue.forEach(event => + this.client.logUserEvent(event) + ); + } + } + connectedCallback() { super.connectedCallback(); this.addEventListener('d2l-telemetry-event', this._handleTelemetryEvent); + if(options.fireOnClose || (options.debounce && options.debounce !== 0)) { + this.addEventListener('visibilitychange', this._handleVisibilityChange); + } } }