Commit 71b171fe authored by Doug Stull's avatar Doug Stull Committed by Paul Slaughter

Remove gon.global experiment scoping and organize helpers

https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55421
parent 1142bacb
export const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0';
import { get } from 'lodash';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { TRACKING_CONTEXT_SCHEMA } from './constants';
const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0'; import { getExperimentData } from './utils';
export default class ExperimentTracking { export default class ExperimentTracking {
constructor(experimentName, trackingArgs = {}) { constructor(experimentName, trackingArgs = {}) {
this.trackingArgs = trackingArgs; this.trackingArgs = trackingArgs;
this.experimentData = get(window, ['gon', 'global', 'experiment', experimentName]); this.data = getExperimentData(experimentName);
} }
event(action) { event(action) {
if (!this.experimentData) { if (!this.data) {
return false; return false;
} }
...@@ -18,7 +17,7 @@ export default class ExperimentTracking { ...@@ -18,7 +17,7 @@ export default class ExperimentTracking {
...this.trackingArgs, ...this.trackingArgs,
context: { context: {
schema: TRACKING_CONTEXT_SCHEMA, schema: TRACKING_CONTEXT_SCHEMA,
data: this.experimentData, data: this.data,
}, },
}); });
} }
......
// This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment
import { get } from 'lodash';
export function getExperimentData(experimentName) {
return get(window, ['gon', 'experiment', experimentName]);
}
export function isExperimentVariant(experimentName, variantName) {
return getExperimentData(experimentName)?.variant === variantName;
}
export function isExperimentEnabled(experimentKey) {
return Boolean(window.gon?.experiments?.[experimentKey]);
}
import ExperimentTracking from '~/experiment_tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking';
function trackEvent(eventName) { function trackEvent(eventName) {
const isEmpty = Boolean(document.querySelector('.project-home-panel.empty-project')); const isEmpty = Boolean(document.querySelector('.project-home-panel.empty-project'));
......
import { omitBy, isUndefined, get } from 'lodash'; import { omitBy, isUndefined } from 'lodash';
import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
import { getExperimentData } from '~/experimentation/utils';
const standardContext = { ...window.gl?.snowplowStandardContext }; const standardContext = { ...window.gl?.snowplowStandardContext };
...@@ -32,8 +34,8 @@ const createEventPayload = (el, { suffix = '' } = {}) => { ...@@ -32,8 +34,8 @@ const createEventPayload = (el, { suffix = '' } = {}) => {
let context = el.dataset.trackContext; let context = el.dataset.trackContext;
if (el.dataset.trackExperiment) { if (el.dataset.trackExperiment) {
const data = get(window, ['gon', 'global', 'experiment', el.dataset.trackExperiment]); const data = getExperimentData(el.dataset.trackExperiment);
if (data) context = { schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0', data }; if (data) context = { schema: TRACKING_CONTEXT_SCHEMA, data };
} }
const data = { const data = {
......
...@@ -11,7 +11,9 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp ...@@ -11,7 +11,9 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
def publish(_result) def publish(_result)
track(:assignment) # track that we've assigned a variant for this context track(:assignment) # track that we've assigned a variant for this context
Gon.global.push({ experiment: { name => signature } }, true) # push the experiment data to the client
# push the experiment data to the client
Gon.push({ experiment: { name => signature } }, true) if in_request_cycle?
end end
def track(action, **event_args) def track(action, **event_args)
...@@ -47,6 +49,12 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp ...@@ -47,6 +49,12 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
name.tr('/', '_') name.tr('/', '_')
end end
def in_request_cycle?
# Gon is only accessible when having a request. This will be fixed with
# https://gitlab.com/gitlab-org/gitlab/-/issues/323352
context.instance_variable_defined?(:@request)
end
def resolve_variant_name def resolve_variant_name
case rollout_strategy case rollout_strategy
when :round_robin when :round_robin
......
...@@ -140,15 +140,8 @@ You find out how to conduct experiments using `gitlab-experiment` in the [README ...@@ -140,15 +140,8 @@ You find out how to conduct experiments using `gitlab-experiment` in the [README
The above checks whether the experiment is enabled and pushes the result to the frontend. The above checks whether the experiment is enabled and pushes the result to the frontend.
You can check the state of the feature flag in JavaScript: The Frontend helpers for this are no longer used in production.
[More details TBD](https://gitlab.com/gitlab-org/gitlab/-/issues/323934).
```javascript
import { isExperimentEnabled } from '~/experimentation';
if ( isExperimentEnabled('signupFlow') ) {
// ...
}
```
- It is also possible to run an experiment outside of the controller scope, for example in a worker: - It is also possible to run an experiment outside of the controller scope, for example in a worker:
...@@ -238,11 +231,10 @@ expect(Gon.tracking_data).to eq( ...@@ -238,11 +231,10 @@ expect(Gon.tracking_data).to eq(
Which can then be used for tracking as follows: Which can then be used for tracking as follows:
```javascript ```javascript
import { isExperimentEnabled } from '~/lib/utils/experimentation';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const signupFlowExperimentEnabled = isExperimentEnabled('signupFlow'); const signupFlowExperimentEnabled = gon.experiments['signupFlow'];
if (signupFlowExperimentEnabled && gon.tracking_data) { if (signupFlowExperimentEnabled && gon.tracking_data) {
const { category, action, ...data } = gon.tracking_data; const { category, action, ...data } = gon.tracking_data;
......
...@@ -64,21 +64,35 @@ RSpec.describe ApplicationExperiment, :experiment do ...@@ -64,21 +64,35 @@ RSpec.describe ApplicationExperiment, :experiment do
subject.publish(nil) subject.publish(nil)
end end
it "pushes the experiment knowledge into the client using Gon.global" do context "when inside a request cycle" do
expect(Gon.global).to receive(:push).with( before do
{ subject.context.instance_variable_set(:@request, double('Request', headers: 'true'))
experiment: { end
'namespaced/stub' => { # string key because it can be namespaced
experiment: 'namespaced/stub', it "pushes the experiment knowledge into the client using Gon" do
key: '86208ac54ca798e11f127e8b23ec396a', expect(Gon).to receive(:push).with(
variant: 'control' {
experiment: {
'namespaced/stub' => { # string key because it can be namespaced
experiment: 'namespaced/stub',
key: '86208ac54ca798e11f127e8b23ec396a',
variant: 'control'
}
} }
} },
}, true
true )
)
subject.publish(nil) subject.publish(nil)
end
end
context "when outside a request cycle" do
it "does not push to gon when outside request cycle" do
expect(Gon).not_to receive(:push)
subject.publish(nil)
end
end end
end end
......
import ExperimentTracking from '~/experiment_tracking'; import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { getExperimentData } from '~/experimentation/utils';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
jest.mock('~/tracking');
const oldGon = window.gon;
let newGon = {};
let experimentTracking; let experimentTracking;
let label; let label;
let property; let property;
jest.mock('~/tracking');
jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() }));
const setup = () => { const setup = () => {
window.gon = newGon;
experimentTracking = new ExperimentTracking('sidebar_experiment', { label, property }); experimentTracking = new ExperimentTracking('sidebar_experiment', { label, property });
}; };
...@@ -20,16 +19,18 @@ beforeEach(() => { ...@@ -20,16 +19,18 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
window.gon = oldGon;
Tracking.mockClear();
label = undefined; label = undefined;
property = undefined; property = undefined;
}); });
describe('event', () => { describe('event', () => {
beforeEach(() => {
getExperimentData.mockReturnValue(undefined);
});
describe('when experiment data exists for experimentName', () => { describe('when experiment data exists for experimentName', () => {
beforeEach(() => { beforeEach(() => {
newGon = { global: { experiment: { sidebar_experiment: 'experiment-data' } } }; getExperimentData.mockReturnValue('experiment-data');
setup(); setup();
}); });
...@@ -45,7 +46,7 @@ describe('event', () => { ...@@ -45,7 +46,7 @@ describe('event', () => {
label: 'sidebar-drawer', label: 'sidebar-drawer',
property: 'dark-mode', property: 'dark-mode',
context: { context: {
schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0', schema: TRACKING_CONTEXT_SCHEMA,
data: 'experiment-data', data: 'experiment-data',
}, },
}); });
...@@ -58,7 +59,7 @@ describe('event', () => { ...@@ -58,7 +59,7 @@ describe('event', () => {
expect(Tracking.event).toHaveBeenCalledTimes(1); expect(Tracking.event).toHaveBeenCalledTimes(1);
expect(Tracking.event).toHaveBeenCalledWith('issues-page', 'click_sidebar_trigger', { expect(Tracking.event).toHaveBeenCalledWith('issues-page', 'click_sidebar_trigger', {
context: { context: {
schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0', schema: TRACKING_CONTEXT_SCHEMA,
data: 'experiment-data', data: 'experiment-data',
}, },
}); });
...@@ -67,7 +68,6 @@ describe('event', () => { ...@@ -67,7 +68,6 @@ describe('event', () => {
describe('when experiment data does NOT exists for the experimentName', () => { describe('when experiment data does NOT exists for the experimentName', () => {
beforeEach(() => { beforeEach(() => {
newGon = { global: { experiment: { unrelated_experiment: 'not happening' } } };
setup(); setup();
}); });
......
import * as experimentUtils from '~/experimentation/utils';
const TEST_KEY = 'abc';
describe('experiment Utilities', () => {
const oldGon = window.gon;
afterEach(() => {
window.gon = oldGon;
});
describe('getExperimentData', () => {
it.each`
gon | input | output
${{ experiment: { [TEST_KEY]: '_data_' } }} | ${[TEST_KEY]} | ${'_data_'}
${{}} | ${[TEST_KEY]} | ${undefined}
`('with input=$input and gon=$gon, returns $output', ({ gon, input, output }) => {
window.gon = gon;
expect(experimentUtils.getExperimentData(...input)).toEqual(output);
});
});
describe('isExperimentVariant', () => {
it.each`
gon | input | output
${{ experiment: { [TEST_KEY]: { variant: 'control' } } }} | ${[TEST_KEY, 'control']} | ${true}
${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${[TEST_KEY, '_variant_name']} | ${true}
${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${[TEST_KEY, '_bogus_name']} | ${false}
${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${['boguskey', '_variant_name']} | ${false}
${{}} | ${[TEST_KEY, '_variant_name']} | ${false}
`('with input=$input and gon=$gon, returns $output', ({ gon, input, output }) => {
window.gon = gon;
expect(experimentUtils.isExperimentVariant(...input)).toEqual(output);
});
});
});
import * as experimentUtils from '~/lib/utils/experimentation';
const TEST_KEY = 'abc';
describe('experiment Utilities', () => {
describe('isExperimentEnabled', () => {
it.each`
experiments | value
${{ [TEST_KEY]: true }} | ${true}
${{ [TEST_KEY]: false }} | ${false}
${{ def: true }} | ${false}
${{}} | ${false}
${null} | ${false}
`('returns correct value of $value for experiments=$experiments', ({ experiments, value }) => {
window.gon = { experiments };
expect(experimentUtils.isExperimentEnabled(TEST_KEY)).toEqual(value);
});
});
});
import ExperimentTracking from '~/experiment_tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking';
import * as UploadFileExperiment from '~/projects/upload_file_experiment'; import * as UploadFileExperiment from '~/projects/upload_file_experiment';
const mockExperimentTrackingEvent = jest.fn(); jest.mock('~/experimentation/experiment_tracking');
jest.mock('~/experiment_tracking', () =>
jest.fn().mockImplementation(() => ({
event: mockExperimentTrackingEvent,
})),
);
const fixture = `<a class='js-upload-file-experiment-trigger' data-toggle='modal' data-target='#modal-upload-blob'></a><div id='modal-upload-blob'></div><div class='project-home-panel empty-project'></div>`; const fixture = `<a class='js-upload-file-experiment-trigger' data-toggle='modal' data-target='#modal-upload-blob'></a><div id='modal-upload-blob'></div><div class='project-home-panel empty-project'></div>`;
const findModal = () => document.querySelector('[aria-modal="true"]'); const findModal = () => document.querySelector('[aria-modal="true"]');
const findTrigger = () => document.querySelector('.js-upload-file-experiment-trigger'); const findTrigger = () => document.querySelector('.js-upload-file-experiment-trigger');
beforeEach(() => { beforeEach(() => {
ExperimentTracking.mockClear();
mockExperimentTrackingEvent.mockClear();
document.body.innerHTML = fixture; document.body.innerHTML = fixture;
}); });
...@@ -31,7 +23,9 @@ describe('trackUploadFileFormSubmitted', () => { ...@@ -31,7 +23,9 @@ describe('trackUploadFileFormSubmitted', () => {
label: 'blob-upload-modal', label: 'blob-upload-modal',
property: 'empty', property: 'empty',
}); });
expect(mockExperimentTrackingEvent).toHaveBeenCalledWith('click_upload_modal_form_submit'); expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
'click_upload_modal_form_submit',
);
}); });
it('initializes ExperimentTracking with the correct arguments when the project is not empty', () => { it('initializes ExperimentTracking with the correct arguments when the project is not empty', () => {
...@@ -53,6 +47,6 @@ describe('initUploadFileTrigger', () => { ...@@ -53,6 +47,6 @@ describe('initUploadFileTrigger', () => {
expect(findModal()).not.toExist(); expect(findModal()).not.toExist();
findTrigger().click(); findTrigger().click();
expect(findModal()).toExist(); expect(findModal()).toExist();
expect(mockExperimentTrackingEvent).toHaveBeenCalledWith('click_upload_modal_trigger'); expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('click_upload_modal_trigger');
}); });
}); });
import { setHTMLFixture } from 'helpers/fixtures'; 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, STANDARD_CONTEXT } from '~/tracking';
jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() }));
describe('Tracking', () => { describe('Tracking', () => {
let snowplowSpy; let snowplowSpy;
let bindDocumentSpy; let bindDocumentSpy;
let trackLoadEventsSpy; let trackLoadEventsSpy;
beforeEach(() => { beforeEach(() => {
getExperimentData.mockReturnValue(undefined);
window.snowplow = window.snowplow || (() => {}); window.snowplow = window.snowplow || (() => {});
window.snowplowOptions = { window.snowplowOptions = {
namespace: '_namespace_', namespace: '_namespace_',
...@@ -245,18 +251,18 @@ describe('Tracking', () => { ...@@ -245,18 +251,18 @@ describe('Tracking', () => {
}); });
it('brings in experiment data if linked to an experiment', () => { it('brings in experiment data if linked to an experiment', () => {
const data = { const mockExperimentData = {
variant: 'candidate', variant: 'candidate',
experiment: 'repo_integrations_link', experiment: 'repo_integrations_link',
key: '2bff73f6bb8cc11156c50a8ba66b9b8b', key: '2bff73f6bb8cc11156c50a8ba66b9b8b',
}; };
getExperimentData.mockReturnValue(mockExperimentData);
window.gon.global = { experiment: { example: data } };
document.querySelector('[data-track-event="click_input3"]').click(); document.querySelector('[data-track-event="click_input3"]').click();
expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input3', { expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input3', {
value: '_value_', value: '_value_',
context: { schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0', data }, context: { schema: TRACKING_CONTEXT_SCHEMA, data: mockExperimentData },
}); });
}); });
}); });
...@@ -301,21 +307,21 @@ describe('Tracking', () => { ...@@ -301,21 +307,21 @@ describe('Tracking', () => {
describe('tracking mixin', () => { describe('tracking mixin', () => {
describe('trackingOptions', () => { describe('trackingOptions', () => {
it('return the options defined on initialisation', () => { it('returns the options defined on initialisation', () => {
const mixin = Tracking.mixin({ foo: 'bar' }); const mixin = Tracking.mixin({ foo: 'bar' });
expect(mixin.computed.trackingOptions()).toEqual({ foo: 'bar' }); expect(mixin.computed.trackingOptions()).toEqual({ foo: 'bar' });
}); });
it('local tracking value override and extend options', () => { it('lets local tracking value override and extend options', () => {
const mixin = Tracking.mixin({ foo: 'bar' }); const mixin = Tracking.mixin({ foo: 'bar' });
// the value of this in the vue lifecyle is different, but this serve the tests purposes // The value of this in the Vue lifecyle is different, but this serves the test's purposes
mixin.computed.tracking = { foo: 'baz', baz: 'bar' }; mixin.computed.tracking = { foo: 'baz', baz: 'bar' };
expect(mixin.computed.trackingOptions()).toEqual({ foo: 'baz', baz: 'bar' }); expect(mixin.computed.trackingOptions()).toEqual({ foo: 'baz', baz: 'bar' });
}); });
}); });
describe('trackingCategory', () => { describe('trackingCategory', () => {
it('return the category set in the component properties first', () => { it('returns the category set in the component properties first', () => {
const mixin = Tracking.mixin({ category: 'foo' }); const mixin = Tracking.mixin({ category: 'foo' });
mixin.computed.tracking = { mixin.computed.tracking = {
category: 'bar', category: 'bar',
...@@ -323,12 +329,12 @@ describe('Tracking', () => { ...@@ -323,12 +329,12 @@ describe('Tracking', () => {
expect(mixin.computed.trackingCategory()).toBe('bar'); expect(mixin.computed.trackingCategory()).toBe('bar');
}); });
it('return the category set in the options', () => { it('returns the category set in the options', () => {
const mixin = Tracking.mixin({ category: 'foo' }); const mixin = Tracking.mixin({ category: 'foo' });
expect(mixin.computed.trackingCategory()).toBe('foo'); expect(mixin.computed.trackingCategory()).toBe('foo');
}); });
it('if no category is selected returns undefined', () => { it('returns undefined if no category is selected', () => {
const mixin = Tracking.mixin(); const mixin = Tracking.mixin();
expect(mixin.computed.trackingCategory()).toBe(undefined); expect(mixin.computed.trackingCategory()).toBe(undefined);
}); });
...@@ -363,7 +369,7 @@ describe('Tracking', () => { ...@@ -363,7 +369,7 @@ describe('Tracking', () => {
expect(eventSpy).toHaveBeenCalledWith(undefined, 'foo', {}); expect(eventSpy).toHaveBeenCalledWith(undefined, 'foo', {});
}); });
it('give precedence to data for category and options', () => { it('gives precedence to data for category and options', () => {
mixin.trackingCategory = mixin.trackingCategory(); mixin.trackingCategory = mixin.trackingCategory();
mixin.trackingOptions = mixin.trackingOptions(); mixin.trackingOptions = mixin.trackingOptions();
const data = { category: 'foo', label: 'baz' }; const data = { category: 'foo', label: 'baz' };
......
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