gitlab-experiment 0.4.1 → 0.4.2

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: 740552e72dc8655bde106bbca76a2b5be2776196ad957c780cf06d2bc3324a11
4
- data.tar.gz: 19f8fbc4588b8efcff729464b5f3480a9d41a0429f8ea8153d097a0c9c948aae
3
+ metadata.gz: 865d51c081670824fc7ffb48d4bc764fd5370606d3aa63b2196421b5570506e7
4
+ data.tar.gz: a007f872a3d56ee81c9925117f603e4930d1b7d689078edb074a2ba5567833f2
5
5
  SHA512:
6
- metadata.gz: b4f4b3f0a7c56087a0bccaf916d568a82ad02cd7784ba94e86254f3c11fabc106cf5da171d889d95818fb4229b34ab9712ddddf597c2dc6075d724c3c08af80b
7
- data.tar.gz: aa6c559ca3e62e810ffa8efd7b1846da0beefd6ad91c10e425d7ed1477eb3073cb792b909d39199cb5847ce1b25d9bf19189793190f6e5a519c43468a341fe02
6
+ metadata.gz: 03d401a71b952b74519a21fa851a87ee9e104ccd19011af37ac41c36bec5fd63db3e68c62c05beb34ab296d354e00e15f013dd34a547c3b4c5c94474b89096cc
7
+ data.tar.gz: 2bb0260e40d4689446c1317273d85140fae9f56394821fda96c7bbc5d1acbe441f3e4024bf00ec05812f64f5588897137fc8061e790d871c5719742f6cedf516
data/README.md CHANGED
@@ -1,4 +1,5 @@
1
- # GitLab Experiment
1
+ GitLab Experiment
2
+ =================
2
3
 
3
4
  <img alt="experiment" src="/uploads/60990b2dbf4c0406bbf8b7f998de2dea/experiment.png" align="right" width="40%">
4
5
 
@@ -16,6 +17,8 @@ When we discuss the behavior of this gem, we'll use terms like experiment, conte
16
17
 
17
18
  Candidate and variant are the same concept, but simplify how we speak about experimental paths.<br clear="all">
18
19
 
20
+ [[_TOC_]]
21
+
19
22
  ## Installation
20
23
 
21
24
  Add the gem to your Gemfile and then `bundle install`.
@@ -24,7 +27,7 @@ Add the gem to your Gemfile and then `bundle install`.
24
27
  gem 'gitlab-experiment'
25
28
  ```
26
29
 
27
- If you're using Rails, you can install the initializer. It provides basic configuration and documentation.
30
+ If you're using Rails, you can install the initializer which provides basic configuration, documentation, and the base experiment class that all your experiments can inherit from.
28
31
 
29
32
  ```shell
30
33
  $ rails generate gitlab:experiment:install
@@ -79,72 +82,84 @@ To this end, we track events that are important by calling the same experiment e
79
82
  experiment(:notification_toggle, actor: user).track(:clicked_button)
80
83
  ```
81
84
 
82
- <details>
83
- <summary>You can also use the more low level class or instance interfaces...</summary>
85
+ ### Custom experiments
84
86
 
85
- ### Class level interface using `.run`
87
+ You can craft more advanced behaviors by defining custom experiments at a higher level. To do this you can define a class that inherits from `ApplicationExperiment` (or `Gitlab::Experiment`).
86
88
 
87
- ```ruby
88
- exp = Gitlab::Experiment.run(:notification_toggle, actor: user) do |e|
89
- # Context may be passed in the block, but must be finalized before calling
90
- # run or track.
91
- e.context(project: project) # add the project to the context
89
+ Let's say you want to do more advanced segmentation, or provide default behavior for the variants on the experiment we've already outlined above -- that way if the variants aren't defined in the block at the time the experiment is run, these methods will be used.
92
90
 
93
- # Define the control and candidate variant.
94
- e.use { render_toggle } # control
95
- e.try { render_button } # candidate
96
- end
91
+ You can generate a custom experiment by running:
97
92
 
98
- # Track an event on the experiment we've defined.
99
- exp.track(:clicked_button)
100
- ```
93
+ ```shell
94
+ $ rails generate gitlab:experiment NotificationToggle control candidate
95
+ ```
96
+
97
+ This will generate a file in `app/experiments/notification_toggle_experiment.rb`, as well as a test file for you to further expand on.
101
98
 
102
- ### Instance level interface
99
+ Here are some examples of what you can introduce once you have a custom experiment defined.
103
100
 
104
101
  ```ruby
