gitlab-experiment 0.2.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fae9519317810c50decdff810249899d5c1855881544a20a31a2219cf9bda715
4
- data.tar.gz: 19987d9c24486e99424aa6cfcade34d3123dbe1fc4a6afb8dc6c4c6ad4f5a99b
3
+ metadata.gz: 8b64d15c51bec74dc0b9be36e3b1541c3509c0f62a2259595c71366ff66f35e3
4
+ data.tar.gz: 5c6862e1a9d44335ebe1d6b6e3974da4e0e368297d006eb91398ebf9d3004681
5
5
  SHA512:
6
- metadata.gz: afe30984c7b7f96bb4ad1122c5a4f5ff48a45f8c7cfca5b1d2d04368ba3eef25c84776e3768588cde6cd717ee918b25e8158746ea9ea3e9bb20fe9ef39d20377
7
- data.tar.gz: 2492d43cbc76cfa20c1b785033fdc635ca75874d1052270c950a86acff17d04011c406bf1879bc5f21b06fb604c5ed259c149aecbea33efe2adae59ab12f6164
6
+ metadata.gz: 0cea1580efdbe7daa88f76fb93de94d4ea04059bbc1bd25a15edf4a501e139ec9102c6abf9c2002e797ecfc9bb6ffc3115dcf4e817b94b5f6499ed09ca14de75
7
+ data.tar.gz: 20490eec566060797379ecea83e07fe74a00f2725817bc396b6bea7cfc07947cd120562d433fb33d6db5b4094c58fd50b2ff1cb9455867c6300443fc4c2c6509
data/README.md CHANGED
@@ -1,36 +1,48 @@
1
- # Experiment Guide
1
+ GitLab Experiment
2
+ =================
2
3
 
3
- Experiments can be conducted by any GitLab team, but are most often conducted by the teams from the [Growth Sub-department](https://about.gitlab.com/handbook/engineering/development/growth/).
4
+ Here at GitLab, experiments are run as A/B/n tests and are evaluated by the data the experiment generates. The team reviews the data, determines the variant that performed most effectively, and promotes that variant as the new default code path (or reverts back to the control).
4
5
 
5
- Experiments are run as A/B/n tests and are evaluated by the data the experiment generates. The team reviews the data, determines the variant that performed most effectively, and promotes that variant as the new default code path (or reverts back to the control). In all instances, an experiment will be cleaned up after it has generated enough data to determine performance and be considered resolved.
6
+ When we discuss the behavior of this gem, we'll use terms like experiment, control, candidate, and variant. It's worth defining these terms so they're more understood.
6
7
 
7
- ## Process and tracking issue
8
+ - `experiment` is any deviation of code paths we want to run sometimes and not others.
9
+ - `control` is the default, or "original" code path.
10
+ - `candidate` is used if there's one experimental code path.
11
+ - `variant(s)` is used when more than one experimental code path exists.
8
12
 
9
- Each experiment should have a related [Experiment tracking issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new?issuable_template=Experiment%20tracking) created, which is intended to track the experiment from deployment and rollout through to resolution and cleanup.
13
+ Candidate and variant are referencing the same concept, but can simplify how we speak about the available code paths of a given experiment.
10
14
 
11
- At the time an experiment is rolled out, the due date of the tracking issue should be specified. The timeline depends on the experiment and can be up to several weeks in the future.
15
+ ## Installation
12
16
 
13
- With experiment resolution, the outcome of the experiment should be posted to the tracking issue with the reasoning for the decision. Any and all non-relevant experiment code should be removed and review concerns should be addressed during cleanup. After cleanup, the tracking issue can be closed.
17
+ Add the gem to your Gemfile and then bundle install.
14
18
 
15
- ## Implementing an experiment
19
+ ```ruby
20
+ gem 'gitlab-experiment'
21
+ ```
16
22
 
17
- For the sake of our example, let's say we want to run an experiment for how to cancel a subscription. In our control (current world) we show a toggle that reads "Auto-renew", and in our experiment candidate we want to show a "Cancel subscription" button with a confirmation. Ultimately the behavior is the same, but the interface will be considerably different.
23
+ If you're using Rails, you can install an initializer that provides basic configuration by running the install generator.
18
24
 
19
- ### Defining the feature flag
25
+ ```shell
26
+ $ rails generate gitlab-experiment:install
27
+ ```
20
28
 
21
- Let's name our experiment `subscription_cancellation`. It's important to understand how this name is prefixed as `growth_experiment_subscription_cancellation` in our Unleash feature detection and in our Snowplow tracking calls. We prefix our experiments so we can consistently identify them as experiments and clean them up over time.
29
+ ## Implementing an experiment
22
30
 
23
- This means that you'll need to go to the [Feature Flags interface](https://gitlab.com/gitlab-org/gitlab/-/feature_flags) interface (for the project you're working on) and add a `growth_experiment_subscription_cancellation` feature flag and define which environment(s) it should be rolled out on for initial deployment, for instance, `gitlab-staging` or `customers-staging`.
31
+ For the sake of our example, let's say we want to run an experiment for the interface we render for subscription cancellation.
24
32
 
