From abdf99d78b835cbf70c64e630c3ed5aa67ff1fa6 Mon Sep 17 00:00:00 2001 From: Mykhailo Semenchenko <45467774+th3M1ke@users.noreply.github.com> Date: Tue, 16 Feb 2021 22:07:56 +0200 Subject: [PATCH] Generic web analytics tracking implementation (#681) * Generic web analytics tracking implementation Signed-off-by: Mykhailo Semenchenko * Update due to comments, refactor Google Analytic tracker Signed-off-by: Mykhailo Semenchenko * Add unit tests Signed-off-by: Mykhailo Semenchenko * Update tests Signed-off-by: Mykhailo Semenchenko * Update tests Signed-off-by: Mykhailo Semenchenko * Increase test coverage Signed-off-by: Mykhailo Semenchenko --- packages/jaeger-ui/package.json | 3 +- .../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 | 35 +++ .../jaeger-ui/src/utils/tracking/ga.test.js | 218 ++++++++++++++++ packages/jaeger-ui/src/utils/tracking/ga.tsx | 169 +++++++++++++ .../src/utils/tracking/index.test.js | 232 +++++++++++------- .../jaeger-ui/src/utils/tracking/index.tsx | 192 +++------------ .../src/utils/tracking/noopWebAnalytics.tsx | 26 ++ .../src/utils/tracking/utils.test.js | 23 ++ .../jaeger-ui/src/utils/tracking/utils.tsx | 24 ++ 12 files changed, 684 insertions(+), 245 deletions(-) create mode 100644 packages/jaeger-ui/src/types/tracking.tsx create mode 100644 packages/jaeger-ui/src/utils/tracking/ga.test.js create mode 100644 packages/jaeger-ui/src/utils/tracking/ga.tsx create mode 100644 packages/jaeger-ui/src/utils/tracking/noopWebAnalytics.tsx create mode 100644 packages/jaeger-ui/src/utils/tracking/utils.test.js create mode 100644 packages/jaeger-ui/src/utils/tracking/utils.tsx diff --git a/packages/jaeger-ui/package.json b/packages/jaeger-ui/package.json index 596f6f24e2..c5a22d9ffd 100644 --- a/packages/jaeger-ui/package.json +++ b/packages/jaeger-ui/package.json @@ -119,7 +119,8 @@ "!src/setup*.js", "!src/utils/DraggableManager/demo/*.tsx", "!src/utils/test/**/*.js", - "!src/demo/**/*.js" + "!src/demo/**/*.js", + "!src/types/*" ] }, "browserslist": [ 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..9b0aea989a --- /dev/null +++ b/packages/jaeger-ui/src/types/tracking.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2021 The Jaeger Authors. +// +// 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 '.'; +import { Config } from './config'; + +export interface IWebAnalyticsFunc { + (config: Config, versionShort: string, versionLong: string): IWebAnalytics; +} + +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/ga.test.js b/packages/jaeger-ui/src/utils/tracking/ga.test.js new file mode 100644 index 0000000000..56f63226c5 --- /dev/null +++ b/packages/jaeger-ui/src/utils/tracking/ga.test.js @@ -0,0 +1,218 @@ +// Copyright (c) 2017 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. + +/* eslint-disable import/first */ +jest.mock('./conv-raven-to-ga', () => () => ({ + category: 'jaeger/a', + action: 'some-action', + message: 'jaeger/a', +})); + +jest.mock('./index', () => { + global.process.env.REACT_APP_VSN_STATE = '{}'; + return require.requireActual('./index'); +}); + +import ReactGA from 'react-ga'; +import * as GA from './ga'; +import * as utils from './utils'; + +let longStr = '---'; +function getStr(len) { + while (longStr.length < len) { + longStr += longStr.slice(0, len - longStr.length); + } + return longStr.slice(0, len); +} + +describe('google analytics tracking', () => { + let calls; + let tracking; + + beforeAll(() => { + tracking = GA.default( + { + tracking: { + gaID: 'UA-123456', + trackErrors: true, + cookiesToDimensions: [{ cookie: 'page', dimension: 'dimension1' }], + }, + }, + 'c0mm1ts', + 'c0mm1tL' + ); + }); + + beforeEach(() => { + calls = ReactGA.testModeAPI.calls; + calls.length = 0; + }); + + describe('init', () => { + it('check init function (no cookies)', () => { + tracking.init(); + expect(calls).toEqual([ + ['create', 'UA-123456', 'auto'], + [ + 'set', + { + appId: 'github.com/jaegertracing/jaeger-ui', + appName: 'Jaeger UI', + appVersion: 'c0mm1tL', + }, + ], + ]); + }); + + it('check init function (no cookies)', () => { + document.cookie = 'page=1;'; + tracking.init(); + expect(calls).toEqual([ + ['create', 'UA-123456', 'auto'], + [ + 'set', + { + appId: 'github.com/jaegertracing/jaeger-ui', + appName: 'Jaeger UI', + appVersion: 'c0mm1tL', + }, + ], + ['set', { dimension1: '1' }], + ]); + }); + }); + + describe('trackPageView', () => { + it('tracks a page view', () => { + tracking.trackPageView('a', 'b'); + expect(calls).toEqual([['send', { hitType: 'pageview', page: 'ab' }]]); + }); + + it('ignores search when it is falsy', () => { + tracking.trackPageView('a'); + expect(calls).toEqual([['send', { hitType: 'pageview', page: 'a' }]]); + }); + }); + + describe('trackError', () => { + it('tracks an error', () => { + tracking.trackError('a'); + expect(calls).toEqual([ + ['send', { hitType: 'exception', exDescription: expect.any(String), exFatal: false }], + ]); + }); + + it('ensures "jaeger" is prepended', () => { + tracking.trackError('a'); + expect(calls).toEqual([['send', { hitType: 'exception', exDescription: 'jaeger/a', exFatal: false }]]); + }); + + it('truncates if needed', () => { + const str = `jaeger/${getStr(200)}`; + tracking.trackError(str); + expect(calls).toEqual([ + ['send', { hitType: 'exception', exDescription: str.slice(0, 149), exFatal: false }], + ]); + }); + }); + + describe('trackEvent', () => { + it('tracks an event', () => { + const category = 'jaeger/some-category'; + const action = 'some-action'; + tracking.trackEvent(category, action); + expect(calls).toEqual([ + [ + 'send', + { + hitType: 'event', + eventCategory: category, + eventAction: action, + }, + ], + ]); + }); + + it('prepends "jaeger/" to the category, if needed', () => { + const category = 'some-category'; + const action = 'some-action'; + tracking.trackEvent(category, action); + expect(calls).toEqual([ + ['send', { hitType: 'event', eventCategory: `jaeger/${category}`, eventAction: action }], + ]); + }); + + it('truncates values, if needed', () => { + const str = `jaeger/${getStr(600)}`; + tracking.trackEvent(str, str, str); + expect(calls).toEqual([ + [ + 'send', + { + hitType: 'event', + eventCategory: str.slice(0, 149), + eventAction: str.slice(0, 499), + eventLabel: str.slice(0, 499), + }, + ], + ]); + }); + }); + + it('converting raven-js errors', () => { + window.onunhandledrejection({ reason: new Error('abc') }); + expect(calls).toEqual([ + ['send', { hitType: 'exception', exDescription: expect.any(String), exFatal: false }], + ['send', { hitType: 'event', eventCategory: expect.any(String), eventAction: expect.any(String) }], + ]); + }); + + describe('Debug mode', () => { + let trackingDebug; + + beforeAll(() => { + const originalWindow = { ...window }; + const windowSpy = jest.spyOn(global, 'window', 'get'); + windowSpy.mockImplementation(() => ({ + ...originalWindow, + location: { + ...originalWindow.location, + href: 'http://my.test/page', + search: 'ga-debug=true', + }, + })); + + trackingDebug = GA.default( + { + tracking: { + gaID: 'UA-123456', + trackErrors: true, + cookiesToDimensions: [{ cookie: 'page', dimension: 'dimension1' }], + }, + }, + 'c0mm1ts', + 'c0mm1tL' + ); + }); + + it('isDebugMode = true', () => { + utils.logTrackingCalls = jest.fn(); + trackingDebug.init(); + trackingDebug.trackError(); + trackingDebug.trackEvent('jaeger/some-category', 'some-action'); + trackingDebug.trackPageView('a', 'b'); + expect(utils.logTrackingCalls).toHaveBeenCalledTimes(4); + }); + }); +}); 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..3be85ae1e4 --- /dev/null +++ b/packages/jaeger-ui/src/utils/tracking/ga.tsx @@ -0,0 +1,169 @@ +// Copyright (c) 2021 The Jaeger Authors. +// +// 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 } from '../../types'; +import { Config } from '../../types/config'; +import { IWebAnalyticsFunc } from '../../types/tracking'; +import { logTrackingCalls } from './utils'; + +const isTruish = (value?: string | string[]) => { + return Boolean(value) && value !== '0' && value !== 'false'; +}; + +const GA: IWebAnalyticsFunc = (config: Config, versionShort: string, versionLong: string) => { + const isProd = process.env.NODE_ENV === 'production'; + const isDev = process.env.NODE_ENV === 'development'; + const isTest = process.env.NODE_ENV === 'test'; + const isDebugMode = + (isDev && isTruish(process.env.REACT_APP_GA_DEBUG)) || + isTruish(queryString.parse(_get(window, 'location.search'))['ga-debug']); + const gaID = _get(config, 'tracking.gaID'); + const isErrorsEnabled = isDebugMode || Boolean(_get(config, 'tracking.trackErrors')); + const cookiesToDimensions = _get(config, 'tracking.cookiesToDimensions'); + const context = isErrorsEnabled ? Raven : (null as any); + const EVENT_LENGTHS = { + action: 499, + category: 149, + label: 499, + }; + + const isEnabled = () => { + return isTest || isDebugMode || (isProd && Boolean(gaID)); + }; + + const 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 (isDebugMode) { + logTrackingCalls(); + } + }; + + const 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, 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(); + } + }; + + const trackRavenError = (ravenData: RavenTransportOptions) => { + const { message, category, action, label, value } = convRavenToGa(ravenData); + trackError(message); + trackEvent(category, action, label, value); + }; + + const init = () => { + if (!isEnabled()) { + return; + } + + const gaConfig = { testMode: isTest || isDebugMode, titleCase: false, debug: true }; + ReactGA.initialize(gaID || 'debug-mode', gaConfig); + ReactGA.set({ + appId: 'github.com/jaegertracing/jaeger-ui', + appName: 'Jaeger UI', + appVersion: versionLong, + }); + if (cookiesToDimensions !== undefined) { + ((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 (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: PromiseRejectionEvent) { + Raven.captureException(evt.reason); + }; + } + if (isDebugMode) { + logTrackingCalls(); + } + }; + + const trackPageView = (pathname: string, search: string | TNil) => { + const pagePath = search ? `${pathname}${search}` : pathname; + ReactGA.pageview(pagePath); + if (isDebugMode) { + logTrackingCalls(); + } + }; + + return { + isEnabled, + init, + context, + trackPageView, + trackError, + trackEvent, + }; +}; + +export default GA; diff --git a/packages/jaeger-ui/src/utils/tracking/index.test.js b/packages/jaeger-ui/src/utils/tracking/index.test.js index b0fb289556..27943c0904 100644 --- a/packages/jaeger-ui/src/utils/tracking/index.test.js +++ b/packages/jaeger-ui/src/utils/tracking/index.test.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2021 The Jaeger Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,120 +12,172 @@ // See the License for the specific language governing permissions and // limitations under the License. -/* eslint-disable import/first */ -jest.mock('./conv-raven-to-ga', () => () => ({ - category: 'jaeger/a', - action: 'some-action', - message: 'jaeger/a', +const mockGA = { + init: jest.fn(), + context: jest.fn(), + isEnabled: jest.fn(), + trackPageView: jest.fn(), + trackError: jest.fn(), +}; + +const mockNoopWebAnalytics = { + init: jest.fn(), + context: jest.fn(), + isEnabled: jest.fn(), + trackPageView: jest.fn(), + trackError: jest.fn(), +}; + +jest.mock('./ga', () => ({ + __esModule: true, + default: () => { + return mockGA; + }, +})); +let internalVersionShort; +let internalVersionLong; + +jest.mock('./noopWebAnalytics', () => ({ + __esModule: true, + default: (config, versionShort, versionLong) => { + internalVersionShort = versionShort; + internalVersionLong = versionLong; + return mockNoopWebAnalytics; + }, })); -jest.mock('./index', () => { - process.env.REACT_APP_VSN_STATE = '{}'; - return require.requireActual('./index'); -}); - -import ReactGA from 'react-ga'; +describe('generic analytics tracking', () => { + beforeEach(() => { + jest.resetModules(); + jest.resetAllMocks(); + }); -import * as tracking from './index'; + it('no web analytic test', () => { + jest.doMock('../config/get-config', () => { + return { + __esModule: true, + default: () => ({}), + }; + }); -let longStr = '---'; -function getStr(len) { - while (longStr.length < len) { - longStr += longStr.slice(0, len - longStr.length); - } - return longStr.slice(0, len); -} + return import('.').then(noopWA => { + expect(internalVersionShort).toBe('unknown'); + expect(internalVersionLong).toBe('unknown'); + expect(mockNoopWebAnalytics.init).toHaveBeenCalled(); + expect(mockGA.init).not.toHaveBeenCalled(); -describe('tracking', () => { - let calls; + noopWA.trackPageView('pathname', 'search'); + noopWA.trackError('description'); - beforeEach(() => { - calls = ReactGA.testModeAPI.calls; - calls.length = 0; + expect(mockNoopWebAnalytics.trackPageView).toHaveBeenCalled(); + expect(mockNoopWebAnalytics.trackError).toHaveBeenCalled(); + }); }); - describe('trackPageView', () => { - it('tracks a page view', () => { - tracking.trackPageView('a', 'b'); - expect(calls).toEqual([['send', { hitType: 'pageview', page: 'ab' }]]); + it('Google Analytics test', () => { + jest.doMock('../config/get-config', () => { + return { + __esModule: true, + default: () => ({ + tracking: { + gaID: 'UA123', + }, + }), + }; }); - it('ignores search when it is falsy', () => { - tracking.trackPageView('a'); - expect(calls).toEqual([['send', { hitType: 'pageview', page: 'a' }]]); + return import('.').then(noopWA => { + expect(mockNoopWebAnalytics.init).not.toHaveBeenCalled(); + expect(mockGA.init).toHaveBeenCalled(); + + noopWA.trackPageView('pathname', 'search'); + noopWA.trackError('description'); + + expect(mockGA.trackPageView).toHaveBeenCalled(); + expect(mockGA.trackError).toHaveBeenCalled(); }); }); - describe('trackError', () => { - it('tracks an error', () => { - tracking.trackError('a'); - expect(calls).toEqual([ - ['send', { hitType: 'exception', exDescription: expect.any(String), exFatal: false }], - ]); + it('Custom Web Analytics test', () => { + const mockCustomWA = { + init: jest.fn(), + context: jest.fn(), + isEnabled: jest.fn(), + }; + + jest.doMock('../config/get-config', () => { + return { + __esModule: true, + default: () => ({ + tracking: { + gaID: 'UA123', + customWebAnalytics: () => mockCustomWA, + }, + }), + }; }); - it('ensures "jaeger" is prepended', () => { - tracking.trackError('a'); - expect(calls).toEqual([['send', { hitType: 'exception', exDescription: 'jaeger/a', exFatal: false }]]); + return import('.').then(() => { + expect(mockNoopWebAnalytics.init).not.toHaveBeenCalled(); + expect(mockGA.init).not.toHaveBeenCalled(); + expect(mockCustomWA.init).toHaveBeenCalled(); }); + }); - it('truncates if needed', () => { - const str = `jaeger/${getStr(200)}`; - tracking.trackError(str); - expect(calls).toEqual([ - ['send', { hitType: 'exception', exDescription: str.slice(0, 149), exFatal: false }], - ]); + it('get versions as a string or bad JSON test', () => { + const version = '123456'; + process.env.REACT_APP_VSN_STATE = version; + jest.doMock('../config/get-config', () => { + return { + __esModule: true, + default: () => ({}), + }; }); - }); - describe('trackEvent', () => { - it('tracks an event', () => { - const category = 'jaeger/some-category'; - const action = 'some-action'; - tracking.trackEvent(category, action); - expect(calls).toEqual([ - [ - 'send', - { - hitType: 'event', - eventCategory: category, - eventAction: action, - }, - ], - ]); + return import('.').then(() => { + expect(internalVersionShort).toBe(version); + expect(internalVersionLong).toBe(version); + expect(mockNoopWebAnalytics.init).toHaveBeenCalled(); + expect(mockGA.init).not.toHaveBeenCalled(); }); + }); - it('prepends "jaeger/" to the category, if needed', () => { - const category = 'some-category'; - const action = 'some-action'; - tracking.trackEvent(category, action); - expect(calls).toEqual([ - ['send', { hitType: 'event', eventCategory: `jaeger/${category}`, eventAction: action }], - ]); + it('get versions as an object test', () => { + const vShot = '48956d5'; + const vLong = ' | github.com/jaegertracing/jaeger-ui | 48956d5 | master'; + process.env.REACT_APP_VSN_STATE = `{"remote":"github.com/jaegertracing/jaeger-ui","objName":"${vShot}","changed":{"hasChanged":false,"files":0,"insertions":0,"deletions":0,"untracked":0,"pretty":""},"refName":"master","pretty":"${vLong}"}`; + jest.doMock('../config/get-config', () => { + return { + __esModule: true, + default: () => ({}), + }; }); - it('truncates values, if needed', () => { - const str = `jaeger/${getStr(600)}`; - tracking.trackEvent(str, str, str); - expect(calls).toEqual([ - [ - 'send', - { - hitType: 'event', - eventCategory: str.slice(0, 149), - eventAction: str.slice(0, 499), - eventLabel: str.slice(0, 499), - }, - ], - ]); + return import('.').then(() => { + expect(internalVersionShort).toBe(vShot); + expect(internalVersionLong).toBe(vLong); + expect(mockNoopWebAnalytics.init).toHaveBeenCalled(); + expect(mockGA.init).not.toHaveBeenCalled(); }); }); - it('converting raven-js errors', () => { - window.onunhandledrejection({ reason: new Error('abc') }); - expect(calls).toEqual([ - ['send', { hitType: 'exception', exDescription: expect.any(String), exFatal: false }], - ['send', { hitType: 'event', eventCategory: expect.any(String), eventAction: expect.any(String) }], - ]); + it('get versions as an object test(hasChanged:true)', () => { + const vShotCommitSHA = '48956d5'; + const vShotChanges = '2f +20 -3 1?'; + const vLong = ' | github.com/jaegertracing/jaeger-ui | 48956d5 | master'; + process.env.REACT_APP_VSN_STATE = `{"remote":"github.com/jaegertracing/jaeger-ui","objName":"${vShotCommitSHA}","changed":{"hasChanged":true,"files":2,"insertions":20,"deletions":3,"untracked":1,"pretty":"${vShotChanges}"},"refName":"master","pretty":"${vLong}"}`; + jest.doMock('../config/get-config', () => { + return { + __esModule: true, + default: () => ({}), + }; + }); + + return import('.').then(() => { + expect(internalVersionShort).toBe(`${vShotCommitSHA} ${vShotChanges}`); + expect(internalVersionLong).toBe(vLong); + expect(mockNoopWebAnalytics.init).toHaveBeenCalled(); + expect(mockGA.init).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/jaeger-ui/src/utils/tracking/index.tsx b/packages/jaeger-ui/src/utils/tracking/index.tsx index d808a66101..f1e38900a1 100644 --- a/packages/jaeger-ui/src/utils/tracking/index.tsx +++ b/packages/jaeger-ui/src/utils/tracking/index.tsx @@ -12,122 +12,17 @@ // 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 getConfig from '../config/get-config'; import { TNil } from '../../types'; +import { IWebAnalyticsFunc } from '../../types/tracking'; +import GA from './ga'; +import NoopWebAnalytics from './noopWebAnalytics'; +import getConfig from '../config/get-config'; -const EVENT_LENGTHS = { - action: 499, - category: 149, - label: 499, -}; - -// Util so "0" and "false" become false -const isTruish = (value?: string | string[]) => Boolean(value) && value !== '0' && value !== 'false'; - -const isProd = process.env.NODE_ENV === 'production'; -const isDev = process.env.NODE_ENV === 'development'; -const isTest = process.env.NODE_ENV === 'test'; - -// 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']); - -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'))); - -/* 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]); - } - calls.length = 0; -} - -export function trackPageView(pathname: string, search: string | TNil) { - if (isGaEnabled) { - const pagePath = search ? `${pathname}${search}` : pathname; - ReactGA.pageview(pagePath); - if (isDebugMode) { - logTrackingCalls(); - } - } -} - -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(); - } - } -} - -export function trackEvent( - category: string, - action: string, - 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) { +const TrackingImplementation = () => { + const config = getConfig(); let versionShort; let versionLong; + if (process.env.REACT_APP_VSN_STATE) { try { const data = JSON.parse(process.env.REACT_APP_VSN_STATE); @@ -146,46 +41,39 @@ if (isGaEnabled) { 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(); + + let webAnalyticsFunc = NoopWebAnalytics; + + if (config.tracking && config.tracking.customWebAnalytics) { + webAnalyticsFunc = config.tracking.customWebAnalytics as IWebAnalyticsFunc; + } else if (config.tracking && config.tracking.gaID) { + webAnalyticsFunc = GA; } + + const webAnalytics = webAnalyticsFunc(config, versionShort, versionLong); + webAnalytics.init(); + + return webAnalytics; +}; + +const tracker = TrackingImplementation(); + +export function trackPageView(pathname: string, search: string | TNil) { + return tracker.trackPageView(pathname, search); +} + +export function trackError(description: string) { + return tracker.trackError(description); +} + +export function trackEvent( + category: string, + action: string, + labelOrValue?: string | number | TNil, + value?: number | TNil +) { + return tracker.trackEvent(category, action, labelOrValue, value); } -export const context = isErrorsEnabled ? Raven : null; +export const context = tracker.context; +export const isWaEnabled = tracker.isEnabled(); diff --git a/packages/jaeger-ui/src/utils/tracking/noopWebAnalytics.tsx b/packages/jaeger-ui/src/utils/tracking/noopWebAnalytics.tsx new file mode 100644 index 0000000000..c2af1fd0a8 --- /dev/null +++ b/packages/jaeger-ui/src/utils/tracking/noopWebAnalytics.tsx @@ -0,0 +1,26 @@ +// Copyright (c) 2021 The Jaeger Authors. +// +// 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 { IWebAnalyticsFunc } from '../../types/tracking'; + +const NoopWebAnalytics: IWebAnalyticsFunc = () => ({ + init: () => {}, + trackPageView: () => {}, + trackError: () => {}, + trackEvent: () => {}, + context: null, + isEnabled: () => false, +}); + +export default NoopWebAnalytics; diff --git a/packages/jaeger-ui/src/utils/tracking/utils.test.js b/packages/jaeger-ui/src/utils/tracking/utils.test.js new file mode 100644 index 0000000000..30c61067d3 --- /dev/null +++ b/packages/jaeger-ui/src/utils/tracking/utils.test.js @@ -0,0 +1,23 @@ +// Copyright (c) 2021 The Jaeger Authors. +// +// 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 * as utils from './utils'; + +describe('utils', () => { + describe('logTrackingCalls', () => { + it('dry run', () => { + expect(utils.logTrackingCalls()).toBeUndefined(); + }); + }); +}); diff --git a/packages/jaeger-ui/src/utils/tracking/utils.tsx b/packages/jaeger-ui/src/utils/tracking/utils.tsx new file mode 100644 index 0000000000..b7fb53873b --- /dev/null +++ b/packages/jaeger-ui/src/utils/tracking/utils.tsx @@ -0,0 +1,24 @@ +// Copyright (c) 2021 The Jaeger Authors. +// +// 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 ReactGA from 'react-ga'; + +export const 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; +};