105
- exp = Gitlab::Experiment.new(:notification_toggle, actor: user)
106
- # Additional context may be provided to the instance (exp) but must be
107
- # finalized before calling run or track.
108
- exp.context(project: project) # add the project id to the context
102
+ class NotificationToggleExperiment < ApplicationExperiment
103
+ # Segment any account less than 2 weeks old into the candidate, without
104
+ # asking the variant resolver to decide which variant to provide.
105
+ segment :account_age, variant: :candidate
106
+
107
+ # Define the default control behavior, which can be overridden at
108
+ # experiment time.
109
+ def control_behavior
110
+ render_toggle
111
+ end
112
+
113
+ # Define the default candidate behavior, which can be overridden
114
+ # at experiment time.
115
+ def candidate_behavior
116
+ render_button
117
+ end
118
+
119
+ private
109
120
 
110
- # Define the control and candidate variant.
111
- exp.use { render_toggle } # control
112
- exp.try { render_button } # candidate
121
+ def account_age
122
+ context.actor && context.actor.created_at < 2.weeks.ago
123
+ end
124
+ end
113
125
 
114
- # Run the experiment, returning the result.
126
+ # The class will be looked up based on the experiment name provided.
127
+ exp = experiment(:notification_toggle, actor: user)
128
+ exp # => instance of NotificationToggleExperiment
129
+
130
+ # Run the experiment -- returning the result.
115
131
  exp.run
116
132
 
117
133
  # Track an event on the experiment we've defined.
118
134
  exp.track(:clicked_button)
119
135
  ```
120
136
 
121
- </details>
137
+ You can now also do things very similar to the simple examples and override the default variant behaviors defined in the custom experiment -- keeping in mind that this should be carefully considered within the scope of your experiment.
138
+
139
+ ```ruby
140
+ experiment(:notification_toggle, actor: user) do |e|
141
+ e.use { render_special_toggle } # override default control behavior
142
+ end
143
+ ```
122
144
 
123
145
  <details>
124
- <summary>You can define and use custom classes...</summary>
146
+ <summary>You can also use the lower level class interface...</summary>
125
147
 
126
- ### Custom class
148
+ ### Using the `.run` approach
127
149
 
128
- ```ruby
129
- class NotificationExperiment < Gitlab::Experiment
130
- def initialize(variant_name = nil, **context, &block)
131
- super(:notification_toggle, variant_name, **context, &block)
150
+ This is useful if you haven't included the DSL and so don't have access to the `experiment` method, but still want to execute an experiment. This is ultimately what the `experiment` method calls through to, and the method signatures are the same.
132
151
 
133
- # Define the control and candidate variant.
134
- use { render_toggle } # control
135
- try { render_button } # candidate
136
- end
137
- end
152
+ ```ruby
153
+ exp = Gitlab::Experiment.run(:notification_toggle, actor: user) do |e|
154
+ # Context may be passed in the block, but must be finalized before calling
155
+ # run or track.
156
+ e.context(project: project) # add the project to the context
138
157
 
139
- exp = NotificationExperiment.new(actor: user) do |e|
140
- # Context may be provided within the block or to the instance (exp) but must
141
- # be finalized before calling run or track.
142
- e.context(project: project) # add the project id to the context
158
+ # Define the control and candidate variant.
159
+ e.use { render_toggle } # control
160
+ e.try { render_button } # candidate
143
161
  end
144
162
 
145
- # Run the experiment -- returning the result.
146
- exp.run
147
-
148
163
  # Track an event on the experiment we've defined.
149
164
  exp.track(:clicked_button)
150
165
  ```
@@ -152,11 +167,11 @@ exp.track(:clicked_button)
152
167
  </details>
153
168
 
154
169
  <details>
155
- <summary>You can also specify the variant to use...</summary>
170
+ <summary>You can also specify the variant to use for segmentation...</summary>
156
171
 
157
172
  ### Specifying variant
158
173
 
159
- You can hardcode the variant if you want. It's important to know what this might do to your data during rollout, so use this with consideration.
174
+ Generally, defining segmentation rules is a better way to approach routing into specific variants, but it's possible to explicitly specify the variant when running an experiment. It's important to know what this might do to your data during rollout, so use this with careful consideration.
160
175
 
