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