gitlab-experiment 0.6.4 → 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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 :variants,
12
- type: :array,
13
- default: %w[control candidate],
14
- banner: 'variant variant'
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. Use `nil` for none.
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 is used to log various details of the experiments.
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. It should
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
- # The caching layer is expected to respond to fetch, like Rails.cache for
15
- # instance -- or anything that adheres to ActiveSupport::Cache::Store.
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 that works for single and multi-variants.
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
- # You can provide your own rollout strategies and override them per
30
- # experiment.
43
+ # Each experiment can specify its own rollout strategy:
31
44
  #
32
- # Examples include:
33
- # Rollout::Random, or Rollout::RoundRobin
34
- config.default_rollout = Gitlab::Experiment::Rollout::Percent
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 instead.
40
- @context_key_secret = nil
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
- @context_key_bit_length = 256
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. Can be nil if you don't want anything to be mounted automatically.
75
+ # mounted. The middleware enables an instrumentation url, that's similar
76
+ # to links that can be instrumented in email campaigns.
52
77
  #
53
- # This enables a similar behavior to how links are instrumented in emails.
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 or
61
- # that it's a valid url on your domain. To avoid this, you can provide your
62
- # own logic for what urls will be considered valid and redirected to.
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 |redirect_url|
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 access
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 context,
99
- # or push the experiment into the client or publish results elsewhere like
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 access
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
- # Push the experiment knowledge into the front end. The signature contains
109
- # the context key, and the variant that has been determined.
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
- # Gon.push({ experiment: { name => signature } }, true)
151
+ # Lograge::Event.log(experiment: name, result: result, signature: signature)
112
152
 
113
- # Log using our logging system, so the result (which can be large) can be
114
- # reviewed later if we want to.
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
- # Lograge::Event.log(experiment: name, result: result, signature: signature)
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
- def <%= variant %>_behavior
11
- end
12
- <%= "\n" unless variant == variants.last -%>
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.safe_constantize || Configuration.base_class.constantize
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
- @name = self.class.experiment_name(name, suffix: false)
41
- @context = Context.new(self, **context)
42
- @variant_name = cache_variant(variant_name) { nil } if variant_name.present?
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__)} @name=#{name} @context=#{context.value}>"
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
- def flipper_id
60
- "Experiment;#{id}"
61
- end
79
+ alias_method :to_param, :id
62
80
 
63
81
  def variant_names
64
- @variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
82
+ @_variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
65
83
  end
66
84
 
67
85
  def behaviors
68
- @behaviors ||= public_methods.each_with_object(super) do |name, behaviors|
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.to_s.sub(/_behavior$/, '')
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
- protected
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
- def raise_on_mismatches?
79
- false
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
- require 'active_support/notifications'
4
-
5
- # This cache strategy is an implementation on top of the redis hash data type,
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
- # Gitlab::Experiment::Configuration.cache = Gitlab::Experiment::Cache::RedisHashStore.new(
15
- # pool: -> { Gitlab::Redis::SharedState.with { |redis| yield redis } }
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
- # since it would reset all resolved variants for the entire experiment.
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.variant.name)
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
- return value
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
- define_callbacks(:unsegmented)
13
- define_callbacks(:segmentation_check)
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 build_callback(chain, filters, **options)
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