scientist 1.5.0 → 1.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.travis.yml +4 -4
- data/CONTRIBUTING.md +3 -3
- data/README.md +40 -2
- data/lib/scientist/experiment.rb +29 -7
- data/lib/scientist/observation.rb +17 -8
- data/lib/scientist/version.rb +1 -1
- data/script/release +4 -4
- data/test/scientist/experiment_test.rb +52 -1
- data/test/scientist/observation_test.rb +28 -4
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 53663e0ed4b522336c69b73073aa144a4b6c8178e3fcd716c0e4519a66e308ba
|
4
|
+
data.tar.gz: b60d0c55dce9e1e9a23eb7e6ce4c445bf01340dcfd459d50716d7cd85e405b93
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6e1baf64882c857abdd3fd02b77785a783a8694854358c78a9da3be213b42ac88e36805ebf7f10f0d87fb14d7808302e1afffd2793c5ffc21addec5326e5f969
|
7
|
+
data.tar.gz: e5a65140ca8704c7443f88404cfb9e971f4a2925d756eed18d7482be74aab3243315fda727dfe8fed13d0732d76ca442a12cb7af200ca6ae3e4de4b0f1bab1a2
|
data/.travis.yml
CHANGED
data/CONTRIBUTING.md
CHANGED
@@ -10,9 +10,9 @@ Contributions to this project are [released](https://help.github.com/articles/gi
|
|
10
10
|
## Submitting a pull request
|
11
11
|
|
12
12
|
0. [Fork][fork] and clone the repository
|
13
|
-
|
14
|
-
|
15
|
-
|
13
|
+
1. Create a new branch: `git checkout -b my-branch-name`
|
14
|
+
2. Make your change, push to your fork and [submit a pull request][pr]
|
15
|
+
3. Pat your self on the back and wait for your pull request to be reviewed and merged.
|
16
16
|
|
17
17
|
Here are a few things you can do that will increase the likelihood of your pull request being accepted:
|
18
18
|
|
data/README.md
CHANGED
@@ -24,7 +24,7 @@ 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
29
|
* Swallow and record exceptions raised in the `try` block when overriding `raised`, and
|
30
30
|
* Publishes all this information.
|
@@ -84,6 +84,8 @@ class MyExperiment
|
|
84
84
|
end
|
85
85
|
```
|
86
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 skipped if you include `Scientist::Experiment` in a module.
|
88
|
+
|
87
89
|
Now calls to the `science` helper will load instances of `MyExperiment`.
|
88
90
|
|
89
91
|
### Controlling comparison
|
@@ -107,6 +109,38 @@ class MyWidget
|
|
107
109
|
end
|
108
110
|
```
|
109
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.start_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
|
+
|
110
144
|
### Adding context
|
111
145
|
|
112
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:
|
@@ -226,7 +260,7 @@ def admin?(user)
|
|
226
260
|
end
|
227
261
|
```
|
228
262
|
|
229
|
-
The ignore blocks are only called if the *values* don't match.
|
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.
|
230
264
|
|
231
265
|
### Enabling/disabling experiments
|
232
266
|
|
@@ -393,6 +427,8 @@ Scientist rescues and tracks _all_ exceptions raised in a `try` or `use` block,
|
|
393
427
|
Scientist::Observation::RESCUES.replace [StandardError]
|
394
428
|
```
|
395
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
|
+
|
396
432
|
#### In a Scientist callback
|
397
433
|
|
398
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:
|
@@ -548,6 +584,8 @@ Be on a Unixy box. Make sure a modern Bundler is available. `script/test` runs t
|
|
548
584
|
- [spoptchev/scientist](https://github.com/spoptchev/scientist) (Kotlin / Java)
|
549
585
|
- [junkpiano/scientist](https://github.com/junkpiano/scientist) (Swift)
|
550
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)
|
551
589
|
|
552
590
|
## Maintainers
|
553
591
|
|
data/lib/scientist/experiment.rb
CHANGED
@@ -10,7 +10,7 @@ module Scientist::Experiment
|
|
10
10
|
attr_accessor :raise_on_mismatches
|
11
11
|
|
12
12
|
def self.included(base)
|
13
|
-
self.set_default(base)
|
13
|
+
self.set_default(base) if base.instance_of?(Class)
|
14
14
|
base.extend RaiseOnMismatch
|
15
15
|
end
|
16
16
|
|
@@ -134,6 +134,16 @@ module Scientist::Experiment
|
|
134
134
|
@_scientist_comparator = block
|
135
135
|
end
|
136
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
|
+
|
137
147
|
# A Symbol-keyed Hash of extra experiment data.
|
138
148
|
def context(context = nil)
|
139
149
|
@_scientist_context ||= {}
|
@@ -177,13 +187,9 @@ module Scientist::Experiment
|
|
177
187
|
"experiment"
|
178
188
|
end
|
179
189
|
|
180
|
-
# Internal: compare two observations, using the configured compare
|
190
|
+
# Internal: compare two observations, using the configured compare and compare_errors lambdas if present.
|
181
191
|
def observations_are_equivalent?(a, b)
|
182
|
-
|
183
|
-
a.equivalent_to?(b, &@_scientist_comparator)
|
184
|
-
else
|
185
|
-
a.equivalent_to? b
|
186
|
-
end
|
192
|
+
a.equivalent_to? b, @_scientist_comparator, @_scientist_error_comparator
|
187
193
|
rescue StandardError => ex
|
188
194
|
raised :compare, ex
|
189
195
|
false
|
@@ -312,4 +318,20 @@ module Scientist::Experiment
|
|
312
318
|
control = observations.detect { |o| o.name == name }
|
313
319
|
Scientist::Result.new(self, observations, control)
|
314
320
|
end
|
321
|
+
|
322
|
+
private
|
323
|
+
|
324
|
+
# In order to support marshaling, we have to make the procs marshalable. Some
|
325
|
+
# CI providers attempt to marshal Scientist mismatch errors so that they can
|
326
|
+
# be sent out to different places (logs, etc.) The mismatch errors contain
|
327
|
+
# code from the experiment. This code contains procs. These procs prevent the
|
328
|
+
# error from being marshaled. To fix this, we simple exclude the procs from
|
329
|
+
# the data that we marshal.
|
330
|
+
def marshal_dump
|
331
|
+
[@name, @result, @raise_on_mismatches]
|
332
|
+
end
|
333
|
+
|
334
|
+
def marshal_load
|
335
|
+
@name, @result, @raise_on_mismatches = array
|
336
|
+
end
|
315
337
|
end
|
@@ -45,25 +45,34 @@ class Scientist::Observation
|
|
45
45
|
|
46
46
|
# Is this observation equivalent to another?
|
47
47
|
#
|
48
|
-
# other
|
49
|
-
# comparator
|
50
|
-
#
|
51
|
-
#
|
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.
|
52
55
|
#
|
53
56
|
# Returns true if:
|
54
57
|
#
|
55
58
|
# * The values of the observation are equal (using `==`)
|
56
59
|
# * The values of the observations are equal according to a comparison
|
57
|
-
#
|
60
|
+
# proc, if given
|
61
|
+
# * The exceptions raised by the obeservations are equal according to the
|
62
|
+
# error comparison proc, if given.
|
58
63
|
# * Both observations raised an exception with the same class and message.
|
59
64
|
#
|
60
65
|
# Returns false otherwise.
|
61
|
-
def equivalent_to?(other,
|
66
|
+
def equivalent_to?(other, comparator=nil, error_comparator=nil)
|
62
67
|
return false unless other.is_a?(Scientist::Observation)
|
63
68
|
|
64
69
|
if raised? || other.raised?
|
65
|
-
|
66
|
-
|
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
|
67
76
|
end
|
68
77
|
|
69
78
|
if comparator
|
data/lib/scientist/version.rb
CHANGED
data/script/release
CHANGED
@@ -12,10 +12,10 @@ cd $(dirname "$0")/..
|
|
12
12
|
rm -rf scientist-*.gem
|
13
13
|
gem build -q scientist.gemspec
|
14
14
|
|
15
|
-
# Make sure we're on the
|
15
|
+
# Make sure we're on the main branch.
|
16
16
|
|
17
|
-
(git branch --no-color | grep -q '*
|
18
|
-
echo "Only release from the
|
17
|
+
(git branch --no-color | grep -q '* main') || {
|
18
|
+
echo "Only release from the main branch."
|
19
19
|
exit 1
|
20
20
|
}
|
21
21
|
|
@@ -35,4 +35,4 @@ git fetch -t origin
|
|
35
35
|
# Tag it and bag it.
|
36
36
|
|
37
37
|
gem push scientist-*.gem && git tag "$tag" &&
|
38
|
-
git push origin
|
38
|
+
git push origin main && git push origin "$tag"
|
@@ -31,6 +31,24 @@ describe Scientist::Experiment do
|
|
31
31
|
@ex = Fake.new
|
32
32
|
end
|
33
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
|
+
|
34
52
|
it "has a default implementation" do
|
35
53
|
ex = Scientist::Experiment.new("hello")
|
36
54
|
assert_kind_of Scientist::Default, ex
|
@@ -183,6 +201,18 @@ describe Scientist::Experiment do
|
|
183
201
|
assert @ex.published_result.matched?
|
184
202
|
end
|
185
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
|
+
|
186
216
|
it "knows how to compare two experiments" do
|
187
217
|
a = Scientist::Observation.new(@ex, "a") { 1 }
|
188
218
|
b = Scientist::Observation.new(@ex, "b") { 2 }
|
@@ -461,6 +491,27 @@ describe Scientist::Experiment do
|
|
461
491
|
assert_raises(Scientist::Experiment::MismatchError) { runner.call }
|
462
492
|
end
|
463
493
|
|
494
|
+
it "can be marshaled" do
|
495
|
+
Fake.raise_on_mismatches = true
|
496
|
+
@ex.before_run { "some block" }
|
497
|
+
@ex.clean { "some block" }
|
498
|
+
@ex.compare_errors { "some block" }
|
499
|
+
@ex.ignore { false }
|
500
|
+
@ex.run_if { "some block" }
|
501
|
+
@ex.try { "candidate" }
|
502
|
+
@ex.use { "control" }
|
503
|
+
@ex.compare { |control, candidate| control == candidate }
|
504
|
+
|
505
|
+
mismatch = nil
|
506
|
+
begin
|
507
|
+
@ex.run
|
508
|
+
rescue Scientist::Experiment::MismatchError => e
|
509
|
+
mismatch = e
|
510
|
+
end
|
511
|
+
|
512
|
+
assert_kind_of(String, Marshal.dump(mismatch))
|
513
|
+
end
|
514
|
+
|
464
515
|
describe "#raise_on_mismatches?" do
|
465
516
|
it "raises when there is a mismatch if the experiment instance's raise on mismatches is enabled" do
|
466
517
|
Fake.raise_on_mismatches = false
|
@@ -549,7 +600,7 @@ candidate:
|
|
549
600
|
assert_equal " \"value\"", lines[2]
|
550
601
|
assert_equal "candidate:", lines[3]
|
551
602
|
assert_equal " #<RuntimeError: error>", lines[4]
|
552
|
-
assert_match %r(
|
603
|
+
assert_match %r(test/scientist/experiment_test.rb:\d+:in `block), lines[5]
|
553
604
|
end
|
554
605
|
end
|
555
606
|
end
|
@@ -80,7 +80,7 @@ describe Scientist::Observation do
|
|
80
80
|
refute x.equivalent_to?(y)
|
81
81
|
end
|
82
82
|
|
83
|
-
|
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
|
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
|
-
|
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
|
-
|
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.
|
4
|
+
version: 1.6.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- GitHub Open Source
|
@@ -12,7 +12,7 @@ authors:
|
|
12
12
|
autorequire:
|
13
13
|
bindir: bin
|
14
14
|
cert_chain: []
|
15
|
-
date:
|
15
|
+
date: 2021-12-09 00:00:00.000000000 Z
|
16
16
|
dependencies:
|
17
17
|
- !ruby/object:Gem::Dependency
|
18
18
|
name: minitest
|
@@ -95,7 +95,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
95
95
|
- !ruby/object:Gem::Version
|
96
96
|
version: '0'
|
97
97
|
requirements: []
|
98
|
-
rubygems_version: 3.
|
98
|
+
rubygems_version: 3.2.32
|
99
99
|
signing_key:
|
100
100
|
specification_version: 4
|
101
101
|
summary: Carefully test, measure, and track refactored code.
|