activeexperiment 0.1.0.alpha
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +0 -0
- data/MIT-LICENSE +20 -0
- data/README.md +373 -0
- data/lib/active_experiment/base.rb +76 -0
- data/lib/active_experiment/cache/active_record_cache_store.rb +116 -0
- data/lib/active_experiment/cache/redis_hash_cache_store.rb +68 -0
- data/lib/active_experiment/cache.rb +28 -0
- data/lib/active_experiment/caching.rb +172 -0
- data/lib/active_experiment/callbacks.rb +111 -0
- data/lib/active_experiment/capturable.rb +117 -0
- data/lib/active_experiment/configured_experiment.rb +74 -0
- data/lib/active_experiment/core.rb +177 -0
- data/lib/active_experiment/executed.rb +86 -0
- data/lib/active_experiment/execution.rb +156 -0
- data/lib/active_experiment/gem_version.rb +18 -0
- data/lib/active_experiment/instrumentation.rb +45 -0
- data/lib/active_experiment/log_subscriber.rb +178 -0
- data/lib/active_experiment/logging.rb +62 -0
- data/lib/active_experiment/railtie.rb +69 -0
- data/lib/active_experiment/rollout.rb +106 -0
- data/lib/active_experiment/rollouts/inactive_rollout.rb +24 -0
- data/lib/active_experiment/rollouts/percent_rollout.rb +84 -0
- data/lib/active_experiment/rollouts/random_rollout.rb +46 -0
- data/lib/active_experiment/rollouts.rb +127 -0
- data/lib/active_experiment/rspec.rb +12 -0
- data/lib/active_experiment/run_key.rb +55 -0
- data/lib/active_experiment/segments.rb +69 -0
- data/lib/active_experiment/test_case.rb +11 -0
- data/lib/active_experiment/test_helper.rb +267 -0
- data/lib/active_experiment/variants.rb +145 -0
- data/lib/active_experiment/version.rb +11 -0
- data/lib/active_experiment.rb +27 -0
- data/lib/activeexperiment.rb +8 -0
- data/lib/rails/generators/experiment/USAGE +12 -0
- data/lib/rails/generators/experiment/experiment_generator.rb +53 -0
- data/lib/rails/generators/experiment/templates/application_experiment.rb.tt +4 -0
- data/lib/rails/generators/experiment/templates/experiment.rb.tt +35 -0
- data/lib/rails/generators/rspec/experiment/experiment_generator.rb +20 -0
- data/lib/rails/generators/rspec/experiment/templates/experiment_spec.rb.tt +7 -0
- data/lib/rails/generators/test_unit/experiment/experiment_generator.rb +21 -0
- data/lib/rails/generators/test_unit/experiment/templates/experiment_test.rb.tt +9 -0
- 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
|