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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbb349cf57adaf426eb37b7e2ed5124bf321e674ef0116ab6e7ff6a5cf65fbae
4
- data.tar.gz: a7f74b8b717b4c34712a09e8792745333fc2b4ee3616202555857f87f8651010
3
+ metadata.gz: af34351515f9c77859089c5ac937f4fd7a7f241ee6fef23dcd59cc42150e90fb
4
+ data.tar.gz: e02cd7f96c2b8a758313956de1e7ebe49218d6fed28e8eaafe543e9af1f8f6fb
5
5
  SHA512:
6
- metadata.gz: 3477a95ecb87731bef45429864595b1428c8c6b576f47a4911158272e582f558420b6757992a8f0f86b3678bbbe0fda9ff091387ae312d770bfacf4857f76224
7
- data.tar.gz: 483970d7592e90564bf48f38a8504d6d6ac10d633eb2687617cc26ea77c38b8def22a46c58194ed8dfa2b52334a127c69b4239189b93dab0dd1c9ea704f033de
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
  [![Gem Version](https://badge.fury.io/rb/activeexperiment.svg)](https://badge.fury.io/rb/activeexperiment)
2
- [![Maintainability](https://api.codeclimate.com/v1/badges/551c00c66f8558c7a01b/maintainability)](https://codeclimate.com/github/jejacks0n/active_experiment/maintainability)
3
- [![Test Coverage](https://api.codeclimate.com/v1/badges/551c00c66f8558c7a01b/test_coverage)](https://codeclimate.com/github/jejacks0n/active_experiment/test_coverage)
4
6
  [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://opensource.org/licenses/MIT)
5
-
6
- # Active Experiment – Decide what to do next
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)
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
- Start by defining an experiment class and adding some variants to it:
18
+ Define your experiments using easily testable classes:
17
19
 
18
20
  ```ruby
19
- class MyExperiment < ApplicationExperiment
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 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:
32
34
 
33
35
  ```ruby
34
36
  MyExperiment.run(current_user) # => "red" or "blue"
35
37
  ```
36
38
 
37
- 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:
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/active_experiment
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 Experimentation
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 < ApplicationExperiment
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 < ApplicationExperiment
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 < ApplicationExperiment
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 < ApplicationExperiment
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 Custom Rollouts
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 Views
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 Side Experimentation
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 &copy; [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.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
13
- TINY = 1
14
- PRE = "alpha"
12
+ MINOR = 2
13
+ TINY = 0
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,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
- 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
@@ -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.1.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-12 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: []