25
- You can include yourself and others in a list and assign that list using the `User List` rollout strategy, or you can add your user id to the experiment manually. You can use this to include or exclude yourself from an experiment for verification before rolling it out to others.
33
+ In our control (current world) we show a simple toggle interface that reads "Auto-renew", and in our experiment candidate we want to show a "Cancel subscription" button with a confirmation dialog. Ultimately the behavior will be the same, but the interface will be considerably different and may involve more or less steps.
34
+
35
+ We hypothesize that making it more clear what the action is will help people in making a choice about taking that action or not.
26
36
 
27
- ### Implementation
37
+ We'll name our experiment `subscription_cancellation`. It's important to understand this name may be prefixed based on your configuration, so if you've set `config.name_prefix = "gitlab"` it would be `gitlab_subscription_cancellation`.
28
38
 
29
- When you implement an experiment in code you'll need to provide the name that you've given it in the feature flags interface, and a context -- which usually will include something like a user / user id, but may also include several other aspects.
39
+ When you implement and run an experiment you'll need to provide the name you've given it, and a context hash. The context hash is used to determine which variant to provide, and is expected to be consistent between calls to your experiment, so a context hash key can be generated and cached -- which is used to consistently render the same experience given the same context.
30
40
 
41
+ In our experiment we're going to render one of two views. Control will be our current one, and candidate will be the new view with a cancel button and javascript confirm call.
42
+
31
43
  ```ruby
32
44
  class SubscriptionsController < ApplicationController
33
- include Gitlab::GrowthExperiment::Interface
45
+ include Gitlab::Experiment::Dsl
34
46
 
35
47
  def show
36
48
  experiment(:subscription_cancellation, user_id: user.id) do |e|
@@ -41,7 +53,7 @@ class SubscriptionsController < ApplicationController
41
53
  end
42
54
  ```
43
55
 
44
- You can also provide different variants for the experience if you've defined variants in the Feature Flag interface (not yet available).
56
+ You can also provide different variant names if you want to. In our case we might want to include the confirmation dialog step or not, to see which one behaves better within our wider experiment.
45
57
 
46
58
  ```ruby
47
59
  experiment(:subscription_cancellation, user_id: user.id) do |e|
@@ -51,7 +63,7 @@ experiment(:subscription_cancellation, user_id: user.id) do |e|
51
63
  end
52
64
  ```
53
65
 
54
- Later, and elsewhere in code you can use the same `experiment` call to track events on the experiment. The important detail here is to use the same context between your calls to `experiment`. If the context is the same, we're able to consistently track the event in a way that associates it to which variant is being presented -- which may be based on the user we're presenting it to.
66
+ Later, and elsewhere in code you can use the same `experiment` call to track events on the experiment. You can use this consistent event concept to build out funnels from the data that's being tracked. The important detail here is to use the same context between your calls to `experiment`. If the context is the same, we're able to consistently track the event in a way that associates it to which variant is being presented.
55
67
 
