From e5a8d95f1ff7cb2e42918a5004d05f1136c1219c Mon Sep 17 00:00:00 2001 From: Hristo Terezov Date: Thu, 3 Jan 2019 13:54:02 +0000 Subject: [PATCH] feat(Amplitude): Integration. --- Makefile | 4 + package-lock.json | 63 +++++++- package.json | 1 + react/features/analytics/functions.js | 9 +- .../analytics/handlers/AbstractHandler.js | 67 ++++++++ .../analytics/handlers/AmplitudeHandler.js | 71 ++++++++ .../handlers/GoogleAnalyticsHandler.js | 153 ++++++++++++++++++ webpack.config.js | 6 +- 8 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 react/features/analytics/handlers/AbstractHandler.js create mode 100644 react/features/analytics/handlers/AmplitudeHandler.js create mode 100644 react/features/analytics/handlers/GoogleAnalyticsHandler.js diff --git a/Makefile b/Makefile index 44a3e22f3..40bb0d0b4 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,10 @@ deploy-appbundle: $(BUILD_DIR)/alwaysontop.min.js \ $(BUILD_DIR)/alwaysontop.min.map \ $(OUTPUT_DIR)/analytics-ga.js \ + $(BUILD_DIR)/analytics-ga.min.js \ + $(BUILD_DIR)/analytics-ga.min.map \ + $(BUILD_DIR)/analytics-amplitude.min.js \ + $(BUILD_DIR)/analytics-amplitude.min.map \ $(DEPLOY_DIR) deploy-lib-jitsi-meet: diff --git a/package-lock.json b/package-lock.json index 9bccdec9c..74ef1d006 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2191,6 +2191,15 @@ "isomorphic-fetch": "^2.2.1" } }, + "@segment/top-domain": { + "version": "3.0.0", + "resolved": "http://registry.npmjs.org/@segment/top-domain/-/top-domain-3.0.0.tgz", + "integrity": "sha1-AuWlpP1CqfbPiGsF6C8QQBKjw6c=", + "requires": { + "component-cookie": "^1.1.2", + "component-url": "^0.2.1" + } + }, "@webassemblyjs/ast": { "version": "1.7.11", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.7.11.tgz", @@ -2460,6 +2469,24 @@ "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", "dev": true }, + "amplitude-js": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/amplitude-js/-/amplitude-js-4.5.2.tgz", + "integrity": "sha512-J075hRBuhCuBqwrhmuGIXg7zCLRO6TvONTJUESpTkM1LVL5bMcTx9BczW4Hh6p6kjBQjs2fD4rNbhPs48kYZwA==", + "requires": { + "@segment/top-domain": "^3.0.0", + "blueimp-md5": "^2.10.0", + "json3": "^3.3.2", + "lodash": "^4.17.4", + "ua-parser-js": "github:amplitude/ua-parser-js#ed538f16f5c6ecd8357da989b617d4f156dcf35d" + }, + "dependencies": { + "ua-parser-js": { + "version": "github:amplitude/ua-parser-js#ed538f16f5c6ecd8357da989b617d4f156dcf35d", + "from": "github:amplitude/ua-parser-js#ed538f1" + } + } + }, "ansi": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz", @@ -3295,6 +3322,11 @@ "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==", "dev": true }, + "blueimp-md5": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.10.0.tgz", + "integrity": "sha512-EkNUOi7tpV68TqjpiUz9D9NcT8um2+qtgntmMbi5UKssVX2m/2PLqotcric0RE63pB3HPN/fjf3cKHN2ufGSUQ==" + }, "bn.js": { "version": "4.11.8", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", @@ -4070,11 +4102,39 @@ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" }, + "component-cookie": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/component-cookie/-/component-cookie-1.1.4.tgz", + "integrity": "sha512-j6rzl+vHDTowvYz7Al3V0ud84O2l4YqGdA9qMj1W1nlZ5yWi7EhOd7ZSPzWFM25gZgv2OxWh6JlJYfsz2+XYow==", + "requires": { + "debug": "2.2.0" + }, + "dependencies": { + "debug": { + "version": "2.2.0", + "resolved": "http://registry.npmjs.org/debug/-/debug-2.2.0.tgz", + "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=", + "requires": { + "ms": "0.7.1" + } + }, + "ms": { + "version": "0.7.1", + "resolved": "http://registry.npmjs.org/ms/-/ms-0.7.1.tgz", + "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg=" + } + } + }, "component-emitter": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" }, + "component-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/component-url/-/component-url-0.2.1.tgz", + "integrity": "sha1-Tk9HmcQ+rZ/TzpG1owXSICCP7kc=" + }, "compressible": { "version": "2.0.15", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.15.tgz", @@ -8247,8 +8307,7 @@ "json3": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz", - "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=", - "dev": true + "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=" }, "json5": { "version": "0.5.1", diff --git a/package.json b/package.json index 9b512f4ce..52ca74ce9 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@atlaskit/tooltip": "12.1.13", "@microsoft/microsoft-graph-client": "1.1.0", "@webcomponents/url": "0.7.1", + "amplitude-js": "4.5.2", "dropbox": "4.0.9", "i18next": "8.4.3", "i18next-browser-languagedetector": "2.0.0", diff --git a/react/features/analytics/functions.js b/react/features/analytics/functions.js index 0fdd03c36..040fc0f69 100644 --- a/react/features/analytics/functions.js +++ b/react/features/analytics/functions.js @@ -43,10 +43,15 @@ export function initAnalytics({ getState }: { getState: Function }) { const state = getState(); const config = state['features/base/config']; - const { analyticsScriptUrls, deploymentInfo, googleAnalyticsTrackingId } - = config; + const { + amplitudeAPPKey, + analyticsScriptUrls, + deploymentInfo, + googleAnalyticsTrackingId + } = config; const { group, server, user } = state['features/base/jwt']; const handlerConstructorOptions = { + amplitudeAPPKey, envType: (deploymentInfo && deploymentInfo.envType) || 'dev', googleAnalyticsTrackingId, group, diff --git a/react/features/analytics/handlers/AbstractHandler.js b/react/features/analytics/handlers/AbstractHandler.js new file mode 100644 index 000000000..6dcf5f790 --- /dev/null +++ b/react/features/analytics/handlers/AbstractHandler.js @@ -0,0 +1,67 @@ +/** + * Abstract implementation of analytics handler + */ +export default class AbstractHandler { + /** + * Creates new instance. + */ + constructor() { + this._enabled = false; + } + + /** + * Extracts a name for the event from the event properties. + * + * @param {Object} event - The analytics event. + * @returns {string} - The extracted name. + */ + _extractName(event) { + // Page events have a single 'name' field. + if (event.type === 'page') { + return event.name; + } + + const { + action, + actionSubject, + source + } = event; + + // All events have action, actionSubject, and source fields. All + // three fields are required, and often jitsi-meet and + // lib-jitsi-meet use the same value when separate values are not + // necessary (i.e. event.action == event.actionSubject). + // Here we concatenate these three fields, but avoid adding the same + // value twice, because it would only make the event's name harder + // to read. + let name = action; + + if (actionSubject && actionSubject !== action) { + name = `${actionSubject}.${action}`; + } + if (source && source !== action) { + name = `${source}.${name}`; + } + + return name; + } + + /** + * Checks if an event should be ignored or not. + * + * @param {Object} event - The event. + * @returns {boolean} + */ + _shouldIgnore(event) { + if (!event || !this._enabled) { + return true; + } + + const ignoredEvents + = [ 'e2e_rtt', 'rtp.stats', 'rtt.by.region', 'available.device', + 'stream.switch.delay', 'ice.state.changed', 'ice.duration' ]; + + // Temporary removing some of the events that are too noisy. + return ignoredEvents.indexOf(event.action) !== -1; + } +} diff --git a/react/features/analytics/handlers/AmplitudeHandler.js b/react/features/analytics/handlers/AmplitudeHandler.js new file mode 100644 index 000000000..9391f55a6 --- /dev/null +++ b/react/features/analytics/handlers/AmplitudeHandler.js @@ -0,0 +1,71 @@ +import amplitude from 'amplitude-js'; + +import { getJitsiMeetGlobalNS } from '../../base/util'; + +import AbstractHandler from './AbstractHandler'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +/** + * Analytics handler for Amplitude. + */ +class AmplitudeHandler extends AbstractHandler { + /** + * Creates new instance of the Amplitude analytics handler. + * + * @param {Object} options - + * @param {string} options.amplitudeAPPKey - The Amplitude app key required + * by the Amplitude API. + */ + constructor(options) { + super(); + + const { amplitudeAPPKey } = options; + + if (!amplitudeAPPKey) { + logger.warn( + 'Failed to initialize Amplitude handler, no tracking ID'); + + return; + } + + this._enabled = true; + + amplitude.getInstance().init(amplitudeAPPKey); + } + + /** + * Sets the Amplitude user properties. + * + * @param {Object} props - The user portperties. + * @returns {void} + */ + setUserProperties(props) { + if (this._enabled) { + amplitude.getInstance().setUserProperties(props); + } + } + + /** + * Sends an event to Amplitude. The format of the event is described + * in AnalyticsAdapter in lib-jitsi-meet. + * + * @param {Object} event - The event in the format specified by + * lib-jitsi-meet. + * @returns {void} + */ + sendEvent(event) { + if (this._shouldIgnore(event)) { + return; + } + + amplitude.getInstance().logEvent( + this._extractName(event), + event); + } +} + +const globalNS = getJitsiMeetGlobalNS(); + +globalNS.analyticsHandlers = globalNS.analyticsHandlers || []; +globalNS.analyticsHandlers.push(AmplitudeHandler); diff --git a/react/features/analytics/handlers/GoogleAnalyticsHandler.js b/react/features/analytics/handlers/GoogleAnalyticsHandler.js new file mode 100644 index 000000000..083489eb1 --- /dev/null +++ b/react/features/analytics/handlers/GoogleAnalyticsHandler.js @@ -0,0 +1,153 @@ +/* global ga */ + +import { getJitsiMeetGlobalNS } from '../../base/util'; + +import AbstractHandler from './AbstractHandler'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +/** + * Analytics handler for Google Analytics. + */ +class GoogleAnalyticsHandler extends AbstractHandler { + + /** + * Creates new instance of the GA analytics handler. + * + * @param {Object} options - + * @param {string} options.googleAnalyticsTrackingId - The GA track id + * required by the GA API. + */ + constructor(options) { + super(); + + this._userProperties = {}; + + if (!options.googleAnalyticsTrackingId) { + logger.warn( + 'Failed to initialize Google Analytics handler, no tracking ID' + ); + + return; + } + + this._enabled = true; + this._initGoogleAnalytics(options); + } + + /** + * Initializes the ga object. + * + * @param {Object} options - + * @param {string} options.googleAnalyticsTrackingId - The GA track id + * required by the GA API. + * @returns {void} + */ + _initGoogleAnalytics(options) { + /** + * TODO: Keep this local, there's no need to add it to window. + */ + /* eslint-disable */ + (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ + (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) + })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); + /* eslint-enable */ + ga('create', options.googleAnalyticsTrackingId, 'auto'); + ga('send', 'pageview'); + } + + /** + * Extracts the integer to use for a Google Analytics event's value field + * from a lib-jitsi-meet analytics event. + * + * @param {Object} event - The lib-jitsi-meet analytics event. + * @returns {number} - The integer to use for the 'value' of a Google + * analytics event, or NaN if the lib-jitsi-meet event doesn't contain a + * suitable value. + * @private + */ + _extractValue(event) { + let value = event && event.attributes && event.attributes.value; + + // Try to extract an integer from the "value" attribute. + value = Math.round(parseFloat(value)); + + return value; + } + + /** + * Extracts the string to use for a Google Analytics event's label field + * from a lib-jitsi-meet analytics event. + * + * @param {Object} event - The lib-jitsi-meet analytics event. + * @returns {string} - The string to use for the 'label' of a Google + * analytics event. + * @private + */ + _extractLabel(event) { + const { attributes = {} } = event; + const labelsArray + = Object.keys(attributes).map(key => `${key}=${attributes[key]}`); + + labelsArray.push(this._userPropertiesString); + + return labelsArray.join('&'); + } + + /** + * Sets the permanent properties for the current session. + * + * @param {Object} props - The permanent portperties. + * @returns {void} + */ + setUserProperties(props = {}) { + if (!this._enabled) { + return; + } + + // The label field is limited to 500B. We will concatenate all + // attributes of the event, except the user agent because it may be + // lengthy and is probably included from elsewhere. + const filter = [ 'user_agent', 'callstats_name' ]; + + this._userPropertiesString + = Object.keys(props) + .filter(key => filter.indexOf(key) === -1) + .map(key => `permanent_${key}=${props[key]}`) + .join('&'); + } + + /** + * This is the entry point of the API. The function sends an event to + * google analytics. The format of the event is described in + * analyticsAdapter in lib-jitsi-meet. + * + * @param {Object} event - The event in the format specified by + * lib-jitsi-meet. + * @returns {void} + */ + sendEvent(event) { + if (this._shouldIgnore(event)) { + return; + } + + const gaEvent = { + 'eventCategory': 'jitsi-meet', + 'eventAction': this._extractName(event), + 'eventLabel': this._extractLabel(event) + }; + const value = this._extractValue(event); + + if (!isNaN(value)) { + gaEvent.eventValue = value; + } + + ga('send', 'event', gaEvent); + } + +} + +const globalNS = getJitsiMeetGlobalNS(); + +globalNS.analyticsHandlers = globalNS.analyticsHandlers || []; +globalNS.analyticsHandlers.push(GoogleAnalyticsHandler); diff --git a/webpack.config.js b/webpack.config.js index 474e25165..fa80f5a84 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -133,7 +133,11 @@ module.exports = [ 'flacEncodeWorker': './react/features/local-recording/' - + 'recording/flac/flacEncodeWorker.js' + + 'recording/flac/flacEncodeWorker.js', + 'analytics-ga': + './react/features/analytics/handlers/GoogleAnalyticsHandler.js', + 'analytics-amplitude': + './react/features/analytics/handlers/AmplitudeHandler.js' } }),