scientist 1.3.0 → 1.6.1

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: b2c73b090777600d31576b815584cba175ba2061ef223b5ec6775038b03828ee
4
- data.tar.gz: 0336cebef463c2c7b92246d7124a395f7351a75452071595ec3c7edac63050f8
3
+ metadata.gz: eee7969d8abea0f0fa0a53dea180d2f786d67a589b8694b2ccf6c70bdcedcbb4
4
+ data.tar.gz: 87bb457307fb452731efed8413770abed9f3d687e9718dd54edce106d745d389
5
5
  SHA512:
6
- metadata.gz: e97b6ba1d1f6a07d518b8d86b1b60dbfc0a978e3ef77e95490f733e290fcdef7a42c73728b7f17657a379706bfbfb5ff2d8dbc24334fdb0f7847069c46c5629f
7
- data.tar.gz: 3c2b414b032e96eebf4fe476078dc170384efaf67622a32f24db44a52b1dec1c0c4da2706151d06a9dbd3e30da1655ad06d4add6db0af25dd9d85f540014452a
6
+ metadata.gz: 882c44d39b595fe4c660abd619b5889552fe5d48c8bf2a2ff57e8b62fa221853a3af8727d04318068da888b6a8dd59e23f78c0eda71237261e409cb4038a5524
7
+ data.tar.gz: a6397a830792dda87404c7775d804d19a8b229a488180bba633501ff80ca889b9e61d44cf06287e48059664f8d6dd5097bf82e2ac6c4d33e478e02ea5e88c78f
data/.travis.yml CHANGED
@@ -3,10 +3,10 @@ language: ruby
3
3
  cache: bundler
4
4
  script: script/test
5
5
  rvm:
6
- - 2.3.8
7
- - 2.4.5
8
- - 2.5.3
9
- - 2.6.1
6
+ - 2.6.7
7
+ - 2.7.3
8
+ - 3.0.1
9
+ - truffleruby-head
10
10
  before_install: gem install bundler
11
11
  addons:
12
12
  apt:
data/README.md CHANGED
@@ -24,9 +24,9 @@ Wrap a `use` block around the code's original behavior, and wrap `try` around th
24
24
 
25
25
  * It decides whether or not to run the `try` block,
26
26
  * Randomizes the order in which `use` and `try` blocks are run,
27
- * Measures the durations of all behaviors,
27
+ * Measures the durations of all behaviors in seconds,
28
28
  * Compares the result of `try` to the result of `use`,
29
- * Swallows (but records) any exceptions raised in the `try` block, and
29
+ * Swallow and record exceptions raised in the `try` block when overriding `raised`, and
30
30
  * Publishes all this information.
31
31
 
32
32
  The `use` block is called the **control**. The `try` block is called the **candidate**.
@@ -62,7 +62,7 @@ class MyExperiment
62
62
 
63
63
  attr_accessor :name
64
64
 
65
- def initialize(name:)
65
+ def initialize(name)
66
66
  @name = name
67
67
  end
68
68
 
@@ -71,20 +71,21 @@ class MyExperiment
71
71
  true
72
72
  end
73
73
 
74
+ def raised(operation, error)
75
+ # see "In a Scientist callback" below
76
+ p "Operation '#{operation}' failed with error '#{error.inspect}'"
77
+ super # will re-raise
78
+ end
79
+
74
80
  def publish(result)
75
81
  # see "Publishing results" below
76
82
  p result
77
83
  end
78
84
  end
79
-
80
- # replace `Scientist::Default` as the default implementation
81
- module Scientist::Experiment
82
- def self.new(name)
83
- MyExperiment.new(name: name)
84
- end
85
- end
86
85
  ```
87
86
 
87
+ When `Scientist::Experiment` is included in a class, it automatically sets it as the default implementation via `Scientist::Experiment.set_default`. This `set_default` call is is skipped if you include `Scientist::Experiment` in a module.
88
+
88
89
  Now calls to the `science` helper will load instances of `MyExperiment`.
89
90
 
90
91
  ### Controlling comparison
@@ -108,6 +109,38 @@ class MyWidget
108
109
  end
109
110
  ```
110
111
 