56
68
  ```ruby
57
69
  exp = experiment(:subscription_cancellation, user_id: user.id)
@@ -59,38 +71,41 @@ exp.track('clicked_button')
59
71
  ```
60
72
 
61
73
  <details>
62
- <summary>You can use the more low level class or instance interfaces...</summary>
74
+ <summary>You can also use the more low level class or instance interfaces...</summary>
63
75
 
64
76
  ### Class level interface using `.run`
65
77
 
66
78
  ```ruby
67
- exp = Gitlab::GrowthExperiment.run(:subscription_cancellation, user_id: user.id) do |e|
68
- # context can be passed to `experiment`, `.run`, `new`, or after the fact like here.
69
- # context must be added before `#run` or `#track` calls.
70
- e.context(project_id: project.id)
71
-
79
+ exp = Gitlab::Experiment.run(:subscription_cancellation, user_id: user.id) do |e|
80
+ # Context may be passed in the block, but must be finalized before calling
81
+ # run or track.
82
+ e.context(project_id: project.id) # add the project id to the context
83
+ e.variant(:candidate) # always run the candidate
84
+ # Define the control and candidate variant.
72
85
  e.use { toggle_button_interface } # control
73
86
  e.try { cancel_button_interface } # candidate
74
87
  end
75
88
 
76
- # track an event on the experiment we've defined.
89
+ # Track an event on the experiment we've defined.
77
90
  exp.track(:clicked_button)
78
91
  ```
79
92
 
80
- While `Gitlab::GrowthExperiment.run` is what we document, you can also use `Gitlab::GrowthExperiment.experiment`.
81
-
82
93
  ### Instance level interface
83
94
 
84
95
  ```ruby
85
- exp = Gitlab::GrowthExperiment.new(:subscription_cancellation, user_id: user.id)
86
- # context can be passed to `.new`, or after the fact like here.
87
- # context must be added before `#run` or `#track` calls.
88
- exp.context(project_id: project.id)
96
+ exp = Gitlab::Experiment.new(:subscription_cancellation, user_id: user.id)
97
+ # Context may be passed in the block, but must be finalized before calling
98
+ # run or track.
99
+ exp.context(project_id: project.id) # add the project id to the context
100
+
101
+ # Define the control and candidate variant.
89
102
  exp.use { toggle_button_interface } # control
90
103
  exp.try { cancel_button_interface } # candidate
104
+
105
+ # Run the experiment -- returning the result.
91
106
  exp.run
92
107
 
93
- # track an event on the experiment we've defined.
108
+ # Track an event on the experiment we've defined.
94
109
  exp.track(:clicked_button)
95
110
  ```
96
111
 
@@ -102,29 +117,33 @@ exp.track(:clicked_button)
102
117
  ### Custom class
103
118
 
104
119
  ```ruby
105
- class CancellationExperiment < Gitlab::GrowthExperiment
120
+ class CancellationExperiment < Gitlab::Experiment
106
121
  def initialize(variant_name = nil, **context, &block)
107
122
  super(:subscription_cancellation, variant_name, **context, &block)
123
+
124
+ # Define the control and candidate variant.
125
+ use { toggle_button_interface } # control
126
+ try { cancel_button_interface } # candidate
108
127
  end
109
128
  end
110
129
 
111
- exp = CancellationExperiment.run(user_id: user.id) do |e|
112
- # context can be passed to `.run`, or after the fact like here.
113
- # context must be added before `#run` or `#track` calls.
114
- e.context(project_id: project.id)
115
-
116
- e.use { toggle_button_interface } # control
117
- e.try { cancel_button_interface } # candidate
130
+ exp = CancellationExperiment.new(user_id: user.id) do |e|
131
+ # Context may be passed in the block, but must be finalized before calling
132
+ # run or track.
133
+ e.context(project_id: project.id) # add the project id to the context
118
134
  end
119
135
 
120
- # track an event on the experiment we've defined.
136
+ # Run the experiment -- returning the result.
137
+ exp.run
138
+
139
+ # Track an event on the experiment we've defined.
121
140
  exp.track(:clicked_button)
