gitlab-experiment 0.6.4 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +410 -290
- data/lib/generators/gitlab/experiment/experiment_generator.rb +9 -4
- data/lib/generators/gitlab/experiment/install/templates/initializer.rb.tt +87 -45
- data/lib/generators/gitlab/experiment/templates/experiment.rb.tt +69 -3
- data/lib/gitlab/experiment/base_interface.rb +86 -24
- data/lib/gitlab/experiment/cache/redis_hash_store.rb +10 -10
- data/lib/gitlab/experiment/cache.rb +3 -7
- data/lib/gitlab/experiment/callbacks.rb +97 -5
- data/lib/gitlab/experiment/configuration.rb +209 -28
- data/lib/gitlab/experiment/context.rb +2 -3
- data/lib/gitlab/experiment/cookies.rb +0 -2
- data/lib/gitlab/experiment/engine.rb +2 -1
- data/lib/gitlab/experiment/errors.rb +21 -0
- data/lib/gitlab/experiment/nestable.rb +51 -0
- data/lib/gitlab/experiment/rollout/percent.rb +41 -16
- data/lib/gitlab/experiment/rollout/random.rb +25 -4
- data/lib/gitlab/experiment/rollout/round_robin.rb +27 -10
- data/lib/gitlab/experiment/rollout.rb +61 -12
- data/lib/gitlab/experiment/rspec.rb +224 -130
- data/lib/gitlab/experiment/test_behaviors/trackable.rb +69 -0
- data/lib/gitlab/experiment/version.rb +1 -1
- data/lib/gitlab/experiment.rb +118 -56
- metadata +8 -24
@@ -8,10 +8,15 @@ module Gitlab
|
|
8
8
|
source_root File.expand_path('templates/', __dir__)
|
9
9
|
check_class_collision suffix: 'Experiment'
|
10
10
|
|
11
|
-
argument
|
12
|
-
|
13
|
-
|
14
|
-
|
11
|
+
argument :variants,
|
12
|
+
type: :array,
|
13
|
+
default: %w[control candidate],
|
14
|
+
banner: 'variant variant'
|
15
|
+
|
16
|
+
class_option :skip_comments,
|
17
|
+
type: :boolean,
|
18
|
+
default: false,
|
19
|
+
desc: 'Omit helpful comments from generated files'
|
15
20
|
|
16
21
|
def create_experiment
|
17
22
|
template 'experiment.rb', File.join('app/experiments', class_path, "#{file_name}_experiment.rb")
|
@@ -1,18 +1,25 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
Gitlab::Experiment.configure do |config|
|
4
|
-
# Prefix all experiment names with a given value.
|
4
|
+
# Prefix all experiment names with a given string value.
|
5
|
+
# Use `nil` for no prefix.
|
5
6
|
config.name_prefix = nil
|
6
7
|
|
7
|
-
# The logger
|
8
|
+
# The logger can be used to log various details of the experiments.
|
8
9
|
config.logger = Logger.new($stdout)
|
9
10
|
|
10
|
-
# The base class that should be instantiated for basic experiments.
|
11
|
-
# be a string, so we can constantize it later.
|
11
|
+
# The base class that should be instantiated for basic experiments.
|
12
|
+
# It should be a string, so we can constantize it later.
|
12
13
|
config.base_class = 'ApplicationExperiment'
|
13
14
|
|
14
|
-
#
|
15
|
-
#
|
15
|
+
# Require experiments to be defined in a class, with variants registered.
|
16
|
+
# This will disallow any anonymous experiments that are run inline
|
17
|
+
# without previously defining a class.
|
18
|
+
config.strict_registration = false
|
19
|
+
|
20
|
+
# The caching layer is expected to match the Rails.cache interface.
|
21
|
+
# If no cache is provided some rollout strategies may behave differently.
|
22
|
+
# Use `nil` for no caching.
|
16
23
|
config.cache = nil
|
17
24
|
|
18
25
|
# The domain to use on cookies.
|
@@ -24,62 +31,71 @@ Gitlab::Experiment.configure do |config|
|
|
24
31
|
# nil, :all, or ['www.gitlab.com', '.gitlab.com']
|
25
32
|
config.cookie_domain = :all
|
26
33
|
|
27
|
-
# The default rollout strategy
|
34
|
+
# The default rollout strategy.
|
35
|
+
#
|
36
|
+
# The recommended default rollout strategy when not using caching would
|
37
|
+
# be `Gitlab::Experiment::Rollout::Percent` as that will consistently
|
38
|
+
# assign the same variant with or without caching.
|
39
|
+
#
|
40
|
+
# Gitlab::Experiment::Rollout::Base can be inherited to implement your
|
41
|
+
# own rollout strategies.
|
28
42
|
#
|
29
|
-
#
|
30
|
-
# experiment.
|
43
|
+
# Each experiment can specify its own rollout strategy:
|
31
44
|
#
|
32
|
-
#
|
33
|
-
#
|
34
|
-
|
45
|
+
# class ExampleExperiment < ApplicationExperiment
|
46
|
+
# default_rollout :random, # :percent, :round_robin,
|
47
|
+
# include_control: true # or MyCustomRollout
|
48
|
+
# end
|
49
|
+
#
|
50
|
+
# Included rollout strategies:
|
51
|
+
# :percent (recommended), :round_robin, or :random
|
52
|
+
config.default_rollout = :percent, {
|
53
|
+
include_control: true # include control in possible assignments
|
54
|
+
)
|
35
55
|
|
36
56
|
# Secret seed used in generating context keys.
|
37
57
|
#
|
58
|
+
# You'll typically want to use an environment variable or secret value
|
59
|
+
# for this.
|
60
|
+
#
|
38
61
|
# Consider not using one that's shared with other systems, like Rails'
|
39
|
-
# SECRET_KEY_BASE. Generate a new secret and utilize that
|
40
|
-
|
62
|
+
# SECRET_KEY_BASE for instance. Generate a new secret and utilize that
|
63
|
+
# instead.
|
64
|
+
config.context_key_secret = nil
|
41
65
|
|
42
66
|
# Bit length used by SHA2 in generating context keys.
|
43
67
|
#
|
44
68
|
# Using a higher bit length would require more computation time.
|
45
69
|
#
|
46
70
|
# Valid bit lengths:
|
47
|
-
# 256, 384, or 512
|
48
|
-
|
71
|
+
# 256, 384, or 512
|
72
|
+
config.context_key_bit_length = 256
|
49
73
|
|
50
74
|
# The default base path that the middleware (or rails engine) will be
|
51
|
-
# mounted.
|
75
|
+
# mounted. The middleware enables an instrumentation url, that's similar
|
76
|
+
# to links that can be instrumented in email campaigns.
|
52
77
|
#
|
53
|
-
#
|
78
|
+
# Use `nil` if you don't want to mount the middleware.
|
54
79
|
#
|
55
80
|
# Examples:
|
56
81
|
# '/-/experiment', '/redirect', nil
|
57
82
|
config.mount_at = '/experiment'
|
58
83
|
|
59
84
|
# When using the middleware, links can be instrumented and redirected
|
60
|
-
# elsewhere. This can be exploited to make a harmful url look innocuous
|
61
|
-
# that it's a valid url on your domain. To avoid this, you can provide
|
62
|
-
# own logic for what urls will be considered valid and redirected
|
85
|
+
# elsewhere. This can be exploited to make a harmful url look innocuous
|
86
|
+
# or that it's a valid url on your domain. To avoid this, you can provide
|
87
|
+
# your own logic for what urls will be considered valid and redirected
|
88
|
+
# to.
|
63
89
|
#
|
64
90
|
# Expected to return a boolean value.
|
65
|
-
config.redirect_url_validator = lambda do |
|
91
|
+
config.redirect_url_validator = lambda do |_redirect_url|
|
66
92
|
true
|
67
93
|
end
|
68
94
|
|
69
|
-
# Logic this project uses to determine inclusion in a given experiment.
|
70
|
-
#
|
71
|
-
# Expected to return a boolean value.
|
72
|
-
#
|
73
|
-
# This block is executed within the scope of the experiment and so can access
|
74
|
-
# experiment methods, like `name`, `context`, and `signature`.
|
75
|
-
config.inclusion_resolver = lambda do |requested_variant|
|
76
|
-
false
|
77
|
-
end
|
78
|
-
|
79
95
|
# Tracking behavior can be implemented to link an event to an experiment.
|
80
96
|
#
|
81
|
-
# This block is executed within the scope of the experiment and so can
|
82
|
-
# experiment methods, like `name`, `context`, and `signature`.
|
97
|
+
# This block is executed within the scope of the experiment and so can
|
98
|
+
# access experiment methods, like `name`, `context`, and `signature`.
|
83
99
|
config.tracking_behavior = lambda do |event, args|
|
84
100
|
# An example of using a generic logger to track events:
|
85
101
|
config.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
|
@@ -93,26 +109,52 @@ Gitlab::Experiment.configure do |config|
|
|
93
109
|
# ))
|
94
110
|
end
|
95
111
|
|
112
|
+
# Logic designed to respond when a given experiment is nested within
|
113
|
+
# another experiment. This can be useful to identify overlaps and when a
|
114
|
+
# code path leads to an experiment being nested within another.
|
115
|
+
#
|
116
|
+
# Reporting complexity can arise when one experiment changes rollout, and
|
117
|
+
# a downstream experiment is impacted by that.
|
118
|
+
#
|
119
|
+
# The base_class or a custom experiment can provide a `nest_experiment`
|
120
|
+
# method that implements its own logic that may allow certain experiments
|
121
|
+
# to be nested within it.
|
122
|
+
#
|
123
|
+
# This block is executed within the scope of the experiment and so can
|
124
|
+
# access experiment methods, like `name`, `context`, and `signature`.
|
125
|
+
#
|
126
|
+
# The default exception will include the where the experiment calls were
|
127
|
+
# initiated on, so for instance:
|
128
|
+
#
|
129
|
+
# Gitlab::Experiment::NestingError: unable to nest level2 within level1:
|
130
|
+
# level1 initiated by file_name.rb:2
|
131
|
+
# level2 initiated by file_name.rb:3
|
132
|
+
config.nested_behavior = lambda do |nested_experiment|
|
133
|
+
raise Gitlab::Experiment::NestingError.new(experiment: self, nested_experiment: nested_experiment)
|
134
|
+
end
|
135
|
+
|
96
136
|
# Called at the end of every experiment run, with the result.
|
97
137
|
#
|
98
|
-
# You may want to track that you've assigned a variant to a given
|
99
|
-
# or push the experiment into the client or publish results
|
100
|
-
# into redis.
|
138
|
+
# You may want to track that you've assigned a variant to a given
|
139
|
+
# context, or push the experiment into the client or publish results
|
140
|
+
# elsewhere like into redis.
|
101
141
|
#
|
102
|
-
# This block is executed within the scope of the experiment and so can
|
103
|
-
# experiment methods, like `name`, `context`, and `signature`.
|
142
|
+
# This block is executed within the scope of the experiment and so can
|
143
|
+
# access experiment methods, like `name`, `context`, and `signature`.
|
104
144
|
config.publishing_behavior = lambda do |result|
|
105
145
|
# Track the event using our own configured tracking logic.
|
106
146
|
track(:assignment)
|
107
147
|
|
108
|
-
#
|
109
|
-
#
|
148
|
+
# Log using our logging system, so the result (which can be large) can
|
149
|
+
# be reviewed later if we want to.
|
110
150
|
#
|
111
|
-
#
|
151
|
+
# Lograge::Event.log(experiment: name, result: result, signature: signature)
|
112
152
|
|
113
|
-
#
|
114
|
-
#
|
153
|
+
# Experiments that have been run during the request lifecycle can be
|
154
|
+
# pushed to the client layer by injecting the published experiments
|
155
|
+
# into javascript in a layout or view using something like:
|
115
156
|
#
|
116
|
-
#
|
157
|
+
# = javascript_tag(nonce: content_security_policy_nonce) do
|
158
|
+
# window.experiments = #{raw Gitlab::Experiment.published_experiments.to_json};
|
117
159
|
end
|
118
160
|
end
|
@@ -6,10 +6,76 @@ require_dependency "<%= namespaced_path %>/application_experiment"
|
|
6
6
|
<% end -%>
|
7
7
|
<% module_namespacing do -%>
|
8
8
|
class <%= class_name %>Experiment < ApplicationExperiment
|
9
|
+
# Describe your experiment:
|
10
|
+
#
|
11
|
+
# The variant behaviors defined here will be called whenever the experiment
|
12
|
+
# is run unless overrides are provided.
|
13
|
+
|
9
14
|
<% variants.each do |variant| -%>
|
10
|
-
|
11
|
-
|
12
|
-
|
15
|
+
<% if %w[control candidate].include?(variant) -%>
|
16
|
+
<%= variant %> { }
|
17
|
+
<% else -%>
|
18
|
+
variant(:<%= variant %>) { }
|
19
|
+
<% end -%>
|
20
|
+
<% end -%>
|
21
|
+
|
22
|
+
<% unless options[:skip_comments] -%>
|
23
|
+
# You can register a `control`, `candidate`, or by naming variants directly.
|
24
|
+
# All of these can be registered using blocks, or by specifying a method.
|
25
|
+
#
|
26
|
+
# Here's some ways you might want to register your control logic:
|
27
|
+
#
|
28
|
+
#control { 'class level control' } # yield this block
|
29
|
+
#control :custom_control # call a private method
|
30
|
+
#control # call the private `control_behavior` method
|
31
|
+
#
|
32
|
+
# You can register candidate logic in the same way:
|
33
|
+
#
|
34
|
+
#candidate { 'class level candidate' } # yield this block
|
35
|
+
#candidate :custom_candidate # call a private method
|
36
|
+
#candidate # call the private `candidate_behavior` method
|
37
|
+
#
|
38
|
+
# For named variants it's the same, but a variant name must be provided:
|
39
|
+
#
|
40
|
+
#variant(:example) { 'class level example variant' }
|
41
|
+
#variant(:example) :example_variant
|
42
|
+
#variant(:example) # call the private `example_behavior` method
|
43
|
+
#
|
44
|
+
# Advanced customization:
|
45
|
+
#
|
46
|
+
# Some additional tools are provided to exclude and segment contexts. To
|
47
|
+
# exclude a given context, you can provide rules. For example, we could
|
48
|
+
# exclude all old accounts and all users with a specific first name.
|
49
|
+
#
|
50
|
+
#exclude :old_account?, ->{ context.user.first_name == 'Richard' }
|
51
|
+
#
|
52
|
+
# Segmentation allows for logic to be used to determine which variant a
|
53
|
+
# context will be assigned. Let's say you want to put all old accounts into a
|
54
|
+
# specific variant, and all users with a specific first name in another:
|
55
|
+
#
|
56
|
+
#segment :old_account?, variant: :variant_two
|
57
|
+
#segment(variant: :variant_one) { context.actor.first_name == 'Richard' }
|
58
|
+
#
|
59
|
+
# Utilizing your experiment:
|
60
|
+
#
|
61
|
+
# Once you've defined your experiment, you can run it elsewhere. You'll want
|
62
|
+
# to specify a context (you can read more about context here), and overrides
|
63
|
+
# for any or all of the variants you've registered in your experiment above.
|
64
|
+
#
|
65
|
+
# Here's an example of running the experiment that's sticky to current_user,
|
66
|
+
# with an override for our class level candidate logic:
|
67
|
+
#
|
68
|
+
# experiment(:<%= file_name %>, user: current_user) do |e|
|
69
|
+
# e.candidate { 'override <%= class_name %>Experiment behavior' }
|
70
|
+
# end
|
71
|
+
#
|
72
|
+
# If you want to publish the experiment to the client without running any
|
73
|
+
# code paths on the server, you can simply call publish instead of passing an
|
74
|
+
# experimental block:
|
75
|
+
#
|
76
|
+
# experiment(:<%= file_name %>, project: project).publish
|
77
|
+
#
|
78
|
+
|
13
79
|
<% end -%>
|
14
80
|
end
|
15
81
|
<% end -%>
|
@@ -4,7 +4,6 @@ module Gitlab
|
|
4
4
|
class Experiment
|
5
5
|
module BaseInterface
|
6
6
|
extend ActiveSupport::Concern
|
7
|
-
include Scientist::Experiment
|
8
7
|
|
9
8
|
class_methods do
|
10
9
|
def configure
|
@@ -24,7 +23,19 @@ module Gitlab
|
|
24
23
|
def constantize(name = nil)
|
25
24
|
return self if name.nil?
|
26
25
|
|
27
|
-
experiment_name(name).classify
|
26
|
+
experiment_class = experiment_name(name).classify
|
27
|
+
experiment_class.safe_constantize || begin
|
28
|
+
return Configuration.base_class.constantize unless Configuration.strict_registration
|
29
|
+
|
30
|
+
raise UnregisteredExperiment, <<~ERR
|
31
|
+
No experiment registered for `#{name}`. Please register the experiment by defining a class:
|
32
|
+
|
33
|
+
class #{experiment_class} < #{Configuration.base_class}
|
34
|
+
control
|
35
|
+
candidate { 'candidate' }
|
36
|
+
end
|
37
|
+
ERR
|
38
|
+
end
|
28
39
|
end
|
29
40
|
|
30
41
|
def from_param(id)
|
@@ -37,59 +48,110 @@ module Gitlab
|
|
37
48
|
def initialize(name = nil, variant_name = nil, **context)
|
38
49
|
raise ArgumentError, 'name is required' if name.blank? && self.class.base?
|
39
50
|
|
40
|
-
@
|
41
|
-
@
|
42
|
-
@
|
43
|
-
|
44
|
-
compare { false }
|
51
|
+
@_name = self.class.experiment_name(name, suffix: false)
|
52
|
+
@_context = Context.new(self, **context)
|
53
|
+
@_assigned_variant_name = cache_variant(variant_name) { nil } if variant_name.present?
|
45
54
|
|
46
55
|
yield self if block_given?
|
47
56
|
end
|
48
57
|
|
49
58
|
def inspect
|
50
|
-
"#<#{self.class.name || 'AnonymousClass'}:#{format('0x%016X', __id__)}
|
59
|
+
"#<#{self.class.name || 'AnonymousClass'}:#{format('0x%016X', __id__)} name=#{name} context=#{context.value}>"
|
60
|
+
end
|
61
|
+
|
62
|
+
def run(variant_name)
|
63
|
+
behaviors.freeze
|
64
|
+
context.freeze
|
65
|
+
|
66
|
+
block = behaviors[variant_name]
|
67
|
+
raise BehaviorMissingError, "the `#{variant_name}` variant hasn't been registered" if block.nil?
|
68
|
+
|
69
|
+
result = block.call
|
70
|
+
publish(result) if enabled?
|
71
|
+
|
72
|
+
result
|
51
73
|
end
|
52
74
|
|
53
75
|
def id
|
54
76
|
"#{name}:#{context.key}"
|
55
77
|
end
|
56
|
-
alias_method :session_id, :id
|
57
|
-
alias_method :to_param, :id
|
58
78
|
|
59
|
-
|
60
|
-
"Experiment;#{id}"
|
61
|
-
end
|
79
|
+
alias_method :to_param, :id
|
62
80
|
|
63
81
|
def variant_names
|
64
|
-
@
|
82
|
+
@_variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
|
65
83
|
end
|
66
84
|
|
67
85
|
def behaviors
|
68
|
-
@
|
86
|
+
@_behaviors ||= public_behaviors_with_deprecations(registered_behavior_callbacks)
|
87
|
+
end
|
88
|
+
|
89
|
+
# @deprecated
|
90
|
+
def public_behaviors_with_deprecations(behaviors)
|
91
|
+
named_variants = %w[control candidate]
|
92
|
+
public_methods.each_with_object(behaviors) do |name, behaviors|
|
93
|
+
name = name.to_s # fixes compatibility for ruby 2.6.x
|
69
94
|
next unless name.end_with?('_behavior')
|
70
95
|
|
71
|
-
behavior_name = name.
|
96
|
+
behavior_name = name.sub(/_behavior$/, '')
|
97
|
+
registration = named_variants.include?(behavior_name) ? behavior_name : "variant :#{behavior_name}"
|
98
|
+
|
99
|
+
Configuration.deprecated(<<~MESSAGE, version: '0.7.0', stack: 2)
|
100
|
+
using a public `#{name}` method is deprecated and will be removed from {{release}}, instead register variants using:
|
101
|
+
|
102
|
+
class #{self.class.name} < #{Configuration.base_class}
|
103
|
+
#{registration}
|
104
|
+
|
105
|
+
private
|
106
|
+
|
107
|
+
def #{name}
|
108
|
+
#...
|
109
|
+
end
|
110
|
+
end
|
111
|
+
MESSAGE
|
112
|
+
|
72
113
|
behaviors[behavior_name] ||= -> { send(name) } # rubocop:disable GitlabSecurity/PublicSend
|
73
114
|
end
|
74
115
|
end
|
75
116
|
|
76
|
-
|
117
|
+
# @deprecated
|
118
|
+
def session_id
|
119
|
+
Configuration.deprecated(:session_id, 'instead use `id` or use a custom rollout strategy', version: '0.7.0')
|
120
|
+
id
|
121
|
+
end
|
77
122
|
|
78
|
-
|
79
|
-
|
123
|
+
# @deprecated
|
124
|
+
def flipper_id
|
125
|
+
Configuration.deprecated(:flipper_id, 'instead use `id` or use a custom rollout strategy', version: '0.7.0')
|
126
|
+
"Experiment;#{id}"
|
80
127
|
end
|
81
128
|
|
129
|
+
# @deprecated
|
130
|
+
def use(&block)
|
131
|
+
Configuration.deprecated(:use, 'instead use `control`', version: '0.7.0')
|
132
|
+
|
133
|
+
control(&block)
|
134
|
+
end
|
135
|
+
|
136
|
+
# @deprecated
|
137
|
+
def try(name = nil, &block)
|
138
|
+
if name.present?
|
139
|
+
Configuration.deprecated(:try, "instead use `variant(:#{name})`", version: '0.7.0')
|
140
|
+
variant(name, &block)
|
141
|
+
else
|
142
|
+
Configuration.deprecated(:try, 'instead use `candidate`', version: '0.7.0')
|
143
|
+
candidate(&block)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
protected
|
148
|
+
|
82
149
|
def cached_variant_resolver(provided_variant)
|
83
150
|
return :control if excluded?
|
84
151
|
|
85
152
|
result = cache_variant(provided_variant) { resolve_variant_name }
|
86
153
|
result.to_sym if result.present?
|
87
154
|
end
|
88
|
-
|
89
|
-
def generate_result(variant_name)
|
90
|
-
observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
|
91
|
-
Scientist::Result.new(self, [observation], observation)
|
92
|
-
end
|
93
155
|
end
|
94
156
|
end
|
95
157
|
end
|
@@ -1,25 +1,25 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
#
|
6
|
-
# that also adheres to the ActiveSupport::Cache::Store interface. It's a good
|
7
|
-
# example of how to build a custom caching strategy for Gitlab::Experiment, and
|
8
|
-
# is intended to be a long lived cache -- until the experiment is cleaned up.
|
3
|
+
# This cache strategy is an implementation on top of the redis hash data type, that also adheres to the
|
4
|
+
# ActiveSupport::Cache::Store interface. It's a good example of how to build a custom caching strategy for
|
5
|
+
# Gitlab::Experiment, and is intended to be a long lived cache -- until the experiment is cleaned up.
|
9
6
|
#
|
10
7
|
# The data structure:
|
11
8
|
# key: experiment.name
|
12
9
|
# fields: context key => variant name
|
13
10
|
#
|
14
|
-
#
|
15
|
-
#
|
11
|
+
# Example configuration usage:
|
12
|
+
#
|
13
|
+
# config.cache = Gitlab::Experiment::Cache::RedisHashStore.new(
|
14
|
+
# pool: ->(&block) { block.call(Redis.current) }
|
16
15
|
# )
|
16
|
+
#
|
17
17
|
module Gitlab
|
18
18
|
class Experiment
|
19
19
|
module Cache
|
20
20
|
class RedisHashStore < ActiveSupport::Cache::Store
|
21
|
-
# Clears the entire cache for a given experiment. Be careful with this
|
22
|
-
#
|
21
|
+
# Clears the entire cache for a given experiment. Be careful with this since it would reset all resolved
|
22
|
+
# variants for the entire experiment.
|
23
23
|
def clear(key:)
|
24
24
|
key = hkey(key)[0] # extract only the first part of the key
|
25
25
|
pool do |redis|
|
@@ -1,7 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require 'active_support/cache'
|
4
|
-
|
5
3
|
module Gitlab
|
6
4
|
class Experiment
|
7
5
|
module Cache
|
@@ -21,7 +19,7 @@ module Gitlab
|
|
21
19
|
end
|
22
20
|
|
23
21
|
def write(value = nil)
|
24
|
-
store.write(key, value || @experiment.
|
22
|
+
store.write(key, value || @experiment.assigned.name)
|
25
23
|
end
|
26
24
|
|
27
25
|
def delete
|
@@ -68,10 +66,8 @@ module Gitlab
|
|
68
66
|
|
69
67
|
store.write(cache_key, value)
|
70
68
|
store.delete(old_key)
|
71
|
-
|
72
|
-
end
|
73
|
-
|
74
|
-
store.fetch(cache_key, &block)
|
69
|
+
break value
|
70
|
+
end || store.fetch(cache_key, &block)
|
75
71
|
end
|
76
72
|
end
|
77
73
|
end
|
@@ -1,7 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require 'active_support/callbacks'
|
4
|
-
|
5
3
|
module Gitlab
|
6
4
|
class Experiment
|
7
5
|
module Callbacks
|
@@ -9,15 +7,85 @@ module Gitlab
|
|
9
7
|
include ActiveSupport::Callbacks
|
10
8
|
|
11
9
|
included do
|
12
|
-
|
13
|
-
|
10
|
+
# Callbacks are listed in order of when they're executed when running an experiment.
|
11
|
+
|
12
|
+
# Exclusion check chain:
|
13
|
+
#
|
14
|
+
# The :exclusion_check chain is executed when determining if the context should be excluded from the experiment.
|
15
|
+
#
|
16
|
+
# If any callback returns true, further chain execution is terminated, the context will be considered excluded,
|
17
|
+
# and the control behavior will be provided.
|
14
18
|
define_callbacks(:exclusion_check, skip_after_callbacks_if_terminated: true)
|
19
|
+
|
20
|
+
# Segmentation chain:
|
21
|
+
#
|
22
|
+
# The :segmentation chain is executed when no variant has been explicitly provided, the experiment is enabled,
|
23
|
+
# and the context hasn't been excluded.
|
24
|
+
#
|
25
|
+
# If the :segmentation callback chain doesn't need to be executed, the :segmentation_skipped chain will be
|
26
|
+
# executed as the fallback.
|
27
|
+
#
|
28
|
+
# If any callback explicitly sets a variant, further chain execution is terminated.
|
29
|
+
define_callbacks(:segmentation)
|
30
|
+
define_callbacks(:segmentation_skipped)
|
31
|
+
|
32
|
+
# Run chain:
|
33
|
+
#
|
34
|
+
# The :run chain is executed when the experiment is enabled, and the context hasn't been excluded.
|
35
|
+
#
|
36
|
+
# If the :run callback chain doesn't need to be executed, the :run_skipped chain will be executed as the
|
37
|
+
# fallback.
|
38
|
+
define_callbacks(:run)
|
39
|
+
define_callbacks(:run_skipped)
|
15
40
|
end
|
16
41
|
|
17
42
|
class_methods do
|
43
|
+
def registered_behavior_callbacks
|
44
|
+
@_registered_behavior_callbacks ||= {}
|
45
|
+
end
|
46
|
+
|
18
47
|
private
|
19
48
|
|
20
|
-
def
|
49
|
+
def build_behavior_callback(filters, variant, **options, &block)
|
50
|
+
if registered_behavior_callbacks[variant.to_s]
|
51
|
+
raise ExistingBehaviorError, "a behavior for the `#{variant}` variant has already been registered"
|
52
|
+
end
|
53
|
+
|
54
|
+
callback_behavior = "#{variant}_behavior".to_sym
|
55
|
+
|
56
|
+
# Register a the behavior so we can define the block later.
|
57
|
+
registered_behavior_callbacks[variant.to_s] = callback_behavior
|
58
|
+
|
59
|
+
# Add our block or default behavior method.
|
60
|
+
filters.push(block) if block.present?
|
61
|
+
filters.unshift(callback_behavior) if filters.empty?
|
62
|
+
|
63
|
+
# Define and build the callback that will set our result.
|
64
|
+
define_callbacks(callback_behavior)
|
65
|
+
build_callback(callback_behavior, *filters, **options) do |target, callback|
|
66
|
+
target.instance_variable_set(:@_behavior_callback_result, callback.call(target, nil))
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def build_exclude_callback(filters, **options)
|
71
|
+
build_callback(:exclusion_check, *filters, **options) do |target, callback|
|
72
|
+
throw(:abort) if target.instance_variable_get(:@_excluded) || callback.call(target, nil) == true
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def build_segment_callback(filters, variant, **options)
|
77
|
+
build_callback(:segmentation, *filters, **options) do |target, callback|
|
78
|
+
if target.instance_variable_get(:@_assigned_variant_name).nil? && callback.call(target, nil)
|
79
|
+
target.assigned(variant)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def build_run_callback(filters, **options)
|
85
|
+
set_callback(:run, *filters.compact, **options)
|
86
|
+
end
|
87
|
+
|
88
|
+
def build_callback(chain, *filters, **options)
|
21
89
|
filters = filters.compact.map do |filter|
|
22
90
|
result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
|
23
91
|
->(target) { yield(target, result_lambda) }
|
@@ -28,6 +96,30 @@ module Gitlab
|
|
28
96
|
set_callback(chain, *filters, **options)
|
29
97
|
end
|
30
98
|
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def exclusion_callback_chain
|
103
|
+
:exclusion_check
|
104
|
+
end
|
105
|
+
|
106
|
+
def segmentation_callback_chain
|
107
|
+
return :segmentation if @_assigned_variant_name.nil? && enabled? && !excluded?
|
108
|
+
|
109
|
+
:segmentation_skipped
|
110
|
+
end
|
111
|
+
|
112
|
+
def run_callback_chain
|
113
|
+
return :run if enabled? && !excluded?
|
114
|
+
|
115
|
+
:run_skipped
|
116
|
+
end
|
117
|
+
|
118
|
+
def registered_behavior_callbacks
|
119
|
+
self.class.registered_behavior_callbacks.transform_values do |callback_behavior|
|
120
|
+
-> { run_callbacks(callback_behavior) { @_behavior_callback_result } }
|
121
|
+
end
|
122
|
+
end
|
31
123
|
end
|
32
124
|
end
|
33
125
|
end
|