From 605f980adc77ed935b2a85f3f7ff4bc01f1143b5 Mon Sep 17 00:00:00 2001 From: Mykhailo Semenchenko Date: Tue, 19 Jan 2021 13:54:25 +0200 Subject: [PATCH] Generic web analytics tracking implementation Signed-off-by: Mykhailo Semenchenko --- .../src/constants/default-config.tsx | 1 + packages/jaeger-ui/src/middlewares/track.tsx | 4 +- packages/jaeger-ui/src/types/index.tsx | 2 + packages/jaeger-ui/src/types/tracking.tsx | 30 +++ .../tracking/{index.test.js => ga.test.js} | 9 +- packages/jaeger-ui/src/utils/tracking/ga.tsx | 187 ++++++++++++++++++ .../jaeger-ui/src/utils/tracking/index.tsx | 185 +++-------------- 7 files changed, 261 insertions(+), 157 deletions(-) create mode 100644 packages/jaeger-ui/src/types/tracking.tsx rename packages/jaeger-ui/src/utils/tracking/{index.test.js => ga.test.js} (95%) create mode 100644 packages/jaeger-ui/src/utils/tracking/ga.tsx diff --git a/packages/jaeger-ui/src/constants/default-config.tsx b/packages/jaeger-ui/src/constants/default-config.tsx index e6ca7a662e..35107883db 100644 --- a/packages/jaeger-ui/src/constants/default-config.tsx +++ b/packages/jaeger-ui/src/constants/default-config.tsx @@ -85,6 +85,7 @@ export default deepFreeze( tracking: { gaID: null, trackErrors: true, + customWebAnalytics: null, }, }, // fields that should be individually merged vs wholesale replaced diff --git a/packages/jaeger-ui/src/middlewares/track.tsx b/packages/jaeger-ui/src/middlewares/track.tsx index 28af57a8f5..3fe5e3ade9 100644 --- a/packages/jaeger-ui/src/middlewares/track.tsx +++ b/packages/jaeger-ui/src/middlewares/track.tsx @@ -17,7 +17,7 @@ import { Dispatch, Store } from 'react-redux'; import { middlewareHooks as searchHooks } from '../components/SearchTracePage/SearchForm.track'; import { middlewareHooks as timelineHooks } from '../components/TracePage/TraceTimelineViewer/duck.track'; -import { isGaEnabled } from '../utils/tracking'; +import { isWaEnabled } from '../utils/tracking'; import { ReduxState } from '../types'; type TMiddlewareFn = (store: Store, action: Action) => void; @@ -36,4 +36,4 @@ function trackingMiddleware(store: Store) { }; } -export default isGaEnabled ? trackingMiddleware : undefined; +export default isWaEnabled ? trackingMiddleware : undefined; diff --git a/packages/jaeger-ui/src/types/index.tsx b/packages/jaeger-ui/src/types/index.tsx index c862732068..6758d40de6 100644 --- a/packages/jaeger-ui/src/types/index.tsx +++ b/packages/jaeger-ui/src/types/index.tsx @@ -22,11 +22,13 @@ import { EmbeddedState } from './embedded'; import { SearchQuery } from './search'; import TDdgState from './TDdgState'; import TNil from './TNil'; +import IWebAnalytics from './tracking'; import { Trace } from './trace'; import TTraceDiffState from './TTraceDiffState'; import TTraceTimeline from './TTraceTimeline'; export type TNil = TNil; +export type IWebAnalytics = IWebAnalytics; export type FetchedState = 'FETCH_DONE' | 'FETCH_ERROR' | 'FETCH_LOADING'; diff --git a/packages/jaeger-ui/src/types/tracking.tsx b/packages/jaeger-ui/src/types/tracking.tsx new file mode 100644 index 0000000000..eedefd76fa --- /dev/null +++ b/packages/jaeger-ui/src/types/tracking.tsx @@ -0,0 +1,30 @@ +// Copyright (c) 2021 Uber Technologies, Inc. +// +// 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 { RavenStatic } from 'raven-js'; +import { TNil } from '.'; + +export default interface IWebAnalytics { + init: () => void; + context: boolean | RavenStatic | null; + isEnabled: () => boolean; + trackPageView: (pathname: string, search: string | TNil) => void; + trackError: (description: string) => void; + trackEvent: ( + category: string, + action: string, + labelOrValue?: string | number | TNil, + value?: number | TNil + ) => void; +} diff --git a/packages/jaeger-ui/src/utils/tracking/index.test.js b/packages/jaeger-ui/src/utils/tracking/ga.test.js similarity index 95% rename from packages/jaeger-ui/src/utils/tracking/index.test.js rename to packages/jaeger-ui/src/utils/tracking/ga.test.js index b0fb289556..847f729f10 100644 --- a/packages/jaeger-ui/src/utils/tracking/index.test.js +++ b/packages/jaeger-ui/src/utils/tracking/ga.test.js @@ -24,6 +24,13 @@ jest.mock('./index', () => { return require.requireActual('./index'); }); +jest.mock('../config/get-config', () => () => ({ + tracking: { + gaID: 'UA-123456', + trackErrors: true, + }, +})); + import ReactGA from 'react-ga'; import * as tracking from './index'; @@ -36,7 +43,7 @@ function getStr(len) { return longStr.slice(0, len); } -describe('tracking', () => { +describe('google analytics tracking', () => { let calls; beforeEach(() => { diff --git a/packages/jaeger-ui/src/utils/tracking/ga.tsx b/packages/jaeger-ui/src/utils/tracking/ga.tsx new file mode 100644 index 0000000000..a068526c03 --- /dev/null +++ b/packages/jaeger-ui/src/utils/tracking/ga.tsx @@ -0,0 +1,187 @@ +// Copyright (c) 2021 Uber Technologies, Inc. +// +// 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 _get from 'lodash/get'; +import queryString from 'query-string'; +import ReactGA from 'react-ga'; +import Raven, { RavenOptions, RavenTransportOptions } from 'raven-js'; + +import convRavenToGa from './conv-raven-to-ga'; +import { TNil, IWebAnalytics } from '../../types'; + +export default class GA implements IWebAnalytics { + isDebugMode = false; + context = null; + isProd = process.env.NODE_ENV === 'production'; + isDev = process.env.NODE_ENV === 'development'; + isTest = process.env.NODE_ENV === 'test'; + gaID = null; + isErrorsEnabled = false; + + private cookiesToDimensions = undefined; + private EVENT_LENGTHS = { + action: 499, + category: 149, + label: 499, + }; + + constructor(config: any) { + this.isDebugMode = + (this.isDev && GA.isTruish(process.env.REACT_APP_GA_DEBUG)) || + GA.isTruish(queryString.parse(_get(window, 'location.search'))['ga-debug']); + + this.gaID = _get(config, 'tracking.gaID'); + this.isErrorsEnabled = this.isDebugMode || Boolean(_get(config, 'tracking.trackErrors')); + this.cookiesToDimensions = _get(config, 'tracking.cookiesToDimensions'); + + this.context = this.isErrorsEnabled ? Raven : (null as any); + } + + isEnabled() { + return this.isTest || this.isDebugMode || (this.isProd && Boolean(this.gaID)); + } + + init() { + let versionShort; + let versionLong; + if (process.env.REACT_APP_VSN_STATE) { + try { + const data = JSON.parse(process.env.REACT_APP_VSN_STATE); + const joiner = [data.objName]; + if (data.changed.hasChanged) { + joiner.push(data.changed.pretty); + } + versionShort = joiner.join(' '); + versionLong = data.pretty; + } catch (_) { + versionShort = process.env.REACT_APP_VSN_STATE; + versionLong = process.env.REACT_APP_VSN_STATE; + } + versionLong = versionLong.length > 99 ? `${versionLong.slice(0, 96)}...` : versionLong; + } else { + versionShort = 'unknown'; + versionLong = 'unknown'; + } + const gaConfig = { testMode: this.isTest || this.isDebugMode, titleCase: false, debug: true }; + ReactGA.initialize(this.gaID || 'debug-mode', gaConfig); + ReactGA.set({ + appId: 'github.com/jaegertracing/jaeger-ui', + appName: 'Jaeger UI', + appVersion: versionLong, + }); + if (this.cookiesToDimensions !== undefined) { + ((this.cookiesToDimensions as unknown) as Array<{ cookie: string; dimension: string }>).forEach( + ({ cookie, dimension }: { cookie: string; dimension: string }) => { + const match = ` ${document.cookie}`.match(new RegExp(`[; ]${cookie}=([^\\s;]*)`)); + if (match) ReactGA.set({ [dimension]: match[1] }); + // eslint-disable-next-line no-console + else console.warn(`${cookie} not present in cookies, could not set dimension: ${dimension}`); + } + ); + } + if (this.isErrorsEnabled) { + const ravenConfig: RavenOptions = { + autoBreadcrumbs: { + xhr: true, + console: false, + dom: true, + location: true, + }, + environment: process.env.NODE_ENV || 'unkonwn', + transport: this.trackRavenError.bind(this), + }; + if (versionShort && versionShort !== 'unknown') { + ravenConfig.tags = { + git: versionShort, + }; + } + Raven.config('https://fakedsn@omg.com/1', ravenConfig).install(); + window.onunhandledrejection = function trackRejectedPromise(evt: PromiseRejectionEvent) { + Raven.captureException(evt.reason); + }; + } + if (this.isDebugMode) { + this.logTrackingCalls(); + } + } + + trackPageView(pathname: string, search: string | TNil) { + const pagePath = search ? `${pathname}${search}` : pathname; + ReactGA.pageview(pagePath); + if (this.isDebugMode) { + this.logTrackingCalls(); + } + } + + trackError(description: string) { + let msg = description; + if (!/^jaeger/i.test(msg)) { + msg = `jaeger/${msg}`; + } + msg = msg.slice(0, 149); + ReactGA.exception({ description: msg, fatal: false }); + if (this.isDebugMode) { + this.logTrackingCalls(); + } + } + + trackEvent(category: string, action: string, labelOrValue?: string | number | TNil, value?: number | TNil) { + const event: { + action: string; + category: string; + label?: string; + value?: number; + } = { + category: !/^jaeger/i.test(category) + ? `jaeger/${category}`.slice(0, this.EVENT_LENGTHS.category) + : category.slice(0, this.EVENT_LENGTHS.category), + action: action.slice(0, this.EVENT_LENGTHS.action), + }; + if (labelOrValue != null) { + if (typeof labelOrValue === 'string') { + event.label = labelOrValue.slice(0, this.EVENT_LENGTHS.action); + } else { + // value should be an int + event.value = Math.round(labelOrValue); + } + } + if (value != null) { + event.value = Math.round(value); + } + ReactGA.event(event); + if (this.isDebugMode) { + this.logTrackingCalls(); + } + } + + // eslint-disable-next-line class-methods-use-this + private logTrackingCalls() { + const calls = ReactGA.testModeAPI.calls; + for (let i = 0; i < calls.length; i++) { + // eslint-disable-next-line no-console + console.log('[react-ga]', ...calls[i]); + } + calls.length = 0; + } + + private trackRavenError(ravenData: RavenTransportOptions) { + const { message, category, action, label, value } = convRavenToGa(ravenData); + this.trackError(message); + this.trackEvent(category, action, label, value); + } + + static isTruish(value?: string | string[]) { + return Boolean(value) && value !== '0' && value !== 'false'; + } +} diff --git a/packages/jaeger-ui/src/utils/tracking/index.tsx b/packages/jaeger-ui/src/utils/tracking/index.tsx index d808a66101..f8d7298953 100644 --- a/packages/jaeger-ui/src/utils/tracking/index.tsx +++ b/packages/jaeger-ui/src/utils/tracking/index.tsx @@ -12,71 +12,47 @@ // See the License for the specific language governing permissions and // limitations under the License. -import _get from 'lodash/get'; -import queryString from 'query-string'; -import ReactGA from 'react-ga'; -import Raven, { RavenOptions, RavenTransportOptions } from 'raven-js'; - -import convRavenToGa from './conv-raven-to-ga'; +import { TNil, IWebAnalytics } from '../../types'; +import GA from './ga'; import getConfig from '../config/get-config'; -import { TNil } from '../../types'; -const EVENT_LENGTHS = { - action: 499, - category: 149, - label: 499, -}; +const TrackingImplementation = () => { + const config = getConfig(); -// Util so "0" and "false" become false -const isTruish = (value?: string | string[]) => Boolean(value) && value !== '0' && value !== 'false'; + const GenericWebAnalytics: IWebAnalytics = { + init: () => {}, + trackPageView: () => {}, + trackError: () => {}, + trackEvent: () => {}, + context: null, + isEnabled: () => false, + }; -const isProd = process.env.NODE_ENV === 'production'; -const isDev = process.env.NODE_ENV === 'development'; -const isTest = process.env.NODE_ENV === 'test'; + let webAnalytics = GenericWebAnalytics; -// In test mode if development and envvar REACT_APP_GA_DEBUG is true-ish -const isDebugMode = - (isDev && isTruish(process.env.REACT_APP_GA_DEBUG)) || - isTruish(queryString.parse(_get(window, 'location.search'))['ga-debug']); + if (config.tracking && config.tracking.customWebAnalytics) { + webAnalytics = config.tracking.customWebAnalytics(config) as IWebAnalytics; + } else if (config.tracking && config.tracking.gaID) { + webAnalytics = new GA(config); + } -const config = getConfig(); -const gaID = _get(config, 'tracking.gaID'); -// enable for tests, debug or if in prod with a GA ID -export const isGaEnabled = isTest || isDebugMode || (isProd && Boolean(gaID)); -const isErrorsEnabled = isDebugMode || (isGaEnabled && Boolean(_get(config, 'tracking.trackErrors'))); + if (webAnalytics.isEnabled()) { + webAnalytics.init(); -/* istanbul ignore next */ -function logTrackingCalls() { - const calls = ReactGA.testModeAPI.calls; - for (let i = 0; i < calls.length; i++) { - // eslint-disable-next-line no-console - console.log('[react-ga]', ...calls[i]); + return webAnalytics; } - calls.length = 0; -} + + return webAnalytics; +}; + +const tracker = TrackingImplementation(); export function trackPageView(pathname: string, search: string | TNil) { - if (isGaEnabled) { - const pagePath = search ? `${pathname}${search}` : pathname; - ReactGA.pageview(pagePath); - if (isDebugMode) { - logTrackingCalls(); - } - } + return tracker.trackPageView(pathname, search); } export function trackError(description: string) { - if (isGaEnabled) { - let msg = description; - if (!/^jaeger/i.test(msg)) { - msg = `jaeger/${msg}`; - } - msg = msg.slice(0, 149); - ReactGA.exception({ description: msg, fatal: false }); - if (isDebugMode) { - logTrackingCalls(); - } - } + return tracker.trackError(description); } export function trackEvent( @@ -85,107 +61,8 @@ export function trackEvent( labelOrValue?: string | number | TNil, value?: number | TNil ) { - if (isGaEnabled) { - const event: { - action: string; - category: string; - label?: string; - value?: number; - } = { - category: !/^jaeger/i.test(category) - ? `jaeger/${category}`.slice(0, EVENT_LENGTHS.category) - : category.slice(0, EVENT_LENGTHS.category), - action: action.slice(0, EVENT_LENGTHS.action), - }; - if (labelOrValue != null) { - if (typeof labelOrValue === 'string') { - event.label = labelOrValue.slice(0, EVENT_LENGTHS.action); - } else { - // value should be an int - event.value = Math.round(labelOrValue); - } - } - if (value != null) { - event.value = Math.round(value); - } - ReactGA.event(event); - if (isDebugMode) { - logTrackingCalls(); - } - } -} - -function trackRavenError(ravenData: RavenTransportOptions) { - const { message, category, action, label, value } = convRavenToGa(ravenData); - trackError(message); - trackEvent(category, action, label, value); -} - -// Tracking needs to be initialized when this file is imported, e.g. early in -// the process of initializing the app, so Raven can wrap various resources, -// like `fetch()`, and generate breadcrumbs from them. - -if (isGaEnabled) { - let versionShort; - let versionLong; - if (process.env.REACT_APP_VSN_STATE) { - try { - const data = JSON.parse(process.env.REACT_APP_VSN_STATE); - const joiner = [data.objName]; - if (data.changed.hasChanged) { - joiner.push(data.changed.pretty); - } - versionShort = joiner.join(' '); - versionLong = data.pretty; - } catch (_) { - versionShort = process.env.REACT_APP_VSN_STATE; - versionLong = process.env.REACT_APP_VSN_STATE; - } - versionLong = versionLong.length > 99 ? `${versionLong.slice(0, 96)}...` : versionLong; - } else { - versionShort = 'unknown'; - versionLong = 'unknown'; - } - const gaConfig = { testMode: isTest || isDebugMode, titleCase: false }; - ReactGA.initialize(gaID || 'debug-mode', gaConfig); - ReactGA.set({ - appId: 'github.com/jaegertracing/jaeger-ui', - appName: 'Jaeger UI', - appVersion: versionLong, - }); - const cookiesToDimensions = _get(config, 'tracking.cookiesToDimensions'); - if (cookiesToDimensions) { - cookiesToDimensions.forEach(({ cookie, dimension }: { cookie: string; dimension: string }) => { - const match = ` ${document.cookie}`.match(new RegExp(`[; ]${cookie}=([^\\s;]*)`)); - if (match) ReactGA.set({ [dimension]: match[1] }); - // eslint-disable-next-line no-console - else console.warn(`${cookie} not present in cookies, could not set dimension: ${dimension}`); - }); - } - if (isErrorsEnabled) { - const ravenConfig: RavenOptions = { - autoBreadcrumbs: { - xhr: true, - console: false, - dom: true, - location: true, - }, - environment: process.env.NODE_ENV || 'unkonwn', - transport: trackRavenError, - }; - if (versionShort && versionShort !== 'unknown') { - ravenConfig.tags = { - git: versionShort, - }; - } - Raven.config('https://fakedsn@omg.com/1', ravenConfig).install(); - window.onunhandledrejection = function trackRejectedPromise(evt) { - Raven.captureException(evt.reason); - }; - } - if (isDebugMode) { - logTrackingCalls(); - } + return tracker.trackEvent(category, action, labelOrValue, value); } -export const context = isErrorsEnabled ? Raven : null; +export const context = tracker.context; +export const isWaEnabled = tracker.isEnabled();