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
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 5809078002c2de223c3c7f1017304defd4a1c7aaa3f9ac53fc7446980657ef14
|
4
|
+
data.tar.gz: 0c954cec3f8bd0af0c399ec4e3af1f2e63a4e7841df961a2df37f761bedaa682
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: b05783407e7c4bda3a6833154949250a654ccfcaec30a25330bf9407d89cd613986fbe80660913c704b266329a756626ba09b5d065826cc7d3968f594b0a32e6
|
7
|
+
data.tar.gz: 649646685dcf34cc67fc8c7de2a02dd9067778382e3266ff87f25c69a4f0256eca210d0c3d1f039b5491fbab07122070338f9de89bcf5cd70b6b55e4d4ecde95
|
data/CHANGELOG.md
ADDED
File without changes
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2022 Jeremy Jackson
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,373 @@
|
|
1
|
+
# Active Experiment – Decide what to do next
|
2
|
+
|
3
|
+
Active Experiment is a framework for defining and running experiments. It supports using a variety of rollout and reporting strategies and/or services.
|
4
|
+
|
5
|
+
Experiments can be everything from determining which query has the best performance, to which feature gets the most engagement, to rolling out a canary version of a new api service.
|
6
|
+
|
7
|
+
Experimentation is complex. There are a lot of different ways to run experiments, and even more ways to report on them. Active Experiment is designed to be flexible enough to support a variety of use cases, but also to be consistent and easy to use.
|
8
|
+
|
9
|
+
## Usage
|
10
|
+
|
11
|
+
Start by defining an experiment class and adding some variants to it:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
class MyExperiment < ApplicationExperiment
|
15
|
+
variant(:red) { "red" }
|
16
|
+
variant(:blue) { "blue" }
|
17
|
+
end
|
18
|
+
```
|
19
|
+
|
20
|
+
This experiment can be generated using the Rails generator:
|
21
|
+
|
22
|
+
```bash
|
23
|
+
rails generate experiment my_experiment red blue
|
24
|
+
```
|
25
|
+
|
26
|
+
Run the experiment anywhere in the application by providing it a context:
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
MyExperiment.run(current_user) # => "red" or "blue"
|
30
|
+
```
|
31
|
+
|
32
|
+
The experiment can also be run using local scope and helpers to override the default variants:
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
MyExperiment.run(current_user) do |experiment|
|
36
|
+
experiment.on(:red) { redirect_to red_path }
|
37
|
+
experiment.on(:blue) { redirect_to blue_path }
|
38
|
+
end
|
39
|
+
```
|
40
|
+
|
41
|
+
That's it! When this experiment is encountered by different users, half<sup>†</sup> will get the red variant, half will get the blue variant, and each will always get the same.
|
42
|
+
|
43
|
+
<small>† roughly half, for the statistically pedantic.</small>
|
44
|
+
|
45
|
+
## Download and Installation
|
46
|
+
|
47
|
+
Add this line to your Gemfile:
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
gem "activeexperiment"
|
51
|
+
```
|
52
|
+
|
53
|
+
Or install the latest version with RubyGems:
|
54
|
+
|
55
|
+
```bash
|
56
|
+
gem install activeexperiment
|
57
|
+
```
|
58
|
+
|
59
|
+
Source code can be downloaded as part of the project on GitHub:
|
60
|
+
|
61
|
+
* https://github.com/jejacks0n/activeexperiment
|
62
|
+
|
63
|
+
## Advanced Experimentation
|
64
|
+
|
65
|
+
This area provides a high level overview of the tools that more complex experiments can benefit from.
|
66
|
+
|
67
|
+
For example, some experiments need to define a default variant (also known as a _control_) that will be assigned if the experiment is skipped:
|
68
|
+
|
69
|
+
```ruby
|
70
|
+
class MyExperiment < ApplicationExperiment
|
71
|
+
variant(:red) { "red" }
|
72
|
+
variant(:blue) { "blue" }
|
73
|
+
|
74
|
+
# The term control is simply a convention that means the default variant, and
|
75
|
+
# any variant can be set as the default with +use_default_variant(:red)+
|
76
|
+
control { "default" }
|
77
|
+
end
|
78
|
+
```
|
79
|
+
|
80
|
+
Callbacks can be used to hook into the lifecycle when experiments are run, and can be targeted to when a specific variant has been assigned:
|
81
|
+
|
82
|
+
```ruby
|
83
|
+
class MyExperiment < ApplicationExperiment
|
84
|
+
control { "default" }
|
85
|
+
variant(:red) { "red" }
|
86
|
+
variant(:blue) { "blue" }
|
87
|
+
|
88
|
+
# Skipping an experiment will always assign the default variant, which could
|
89
|
+
# be nothing, but since there's a control defined, it will be used.
|
90
|
+
before_run { skip if context.admin? }
|
91
|
+
|
92
|
+
# Only invoked when the red variant has been assigned.
|
93
|
+
before_variant(:red) { puts "running the red variant" }
|
94
|
+
|
95
|
+
# Maybe there's cleanup or logging to do afterwards?
|
96
|
+
after_run { puts "run complete with the #{variant} variant" unless skipped? }
|
97
|
+
end
|
98
|
+
```
|
99
|
+
|
100
|
+
Segment rules can be used to assign specific variants for certain cases:
|
101
|
+
|
102
|
+
```ruby
|
103
|
+
class MyExperiment < ApplicationExperiment
|
104
|
+
control { "default" }
|
105
|
+
variant(:red) { "red" }
|
106
|
+
variant(:blue) { "blue" }
|
107
|
+
|
108
|
+
segment :admins, into: :red
|
109
|
+
segment :old_accounts, into: :control
|
110
|
+
|
111
|
+
private
|
112
|
+
|
113
|
+
def admins
|
114
|
+
context.admin?
|
115
|
+
end
|
116
|
+
|
117
|
+
def old_accounts
|
118
|
+
context.created_at < 1.year.ago
|
119
|
+
end
|
120
|
+
end
|
121
|
+
```
|
122
|
+
|
123
|
+
## Rollouts
|
124
|
+
|
125
|
+
Rollouts are a core concept in Active Experiment. They allow specifying how an experiment should be rolled out, and even if it should be skipped or not. For example, the default rollout in Active Experiment is percentage based and accepts distribution rules -- if no rules are provided, even distribution is used.
|
126
|
+
|
127
|
+
A rollout can implement any number of different strategies, interact with services, and can be used on a per-experiment basis.
|
128
|
+
|
129
|
+
Here's an example of using the default percent rollout with custom distribution rules:
|
130
|
+
|
131
|
+
```ruby
|
132
|
+
class MyExperiment < ApplicationExperiment
|
133
|
+
variant(:red) { "red" }
|
134
|
+
variant(:blue) { "blue" }
|
135
|
+
variant(:green) { "green" }
|
136
|
+
|
137
|
+
# Will assign the green variant 80% of the time, red and blue 10% each.
|
138
|
+
use_rollout :percent, rules: { red: 10, blue: 10, green: 80 }
|
139
|
+
end
|
140
|
+
```
|
141
|
+
|
142
|
+
### Defining Custom Rollouts
|
143
|
+
|
144
|
+
Project specific rollouts can be defined and registered too. To illustrate, here's a custom rollout that inherits from the base rollout, uses a fictional feature flag library, and assigns a random variant.
|
145
|
+
|
146
|
+
```ruby
|
147
|
+
class FeatureFlagRollout < ActiveExperiment::Rollouts::BaseRollout
|
148
|
+
def skipped_for(experiment)
|
149
|
+
!Feature.enabled?(@rollout_options[:flag_name] || experiment.name)
|
150
|
+
end
|
151
|
+
|
152
|
+
def variant_for(experiment)
|
153
|
+
experiment.variant_names.sample
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
ActiveExperiment::Rollouts.register(:feature_flag, FeatureRollout)
|
158
|
+
```
|
159
|
+
|
160
|
+
This rollout can now be used the same way the built-in rollouts are:
|
161
|
+
|
162
|
+
```ruby
|
163
|
+
class MyExperiment < ActiveExperiment::Base
|
164
|
+
variant(:red) { "red" }
|
165
|
+
variant(:blue) { "blue" }
|
166
|
+
|
167
|
+
# Using a custom rollout with options.
|
168
|
+
rollout :feature_flag, flag_name: "my_feature_flag"
|
169
|
+
end
|
170
|
+
```
|
171
|
+
|
172
|
+
Custom rollouts can be registered to autoload as well, so they're only loaded when needed:
|
173
|
+
|
174
|
+
```ruby
|
175
|
+
ActiveExperiment::Rollouts.register(
|
176
|
+
:feature_flag,
|
177
|
+
"lib/feature_flag_rollout.rb"
|
178
|
+
)
|
179
|
+
```
|
180
|
+
|
181
|
+
There's a world of flexibility with custom rollouts. One creative and simple rollout concept is to use the experiment itself:
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
class MyExperiment < ActiveExperiment::Base
|
185
|
+
variant(:red) { "red" }
|
186
|
+
variant(:blue) { "blue" }
|
187
|
+
|
188
|
+
def self.skipped_for(*)
|
189
|
+
false
|
190
|
+
end
|
191
|
+
|
192
|
+
def self.variant_for(*)
|
193
|
+
variant_names.sample
|
194
|
+
end
|
195
|
+
|
196
|
+
use_rollout self
|
197
|
+
end
|
198
|
+
```
|
199
|
+
|
200
|
+
## Reporting
|
201
|
+
|
202
|
+
Reporting is a core concept in Active Experiment. It allows for collecting data about experiments and variants, and can be used to track performance metrics, analyze results, and more.
|
203
|
+
|
204
|
+
Some simple reporting strategies might simply be added to `after_run` callbacks, but more complex reporting strategies can be implemented using a subscriber.
|
205
|
+
|
206
|
+
A subscriber can be used to listen for experiment events and report them to a service. For example, here's a subscriber that reports to a fictional analytics service:
|
207
|
+
|
208
|
+
```ruby
|
209
|
+
class MyAnalyticsSubscriber
|
210
|
+
def process_run(event)
|
211
|
+
experiment = event.payload[:experiment]
|
212
|
+
return if experiment.skipped?
|
213
|
+
|
214
|
+
Analytics.report(
|
215
|
+
experiment.serialize,
|
216
|
+
error: event.payload[:exception_object]
|
217
|
+
)
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
MyAnalyticsSubscriber.attach_to(:active_experiment)
|
222
|
+
```
|
223
|
+
|
224
|
+
The following Active Experiment events are available for subscribers:
|
225
|
+
|
226
|
+
- `start_experiment` - The experiment has begun.
|
227
|
+
- `process_segment_callbacks` - The experiment has processed all segment rules. A variant may have been resolved through this step.
|
228
|
+
- `process_variant_steps` - An experiment variant has been run.
|
229
|
+
- `process_variant_callbacks` - The experiment has processed variant callbacks.
|
230
|
+
- `process_run_callbacks` - The experiment has processed run callbacks.
|
231
|
+
- `process_run` - The experiment has completed and can be reported on.
|
232
|
+
|
233
|
+
In each of these events, the experiment instance is available in the `event.payload` hash.
|
234
|
+
|
235
|
+
## Experiments in Views
|
236
|
+
|
237
|
+
Experiments can be used in views, just like in any other part of your application. Sometimes though, you might want to render markup inside your run block too, and to do this, you'll need to "capture" the experiment.
|
238
|
+
|
239
|
+
To accomplish this, you can ask the experiment to capture itself by providing the view scope. The following examples (HAML or ERB) help illustrate how to avoid duplicating markup within each variant block by putting it (the container div for instance) in the run block.
|
240
|
+
|
241
|
+
Remember to include the `ActiveExperiment::Capturable` module in your experiment class:
|
242
|
+
|
243
|
+
```ruby
|
244
|
+
class MyExperiment < ActiveExperiment::Base
|
245
|
+
include ActiveExperiment::Capturable
|
246
|
+
|
247
|
+
variant(:red) { "red" }
|
248
|
+
variant(:blue) { "blue" }
|
249
|
+
end
|
250
|
+
```
|
251
|
+
|
252
|
+
<details>
|
253
|
+
<summary>Expand HAML example</summary>
|
254
|
+
|
255
|
+
```haml
|
256
|
+
!= MyExperiment.set(capture: self).run(current_user) do |experiment|
|
257
|
+
%div.container
|
258
|
+
= experiment.on(:red) do
|
259
|
+
%button.red-pill Red
|
260
|
+
= experiment.on(:blue) do
|
261
|
+
%button.blue-pill Blue
|
262
|
+
```
|
263
|
+
</details>
|
264
|
+
|
265
|
+
<details>
|
266
|
+
<summary>Expand ERB example</summary>
|
267
|
+
|
268
|
+
```erb
|
269
|
+
<%== MyExperiment.set(capture: self).run do |experiment| %>
|
270
|
+
<div class="container">
|
271
|
+
<%= experiment.on(:red) do %>
|
272
|
+
<button class="red-pill">Red</button>
|
273
|
+
<% end %>
|
274
|
+
<%= experiment.on(:blue) do %>
|
275
|
+
<button class="blue-pill">Blue</button>
|
276
|
+
<% end %>
|
277
|
+
</div>
|
278
|
+
<% end %>
|
279
|
+
```
|
280
|
+
</details>
|
281
|
+
|
282
|
+
## Client Side Experimentation
|
283
|
+
|
284
|
+
While Active Experiment doesn't include any specific tooling for client side experimentation at this time, it does provide the ability to surface experiments in the client layer.
|
285
|
+
|
286
|
+
Whenever an experiment is run in the request lifecycle, it's stored so it can be provided to the client. This means that if an experiment is run in controller, a view, a helper, etc. it will be available to the client.
|
287
|
+
|
288
|
+
In the layout, the experiment data can be rendered as JSON for instance:
|
289
|
+
|
290
|
+
```erb
|
291
|
+
<title>My App</title>
|
292
|
+
<script>
|
293
|
+
window.experiments = <%== ActiveExperiment::Executed.to_json %>
|
294
|
+
</script>
|
295
|
+
```
|
296
|
+
|
297
|
+
Or each experiment can be iterated over and rendered individually:
|
298
|
+
|
299
|
+
```erb
|
300
|
+
<% ActiveExperiment::Executed.experiments.each do |experiment| %>
|
301
|
+
<meta name="<%= experiment.name %>" content="<%== experiment.serialize.to_json %>">
|
302
|
+
<% end %>
|
303
|
+
```
|
304
|
+
|
305
|
+
## Testing
|
306
|
+
|
307
|
+
Active Experiment provides a test helper that can be used to stub experiments and assert that the expected experiments have been run.
|
308
|
+
|
309
|
+
To use the test helper, include it in your test case:
|
310
|
+
|
311
|
+
```ruby
|
312
|
+
class MyTestCase < ActiveSupport::TestCase
|
313
|
+
include ActiveExperiment::TestHelper
|
314
|
+
end
|
315
|
+
```
|
316
|
+
|
317
|
+
Now you can stub experiments in your tests:
|
318
|
+
|
319
|
+
```ruby
|
320
|
+
test "stubbing experiments" do
|
321
|
+
stub_experiment(MyExperiment, :red) do
|
322
|
+
# Now all MyExperiment experiments will assign the :red variant.
|
323
|
+
end
|
324
|
+
|
325
|
+
stub_experiment(MyExperiment, skip: true) do
|
326
|
+
# Now all MyExperiment experiments be skipped.
|
327
|
+
end
|
328
|
+
end
|
329
|
+
```
|
330
|
+
|
331
|
+
Assertion helpers are also available:
|
332
|
+
|
333
|
+
```ruby
|
334
|
+
test "asserting experiments" do
|
335
|
+
# no experiments has been run
|
336
|
+
assert_no_experiments
|
337
|
+
|
338
|
+
MyExperiment.run(id: 1)
|
339
|
+
|
340
|
+
# 1 experiment has been run
|
341
|
+
assert_experiments 1
|
342
|
+
|
343
|
+
# 2 experiments expected within the block.
|
344
|
+
assert_experiments 2 do
|
345
|
+
MyExperiment.run(id: 2)
|
346
|
+
MyExperiment.run(id: 3)
|
347
|
+
end
|
348
|
+
|
349
|
+
# assert an experiment has been run with context.
|
350
|
+
assert_experiment_with(MyExperiment, context: { id: 1 })
|
351
|
+
|
352
|
+
# experiment with context, and a variant assigned expected within the block.
|
353
|
+
assert_experiment_with(MyExperiment, variant: :red, context: { id: 4 }) do
|
354
|
+
MyExperiment.set(variant: :red).run(id: 4)
|
355
|
+
end
|
356
|
+
end
|
357
|
+
```
|
358
|
+
|
359
|
+
RSpec support can be added by requiring `active_experiment/rspec` in the appropriate spec helper.
|
360
|
+
|
361
|
+
## GlobalID support
|
362
|
+
|
363
|
+
Active Experiment supports [GlobalID serialization](https://github.com/rails/globalid/) for experiment contexts. This is part of what makes it possible to utilize Active Record objects as context to consistently assign the same variant across multiple runs.
|
364
|
+
|
365
|
+
## License
|
366
|
+
|
367
|
+
Active Experiment is released under the MIT license:
|
368
|
+
|
369
|
+
* https://opensource.org/licenses/MIT
|
370
|
+
|
371
|
+
Copyright 2022 [jejacks0n](https://github.com/jejacks0n)
|
372
|
+
|
373
|
+
## Make Code Not War
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/module/attribute_accessors"
|
4
|
+
require "active_experiment"
|
5
|
+
require "active_experiment/log_subscriber"
|
6
|
+
require "active_experiment/core"
|
7
|
+
require "active_experiment/caching"
|
8
|
+
require "active_experiment/callbacks"
|
9
|
+
require "active_experiment/execution"
|
10
|
+
require "active_experiment/instrumentation"
|
11
|
+
require "active_experiment/logging"
|
12
|
+
require "active_experiment/rollout"
|
13
|
+
require "active_experiment/run_key"
|
14
|
+
require "active_experiment/segments"
|
15
|
+
require "active_experiment/variants"
|
16
|
+
|
17
|
+
module ActiveExperiment
|
18
|
+
# = Active Experiment
|
19
|
+
#
|
20
|
+
# Active Experiment is a library that helps with defining, running, and
|
21
|
+
# reporting on experiments.
|
22
|
+
#
|
23
|
+
# In general terms, defining an experiment is done by subclassing
|
24
|
+
# +ActiveExperiment::Base+ and using the +control+ and +variant+ methods to
|
25
|
+
# register the variants of the experiment. Variants are the names of the code
|
26
|
+
# paths that will deviate from one another.
|
27
|
+
#
|
28
|
+
# An example of an experiment definition might look like:
|
29
|
+
#
|
30
|
+
# class MyExperiment < ActiveExperiment::Base
|
31
|
+
# control { }
|
32
|
+
# variant(:red) { "red" }
|
33
|
+
# variant(:blue) { "blue" }
|
34
|
+
# end
|
35
|
+
#
|
36
|
+
# The term "control" is used to refer to the default variant, or when no
|
37
|
+
# other variant is assigned. Calling it the control is largely a convention
|
38
|
+
# and is not enforced by the library.
|
39
|
+
#
|
40
|
+
# Once an experiment has been defined, it can be run in various areas of an
|
41
|
+
# application. When running an experiment, variants can be overridden, which
|
42
|
+
# allows utilizing the scope and helpers available where the experiment is
|
43
|
+
# being run.
|
44
|
+
#
|
45
|
+
# For instance, within a view it can be useful to render different partials:
|
46
|
+
#
|
47
|
+
# MyExperiment.run(current_user) do |experiment|
|
48
|
+
# experiment.on(:red) { render partial: "red" }
|
49
|
+
# experiment.on(:blue) { render partial: "blue" }
|
50
|
+
# end
|
51
|
+
#
|
52
|
+
# Or within a controller, it can be useful to redirect to different paths:
|
53
|
+
#
|
54
|
+
# MyExperiment.run(current_user) do |experiment|
|
55
|
+
# experiment.on(:red) { redirect_to red_path }
|
56
|
+
# experiment.on(:blue) { redirect_to blue_path }
|
57
|
+
# end
|
58
|
+
#
|
59
|
+
# This approach allows for the same experiment to be run in different areas
|
60
|
+
# of an application, with consistent variant assignment, even if the
|
61
|
+
# experimental behavior is different in different parts of the application.
|
62
|
+
class Base
|
63
|
+
include Core
|
64
|
+
include Caching
|
65
|
+
include Callbacks
|
66
|
+
include Execution
|
67
|
+
include Instrumentation
|
68
|
+
include Logging
|
69
|
+
include Rollout
|
70
|
+
include RunKey
|
71
|
+
include Segments
|
72
|
+
include Variants
|
73
|
+
|
74
|
+
ActiveSupport.run_load_hooks(:active_experiment, self)
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "monitor"
|
4
|
+
require "active_record"
|
5
|
+
|
6
|
+
module ActiveExperiment
|
7
|
+
module Cache
|
8
|
+
# == Active Experiment Active Record Cache Store
|
9
|
+
#
|
10
|
+
# This cache store is an implementation on top of ActiveRecord and expects
|
11
|
+
# that the cache will live until the experiment is cleaned up and removed.
|
12
|
+
#
|
13
|
+
# This is a useful but not particularly performant cache store. It's useful
|
14
|
+
# because a lot of Rails projects already have a usable ActiveRecord
|
15
|
+
# connection, and because it's likely to be a long lived datastore.
|
16
|
+
#
|
17
|
+
# This cache store doesn't use a model class directly, and instead executes
|
18
|
+
# raw sql to minimize memory usage and allocations.
|
19
|
+
#
|
20
|
+
# The data structure:
|
21
|
+
# key: experiment name : run key
|
22
|
+
# value: cache entry
|
23
|
+
#
|
24
|
+
# To use this cache in an experiment the table needs to be created. All
|
25
|
+
# experiments will use the same table by default for their cache store, and
|
26
|
+
# can be distinguishable by the experiment name that's part of the cache
|
27
|
+
# key.
|
28
|
+
#
|
29
|
+
# create_table :active_experiment_cache_entries, id: false do |t|
|
30
|
+
# t.string :key, null: false
|
31
|
+
# t.string :value, null: false
|
32
|
+
# end
|
33
|
+
#
|
34
|
+
# add_index :active_experiment_cache_entries, :key, unique: true
|
35
|
+
#
|
36
|
+
# Once a table is created, the cache store can be used in an experiment:
|
37
|
+
#
|
38
|
+
# class MyExperiment < ActiveExperiment::Base
|
39
|
+
# variant(:red) { "red" }
|
40
|
+
# variant(:blue) { "blue" }
|
41
|
+
#
|
42
|
+
# use_cache_store :active_record
|
43
|
+
# end
|
44
|
+
class ActiveRecordCacheStore < ActiveSupport::Cache::Store
|
45
|
+
DEFAULT_TABLE_NAME = "active_experiment_cache_entries"
|
46
|
+
|
47
|
+
def initialize(options = nil)
|
48
|
+
super
|
49
|
+
@connection = ActiveRecord::Base.connection
|
50
|
+
end
|
51
|
+
|
52
|
+
def length(options = nil)
|
53
|
+
options = merged_options(options)
|
54
|
+
execute(<<~SQL).first["COUNT(key)"]
|
55
|
+
SELECT COUNT(key) FROM #{table_name(options)}
|
56
|
+
SQL
|
57
|
+
end
|
58
|
+
|
59
|
+
def clear(options = nil)
|
60
|
+
options = merged_options(options)
|
61
|
+
execute(<<~SQL)
|
62
|
+
DELETE FROM #{table_name(options)}
|
63
|
+
SQL
|
64
|
+
end
|
65
|
+
|
66
|
+
def delete_matched(matcher, options = nil)
|
67
|
+
options = merged_options(options)
|
68
|
+
execute(<<~SQL, key_matcher(matcher, options))
|
69
|
+
DELETE FROM #{table_name(options)} WHERE key LIKE $1
|
70
|
+
SQL
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
def read_entry(key, **options)
|
75
|
+
results = execute(<<~SQL, key)&.first.try(:[], "value")
|
76
|
+
SELECT value FROM #{table_name(options)} WHERE key = $1
|
77
|
+
SQL
|
78
|
+
|
79
|
+
deserialize_entry(results)
|
80
|
+
end
|
81
|
+
|
82
|
+
def write_entry(key, entry, **options)
|
83
|
+
return false if options[:unless_exist] && exist?(key, options)
|
84
|
+
|
85
|
+
execute(<<~SQL, key, serialize_entry(entry, **options))
|
86
|
+
INSERT INTO #{table_name(options)} (key, value) VALUES ($1, $2)
|
87
|
+
SQL
|
88
|
+
|
89
|
+
true
|
90
|
+
end
|
91
|
+
|
92
|
+
def delete_entry(key, **options)
|
93
|
+
execute(<<~SQL, key)
|
94
|
+
DELETE FROM #{table_name(options)} WHERE key = $1
|
95
|
+
SQL
|
96
|
+
|
97
|
+
true
|
98
|
+
end
|
99
|
+
|
100
|
+
def table_name(options)
|
101
|
+
options[:table_name] || DEFAULT_TABLE_NAME
|
102
|
+
end
|
103
|
+
|
104
|
+
def key_matcher(source, options)
|
105
|
+
source = "#{source}%"
|
106
|
+
|
107
|
+
return source unless options[:namespace]
|
108
|
+
namespace_key(source, options)
|
109
|
+
end
|
110
|
+
|
111
|
+
def execute(sql, *args, prepare: true, **kws, &block)
|
112
|
+
@connection.exec_query(sql, "SQL", args, prepare: prepare, **kws, &block)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/cache/redis_cache_store"
|
4
|
+
|
5
|
+
module ActiveExperiment
|
6
|
+
module Cache
|
7
|
+
# == Active Experiment Redis Hash Cache Store
|
8
|
+
#
|
9
|
+
# This cache store is an implementation on top of the redis hash data type
|
10
|
+
# (https://redis.io/docs/data-types/hashes/) and expects that the cache
|
11
|
+
# will live until the experiment is cleaned up and removed.
|
12
|
+
#
|
13
|
+
# This is a good cache store to use with Active Experiment because of the
|
14
|
+
# optimized way that redis stores hashes.
|
15
|
+
#
|
16
|
+
# The data structure:
|
17
|
+
# key: experiment name
|
18
|
+
# fields: key => entry
|
19
|
+
#
|
20
|
+
# To use this cache in an experiment:
|
21
|
+
#
|
22
|
+
# class MyExperiment < ActiveExperiment::Base
|
23
|
+
# variant(:red) { "red" }
|
24
|
+
# variant(:blue) { "blue" }
|
25
|
+
#
|
26
|
+
# use_cache_store :redis_hash
|
27
|
+
# end
|
28
|
+
class RedisHashCacheStore < ActiveSupport::Cache::RedisCacheStore
|
29
|
+
def length(hkey = nil)
|
30
|
+
if hkey
|
31
|
+
failsafe :read_hlen do
|
32
|
+
redis.then { |c| c.hlen(hkey) }
|
33
|
+
end
|
34
|
+
else
|
35
|
+
failsafe :read_dbsize do
|
36
|
+
redis.then { |c| c.dbsize }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
def hkey(key)
|
43
|
+
parts = key.to_s.split(":")
|
44
|
+
run_key = parts.pop
|
45
|
+
[Array(parts).join(":"), run_key]
|
46
|
+
end
|
47
|
+
|
48
|
+
def read_serialized_entry(key, raw: false, **options)
|
49
|
+
failsafe :read_entry do
|
50
|
+
redis.then { |c| c.hget(*hkey(key)) }
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def write_serialized_entry(key, payload, raw: false, unless_exist: false, expires_in: nil, race_condition_ttl: nil, pipeline: nil, **options)
|
55
|
+
# TODO: Support pipeline?
|
56
|
+
failsafe :write_entry, returning: false do
|
57
|
+
redis.then { |c| c.hset(*hkey(key), payload) }
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def delete_entry(key, **options)
|
62
|
+
failsafe :delete_entry, returning: false do
|
63
|
+
redis.then { |c| c.hdel(*hkey(key)) }
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveExperiment
|
4
|
+
# == Cache Stores
|
5
|
+
#
|
6
|
+
# TODO: finish documenting.
|
7
|
+
module Cache
|
8
|
+
extend ActiveSupport::Autoload
|
9
|
+
|
10
|
+
autoload :ActiveRecordCacheStore
|
11
|
+
autoload :RedisHashCacheStore
|
12
|
+
|
13
|
+
CACHE_STORE_SUFFIX = "CacheStore"
|
14
|
+
private_constant :CACHE_STORE_SUFFIX
|
15
|
+
|
16
|
+
# Allows looking up a cache store by name.
|
17
|
+
#
|
18
|
+
# Raises an +ArgumentError+ if the cache store isn't found.
|
19
|
+
def self.lookup(name, *args, **options)
|
20
|
+
const_get("#{name.to_s.camelize}#{CACHE_STORE_SUFFIX}").new(*args, **options)
|
21
|
+
rescue NameError
|
22
|
+
store = ActiveSupport::Cache.lookup_store(name, *args, **options)
|
23
|
+
raise ArgumentError, "No cache store found for #{name.inspect}" unless store
|
24
|
+
|
25
|
+
store
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|