Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generic web analytics tracking implementation #681

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Update due to comments, refactor Google Analytic tracker
Signed-off-by: Mykhailo Semenchenko <mykhailo.semenchenko@logz.io>
  • Loading branch information
th3M1ke committed Feb 11, 2021
commit 3f71ca3624a0b4eb2998db8bb227970f1b654e38
5 changes: 5 additions & 0 deletions packages/jaeger-ui/src/types/tracking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@

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;
Expand Down
225 changes: 108 additions & 117 deletions packages/jaeger-ui/src/utils/tracking/ga.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2021 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.
Expand All @@ -18,70 +18,111 @@ 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 { TNil } from '../../types';
import { Config } from '../../types/config';
import { IWebAnalyticsFunc } from '../../types/tracking';

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;
const isTruish = (value?: string | string[]) => {
return Boolean(value) && value !== '0' && value !== 'false';
};

private cookiesToDimensions = undefined;
private EVENT_LENGTHS = {
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;
};

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,
};

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']);
const isEnabled = () => {
return isTest || isDebugMode || (isProd && Boolean(gaID));
};

this.gaID = _get(config, 'tracking.gaID');
this.isErrorsEnabled = this.isDebugMode || Boolean(_get(config, 'tracking.trackErrors'));
this.cookiesToDimensions = _get(config, 'tracking.cookiesToDimensions');
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();
}
};

this.context = this.isErrorsEnabled ? Raven : (null as any);
}
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();
}
};

isEnabled() {
return this.isTest || this.isDebugMode || (this.isProd && Boolean(this.gaID));
}
const trackRavenError = (ravenData: RavenTransportOptions) => {
const { message, category, action, label, value } = convRavenToGa(ravenData);
trackError(message);
trackEvent(category, action, label, value);
};

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 init = () => {
if (!isEnabled()) {
return;
}
const gaConfig = { testMode: this.isTest || this.isDebugMode, titleCase: false, debug: true };
ReactGA.initialize(this.gaID || 'debug-mode', gaConfig);

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 (this.cookiesToDimensions !== undefined) {
((this.cookiesToDimensions as unknown) as Array<{ cookie: string; dimension: string }>).forEach(
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] });
Expand All @@ -90,7 +131,7 @@ export default class GA implements IWebAnalytics {
}
);
}
if (this.isErrorsEnabled) {
if (isErrorsEnabled) {
const ravenConfig: RavenOptions = {
autoBreadcrumbs: {
xhr: true,
Expand All @@ -99,7 +140,7 @@ export default class GA implements IWebAnalytics {
location: true,
},
environment: process.env.NODE_ENV || 'unkonwn',
transport: this.trackRavenError.bind(this),
transport: trackRavenError,
};
if (versionShort && versionShort !== 'unknown') {
ravenConfig.tags = {
Expand All @@ -111,77 +152,27 @@ export default class GA implements IWebAnalytics {
Raven.captureException(evt.reason);
};
}
if (this.isDebugMode) {
this.logTrackingCalls();
if (isDebugMode) {
logTrackingCalls();
}
}
};

trackPageView(pathname: string, search: string | TNil) {
const trackPageView = (pathname: string, search: string | TNil) => {
const pagePath = search ? `${pathname}${search}` : pathname;
ReactGA.pageview(pagePath);
if (this.isDebugMode) {
this.logTrackingCalls();
if (isDebugMode) {
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);
}
return {
isEnabled,
init,
context,
trackPageView,
trackError,
trackEvent,
};
};

static isTruish(value?: string | string[]) {
return Boolean(value) && value !== '0' && value !== 'false';
}
}
export default GA;
41 changes: 30 additions & 11 deletions packages/jaeger-ui/src/utils/tracking/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,54 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { TNil, IWebAnalytics } from '../../types';
import { TNil } from '../../types';
import { IWebAnalyticsFunc } from '../../types/tracking';
import GA from './ga';
import getConfig from '../config/get-config';

const TrackingImplementation = () => {
const config = getConfig();
let versionShort;
let versionLong;

const GenericWebAnalytics: IWebAnalytics = {
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 NoopWebAnalytics: IWebAnalyticsFunc = () => ({
init: () => {},
trackPageView: () => {},
trackError: () => {},
trackEvent: () => {},
context: null,
isEnabled: () => false,
};
});

let webAnalytics = GenericWebAnalytics;
let webAnalyticsFunc = NoopWebAnalytics;

if (config.tracking && config.tracking.customWebAnalytics) {
webAnalytics = config.tracking.customWebAnalytics(config) as IWebAnalytics;
webAnalyticsFunc = config.tracking.customWebAnalytics as IWebAnalyticsFunc;
} else if (config.tracking && config.tracking.gaID) {
webAnalytics = new GA(config);
webAnalyticsFunc = GA;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option instead of forced priority order is to allow enabling both GA and custom WA via a wrapper implementation that would dispatch events to both. But I don't feel strongly about it, the priority order seems fine.


if (webAnalytics.isEnabled()) {
webAnalytics.init();

return webAnalytics;
}
const webAnalytics = webAnalyticsFunc(config, versionShort, versionLong);
webAnalytics.init();

return webAnalytics;
};
Expand Down