Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-08-19 12:08:42 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-08-19 12:08:42 +0300
commitb76ae638462ab0f673e5915986070518dd3f9ad3 (patch)
treebdab0533383b52873be0ec0eb4d3c66598ff8b91 /app/assets/javascripts/tracking
parent434373eabe7b4be9593d18a585fb763f1e5f1a6f (diff)
Add latest changes from gitlab-org/gitlab@14-2-stable-eev14.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/tracking')
-rw-r--r--app/assets/javascripts/tracking/constants.js25
-rw-r--r--app/assets/javascripts/tracking/dispatch_snowplow_event.js23
-rw-r--r--app/assets/javascripts/tracking/index.js266
-rw-r--r--app/assets/javascripts/tracking/tracking.js193
-rw-r--r--app/assets/javascripts/tracking/utils.js102
5 files changed, 374 insertions, 235 deletions
diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js
index cd0af59e4fe..598111e4086 100644
--- a/app/assets/javascripts/tracking/constants.js
+++ b/app/assets/javascripts/tracking/constants.js
@@ -1 +1,26 @@
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: [] },
+ },
+};
+
+export const ACTION_ATTR_SELECTOR = '[data-track-action]';
+export const LOAD_ACTION_ATTR_SELECTOR = '[data-track-action="render"]';
+
+export const DEPRECATED_EVENT_ATTR_SELECTOR = '[data-track-event]';
+export const DEPRECATED_LOAD_EVENT_ATTR_SELECTOR = '[data-track-event="render"]';
diff --git a/app/assets/javascripts/tracking/dispatch_snowplow_event.js b/app/assets/javascripts/tracking/dispatch_snowplow_event.js
new file mode 100644
index 00000000000..bc9d7384ea4
--- /dev/null
+++ b/app/assets/javascripts/tracking/dispatch_snowplow_event.js
@@ -0,0 +1,23 @@
+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);
+}
diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js
index 3714cac3fba..5417e2d969b 100644
--- a/app/assets/javascripts/tracking/index.js
+++ b/app/assets/javascripts/tracking/index.js
@@ -1,239 +1,20 @@
-import { omitBy, isUndefined } from 'lodash';
-import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
-import { getExperimentData } from '~/experimentation/utils';
+import { DEFAULT_SNOWPLOW_OPTIONS } from './constants';
import getStandardContext from './get_standard_context';
+import Tracking from './tracking';
-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: [] },
- },
-};
-
-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);
- },
- },
- };
- }
-}
+export { Tracking as default };
+/**
+ * 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() {
- if (!Tracking.enabled()) return;
+ if (!Tracking.enabled()) {
+ return;
+ }
const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions };
window.snowplow('newTracker', opts.namespace, opts.hostname, opts);
@@ -242,8 +23,18 @@ export function initUserTracking() {
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() {
- if (!Tracking.enabled()) return;
+ if (!Tracking.enabled()) {
+ return;
+ }
const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions };
@@ -252,8 +43,13 @@ export function initDefaultTrackers() {
const standardContext = getStandardContext();
window.snowplow('trackPageView', null, [standardContext]);
- if (window.snowplowOptions.formTracking) Tracking.enableFormTracking(opts.formTrackingConfig);
- if (window.snowplowOptions.linkClickTracking) window.snowplow('enableLinkClickTracking');
+ if (window.snowplowOptions.formTracking) {
+ Tracking.enableFormTracking(opts.formTrackingConfig);
+ }
+
+ if (window.snowplowOptions.linkClickTracking) {
+ window.snowplow('enableLinkClickTracking');
+ }
Tracking.bindDocument();
Tracking.trackLoadEvents();
diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js
new file mode 100644
index 00000000000..a1f745bc172
--- /dev/null
+++ b/app/assets/javascripts/tracking/tracking.js
@@ -0,0 +1,193 @@
+import { LOAD_ACTION_ATTR_SELECTOR, DEPRECATED_LOAD_EVENT_ATTR_SELECTOR } from './constants';
+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(
+ `${LOAD_ACTION_ATTR_SELECTOR}, ${DEPRECATED_LOAD_EVENT_ATTR_SELECTOR}`,
+ );
+
+ 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);
+ },
+ },
+ };
+ }
+}
diff --git a/app/assets/javascripts/tracking/utils.js b/app/assets/javascripts/tracking/utils.js
new file mode 100644
index 00000000000..1189b2168ad
--- /dev/null
+++ b/app/assets/javascripts/tracking/utils.js
@@ -0,0 +1,102 @@
+import { omitBy, isUndefined } from 'lodash';
+import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
+import { getExperimentData } from '~/experimentation/utils';
+import {
+ ACTION_ATTR_SELECTOR,
+ LOAD_ACTION_ATTR_SELECTOR,
+ DEPRECATED_EVENT_ATTR_SELECTOR,
+ DEPRECATED_LOAD_EVENT_ATTR_SELECTOR,
+} from './constants';
+
+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 actionSelector = `${ACTION_ATTR_SELECTOR}:not(${LOAD_ACTION_ATTR_SELECTOR})`;
+ const deprecatedEventSelector = `${DEPRECATED_EVENT_ATTR_SELECTOR}:not(${DEPRECATED_LOAD_EVENT_ATTR_SELECTOR})`;
+ const el = e.target.closest(`${actionSelector}, ${deprecatedEventSelector}`);
+
+ 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;
+};