From a065a9972f5be075ae83beec262dff78a5cbfcda Mon Sep 17 00:00:00 2001 From: Jeremy Jackson <jjackson@gitlab.com> Date: Thu, 27 Jan 2022 04:03:38 +0000 Subject: [PATCH] Update gitlab-experiment to version 0.7.0 --- Gemfile | 2 +- Gemfile.lock | 16 +- app/experiments/application_experiment.rb | 8 +- config/initializers/gitlab_experiment.rb | 7 + .../experiment_guide/gitlab_experiment.md | 204 ++++++------------ .../application_experiment_spec.rb | 6 +- spec/support/gitlab_experiment.rb | 15 ++ .../gitlab/experimentation_shared_examples.rb | 8 +- 8 files changed, 112 insertions(+), 154 deletions(-) diff --git a/Gemfile b/Gemfile index 07e744c4e37e..5ea6fe3a78eb 100644 --- a/Gemfile +++ b/Gemfile @@ -489,7 +489,7 @@ gem 'flipper', '~> 0.21.0' gem 'flipper-active_record', '~> 0.21.0' gem 'flipper-active_support_cache_store', '~> 0.21.0' gem 'unleash', '~> 3.2.2' -gem 'gitlab-experiment', '~> 0.6.5' +gem 'gitlab-experiment', git: 'https://gitlab.com/gitlab-org/ruby/gems/gitlab-experiment', branch: 'release-0.7.0' # Structured logging gem 'lograge', '~> 0.5' diff --git a/Gemfile.lock b/Gemfile.lock index bca63aff7323..500ecd3a3566 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,12 @@ +GIT + remote: https://gitlab.com/gitlab-org/ruby/gems/gitlab-experiment.git + revision: 176d767486217ae5080785247f29dc4549d85a4a + branch: release-0.7.0 + specs: + gitlab-experiment (0.7.0) + activesupport (>= 3.0) + request_store (>= 1.0) + PATH remote: vendor/gems/mail-smtp_pool specs: @@ -460,10 +469,6 @@ GEM gitlab-dangerfiles (2.8.0) danger (>= 8.3.1) danger-gitlab (>= 8.0.0) - gitlab-experiment (0.6.5) - activesupport (>= 3.0) - request_store (>= 1.0) - scientist (~> 1.6, >= 1.6.0) gitlab-fog-azure-rm (1.2.0) azure-storage-blob (~> 2.0) azure-storage-common (~> 2.0) @@ -1157,7 +1162,6 @@ GEM sawyer (0.8.2) addressable (>= 2.3.5) faraday (> 0.8, < 2.0) - scientist (1.6.2) sd_notify (0.1.0) securecompare (1.0.0) seed-fu (2.3.7) @@ -1470,7 +1474,7 @@ DEPENDENCIES github-markup (~> 1.7.0) gitlab-chronic (~> 0.10.5) gitlab-dangerfiles (~> 2.8.0) - gitlab-experiment (~> 0.6.5) + gitlab-experiment! gitlab-fog-azure-rm (~> 1.2.0) gitlab-labkit (~> 0.21.3) gitlab-license (~> 2.1.0) diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb index 859716b4739b..2dabf33405d3 100644 --- a/app/experiments/application_experiment.rb +++ b/app/experiments/application_experiment.rb @@ -41,10 +41,6 @@ def control_behavior # define a default nil control behavior so we can omit it when not needed end - def track(action, **event_args) - super(action, **tracking_context.merge(event_args)) - end - # TODO: remove # This is deprecated logic as of v0.6.0 and should eventually be removed, but # needs to stay intact for actively running experiments. The new strategy @@ -64,12 +60,12 @@ def nest_experiment(other) private - def tracking_context + def tracking_context(event_args) { namespace: context.try(:namespace) || context.try(:group), project: context.try(:project), user: user_or_actor - }.compact || {} + }.merge(event_args) end def user_or_actor diff --git a/config/initializers/gitlab_experiment.rb b/config/initializers/gitlab_experiment.rb index 5878b8702b91..e2157d928b7e 100644 --- a/config/initializers/gitlab_experiment.rb +++ b/config/initializers/gitlab_experiment.rb @@ -10,6 +10,13 @@ # config.base_class = 'ApplicationExperiment' + # Customize the logic of our default rollout, which shouldn't include + # assigning the control yet -- we specifically set it to false for now. + # + config.default_rollout = Gitlab::Experiment::Rollout::Percent.new( + include_control: false + ) + # Mount the engine and middleware at a gitlab friendly style path. # # The middleware currently focuses only on handling redirection logic, which diff --git a/doc/development/experiment_guide/gitlab_experiment.md b/doc/development/experiment_guide/gitlab_experiment.md index d229a2a6f1b0..e1088fb3a175 100644 --- a/doc/development/experiment_guide/gitlab_experiment.md +++ b/doc/development/experiment_guide/gitlab_experiment.md @@ -4,26 +4,24 @@ group: Adoption info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments --- -# Implementing an A/B/n experiment using GLEX +# Implementing an A/B/n experiment ## Introduction -`Gitlab::Experiment` (GLEX) is tightly coupled with the concepts provided by -[Feature flags in development of GitLab](../feature_flags/index.md). Here, we refer -to this layer as feature flags, and may also use the term Flipper, because we -built our development and experiment feature flags atop it. - -You're strongly encouraged to read and understand the -[Feature flags in development of GitLab](../feature_flags/index.md) portion of the -documentation before considering running experiments. Experiments add additional -concepts which may seem confusing or advanced without understanding the underpinnings -of how GitLab uses feature flags in development. One concept: GLEX supports -experiments with multiple variants, which are sometimes referred to as A/B/n tests. - -The [`gitlab-experiment` project](https://gitlab.com/gitlab-org/ruby/gems/gitlab-experiment) -exists in a separate repository, so it can be shared across any GitLab property that uses -Ruby. You should feel comfortable reading the documentation on that project as well -if you want to dig into more advanced topics. +Experiments in GitLab are tightly coupled with the concepts provided by +[Feature flags in development of GitLab](../feature_flags/index.md). You're strongly encouraged +to read and understand the [Feature flags in development of GitLab](../feature_flags/index.md) +portion of the documentation before considering running experiments. Experiments add additional +concepts which may seem confusing or advanced without understanding the underpinnings of how GitLab +uses feature flags in development. One concept: experiments can be run with multiple variants, +which are sometimes referred to as A/B/n tests. + +We use the [`gitlab-experiment` gem](https://gitlab.com/gitlab-org/ruby/gems/gitlab-experiment), +sometimes referred to as GLEX, to run our experiments. The gem exists in a separate repository +so it can be shared across any GitLab property that uses Ruby. You should feel comfortable reading +the documentation on that project if you want to dig into more advanced topics or open issues. Be +aware that the documentation there reflects what's in the main branch and may not be the same as +the version being used within GitLab. ## Glossary of terms @@ -35,41 +33,9 @@ when communicating about experiments: - `control`: The default, or "original" code path. - `candidate`: Defines an experiment with only one code path. - `variant(s)`: Defines an experiment with multiple code paths. +- `behaviors`: Used to reference all possible code paths of an experiment, including the control. -### How it works - -Use this decision tree diagram to understand how GLEX works. When an experiment runs, -the following logic is executed to determine what variant should be provided, -given how the experiment has been defined and using the provided context: - -```mermaid -graph TD - GP[General Pool/Population] --> Running? - Running? -->|Yes| Cached?[Cached? / Pre-segmented?] - Running? -->|No| Excluded[Control / No Tracking] - Cached? -->|No| Excluded? - Cached? -->|Yes| Cached[Cached Value] - Excluded? -->|Yes| Excluded - Excluded? -->|No| Segmented? - Segmented? -->|Yes / Cached| VariantA - Segmented? -->|No| Included?[Experiment Group?] - Included? -->|Yes| Rollout - Included? -->|No| Control - Rollout -->|Cached| VariantA - Rollout -->|Cached| VariantB - Rollout -->|Cached| VariantC - -classDef included fill:#380d75,color:#ffffff,stroke:none -classDef excluded fill:#fca121,stroke:none -classDef cached fill:#2e2e2e,color:#ffffff,stroke:none -classDef default fill:#fff,stroke:#6e49cb - -class VariantA,VariantB,VariantC included -class Control,Excluded excluded -class Cached cached -``` - -## Implement an experiment +## Implementing an experiment [Examples](https://gitlab.com/gitlab-org/growth/growth/-/wikis/GLEX-Framework-code-examples) @@ -87,9 +53,9 @@ experiment in code. An experiment implementation can be as simple as: ```ruby experiment(:pill_color, actor: current_user) do |e| - e.use { 'control' } - e.try(:red) { 'red' } - e.try(:blue) { 'blue' } + e.control { 'control' } + e.variant(:red) { 'red' } + e.variant(:blue) { 'blue' } end ``` @@ -146,11 +112,11 @@ We can also implement this experiment in a HAML file with HTML wrappings: ```haml #cta-interface - experiment(:pill_color, actor: current_user) do |e| - - e.use do + - e.control do .pill-button control - - e.try(:red) do + - e.variant(:red) do .pill-button.red red - - e.try(:blue) do + - e.variant(:blue) do .pill-button.blue blue ``` @@ -212,38 +178,30 @@ wouldn't be resolvable. ### Advanced experimentation -GLEX allows for two general implementation styles: +There are two ways to implement an experiment: 1. The simple experiment style described previously. -1. A more advanced style where an experiment class can be provided. +1. A more advanced style where an experiment class is provided. The advanced style is handled by naming convention, and works similar to what you would expect in Rails. To generate a custom experiment class that can override the defaults in -`ApplicationExperiment` (our base GLEX implementation), use the rails generator: +`ApplicationExperiment` use the Rails generator: ```shell rails generate gitlab:experiment pill_color control red blue ``` This generates an experiment class in `app/experiments/pill_color_experiment.rb` -with the variants (or _behaviors_) we've provided to the generator. Here's an example -of how that class would look after migrating the previous example into it: +with the _behaviors_ we've provided to the generator. Here's an example +of how that class would look after migrating our previous example into it: ```ruby class PillColorExperiment < ApplicationExperiment - def control_behavior - 'control' - end - - def red_behavior - 'red' - end - - def blue_behavior - 'blue' - end + control { 'control' } + variant(:red) { 'red' } + variant(:blue) { 'blue' } end ``` @@ -254,13 +212,13 @@ providing the block we were initially providing, by explicitly calling `run`: experiment(:pill_color, actor: current_user).run ``` -The _behavior_ methods we defined in our experiment class represent the default -implementation. You can still use the block syntax to override these _behavior_ -methods however, so the following would also be valid: +The _behaviors_ we defined in our experiment class represent the default +implementation. You can still use the block syntax to override these _behaviors_ +however, so the following would also be valid: ```ruby experiment(:pill_color, actor: current_user) do |e| - e.use { '<strong>control</strong>' } + e.control { '<strong>control</strong>' } end ``` @@ -279,11 +237,11 @@ variant, and any account older than 2 weeks old would be assigned the _blue_ var ```ruby class PillColorExperiment < ApplicationExperiment + # ...registered behaviors + segment(variant: :red) { context.actor.first_name == 'Richard' } segment :old_account?, variant: :blue - # ...behaviors - private def old_account? @@ -315,9 +273,9 @@ be nothing - but no events are tracked in these cases as well. ```ruby class PillColorExperiment < ApplicationExperiment - exclude :old_account?, ->{ context.actor.first_name == 'Richard' } + # ...registered behaviors - # ...behaviors + exclude :old_account?, ->{ context.actor.first_name == 'Richard' } private @@ -327,23 +285,11 @@ class PillColorExperiment < ApplicationExperiment end ``` -We can also do exclusion when we run the experiment. For instance, -if we wanted to prevent the inclusion of non-administrators in an experiment, consider -the following experiment. This type of logic enables us to do complex experiments -while preventing us from passing things into our experiments, because -we want to minimize passing things into our experiments: - -```ruby -experiment(:pill_color, actor: current_user) do |e| - e.exclude! unless can?(current_user, :admin_project, project) -end -``` - You may also need to check exclusion in custom tracking logic by calling `should_track?`: ```ruby class PillColorExperiment < ApplicationExperiment - # ...behaviors + # ...registered behaviors def expensive_tracking_logic return unless should_track? @@ -353,16 +299,11 @@ class PillColorExperiment < ApplicationExperiment end ``` -Exclusion rules aren't the best way to determine if an experiment is active. Override -the `enabled?` method for a high-level way of determining if an experiment should -run and track. Make the `enabled?` check as efficient as possible because it's the -first early opt-out path an experiment can implement. - ### Tracking events One of the most important aspects of experiments is gathering data and reporting on -it. GLEX provides an interface that allows tracking events across an experiment. -You can implement it consistently if you provide the same context between +it. You can use the `track` method to track events across an experimental implementation. +You can track events consistently to an experiment if you provide the same context between calls to your experiment. If you do not yet understand context, you should read about contexts now. @@ -373,10 +314,10 @@ the arguments you would normally use when of tracking an event in Ruby would be: ```ruby -experiment(:pill_color, actor: current_user).track(:created) +experiment(:pill_color, actor: current_user).track(:clicked) ``` -When you run an experiment with any of these examples, an `:assigned` event +When you run an experiment with any of the examples so far, an `:assigned` event is tracked automatically by default. All events that are tracked from an experiment have a special [experiment context](https://gitlab.com/gitlab-org/iglu/-/blob/master/public/schemas/com.gitlab/gitlab_experiment/jsonschema/1-0-0) @@ -390,28 +331,20 @@ event would be tracked at that time for them. NOTE: GitLab tries to be sensitive and respectful of our customers regarding tracking, -so GLEX allows us to implement an experiment without ever tracking identifying +so our experimentation library allows us to implement an experiment without ever tracking identifying IDs. It's not always possible, though, based on experiment reporting requirements. You may be asked from time to time to track a specific record ID in experiments. The approach is largely up to the PM and engineer creating the implementation. No recommendations are provided here at this time. -## Test with RSpec - -This gem provides some RSpec helpers and custom matchers. These are in flux as of GitLab 13.10. - -First, require the RSpec support file to mix in some of the basics: +## Testing with RSpec -```ruby -require 'gitlab/experiment/rspec' -``` - -You still need to include matchers and other aspects, which happens -automatically for files in `spec/experiments`, but for other files and specs -you want to include it in, you can specify the `:experiment` type: +In the course of working with experiments, you'll probably want to utilize the RSpec +tooling that's built in. This happens automatically for files in `spec/experiments`, but +for other files and specs you want to include it in, you can specify the `:experiment` type: ```ruby -it "tests", :experiment do +it "tests experiments nicely", :experiment do end ``` @@ -421,28 +354,32 @@ You can stub experiments using `stub_experiments`. Pass it a hash using experime names as the keys, and the variants you want each to resolve to, as the values: ```ruby -# Ensures the experiments named `:example` & `:example2` are both -# "enabled" and that each will resolve to the given variant -# (`:my_variant` & `:control` respectively). +# Ensures the experiments named `:example` & `:example2` are both "enabled" and +# that each will resolve to the given variant (`:my_variant` and `:control` +# respectively). stub_experiments(example: :my_variant, example2: :control) experiment(:example) do |e| e.enabled? # => true - e.variant.name # => 'my_variant' + e.assigned.name # => 'my_variant' end experiment(:example2) do |e| e.enabled? # => true - e.variant.name # => 'control' + e.assigned.name # => 'control' end ``` -### Exclusion and segmentation matchers +### Exclusion, segmentation, and behavior matchers -You can also test the exclusion and segmentation matchers. +You can also test things like the registered behaviors, the exclusions, and +segmentations using the matchers. ```ruby class ExampleExperiment < ApplicationExperiment + control { } + candidate { '_candidate_' } + exclude { context.actor.first_name == 'Richard' } segment(variant: :candidate) { context.actor.username == 'jejacks0n' } end @@ -450,6 +387,10 @@ end excluded = double(username: 'rdiggitty', first_name: 'Richard') segmented = double(username: 'jejacks0n', first_name: 'Jeremy') +# register_behavior matcher +expect(experiment(:example)).to register_behavior(:control) +expect(experiment(:example)).to register_behavior(:candidate).with('_candidate_') + # exclude matcher expect(experiment(:example)).to exclude(actor: excluded) expect(experiment(:example)).not_to exclude(actor: segmented) @@ -495,18 +436,11 @@ expect(experiment(:example)).to track(:my_event, value: 1, property: '_property_ experiment(:example, :variant_name, foo: :bar).track(:my_event, value: 1, property: '_property_') ``` -### Recording and assignment tracking - -To test assignment tracking and the `record!` method, you can use or adopt the following -shared example: [tracks assignment and records the subject](https://gitlab.com/gitlab-org/gitlab/blob/master/spec/support/shared_examples/lib/gitlab/experimentation_shared_examples.rb). - ## Experiments in the client layer -This is in flux as of GitLab 13.10, and can't be documented just yet. - Any experiment that's been run in the request lifecycle surfaces in and `window.gl.experiments`, -and matches [this schema](https://gitlab.com/gitlab-org/iglu/-/blob/master/public/schemas/com.gitlab/gitlab_experiment/jsonschema/1-0-0) -so you can use it when resolving some concepts around experimentation in the client layer. +and matches [this schema](https://gitlab.com/gitlab-org/iglu/-/blob/master/public/schemas/com.gitlab/gitlab_experiment/jsonschema/1-0-3) +so it can be used when resolving experimentation in the client layer. ### Use experiments in Vue @@ -628,7 +562,7 @@ is viewed as being either `on` or `off`, this isn't accurate for experiments. Generally, `off` means that when we ask if a feature flag is enabled, it will always return `false`, and `on` means that it will always return `true`. An interim state, -considered `conditional`, also exists. GLEX takes advantage of this trinary state of +considered `conditional`, also exists. We take advantage of this trinary state of feature flags. To understand this `conditional` aspect: consider that either of these settings puts a feature flag into this state: @@ -638,7 +572,7 @@ settings puts a feature flag into this state: Conditional means that it returns `true` in some situations, but not all situations. When a feature flag is disabled (meaning the state is `off`), the experiment is -considered _inactive_. You can visualize this in the [decision tree diagram](#how-it-works) +considered _inactive_. You can visualize this in the [decision tree diagram](https://gitlab.com/gitlab-org/ruby/gems/gitlab-experiment#how-it-works) as reaching the first `Running?` node, and traversing the negative path. When a feature flag is rolled out to a `percentage_of_actors` or similar (meaning the diff --git a/spec/experiments/application_experiment_spec.rb b/spec/experiments/application_experiment_spec.rb index 5146fe3e7524..7c2b5382c420 100644 --- a/spec/experiments/application_experiment_spec.rb +++ b/spec/experiments/application_experiment_spec.rb @@ -117,7 +117,7 @@ describe '#publish_to_database' do using RSpec::Parameterized::TableSyntax - let(:publish_to_database) { application_experiment.publish_to_database } + let(:publish_to_database) { ActiveSupport::Deprecation.silence { application_experiment.publish_to_database } } shared_examples 'does not record to the database' do it 'does not create an experiment record' do @@ -358,11 +358,11 @@ stub_feature_flags(namespaced_stub: true) end - it "returns the first variant name" do + it "returns an assigned name" do application_experiment.try(:variant1) {} application_experiment.try(:variant2) {} - expect(application_experiment.variant.name).to eq('variant1') + expect(application_experiment.variant.name).to eq('variant2') end end end diff --git a/spec/support/gitlab_experiment.rb b/spec/support/gitlab_experiment.rb index 3d099dc689c6..21364bbb4e47 100644 --- a/spec/support/gitlab_experiment.rb +++ b/spec/support/gitlab_experiment.rb @@ -10,9 +10,24 @@ # Disable all caching for experiments in tests. config.before do allow(Gitlab::Experiment::Configuration).to receive(:cache).and_return(nil) + + # Disable all deprecation warnings in the test environment, which can be + # resolved one by one and tracked in: + # + # https://gitlab.com/gitlab-org/gitlab/-/issues/350944 + allow(Gitlab::Experiment::Configuration).to receive(:deprecator).and_wrap_original do |method, version| + method.call(version).tap do |deprecator| + deprecator.silenced = true + end + end + end config.before(:each, :experiment) do stub_snowplow end end + +# Once you've resolved a given deprecation, you can disallow it here, which +# will raise an exception if it's used anywhere. +ActiveSupport::Deprecation.disallowed_warnings << "`experiment_group?` is deprecated" diff --git a/spec/support/shared_examples/lib/gitlab/experimentation_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/experimentation_shared_examples.rb index 5baa6478225e..fdca326dbea8 100644 --- a/spec/support/shared_examples/lib/gitlab/experimentation_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/experimentation_shared_examples.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true RSpec.shared_examples 'tracks assignment and records the subject' do |experiment, subject_type| + before do + stub_experiments(experiment => true) + end + it 'tracks the assignment', :experiment do expect(experiment(experiment)) .to track(:assignment) @@ -11,9 +15,7 @@ end it 'records the subject' do - stub_experiments(experiment => :candidate) - - expect(Experiment).to receive(:add_subject).with(experiment.to_s, variant: :experimental, subject: subject) + expect(Experiment).to receive(:add_subject).with(experiment.to_s, variant: anything, subject: subject) action end -- GitLab