Skip to content

Commit d6e2aa5

Browse files
authored
Merge branch 'master' into java_alternative_without_dependencies
2 parents ef618d8 + 3fa27b8 commit d6e2aa5

7 files changed

Lines changed: 172 additions & 65 deletions

File tree

.travis.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ language: ruby
33
cache: bundler
44
script: script/test
55
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
1010
before_install: gem install bundler
1111
addons:
1212
apt:

README.md

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Wrap a `use` block around the code's original behavior, and wrap `try` around th
2424

2525
* It decides whether or not to run the `try` block,
2626
* 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,
2828
* Compares the result of `try` to the result of `use`,
2929
* Swallow and record exceptions raised in the `try` block when overriding `raised`, and
3030
* Publishes all this information.
@@ -62,7 +62,7 @@ class MyExperiment
6262

6363
attr_accessor :name
6464

65-
def initialize(name:)
65+
def initialize(name)
6666
@name = name
6767
end
6868

@@ -82,15 +82,10 @@ class MyExperiment
8282
p result
8383
end
8484
end
85-
86-
# replace `Scientist::Default` as the default implementation
87-
module Scientist::Experiment
88-
def self.new(name)
89-
MyExperiment.new(name: name)
90-
end
91-
end
9285
```
9386

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+
9489
Now calls to the `science` helper will load instances of `MyExperiment`.
9590

9691
### Controlling comparison
@@ -114,6 +109,38 @@ class MyWidget
114109
end
115110
```
116111

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+
117144
### Adding context
118145

119146
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:
@@ -233,7 +260,7 @@ def admin?(user)
233260
end
234261
```
235262

236-
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.
237264

238265
### Enabling/disabling experiments
239266

@@ -262,7 +289,7 @@ class MyExperiment
262289

263290
attr_accessor :name, :percent_enabled
264291

265-
def initialize(name:)
292+
def initialize(name)
266293
@name = name
267294
@percent_enabled = 100
268295
end
@@ -400,6 +427,8 @@ Scientist rescues and tracks _all_ exceptions raised in a `try` or `use` block,
400427
Scientist::Observation::RESCUES.replace [StandardError]
401428
```
402429

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+
403432
#### In a Scientist callback
404433

