From a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Wed, 16 Jun 2021 18:25:58 +0000 Subject: Add latest changes from gitlab-org/gitlab@14-0-stable-ee --- app/assets/javascripts/tracking/constants.js | 1 + .../javascripts/tracking/get_standard_context.js | 14 ++ app/assets/javascripts/tracking/index.js | 251 +++++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 app/assets/javascripts/tracking/constants.js create mode 100644 app/assets/javascripts/tracking/get_standard_context.js create mode 100644 app/assets/javascripts/tracking/index.js (limited to 'app/assets/javascripts/tracking') diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js new file mode 100644 index 00000000000..cd0af59e4fe --- /dev/null +++ b/app/assets/javascripts/tracking/constants.js @@ -0,0 +1 @@ +export const SNOWPLOW_JS_SOURCE = 'gitlab-javascript'; diff --git a/app/assets/javascripts/tracking/get_standard_context.js b/app/assets/javascripts/tracking/get_standard_context.js new file mode 100644 index 00000000000..c318029323d --- /dev/null +++ b/app/assets/javascripts/tracking/get_standard_context.js @@ -0,0 +1,14 @@ +import { SNOWPLOW_JS_SOURCE } from './constants'; + +export default function getStandardContext({ extra = {} } = {}) { + const { schema, data = {} } = { ...window.gl?.snowplowStandardContext }; + + return { + schema, + data: { + ...data, + source: SNOWPLOW_JS_SOURCE, + extra: extra || data.extra, + }, + }; +} diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js new file mode 100644 index 00000000000..e0ba7dba97f --- /dev/null +++ b/app/assets/javascripts/tracking/index.js @@ -0,0 +1,251 @@ +import { omitBy, isUndefined } from 'lodash'; +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import { getExperimentData } from '~/experimentation/utils'; +import getStandardContext from './get_standard_context'; + +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 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 = { + forms: { whitelist: config.forms?.allow || [] }, + fields: { whitelist: config.fields?.allow || [] }, + }; + + const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts); + + if (document.readyState !== 'loading') enabler(); + else document.addEventListener('DOMContentLoaded', 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 function initUserTracking() { + if (!Tracking.enabled()) return; + + const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions }; + window.snowplow('newTracker', opts.namespace, opts.hostname, opts); + + document.dispatchEvent(new Event('SnowplowInitialized')); + Tracking.flushPendingEvents(); +} + +export function initDefaultTrackers() { + if (!Tracking.enabled()) return; + + const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions }; + + window.snowplow('enableActivityTracking', 30, 30); + // must be after enableActivityTracking + const standardContext = getStandardContext(); + window.snowplow('trackPageView', null, [standardContext]); + + if (window.snowplowOptions.formTracking) Tracking.enableFormTracking(opts.formTrackingConfig); + if (window.snowplowOptions.linkClickTracking) window.snowplow('enableLinkClickTracking'); + + Tracking.bindDocument(); + Tracking.trackLoadEvents(); +} -- cgit v1.2.3