diff options
author | Jeremy Jackson <jjackson@gitlab.com> | 2019-08-01 05:28:15 +0000 |
---|---|---|
committer | Mike Greiling <mike@pixelcog.com> | 2019-08-01 05:28:15 +0000 |
commit | 23cc246066c6ba5242ff60049487983748679fd8 (patch) | |
tree | 4fc42e7f0f86b5ad42684dc1fcaa8769dcddca57 | |
parent | d89d71ebed9d5e207fce2ed63bf951f95cf367cd (diff) | |
download | gitlab-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.js | 67 | ||||
-rw-r--r-- | changelogs/unreleased/snowplow-ee-to-ce.yml | 5 | ||||
-rw-r--r-- | doc/development/fe_guide/event_tracking.md | 93 | ||||
-rw-r--r-- | spec/frontend/tracking_spec.js | 123 |
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: '', + }); + }); + }); +}); |