112
+ If either the control block or candidate block raises an error, Scientist compares the two observations' classes and messages using `==`. To override this behavior, use `compare_error` to define how to compare observed errors instead:
113
+
114
+ ```ruby
115
+ class MyWidget
116
+ include Scientist
117
+
118
+ def slug_from_login(login)
119
+ science "slug_from_login" do |e|
120
+ e.use { User.slug_from_login login } # returns String instance or ArgumentError
121
+ e.try { UserService.slug_from_login login } # returns String instance or ArgumentError
122
+
123
+ compare_error_message_and_class = -> (control, candidate) do
124
+ control.class == candidate.class &&
125
+ control.message == candidate.message
126
+ end
127
+
128
+ compare_argument_errors = -> (control, candidate) do
129
+ control.class == ArgumentError &&
130
+ candidate.class == ArgumentError &&
131
+ control.message.start_with?("Input has invalid characters") &&
132
+ candidate.message.star_with?("Invalid characters in input")
133
+ end
134
+
135
+ e.compare_error do |control, candidate|
136
+ compare_error_message_and_class.call(control, candidate) ||
137
+ compare_argument_errors.call(control, candidate)
138
+ end
139
+ end
140
+ end
141
+ end
142
+ ```
143
+
111
144
  ### Adding context
112
145
 
113
146
  Results aren't very useful without some way to identify them. Use the `context` method to add to or retrieve the context for an experiment:
@@ -227,7 +260,7 @@ def admin?(user)
227
260
  end
228
261
  ```
229
262
 
230
- The ignore blocks are only called if the *values* don't match. If one observation raises an exception and the other doesn't, it's always considered a mismatch. If both observations raise different exceptions, that is also considered a mismatch.
263
+ The ignore blocks are only called if the *values* don't match. Unless a `compare_error` comparator is defined, two cases are considered mismatches: a) one observation raising an exception and the other not, b) observations raising exceptions with different classes or messages.
231
264
 
232
265
  ### Enabling/disabling experiments
233
266
 
@@ -256,7 +289,7 @@ class MyExperiment
256
289
 
257
290
  attr_accessor :name, :percent_enabled
258
291
 
259
- def initialize(name:)
292
+ def initialize(name)
260
293
  @name = name
261
294
  @percent_enabled = 100
262
295
  end
@@ -394,6 +427,8 @@ Scientist rescues and tracks _all_ exceptions raised in a `try` or `use` block,
394
427
  Scientist::Observation::RESCUES.replace [StandardError]
395
428
  ```
396
429
 