405434
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:
@@ -544,6 +573,7 @@ Be on a Unixy box. Make sure a modern Bundler is available. `script/test` runs t
544573
- [trello/scientist](https://github.com/trello/scientist) (node.js)
545574
- [ziyasal/scientist.js](https://github.com/ziyasal/scientist.js) (node.js, ES6)
546575
- [TrueWill/tzientist](https://github.com/TrueWill/tzientist) (node.js, TypeScript)
576+
- [TrueWill/paleontologist](https://github.com/TrueWill/paleontologist) (Deno, TypeScript)
547577
- [yeller/laboratory](https://github.com/yeller/laboratory) (Clojure)
548578
- [lancew/Scientist](https://github.com/lancew/Scientist) (Perl 5)
549579
- [lancew/ScientistP6](https://github.com/lancew/ScientistP6) (Perl 6)
@@ -554,7 +584,8 @@ Be on a Unixy box. Make sure a modern Bundler is available. `script/test` runs t
554584
- [spoptchev/scientist](https://github.com/spoptchev/scientist) (Kotlin / Java)
555585
- [junkpiano/scientist](https://github.com/junkpiano/scientist) (Swift)
556586
- [serverless scientist](http://serverlessscientist.com/) (AWS Lambda)
557-
- [Mister Spex Scientist](https://github.com/MisterSpex/misterspex-scientist/) (Java, no dependencies)
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)
558589

559590
## Maintainers
560591

lib/scientist/experiment.rb

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,22 @@ module Scientist::Experiment
99
# If this is nil, raise_on_mismatches class attribute is used instead.
1010
attr_accessor :raise_on_mismatches
1111

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).
1618
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
1828
end
1929

2030
# A mismatch, raised when raise_on_mismatches is enabled.
@@ -67,10 +77,6 @@ def raise_on_mismatches?
6777
end
6878
end
6979

70-
def self.included(base)
71-
base.extend RaiseOnMismatch
72-
end
73-
7480
# Define a block of code to run before an experiment begins, if the experiment
7581
# is enabled.
7682
#
@@ -128,6 +134,16 @@ def compare(*args, &block)
128134
@_scientist_comparator = block
129135
end
130136

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+
131147
# A Symbol-keyed Hash of extra experiment data.
132148
def context(context = nil)
133149
@_scientist_context ||= {}
@@ -171,13 +187,9 @@ def name
171187
"experiment"
172188
end
173189

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.
175191
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
181193
rescue StandardError => ex
182194
raised :compare, ex
183195
false
@@ -216,17 +228,7 @@ def run(name = nil)
216228
@_scientist_before_run.call
217229
end
218230

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)
230232

231233
begin
232234
publish(result)
@@ -242,11 +244,9 @@ def run(name = nil)
242244
end
243245
end
244246

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
250250
end
251251

252252
# Define a block that determines whether or not the experiment should run.
@@ -304,4 +304,18 @@ def raise_on_mismatches?
304304
def fabricate_durations_for_testing_purposes(fabricated_durations = {})
305305
@_scientist_fabricated_durations = fabricated_durations
306306
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
307321
end

lib/scientist/observation.rb

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@ class Scientist::Observation
88
# The experiment this observation is for
99
attr_reader :experiment
1010

11-
# The instant observation began.
12-
attr_reader :now
13-
1411
# The String name of the behavior.
1512
attr_reader :name
1613

@@ -26,7 +23,6 @@ class Scientist::Observation
2623
def initialize(name, experiment, fabricated_duration: nil, &block)
2724
@name = name
2825
@experiment = experiment
29-
@now = Time.now
3026

3127
starting = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) unless fabricated_duration
3228
begin
@@ -49,25 +45,34 @@ def cleaned_value
4945

5046
# Is this observation equivalent to another?
5147
#
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.
5655
#
5756
# Returns true if:
5857
#
5958
# * The values of the observation are equal (using `==`)
6059
# * 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.
6263
# * Both observations raised an exception with the same class and message.
6364
#
6465
# Returns false otherwise.
65-
def equivalent_to?(other, &comparator)
66+
def equivalent_to?(other, comparator=nil, error_comparator=nil)
6667
return false unless other.is_a?(Scientist::Observation)
6768

6869
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
7176
end
7277

7378
if comparator

lib/scientist/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module Scientist
2-
VERSION = "1.4.0"
2+
VERSION = "1.6.0"
33
end

test/scientist/experiment_test.rb

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
class Fake
33
include Scientist::Experiment
44

5+
# Undo auto-config magic / preserve default behavior of Scientist::Experiment.new
6+
Scientist::Experiment.set_default(nil)
7+
58
def initialize(*args)
69
end
710

@@ -28,6 +31,24 @@ def publish(result)
2831
@ex = Fake.new
2932
end
3033

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+
3152
it "has a default implementation" do
3253
ex = Scientist::Experiment.new("hello")
3354
assert_kind_of Scientist::Default, ex
@@ -180,6 +201,18 @@ def @ex.publish(result)
180201
assert @ex.published_result.matched?
181202
end
182203

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+
183216
it "knows how to compare two experiments" do
184217
a = Scientist::Observation.new(@ex, "a") { 1 }
185218
b = Scientist::Observation.new(@ex, "b") { 2 }
@@ -546,7 +579,7 @@ def @ex.raised(op, exception)
546579
assert_equal " \"value\"", lines[2]
547580
assert_equal "candidate:", lines[3]
548581
assert_equal " #<RuntimeError: error>", lines[4]
549-
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]
550583
end
551584
end
552585
end

0 commit comments

Comments
 (0)