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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5809078002c2de223c3c7f1017304defd4a1c7aaa3f9ac53fc7446980657ef14
4
- data.tar.gz: 0c954cec3f8bd0af0c399ec4e3af1f2e63a4e7841df961a2df37f761bedaa682
3
+ metadata.gz: af34351515f9c77859089c5ac937f4fd7a7f241ee6fef23dcd59cc42150e90fb
4
+ data.tar.gz: e02cd7f96c2b8a758313956de1e7ebe49218d6fed28e8eaafe543e9af1f8f6fb
5
5
  SHA512:
6
- metadata.gz: b05783407e7c4bda3a6833154949250a654ccfcaec30a25330bf9407d89cd613986fbe80660913c704b266329a756626ba09b5d065826cc7d3968f594b0a32e6
7
- data.tar.gz: 649646685dcf34cc67fc8c7de2a02dd9067778382e3266ff87f25c69a4f0256eca210d0c3d1f039b5491fbab07122070338f9de89bcf5cd70b6b55e4d4ecde95
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
+ [![Gem Version](https://badge.fury.io/rb/activeexperiment.svg)](https://badge.fury.io/rb/activeexperiment)
6
+ [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://opensource.org/licenses/MIT)
7
+ [![CI](https://github.com/jejacks0n/activeexperiment/actions/workflows/ci.yml/badge.svg)](https://github.com/jejacks0n/activeexperiment/actions/workflows/ci.yml)
8
+ [![codecov](https://codecov.io/gh/jejacks0n/activeexperiment/branch/main/graph/badge.svg)](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
- Start by defining an experiment class and adding some variants to it:
18
+ Define your experiments using easily testable classes:
12
19
 
13
20
  ```ruby
14
- class MyExperiment < ApplicationExperiment
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 anywhere in the application by providing it a context:
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
- The experiment can also be run using local scope and helpers to override the default variants:
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
- ## Advanced Experimentation
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 < ApplicationExperiment
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 < ApplicationExperiment
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 < ApplicationExperiment
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 < ApplicationExperiment
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 Custom Rollouts
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
- rollout :feature_flag, flag_name: "my_feature_flag"
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 Views
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
- ## Client Side Experimentation
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.experiments.each do |experiment| %>
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 has been run
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
- # 2 experiments expected within the block.
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
- # assert an experiment has been run with context.
373
+ # Assert an experiment has been run with a given context.
350
374
  assert_experiment_with(MyExperiment, context: { id: 1 })
351
375
 
352
- # experiment with context, and a variant assigned expected within the block.
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 &copy; [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.string :value, null: false
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["COUNT(key)"]
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 $1
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
- results = execute(<<~SQL, key)&.first.try(:[], "value")
76
- SELECT value FROM #{table_name(options)} WHERE key = $1
88
+ result = execute(<<~SQL, key)
89
+ SELECT value FROM #{table_name(options)} WHERE key = ?
77
90
  SQL
78
91
 
79
- deserialize_entry(results)
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 ($1, $2)
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 = $1
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 execute(sql, *args, prepare: true, **kws, &block)
112
- @connection.exec_query(sql, "SQL", args, prepare: prepare, **kws, &block)
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, :__callbacks?, :run_callbacks, :_run_callbacks, :_run_run_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
- "{{#{variant_names.join("}}{{")}}}"
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(/{{([\w]+)}}/) { $1 == variant.to_s ? @results : "" }
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 empty. Generally, this should be called after the
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
- "experiment" => name,
164
- "run_id" => run_id,
165
- "run_key" => run_key,
166
- "variant" => variant.to_s,
167
- "skipped" => skipped?
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, or if the experiment is already running, in the case of
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?
@@ -9,9 +9,9 @@ module ActiveExperiment
9
9
 
10
10
  module VERSION
11
11
  MAJOR = 0
12
- MINOR = 1
12
+ MINOR = 2
13
13
  TINY = 0
14
- PRE = "alpha"
14
+ PRE = nil
15
15
 
16
16
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
17
17
  end
@@ -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.secrets.secret_key_base
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
- ActiveSupport.on_load(:active_experiment) do
38
- options.each do |k, v|
39
- k = "#{k}="
40
- if ActiveExperiment.respond_to?(k)
41
- ActiveExperiment.send(k, v)
42
- elsif respond_to?(k)
43
- send(k, v)
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
- # rollout :percent, rules: { blue: 60, red: 40 }
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
- # rollout :percent
24
+ # use_rollout :percent
25
25
  #
26
26
  # # Assign 25% to control, 30% to red, and 45% to blue.
27
- # rollout :percent, rules: {control: 25, red: 30, blue: 45}
27
+ # use_rollout :percent, rules: {control: 25, red: 30, blue: 45}
28
28
  #
29
29
  # # Same as above, but using an array.
30
- # rollout :percent, rules: [25, 30, 45]
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 <= total += percent }]
51
- when Hash then rules.find { |_, percent| crc % 100 <= total += percent }.first
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
- # rollout :random
25
+ # use_rollout :random
26
26
  #
27
27
  # # Random, but once assigned, cache the assignment.
28
- # rollout :random, cache: true
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
- const_get("#{name.to_s.camelize}#{ROLLOUT_SUFFIX}")
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.secrets.secret_key_base+
17
- # when possible, and can be configured by:
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
- private :digest_secret_key, :digest_bit_length
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
- source = source.keys + source.values if source.is_a?(Hash)
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
- def identify_object(arg)
44
- case arg
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
- arg.to_global_id.to_s rescue arg
86
+ global_id_ingredient(value)
87
+ when *STABLE_TYPES
88
+ value.inspect
47
89
  else
48
- # TODO: maybe we should strip things out that might cause issues?
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
@@ -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"
@@ -1,7 +1,7 @@
1
1
  require "test_helper"
2
2
 
3
3
  <% module_namespacing do -%>
4
- class <%= class_name %>ExperimentTest < ActiveJob::TestCase
4
+ class <%= class_name %>ExperimentTest < ActiveExperiment::TestCase
5
5
  # test "the truth" do
6
6
  # assert true
7
7
  # end
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.1.0.alpha
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: 2022-12-10 00:00:00.000000000 Z
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.0.4
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.0.4
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/active_experiment
88
+ homepage: https://github.com/jejacks0n/activeexperiment
90
89
  licenses:
91
90
  - MIT
92
91
  metadata:
93
- homepage_uri: https://github.com/jejacks0n/active_experiment
94
- source_code_uri: https://github.com/jejacks0n/active_experiment
95
- bug_tracker_uri: https://github.com/jejacks0n/active_experiment/issues
96
- changelog_uri: https://github.com/jejacks0n/active_experiment/CHANGELOG.md
97
- documentation_uri: https://github.com/jejacks0n/active_experiment/README.md
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.7.0
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: 1.3.1
110
+ version: '0'
113
111
  requirements: []
114
- rubygems_version: 3.3.7
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: []