122
141
  ```
123
142
 
124
143
  </details>
125
144
 
126
145
  <details>
127
- <summary>You can hard specify the variant to use...</summary>
146
+ <summary>You can also hard specify the variant to use...</summary>
128
147
 
129
148
  ### Specifying which variant to use
130
149
 
@@ -138,45 +157,62 @@ experiment(:subscription_cancellation, :no_interface, user_id: user.id) do |e|
138
157
  end
139
158
  ```
140
159
 
141
- Or you can set the variant within the block.
160
+ Or you can set the variant within the block -- potentially using some unique or different segmentation strategy that you've written specifically for the experiment at hand.
142
161
 
143
162
  ```ruby
144
163
  experiment(:subscription_cancellation, user_id: user.id) do |e|
145
- e.variant(:variant) # set the variant
164
+ e.variant(:no_interface) # set the variant
146
165
  # ...
147
166
  end
148
167
  ```
149
168
 
150
169
  </details>
151
170
 
152
- The `experiment` method, and the underlying `Gitlab::GrowthExperiment` is an implementation on top of [Scientist](https://github.com/github/scientist). Generally speaking you can use the DSL that Scientist defines, but for experiments we use `experiment` instead of `science`, and specify the variant on initialization (or via `#variant` and not in the call to `#run`. The interface is otherwise the same, even though not every aspect of Scientist makes sense for experiments.
171
+ The `experiment` method, and the underlying `Gitlab::Experiment` instance is an implementation on top of [Scientist](https://github.com/github/scientist). Generally speaking you can use the DSL that Scientist defines, but for experiments we use `experiment` instead of `science`, and specify the variant on initialization (or via `#variant` and not in the call to `#run`. The interface is otherwise the same, even though not every aspect of Scientist makes sense for experiments.
153
172
 
154
173
  ### Context migrations
155
174
 
156
- There are times when we may need to add new values or change something that we're providing in context while an experiment is running. We make this possible by passing the `migrated_from` context key.
175
+ There are times when we may need to change something that we're providing in the context while an experiment is running, or even add or remove contexts. We make this possible by passing the migration data to the experiment.
157
176
 
158
- Take for instance, that you might be using `version: 1` in your context. If you want to migrate this to `version: 2`, you just need to provide the context that you provided prior, in a `migrated_from` context key. In doing this, a given experiment experience can be resolved back through any number of migrations.
177
+ Take for instance, that you might be using `version: 1` in your context currently. If you want to migrate this to `version: 2`, you just need to provide the context that you want to change using a `migrated_with` option. In doing this, a given experience (variant rendered/events tracked) can be resolved back through any number of migrations, and can be cached/resolved by using the context key value that was already already generated.
159
178
 
160
179
  ```ruby
161
- experiment(:my_experiment, version: 2, migrated_from: { version: 1 })
180
+ experiment(:my_experiment, user_id: 42, version: 2, migrated_with: { version: 1 })
162
181
  ```
163
182
 
164
- It's important to understand that this can bucket a user in a new experience (depending on the rollout strategy being used and what is changing in the context), so you should investigate how this might impact your experiment before using it.
183
+ If you're adding or removing a new a value from the context, you'll need to use `migrated_from`, which expects a full context replacement -- e.g. what it was before you added or removed the new context key. For instance, if you wanted to introduce the `version: 1` concept to your context, you would need to use something like the following.
184
+
185
+ ```ruby
186
+ experiment(:my_experiment, user_id: 42, version: 1, migrated_from: { user_id: 42 })
187
+ ```
165
188
 
166
- ### When there isn't a user
189
+ It's important to understand that this can bucket a user in a new experience (depending on the logic in determining in the variant), so you should investigate how this might impact your experiment before using it and that your experiment is implemented in a way that supports migrations.
167
190
 
168
- When there isn't a user, we typically have to fall back to another concept to provide a consistent experiment experience. What this means, is that once we bucket someone in a certain bucket, we always bucket them in that bucket.
191
+ ### When there isn't a user (cookies)
169
192
 
170
- We do this by using cookies.... [document more]
193
+ When there isn't a user we typically have to fall back to another concept to provide a consistent experience. What this means, is that once we assign someone a certain variant, we want to always give them the same experience, and we do this by setting a cookie for the experiment we're currently checking on. This cookie value is a random uuid, which we auto migrate to a user_id when our experiment context is finally provided that information. This means that you really only need to provide the request as an option if you're wanting to have an experiment that flows from not-signed-in (or not registered) users to eventually signed-in users.
171
194
 
172
- ## Tracking, anonymity and GDPR
195
+ This is considered a temporary cookie value, and isn't used for tracking purposes other than to give a given "user" (in this case it's actually the browser), a consistent experience after we've assigned one.
173
196
 
174
- We intentionally don't, and shouldn't, track things like user ids. What we can and do track is what we consider an "experiment experience" key. This key is generated by the context we pass to the experiment implementation. If we consistently pass the same context to an experiment, we're able to consistently track events generated in that experience. A context can contain things like user, or project -- so, if you only included a user in the context that user would get the same experience across all projects they view, but if you include the currently viewed project in the context the user would potentially have a different experience on each of their projects. Each can be desirable given the objectives of the experiment.
197
+ To read and write cookies, we allow for passing the `request` as an option. This allows us to read, write, and even clean up a cookie when appropriate. We've provided this by default in the ActionController interface though, so this is primarily useful if you're trying to have an experiment span controller and model layers.
175
198
 
176
- ## Code quality expectations
199
+ ```ruby
200
+ experiment(:subscription_cancellation, user_id: user.id, request: request) do |e|
201
+ e.use { render_toggle_button } # control
202
+ e.try { render_cancel_button } # candidate
203
+ end
204
+ ```
205
+
206
+ If needed, for edge cases (like in a background job), you can manually pass in the cookie value. Passing it in as `user_id: request.cookie_jar.signed['subscription_cancellation_id']`. The cookie name is the experiment name (prefixed if configured to be) with `_id` appended.
207
+
208
+ ## Configuration
177
209
 
178
- Since experimental code is inherently short lived, an intentionally stated goal is to iterate quickly to generate and evaluate performance data.
210
+ The gem is meant to be configured before being used. The default configuration will always render the control behavior, so it's important to implement your own logic for this or you will always get the control of an experiment.
179
211
 
180
- This goal prioritizes iteration and resolution over code quality, which means that experiment code may not always meet our code standards guidelines, but should also not negatively impact the availability of GitLab nor contribute to bad data. Even though experiments will be deployed to a minority of users, we still expect a flawless experience for those users, therefore, good test coverage is still required.
212
+ The most important aspect of the gem, determining which variant to render and when, is up to you, and you may want to consider using [Unleash](https://github.com/Unleash/unleash-client-ruby) (which has the concept of multi-variants built in), or [Flipper](https://github.com/jnunemaker/flipper) in helping with this.
213
+
214
+ Examples for configuration are available in the provided install generator, or in the source code configuration.rb file itself.
215
+
216
+ ## Tracking, anonymity and GDPR
181
217
 
182
- Reviewers and maintainers are encouraged to note when code doesn't meet our code standards guidelines. Please mention your concerns and include or link to them on the experiment tracking issue. The experiment author(s) are responsible for addressing these concerns when the experiment is resolved.
218
+ We intentionally don't, and shouldn't, track things like user ids on our experiments. What we can and do track is what we consider an "experiment experience" key. This key is generated from the context we pass to the experiment and has the concept of migrating through different versions of context. If we consistently pass the same context to an experiment, we're able to consistently track events generated in that experience. A context can contain things like user, or project -- so, if you only included a user in the context that user would get the same experience across all projects they view, but if you include the currently viewed project in the context the user would potentially have a different experience on each of their projects. Each can be desirable given the objectives of the experiment.
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module GitlabExperiment
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path(__dir__)
9
+
10
+ desc 'Installs the Gitlab Experiment initializer into your application.'
11
+
12
+ def copy_initializers
13
+ copy_file 'templates/initializer.rb', 'config/initializers/gitlab_experiment.rb'
14
+ end
15
+
16
+ def display_post_install
17
+ readme 'POST_INSTALL' if behavior == :invoke
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gitlab::Experiment.configure do |config|
4
+ # Prefix all experiment names with a given value. Use `nil` for none.
5
+ config.name_prefix = nil
6
+
7
+ # The logger is used to log various details of the experiments.
8
+ config.logger = Logger.new(STDOUT)
9
+
10
+ # Logic this project uses to resolve a variant for a given experiment.
11
+ #
12
+ # This can return an instance of any object that responds to `name`, or can
13
+ # return a variant name as a string, in which case the build in variant
14
+ # class will be used.
15
+ #
16
+ # This block will be executed within the scope of the experiment instance,
17
+ # so can easily access experiment methods, like getting the name or context.
18
+ config.variant_resolver = lambda do |requested_variant|
19
+ # An example of running the control, unless a variant was requested:
20
+ requested_variant || 'control'
21
+
22
+ # Always run candidate, unless a variant was requested, with fallback:
23
+ # variant_name || variant_names.first || 'control'
24
+
25
+ # Using unleash to determine the variant:
26
+ # TODO: this isn't entirely accurate.
27
+ # fallback = Unleash::Variant.new(name: requested_variant || 'control', enabled: true)
28
+ # UNLEASH.get_variant(name, context.value, fallback)
29
+
30
+ # Using Flipper to determine the variant:
31
+ # TODO: provide example.
32
+ # Variant.new(name: resolved)
33
+ end
34
+
35
+ # Tracking behavior can be implemented to link an event to an experiment.
36
+ #
37
+ # Similar to the variant resolver, this is called within the scope of the
38
+ # experiment instance and so can access any methods on the experiment.
39
+ config.tracking_behavior = lambda do |action, event_args|
40
+ # An example of using a generic logger to track events:
41
+ (event_args[:context] ||= []) << context.signature.merge(group: variant.name)
42
+ config.logger.info "Gitlab::Experiment[#{name}] #{action}: #{event_args}"
43
+
44
+ # Using something like snowplow to track events:
45
+ # (event_args[:context] ||= []) << SnowplowTracker::SelfDescribingJson.new(
46
+ # 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0',
47
+ # experiment: context.signature.merge(group: variant.name)
48
+ # )
49
+ #
50
+ # Tracking.event(name, action, **event_args)
51
+ end
52
+
53
+ # Called at the end of every experiment run, with the result.
54
+ #
55
+ # You may want to track that you've assigned a variant to a given context,
56
+ # or push the experiment into the client or publish results elsewhere, like
57
+ # into redis.
58
+ config.publishing_behavior = lambda do |_result|
59
+ track(:assignment) # this will call the config.tracking_behavior
60
+
61
+ # Log the results, so we can inspect them if we wanted to later.
62
+ # LogRage.log(result: _result)
63
+
64
+ # Push the experiment knowledge into the client using Gon.
65
+ # Gon.push(experiment: { name => signature })
66
+ end
67
+
68
+ # Algorithm that consistently generates a hash key for a given hash map.
69
+ #
70
+ # Given a specific context hash map, we need to generate a consistent hash
71
+ # key. The logic in here will be used for generating cache keys, and may also
72
+ # be used when determining which variant may be presented.
73
+ config.context_hash_strategy = lambda do |context|
74
+ values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
75
+ Digest::MD5.hexdigest((context.keys + values).join('|'))
76
+ end
77
+ end
@@ -7,6 +7,7 @@ require 'gitlab/experiment/context'
7
7
  require 'gitlab/experiment/dsl'
8
8
  require 'gitlab/experiment/variant'
9
9
  require 'gitlab/experiment/version'
10
+ require 'gitlab/experiment/engine' if defined?(Rails::Engine)
10
11
 
11
12
  module Gitlab
12
13
  class Experiment
@@ -25,6 +26,8 @@ module Gitlab
25
26
  end
26
27
  end
27
28
 
29
+ delegate :signature, to: :context
30
+
28
31
  def initialize(name, variant_name = nil, **context)
29
32
  @name = name
30
33
  @variant_name = variant_name
@@ -47,15 +50,16 @@ module Gitlab
47
50
 
48
51
  def variant(value = nil)
49
52
  @variant_name = value unless value.nil?
50
- instance_exec(@variant_name, &Configuration.variant_resolver)
53
+ result = instance_exec(@variant_name, &Configuration.variant_resolver)
54
+ result.respond_to?(:name) ? result : Variant.new(name: result.to_s)
51
55
  end
52
56
 
53
57
  def run
54
58
  @result ||= super(variant.name)
55
59
  end
56
60
 
57
- def publish(_result)
58
- track(:assignment)
61
+ def publish(result)
62
+ instance_exec(result, &Configuration.publishing_behavior)
59
63
  end
60
64
 
61
65
  def track(action, **event_args)
@@ -10,41 +10,25 @@ module Gitlab
10
10
  include Singleton
11
11
 
12
12
  # Prefix all experiment names with a given value. Use `nil` for none.
13
- @name_prefix = 'gitlab_experiment'
13
+ @name_prefix = nil
14
14
 
15
15
  # The logger is used to log various details of the experiments.
16
16
  @logger = Logger.new(STDOUT)
17
17
 
18
18
  # Logic this project uses to resolve a variant for a given experiment.
19
19
  @variant_resolver = lambda do |requested_variant|
20
- # An example of running the control, unless a variant was requested:
21
- Variant.new(name: requested_variant || 'control')
22
-
23
- # Always run candidate, unless a variant was requested, with fallback:
24
- # Variant.new(name: variant_name || variant_names.first || 'control')
25
-
26
- # Using unleash to determine the variant:
27
- # fallback = Unleash::Variant.new(name: 'control', enabled: true)
28
- # Unleash.get_variant(name, context.value, fallback)
29
-
30
- # Using Flipper to determine the variant:
31
- # TODO: provide example
32
- # Variant.new(name: resolved)
20
+ requested_variant || 'control'
33
21
  end
34
22
 
35
23
  # Tracking behavior can be implemented to link an event to an experiment.
36
24
  @tracking_behavior = lambda do |action, event_args|
37
- # An example of using a generic logger to track events:
38
- (event_args[:context] ||= []) << context.signature.merge(group: variant.name)
39
- Configuration.logger.info "EXPERIMENT[#{name}] #{action}: #{event_args}"
40
-
41
- # Using snowplow to track events:
42
- # (event_args[:context] ||= []) << SnowplowTracker::SelfDescribingJson.new(
43
- # 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0',
44
- # experiment: context.signature.merge(group: variant.name)
45
- # )
46
- #
47
- # Tracking.event(name, action, **event_args)
25
+ Configuration.logger.info "Gitlab::Experiment[#{name}] #{action}: #{event_args.merge(signature: signature)}"
26
+ end
27
+
28
+ # Called at the end of every experiment run, with the results. You may
29
+ # want to push the experiment into the client or push results elsewhere.
30
+ @publishing_behavior = lambda do |_result|
31
+ track(:assignment) # this will call the config.tracking_behavior
48
32
  end
49
33
 
50
34
  # Algorithm that consistently generates a hash key for a given hash map.
@@ -55,7 +39,7 @@ module Gitlab
55
39
 
56
40
  class << self
57
41
  attr_accessor :name_prefix, :logger
58
- attr_accessor :variant_resolver, :tracking_behavior, :context_hash_strategy
42
+ attr_accessor :variant_resolver, :tracking_behavior, :publishing_behavior, :context_hash_strategy
59
43
  end
60
44
  end
61
45
  end
@@ -3,6 +3,8 @@
3
3
  module Gitlab
4
4
  class Experiment
5
5
  class Context
6
+ DNT_REGEXP = /^(true|t|yes|y|1|on)$/i.freeze
7
+
6
8
  def initialize(experiment)
7
9
  @experiment = experiment
8
10
  @value = {}
@@ -27,32 +29,45 @@ module Gitlab
27
29
  end
28
30
 
29
31
  def signature
30
- @signature ||= { key: key_for(@value), migration_keys: migration_keys }.compact
32
+ @signature ||= {
33
+ key: key_for(@value),
34
+ migration_keys: migration_keys,
35
+ variant: @experiment.variant.name
36
+ }.compact
31
37
  end
32
38
 
33
39
  private
34
40
 
35
- def migrate_cookie_to_user_id(value)
36
- return value unless (request = value.delete(:request))
37
- return value if request.headers['DNT'].to_s.match?(/^(true|t|yes|y|1|on)$/i)
38
-
39
- cookie_name = [@experiment.name, 'id'].join('_')
40
- cookie_value = request.cookie_jar.signed[cookie_name]
41
-
42
- if value[:user_id].blank? && cookie_value.blank?
43
- request.cookie_jar.permanent.signed[cookie_name] = {
44
- value: value[:user_id] = SecureRandom.uuid, secure: true, domain: :all, httponly: true
45
- }
46
- elsif cookie_value # we know via the cookie
47
- if value[:user_id].blank?
48
- value[:user_id] = cookie_value
49
- else
50
- @migrations_with << { user_id: cookie_value }
51
- request.cookie_jar.delete(cookie_name, domain: :all)
52
- end
53
- end
54
-
55
- value
41
+ def migrate_cookie_to_user_id(hash)
42
+ return hash unless (request = hash.delete(:request))
43
+ return hash unless request.respond_to?(:headers) && request.respond_to?(:cookie_jar)
44
+ return hash if request.headers['DNT'].to_s.match?(DNT_REGEXP)
45
+
46
+ jar = request.cookie_jar
47
+ resolver = [jar, hash, :user_id, jar.signed[cookie_name]].compact
48
+ resolve_cookie(*resolver) or generate_cookie(*resolver)
49
+ end
50
+
51
+ def cookie_name
52
+ @cookie_name ||= [@experiment.name, 'id'].join('_')
53
+ end
54
+
55
+ def resolve_cookie(jar, hash, key, cookie = nil)
56
+ return if cookie.blank? && hash[key].blank?
57
+ return hash.merge(key => cookie) if hash[key].blank?
58
+
59
+ @migrations_with << { user_id: cookie }
60
+ jar.delete(cookie_name, domain: :all)
61
+
62
+ hash
63
+ end
64
+
65
+ def generate_cookie(jar, hash, key, cookie = SecureRandom.uuid)
66
+ jar.permanent.signed[cookie_name] = {
67
+ value: cookie, secure: true, domain: :all, httponly: true
68
+ }
69
+
70
+ hash.merge(key => cookie)
56
71
  end
57
72
 
58
73
  def migration_keys
@@ -4,6 +4,7 @@ module Gitlab
4
4
  class Experiment
5
5
  module Dsl
6
6
  def experiment(name, variant_name = nil, **context, &block)
7
+ context[:request] ||= request if respond_to?(:request)
7
8
  Experiment.run(name, variant_name, **context, &block)
8
9
  end
9
10
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ class Engine < ::Rails::Engine
6
+ config.after_initialize do
7
+ # Add out experiment method to the base controller.
8
+ ActionController::Base.include(Dsl) if defined?(ActionController)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.2.0'
5
+ VERSION = '0.2.1'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-experiment
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-09-20 00:00:00.000000000 Z
11
+ date: 2020-09-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: scientist
@@ -39,10 +39,14 @@ extra_rdoc_files: []
39
39
  files:
40
40
  - LICENSE.txt
41
41
  - README.md
42
+ - lib/generators/gitlab_experiment/install/POST_INSTALL
43
+ - lib/generators/gitlab_experiment/install/install_generator.rb
44
+ - lib/generators/gitlab_experiment/install/templates/initializer.rb
42
45
  - lib/gitlab/experiment.rb
43
46
  - lib/gitlab/experiment/configuration.rb
44
47
  - lib/gitlab/experiment/context.rb
45
48
  - lib/gitlab/experiment/dsl.rb
49
+ - lib/gitlab/experiment/engine.rb
46
50
  - lib/gitlab/experiment/variant.rb
47
51
  - lib/gitlab/experiment/version.rb
48
52
  homepage: https://gitlab.com/gitlab-org/gitlab-experiment