summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJeremy Jackson <jjackson@gitlab.com>2019-08-01 05:28:15 +0000
committerMike Greiling <mike@pixelcog.com>2019-08-01 05:28:15 +0000
commit23cc246066c6ba5242ff60049487983748679fd8 (patch)
tree4fc42e7f0f86b5ad42684dc1fcaa8769dcddca57
parentd89d71ebed9d5e207fce2ed63bf951f95cf367cd (diff)
downloadgitlab-ce-23cc246066c6ba5242ff60049487983748679fd8.tar.gz
Adds new tracking interface for snowplow
This will ultimately replace the stats.js that exists in EE.
-rw-r--r--app/assets/javascripts/tracking.js67
-rw-r--r--changelogs/unreleased/snowplow-ee-to-ce.yml5
-rw-r--r--doc/development/fe_guide/event_tracking.md93
-rw-r--r--spec/frontend/tracking_spec.js123
4 files changed, 240 insertions, 48 deletions
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
new file mode 100644
index 00000000000..2d0b099cf0b
--- /dev/null
+++ b/app/assets/javascripts/tracking.js
@@ -0,0 +1,67 @@
+import $ from 'jquery';
+
+const extractData = (el, opts = {}) => {
+ const { trackEvent, trackLabel = '', trackProperty = '' } = el.dataset;
+ let trackValue = el.dataset.trackValue || el.value || '';
+ if (el.type === 'checkbox' && !el.checked) trackValue = false;
+ return [
+ trackEvent + (opts.suffix || ''),
+ {
+ label: trackLabel,
+ property: trackProperty,
+ value: trackValue,
+ },
+ ];
+};
+
+export default class Tracking {
+ static enabled() {
+ return typeof window.snowplow === 'function';
+ }
+
+ static event(category = document.body.dataset.page, event = 'generic', data = {}) {
+ if (!this.enabled()) return false;
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ if (!category) throw new Error('Tracking: no category provided for tracking.');
+
+ return window.snowplow(
+ 'trackStructEvent',
+ category,
+ event,
+ Object.assign({}, { label: '', property: '', value: '' }, data),
+ );
+ }
+
+ constructor(category = document.body.dataset.page) {
+ this.category = category;
+ }
+
+ bind(container = document) {
+ if (!this.constructor.enabled()) return;
+ container.querySelectorAll(`[data-track-event]`).forEach(el => {
+ if (this.customHandlingFor(el)) return;
+ // jquery is required for select2, so we use it always
+ // see: https://github.com/select2/select2/issues/4686
+ $(el).on('click', this.eventHandler(this.category));
+ });
+ }
+
+ customHandlingFor(el) {
+ const classes = el.classList;
+
+ // bootstrap dropdowns
+ if (classes.contains('dropdown')) {
+ $(el).on('show.bs.dropdown', this.eventHandler(this.category, { suffix: '_show' }));
+ $(el).on('hide.bs.dropdown', this.eventHandler(this.category, { suffix: '_hide' }));
+ return true;
+ }
+
+ return false;
+ }
+
+ eventHandler(category = null, opts = {}) {
+ return e => {
+ this.constructor.event(category || this.category, ...extractData(e.currentTarget, opts));
+ };
+ }
+}
diff --git a/changelogs/unreleased/snowplow-ee-to-ce.yml b/changelogs/unreleased/snowplow-ee-to-ce.yml
new file mode 100644
index 00000000000..85c959f0510
--- /dev/null
+++ b/changelogs/unreleased/snowplow-ee-to-ce.yml
@@ -0,0 +1,5 @@
+---
+title: Moves snowplow tracking from ee to ce
+merge_request: 31160
+author: jejacks0n
+type: added
diff --git a/doc/development/fe_guide/event_tracking.md b/doc/development/fe_guide/event_tracking.md
index 716f6ad7f92..1e6287d8f6d 100644
--- a/doc/development/fe_guide/event_tracking.md
+++ b/doc/development/fe_guide/event_tracking.md
@@ -1,79 +1,76 @@
# Event Tracking
-We use [Snowplow](https://github.com/snowplow/snowplow) for tracking custom events (available in GitLab [Enterprise Edition](https://about.gitlab.com/pricing/) only).
+We use a tracking interface that wraps up [Snowplow](https://github.com/snowplow/snowplow) for tracking custom events. Snowplow implements page tracking, but also exposes custom event tracking.
-## Generic tracking function
-
-In addition to Snowplow's built-in method for tracking page views, we use a generic tracking function which enables us to selectively apply listeners to events.
-
-The generic tracking function can be imported in EE-specific JS files as follows:
+The tracking interface can be imported in JS files as follows:
```javascript
-import { trackEvent } from `ee/stats`;
+import Tracking from `~/tracking`;
```
-This gives the user access to the `trackEvent` method, which takes the following parameters:
+## Tracking in HAML or Vue templates
-| parameter | type | description | required |
-| ---------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
-| `category` | string | Describes the page that you're capturing click events on. Unless infeasible, please use the Rails page attribute `document.body.dataset.page` by default. | true |
-| `eventName` | string | Describes the action the user is taking. The first word should always describe the action. For example, clicks should be `click` and activations should be `activate`. Use underscores to describe what was acted on. For example, activating a form field would be `activate_form_input`. Clicking on a dropdown is `click_dropdown`. | true |
-| `additionalData` | object | Additional data such as `label`, `property`, and `value` as described [in our Feature Instrumentation taxonomy](https://about.gitlab.com/handbook/product/feature-instrumentation/#taxonomy). | false |
+To avoid having to do create a bunch of custom javascript event handlers, when working within HAML or Vue templates, we can add `data-track-*` attributes to elements of interest. This way, all elements that have a `data-track-event` attribute to automatically have event tracking bound.
-Read more about instrumentation and the taxonomy in the [Product Handbook](https://about.gitlab.com/handbook/product/feature-instrumentation).
-
-### Tracking in `.js` and `.vue` files
+Below is an example of `data-track-*` attributes assigned to a button in HAML:
-The most simple use case is to add tracking programmatically to an event of interest in Javascript.
+```haml
+%button.btn{ data: { track_event: "click_button", track_label: "template_preview", track_property: "my-template", track_value: "" } }
+```
-The following example demonstrates how to track a click on a button in Javascript by calling the `trackEvent` method explicitly:
+We can then setup tracking for large sections of a page, or an entire page by telling the Tracking interface to bind to it.
```javascript
-import { trackEvent } from `ee/stats`;
+import Tracking from '~/tracking';
-trackEvent('dashboard:projects:index', 'click_button', {
- label: 'create_from_template',
- property: 'template_preview',
- value: 'rails',
+// for the entire document
+new Tracking().bind();
+
+// for a container element
+document.addEventListener('DOMContentLoaded', () => {
+ new Tracking('my_category').bind(document.getElementById('my-container'));
});
+
```
-### Tracking in HAML templates
+When you instantiate a Tracking instance you can provide a category. If none is provided, `document.body.dataset.page` will be used. When you bind the Tracking instance you can provide an element. If no element is provided to bind to, the `document` is assumed.
-Sometimes we want to track clicks for multiple elements on a page. Creating event handlers for all elements could soon turn into a tedious task.
+Below is a list of supported `data-track-*` attributes:
-There's a more convenient solution to this problem. When working with HAML templates, we can add `data-track-*` attributes to elements of interest. This way, all elements that have both `data-track-label` and `data-track-event` attributes assigned get marked for event tracking. All we have to do is call the `bindTrackableContainer` method on a container which allows for better scoping.
+| attribute | required | description |
+|:----------------------|:---------|:------------|
+| `data-track-event` | true | 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-track-label` | false | The `label` as described [in our Feature Instrumentation taxonomy](https://about.gitlab.com/handbook/product/feature-instrumentation/#taxonomy) |
+| `data-track-property` | false | The `property` as described [in our Feature Instrumentation taxonomy](https://about.gitlab.com/handbook/product/feature-instrumentation/#taxonomy)
+| `data-track-value` | false | The `value` as described [in our Feature Instrumentation taxonomy](https://about.gitlab.com/handbook/product/feature-instrumentation/#taxonomy). If omitted, this will be the elements `value` property or an empty string. For checkboxes, the default value will be the element's checked attribute or `false` when unchecked.
-Below is an example of `data-track-*` attributes assigned to a button in HAML:
-```ruby
-%button.btn{ data: { track_label: "template_preview", track_property: "my-template", track_event: "click_button", track_value: "" } }
-```
-
-By calling `bindTrackableContainer('.my-container')`, click handlers get bound to all elements located in `.my-container` provided that they have the necessary `data-track-*` attributes assigned to them.
+## Tracking in raw Javascript
-```javascript
-import Stats from 'ee/stats';
+Custom events can be tracked by directly calling the `Tracking.event` static function, which accepts the following arguments:
-document.addEventListener('DOMContentLoaded', () => {
- Stats.bindTrackableContainer('.my-container', 'category');
-});
-```
+| argument | type | default value | description |
+|:-----------|:-------|:---------------------------|:------------|
+| `category` | string | document.body.dataset.page | Page or subsection of a page that events are being captured within. |
+| `event` | 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`, and `value` as described [in our Feature Instrumentation taxonomy](https://about.gitlab.com/handbook/product/feature-instrumentation/#taxonomy). These will be set as empty strings if you don't provide them. |
-The second parameter in `bindTrackableContainer` is optional. If omitted, the value of `document.body.dataset.page` will be used as category instead.
+Tracking can be programmatically added to an event of interest in Javascript, and the following example demonstrates tracking a click on a button by calling `Tracking.event` manually.
-Below is a list of supported `data-track-*` attributes:
+```javascript
+import Tracking from `~/tracking`;
-| attribute | description | required |
-| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
-| `data-track-label` | The `label` in `trackEvent` | true |
-| `data-track-event` | The `eventName` in `trackEvent` | true |
-| `data-track-property` | The `property` in `trackEvent`. If omitted, an empty string will be used as a default value. | false |
-| `data-track-value` | The `value` in `trackEvent`. If omitted, this will be `target.value` or empty string. For checkboxes, the default value being tracked will be the element's checked attribute if `data-track-value` is omitted. | false |
+document.getElementById('my_button').addEventListener('click', () => {
+ Tracking.event('dashboard:projects:index', 'click_button', {
+ label: 'create_from_template',
+ property: 'template_preview',
+ value: 'rails',
+ });
+})
+```
-Since Snowplow is an Enterprise Edition feature, it's necessary to create a CE backport when adding `data-track-*` attributes to HAML templates in most cases.
-## Testing
+## Toggling tracking on or off
Snowplow can be enabled by navigating to:
diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js
new file mode 100644
index 00000000000..7e462e9a6ce
--- /dev/null
+++ b/spec/frontend/tracking_spec.js
@@ -0,0 +1,123 @@
+import $ from 'jquery';
+import { setHTMLFixture } from './helpers/fixtures';
+
+import Tracking from '~/tracking';
+
+describe('Tracking', () => {
+ beforeEach(() => {
+ window.snowplow = window.snowplow || (() => {});
+ });
+
+ describe('.event', () => {
+ let snowplowSpy = null;
+
+ beforeEach(() => {
+ snowplowSpy = jest.spyOn(window, 'snowplow');
+ });
+
+ it('tracks to snowplow (our current tracking system)', () => {
+ Tracking.event('_category_', '_eventName_', { label: '_label_' });
+
+ expect(snowplowSpy).toHaveBeenCalledWith('trackStructEvent', '_category_', '_eventName_', {
+ label: '_label_',
+ property: '',
+ value: '',
+ });
+ });
+
+ it('skips tracking if snowplow is unavailable', () => {
+ window.snowplow = false;
+ Tracking.event('_category_', '_eventName_');
+
+ expect(snowplowSpy).not.toHaveBeenCalled();
+ });
+
+ it('skips tracking if ', () => {
+ window.snowplow = false;
+ Tracking.event('_category_', '_eventName_');
+
+ expect(snowplowSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('tracking interface events', () => {
+ let eventSpy = null;
+ let subject = null;
+
+ beforeEach(() => {
+ eventSpy = jest.spyOn(Tracking, 'event');
+ subject = new Tracking('_category_');
+ setHTMLFixture(`
+ <input data-track-event="click_input1" data-track-label="_label_" value="_value_"/>
+ <input data-track-event="click_input2" data-track-value="_value_override_" value="_value_"/>
+ <input type="checkbox" data-track-event="toggle_checkbox" value="_value_" checked/>
+ <input class="dropdown" data-track-event="toggle_dropdown"/>
+ <div class="js-projects-list-holder"></div>
+ `);
+ });
+
+ it('binds to clicks on elements matching [data-track-event]', () => {
+ subject.bind(document);
+ $('[data-track-event="click_input1"]').click();
+
+ expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input1', {
+ label: '_label_',
+ value: '_value_',
+ property: '',
+ });
+ });
+
+ it('allows value override with the data-track-value attribute', () => {
+ subject.bind(document);
+ $('[data-track-event="click_input2"]').click();
+
+ expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input2', {
+ label: '',
+ value: '_value_override_',
+ property: '',
+ });
+ });
+
+ it('handles checkbox values correctly', () => {
+ subject.bind(document);
+ const $checkbox = $('[data-track-event="toggle_checkbox"]');
+
+ $checkbox.click(); // unchecking
+
+ expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', {
+ label: '',
+ property: '',
+ value: false,
+ });
+
+ $checkbox.click(); // checking
+
+ expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', {
+ label: '',
+ property: '',
+ value: '_value_',
+ });
+ });
+
+ it('handles bootstrap dropdowns', () => {
+ new Tracking('_category_').bind(document);
+ const $dropdown = $('[data-track-event="toggle_dropdown"]');
+
+ $dropdown.trigger('show.bs.dropdown'); // showing
+
+ expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_show', {
+ label: '',
+ property: '',
+ value: '',
+ });
+
+ $dropdown.trigger('hide.bs.dropdown'); // hiding
+
+ expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_hide', {
+ label: '',
+ property: '',
+ value: '',
+ });
+ });
+ });
+});