activeexperiment 0.1.1.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 +29 -19
- 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 +3 -3
- 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 +17 -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,9 +1,11 @@
|
|
|
1
|
+
# Active Experiment – Decide what to do next
|
|
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
|
+
|
|
1
5
|
[](https://badge.fury.io/rb/activeexperiment)
|
|
2
|
-
[](https://codeclimate.com/github/jejacks0n/active_experiment/maintainability)
|
|
3
|
-
[](https://codeclimate.com/github/jejacks0n/active_experiment/test_coverage)
|
|
4
6
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
[](https://github.com/jejacks0n/activeexperiment/actions/workflows/ci.yml)
|
|
8
|
+
[](https://codecov.io/gh/jejacks0n/activeexperiment)
|
|
7
9
|
|
|
8
10
|
Active Experiment is a framework for defining and running experiments. It supports using a variety of rollout and reporting strategies and/or services.
|
|
9
11
|
|
|
@@ -13,10 +15,10 @@ Experimentation is complex. There are a lot of different ways to run experiments
|
|
|
13
15
|
|
|
14
16
|
## Usage
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
Define your experiments using easily testable classes:
|
|
17
19
|
|
|
18
20
|
```ruby
|
|
19
|
-
class MyExperiment <
|
|
21
|
+
class MyExperiment < ActiveExperiment::Base
|
|
20
22
|
variant(:red) { "red" }
|
|
21
23
|
variant(:blue) { "blue" }
|
|
22
24
|
end
|
|
@@ -28,13 +30,13 @@ This experiment can be generated using the Rails generator:
|
|
|
28
30
|
rails generate experiment my_experiment red blue
|
|
29
31
|
```
|
|
30
32
|
|
|
31
|
-
Run the experiment
|
|
33
|
+
Run the experiment with a context, like the current user, or the post being rendered:
|
|
32
34
|
|
|
33
35
|
```ruby
|
|
34
36
|
MyExperiment.run(current_user) # => "red" or "blue"
|
|
35
37
|
```
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
Optionally override the defaults using local scope and helpers:
|
|
38
40
|
|
|
39
41
|
```ruby
|
|
40
42
|
MyExperiment.run(current_user) do |experiment|
|
|
@@ -63,20 +65,20 @@ gem install activeexperiment
|
|
|
63
65
|
|
|
64
66
|
Source code can be downloaded as part of the project on GitHub:
|
|
65
67
|
|
|
66
|
-
* https://github.com/jejacks0n/
|
|
68
|
+
* https://github.com/jejacks0n/activeexperiment
|
|
67
69
|
|
|
68
70
|
Adapters can be added to integrate with various services:
|
|
69
71
|
|
|
70
72
|
- [Unleash adapter](https://github.com/jejacks0n/activeexperiment-unleash)
|
|
71
73
|
|
|
72
|
-
## Advanced
|
|
74
|
+
## Advanced experimentation
|
|
73
75
|
|
|
74
76
|
This area provides a high level overview of the tools that more complex experiments can benefit from.
|
|
75
77
|
|
|
76
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:
|
|
77
79
|
|
|
78
80
|
```ruby
|
|
79
|
-
class MyExperiment <
|
|
81
|
+
class MyExperiment < ActiveExperiment::Base
|
|
80
82
|
variant(:red) { "red" }
|
|
81
83
|
variant(:blue) { "blue" }
|
|
82
84
|
|
|
@@ -89,7 +91,7 @@ end
|
|
|
89
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:
|
|
90
92
|
|
|
91
93
|
```ruby
|
|
92
|
-
class MyExperiment <
|
|
94
|
+
class MyExperiment < ActiveExperiment::Base
|
|
93
95
|
control { "default" }
|
|
94
96
|
variant(:red) { "red" }
|
|
95
97
|
variant(:blue) { "blue" }
|
|
@@ -109,7 +111,7 @@ end
|
|
|
109
111
|
Segment rules can be used to assign specific variants for certain cases:
|
|
110
112
|
|
|
111
113
|
```ruby
|
|
112
|
-
class MyExperiment <
|
|
114
|
+
class MyExperiment < ActiveExperiment::Base
|
|
113
115
|
control { "default" }
|
|
114
116
|
variant(:red) { "red" }
|
|
115
117
|
variant(:blue) { "blue" }
|
|
@@ -138,7 +140,7 @@ A rollout can implement any number of different strategies, interact with servic
|
|
|
138
140
|
Here's an example of using the default percent rollout with custom distribution rules:
|
|
139
141
|
|
|
140
142
|
```ruby
|
|
141
|
-
class MyExperiment <
|
|
143
|
+
class MyExperiment < ActiveExperiment::Base
|
|
142
144
|
variant(:red) { "red" }
|
|
143
145
|
variant(:blue) { "blue" }
|
|
144
146
|
variant(:green) { "green" }
|
|
@@ -148,7 +150,7 @@ class MyExperiment < ApplicationExperiment
|
|
|
148
150
|
end
|
|
149
151
|
```
|
|
150
152
|
|
|
151
|
-
### Defining
|
|
153
|
+
### Defining custom rollouts
|
|
152
154
|
|
|
153
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.
|
|
154
156
|
|
|
@@ -241,7 +243,7 @@ The following Active Experiment events are available for subscribers:
|
|
|
241
243
|
|
|
242
244
|
In each of these events, the experiment instance is available in the `event.payload` hash.
|
|
243
245
|
|
|
244
|
-
## Experiments in
|
|
246
|
+
## Experiments in views
|
|
245
247
|
|
|
246
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.
|
|
247
249
|
|
|
@@ -301,7 +303,7 @@ If you don't need to capture the experiment, simply run like you would anywhere
|
|
|
301
303
|
<% end %>
|
|
302
304
|
```
|
|
303
305
|
|
|
304
|
-
## Client
|
|
306
|
+
## Client side experimentation
|
|
305
307
|
|
|
306
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.
|
|
307
309
|
|
|
@@ -384,12 +386,20 @@ RSpec support can be added by requiring `active_experiment/rspec` in the appropr
|
|
|
384
386
|
|
|
385
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.
|
|
386
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
|
+
|
|
387
396
|
## License
|
|
388
397
|
|
|
389
398
|
Active Experiment is released under the MIT license:
|
|
390
399
|
|
|
391
400
|
* https://opensource.org/licenses/MIT
|
|
392
401
|
|
|
393
|
-
Copyright 2022 [jejacks0n](https://github.com/jejacks0n)
|
|
402
|
+
Copyright 2022-2026 © [jejacks0n](https://github.com/jejacks0n)
|
|
403
|
+
|
|
404
|
+
## Make Code Not War ♥
|
|
394
405
|
|
|
395
|
-
## 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,11 +96,26 @@ 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
|
|
@@ -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: []
|