diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-10 18:10:13 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-10 18:10:13 +0000 |
commit | 1c17f34a4bdf51030a36985b097161a914fb7ea8 (patch) | |
tree | 8a9f526af2c12de6fee6f78e83220da64ffa449c /app | |
parent | c74c13e2e1f3287e98f2519b098180bb30d358af (diff) | |
download | gitlab-ce-1c17f34a4bdf51030a36985b097161a914fb7ea8.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r-- | app/assets/javascripts/tracking/constants.js | 19 | ||||
-rw-r--r-- | app/assets/javascripts/tracking/dispatch_snowplow_event.js | 23 | ||||
-rw-r--r-- | app/assets/javascripts/tracking/index.js | 266 | ||||
-rw-r--r-- | app/assets/javascripts/tracking/tracking.js | 192 | ||||
-rw-r--r-- | app/assets/javascripts/tracking/utils.js | 94 |
5 files changed, 359 insertions, 235 deletions
diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js index cd0af59e4fe..dfca633dc24 100644 --- a/app/assets/javascripts/tracking/constants.js +++ b/app/assets/javascripts/tracking/constants.js @@ -1 +1,20 @@ 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: [] }, + }, +}; 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..7ae3a8cd834 --- /dev/null +++ b/app/assets/javascripts/tracking/tracking.js @@ -0,0 +1,192 @@ +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); + }, + }, + }; + } +} diff --git a/app/assets/javascripts/tracking/utils.js b/app/assets/javascripts/tracking/utils.js new file mode 100644 index 00000000000..3647280c41a --- /dev/null +++ b/app/assets/javascripts/tracking/utils.js @@ -0,0 +1,94 @@ +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; +}; |