From e51a542d57f4314ec4c192b32da47d487adebb4e Mon Sep 17 00:00:00 2001 From: Edgard Date: Fri, 17 Jun 2022 19:24:59 -0300 Subject: [PATCH] fix: Improved Google Analytics tracker --- package-lock.json | 13 +++ package.json | 1 + src/config.ts | 4 +- src/gtag.ts | 117 ------------------------- src/gtag/Tracker.ts | 205 ++++++++++++++++++++++++++++++++++++++++++++ src/gtag/index.ts | 78 +++++++++++++++++ 6 files changed, 299 insertions(+), 119 deletions(-) delete mode 100644 src/gtag.ts create mode 100644 src/gtag/Tracker.ts create mode 100644 src/gtag/index.ts diff --git a/package-lock.json b/package-lock.json index c562a6e18c..db4f920412 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "typedoc-plugin-mdn-links": "^1.0.6", "typedoc-plugin-missing-exports": "^0.22.6", "typescript": "^4.7.3", + "typescript-debounce-decorator": "^0.0.18", "webpack": "^5.73.0", "webpack-cli": "^4.10.0" }, @@ -9124,6 +9125,12 @@ "node": ">=4.2.0" } }, + "node_modules/typescript-debounce-decorator": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/typescript-debounce-decorator/-/typescript-debounce-decorator-0.0.18.tgz", + "integrity": "sha512-AaCIL9z/hHkD+x63xrjjPpRGCfYhrwtfP0E5liwnopmvCrXrb7ozcKCalnh82w9c9OM8tZqRslCwOHsKBvw1tw==", + "dev": true + }, "node_modules/uglify-js": { "version": "3.16.0", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.16.0.tgz", @@ -16594,6 +16601,12 @@ "integrity": "sha512-WOkT3XYvrpXx4vMMqlD+8R8R37fZkjyLGlxavMc4iB8lrl8L0DeTcHbYgw/v0N/z9wAFsgBhcsF0ruoySS22mA==", "dev": true }, + "typescript-debounce-decorator": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/typescript-debounce-decorator/-/typescript-debounce-decorator-0.0.18.tgz", + "integrity": "sha512-AaCIL9z/hHkD+x63xrjjPpRGCfYhrwtfP0E5liwnopmvCrXrb7ozcKCalnh82w9c9OM8tZqRslCwOHsKBvw1tw==", + "dev": true + }, "uglify-js": { "version": "3.16.0", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.16.0.tgz", diff --git a/package.json b/package.json index 25c3ebda9c..05c8702d93 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "typedoc-plugin-mdn-links": "^1.0.6", "typedoc-plugin-missing-exports": "^0.22.6", "typescript": "^4.7.3", + "typescript-debounce-decorator": "^0.0.18", "webpack": "^5.73.0", "webpack-cli": "^4.10.0" }, diff --git a/src/config.ts b/src/config.ts index 4d7d3bdde3..d81efb7238 100644 --- a/src/config.ts +++ b/src/config.ts @@ -48,7 +48,7 @@ export interface Config { /** * Google Analytics Id */ - googleAnalyticsId: string; + googleAnalyticsId: string | null; /** * Link Preview API servers @@ -60,7 +60,7 @@ export const defaultConfig: Config = { deviceName: false, liveLocationLimit: 10, disableGoogleAnalytics: false, - googleAnalyticsId: 'G-MTQ4KY110F', + googleAnalyticsId: null, linkPreviewApiServers: null, }; diff --git a/src/gtag.ts b/src/gtag.ts deleted file mode 100644 index 30a670a70e..0000000000 --- a/src/gtag.ts +++ /dev/null @@ -1,117 +0,0 @@ -/*! - * Copyright 2021 WPPConnect Team - * - * Licensed 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. - */ - -/** - * Google Analytics 4 - * @see https://www.optimizesmart.com/what-is-measurement-protocol-in-google-analytics-4-ga4/ - */ - -import { config } from './config'; -import * as conn from './conn'; -import { internalEv } from './eventEmitter'; - -declare const __VERSION__: string; -export const version = __VERSION__; - -const id = config.googleAnalyticsId; - -const endPoint = 'https://www.google-analytics.com/g/collect'; - -let session_hits = 0; - -function randomNumber() { - return Math.floor(100000000 + Math.random() * 900000000); -} - -function getUserId() { - const userId = - localStorage.getItem('cid') || - randomNumber() + '.' + Math.floor(Date.now() / 1000); - - localStorage.setItem('cid', userId); - - return userId; -} - -const eventModel: Record = { - v: '2', // Protocol Version - tid: id, // Measurement ID - sr: screen.width + 'x' + screen.height, // Screen Resolution - ul: (navigator.language || '').toLowerCase(), // User Language - cid: getUserId(), // Client ID - dl: location.href, // Document location - dr: document.referrer, // Document referrer - dt: document.querySelector('title')?.innerText || 'WhatsApp', // Document title - sid: String(Math.floor(Date.now() / 1000)), // Session ID - seg: '1', // Session Engaged - sct: '1', // Session Count - _s: '1', // Session Hits count -}; - -function sendBeacon(data: Record) { - const queryString = new URLSearchParams(data); - - navigator.sendBeacon(`${endPoint}?${queryString.toString()}`); -} - -function trackEvent( - eventName: string, - params?: Record -) { - const data: Record = { - ...eventModel, - en: eventName, // Event Name - _s: String(session_hits++), - }; - - for (const k in params) { - const v = params[k]; - - switch (typeof v) { - case 'boolean': - case 'string': - data[`ep.${k}`] = String(v); - break; - case 'number': - data[`epn.${k}`] = String(v); - break; - } - } - - sendBeacon(data); -} - -if (!config.disableGoogleAnalytics) { - internalEv.on('webpack.injected', () => { - // Add version info - eventModel['up.wa_js'] = version; - eventModel['up.whatsapp'] = (window as any).Debug?.VERSION || '-'; - - const authenticated = conn.isAuthenticated(); - - trackEvent('page_view', { authenticated }); - }); - - internalEv.on('conn.authenticated', () => { - const method = conn.isMultiDevice() ? 'multidevice' : 'legacy'; - trackEvent('login', { method }); - }); - - internalEv.on('conn.logout', () => { - const method = conn.isMultiDevice() ? 'multidevice' : 'legacy'; - trackEvent('logout', { method }); - }); -} diff --git a/src/gtag/Tracker.ts b/src/gtag/Tracker.ts new file mode 100644 index 0000000000..936a310063 --- /dev/null +++ b/src/gtag/Tracker.ts @@ -0,0 +1,205 @@ +/*! + * Copyright 2022 WPPConnect Team + * + * Licensed 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 { debounce } from 'typescript-debounce-decorator'; + +function randomNumber(min = 0, max = 2147483647) { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +/** + * [Tracker description] + * + * @see https://www.thyngster.com/ga4-measurement-protocol-cheatsheet/ + */ +export class Tracker { + static collectURL = 'https://www.google-analytics.com/g/collect'; + static pageLoadHash = randomNumber(); + + static get clientState() { + const firstVisit = !localStorage['cid']; + + const cid = + localStorage['cid'] || + sessionStorage['cid'] || + randomNumber(1000000000) + '.' + Math.floor(Date.now() / 1000); + + localStorage['cid'] = sessionStorage['cid'] = cid; + + return { firstVisit, cid }; + } + + private events: [ + string, + Record | undefined, + number + ][] = []; + private userProperties: { [key: string]: any } = {}; + + private lastTime = Date.now(); + private hitsCount = 1; + + constructor(readonly trackingId: string) {} + + private get sid() { + const sid_key = `${this.trackingId}_sid`; + + const sid = sessionStorage[sid_key] || Math.floor(Date.now() / 1000); + + sessionStorage[sid_key] = sid; + + return sid; + } + + private get sct() { + const sct_key = `${this.trackingId}_sct`; + let count = parseInt(localStorage[sct_key]); + + if (isNaN(count)) { + count = 0; + } + + localStorage[sct_key] = count + 1; + + return localStorage[sct_key]; + } + + private getHeader() { + const { cid, firstVisit } = Tracker.clientState; + + return { + v: 2, // Protocol Version + tid: this.trackingId, // Measurement ID + _p: Tracker.pageLoadHash, // Screen Resolution + cid: cid, // Client ID + _fv: firstVisit ? 1 : void 0, // Client ID + ul: (navigator.language || '').toLowerCase() || void 0, // User Language + sr: `${screen.width}x${screen.height}`, + _s: this.hitsCount++, // Session Hits count + sid: this.sid, // Session ID + sct: this.sct, // Session Count + seg: 1, // Session Engaged + dl: location.href, // Document location + dr: document.referrer, // Document referrer + dt: document.title, // Document title + // _dbg: 1, // Debug + }; + } + + /** + * Get the current user properties + */ + private getUserProperties() { + const userProperties = this.userProperties; + this.userProperties = {}; + + const data = Object.entries(userProperties) + .filter(([, v]) => typeof v !== 'undefined') + .map(([k, v]) => { + if (typeof v === 'number') { + return [`upn.${k}`, String(v)]; + } + return [`up.${k}`, String(v)]; + }); + + return data; + } + + /** + * Process all queued events + */ + @debounce(1000) + private processEvents() { + const events = this.events; + this.events = []; + + if (!events.length) { + return; + } + + const eventsData = events.map(([name, params, time]) => { + const data: [string, string][] = []; + + data.push(['en', name]); + data.push(['_ee', '1']); + + if (params) { + for (const k in params) { + const v = params[k]; + if (typeof v === 'undefined') { + continue; + } else if (typeof v === 'number') { + data.push([`epn.${k}`, String(v)]); + } else { + data.push([`ep.${k}`, String(v)]); + } + } + } + + data.push(['_et', String(time)]); + + return data; + }); + + const header = Object.entries(this.getHeader()) + .filter(([, v]) => typeof v !== 'undefined') + .map(([k, v]) => [k, String(v)]); + + header.push(...this.getUserProperties()); + + const url = new URLSearchParams(header); + + if (eventsData.length === 1) { + for (const [k, v] of eventsData[0]) { + url.append(k, v); + } + navigator.sendBeacon(`${Tracker.collectURL}?${url.toString()}`); + } else { + // Send a batch of events + const data = eventsData.map((e) => new URLSearchParams(e).toString()); + + navigator.sendBeacon( + `${Tracker.collectURL}?${url.toString()}`, + data.join('\n') + ); + } + } + + /** + * Send a user engagement each 5 minutes + */ + @debounce(300000) + private processUserEngagement() { + this.trackEvent('user_engagement'); + } + + public trackEvent( + eventName: string, + params?: Record + ) { + const now = Date.now(); + const time = now - this.lastTime; + this.lastTime = now; + + this.events.push([eventName, params, time]); + this.processEvents(); + this.processUserEngagement(); + } + + public setUserProperty(key: string, value: any) { + this.userProperties[key] = value; + } +} diff --git a/src/gtag/index.ts b/src/gtag/index.ts new file mode 100644 index 0000000000..f17bd01580 --- /dev/null +++ b/src/gtag/index.ts @@ -0,0 +1,78 @@ +/*! + * Copyright 2022 WPPConnect Team + * + * Licensed 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 { config } from '../config'; +import * as conn from '../conn'; +import { internalEv } from '../eventEmitter'; +import { Tracker } from './Tracker'; + +export * from './Tracker'; + +declare const __VERSION__: string; +export const waVersion = __VERSION__; + +/** + * Always keep the main tracker only for version report + */ +const mainTracker = new Tracker('G-MTQ4KY110F'); + +const otherTracker = config.googleAnalyticsId + ? new Tracker(config.googleAnalyticsId) + : null; + +internalEv.on('webpack.injected', () => { + const authenticated = conn.isAuthenticated(); + const method = conn.isMultiDevice() ? 'multidevice' : 'legacy'; + + // Add version info + mainTracker.setUserProperty('method', method); + mainTracker.setUserProperty('wa_js', waVersion); + mainTracker.setUserProperty( + 'whatsapp', + (window as any).Debug?.VERSION || '-' + ); + + mainTracker.trackEvent('page_view', { authenticated, method }); + + if (otherTracker) { + otherTracker.setUserProperty('method', method); + otherTracker.setUserProperty('wa_js', waVersion); + otherTracker.setUserProperty( + 'whatsapp', + (window as any).Debug?.VERSION || '-' + ); + + otherTracker.trackEvent('page_view', { authenticated, method }); + } +}); + +if (!config.disableGoogleAnalytics) { + internalEv.on('conn.authenticated', () => { + const method = conn.isMultiDevice() ? 'multidevice' : 'legacy'; + mainTracker.trackEvent('login', { method }); + if (otherTracker) { + mainTracker.trackEvent('login', { method }); + } + }); + + internalEv.on('conn.logout', () => { + const method = conn.isMultiDevice() ? 'multidevice' : 'legacy'; + mainTracker.trackEvent('logout', { method }); + if (otherTracker) { + otherTracker.trackEvent('logout', { method }); + } + }); +}