161
176
  ```ruby
162
177
  experiment(:notification_toggle, :no_interface, actor: user) do |e|
@@ -188,6 +203,25 @@ end
188
203
 
189
204
  </details>
190
205
 
206
+ ### Segmentation rules
207
+
208
+ This library comes with the capability to segment contexts into a specific variant, before asking the variant resolver which variant to provide.
209
+
210
+ Segmentation can be achieved by using a custom experiment class and specifying the segmentation rules at a class level.
211
+
212
+ ```ruby
213
+ class NotificationToggleExperiment < ApplicationExperiment
214
+ segment(variant: :variant_one) { context.actor.username == 'jejacks0n' }
215
+ segment(variant: :variant_two) { context.actor.created_at < 2.weeks.ago }
216
+ end
217
+ ```
218
+
219
+ In the previous examples, any user with the username `'jejacks0n'` would always receive the experience defined in "variant_one". As well, any account less than 2 weeks old would get the alternate experience defined in "variant_two".
220
+
221
+ When an experiment is run, the segmentation rules are executed in the order they're defined. The first segmentation rule to produce a truthy result is the one which gets used to assign the variant. The remaining segmentation rules are skipped.
222
+
223
+ This means that any user with the name `'jejacks0n'`, regardless of account age, will always be provided the experience as defined in "variant_one".
224
+
191
225
  ### Return value
192
226
 
193
227
  By default the return value is a `Gitlab::Experiment` instance. In simple cases you may want only the results of the experiment though. You can call `run` within the block to get the return value of the assigned variant.
@@ -351,4 +385,32 @@ If you only include a user, that user would get the same experience across every
351
385
 
352
386
  Each of these approaches could be desirable given the objectives of your experiment.
353
387
 
354
- ### Make code not war
388
+ ## Development
389
+
390
+ After checking out the repo, run `bundle install` to install dependencies.
391
+ Then, run `bundle exec rspec` to run the tests. You can also run `bundle exec pry` for an
392
+ interactive prompt that will allow you to experiment.
393
+
394
+ ## Contributing
395
+
396
+ Bug reports and merge requests are welcome on GitLab at
397
+ https://gitlab.com/gitlab-org/gitlab-experiment. This project is intended to be a
398
+ safe, welcoming space for collaboration, and contributors are expected to adhere
399
+ to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
400
+
401
+ ## Release Process
402
+
403
+ Please refer to the [Release Process](docs/release_process.md).
404
+
405
+ ## License
406
+
407
+ The gem is available as open source under the terms of the
408
+ [MIT License](http://opensource.org/licenses/MIT).
409
+
410
+ ## Code of Conduct
411
+
412
+ Everyone interacting in the `Gitlab::Experiment` project’s codebases, issue trackers,
413
+ chat rooms and mailing lists is expected to follow the
414
+ [code of conduct](CODE_OF_CONDUCT.md).
415
+
416
+ ***Make code not war***
@@ -7,20 +7,28 @@ Gitlab::Experiment.configure do |config|
7
7
  # The logger is used to log various details of the experiments.
8
8
  config.logger = Logger.new($stdout)
9
9
 
10
- # The base class that should be instantiated for basic experiments.
10
+ # The base class that should be instantiated for basic experiments. It should
11
+ # be a string, so we can constantize it later.
11
12
  config.base_class = 'ApplicationExperiment'
12
13
 
13
- # The caching layer is expected to respond to fetch, like Rails.cache.
14
+ # The caching layer is expected to respond to fetch, like Rails.cache for
15
+ # instance -- or anything that adheres to ActiveSupport::Cache::Store.
14
16
  config.cache = nil
15
17
 
18
+ # The domain to use on cookies.
19
+ #
20
+ # When not set, it uses the current host. If you want to provide specific
21
+ # hosts, you use `:all`, or provide an array like
22
+ # `['www.gitlab.com', '.gitlab.com']`.
23
+ config.cookie_domain = :all
24
+
16
25
  # Logic this project uses to resolve a variant for a given experiment.
17
26
  #
18
- # This can return an instance of any object that responds to `name`, or can
19
- # return a variant name as a string, in which case the build in variant
20
- # class will be used.
27
+ # Should return a symbol or string that represents the variant that should
28
+ # be assigned.
21
29
  #
22
- # This block will be executed within the scope of the experiment instance,
23
- # so can easily access experiment methods, like getting the name or context.
30
+ # This block is executed within the scope of the experiment and so can access
31
+ # experiment methods, like `name`, `context`, and `signature`.
24
32
  config.variant_resolver = lambda do |requested_variant|
25
33
  # Run the control, unless a variant was requested in code:
26
34
  requested_variant || 'control'
@@ -28,23 +36,12 @@ Gitlab::Experiment.configure do |config|
28
36
  # Run the candidate, unless a variant was requested, with a fallback:
29
37
  #
30
38
  # requested_variant || variant_names.first || 'control'
31
-
32
- # Using Unleash to determine the variant:
33
- #
34
- # fallback = Unleash::Variant.new(name: requested_variant || 'control', enabled: true)
35
- # Unleash.get_variant(name, context.value, fallback)
36
-
37
- # Using Flipper to determine the variant:
38
- #
39
- # TODO: provide example.
40
- # Variant.new(name: requested_variant || 'control')
41
39
  end
42
40
 
43
41
  # Tracking behavior can be implemented to link an event to an experiment.
44
42
  #
45
- # Similar to the variant_resolver, this is called within the scope of the
46
- # experiment instance and so can access any methods on the experiment,
47
- # such as name and signature.
43
+ # This block is executed within the scope of the experiment and so can access
44
+ # experiment methods, like `name`, `context`, and `signature`.
48
45
  config.tracking_behavior = lambda do |event, args|
49
46
  # An example of using a generic logger to track events:
50
47
  config.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
@@ -61,8 +58,11 @@ Gitlab::Experiment.configure do |config|
61
58
  # Called at the end of every experiment run, with the result.
62
59
  #
63
60
  # You may want to track that you've assigned a variant to a given context,
64
- # or push the experiment into the client or publish results elsewhere, like
65
- # into redis. Also called within the scope of the experiment instance.
61
+ # or push the experiment into the client or publish results elsewhere like
62
+ # into redis.
63
+ #
64
+ # This block is executed within the scope of the experiment and so can access
65
+ # experiment methods, like `name`, `context`, and `signature`.
66
66
  config.publishing_behavior = lambda do |result|
67
67
  # Track the event using our own configured tracking logic.
68
68
  track(:assignment)
@@ -83,14 +83,11 @@ Gitlab::Experiment.configure do |config|
83
83
  # Given a specific context hash map, we need to generate a consistent hash
84
84
  # key. The logic in here will be used for generating cache keys, and may also
85
85
  # be used when determining which variant may be presented.
86
+ #
87
+ # This block is executed within the scope of the experiment and so can access
88
+ # experiment methods, like `name`, `context`, and `signature`.
86
89
  config.context_hash_strategy = lambda do |context|
87
90
  values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
88
91
  Digest::MD5.hexdigest((context.keys + values).join('|'))
89
92
  end
90
-
91
- # The domain for which this cookie applies so you can restrict to the domain level.
92
- #
93
- # When not set, it uses the current host. If you want to provide specific hosts, you can
94
- # provide them either via an array like `['www.gitlab.com', .gitlab.com']`, or set it to `:all`.
95
- config.cookie_domain = :all
96
93
  end
@@ -76,10 +76,11 @@ module Gitlab
76
76
  end
77
77
 
78
78
  def variant(value = nil)
79
- return @variant_name = value unless value.nil?
79
+ @variant_name = value unless value.blank?
80
+ @variant_name ||= :control if excluded?
80
81
 
81
- result = instance_exec(@variant_name, &Configuration.variant_resolver)
82
- result.respond_to?(:name) ? result : Variant.new(name: (result.presence || :control).to_s)
82
+ resolved = cache { resolve_variant_name }
83
+ Variant.new(name: (resolved.presence || :control).to_s)
83
84
  end
84
85
 
85
86
  def exclude(&block)
@@ -88,19 +89,13 @@ module Gitlab
88
89
 
89
90
  def run(variant_name = nil)
90
91
  @result ||= begin
91
- @variant_name = variant_name unless variant_name.nil?
92
- @variant_name ||= :control if excluded?
93
-
94
- chain = variant_assigned? ? :unsegmented_run : :segmented_run
95
- run_callbacks(chain) do
96
- variant_name = cache { variant.name }
97
-
98
- method_name = "#{variant_name}_behavior"
99
- if respond_to?(method_name)
100
- behaviors[variant_name] ||= -> { send(method_name) } # rubocop:disable GitlabSecurity/PublicSend
92
+ variant_name = variant(variant_name).name
93
+ run_callbacks(variant_assigned? ? :unsegmented_run : :segmented_run) do
94
+ if respond_to?((behavior_name = "#{variant_name}_behavior"))
95
+ behaviors[variant_name] ||= -> { send(behavior_name) } # rubocop:disable GitlabSecurity/PublicSend
101
96
  end
102
97
 
103
- super(variant_name)
98
+ super(@variant_name = variant_name)
104
99
  end
105
100
  end
106
101
  end
@@ -140,7 +135,7 @@ module Gitlab
140
135
  end
141
136
 
142
137
  def id
143
- "#{name}:#{signature[:key]}"
138
+ "#{name}:#{key_for(context.value)}"
144
139
  end
145
140
  alias_method :session_id, :id
146
141
 
@@ -154,6 +149,15 @@ module Gitlab
154
149
 
155
150
  protected
156
151
 
152
+ def resolve_variant_name
153
+ return :unresolved if @resolving
154
+
155
+ @resolving = true
156
+ result = instance_exec(@variant_name, &Configuration.variant_resolver)
157
+ @resolving = false
158
+ result
159
+ end
160
+
157
161
  def generate_result(variant_name)
158
162
  observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
159
163
  Scientist::Result.new(self, [observation], observation)
@@ -14,8 +14,8 @@ module Gitlab
14
14
 
15
15
  def cache_strategy
16
16
  [
17
- "#{name}:#{signature[:key]}",
18
- signature[:migration_keys]&.map { |key| "#{name}:#{key}" }
17
+ "#{name}:#{context.signature[:key]}",
18
+ context.signature[:migration_keys]&.map { |key| "#{name}:#{key}" }
19
19
  ]
20
20
  end
21
21
 
@@ -18,12 +18,15 @@ module Gitlab
18
18
  # The base class that should be instantiated for basic experiments.
19
19
  @base_class = 'Gitlab::Experiment'
20
20
 
21
- # Cache layer. Expected to respond to fetch, like Rails.cache.
21
+ # The caching layer is expected to respond to fetch, like Rails.cache.
22
22
  @cache = nil
23
23
 
24
+ # The domain to use on cookies.
25
+ @cookie_domain = :all
26
+
24
27
  # Logic this project uses to resolve a variant for a given experiment.
25
28
  @variant_resolver = lambda do |requested_variant|
26
- requested_variant || 'control'
29
+ requested_variant || :control
27
30
  end
28
31
 
29
32
  # Tracking behavior can be implemented to link an event to an experiment.
@@ -31,8 +34,7 @@ module Gitlab
31
34
  Configuration.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
32
35
  end
33
36
 
34
- # Called at the end of every experiment run, with the results. You may
35
- # want to push the experiment into the client or push results elsewhere.
37
+ # Called at the end of every experiment run, with the result.
36
38
  @publishing_behavior = lambda do |_result|
37
39
  track(:assignment)
38
40
  end
@@ -43,22 +45,17 @@ module Gitlab
43
45
  Digest::MD5.hexdigest(([name] + hash_map.keys + values).join('|'))
44
46
  end
45
47
 
46
- # The domain for which this cookie applies so you can restrict to the domain level.
47
- # When not set, it uses the current host. If you want to provide specific hosts, you can
48
- # provide them either via an array like `['www.gitlab.com', .gitlab.com']`, or set it to `:all`.
49
- @cookie_domain = :all
50
-
51
48
  class << self
52
49
  attr_accessor(
53
50
  :name_prefix,
54
51
  :logger,
55
52
  :base_class,
56
53
  :cache,
54
+ :cookie_domain,
57
55
  :variant_resolver,
58
56
  :tracking_behavior,
59
57
  :publishing_behavior,
60
- :context_hash_strategy,
61
- :cookie_domain
58
+ :context_hash_strategy
62
59
  )
63
60
  end
64
61
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.4.1'
5
+ VERSION = '0.4.2'
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.4.1
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-11-17 00:00:00.000000000 Z
11
+ date: 2020-11-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport