activeexperiment 0.1.0.alpha

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +0 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +373 -0
  5. data/lib/active_experiment/base.rb +76 -0
  6. data/lib/active_experiment/cache/active_record_cache_store.rb +116 -0
  7. data/lib/active_experiment/cache/redis_hash_cache_store.rb +68 -0
  8. data/lib/active_experiment/cache.rb +28 -0
  9. data/lib/active_experiment/caching.rb +172 -0
  10. data/lib/active_experiment/callbacks.rb +111 -0
  11. data/lib/active_experiment/capturable.rb +117 -0
  12. data/lib/active_experiment/configured_experiment.rb +74 -0
  13. data/lib/active_experiment/core.rb +177 -0
  14. data/lib/active_experiment/executed.rb +86 -0
  15. data/lib/active_experiment/execution.rb +156 -0
  16. data/lib/active_experiment/gem_version.rb +18 -0
  17. data/lib/active_experiment/instrumentation.rb +45 -0
  18. data/lib/active_experiment/log_subscriber.rb +178 -0
  19. data/lib/active_experiment/logging.rb +62 -0
  20. data/lib/active_experiment/railtie.rb +69 -0
  21. data/lib/active_experiment/rollout.rb +106 -0
  22. data/lib/active_experiment/rollouts/inactive_rollout.rb +24 -0
  23. data/lib/active_experiment/rollouts/percent_rollout.rb +84 -0
  24. data/lib/active_experiment/rollouts/random_rollout.rb +46 -0
  25. data/lib/active_experiment/rollouts.rb +127 -0
  26. data/lib/active_experiment/rspec.rb +12 -0
  27. data/lib/active_experiment/run_key.rb +55 -0
  28. data/lib/active_experiment/segments.rb +69 -0
  29. data/lib/active_experiment/test_case.rb +11 -0
  30. data/lib/active_experiment/test_helper.rb +267 -0
  31. data/lib/active_experiment/variants.rb +145 -0
  32. data/lib/active_experiment/version.rb +11 -0
  33. data/lib/active_experiment.rb +27 -0
  34. data/lib/activeexperiment.rb +8 -0
  35. data/lib/rails/generators/experiment/USAGE +12 -0
  36. data/lib/rails/generators/experiment/experiment_generator.rb +53 -0
  37. data/lib/rails/generators/experiment/templates/application_experiment.rb.tt +4 -0
  38. data/lib/rails/generators/experiment/templates/experiment.rb.tt +35 -0
  39. data/lib/rails/generators/rspec/experiment/experiment_generator.rb +20 -0
  40. data/lib/rails/generators/rspec/experiment/templates/experiment_spec.rb.tt +7 -0
  41. data/lib/rails/generators/test_unit/experiment/experiment_generator.rb +21 -0
  42. data/lib/rails/generators/test_unit/experiment/templates/experiment_test.rb.tt +9 -0
  43. metadata +118 -0
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/cache"
4
+
5
+ module ActiveExperiment
6
+ # == Caching
7
+ #
8
+ # Active Experiment can use caching for variant assignments. Caching is a
9
+ # complex topic, and unlike a lot of caching strategies where a key can
10
+ # expire and/or be cleaned up automatically, Active Experiment requires a
11
+ # cache store that will hold a given cache key for the lifetime of the
12
+ # experiment.
13
+ #
14
+ # Since an experiment cache has to live for the lifetime of the experiment,
15
+ # there are some special considerations about the size of the cache and how
16
+ # we might clean it up after an experiment is removed, and also if/when to
17
+ # use caching at all.
18
+ #
19
+ # == When to Use Caching
20
+ #
21
+ # In simple experiments caching may not be required, but as experiments get
22
+ # more complex, caching starts to become a more important aspect to consider.
23
+ # Because of this, caching can be configured on a per experiment basis.
24
+ #
25
+ # When should you consider caching? Exclusions and segmenting rules can often
26
+ # benefit from caching, and so it should be considered whenever adding
27
+ # segment rules to an experiment.
28
+ #
29
+ # For example, here's an experiment that highlights why caching can be an
30
+ # important consideration when adding a segment rule:
31
+ #
32
+ # class MyExperiment < ActiveExperiment::Base
33
+ # variant(:red) { "red" }
34
+ # variant(:blue) { "blue" }
35
+ #
36
+ # segment :older_accounts, into: :red
37
+ #
38
+ # private
39
+ #
40
+ # def older_accounts
41
+ # context.created_at < 1.week.ago
42
+ # end
43
+ # end
44
+ #
45
+ # If caching isn't used for this experiment, a new account might be assigned
46
+ # the blue variant initially, and within a week the variant would switch to
47
+ # red because they shift into the "older_accounts" segment.
48
+ #
49
+ # In some scenarios it might be desirable to allow contexts to move between
50
+ # the segments, but in most cases it's not.
51
+ #
52
+ # == Cache Considerations
53
+ #
54
+ # The cache store used should be a long lived cache, such as Redis, or even a
55
+ # database. The cache store should also be able to handle the number of keys
56
+ # that will be stored in it.
57
+ #
58
+ # For example, if you have 100 users and 100 posts, and define an experiment
59
+ # that runs on all users viewing all posts, you'll have a cache potential of
60
+ # ~1,000,000 entries for that experiment alone.
61
+ #
62
+ # Here's an example of what that means, and how you can consider the cache
63
+ # size:
64
+ #
65
+ # User.count # => 100
66
+ # Post.count # => 100
67
+ # MyExperiment.run(user: user, post: post) # => cache potential: ~1,000,000
68
+ #
69
+ # Now, will that potential ever be hit? It's hard to say, and the answer is
70
+ # dependent on where the experiment is being run, and other considerations
71
+ # like those.
72
+ #
73
+ # If the same experiment is run only on posts (or users), the cache potential
74
+ # would be limited to 100.
75
+ #
76
+ # == Custom Cache Stores
77
+ #
78
+ # Custom cache stores can be created and registered, as long as they adhere
79
+ # to the standard interface in +ActiveSupport::Cache::Store+ they can be
80
+ # used -- provided it can handle the long lived caching nature required by
81
+ # Active Experiment.
82
+ #
83
+ # == Configuring Caching
84
+ #
85
+ # Caching can be configured globally, and overridden on a per experiment
86
+ # basis. By default the cache store used is the standard +:null_store+ as its
87
+ # defined in +ActiveSupport::Cache::NullStore+. This is a no-op cache store
88
+ # that doesn't actually cache anything but provides a consistent interface.
89
+ #
90
+ # Active Experiment ships with a functional cache store based on using the
91
+ # Redis hash data type (https://redis.io/docs/data-types/hashes/). This cache
92
+ # store expects a redis instance or pool that hasn't been configured to auto
93
+ # expire keys.
94
+ #
95
+ # To configure the cache store globally:
96
+ #
97
+ # ActiveExperiment::Base.cache_store = :redis_hash
98
+ #
99
+ # To configure the cache store on a per experiment basis:
100
+ #
101
+ # class MyExperiment < ActiveExperiment::Base
102
+ # variant(:red) { "red" }
103
+ # variant(:blue) { "blue" }
104
+ #
105
+ # use_cache_store :redis_hash
106
+ # end
107
+ module Caching
108
+ extend ActiveSupport::Concern
109
+
110
+ included do
111
+ class_attribute :cache_store, instance_writer: false, instance_predicate: false
112
+
113
+ self.default_cache_store = :null_store
114
+ end
115
+
116
+ module ClassMethods
117
+ def inherited(subclass)
118
+ super
119
+ subclass.default_cache_store = @default_cache_store
120
+ end
121
+
122
+ def default_cache_store=(name_or_cache_store)
123
+ use_cache_store(name_or_cache_store)
124
+ @default_cache_store = name_or_cache_store
125
+ end
126
+
127
+ def clear_cache(cache_key_prefix = nil)
128
+ cache_store.delete_matched(cache_key_prefix || experiment_name)
129
+ end
130
+
131
+ private
132
+ def use_cache_store(name_or_cache_store, *args, **kws)
133
+ case name_or_cache_store
134
+ when Symbol, String
135
+ self.cache_store = ActiveExperiment::Cache.lookup(name_or_cache_store, *args, **kws)
136
+ else
137
+ self.cache_store = name_or_cache_store
138
+ end
139
+ end
140
+ end
141
+
142
+ # The cache key prefix.
143
+ #
144
+ # This is used to namespace cache keys, and can be used to find all cache
145
+ # keys for a given experiment.
146
+ def cache_key_prefix
147
+ name
148
+ end
149
+
150
+ # The cache key for a given experiment and experiment context.
151
+ #
152
+ # The cache key includes the experiment name and hexdigest generated by the
153
+ # experiment context.
154
+ def cache_key
155
+ [cache_key_prefix, run_key.slice(0, 32)].join(":")
156
+ end
157
+
158
+ # Store the variant assignment in the cache.
159
+ #
160
+ # Raises an +ExecutionError+ if no variant has been assigned.
161
+ def cache_variant!
162
+ raise ExecutionError, "No variant assigned" unless variant.present?
163
+
164
+ cache_store.write(self, variant)
165
+ end
166
+
167
+ private
168
+ def cached_variant(variant, &block)
169
+ cache_store.fetch(self, skip_nil: true) { variant || block&.call }
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/callbacks"
4
+
5
+ module ActiveExperiment
6
+ # == Callbacks
7
+ #
8
+ # Active Experiment provides several callbacks to hook into the lifecycle of
9
+ # running an experiment. Using callbacks in Active Experiment is the same as
10
+ # using other callbacks within Rails.
11
+ #
12
+ # The callbacks are generally separated into two concepts: run and variant.
13
+ # Run callbacks are invoked whenever an experiment is run, and variant
14
+ # callbacks are only invoked when that variant is assigned or resolved.
15
+ #
16
+ # The following run callback methods are available:
17
+ #
18
+ # * +before_run+
19
+ # * +after_run+
20
+ # * +around_run+
21
+ #
22
+ # The variant may not be known when each run callback is invoked, so it's not
23
+ # advised to rely on a variant within run callbacks. The variant callbacks
24
+ # are useful for that however, since they're only invoked for a given
25
+ # variant. The variant name must be provided to the variant callback methods.
26
+ #
27
+ # The following variant callback methods are available:
28
+ #
29
+ # * +before_variant+
30
+ # * +after_variant+
31
+ # * +around_variant+
32
+ #
33
+ # An example of an experiment that uses run and variant callbacks:
34
+ #
35
+ # class MyExperiment < ActiveExperiment::Base
36
+ # variant(:red) { "red" }
37
+ # variant(:blue) { "blue" }
38
+ #
39
+ # after_run :after_run_callback_method, if: -> { true }
40
+ # before_run { puts "before #{name}" }
41
+ # around_run do |_experiment, block|
42
+ # puts "around #{name} [#{block.call}]"
43
+ # end
44
+ #
45
+ # after_variant(:red) { puts "after:red #{name}" }
46
+ # before_variant(:red) { puts "before:red #{name}" }
47
+ # around_variant(:blue) do |_experiment, block|
48
+ # puts "around:blue #{name} [#{block.call}]"
49
+ # end
50
+ #
51
+ # private
52
+ #
53
+ # def after_run_callback_method
54
+ # puts "after #{name}"
55
+ # end
56
+ # end
57
+ module Callbacks
58
+ extend ActiveSupport::Concern
59
+ include ActiveSupport::Callbacks
60
+
61
+ included do
62
+ define_callbacks :run, skip_after_callbacks_if_terminated: true
63
+ private :__callbacks, :__callbacks?, :run_callbacks, :_run_callbacks, :_run_run_callbacks
64
+ end
65
+
66
+ # These methods will be included into any Active Experiment object, adding
67
+ # the run and variant callback methods, and tooling to build callbacks with
68
+ # a target, which is used by segment rules and variant steps.
69
+ module ClassMethods
70
+ private
71
+ def before_run(*filters, &block)
72
+ set_callback(:run, :before, *filters, &block)
73
+ end
74
+
75
+ def after_run(*filters, &block)
76
+ set_callback(:run, :after, *filters, &block)
77
+ end
78
+
79
+ def around_run(*filters, &block)
80
+ set_callback(:run, :around, *filters, &block)
81
+ end
82
+
83
+ def before_variant(variant, *filters, &block)
84
+ set_variant_callback(variant, :before, *filters, &block)
85
+ end
86
+
87
+ def after_variant(variant, *filters, &block)
88
+ set_variant_callback(variant, :after, *filters, &block)
89
+ end
90
+
91
+ def around_variant(variant, *filters, &block)
92
+ set_variant_callback(variant, :around, *filters, &block)
93
+ end
94
+
95
+ def set_callback_with_target(chain, *filters, default: nil, **options)
96
+ filters = filters.compact
97
+
98
+ if filters.empty? && !default.nil?
99
+ filters = [default] if options[:if].present? || options[:unless].present?
100
+ end
101
+
102
+ filters = filters.map do |filter|
103
+ result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
104
+ ->(target) { yield(target, result_lambda) }
105
+ end
106
+
107
+ set_callback(chain, *filters, **options)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveExperiment
4
+ # == Capturable Mixin
5
+ #
6
+ # This module adds the capability to capture and render the results of an
7
+ # experiment in the order that would make sense when rendering in a view.
8
+ #
9
+ # Add it to experiments that should capture and render their results.
10
+ #
11
+ # class MyExperiment < ActiveExperiment::Base
12
+ # include ActiveExperiment::Capturable
13
+ # end
14
+ #
15
+ # The order of experiment execution is to call the run block before resolving
16
+ # the variant, and subsequently calling the appropriate variant block. This
17
+ # order allows the run block to set details that can be used in resolving the
18
+ # variant and/or to set the variant directly -- or to skip the experiment
19
+ # altogether even.
20
+ #
21
+ # The order of how code is implemented in the run block shouldn't matter to
22
+ # the experiment (generally speaking) and the two following examples do the
23
+ # same thing:
24
+ #
25
+ # MyExperiment.run do |experiment|
26
+ # experiment.skip if current_user.admin?
27
+ # experiment.on(:red) { "red override" }
28
+ # end
29
+ #
30
+ # MyExperiment.run do |experiment|
31
+ # experiment.on(:red) { "red override" }
32
+ # experiment.skip if current_user.admin?
33
+ # end
34
+ #
35
+ # This is desirable most of the time, for important performance reasons, but
36
+ # can also be undesirable when running an experiment in a view and wanting to
37
+ # capture the markup in the expected order.
38
+ #
39
+ # In the following example the container div is shared between the variants,
40
+ # and duplicating it (potentially several times) in each variant block would
41
+ # be undesirable:
42
+ #
43
+ # <%== MyExperiment.set(capture: self).run do |experiment| %>
44
+ # <div class="container">
45
+ # <%= experiment.on(:red) do %>
46
+ # <button class="red-pill">Red</button>
47
+ # <% end %>
48
+ # <%= experiment.on(:blue) do %>
49
+ # <button class="blue-pill">Blue</button>
50
+ # <% end %>
51
+ # </div>
52
+ # <% end %>
53
+ #
54
+ # There are a couple important things to note about the above example to
55
+ # ensure capturing works as expected:
56
+ #
57
+ # 1. The `ActiveExperiment::Capturable` module has been included in the
58
+ # experiment class.
59
+ #
60
+ # 2. The use of +==+ in the ERB tag is important because Active Experiment
61
+ # doesn't try to determine if the experiment results are safe to render,
62
+ # and it's up to the caller to make them html safe again.
63
+ #
64
+ # 3. The +capture+ option that's passed to the +set+ method tells Active
65
+ # Experiment to use the view context's +capture+ logic to build the output
66
+ # in the expected order.
67
+ #
68
+ # 4. Each variant block should use +=+ on the ERB tag to ensure the variant
69
+ # content ends up where it should be in the output.
70
+ #
71
+ # In HAML, the above example would look like:
72
+ #
73
+ # != MyExperiment.set(capture: self).run do |experiment|
74
+ # %div.container
75
+ # = experiment.on(:red) do
76
+ # %button.red-pill Red
77
+ # = experiment.on(:blue) do
78
+ # %button.blue-pill Blue
79
+ module Capturable
80
+ extend ActiveSupport::Concern
81
+
82
+ def on(*variant_names, &block)
83
+ super
84
+
85
+ "{{#{variant_names.join("}}{{")}}}"
86
+ end
87
+
88
+ def run(&block)
89
+ super
90
+
91
+ if capturable?
92
+ @results = @capture.to_s.gsub(/{{([\w]+)}}/) { $1 == variant.to_s ? @results : "" }
93
+ else
94
+ @results
95
+ end
96
+ end
97
+
98
+ private
99
+ def resolve_results
100
+ @results = capture { super }
101
+ end
102
+
103
+ def call_run_block(&block)
104
+ @capture = capture { super }
105
+ end
106
+
107
+ def capture(&block)
108
+ return yield unless capturable?
109
+
110
+ @options[:capture].capture(&block)
111
+ end
112
+
113
+ def capturable?
114
+ !!@options[:capture]&.respond_to?(:capture)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveExperiment
4
+ # == Configured Experiment
5
+ #
6
+ # A wrapper around an experiment that allows setting options on, and then
7
+ # doing more with the experiment. It includes tooling for running, or caching
8
+ # a variant for a collection of contexts.
9
+ #
10
+ # When calling +MyExperiment.set+, a configured experiment will be returned.
11
+ # Additional methods can then be called on the configured experiment instance
12
+ # to run the experiment or cache variants.
13
+ #
14
+ # For example, a configured experiment can be used to configure, instantiate
15
+ # and run an experiment:
16
+ #
17
+ # MyExperiment.set(variant: :blue).run(id: 1) do |experiment|
18
+ # experiment.on(:blue) { "blue override" }
19
+ # end
20
+ #
21
+ # Or it can be used to cache the variant assignment for a collection of
22
+ # contexts.
23
+ #
24
+ # MyExperiment.set(variant: red).cache_each(User.find_each)
25
+ #
26
+ # This class is also provided to be used when adding additional tooling for
27
+ # specific project needs. Here's an example of reopening this class to add a
28
+ # project specific +cleanup_cache+ method:
29
+ #
30
+ # class ActiveExperiment::ConfiguredExperiment
31
+ # def cleanup_cache
32
+ # store = experiment.cache_store
33
+ # store.delete_matched("#{experiment.cache_key_prefix}/*")
34
+ # end
35
+ # end
36
+ #
37
+ # Which could then be used by calling one of the following:
38
+ #
39
+ # MyExperiment.set.cleanup_cache
40
+ # ConfiguredExperiment.new(MyExperiment).cleanup_cache
41
+ class ConfiguredExperiment
42
+ def initialize(experiment_class, **options) # :nodoc:
43
+ @experiment_class = experiment_class
44
+ @options = options
45
+ end
46
+
47
+ # Runs the experiment with the configured options, context, and the given
48
+ # block. The block will be called with the experiment instance.
49
+ #
50
+ # This is a convenience method for instantiating and running an experiment:
51
+ #
52
+ # MyExperiment.set(variant: :blue).run(id: 1) { }
53
+ def run(context = {}, &block)
54
+ experiment(context).run(&block)
55
+ end
56
+
57
+ # When provided an enumerable, an experiment will be instantiated for each
58
+ # item and the variant assignment will be cached. This method can be used
59
+ # to pre-cache the variant assignment for a collection of contexts.
60
+ #
61
+ # MyExperiment.set(variant: red).cache_each(User.find_each)
62
+ def cache_each(enumerable_contexts)
63
+ enumerable_contexts.each do |context|
64
+ experiment(context).cache_variant!
65
+ end
66
+ end
67
+
68
+ # Returns the experiment instance with the configured options and provided
69
+ # context.
70
+ def experiment(context = {})
71
+ @experiment_class.new(context).set(**@options)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveExperiment
4
+ # == Core Module
5
+ #
6
+ # Provides general behavior that will be included into every Active
7
+ # Experiment object that inherits from ActiveExperiment::Base.
8
+ module Core
9
+ extend ActiveSupport::Concern
10
+
11
+ # The experiment context.
12
+ #
13
+ # Experiment contexts should be consistent and unique values that are used
14
+ # to assign the same variant over many runs. Examples can range from an
15
+ # Active Record object to the weekday name. If a given variant is assigned
16
+ # on Tuesdays, it will always be assigned on Tuesdays, or if a variant is
17
+ # assigned for a given Active Record object, it will always be assigned for
18
+ # that record.
19
+ #
20
+ # Context is used to generate the cache key, so including something that
21
+ # would change the cache key on every run is not recommended.
22
+ attr_reader :context
23
+
24
+ # The experiment name.
25
+ #
26
+ # This is an underscored version of the experiment class name. If within a
27
+ # namespace, the namespace will be included in the name, separated by a
28
+ # slash (e.g. "my_namespace/my_experiment").
29
+ attr_reader :name
30
+
31
+ # The experiment run identifier.
32
+ #
33
+ # A unique UUID, per experiment instantiation.
34
+ attr_reader :run_id
35
+
36
+ # The experiment run key.
37
+ #
38
+ # This is a hexdigest that's generated from the experiment context. The run
39
+ # key is used as the cache key and can be used by the rollout to determine
40
+ # variant assignment.
41
+ attr_reader :run_key
42
+
43
+ # The variant that's been assigned or resolved for this run.
44
+ #
45
+ # This can be manually provided before the experiment is run, or can be
46
+ # resolved by segment rules or asking the rollout.
47
+ attr_reader :variant
48
+
49
+ # Experiment options.
50
+ #
51
+ # Generally not used within the core library, this is provided to expose
52
+ # additional data when running experiments. Use the +set+ method to set a
53
+ # variant, or other options.
54
+ attr_reader :options
55
+
56
+ # These methods will be included into any Active Experiment object and
57
+ # provide variant registration methods.
58
+ module ClassMethods
59
+ # The experiment name.
60
+ #
61
+ # An underscored version of the experiment class name. If within a
62
+ # namespace, the namespace will be included in the name, separated by a
63
+ # slash (e.g. "my_namespace/my_experiment").
64
+ def experiment_name
65
+ name.underscore
66
+ end
67
+
68
+ private
69
+ def control(...)
70
+ variant(:control, ...)
71
+ end
72
+
73
+ def variant(name, ...)
74
+ register_variant_callback(name, ...)
75
+ end
76
+ end
77
+
78
+ # Creates a new experiment instance.
79
+ #
80
+ # The context provided to an experiment should be a consistent and unique
81
+ # value used to assign the same variant over many runs.
82
+ def initialize(context = {})
83
+ @context = context
84
+ @name = self.class.experiment_name
85
+ @run_id = SecureRandom.uuid
86
+ @run_key = run_key_hexdigest(context)
87
+ @options = {}
88
+ end
89
+
90
+ # Configures the experiment with the given options.
91
+ #
92
+ # This is used to set the variant, and can be used to set other options
93
+ # that may be used within an experiment. It's separate from the context to
94
+ # allow for the context to be used for variant assignment, while other
95
+ # options might still be useful.
96
+ #
97
+ # Raises an +ArgumentError+ if the variant is unknown.
98
+ # Returns self to allow chaining, typically for calling run.
99
+ def set(variant: nil, **options)
100
+ @options = @options.merge(options)
101
+ if variant.present?
102
+ variant = variant.to_sym
103
+ raise ArgumentError, "Unknown #{variant.inspect} variant" unless variants[variant]
104
+
105
+ @variant = variant
106
+ end
107
+
108
+ self
109
+ end
110
+
111
+ # Allows providing overrides for registered variants.
112
+ #
113
+ # When running experiments, any variant can be overridden to only invoke
114
+ # the provided override. This allows access to the scope and helpers where
115
+ # the experiment is being run. An example in a controller might look like:
116
+ #
117
+ # MyExperiment.run(current_user) do |experiment|
118
+ # experiment.on(:red) { render "red_pill" }
119
+ # experiment.on(:blue) { redirect_to "blue_pill" }
120
+ # end
121
+ #
122
+ # Raises an +ArgumentError+ if the variant is unknown, or if no block has
123
+ # been provided.
124
+ def on(*variant_names, &block)
125
+ variant_names.each do |variant|
126
+ variant = variant.to_sym
127
+ raise ArgumentError, "Unknown #{variant.inspect} variant" unless variants[variant]
128
+ raise ArgumentError, "Missing block" unless block
129
+
130
+ variant_step_chains[variant] = block
131
+ end
132
+ end
133
+
134
+ # Allows skipping the experiment.
135
+ #
136
+ # When an experiment is skipped, the default variant will be assigned, and
137
+ # generally means that no reporting should be generated for the run.
138
+ def skip
139
+ @skip = true
140
+ end
141
+
142
+ # Returns true if the experiment should be skipped.
143
+ #
144
+ # If the experiment has been instructed to be skipped manually, or if the
145
+ # rollout determines the experiment should be skipped.
146
+ def skipped?
147
+ return @skip if defined?(@skip)
148
+
149
+ @skip = self.rollout.skipped_for(self)
150
+ end
151
+
152
+ # Returns a hash with the experiment data.
153
+ #
154
+ # This is used to serialize the experiment data for logging and reporting,
155
+ # And can be overridden to provide additional data relevant to the
156
+ # experiment.
157
+ #
158
+ # Calling this before the variant has been assigned or resolved will result
159
+ # in the variant being empty. Generally, this should be called after the
160
+ # experiment has been run.
161
+ def serialize
162
+ {
163
+ "experiment" => name,
164
+ "run_id" => run_id,
165
+ "run_key" => run_key,
166
+ "variant" => variant.to_s,
167
+ "skipped" => skipped?
168
+ }
169
+ end
170
+
171
+ def to_s # :nodoc:
172
+ details = [@variant.inspect, @skip.inspect, (@run_key.slice(0, 16) + "..."), @context.inspect, @options.inspect]
173
+ string = "#<%s:%#0x @variant=%s @skip=%s @run_key=%s @context=%s, @options=%s>"
174
+ sprintf(string, self.class.name, object_id, *details)
175
+ end
176
+ end
177
+ end