Commit ce0582d8 authored by Brandon Labuschagne's avatar Brandon Labuschagne

Merge branch '333220-split-tracking-related-classes-into-specific-files-for-each-one' into 'master'

Split tracking related classes/functions into specific files

See merge request gitlab-org/gitlab!67698
parents baa59ee9 7bf18232
export const SNOWPLOW_JS_SOURCE = 'gitlab-javascript'; export const SNOWPLOW_JS_SOURCE = 'gitlab-javascript';
export const DEFAULT_SNOWPLOW_OPTIONS = {
namespace: 'gl',
hostname: window.location.hostname,
cookieDomain: window.location.hostname,
appId: '',
userFingerprint: false,
respectDoNotTrack: true,
forceSecureTracker: true,
eventMethod: 'post',
contexts: { webPage: true, performanceTiming: true },
formTracking: false,
linkClickTracking: false,
pageUnloadTimer: 10,
formTrackingConfig: {
forms: { allow: [] },
fields: { allow: [] },
},
};
import getStandardContext from './get_standard_context';
export function dispatchSnowplowEvent(
category = document.body.dataset.page,
action = 'generic',
data = {},
) {
if (!category) {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
throw new Error('Tracking: no category provided for tracking.');
}
const { label, property, value, extra = {} } = data;
const standardContext = getStandardContext({ extra });
const contexts = [standardContext];
if (data.context) {
contexts.push(data.context);
}
return window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
}
import { omitBy, isUndefined } from 'lodash'; import { DEFAULT_SNOWPLOW_OPTIONS } from './constants';
import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
import { getExperimentData } from '~/experimentation/utils';
import getStandardContext from './get_standard_context'; import getStandardContext from './get_standard_context';
import Tracking from './tracking';
const DEFAULT_SNOWPLOW_OPTIONS = { export { Tracking as default };
namespace: 'gl',
hostname: window.location.hostname,
cookieDomain: window.location.hostname,
appId: '',
userFingerprint: false,
respectDoNotTrack: true,
forceSecureTracker: true,
eventMethod: 'post',
contexts: { webPage: true, performanceTiming: true },
formTracking: false,
linkClickTracking: false,
pageUnloadTimer: 10,
formTrackingConfig: {
forms: { allow: [] },
fields: { allow: [] },
},
};
const addExperimentContext = (opts) => {
const { experiment, ...options } = opts;
if (experiment) {
const data = getExperimentData(experiment);
if (data) {
const context = { schema: TRACKING_CONTEXT_SCHEMA, data };
return { ...options, context };
}
}
return options;
};
const renameKey = (o, oldKey, newKey) => {
const ret = {};
delete Object.assign(ret, o, { [newKey]: o[oldKey] })[oldKey];
return ret;
};
const createEventPayload = (el, { suffix = '' } = {}) => {
const {
trackAction,
trackEvent,
trackValue,
trackExtra,
trackExperiment,
trackContext,
trackLabel,
trackProperty,
} = el?.dataset || {};
const action = (trackAction || trackEvent) + (suffix || '');
let value = trackValue || el.value || undefined;
if (el.type === 'checkbox' && !el.checked) value = 0;
let extra = trackExtra;
if (extra !== undefined) {
try {
extra = JSON.parse(extra);
} catch (e) {
extra = undefined;
}
}
const context = addExperimentContext({
experiment: trackExperiment,
context: trackContext,
});
const data = {
label: trackLabel,
property: trackProperty,
value,
extra,
...context,
};
return {
action,
data: omitBy(data, isUndefined),
};
};
const eventHandler = (e, func, opts = {}) => {
const el = e.target.closest('[data-track-event], [data-track-action]');
if (!el) return;
const { action, data } = createEventPayload(el, opts);
func(opts.category, action, data);
};
const eventHandlers = (category, func) => {
const handler = (opts) => (e) => eventHandler(e, func, { ...{ category }, ...opts });
const handlers = [];
handlers.push({ name: 'click', func: handler() });
handlers.push({ name: 'show.bs.dropdown', func: handler({ suffix: '_show' }) });
handlers.push({ name: 'hide.bs.dropdown', func: handler({ suffix: '_hide' }) });
return handlers;
};
const dispatchEvent = (category = document.body.dataset.page, action = 'generic', data = {}) => {
// eslint-disable-next-line @gitlab/require-i18n-strings
if (!category) throw new Error('Tracking: no category provided for tracking.');
const { label, property, value, extra = {} } = data;
const standardContext = getStandardContext({ extra });
const contexts = [standardContext];
if (data.context) {
contexts.push(data.context);
}
return window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
};
export default class Tracking {
static queuedEvents = [];
static initialized = false;
static trackable() {
return !['1', 'yes'].includes(
window.doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack,
);
}
static flushPendingEvents() {
this.initialized = true;
while (this.queuedEvents.length) {
dispatchEvent(...this.queuedEvents.shift());
}
}
static enabled() {
return typeof window.snowplow === 'function' && this.trackable();
}
static event(...eventData) {
if (!this.enabled()) return false;
if (!this.initialized) {
this.queuedEvents.push(eventData);
return false;
}
return dispatchEvent(...eventData);
}
static bindDocument(category = document.body.dataset.page, parent = document) {
if (!this.enabled() || parent.trackingBound) return [];
// eslint-disable-next-line no-param-reassign
parent.trackingBound = true;
const handlers = eventHandlers(category, (...args) => this.event(...args));
handlers.forEach((event) => parent.addEventListener(event.name, event.func));
return handlers;
}
static trackLoadEvents(category = document.body.dataset.page, parent = document) {
if (!this.enabled()) return [];
const loadEvents = parent.querySelectorAll(
'[data-track-action="render"], [data-track-event="render"]',
);
loadEvents.forEach((element) => {
const { action, data } = createEventPayload(element);
this.event(category, action, data);
});
return loadEvents;
}
static enableFormTracking(config, contexts = []) {
if (!this.enabled()) return;
if (!Array.isArray(config?.forms?.allow) && !Array.isArray(config?.fields?.allow)) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Unable to enable form event tracking without allow rules.');
}
// Ignore default/standard schema
const standardContext = getStandardContext();
const userProvidedContexts = contexts.filter(
(context) => context.schema !== standardContext.schema,
);
const mappedConfig = {};
if (config.forms) mappedConfig.forms = renameKey(config.forms, 'allow', 'whitelist');
if (config.fields) mappedConfig.fields = renameKey(config.fields, 'allow', 'whitelist');
const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts);
if (document.readyState === 'complete') enabler();
else {
document.addEventListener('readystatechange', () => {
if (document.readyState === 'complete') enabler();
});
}
}
static mixin(opts = {}) {
return {
computed: {
trackingCategory() {
const localCategory = this.tracking ? this.tracking.category : null;
return localCategory || opts.category;
},
trackingOptions() {
const options = addExperimentContext(opts);
return { ...options, ...this.tracking };
},
},
methods: {
track(action, data = {}) {
const category = data.category || this.trackingCategory;
const options = {
...this.trackingOptions,
...data,
};
Tracking.event(category, action, options);
},
},
};
}
}
/**
* Tracker initialization as defined in:
* https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-trackers/javascript-tracker/javascript-tracker-v2/tracker-setup/initializing-a-tracker-2/.
* It also dispatches any event emitted before its execution.
*
* @returns {undefined}
*/
export function initUserTracking() { export function initUserTracking() {
if (!Tracking.enabled()) return; if (!Tracking.enabled()) {
return;
}
const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions }; const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions };
window.snowplow('newTracker', opts.namespace, opts.hostname, opts); window.snowplow('newTracker', opts.namespace, opts.hostname, opts);
...@@ -242,8 +23,18 @@ export function initUserTracking() { ...@@ -242,8 +23,18 @@ export function initUserTracking() {
Tracking.flushPendingEvents(); Tracking.flushPendingEvents();
} }
/**
* Enables tracking of built-in events: page views, page pings.
* Optionally enables form and link tracking (automatically).
* Attaches event handlers for data-attributes powered events, and
* load-events (on render).
*
* @returns {undefined}
*/
export function initDefaultTrackers() { export function initDefaultTrackers() {
if (!Tracking.enabled()) return; if (!Tracking.enabled()) {
return;
}
const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions }; const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions };
...@@ -252,8 +43,13 @@ export function initDefaultTrackers() { ...@@ -252,8 +43,13 @@ export function initDefaultTrackers() {
const standardContext = getStandardContext(); const standardContext = getStandardContext();
window.snowplow('trackPageView', null, [standardContext]); window.snowplow('trackPageView', null, [standardContext]);
if (window.snowplowOptions.formTracking) Tracking.enableFormTracking(opts.formTrackingConfig); if (window.snowplowOptions.formTracking) {
if (window.snowplowOptions.linkClickTracking) window.snowplow('enableLinkClickTracking'); Tracking.enableFormTracking(opts.formTrackingConfig);
}
if (window.snowplowOptions.linkClickTracking) {
window.snowplow('enableLinkClickTracking');
}
Tracking.bindDocument(); Tracking.bindDocument();
Tracking.trackLoadEvents(); Tracking.trackLoadEvents();
......
import { dispatchSnowplowEvent } from './dispatch_snowplow_event';
import getStandardContext from './get_standard_context';
import { getEventHandlers, createEventPayload, renameKey, addExperimentContext } from './utils';
export default class Tracking {
static queuedEvents = [];
static initialized = false;
/**
* (Legacy) Determines if tracking is enabled at the user level.
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/DNT.
*
* @returns {Boolean}
*/
static trackable() {
return !['1', 'yes'].includes(
window.doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack,
);
}
/**
* Determines if Snowplow is available/enabled.
*
* @returns {Boolean}
*/
static enabled() {
return typeof window.snowplow === 'function' && this.trackable();
}
/**
* Dispatches a structured event per our taxonomy:
* https://docs.gitlab.com/ee/development/snowplow/index.html#structured-event-taxonomy.
*
* If the library is not initialized and events are trying to be
* dispatched (data-attributes, load-events), they will be added
* to a queue to be flushed afterwards.
*
* @param {...any} eventData defined event taxonomy
* @returns {undefined|Boolean}
*/
static event(...eventData) {
if (!this.enabled()) {
return false;
}
if (!this.initialized) {
this.queuedEvents.push(eventData);
return false;
}
return dispatchSnowplowEvent(...eventData);
}
/**
* Dispatches any event emitted before initialization.
*
* @returns {undefined}
*/
static flushPendingEvents() {
this.initialized = true;
while (this.queuedEvents.length) {
dispatchSnowplowEvent(...this.queuedEvents.shift());
}
}
/**
* Attaches event handlers for data-attributes powered events.
*
* @param {String} category - the default category for all events
* @param {HTMLElement} parent - element containing data-attributes
* @returns {Array}
*/
static bindDocument(category = document.body.dataset.page, parent = document) {
if (!this.enabled() || parent.trackingBound) {
return [];
}
// eslint-disable-next-line no-param-reassign
parent.trackingBound = true;
const handlers = getEventHandlers(category, (...args) => this.event(...args));
handlers.forEach((event) => parent.addEventListener(event.name, event.func));
return handlers;
}
/**
* Attaches event handlers for load-events (on render).
*
* @param {String} category - the default category for all events
* @param {HTMLElement} parent - element containing event targets
* @returns {Array}
*/
static trackLoadEvents(category = document.body.dataset.page, parent = document) {
if (!this.enabled()) {
return [];
}
const loadEvents = parent.querySelectorAll(
'[data-track-action="render"], [data-track-event="render"]',
);
loadEvents.forEach((element) => {
const { action, data } = createEventPayload(element);
this.event(category, action, data);
});
return loadEvents;
}
/**
* Enable Snowplow automatic form tracking.
* The config param requires at least one array of either forms
* class names, or field name attributes.
* https://docs.gitlab.com/ee/development/snowplow/index.html#form-tracking.
*
* @param {Object} config
* @param {Array} contexts
* @returns {undefined}
*/
static enableFormTracking(config, contexts = []) {
if (!this.enabled()) {
return;
}
if (!Array.isArray(config?.forms?.allow) && !Array.isArray(config?.fields?.allow)) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Unable to enable form event tracking without allow rules.');
}
// Ignore default/standard schema
const standardContext = getStandardContext();
const userProvidedContexts = contexts.filter(
(context) => context.schema !== standardContext.schema,
);
const mappedConfig = {};
if (config.forms) {
mappedConfig.forms = renameKey(config.forms, 'allow', 'whitelist');
}
if (config.fields) {
mappedConfig.fields = renameKey(config.fields, 'allow', 'whitelist');
}
const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts);
if (document.readyState === 'complete') {
enabler();
} else {
document.addEventListener('readystatechange', () => {
if (document.readyState === 'complete') {
enabler();
}
});
}
}
/**
* Returns an implementation of this class in the form of
* a Vue mixin.
*
* @param {Object} opts - default options for all events
* @returns {Object}
*/
static mixin(opts = {}) {
return {
computed: {
trackingCategory() {
const localCategory = this.tracking ? this.tracking.category : null;
return localCategory || opts.category;
},
trackingOptions() {
const options = addExperimentContext(opts);
return { ...options, ...this.tracking };
},
},
methods: {
track(action, data = {}) {
const category = data.category || this.trackingCategory;
const options = {
...this.trackingOptions,
...data,
};
Tracking.event(category, action, options);
},
},
};
}
}
import { omitBy, isUndefined } from 'lodash';
import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
import { getExperimentData } from '~/experimentation/utils';
export const addExperimentContext = (opts) => {
const { experiment, ...options } = opts;
if (experiment) {
const data = getExperimentData(experiment);
if (data) {
const context = { schema: TRACKING_CONTEXT_SCHEMA, data };
return { ...options, context };
}
}
return options;
};
export const createEventPayload = (el, { suffix = '' } = {}) => {
const {
trackAction,
trackEvent,
trackValue,
trackExtra,
trackExperiment,
trackContext,
trackLabel,
trackProperty,
} = el?.dataset || {};
const action = (trackAction || trackEvent) + (suffix || '');
let value = trackValue || el.value || undefined;
if (el.type === 'checkbox' && !el.checked) {
value = 0;
}
let extra = trackExtra;
if (extra !== undefined) {
try {
extra = JSON.parse(extra);
} catch (e) {
extra = undefined;
}
}
const context = addExperimentContext({
experiment: trackExperiment,
context: trackContext,
});
const data = {
label: trackLabel,
property: trackProperty,
value,
extra,
...context,
};
return {
action,
data: omitBy(data, isUndefined),
};
};
export const eventHandler = (e, func, opts = {}) => {
const el = e.target.closest('[data-track-event], [data-track-action]');
if (!el) {
return;
}
const { action, data } = createEventPayload(el, opts);
func(opts.category, action, data);
};
export const getEventHandlers = (category, func) => {
const handler = (opts) => (e) => eventHandler(e, func, { ...{ category }, ...opts });
const handlers = [];
handlers.push({ name: 'click', func: handler() });
handlers.push({ name: 'show.bs.dropdown', func: handler({ suffix: '_show' }) });
handlers.push({ name: 'hide.bs.dropdown', func: handler({ suffix: '_hide' }) });
return handlers;
};
export const renameKey = (o, oldKey, newKey) => {
const ret = {};
delete Object.assign(ret, o, { [newKey]: o[oldKey] })[oldKey];
return ret;
};
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment