activeexperiment 0.1.0.alpha → 0.2.0
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 +4 -4
- data/CHANGELOG.md +61 -0
- data/README.md +60 -28
- data/lib/active_experiment/cache/active_record_cache_store.rb +47 -18
- data/lib/active_experiment/callbacks.rb +1 -1
- data/lib/active_experiment/capturable.rb +12 -2
- data/lib/active_experiment/core.rb +6 -6
- data/lib/active_experiment/execution.rb +7 -2
- data/lib/active_experiment/gem_version.rb +2 -2
- data/lib/active_experiment/railtie.rb +15 -16
- data/lib/active_experiment/rollout.rb +1 -1
- data/lib/active_experiment/rollouts/percent_rollout.rb +5 -5
- data/lib/active_experiment/rollouts/random_rollout.rb +2 -2
- data/lib/active_experiment/rollouts.rb +22 -1
- data/lib/active_experiment/run_key.rb +73 -13
- data/lib/active_experiment.rb +8 -0
- data/lib/rails/generators/test_unit/experiment/templates/experiment_test.rb.tt +1 -1
- metadata +14 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af34351515f9c77859089c5ac937f4fd7a7f241ee6fef23dcd59cc42150e90fb
|
|
4
|
+
data.tar.gz: e02cd7f96c2b8a758313956de1e7ebe49218d6fed28e8eaafe543e9af1f8f6fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33bcf6e7d09affba6e26a9048f22b951721e69dc5a98038debf923985be45b0c3a6edd4c70933f98b551348a6a33301aeb21e4d8b741a079e343eeb5b654cc97
|
|
7
|
+
data.tar.gz: 90843e649a0457470fd74c603b1936dca0bb06419fdca402bde3ead122b4d3a371a2088dee1fcdd4564b9f89bac61f9091d3c2498a1aa39b5bc20f92f15b33fc
|
data/CHANGELOG.md
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
### Breaking changes
|
|
6
|
+
|
|
7
|
+
**Variant assignment changes for every existing context.**
|
|
8
|
+
|
|
9
|
+
Run keys are computed differently in this release, and the percent rollout's
|
|
10
|
+
boundaries have shifted. Any context that has already been assigned a variant
|
|
11
|
+
may be assigned a different one after upgrading, and existing cache entries no
|
|
12
|
+
longer correspond to the keys that will be looked up.
|
|
13
|
+
|
|
14
|
+
If you have experiments in flight, treat this as the end of those experiments.
|
|
15
|
+
|
|
16
|
+
Collect your results, clear the experiment cache (`MyExperiment.clear_cache`),
|
|
17
|
+
and start new experiments fresh.
|
|
18
|
+
|
|
19
|
+
* Run keys are now computed from a context hash in a stable order. Previously
|
|
20
|
+
`{a: 1, b: 2}` and `{b: 2, a: 1}` produced different digests, so the same
|
|
21
|
+
context written two different ways would split a subject across variants and
|
|
22
|
+
across cache entries.
|
|
23
|
+
|
|
24
|
+
* Contexts containing objects that aren't `GlobalID::Identification` now raise
|
|
25
|
+
an `ArgumentError`. Such objects fell through to `inspect`, embedding a memory
|
|
26
|
+
address that changes every process — which silently destroyed both consistent
|
|
27
|
+
assignment and cache stability.
|
|
28
|
+
Set `ActiveExperiment::Base.unsafe_context_digest = true` to restore the
|
|
29
|
+
previous behavior if you don't want exceptions raised on this / want the
|
|
30
|
+
existing behavior.
|
|
31
|
+
|
|
32
|
+
* The percent rollout no longer over-allocates the first bucket and
|
|
33
|
+
under-allocates the last. Declared rules of `{control: 25, red: 30, blue: 45}`
|
|
34
|
+
previously distributed as `26/30/44`.
|
|
35
|
+
|
|
36
|
+
* Run key digests now include a version marker, so future changes to the key
|
|
37
|
+
algorithm are detectable rather than silent.
|
|
38
|
+
|
|
39
|
+
* `ActiveExperiment::Rollouts.lookup` no longer resolves constants it wasn't
|
|
40
|
+
given. Lookup fell through to the top level, so an unregistered `FooRollout`
|
|
41
|
+
defined anywhere in an application would answer `lookup(:foo)` and could be
|
|
42
|
+
used by `use_rollout :foo`. Rollouts now have to be registered. Registering by
|
|
43
|
+
path still works, including when the file defines the class at the top level.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
* The Railtie no longer calls `Rails.application.secrets`, which was removed in
|
|
48
|
+
Rails 7.2 and raised `NoMethodError` during initialization on Rails 7.2+.
|
|
49
|
+
|
|
50
|
+
* Active Experiment doesn't consider a missing `secret_key_base` an issue and
|
|
51
|
+
instead falls back to an unsalted digest rather than keeping the application
|
|
52
|
+
from booting over an optional feature.
|
|
53
|
+
|
|
54
|
+
* Compatibility with Ruby 3.4+ and Rails 8.1.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
* Minimum supported versions are now Ruby 3.2 and Active Support 7.1.
|
|
59
|
+
|
|
60
|
+
* Integration tests now run as part of `rake test`. They previously existed but
|
|
61
|
+
were never executed.
|
data/README.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Active Experiment – Decide what to do next
|
|
2
2
|
|
|
3
|
+
<img alt="Active Experiment" width="150" height="183" src="https://user-images.githubusercontent.com/13765/208318101-b48c9493-15ed-4a99-b42f-b20720dd7c77.png" align="right" hspace="20">
|
|
4
|
+
|
|
5
|
+
[](https://badge.fury.io/rb/activeexperiment)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://github.com/jejacks0n/activeexperiment/actions/workflows/ci.yml)
|
|
8
|
+
[](https://codecov.io/gh/jejacks0n/activeexperiment)
|
|
9
|
+
|
|
3
10
|
Active Experiment is a framework for defining and running experiments. It supports using a variety of rollout and reporting strategies and/or services.
|
|
4
11
|
|
|
5
12
|
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.
|
|
@@ -8,10 +15,10 @@ Experimentation is complex. There are a lot of different ways to run experiments
|
|
|
8
15
|
|
|
9
16
|
## Usage
|
|
10
17
|
|
|
11
|
-
|
|
18
|
+
Define your experiments using easily testable classes:
|
|
12
19
|
|
|
13
20
|
```ruby
|
|
14
|
-
class MyExperiment <
|
|
21
|
+
class MyExperiment < ActiveExperiment::Base
|
|
15
22
|
variant(:red) { "red" }
|
|
16
23
|
variant(:blue) { "blue" }
|
|
17
24
|
end
|
|
@@ -23,13 +30,13 @@ This experiment can be generated using the Rails generator:
|
|
|
23
30
|
rails generate experiment my_experiment red blue
|
|
24
31
|
```
|
|
25
32
|
|
|
26
|
-
Run the experiment
|
|
33
|
+
Run the experiment with a context, like the current user, or the post being rendered:
|
|
27
34
|
|
|
28
35
|
```ruby
|
|
29
36
|
MyExperiment.run(current_user) # => "red" or "blue"
|
|
30
37
|
```
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
Optionally override the defaults using local scope and helpers:
|
|
33
40
|
|
|
34
41
|
```ruby
|
|
35
42
|
MyExperiment.run(current_user) do |experiment|
|
|
@@ -60,14 +67,18 @@ Source code can be downloaded as part of the project on GitHub:
|
|
|
60
67
|
|
|
61
68
|
* https://github.com/jejacks0n/activeexperiment
|
|
62
69
|
|
|
63
|
-
|
|
70
|
+
Adapters can be added to integrate with various services:
|
|
71
|
+
|
|
72
|
+
- [Unleash adapter](https://github.com/jejacks0n/activeexperiment-unleash)
|
|
73
|
+
|
|
74
|
+
## Advanced experimentation
|
|
64
75
|
|
|
65
76
|
This area provides a high level overview of the tools that more complex experiments can benefit from.
|
|
66
77
|
|
|
67
78
|
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
79
|
|
|
69
80
|
```ruby
|
|
70
|
-
class MyExperiment <
|
|
81
|
+
class MyExperiment < ActiveExperiment::Base
|
|
71
82
|
variant(:red) { "red" }
|
|
72
83
|
variant(:blue) { "blue" }
|
|
73
84
|
|
|
@@ -80,7 +91,7 @@ end
|
|
|
80
91
|
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
92
|
|
|
82
93
|
```ruby
|
|
83
|
-
class MyExperiment <
|
|
94
|
+
class MyExperiment < ActiveExperiment::Base
|
|
84
95
|
control { "default" }
|
|
85
96
|
variant(:red) { "red" }
|
|
86
97
|
variant(:blue) { "blue" }
|
|
@@ -100,7 +111,7 @@ end
|
|
|
100
111
|
Segment rules can be used to assign specific variants for certain cases:
|
|
101
112
|
|
|
102
113
|
```ruby
|
|
103
|
-
class MyExperiment <
|
|
114
|
+
class MyExperiment < ActiveExperiment::Base
|
|
104
115
|
control { "default" }
|
|
105
116
|
variant(:red) { "red" }
|
|
106
117
|
variant(:blue) { "blue" }
|
|
@@ -129,7 +140,7 @@ A rollout can implement any number of different strategies, interact with servic
|
|
|
129
140
|
Here's an example of using the default percent rollout with custom distribution rules:
|
|
130
141
|
|
|
131
142
|
```ruby
|
|
132
|
-
class MyExperiment <
|
|
143
|
+
class MyExperiment < ActiveExperiment::Base
|
|
133
144
|
variant(:red) { "red" }
|
|
134
145
|
variant(:blue) { "blue" }
|
|
135
146
|
variant(:green) { "green" }
|
|
@@ -139,12 +150,14 @@ class MyExperiment < ApplicationExperiment
|
|
|
139
150
|
end
|
|
140
151
|
```
|
|
141
152
|
|
|
142
|
-
### Defining
|
|
153
|
+
### Defining custom rollouts
|
|
143
154
|
|
|
144
155
|
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
156
|
|
|
146
157
|
```ruby
|
|
147
158
|
class FeatureFlagRollout < ActiveExperiment::Rollouts::BaseRollout
|
|
159
|
+
register_as :feature_flag
|
|
160
|
+
|
|
148
161
|
def skipped_for(experiment)
|
|
149
162
|
!Feature.enabled?(@rollout_options[:flag_name] || experiment.name)
|
|
150
163
|
end
|
|
@@ -153,8 +166,6 @@ class FeatureFlagRollout < ActiveExperiment::Rollouts::BaseRollout
|
|
|
153
166
|
experiment.variant_names.sample
|
|
154
167
|
end
|
|
155
168
|
end
|
|
156
|
-
|
|
157
|
-
ActiveExperiment::Rollouts.register(:feature_flag, FeatureRollout)
|
|
158
169
|
```
|
|
159
170
|
|
|
160
171
|
This rollout can now be used the same way the built-in rollouts are:
|
|
@@ -165,7 +176,7 @@ class MyExperiment < ActiveExperiment::Base
|
|
|
165
176
|
variant(:blue) { "blue" }
|
|
166
177
|
|
|
167
178
|
# Using a custom rollout with options.
|
|
168
|
-
|
|
179
|
+
use_rollout :feature_flag, flag_name: "my_feature_flag"
|
|
169
180
|
end
|
|
170
181
|
```
|
|
171
182
|
|
|
@@ -206,7 +217,9 @@ Some simple reporting strategies might simply be added to `after_run` callbacks,
|
|
|
206
217
|
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
218
|
|
|
208
219
|
```ruby
|
|
209
|
-
class MyAnalyticsSubscriber
|
|
220
|
+
class MyAnalyticsSubscriber < ActiveSupport::Subscriber
|
|
221
|
+
attach_to :active_experiment
|
|
222
|
+
|
|
210
223
|
def process_run(event)
|
|
211
224
|
experiment = event.payload[:experiment]
|
|
212
225
|
return if experiment.skipped?
|
|
@@ -217,8 +230,6 @@ class MyAnalyticsSubscriber
|
|
|
217
230
|
)
|
|
218
231
|
end
|
|
219
232
|
end
|
|
220
|
-
|
|
221
|
-
MyAnalyticsSubscriber.attach_to(:active_experiment)
|
|
222
233
|
```
|
|
223
234
|
|
|
224
235
|
The following Active Experiment events are available for subscribers:
|
|
@@ -232,7 +243,7 @@ The following Active Experiment events are available for subscribers:
|
|
|
232
243
|
|
|
233
244
|
In each of these events, the experiment instance is available in the `event.payload` hash.
|
|
234
245
|
|
|
235
|
-
## Experiments in
|
|
246
|
+
## Experiments in views
|
|
236
247
|
|
|
237
248
|
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
249
|
|
|
@@ -266,7 +277,7 @@ end
|
|
|
266
277
|
<summary>Expand ERB example</summary>
|
|
267
278
|
|
|
268
279
|
```erb
|
|
269
|
-
<%== MyExperiment.set(capture: self).run do |experiment| %>
|
|
280
|
+
<%== MyExperiment.set(capture: self).run(current_user) do |experiment| %>
|
|
270
281
|
<div class="container">
|
|
271
282
|
<%= experiment.on(:red) do %>
|
|
272
283
|
<button class="red-pill">Red</button>
|
|
@@ -279,7 +290,20 @@ end
|
|
|
279
290
|
```
|
|
280
291
|
</details>
|
|
281
292
|
|
|
282
|
-
|
|
293
|
+
If you don't need to capture the experiment, simply run like you would anywhere else:
|
|
294
|
+
|
|
295
|
+
```erb
|
|
296
|
+
<% MyExperiment.run(current_user) do |experiment| %>
|
|
297
|
+
<% experiment.on(:red) do %>
|
|
298
|
+
<button class="red-pill">Red</button>
|
|
299
|
+
<% end %>
|
|
300
|
+
<% experiment.on(:blue) do %>
|
|
301
|
+
<button class="blue-pill">Blue</button>
|
|
302
|
+
<% end %>
|
|
303
|
+
<% end %>
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
## Client side experimentation
|
|
283
307
|
|
|
284
308
|
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
309
|
|
|
@@ -297,7 +321,7 @@ In the layout, the experiment data can be rendered as JSON for instance:
|
|
|
297
321
|
Or each experiment can be iterated over and rendered individually:
|
|
298
322
|
|
|
299
323
|
```erb
|
|
300
|
-
<% ActiveExperiment::Executed.
|
|
324
|
+
<% ActiveExperiment::Executed.as_array.each do |experiment| %>
|
|
301
325
|
<meta name="<%= experiment.name %>" content="<%== experiment.serialize.to_json %>">
|
|
302
326
|
<% end %>
|
|
303
327
|
```
|
|
@@ -323,7 +347,7 @@ test "stubbing experiments" do
|
|
|
323
347
|
end
|
|
324
348
|
|
|
325
349
|
stub_experiment(MyExperiment, skip: true) do
|
|
326
|
-
# Now all MyExperiment experiments be skipped.
|
|
350
|
+
# Now all MyExperiment experiments will be skipped.
|
|
327
351
|
end
|
|
328
352
|
end
|
|
329
353
|
```
|
|
@@ -332,24 +356,24 @@ Assertion helpers are also available:
|
|
|
332
356
|
|
|
333
357
|
```ruby
|
|
334
358
|
test "asserting experiments" do
|
|
335
|
-
# no experiments
|
|
359
|
+
# Assert that no experiments have been run.
|
|
336
360
|
assert_no_experiments
|
|
337
361
|
|
|
338
362
|
MyExperiment.run(id: 1)
|
|
339
363
|
|
|
340
|
-
# 1 experiment has been run
|
|
364
|
+
# Assert that 1 experiment has been run.
|
|
341
365
|
assert_experiments 1
|
|
342
366
|
|
|
343
|
-
#
|
|
367
|
+
# Assert that within the block, 2 experiments will be run.
|
|
344
368
|
assert_experiments 2 do
|
|
345
369
|
MyExperiment.run(id: 2)
|
|
346
370
|
MyExperiment.run(id: 3)
|
|
347
371
|
end
|
|
348
372
|
|
|
349
|
-
#
|
|
373
|
+
# Assert an experiment has been run with a given context.
|
|
350
374
|
assert_experiment_with(MyExperiment, context: { id: 1 })
|
|
351
375
|
|
|
352
|
-
#
|
|
376
|
+
# Assert that within the block, a matching experiment will be run.
|
|
353
377
|
assert_experiment_with(MyExperiment, variant: :red, context: { id: 4 }) do
|
|
354
378
|
MyExperiment.set(variant: :red).run(id: 4)
|
|
355
379
|
end
|
|
@@ -362,12 +386,20 @@ RSpec support can be added by requiring `active_experiment/rspec` in the appropr
|
|
|
362
386
|
|
|
363
387
|
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
388
|
|
|
389
|
+
## Similar and noteworthy projects
|
|
390
|
+
|
|
391
|
+
- [Vanity](https://vanity.labnotes.org/) - Experiment Driven Development framework for Rails.
|
|
392
|
+
- [Scientist](https://github.com/github/scientist) - A Ruby library for carefully refactoring critical paths.
|
|
393
|
+
- [Gitlab::Experiment](https://gitlab.com/gitlab-org/ruby/gems/gitlab-experiment) - A framework for running experiments, by GitLab.
|
|
394
|
+
- [Split](https://github.com/splitrb/split) - The Rack Based A/B testing framework.
|
|
395
|
+
|
|
365
396
|
## License
|
|
366
397
|
|
|
367
398
|
Active Experiment is released under the MIT license:
|
|
368
399
|
|
|
369
400
|
* https://opensource.org/licenses/MIT
|
|
370
401
|
|
|
371
|
-
Copyright 2022 [jejacks0n](https://github.com/jejacks0n)
|
|
402
|
+
Copyright 2022-2026 © [jejacks0n](https://github.com/jejacks0n)
|
|
403
|
+
|
|
404
|
+
## Make Code Not War ♥
|
|
372
405
|
|
|
373
|
-
## Make Code Not War
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "monitor"
|
|
4
3
|
require "active_record"
|
|
5
4
|
|
|
6
5
|
module ActiveExperiment
|
|
@@ -28,11 +27,30 @@ module ActiveExperiment
|
|
|
28
27
|
#
|
|
29
28
|
# create_table :active_experiment_cache_entries, id: false do |t|
|
|
30
29
|
# t.string :key, null: false
|
|
31
|
-
# t.
|
|
30
|
+
# t.binary :value, null: false
|
|
32
31
|
# end
|
|
33
32
|
#
|
|
34
33
|
# add_index :active_experiment_cache_entries, :key, unique: true
|
|
35
34
|
#
|
|
35
|
+
# == Database Support
|
|
36
|
+
#
|
|
37
|
+
# The value column is binary because that's what entries serialize to -- a
|
|
38
|
+
# payload that leads with a null byte and isn't necessarily valid in the
|
|
39
|
+
# connection's encoding. PostgreSQL rejects that outright in a text or
|
|
40
|
+
# varchar column, and MySQL will too unless the column is binary.
|
|
41
|
+
#
|
|
42
|
+
# A +t.string :value+ column still works on SQLite, which doesn't enforce
|
|
43
|
+
# column types, so tables created before this was documented don't need
|
|
44
|
+
# migrating. Anything else wants +t.binary+.
|
|
45
|
+
#
|
|
46
|
+
# The statements are written with +?+ placeholders and quoted through the
|
|
47
|
+
# connection, so they don't depend on a particular adapter's bind syntax.
|
|
48
|
+
#
|
|
49
|
+
# SQLite and PostgreSQL are both covered by the test suite, the latter
|
|
50
|
+
# against a real server in CI. MySQL should work on the same basis -- it
|
|
51
|
+
# takes the same +x'..'+ binary literal SQLite does -- but nothing verifies
|
|
52
|
+
# that, so treat it as untested.
|
|
53
|
+
#
|
|
36
54
|
# Once a table is created, the cache store can be used in an experiment:
|
|
37
55
|
#
|
|
38
56
|
# class MyExperiment < ActiveExperiment::Base
|
|
@@ -44,15 +62,10 @@ module ActiveExperiment
|
|
|
44
62
|
class ActiveRecordCacheStore < ActiveSupport::Cache::Store
|
|
45
63
|
DEFAULT_TABLE_NAME = "active_experiment_cache_entries"
|
|
46
64
|
|
|
47
|
-
def initialize(options = nil)
|
|
48
|
-
super
|
|
49
|
-
@connection = ActiveRecord::Base.connection
|
|
50
|
-
end
|
|
51
|
-
|
|
52
65
|
def length(options = nil)
|
|
53
66
|
options = merged_options(options)
|
|
54
|
-
execute(<<~SQL).first["
|
|
55
|
-
SELECT COUNT(key) FROM #{table_name(options)}
|
|
67
|
+
execute(<<~SQL).first["count"]
|
|
68
|
+
SELECT COUNT(key) AS count FROM #{table_name(options)}
|
|
56
69
|
SQL
|
|
57
70
|
end
|
|
58
71
|
|
|
@@ -66,24 +79,24 @@ module ActiveExperiment
|
|
|
66
79
|
def delete_matched(matcher, options = nil)
|
|
67
80
|
options = merged_options(options)
|
|
68
81
|
execute(<<~SQL, key_matcher(matcher, options))
|
|
69
|
-
DELETE FROM #{table_name(options)} WHERE key LIKE
|
|
82
|
+
DELETE FROM #{table_name(options)} WHERE key LIKE ?
|
|
70
83
|
SQL
|
|
71
84
|
end
|
|
72
85
|
|
|
73
86
|
private
|
|
74
87
|
def read_entry(key, **options)
|
|
75
|
-
|
|
76
|
-
SELECT value FROM #{table_name(options)} WHERE key =
|
|
88
|
+
result = execute(<<~SQL, key)
|
|
89
|
+
SELECT value FROM #{table_name(options)} WHERE key = ?
|
|
77
90
|
SQL
|
|
78
91
|
|
|
79
|
-
deserialize_entry(
|
|
92
|
+
deserialize_entry(column_value(result, "value"))
|
|
80
93
|
end
|
|
81
94
|
|
|
82
95
|
def write_entry(key, entry, **options)
|
|
83
96
|
return false if options[:unless_exist] && exist?(key, options)
|
|
84
97
|
|
|
85
|
-
execute(<<~SQL, key, serialize_entry(entry, **options))
|
|
86
|
-
INSERT INTO #{table_name(options)} (key, value) VALUES (
|
|
98
|
+
execute(<<~SQL, key, binary(serialize_entry(entry, **options)))
|
|
99
|
+
INSERT INTO #{table_name(options)} (key, value) VALUES (?, ?)
|
|
87
100
|
SQL
|
|
88
101
|
|
|
89
102
|
true
|
|
@@ -91,7 +104,7 @@ module ActiveExperiment
|
|
|
91
104
|
|
|
92
105
|
def delete_entry(key, **options)
|
|
93
106
|
execute(<<~SQL, key)
|
|
94
|
-
DELETE FROM #{table_name(options)} WHERE key =
|
|
107
|
+
DELETE FROM #{table_name(options)} WHERE key = ?
|
|
95
108
|
SQL
|
|
96
109
|
|
|
97
110
|
true
|
|
@@ -108,8 +121,24 @@ module ActiveExperiment
|
|
|
108
121
|
namespace_key(source, options)
|
|
109
122
|
end
|
|
110
123
|
|
|
111
|
-
def
|
|
112
|
-
|
|
124
|
+
def column_value(result, column)
|
|
125
|
+
row = result&.first
|
|
126
|
+
return if row.nil?
|
|
127
|
+
|
|
128
|
+
type = result.column_types[column]
|
|
129
|
+
type ? type.deserialize(row[column]) : row[column]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def binary(payload)
|
|
133
|
+
ActiveRecord::Type::Binary::Data.new(payload)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def execute(sql, *binds)
|
|
137
|
+
sql = ActiveRecord::Base.sanitize_sql_array([sql, *binds]) if binds.any?
|
|
138
|
+
|
|
139
|
+
ActiveRecord::Base.connection_pool.with_connection do |connection|
|
|
140
|
+
connection.exec_query(sql, "ActiveExperiment::Cache")
|
|
141
|
+
end
|
|
113
142
|
end
|
|
114
143
|
end
|
|
115
144
|
end
|
|
@@ -60,7 +60,7 @@ module ActiveExperiment
|
|
|
60
60
|
|
|
61
61
|
included do
|
|
62
62
|
define_callbacks :run, skip_after_callbacks_if_terminated: true
|
|
63
|
-
private :__callbacks, :
|
|
63
|
+
private :__callbacks, :run_callbacks, :_run_callbacks, :_run_run_callbacks
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
# These methods will be included into any Active Experiment object, adding
|
|
@@ -82,20 +82,30 @@ module ActiveExperiment
|
|
|
82
82
|
def on(*variant_names, &block)
|
|
83
83
|
super
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
variant_names.map { |variant| capture_placeholder(variant) }.join
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
def run(&block)
|
|
89
89
|
super
|
|
90
90
|
|
|
91
91
|
if capturable?
|
|
92
|
-
@results = @capture.to_s.gsub(
|
|
92
|
+
@results = @capture.to_s.gsub(capture_placeholder_pattern) do
|
|
93
|
+
$1 == variant.to_s ? @results : ""
|
|
94
|
+
end
|
|
93
95
|
else
|
|
94
96
|
@results
|
|
95
97
|
end
|
|
96
98
|
end
|
|
97
99
|
|
|
98
100
|
private
|
|
101
|
+
def capture_placeholder(variant)
|
|
102
|
+
"{{#{run_id}:#{variant}}}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def capture_placeholder_pattern
|
|
106
|
+
/\{\{#{Regexp.escape(run_id)}:(\w+)\}\}/
|
|
107
|
+
end
|
|
108
|
+
|
|
99
109
|
def resolve_results
|
|
100
110
|
@results = capture { super }
|
|
101
111
|
end
|
|
@@ -156,15 +156,15 @@ module ActiveExperiment
|
|
|
156
156
|
# experiment.
|
|
157
157
|
#
|
|
158
158
|
# Calling this before the variant has been assigned or resolved will result
|
|
159
|
-
# in the variant being
|
|
159
|
+
# in the variant being nil. Generally, this should be called after the
|
|
160
160
|
# experiment has been run.
|
|
161
161
|
def serialize
|
|
162
162
|
{
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
163
|
+
experiment: name,
|
|
164
|
+
run_id: run_id,
|
|
165
|
+
run_key: run_key,
|
|
166
|
+
variant: variant,
|
|
167
|
+
skipped: skipped?
|
|
168
168
|
}
|
|
169
169
|
end
|
|
170
170
|
|
|
@@ -106,9 +106,14 @@ module ActiveExperiment
|
|
|
106
106
|
# experiment.on(:treatment) { "treatment" }
|
|
107
107
|
# end
|
|
108
108
|
#
|
|
109
|
+
# Running an experiment that's already been run returns the result of the
|
|
110
|
+
# first run instead of running it again, and the block isn't called. That
|
|
111
|
+
# makes +run+ safe to call more than once -- repeatedly in a view, or by
|
|
112
|
+
# accident from within a run or variant block -- without resolving a second
|
|
113
|
+
# variant or recording a second execution.
|
|
114
|
+
#
|
|
109
115
|
# Raises an ActiveExperiment::ExecutionError if there are no variants
|
|
110
|
-
# registered
|
|
111
|
-
# accidentally calling run again within a run or variant block.
|
|
116
|
+
# registered.
|
|
112
117
|
def run(&block)
|
|
113
118
|
return @results if defined?(@results)
|
|
114
119
|
raise ExecutionError, "No variants registered" if variant_names.empty?
|
|
@@ -5,6 +5,12 @@ require "active_experiment"
|
|
|
5
5
|
|
|
6
6
|
module ActiveExperiment
|
|
7
7
|
class Railtie < Rails::Railtie # :nodoc:
|
|
8
|
+
def self.default_digest_secret_key(app)
|
|
9
|
+
app.secret_key_base
|
|
10
|
+
rescue ArgumentError
|
|
11
|
+
nil
|
|
12
|
+
end
|
|
13
|
+
|
|
8
14
|
config.active_experiment = ActiveSupport::OrderedOptions.new
|
|
9
15
|
config.active_experiment.custom_rollouts = {}
|
|
10
16
|
config.active_experiment.log_query_tags_around_run = true
|
|
@@ -24,23 +30,16 @@ module ActiveExperiment
|
|
|
24
30
|
initializer "active_experiment.set_configs" do |app|
|
|
25
31
|
options = app.config.active_experiment
|
|
26
32
|
config.after_initialize do
|
|
27
|
-
options.digest_secret_key ||= app
|
|
28
|
-
|
|
29
|
-
options.each do |k, v|
|
|
30
|
-
k = "#{k}="
|
|
31
|
-
if ActiveExperiment.respond_to?(k)
|
|
32
|
-
ActiveExperiment.send(k, v)
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
33
|
+
options.digest_secret_key ||= Railtie.default_digest_secret_key(app)
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
ActiveSupport.on_load(:active_experiment) do
|
|
36
|
+
options.each do |k, v|
|
|
37
|
+
setter = "#{k}="
|
|
38
|
+
if ActiveExperiment.respond_to?(setter)
|
|
39
|
+
ActiveExperiment.send(setter, v)
|
|
40
|
+
elsif respond_to?(setter)
|
|
41
|
+
send(setter, v)
|
|
42
|
+
end
|
|
44
43
|
end
|
|
45
44
|
end
|
|
46
45
|
end
|
|
@@ -20,7 +20,7 @@ module ActiveExperiment
|
|
|
20
20
|
# variant(:red) { "red" }
|
|
21
21
|
# variant(:blue) { "blue" }
|
|
22
22
|
#
|
|
23
|
-
#
|
|
23
|
+
# use_rollout :percent, rules: { blue: 60, red: 40 }
|
|
24
24
|
# end
|
|
25
25
|
#
|
|
26
26
|
# An experiment might even be configured to use itself as a rollout. As
|
|
@@ -21,13 +21,13 @@ module ActiveExperiment
|
|
|
21
21
|
# variant(:blue) { }
|
|
22
22
|
#
|
|
23
23
|
# # Assign even distribution to all variants.
|
|
24
|
-
#
|
|
24
|
+
# use_rollout :percent
|
|
25
25
|
#
|
|
26
26
|
# # Assign 25% to control, 30% to red, and 45% to blue.
|
|
27
|
-
#
|
|
27
|
+
# use_rollout :percent, rules: {control: 25, red: 30, blue: 45}
|
|
28
28
|
#
|
|
29
29
|
# # Same as above, but using an array.
|
|
30
|
-
#
|
|
30
|
+
# use_rollout :percent, rules: [25, 30, 45]
|
|
31
31
|
# end
|
|
32
32
|
#
|
|
33
33
|
# To use as the default, configure it to +:percent+.
|
|
@@ -47,8 +47,8 @@ module ActiveExperiment
|
|
|
47
47
|
total = 0
|
|
48
48
|
|
|
49
49
|
case rules
|
|
50
|
-
when Array then variants[rules.find_index { |percent| crc % 100
|
|
51
|
-
when Hash then rules.find { |_, percent| crc % 100
|
|
50
|
+
when Array then variants[rules.find_index { |percent| crc % 100 < total += percent }]
|
|
51
|
+
when Hash then rules.find { |_, percent| crc % 100 < total += percent }.first
|
|
52
52
|
else variants[crc % variants.length]
|
|
53
53
|
end
|
|
54
54
|
end
|
|
@@ -22,10 +22,10 @@ module ActiveExperiment
|
|
|
22
22
|
# variant(:blue) { }
|
|
23
23
|
#
|
|
24
24
|
# # Randomize between all variants, every run.
|
|
25
|
-
#
|
|
25
|
+
# use_rollout :random
|
|
26
26
|
#
|
|
27
27
|
# # Random, but once assigned, cache the assignment.
|
|
28
|
-
#
|
|
28
|
+
# use_rollout :random, cache: true
|
|
29
29
|
# end
|
|
30
30
|
#
|
|
31
31
|
# To use as the default, configure it to +:random+.
|
|
@@ -83,6 +83,7 @@ module ActiveExperiment
|
|
|
83
83
|
const_name = "#{name.to_s.camelize}#{ROLLOUT_SUFFIX}"
|
|
84
84
|
case rollout
|
|
85
85
|
when String, Pathname
|
|
86
|
+
registered_by_path << const_name
|
|
86
87
|
autoload(const_name, rollout)
|
|
87
88
|
when Class
|
|
88
89
|
const_set(const_name, rollout)
|
|
@@ -95,17 +96,37 @@ module ActiveExperiment
|
|
|
95
96
|
#
|
|
96
97
|
# Raises an +ArgumentError+ if the rollout hasn't been registered.
|
|
97
98
|
def self.lookup(name)
|
|
98
|
-
|
|
99
|
+
const_name = "#{name.to_s.camelize}#{ROLLOUT_SUFFIX}"
|
|
100
|
+
|
|
101
|
+
# Scoped to constants on this module, so an unrelated top level
|
|
102
|
+
# FooRollout can't answer lookup(:foo) without having been registered.
|
|
103
|
+
# Rollouts registered by path are the exception -- the file they point at
|
|
104
|
+
# usually defines the class at the top level, so the constant isn't here
|
|
105
|
+
# once it loads, and the autoload entry that was is gone.
|
|
106
|
+
unless const_defined?(const_name, false) || registered_by_path.include?(const_name)
|
|
107
|
+
raise ArgumentError, "No rollout registered for #{name.inspect}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
const_get(const_name)
|
|
99
111
|
rescue NameError
|
|
100
112
|
raise ArgumentError, "No rollout registered for #{name.inspect}"
|
|
101
113
|
end
|
|
102
114
|
|
|
115
|
+
def self.registered_by_path # :nodoc:
|
|
116
|
+
@registered_by_path ||= []
|
|
117
|
+
end
|
|
118
|
+
|
|
103
119
|
# Base class for the included rollouts. Useful for custom rollouts.
|
|
104
120
|
#
|
|
105
121
|
# Any rollout that inherits from this class will be valid, not skipped, and
|
|
106
122
|
# will assign the first defined variant unless the provided methods are
|
|
107
123
|
# overridden.
|
|
108
124
|
class BaseRollout
|
|
125
|
+
# Convenience method to register the rollout with Active Experiment.
|
|
126
|
+
def self.register_as(name)
|
|
127
|
+
Rollouts.register(name, self)
|
|
128
|
+
end
|
|
129
|
+
|
|
109
130
|
def initialize(experiment_class, *args, **options, &block) # :nodoc:
|
|
110
131
|
@experiment_class = experiment_class
|
|
111
132
|
@rollout_args = args
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "date"
|
|
3
4
|
require "digest/sha2"
|
|
4
5
|
|
|
5
6
|
module ActiveExperiment
|
|
@@ -9,12 +10,31 @@ module ActiveExperiment
|
|
|
9
10
|
# is generally referred to as the run key and can be used as the cache key
|
|
10
11
|
# and for variant assignment.
|
|
11
12
|
#
|
|
13
|
+
# For this to be meaningful, the digest has to be stable, meaning that the
|
|
14
|
+
# same context must produce the same run key in every process, forever. Two
|
|
15
|
+
# rules follow from that:
|
|
16
|
+
#
|
|
17
|
+
# - Hashes are digested in a sorted order, so the order the keys happen to be
|
|
18
|
+
# written in at a given call site can't change the result, so these would
|
|
19
|
+
# produce the same run key:
|
|
20
|
+
#
|
|
21
|
+
# MyExperiment.run(account: account, user: user)
|
|
22
|
+
# MyExperiment.run(user: user, account: account)
|
|
23
|
+
#
|
|
24
|
+
# - And objects that can't be identified stably are rejected rather than
|
|
25
|
+
# silently digested by their +inspect+ output, which embeds a memory
|
|
26
|
+
# address that changes every process. Contexts should be built from
|
|
27
|
+
# +GlobalID::Identification+ objects (any Active Record model) and
|
|
28
|
+
# primitives. You can disable this by configuring:
|
|
29
|
+
#
|
|
30
|
+
# ActiveExperiment::Base.unsafe_context_digest = true
|
|
31
|
+
#
|
|
12
32
|
# You can configure the details used in generating the digest by specifying a
|
|
13
33
|
# secret key and a bit length. The secret key is used to salt the digest, and
|
|
14
34
|
# the bit length is used to determine the length of the digest.
|
|
15
35
|
#
|
|
16
|
-
# The secret key will default to +Rails.application.
|
|
17
|
-
#
|
|
36
|
+
# The secret key will default to +Rails.application.secret_key_base+ when
|
|
37
|
+
# possible, and can be configured by:
|
|
18
38
|
#
|
|
19
39
|
# ActiveExperiment::Base.digest_secret_key = ENV["AE_SECRET_KEY"]
|
|
20
40
|
#
|
|
@@ -25,31 +45,71 @@ module ActiveExperiment
|
|
|
25
45
|
module RunKey
|
|
26
46
|
extend ActiveSupport::Concern
|
|
27
47
|
|
|
48
|
+
# Included in every digest, and bumped whenever the way run keys are
|
|
49
|
+
# generated changes. Without it a change to the algorithm would silently
|
|
50
|
+
# reassign variants with no way to tell old keys from new ones.
|
|
51
|
+
DIGEST_VERSION = "2"
|
|
52
|
+
private_constant :DIGEST_VERSION
|
|
53
|
+
|
|
54
|
+
# Types that inspect to the same string in every process, and so can be
|
|
55
|
+
# digested directly.
|
|
56
|
+
STABLE_TYPES = [
|
|
57
|
+
NilClass, TrueClass, FalseClass, String, Symbol, Numeric, Time, Date
|
|
58
|
+
].freeze
|
|
59
|
+
private_constant :STABLE_TYPES
|
|
60
|
+
|
|
28
61
|
included do
|
|
29
62
|
class_attribute :digest_secret_key, instance_writer: false, instance_predicate: false
|
|
30
63
|
class_attribute :digest_bit_length, instance_writer: false, instance_predicate: false, default: 256
|
|
31
|
-
|
|
64
|
+
class_attribute :unsafe_context_digest, instance_writer: false, instance_predicate: false, default: false
|
|
65
|
+
private :digest_secret_key, :digest_bit_length, :unsafe_context_digest
|
|
32
66
|
end
|
|
33
67
|
|
|
34
68
|
private
|
|
35
69
|
def run_key_hexdigest(source)
|
|
36
|
-
|
|
37
|
-
ingredients = Array(source).map { |value| identify_object(value).inspect }
|
|
38
|
-
ingredients.unshift(name, digest_secret_key)
|
|
70
|
+
ingredients = [DIGEST_VERSION, name, digest_secret_key, digest_ingredient(source)]
|
|
39
71
|
|
|
40
72
|
::Digest::SHA2.new(digest_bit_length).hexdigest(ingredients.join("|"))
|
|
41
73
|
end
|
|
42
74
|
|
|
43
|
-
|
|
44
|
-
|
|
75
|
+
# Renders a value as a string that's identical for equivalent contexts.
|
|
76
|
+
def digest_ingredient(value)
|
|
77
|
+
case value
|
|
78
|
+
when Hash
|
|
79
|
+
# Sorted, so key order at the call site can't change the digest.
|
|
80
|
+
pairs = value.map { |k, v| "#{digest_ingredient(k)}=>#{digest_ingredient(v)}" }
|
|
81
|
+
"{#{pairs.sort.join(",")}}"
|
|
82
|
+
when Array
|
|
83
|
+
# Not sorted: order is meaningful in an array.
|
|
84
|
+
"[#{value.map { |v| digest_ingredient(v) }.join(",")}]"
|
|
45
85
|
when GlobalID::Identification
|
|
46
|
-
|
|
86
|
+
global_id_ingredient(value)
|
|
87
|
+
when *STABLE_TYPES
|
|
88
|
+
value.inspect
|
|
47
89
|
else
|
|
48
|
-
|
|
49
|
-
# e.g. `#<User:0x00007f9b0a0b0e60>` is going to change every run,
|
|
50
|
-
# and we don't want that to happen by accident.
|
|
51
|
-
arg
|
|
90
|
+
unstable_ingredient(value)
|
|
52
91
|
end
|
|
53
92
|
end
|
|
93
|
+
|
|
94
|
+
def global_id_ingredient(value)
|
|
95
|
+
value.to_global_id.to_s
|
|
96
|
+
rescue StandardError => error
|
|
97
|
+
# Most often a record that hasn't been saved yet, and so has no id to
|
|
98
|
+
# identify it by. Whatever the reason, there's no stable identity here.
|
|
99
|
+
unstable_ingredient(value, because: "#{error.class}: #{error.message}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def unstable_ingredient(value, because: nil)
|
|
103
|
+
return value.inspect if unsafe_context_digest
|
|
104
|
+
|
|
105
|
+
raise ArgumentError, <<~MESSAGE.squish
|
|
106
|
+
Unable to generate a stable run key from the #{value.class} in the
|
|
107
|
+
#{name} context#{because ? " (#{because})" : ""}. Experiment contexts
|
|
108
|
+
should be built from GlobalID::Identification objects and primitives.
|
|
109
|
+
Set `ActiveExperiment::Base.unsafe_context_digest = true` to fall back
|
|
110
|
+
to the object's inspect output instead, accepting that the run key
|
|
111
|
+
can change every process and variant assignment won't be consistent.
|
|
112
|
+
MESSAGE
|
|
113
|
+
end
|
|
54
114
|
end
|
|
55
115
|
end
|
data/lib/active_experiment.rb
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# GlobalID's URI parsing calls CGI.unescape without requiring it. Ruby 4.0
|
|
4
|
+
# removed the CGI library, leaving cgi/escape as the only place that method
|
|
5
|
+
# lives; before 4.0 cgi/escape can't stand on its own, so cgi/util is needed.
|
|
6
|
+
if RUBY_VERSION >= "4.0"
|
|
7
|
+
require "cgi/escape"
|
|
8
|
+
else
|
|
9
|
+
require "cgi/util"
|
|
10
|
+
end
|
|
3
11
|
require "global_id"
|
|
4
12
|
require "active_support"
|
|
5
13
|
require "active_support/rails"
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activeexperiment
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jeremy Jackson
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: activesupport
|
|
@@ -16,14 +15,14 @@ dependencies:
|
|
|
16
15
|
requirements:
|
|
17
16
|
- - ">="
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 7.
|
|
18
|
+
version: '7.1'
|
|
20
19
|
type: :runtime
|
|
21
20
|
prerelease: false
|
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
22
|
requirements:
|
|
24
23
|
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 7.
|
|
25
|
+
version: '7.1'
|
|
27
26
|
- !ruby/object:Gem::Dependency
|
|
28
27
|
name: globalid
|
|
29
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -86,17 +85,16 @@ files:
|
|
|
86
85
|
- lib/rails/generators/rspec/experiment/templates/experiment_spec.rb.tt
|
|
87
86
|
- lib/rails/generators/test_unit/experiment/experiment_generator.rb
|
|
88
87
|
- lib/rails/generators/test_unit/experiment/templates/experiment_test.rb.tt
|
|
89
|
-
homepage: https://github.com/jejacks0n/
|
|
88
|
+
homepage: https://github.com/jejacks0n/activeexperiment
|
|
90
89
|
licenses:
|
|
91
90
|
- MIT
|
|
92
91
|
metadata:
|
|
93
|
-
homepage_uri: https://github.com/jejacks0n/
|
|
94
|
-
source_code_uri: https://github.com/jejacks0n/
|
|
95
|
-
bug_tracker_uri: https://github.com/jejacks0n/
|
|
96
|
-
changelog_uri: https://github.com/jejacks0n/
|
|
97
|
-
documentation_uri: https://github.com/jejacks0n/
|
|
92
|
+
homepage_uri: https://github.com/jejacks0n/activeexperiment
|
|
93
|
+
source_code_uri: https://github.com/jejacks0n/activeexperiment
|
|
94
|
+
bug_tracker_uri: https://github.com/jejacks0n/activeexperiment/issues
|
|
95
|
+
changelog_uri: https://github.com/jejacks0n/activeexperiment/CHANGELOG.md
|
|
96
|
+
documentation_uri: https://github.com/jejacks0n/activeexperiment/README.md
|
|
98
97
|
rubygems_mfa_required: 'true'
|
|
99
|
-
post_install_message:
|
|
100
98
|
rdoc_options: []
|
|
101
99
|
require_paths:
|
|
102
100
|
- lib
|
|
@@ -104,15 +102,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
104
102
|
requirements:
|
|
105
103
|
- - ">="
|
|
106
104
|
- !ruby/object:Gem::Version
|
|
107
|
-
version: 2.
|
|
105
|
+
version: 3.2.0
|
|
108
106
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
109
107
|
requirements:
|
|
110
|
-
- - "
|
|
108
|
+
- - ">="
|
|
111
109
|
- !ruby/object:Gem::Version
|
|
112
|
-
version:
|
|
110
|
+
version: '0'
|
|
113
111
|
requirements: []
|
|
114
|
-
rubygems_version:
|
|
115
|
-
signing_key:
|
|
112
|
+
rubygems_version: 4.0.19
|
|
116
113
|
specification_version: 4
|
|
117
114
|
summary: Experiment framework with pluggable rollouts and instrumentation.
|
|
118
115
|
test_files: []
|