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 +4 -4
- data/README.md +94 -58
- data/lib/generators/gitlab_experiment/install/POST_INSTALL +0 -0
- data/lib/generators/gitlab_experiment/install/install_generator.rb +21 -0
- data/lib/generators/gitlab_experiment/install/templates/initializer.rb +77 -0
- data/lib/gitlab/experiment.rb +7 -3
- data/lib/gitlab/experiment/configuration.rb +10 -26
- data/lib/gitlab/experiment/context.rb +37 -22
- data/lib/gitlab/experiment/dsl.rb +1 -0
- data/lib/gitlab/experiment/engine.rb +12 -0
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8b64d15c51bec74dc0b9be36e3b1541c3509c0f62a2259595c71366ff66f35e3
|
4
|
+
data.tar.gz: 5c6862e1a9d44335ebe1d6b6e3974da4e0e368297d006eb91398ebf9d3004681
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0cea1580efdbe7daa88f76fb93de94d4ea04059bbc1bd25a15edf4a501e139ec9102c6abf9c2002e797ecfc9bb6ffc3115dcf4e817b94b5f6499ed09ca14de75
|
7
|
+
data.tar.gz: 20490eec566060797379ecea83e07fe74a00f2725817bc396b6bea7cfc07947cd120562d433fb33d6db5b4094c58fd50b2ff1cb9455867c6300443fc4c2c6509
|
data/README.md
CHANGED
@@ -1,36 +1,48 @@
|
|
1
|
-
|
1
|
+
GitLab Experiment
|
2
|
+
=================
|
2
3
|
|
3
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
15
|
+
## Installation
|
12
16
|
|
13
|
-
|
17
|
+
Add the gem to your Gemfile and then bundle install.
|
14
18
|
|
15
|
-
|
19
|
+
```ruby
|
20
|
+
gem 'gitlab-experiment'
|
21
|
+
```
|
16
22
|
|
17
|
-
|
23
|
+
If you're using Rails, you can install an initializer that provides basic configuration by running the install generator.
|
18
24
|
|
19
|
-
|
25
|
+
```shell
|
26
|
+
$ rails generate gitlab-experiment:install
|
27
|
+
```
|
20
28
|
|
21
|
-
|
29
|
+
## Implementing an experiment
|
22
30
|
|
23
|
-
|
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
|
-
|
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
|
-
|
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
|
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::
|
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
|
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
|
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::
|
68
|
-
#
|
69
|
-
#
|
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
|
-
#
|
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::
|
86
|
-
#
|
87
|
-
#
|
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
|
-
#
|
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::
|
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.
|
112
|
-
#
|
113
|
-
#
|
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
|
-
#
|
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(:
|
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::
|
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
|
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
|
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,
|
180
|
+
experiment(:my_experiment, user_id: 42, version: 2, migrated_with: { version: 1 })
|
162
181
|
```
|
163
182
|
|
164
|
-
|
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
|
-
|
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
|
191
|
+
### When there isn't a user (cookies)
|
169
192
|
|
170
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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.
|
File without changes
|
@@ -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
|
data/lib/gitlab/experiment.rb
CHANGED
@@ -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(
|
58
|
-
|
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 =
|
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
|
-
|
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
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
#
|
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 ||= {
|
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(
|
36
|
-
return
|
37
|
-
return
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
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
|
@@ -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
|
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.
|
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-
|
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
|