activeexperiment 0.1.0.alpha

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