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