Commit 9c86e4b5 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 870ea48a 924ec18e
export const SNOWPLOW_JS_SOURCE = 'gitlab-javascript';
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,
},
};
}
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');
......
......@@ -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.
......
......@@ -65,53 +65,65 @@ to draw the visualization on the merge request expires **one week** after creati
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217664) in GitLab 13.8.
> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/284822) in GitLab 13.9.
For the coverage report to properly match the files displayed on a merge request diff, the `filename` of a `class` element
must contain the full path relative to the project root. But in some coverage analysis frameworks, the generated
Cobertura XML has the `filename` path relative to the class package directory instead.
The coverage report properly matches changed files only if the `filename` of a `class` element
contains the full path relative to the project root. However, in some coverage analysis frameworks,
the generated Cobertura XML has the `filename` path relative to the class package directory instead.
To make an intelligent guess on the project root relative `class` path, the Cobertura XML parser attempts to build the
full path by doing the following:
To make an intelligent guess on the project root relative `class` path, the Cobertura XML parser
attempts to build the full path by:
1. Extract a portion of the `source` paths from the `sources` element and combine them with the class `filename` path.
1. Check if the candidate path exists in the project.
1. Use the first candidate that matches as the class full path.
- Extracting a portion of the `source` paths from the `sources` element and combining them with the
class `filename` path.
- Checking if the candidate path exists in the project.
- Using the first candidate that matches as the class full path.
As an example scenario, given the project's full path is `test-org/test-project`, and has the following file tree relative
to the project root:
#### Path correction example
```shell
Auth/User.cs
Lib/Utils/User.cs
src/main/java
```
As an example, a project with:
In the Cobertura XML, the `filename` attribute in the `class` element assumes the value is a
relative path to project's root.
- A full path of `test-org/test-project`.
- The following files relative to the project root:
```xml
<class name="packet.name" filename="src/main/java" line-rate="0.0" branch-rate="0.0" complexity="5">
```
```shell
Auth/User.cs
Lib/Utils/User.cs
src/main/java
```
And the `sources` from Cobertura XML with paths in the format of `<CI_BUILDS_DIR>/<PROJECT_FULL_PATH>/...`:
In the:
```xml
<sources>
<source>/builds/test-org/test-project/Auth</source>
<source>/builds/test-org/test-project/Lib/Utils</source>
</sources>
```
- Cobertura XML, the `filename` attribute in the `class` element assumes the value is a relative
path to the project's root:
```xml
<class name="packet.name" filename="src/main/java" line-rate="0.0" branch-rate="0.0" complexity="5">
```
- `sources` from Cobertura XML, the following paths in the format
`<CI_BUILDS_DIR>/<PROJECT_FULL_PATH>/...`:
The parser extracts `Auth` and `Lib/Utils` from the sources and use these as basis to determine the class path relative to
the project root, combining these extracted sources and the class filename.
```xml
<sources>
<source>/builds/test-org/test-project/Auth</source>
<source>/builds/test-org/test-project/Lib/Utils</source>
</sources>
```
If for example there is a `class` element with the `filename` value of `User.cs`, the parser takes the first candidate path
that matches, which is `Auth/User.cs`.
The parser:
For each `class` element, the parser attempts to look for a match for each extracted `source` path up to `100` iterations. If it reaches this limit without finding a matching path in the file tree, the class will not be included in the final coverage report.
- Extracts `Auth` and `Lib/Utils` from the `sources` and uses these to determine the `class` path
relative to the project root.
- Combines these extracted `sources` and the class filename. For example, if there is a `class`
element with the `filename` value of `User.cs`, the parser takes the first candidate path that
matches, which is `Auth/User.cs`.
- For each `class` element, attempts to look for a match for each extracted `source` path up to
100 iterations. If it reaches this limit without finding a matching path in the file tree, the
class is not included in the final coverage report.
NOTE:
The automatic class path correction only works on `source` paths in the format of `<CI_BUILDS_DIR>/<PROJECT_FULL_PATH>/...`. If `source` will be ignored if the path does not follow this pattern. The parser assumes that
the `filename` of a `class` element contains the full path relative to the project root.
Automatic class path correction only works on `source` paths in the format `<CI_BUILDS_DIR>/<PROJECT_FULL_PATH>/...`.
The `source` is ignored if the path does not follow this pattern. The parser assumes that the
`filename` of a `class` element contains the full path relative to the project root.
## Example test coverage configurations
......
......@@ -6,17 +6,18 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= _('Set instance-wide template repository')
= _('Select a shared template repository for all projects on this instance.')
.settings-content
= form_for @application_setting, url: templates_admin_application_settings_path, html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
= f.label :file_template_project_id, class: 'label-light' do
.form-text.text-muted
Select a
= link_to 'template repository', help_page_path("user/admin_area/settings/instance_template_repository")
= f.label :file_template_project_id, class: 'label-bold' do
.form-text
= _('Select a template repository')
= project_select_tag('application_setting[file_template_project_id]', class: 'project-item-select hidden-filter-value', toggle_class: 'js-project-search js-project-filter js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
placeholder: _('Search projects'), idAttribute: 'id', data: { order_by: 'last_activity_at', idattribute: 'id', all_projects: 'true', simple_filter: true, allow_clear: true }, value: @application_setting.file_template_project_id)
- link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('user/admin_area/settings/instance_template_repository') }
= s_('TemplateRepository|Select a repository to make its templates available to all projects. %{link_start}What should the repository contain?%{link_end} ').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
= f.submit 'Save changes', class: "gl-button btn btn-confirm"
......@@ -29360,6 +29360,9 @@ msgstr ""
msgid "Select a repository"
msgstr ""
msgid "Select a shared template repository for all projects on this instance."
msgstr ""
msgid "Select a template repository"
msgstr ""
......@@ -29750,9 +29753,6 @@ msgstr ""
msgid "Set due date"
msgstr ""
msgid "Set instance-wide template repository"
msgstr ""
msgid "Set iteration"
msgstr ""
......@@ -32001,6 +32001,9 @@ msgstr ""
msgid "Template to append to all Service Desk issues"
msgstr ""
msgid "TemplateRepository|Select a repository to make its templates available to all projects. %{link_start}What should the repository contain?%{link_end} "
msgstr ""
msgid "Templates"
msgstr ""
......
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);
});
});
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`
......
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment