From b75c135b4b4c588dadfc9540f16c06e450406d91 Mon Sep 17 00:00:00 2001 From: Axel Garcia <agarcia@gitlab.com> Date: Mon, 31 May 2021 07:19:07 +0000 Subject: [PATCH] Allow `extra` parameter for Snowplow events Changelog: changed MR: !56869 --- app/assets/javascripts/tracking/constants.js | 1 + .../tracking/get_standard_context.js | 14 +++ .../{tracking.js => tracking/index.js} | 63 ++++++++---- doc/development/snowplow/index.md | 19 +++- .../tracking/get_standard_context_spec.js | 53 ++++++++++ spec/frontend/tracking_spec.js | 98 ++++++++++++------- 6 files changed, 188 insertions(+), 60 deletions(-) create mode 100644 app/assets/javascripts/tracking/constants.js create mode 100644 app/assets/javascripts/tracking/get_standard_context.js rename app/assets/javascripts/{tracking.js => tracking/index.js} (84%) create mode 100644 spec/frontend/tracking/get_standard_context_spec.js diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js new file mode 100644 index 000000000000..cd0af59e4fe8 --- /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 000000000000..c318029323de --- /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.js b/app/assets/javascripts/tracking/index.js similarity index 84% rename from app/assets/javascripts/tracking.js rename to app/assets/javascripts/tracking/index.js index 3b1af5eba93a..0c920ac927b1 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking/index.js @@ -1,16 +1,7 @@ import { omitBy, isUndefined } from 'lodash'; import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; import { getExperimentData } from '~/experimentation/utils'; - -const standardContext = { ...window.gl?.snowplowStandardContext }; - -export const STANDARD_CONTEXT = { - schema: standardContext.schema, - data: { - ...(standardContext.data || {}), - source: 'gitlab-javascript', - }, -}; +import getStandardContext from './get_standard_context'; const DEFAULT_SNOWPLOW_OPTIONS = { namespace: 'gl', @@ -44,19 +35,41 @@ const addExperimentContext = (opts) => { }; const createEventPayload = (el, { suffix = '' } = {}) => { - const action = (el.dataset.trackAction || el.dataset.trackEvent) + (suffix || ''); - let value = el.dataset.trackValue || el.value || undefined; + 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 = false; + let extra = trackExtra; + + if (extra !== undefined) { + try { + extra = JSON.parse(extra); + } catch (e) { + extra = undefined; + } + } + const context = addExperimentContext({ - experiment: el.dataset.trackExperiment, - context: el.dataset.trackContext, + experiment: trackExperiment, + context: trackContext, }); const data = { - label: el.dataset.trackLabel, - property: el.dataset.trackProperty, + label: trackLabel, + property: trackProperty, value, + extra, ...context, }; @@ -88,8 +101,10 @@ const dispatchEvent = (category = document.body.dataset.page, action = 'generic' // eslint-disable-next-line @gitlab/require-i18n-strings if (!category) throw new Error('Tracking: no category provided for tracking.'); - const { label, property, value } = data; - const contexts = [STANDARD_CONTEXT]; + const { label, property, value, extra = {} } = data; + + const standardContext = getStandardContext({ extra }); + const contexts = [standardContext]; if (data.context) { contexts.push(data.context); @@ -165,13 +180,18 @@ export default class Tracking { throw new Error('Unable to enable form event tracking without allow rules.'); } - contexts.unshift(STANDARD_CONTEXT); + // 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, contexts); + const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts); if (document.readyState !== 'loading') enabler(); else document.addEventListener('DOMContentLoaded', enabler); @@ -220,7 +240,8 @@ export function initDefaultTrackers() { window.snowplow('enableActivityTracking', 30, 30); // must be after enableActivityTracking - window.snowplow('trackPageView', null, [STANDARD_CONTEXT]); + const standardContext = getStandardContext(); + window.snowplow('trackPageView', null, [standardContext]); if (window.snowplowOptions.formTracking) Tracking.enableFormTracking(opts.formTrackingConfig); if (window.snowplowOptions.linkClickTracking) window.snowplow('enableLinkClickTracking'); diff --git a/doc/development/snowplow/index.md b/doc/development/snowplow/index.md index c4f5125ac0dd..504453b8e6ff 100644 --- a/doc/development/snowplow/index.md +++ b/doc/development/snowplow/index.md @@ -155,13 +155,13 @@ Snowplow JS adds many [web-specific parameters](https://docs.snowplowanalytics.c ## Implementing Snowplow JS (Frontend) tracking -GitLab provides `Tracking`, an interface that wraps the [Snowplow JavaScript Tracker](https://github.com/snowplow/snowplow/wiki/javascript-tracker) for tracking custom events. The simplest way to use it is to add `data-` attributes to clickable elements and dropdowns. There is also a Vue mixin (exposing a `track` method), and the static method `Tracking.event`. Each of these requires at minimum a `category` and an `action`. Additional data can be provided that adheres to our [Structured event taxonomy](#structured-event-taxonomy). +GitLab provides `Tracking`, an interface that wraps the [Snowplow JavaScript Tracker](https://github.com/snowplow/snowplow/wiki/javascript-tracker) for tracking custom events. The simplest way to use it is to add `data-` attributes to clickable elements and dropdowns. There is also a Vue mixin (exposing a `track` method), and the static method `Tracking.event`. Each of these requires at minimum a `category` and an `action`. You can provide additional [Structured event taxonomy](#structured-event-taxonomy) properties along with an `extra` object that accepts key-value pairs. | field | type | default value | description | |:-----------|:-------|:---------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `category` | string | `document.body.dataset.page` | Page or subsection of a page that events are being captured within. | | `action` | string | generic | Action the user is taking. Clicks should be `click` and activations should be `activate`, so for example, focusing a form field would be `activate_form_input`, and clicking a button would be `click_button`. | -| `data` | object | `{}` | Additional data such as `label`, `property`, `value`, and `context` as described in our [Structured event taxonomy](#structured-event-taxonomy). | +| `data` | object | `{}` | Additional data such as `label`, `property`, `value`, `context` (as described in our [Structured event taxonomy](#structured-event-taxonomy)), and `extra` (key-value pairs object). | ### Usage recommendations @@ -171,7 +171,7 @@ GitLab provides `Tracking`, an interface that wraps the [Snowplow JavaScript Tra ### Tracking with data attributes -When working within HAML (or Vue templates) we can add `data-track-*` attributes to elements of interest. All elements that have a `data-track-action` attribute automatically have event tracking bound on clicks. +When working within HAML (or Vue templates) we can add `data-track-*` attributes to elements of interest. All elements that have a `data-track-action` attribute automatically have event tracking bound on clicks. You can provide extra data as a valid JSON string using `data-track-extra`. Below is an example of `data-track-*` attributes assigned to a button: @@ -184,6 +184,7 @@ Below is an example of `data-track-*` attributes assigned to a button: data-track-action="click_button" data-track-label="template_preview" data-track-property="my-template" + data-track-extra='{ "template_variant": "primary" }' /> ``` @@ -197,6 +198,7 @@ Below is a list of supported `data-track-*` attributes: | `data-track-label` | false | The `label` as described in our [Structured event taxonomy](#structured-event-taxonomy). | | `data-track-property` | false | The `property` as described in our [Structured event taxonomy](#structured-event-taxonomy). | | `data-track-value` | false | The `value` as described in our [Structured event taxonomy](#structured-event-taxonomy). If omitted, this is the element's `value` property or an empty string. For checkboxes, the default value is the element's checked attribute or `false` when unchecked. | +| `data-track-extra` | false | A key-value pairs object passed as a valid JSON string. This is added to the `extra` property in our [`gitlab_standard`](#gitlab_standard) schema. | | `data-track-context` | false | The `context` as described in our [Structured event taxonomy](#structured-event-taxonomy). | #### Available helpers @@ -287,6 +289,7 @@ export default { // category: '', // property: '', // value: '', + // extra: {}, }, }; }, @@ -357,6 +360,10 @@ button.addEventListener('click', () => { Tracking.event('dashboard:projects:index', 'click_button', { label: 'create_from_template', property: 'template_preview', + extra: { + templateVariant: 'primary', + valid: true, + }, }); }); ``` @@ -381,6 +388,10 @@ describe('MyTracking', () => { expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { label: 'create_from_template', property: 'template_preview', + extra: { + templateVariant: 'primary', + valid: true, + }, }); }); }); @@ -446,7 +457,7 @@ There are several tools for developing and testing Snowplow Event ### Test frontend events -To test frontend events in development: +To test frontend events in development: - [Enable Snowplow in the admin area](#enabling-snowplow). - Turn off any ad blockers that would prevent Snowplow JS from loading in your environment. diff --git a/spec/frontend/tracking/get_standard_context_spec.js b/spec/frontend/tracking/get_standard_context_spec.js new file mode 100644 index 000000000000..b7bdc56b801d --- /dev/null +++ b/spec/frontend/tracking/get_standard_context_spec.js @@ -0,0 +1,53 @@ +import { SNOWPLOW_JS_SOURCE } from '~/tracking/constants'; +import getStandardContext from '~/tracking/get_standard_context'; + +describe('~/tracking/get_standard_context', () => { + beforeEach(() => { + window.gl = window.gl || {}; + window.gl.snowplowStandardContext = {}; + }); + + it('returns default object if called without server context', () => { + expect(getStandardContext()).toStrictEqual({ + schema: undefined, + data: { + source: SNOWPLOW_JS_SOURCE, + extra: {}, + }, + }); + }); + + it('returns filled object if called with server context', () => { + window.gl.snowplowStandardContext = { + schema: 'iglu:com.gitlab/gitlab_standard', + data: { + environment: 'testing', + }, + }; + + expect(getStandardContext()).toStrictEqual({ + schema: 'iglu:com.gitlab/gitlab_standard', + data: { + environment: 'testing', + source: SNOWPLOW_JS_SOURCE, + extra: {}, + }, + }); + }); + + it('always overrides `source` property', () => { + window.gl.snowplowStandardContext = { + data: { + source: 'custom_source', + }, + }; + + expect(getStandardContext().data.source).toBe(SNOWPLOW_JS_SOURCE); + }); + + it('accepts optional `extra` property', () => { + const extra = { foo: 'bar' }; + + expect(getStandardContext({ extra }).data.extra).toBe(extra); + }); +}); diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index 9fd5eab6381d..9d60f9232ebb 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -1,16 +1,32 @@ import { setHTMLFixture } from 'helpers/fixtures'; import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; import { getExperimentData } from '~/experimentation/utils'; -import Tracking, { initUserTracking, initDefaultTrackers, STANDARD_CONTEXT } from '~/tracking'; +import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking'; +import getStandardContext from '~/tracking/get_standard_context'; jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() })); describe('Tracking', () => { + let standardContext; let snowplowSpy; let bindDocumentSpy; let trackLoadEventsSpy; let enableFormTracking; + beforeAll(() => { + window.gl = window.gl || {}; + window.gl.snowplowStandardContext = { + schema: 'iglu:com.gitlab/gitlab_standard', + data: { + environment: 'testing', + source: 'unknown', + extra: {}, + }, + }; + + standardContext = getStandardContext(); + }); + beforeEach(() => { getExperimentData.mockReturnValue(undefined); @@ -59,7 +75,7 @@ describe('Tracking', () => { it('should activate features based on what has been enabled', () => { initDefaultTrackers(); expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [STANDARD_CONTEXT]); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [standardContext]); expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); @@ -93,34 +109,6 @@ describe('Tracking', () => { navigator.msDoNotTrack = undefined; }); - describe('builds the standard context', () => { - let standardContext; - - beforeAll(async () => { - window.gl = window.gl || {}; - window.gl.snowplowStandardContext = { - schema: 'iglu:com.gitlab/gitlab_standard', - data: { - environment: 'testing', - source: 'unknown', - }, - }; - - jest.resetModules(); - - ({ STANDARD_CONTEXT: standardContext } = await import('~/tracking')); - }); - - it('uses server data', () => { - expect(standardContext.schema).toBe('iglu:com.gitlab/gitlab_standard'); - expect(standardContext.data.environment).toBe('testing'); - }); - - it('overrides schema source', () => { - expect(standardContext.data.source).toBe('gitlab-javascript'); - }); - }); - it('tracks to snowplow (our current tracking system)', () => { Tracking.event('_category_', '_eventName_', { label: '_label_' }); @@ -131,7 +119,31 @@ describe('Tracking', () => { '_label_', undefined, undefined, - [STANDARD_CONTEXT], + [standardContext], + ); + }); + + it('allows adding extra data to the default context', () => { + const extra = { foo: 'bar' }; + + Tracking.event('_category_', '_eventName_', { extra }); + + expect(snowplowSpy).toHaveBeenCalledWith( + 'trackStructEvent', + '_category_', + '_eventName_', + undefined, + undefined, + undefined, + [ + { + ...standardContext, + data: { + ...standardContext.data, + extra, + }, + }, + ], ); }); @@ -165,14 +177,14 @@ describe('Tracking', () => { }); describe('.enableFormTracking', () => { - it('tells snowplow to enable form tracking', () => { + it('tells snowplow to enable form tracking, with only explicit contexts', () => { const config = { forms: { allow: ['form-class1'] }, fields: { allow: ['input-class1'] } }; - Tracking.enableFormTracking(config, ['_passed_context_']); + Tracking.enableFormTracking(config, ['_passed_context_', standardContext]); expect(snowplowSpy).toHaveBeenCalledWith( 'enableFormTracking', { forms: { whitelist: ['form-class1'] }, fields: { whitelist: ['input-class1'] } }, - [{ data: { source: 'gitlab-javascript' }, schema: undefined }, '_passed_context_'], + ['_passed_context_'], ); }); @@ -203,7 +215,7 @@ describe('Tracking', () => { '_label_', undefined, undefined, - [STANDARD_CONTEXT], + [standardContext], ); }); }); @@ -226,6 +238,8 @@ describe('Tracking', () => { <div data-track-${term}="nested_event"><span class="nested"></span></div> <input data-track-bogus="click_bogusinput" data-track-label="_label_" value="_value_"/> <input data-track-${term}="click_input3" data-track-experiment="example" value="_value_"/> + <input data-track-${term}="event_with_extra" data-track-extra='{ "foo": "bar" }' /> + <input data-track-${term}="event_with_invalid_extra" data-track-extra="invalid_json" /> `); }); @@ -301,6 +315,20 @@ describe('Tracking', () => { context: { schema: TRACKING_CONTEXT_SCHEMA, data: mockExperimentData }, }); }); + + it('supports extra data as JSON', () => { + document.querySelector(`[data-track-${term}="event_with_extra"]`).click(); + + expect(eventSpy).toHaveBeenCalledWith('_category_', 'event_with_extra', { + extra: { foo: 'bar' }, + }); + }); + + it('ignores extra if provided JSON is invalid', () => { + document.querySelector(`[data-track-${term}="event_with_invalid_extra"]`).click(); + + expect(eventSpy).toHaveBeenCalledWith('_category_', 'event_with_invalid_extra', {}); + }); }); describe.each` -- GitLab