src/amo/tracking.js (291 lines of code) (raw):
/* @flow */
/* global navigator, window */
import { oneLine } from 'common-tags';
import config from 'config';
import invariant from 'invariant';
import { getCLS, getFID, getLCP } from 'web-vitals';
import {
ADDON_TYPE_DICT,
ADDON_TYPE_EXTENSION,
ADDON_TYPE_LANG,
ADDON_TYPE_STATIC_THEME,
ENABLE_ACTION,
ENABLE_EXTENSION_CATEGORY,
ENABLE_THEME_CATEGORY,
INSTALL_CANCELLED_ACTION,
INSTALL_CANCELLED_EXTENSION_CATEGORY,
INSTALL_CANCELLED_THEME_CATEGORY,
INSTALL_DOWNLOAD_FAILED_ACTION,
INSTALL_DOWNLOAD_FAILED_EXTENSION_CATEGORY,
INSTALL_DOWNLOAD_FAILED_THEME_CATEGORY,
INSTALL_EXTENSION_CATEGORY,
INSTALL_STARTED_ACTION,
INSTALL_STARTED_EXTENSION_CATEGORY,
INSTALL_STARTED_THEME_CATEGORY,
INSTALL_THEME_CATEGORY,
TRACKING_TYPE_EXTENSION,
TRACKING_TYPE_INVALID,
TRACKING_TYPE_STATIC_THEME,
UNINSTALL_ACTION,
UNINSTALL_EXTENSION_CATEGORY,
UNINSTALL_THEME_CATEGORY,
} from 'amo/constants';
import log from 'amo/logger';
import { convertBoolean } from 'amo/utils';
type MakeTrackingEventDataParams = {|
action: string,
category: string,
label?: string,
value?: number,
|};
export type SendTrackingEventParams = {|
_config?: typeof config,
sendSecondEventWithOverrides?: Object,
...MakeTrackingEventDataParams,
|};
type IsDoNoTrackEnabledParams = {
_log: typeof log,
_navigator: ?typeof navigator,
_window: ?typeof window,
};
export function isDoNotTrackEnabled({
_log = log,
// The type above is correct but Flow complains about `Navigator` being
// incompatible with `null`, so $FlowIgnore.
_navigator = typeof navigator !== 'undefined' ? navigator : null,
_window = typeof window !== 'undefined' ? window : null,
}: IsDoNoTrackEnabledParams = {}): boolean {
if (!_navigator || !_window) {
return false;
}
// We ignore things like `msDoNotTrack` because they are for older,
// unsupported browsers and don't really respect the DNT spec. This
// covers new versions of IE/Edge, Firefox from 32+, Chrome, Safari, and
// any browsers built on these stacks (Chromium, Tor Browser, etc.).
const dnt = _navigator.doNotTrack || _window.doNotTrack;
if (dnt === '1') {
_log.info('Do Not Track is enabled');
return true;
}
// Known DNT values not set, so we will assume it's off.
return false;
}
type TrackingParams = {
_config: typeof config,
_isDoNotTrackEnabled: typeof isDoNotTrackEnabled,
_getCLS: typeof getCLS,
_getFID: typeof getFID,
_getLCP: typeof getLCP,
};
const makeTrackingEventData = ({
action,
category,
label,
value,
}: MakeTrackingEventDataParams) => {
return {
eventAction: action,
eventCategory: category,
eventLabel: label,
eventValue: value,
hitType: 'event',
};
};
export class Tracking {
_log: typeof log;
logPrefix: string;
trackingEnabled: boolean;
sendWebVitals: boolean;
// Tracking IDs for UA and GA4
id: string;
ga4Id: string;
constructor({
_config = config,
_isDoNotTrackEnabled = isDoNotTrackEnabled,
_getCLS = getCLS,
_getFID = getFID,
_getLCP = getLCP,
}: TrackingParams = {}) {
if (typeof window === 'undefined') {
return;
}
this._log = log;
this.logPrefix = '[GA]'; // this gets updated below
this.id = _config.get('trackingId');
this.ga4Id = _config.get('ga4PropertyId');
if (!convertBoolean(_config.get('trackingEnabled'))) {
this.log('GA disabled because trackingEnabled was false');
this.trackingEnabled = false;
} else if (!this.id && !this.ga4Id) {
this.log('GA Disabled because UA and GA4 trackingIds are empty');
this.trackingEnabled = false;
} else if (_isDoNotTrackEnabled()) {
this.log(oneLine`Do Not Track Enabled; Google Analytics not
loaded and tracking disabled`);
this.trackingEnabled = false;
} else {
this.log('Google Analytics is enabled');
this.trackingEnabled = true;
}
this.logPrefix = `[GA: ${this.trackingEnabled ? 'ON' : 'OFF'}]`;
if (this.trackingEnabled) {
// Create a Flow typed variable for `ga`.
declare var ga: {|
(string, string, ?string): void,
q: Array<string>,
l: number,
|};
/* eslint-disable */
// Snippet from Google UA docs: http://bit.ly/1O6Dsdh
window.ga =
window.ga ||
function () {
(ga.q = ga.q || []).push(arguments);
};
ga.l = +new Date();
/* eslint-enable */
ga('create', this.id, 'auto');
ga('set', 'transport', 'beacon');
if (convertBoolean(_config.get('trackingSendInitPageView'))) {
ga('send', 'pageview');
}
// Set a custom dimension; this allows us to tell which front-end
// (addons-frontend vs addons-server) is being used in analytics.
ga('set', 'dimension3', 'addons-frontend');
if (convertBoolean(_config.get('trackingSendWebVitals'))) {
this.log('trackingSendWebVitals is enabled');
// $FlowFixMe: Deal with method-unbinding error.
const sendWebVitalStats = this.sendWebVitalStats.bind(this);
_getCLS(sendWebVitalStats);
_getFID(sendWebVitalStats);
_getLCP(sendWebVitalStats);
}
// GA4 setup
window.dataLayer = window.dataLayer || [];
const extraConfig = _config.get('ga4DebugMode')
? { debug_mode: true }
: {};
// $FlowIgnore
this._ga4('js', new Date());
// $FlowIgnore
this._ga4('config', this.ga4Id, extraConfig);
}
}
sendWebVitalStats({
delta,
id,
name,
value,
}: {|
delta: number,
id: number,
name: string,
value: number,
|}) {
this.log('sendWebVitalStats', { delta, id, name, value });
// Google Analytics metrics must be integers, so the value is rounded.
// For CLS the value is first multiplied by 1000 for greater precision
// (note: increase the multiplier for greater precision if needed).
const adjustedDelta = Math.round(name === 'CLS' ? delta * 1000 : delta);
this._ga('send', 'event', {
eventCategory: 'Web Vitals',
eventAction: name,
// The `id` value will be unique to the current page load. When sending
// multiple values from the same page (e.g. for CLS), Google Analytics
// can compute a total by grouping on this ID (note: requires
// `eventLabel` to be a dimension in your report).
eventLabel: id,
eventValue: adjustedDelta,
// Use a non-interaction event to avoid affecting bounce rate.
nonInteraction: true,
// Use `sendBeacon()` if the browser supports it.
transport: 'beacon',
});
// Also send to GA4.
// See https://github.com/GoogleChrome/web-vitals#using-gtagjs-google-analytics-4
// $FlowIgnore
this._ga4('event', name, {
value: adjustedDelta,
metric_id: id,
metric_value: value,
metric_delta: adjustedDelta,
});
}
log(message: string, obj?: Object) {
if (this._log) {
const pattern = typeof obj === 'undefined' ? '%s %s' : '%s %s: %o';
// eslint-disable-next-line amo/only-log-strings
this._log.info(pattern, this.logPrefix, message, obj);
}
}
_ga(...args: Array<mixed>) {
if (this.trackingEnabled) {
window.ga(...args);
}
}
_ga4() {
if (this.trackingEnabled) {
/* eslint-disable */
// $FlowIgnore
dataLayer.push(arguments);
/* eslint-enable */
}
}
/*
* Param Type Required Description
* obj.action String Yes The type of interaction
* (e.g. click)
* obj.category String Yes Typically the object
* that was interacted
* with (e.g. button)
* obj.label String No Useful for categorizing
* events (e.g. nav
* buttons)
* obj.sendSecondEventWithOverrides Object No If passed, an extra
* event will be sent
* using the object's
* properties as overrides
* obj.value Number No Values must be
* non-negative.
* Useful to pass counts
* (e.g. 4 times)
*/
sendEvent({
_config = config,
action,
category,
label,
sendSecondEventWithOverrides,
value,
}: SendTrackingEventParams) {
if (!category) {
throw new Error('sendEvent: category is required');
}
if (!action) {
throw new Error('sendEvent: action is required');
}
if (_config.get('server')) {
// It is not possible to send GA events from the server, but a developer
// might call this function from code that executes on the server. This
// exception will act as a warning that the code should be altered or
// moved.
throw new Error('sendEvent: cannot send tracking events on the server');
} else {
const trackingData = { action, category, label, value };
const data = makeTrackingEventData(trackingData);
this._ga('send', data);
// Also send the event to GA4
// $FlowIgnore
this._ga4('event', data.eventCategory, data);
this.log('sendEvent', data);
if (typeof sendSecondEventWithOverrides === 'object') {
const secondEventData = makeTrackingEventData({
...trackingData,
...sendSecondEventWithOverrides,
});
this._ga('send', secondEventData);
// Also send the event to GA4
// $FlowIgnore
this._ga4('event', secondEventData.eventCategory, secondEventData);
this.log('sendEvent', secondEventData);
}
}
}
/*
* Should be called when a view changes or a routing update.
* This is not needed by GA4.
*/
setPage(page: string) {
if (!page) {
throw new Error('setPage: page is required');
}
this._ga('set', 'page', page);
this.log('setPage', page);
}
pageView(data: Object = {}) {
// See: https://developers.google.com/analytics/devguides/collection/analyticsjs/pages#pageview_fields
this._ga('send', { hitType: 'pageview', ...data });
this.log('pageView', data);
}
/*
* Can be called to set a dimension which will be sent with all subsequent
* calls to GA.
*/
setDimension({ dimension, value }: {| dimension: string, value: string |}) {
invariant(dimension, 'A dimension is required');
invariant(value, 'A value is required');
this._ga('set', dimension, value);
this.log('set', { dimension, value });
}
/*
* Can be called to set user properties which will be sent with all subsequent
* calls to GA4.
*/
setUserProperties(props: { [string]: string }) {
// $FlowIgnore
this._ga4('set', 'user_properties', props);
this.log('setUserProperties', props);
}
}
export function getAddonTypeForTracking(type: string): string {
return (
{
[ADDON_TYPE_DICT]: TRACKING_TYPE_EXTENSION,
[ADDON_TYPE_EXTENSION]: TRACKING_TYPE_EXTENSION,
[ADDON_TYPE_LANG]: TRACKING_TYPE_EXTENSION,
[ADDON_TYPE_STATIC_THEME]: TRACKING_TYPE_STATIC_THEME,
}[type] || TRACKING_TYPE_INVALID
);
}
export const getAddonEventCategory = (
type: string,
installAction: string,
): string => {
const isThemeType = ADDON_TYPE_STATIC_THEME === type;
switch (installAction) {
case ENABLE_ACTION:
return isThemeType ? ENABLE_THEME_CATEGORY : ENABLE_EXTENSION_CATEGORY;
case INSTALL_CANCELLED_ACTION:
return isThemeType
? INSTALL_CANCELLED_THEME_CATEGORY
: INSTALL_CANCELLED_EXTENSION_CATEGORY;
case INSTALL_DOWNLOAD_FAILED_ACTION:
return isThemeType
? INSTALL_DOWNLOAD_FAILED_THEME_CATEGORY
: INSTALL_DOWNLOAD_FAILED_EXTENSION_CATEGORY;
case INSTALL_STARTED_ACTION:
return isThemeType
? INSTALL_STARTED_THEME_CATEGORY
: INSTALL_STARTED_EXTENSION_CATEGORY;
case UNINSTALL_ACTION:
return isThemeType
? UNINSTALL_THEME_CATEGORY
: UNINSTALL_EXTENSION_CATEGORY;
default:
return isThemeType ? INSTALL_THEME_CATEGORY : INSTALL_EXTENSION_CATEGORY;
}
};
export default (new Tracking(): Tracking);