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.
@@ -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