430
+ **Timeout ⏲️**: If you're introducing a candidate that could possibly timeout, use caution. ⚠️ While Scientist rescues all exceptions that occur in the candidate block, it *does not* protect you from timeouts, as doing so would be complicated. It would likely require running the candidate code in a background job and tracking the time of a request. We feel the cost of this complexity would outweigh the benefit, so make sure that your code doesn't cause timeouts. This risk can be reduced by running the experiment on a low percentage so that users can (most likely) bypass the experiment by refreshing the page if they hit a timeout. See [Ramping up experiments](#ramping-up-experiments) below for how details on how to set the percentage for your experiment.
431
+
397
432
  #### In a Scientist callback
398
433
 
399
434
  If an exception is raised within any of Scientist's internal helpers, like `publish`, `compare`, or `clean`, the `raised` method is called with the symbol name of the internal operation that failed and the exception that was raised. The default behavior of `Scientist::Default` is to simply re-raise the exception. Since this halts the experiment entirely, it's often a better idea to handle this error and continue so the experiment as a whole isn't canceled entirely:
@@ -524,6 +559,10 @@ end
524
559
 
525
560
  Be on a Unixy box. Make sure a modern Bundler is available. `script/test` runs the unit tests. All development dependencies are installed automatically. Scientist requires Ruby 2.3 or newer.
526
561
 
562
+ ## Wrappers
563
+
564
+ - [RealGeeks/lab_tech](https://github.com/RealGeeks/lab_tech) is a Rails engine for using this library by controlling, storing, and analyzing experiment results with ActiveRecord.
565
+
527
566
  ## Alternatives
528
567
 
529
568
  - [daylerees/scientist](https://github.com/daylerees/scientist) (PHP)
@@ -533,6 +572,8 @@ Be on a Unixy box. Make sure a modern Bundler is available. `script/test` runs t
533
572
  - [tomiaijo/scientist](https://github.com/tomiaijo/scientist) (C++)
534
573
  - [trello/scientist](https://github.com/trello/scientist) (node.js)
535
574
  - [ziyasal/scientist.js](https://github.com/ziyasal/scientist.js) (node.js, ES6)
575
+ - [TrueWill/tzientist](https://github.com/TrueWill/tzientist) (node.js, TypeScript)
576
+ - [TrueWill/paleontologist](https://github.com/TrueWill/paleontologist) (Deno, TypeScript)
536
577
  - [yeller/laboratory](https://github.com/yeller/laboratory) (Clojure)
537
578
  - [lancew/Scientist](https://github.com/lancew/Scientist) (Perl 5)
538
579
  - [lancew/ScientistP6](https://github.com/lancew/ScientistP6) (Perl 6)
@@ -542,7 +583,9 @@ Be on a Unixy box. Make sure a modern Bundler is available. `script/test` runs t
542
583
  - [jelmersnoeck/experiment](https://github.com/jelmersnoeck/experiment) (Go)
543
584
  - [spoptchev/scientist](https://github.com/spoptchev/scientist) (Kotlin / Java)
544
585
  - [junkpiano/scientist](https://github.com/junkpiano/scientist) (Swift)
545
-
586
+ - [serverless scientist](http://serverlessscientist.com/) (AWS Lambda)
587
+ - [fightmegg/scientist](https://github.com/fightmegg/scientist) (TypeScript, Browser / Node.js)
588
+ - [MisterSpex/misterspex-scientist](https://github.com/MisterSpex/misterspex-scientist) (Java, no dependencies)
546
589
 
547
590
  ## Maintainers
548
591
 
@@ -9,16 +9,26 @@ module Scientist::Experiment
9
9
  # If this is nil, raise_on_mismatches class attribute is used instead.
10
10
  attr_accessor :raise_on_mismatches
11
11
 
12
- # Create a new instance of a class that implements the Scientist::Experiment
13
- # interface.
14
- #
15
- # Override this method directly to change the default implementation.
12
+ def self.included(base)
13
+ self.set_default(base) if base.instance_of?(Class)
14
+ base.extend RaiseOnMismatch
15
+ end
16
+
17
+ # Instantiate a new experiment (using the class given to the .set_default method).
16
18
  def self.new(name)
17
- Scientist::Default.new(name)
19
+ (@experiment_klass || Scientist::Default).new(name)
20
+ end
21
+
22
+ # Configure Scientist to use the given class for all future experiments
23
+ # (must implement the Scientist::Experiment interface).
24
+ #
25
+ # Called automatically when new experiments are defined.
26
+ def self.set_default(klass)
27
+ @experiment_klass = klass
18
28
  end
19
29
 
20
30
  # A mismatch, raised when raise_on_mismatches is enabled.
21
- class MismatchError < StandardError
31
+ class MismatchError < Exception
22
32
  attr_reader :name, :result
23
33
 
24
34
  def initialize(name, result)
@@ -67,10 +77,6 @@ module Scientist::Experiment
67
77
  end
68
78
  end
69
79
 
70
- def self.included(base)
71
- base.extend RaiseOnMismatch
72
- end
73
-
74
80
  # Define a block of code to run before an experiment begins, if the experiment
75
81
  # is enabled.
76
82
  #
@@ -128,6 +134,16 @@ module Scientist::Experiment
128
134
  @_scientist_comparator = block
129
135
  end
130
136
 
137
+ # A block which compares two experimental errors.
138
+ #
139
+ # The block must take two arguments, the control Error and a candidate Error,
140
+ # and return true or false.
141
+ #
142
+ # Returns the block.
143
+ def compare_errors(*args, &block)
144
+ @_scientist_error_comparator = block
145
+ end
146
+
131
147
  # A Symbol-keyed Hash of extra experiment data.
132
148
  def context(context = nil)
133
149
  @_scientist_context ||= {}
@@ -171,13 +187,9 @@ module Scientist::Experiment
171
187
  "experiment"
172
188
  end
173
189
 
174
- # Internal: compare two observations, using the configured compare block if present.
190
+ # Internal: compare two observations, using the configured compare and compare_errors lambdas if present.
175
191
  def observations_are_equivalent?(a, b)
176
- if @_scientist_comparator
177
- a.equivalent_to?(b, &@_scientist_comparator)
178
- else
179
- a.equivalent_to? b
180
- end
192
+ a.equivalent_to? b, @_scientist_comparator, @_scientist_error_comparator
181
193
  rescue StandardError => ex
182
194
  raised :compare, ex
183
195
  false
@@ -216,17 +228,7 @@ module Scientist::Experiment
216
228
  @_scientist_before_run.call
217
229
  end
218
230
 
219
- observations = []
220
-
221
- behaviors.keys.shuffle.each do |key|
222
- block = behaviors[key]
223
- fabricated_duration = @_scientist_fabricated_durations && @_scientist_fabricated_durations[key]
224
- observations << Scientist::Observation.new(key, self, fabricated_duration: fabricated_duration, &block)
225
- end
226
-
227
- control = observations.detect { |o| o.name == name }
228
-
229
- result = Scientist::Result.new self, observations, control
231
+ result = generate_result(name)
230
232
 
231
233
  begin
232
234
  publish(result)
@@ -242,11 +244,9 @@ module Scientist::Experiment
242
244
  end
243
245
  end
244
246
 
245
- if control.raised?
246
- raise control.exception
247
- else
248
- control.value
249
- end
247
+ control = result.control
248
+ raise control.exception if control.raised?
249
+ control.value
250
250
  end
251
251
 
252
252
  # Define a block that determines whether or not the experiment should run.
@@ -304,4 +304,18 @@ module Scientist::Experiment
304
304
  def fabricate_durations_for_testing_purposes(fabricated_durations = {})
305
305
  @_scientist_fabricated_durations = fabricated_durations
306
306
  end
307
+
308
+ # Internal: Generate the observations and create the result from those and the control.
309
+ def generate_result(name)
310
+ observations = []
311
+
312
+ behaviors.keys.shuffle.each do |key|
313
+ block = behaviors[key]
314
+ fabricated_duration = @_scientist_fabricated_durations && @_scientist_fabricated_durations[key]
315
+ observations << Scientist::Observation.new(key, self, fabricated_duration: fabricated_duration, &block)
316
+ end
317
+
318
+ control = observations.detect { |o| o.name == name }
319
+ Scientist::Result.new(self, observations, control)
320
+ end
307
321
  end
@@ -8,9 +8,6 @@ class Scientist::Observation
8
8
  # The experiment this observation is for
9
9
  attr_reader :experiment
10
10
 
11
- # The instant observation began.
12
- attr_reader :now
13
-
14
11
  # The String name of the behavior.
15
12
  attr_reader :name
16
13
 
@@ -26,7 +23,6 @@ class Scientist::Observation
26
23
  def initialize(name, experiment, fabricated_duration: nil, &block)
27
24
  @name = name
28
25
  @experiment = experiment
29
- @now = Time.now
30
26
 
31
27
  starting = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) unless fabricated_duration
32
28
  begin
@@ -49,25 +45,34 @@ class Scientist::Observation
49
45
 
50
46
  # Is this observation equivalent to another?
51
47
  #
52
- # other - the other Observation in question
53
- # comparator - an optional comparison block. This observation's value and the
54
- # other observation's value are yielded to this to determine
55
- # their equivalency. Block should return true/false.
48
+ # other - the other Observation in question
49
+ # comparator - an optional comparison proc. This observation's value and the
50
+ # other observation's value are passed to this to determine
51
+ # their equivalency. Proc should return true/false.
52
+ # error_comparator - an optional comparison proc. This observation's Error and the
53
+ # other observation's Error are passed to this to determine
54
+ # their equivalency. Proc should return true/false.
56
55
  #
57
56
  # Returns true if:
58
57
  #
59
58
  # * The values of the observation are equal (using `==`)
60
59
  # * The values of the observations are equal according to a comparison
61
- # block, if given
60
+ # proc, if given
61
+ # * The exceptions raised by the obeservations are equal according to the
62
+ # error comparison proc, if given.
62
63
  # * Both observations raised an exception with the same class and message.
63
64
  #
64
65
  # Returns false otherwise.
65
- def equivalent_to?(other, &comparator)
66
+ def equivalent_to?(other, comparator=nil, error_comparator=nil)
66
67
  return false unless other.is_a?(Scientist::Observation)
67
68
 
68
69
  if raised? || other.raised?
69
- return other.exception.class == exception.class &&
70
- other.exception.message == exception.message
70
+ if error_comparator
71
+ return error_comparator.call(exception, other.exception)
72
+ else
73
+ return other.exception.class == exception.class &&
74
+ other.exception.message == exception.message
75
+ end
71
76
  end
72
77
 
73
78
  if comparator
@@ -1,3 +1,3 @@
1
1
  module Scientist
2
- VERSION = "1.3.0"
2
+ VERSION = "1.6.1"
3
3
  end
@@ -2,6 +2,9 @@ describe Scientist::Experiment do
2
2
  class Fake
3
3
  include Scientist::Experiment
4
4
 
5
+ # Undo auto-config magic / preserve default behavior of Scientist::Experiment.new
6
+ Scientist::Experiment.set_default(nil)
7
+
5
8
  def initialize(*args)
6
9
  end
7
10
 
@@ -28,6 +31,24 @@ describe Scientist::Experiment do
28
31
  @ex = Fake.new
29
32
  end
30
33
 
34
+ it "sets the default on inclusion" do
35
+ klass = Class.new do
36
+ include Scientist::Experiment
37
+
38
+ def initialize(name)
39
+ end
40
+ end
41
+
42
+ assert_kind_of klass, Scientist::Experiment.new("hello")
43
+
44
+ Scientist::Experiment.set_default(nil)
45
+ end
46
+
47
+ it "doesn't set the default on inclusion when it's a module" do
48
+ Module.new { include Scientist::Experiment }
49
+ assert_kind_of Scientist::Default, Scientist::Experiment.new("hello")
50
+ end
51
+
31
52
  it "has a default implementation" do
32
53
  ex = Scientist::Experiment.new("hello")
33
54
  assert_kind_of Scientist::Default, ex
@@ -180,6 +201,18 @@ describe Scientist::Experiment do
180
201
  assert @ex.published_result.matched?
181
202
  end
182
203
 
204
+ it "compares errors with an error comparator block if provided" do
205
+ @ex.compare_errors { |a, b| a.class == b.class }
206
+ @ex.use { raise "foo" }
207
+ @ex.try { raise "bar" }
208
+
209
+ resulting_error = assert_raises RuntimeError do
210
+ @ex.run
211
+ end
212
+ assert_equal "foo", resulting_error.message
213
+ assert @ex.published_result.matched?
214
+ end
215
+
183
216
  it "knows how to compare two experiments" do
184
217
  a = Scientist::Observation.new(@ex, "a") { 1 }
185
218
  b = Scientist::Observation.new(@ex, "b") { 2 }
@@ -444,6 +477,20 @@ describe Scientist::Experiment do
444
477
  assert_raises(Scientist::Experiment::MismatchError) { @ex.run }
445
478
  end
446
479
 
480
+ it "allows MismatchError to bubble up through bare rescues" do
481
+ Fake.raise_on_mismatches = true
482
+ @ex.use { "control" }
483
+ @ex.try { "candidate" }
484
+ runner = -> {
485
+ begin
486
+ @ex.run
487
+ rescue
488
+ # StandardError handled
489
+ end
490
+ }
491
+ assert_raises(Scientist::Experiment::MismatchError) { runner.call }
492
+ end
493
+
447
494
  describe "#raise_on_mismatches?" do
448
495
  it "raises when there is a mismatch if the experiment instance's raise on mismatches is enabled" do
449
496
  Fake.raise_on_mismatches = false
@@ -532,7 +579,7 @@ candidate:
532
579
  assert_equal " \"value\"", lines[2]
533
580
  assert_equal "candidate:", lines[3]
534
581
  assert_equal " #<RuntimeError: error>", lines[4]
535
- assert_match %r( test/scientist/experiment_test.rb:\d+:in `block), lines[5]
582
+ assert_match %r(test/scientist/experiment_test.rb:\d+:in `block), lines[5]
536
583
  end
537
584
  end
538
585
  end
@@ -80,7 +80,7 @@ describe Scientist::Observation do
80
80
  refute x.equivalent_to?(y)
81
81
  end
82
82
 
83
- FirstErrror = Class.new(StandardError)
83
+ FirstError = Class.new(StandardError)
84
84
  SecondError = Class.new(StandardError)
85
85
 
86
86
  it "compares exception classes" do
@@ -92,22 +92,46 @@ describe Scientist::Observation do
92
92
  refute x.equivalent_to?(y)
93
93
  end
94
94
 
95
- it "compares values using a comparator block" do
95
+ it "compares values using a comparator proc" do
96
96
  a = Scientist::Observation.new("test", @experiment) { 1 }
97
97
  b = Scientist::Observation.new("test", @experiment) { "1" }
98
98
 
99
99
  refute a.equivalent_to?(b)
100
- assert a.equivalent_to?(b) { |x, y| x.to_s == y.to_s }
100
+
101
+ compare_on_string = -> (x, y) { x.to_s == y.to_s }
102
+
103
+ assert a.equivalent_to?(b, compare_on_string)
101
104
 
102
105
  yielded = []
103
- a.equivalent_to?(b) do |x, y|
106
+ compare_appends = -> (x, y) do
104
107
  yielded << x
105
108
  yielded << y
106
109
  true
107
110
  end
111
+ a.equivalent_to?(b, compare_appends)
112
+
108
113
  assert_equal [a.value, b.value], yielded
109
114
  end
110
115
 
116
+ it "compares exceptions using an error comparator proc" do
117
+ x = Scientist::Observation.new("test", @experiment) { raise FirstError, "error" }
118
+ y = Scientist::Observation.new("test", @experiment) { raise SecondError, "error" }
119
+ z = Scientist::Observation.new("test", @experiment) { raise FirstError, "ERROR" }
120
+
121
+ refute x.equivalent_to?(z)
122
+ refute x.equivalent_to?(y)
123
+
124
+ compare_on_class = -> (error, other_error) {
125
+ error.class == other_error.class
126
+ }
127
+ compare_on_message = -> (error, other_error) {
128
+ error.message == other_error.message
129
+ }
130
+
131
+ assert x.equivalent_to?(z, nil, compare_on_class)
132
+ assert x.equivalent_to?(y, nil, compare_on_message)
133
+ end
134
+
111
135
  describe "#cleaned_value" do
112
136
  it "returns the observation's value by default" do
113
137
  a = Scientist::Observation.new("test", @experiment) { 1 }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scientist
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitHub Open Source
@@ -9,10 +9,10 @@ authors:
9
9
  - Rick Bradley
10
10
  - Jesse Toth
11
11
  - Nathan Witmer
12
- autorequire:
12
+ autorequire:
13
13
  bindir: bin
14
14
  cert_chain: []
15
- date: 2019-04-02 00:00:00.000000000 Z
15
+ date: 2021-10-22 00:00:00.000000000 Z
16
16
  dependencies:
17
17
  - !ruby/object:Gem::Dependency
18
18
  name: minitest
@@ -80,7 +80,7 @@ homepage: https://github.com/github/scientist
80
80
  licenses:
81
81
  - MIT
82
82
  metadata: {}
83
- post_install_message:
83
+ post_install_message:
84
84
  rdoc_options: []
85
85
  require_paths:
86
86
  - lib
@@ -95,8 +95,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
95
95
  - !ruby/object:Gem::Version
96
96
  version: '0'
97
97
  requirements: []
98
- rubygems_version: 3.0.3
99
- signing_key:
98
+ rubygems_version: 3.2.22
99
+ signing_key:
100
100
  specification_version: 4
101
101
  summary: Carefully test, measure, and track refactored code.
102
102
  test_files: