scientist 0.0.0 → 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CLA.md +54 -0
- data/CONTRIBUTING.md +3 -0
- data/Gemfile +0 -1
- data/README.md +413 -4
- data/lib/scientist/default.rb +21 -0
- data/lib/scientist/errors.rb +38 -0
- data/lib/scientist/experiment.rb +233 -0
- data/lib/scientist/observation.rb +92 -0
- data/lib/scientist/result.rb +77 -0
- data/lib/scientist/version.rb +1 -1
- data/lib/scientist.rb +39 -2
- data/scientist.gemspec +5 -3
- data/script/test +4 -1
- data/test/scientist/default_test.rb +23 -0
- data/test/scientist/experiment_test.rb +398 -0
- data/test/scientist/observation_test.rb +93 -0
- data/test/scientist/result_test.rb +111 -0
- data/test/scientist_test.rb +42 -4
- metadata +22 -21
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7b1ce13f8a2483a8027f3252d79a962dd116733c
|
4
|
+
data.tar.gz: 54e81bb6123073b95c9bec0b1e751f88519fd498
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5e97d816e8b340701d2a6488799f899ae74480992e223258615c96c6f19042d53a867680aada5cc010c5718fa499d31889056f111b9476b810ac74af2023b4d7
|
7
|
+
data.tar.gz: c65a04f83ef07d9c770354fc5a1dcfa1a76199f150152c0c39735839eec2cf975f333779418827c201d03b4e9047d9afcb740d1ac735a69004e2a945afafb5de
|
data/CLA.md
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
GitHub CLA
|
2
|
+
===============
|
3
|
+
|
4
|
+
## Don't give up - please go ahead and create this PR.
|
5
|
+
|
6
|
+
We welcome you to follow, fork, and work on, our open source projects. If you want to contribute back to this project, or any other GitHub project, we need to ask you to complete the Contributor License Agreement (CLA) below. If you are contributing on behalf of your employer, or as part of your role as an employee, remember that you are signing in the name of your employer and you have to make sure that that's okay before you sign.
|
7
|
+
|
8
|
+
## What is this?
|
9
|
+
|
10
|
+
This is GitHub Inc.’s Contributor License Agreement. If you’ve worked in the technology space before, contributed or maintained an open source project, there’s a good chance that you’ve run across one or more of these in the past. What CLAs aim to do is make sure the project is able to merge contributions from multiple contributors without getting itself into different types of trouble. This one is no different in that sense.
|
11
|
+
|
12
|
+
## Why is this?
|
13
|
+
|
14
|
+
The answer is that we need to protect the open source projects that we maintain, their users and their contributors (including Hubber contributors, of course, but not just). Why? Just imagine a case when a contributor is making a contribution to a project and that contribution is subsequently merged and becomes an integral part of the project. Now, go on to imagine that our contributor copied the code, or that this contributor works for a company that doesn't want its employees to make contributions to this project or any project. What then? Well, the person the code was copied from or the company can do whatever the hell they like, including to come after the project and its users if using it, make them stop or even sue. If either has patent rights in the code, the project is in even deeper trouble.
|
15
|
+
|
16
|
+
|
17
|
+
## So.
|
18
|
+
|
19
|
+
Please read the following terms, make sure you understand, and that if you agree, that you sign. Then, your pull request would be created and the project and the other contributors would be safe. It's important to us that you remember that except for the license you grant by signing this document - to GitHub, to your fellow contributors and to the project, you reserve all right, title, and interest in your contributions.
|
20
|
+
|
21
|
+
### 1. Definitions.
|
22
|
+
|
23
|
+
*You*, *you* (*Your*, or *your*) means the copyright owner or legal entity authorized by the copyright owner to sign this agreement.
|
24
|
+
|
25
|
+
*Contribution* or *contribution* means any original work of authorship, including any modifications or additions to an existing work, that is submitted to a GitHub project. "Submitted" means via a Pull Request, an issue or any form of electronic, verbal, or written communication sent to GitHub.
|
26
|
+
|
27
|
+
### 2. Grant of Copyright License.
|
28
|
+
|
29
|
+
Subject to the terms and conditions of this agreement, you grant to GitHub, to fellow contributors and to the project a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute your contributions and such derivative works.
|
30
|
+
|
31
|
+
### 3. Grant of Patent License.
|
32
|
+
|
33
|
+
Subject to the terms and conditions of this agreement, You hereby grant to GitHub, to fellow contributors to the project, and to its users a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer your contribution as part of the project, where such license applies only to those patent claims licensable by you that are necessarily infringed by your contribution or by combination of your contribution with the project to which this contribution was submitted. If any entity institutes patent litigation against you or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your contribution, or the project to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this agreement for that contribution shall terminate as of the date such litigation is filed.
|
34
|
+
|
35
|
+
### 4. You Can Grant this License.
|
36
|
+
|
37
|
+
Signing would mean that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your contributions, you have verified and are certain that you have received permission to make your contribution on behalf of that employer, that your employer has waived such rights for your contribution, or that your employer has executed a separate license with GitHub or the project.
|
38
|
+
|
39
|
+
### 5. Your Contribution is Yours.
|
40
|
+
|
41
|
+
Signing doesn't change the fact that your contribution is your original creation (see section 7 for submissions on behalf of others) and that they include complete details of any third-party license or other restriction (including related patents and trademarks) of which you are personally aware and which are associated with any part of your contributions.
|
42
|
+
|
43
|
+
### 6. You Provide Your Contribution "as is".
|
44
|
+
|
45
|
+
Signing this won't mean anybody will argue otherwise. In other words, your contributions are made without warranties or conditions of any kind.
|
46
|
+
|
47
|
+
### 7. If Some or All Your Contributions Is Not Yours.
|
48
|
+
|
49
|
+
That's fine but you need to identify the source or sources of the contribution and any license or other restriction (like related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the contribution as "Submitted on behalf of a third-party or third parties: [named here]". A place where you can do this is in a commit comment to the PR.
|
50
|
+
|
51
|
+
### 8. If Any Circumstances of Your Contribution change.
|
52
|
+
You agree to notify the project and GitHub of any facts or circumstances of which you become aware.
|
53
|
+
|
54
|
+
### 9. That's it!
|
data/CONTRIBUTING.md
ADDED
@@ -0,0 +1,3 @@
|
|
1
|
+
Hi there! We're thrilled that you'd like to contribute to this project. Before you do, would you mind reading [this license agreement](CLA.md)? If you open a PR, we'll assume you agree to it. If you have any hesitation or disagreement, please do open a PR still, but note your concerns as well.
|
2
|
+
|
3
|
+
Thanks!
|
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -1,11 +1,420 @@
|
|
1
|
-
#
|
1
|
+
# Scientist!
|
2
2
|
|
3
|
-
A Ruby library for carefully refactoring critical paths.
|
3
|
+
A Ruby library for carefully refactoring critical paths.
|
4
|
+
|
5
|
+
## How do I do science?
|
6
|
+
|
7
|
+
Let's pretend you're changing the way you handle permissions in a large web app. Tests can help guide your refactoring, but you really want to compare the current and refactored behaviors under load.
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
require "scientist"
|
11
|
+
|
12
|
+
class MyWidget
|
13
|
+
def allows?(user)
|
14
|
+
experiment = Scientist::Default.new "widget-permissions" do |e|
|
15
|
+
e.use { model.check_user?(user).valid? } # old way
|
16
|
+
e.try { user.can?(:read, model) } # new way
|
17
|
+
end
|
18
|
+
|
19
|
+
experiment.run
|
20
|
+
end
|
21
|
+
end
|
22
|
+
```
|
23
|
+
|
24
|
+
Wrap a `use` block around the code's original behavior, and wrap `try` around the new behavior. `experiment.run` will always return whatever the `use` block returns, but it does a bunch of stuff behind the scenes:
|
25
|
+
|
26
|
+
* It decides whether or not to run the `try` block,
|
27
|
+
* Randomizes the order in which `use` and `try` blocks are run,
|
28
|
+
* Measures the durations of all behaviors,
|
29
|
+
* Compares the result of `try` to the result of `use`,
|
30
|
+
* Swallows (but records) any exceptions raise in the `try` block, and
|
31
|
+
* Publishes all this information.
|
32
|
+
|
33
|
+
The `use` block is called the **control**. The `try` block is called the **candidate**.
|
34
|
+
|
35
|
+
Creating an experiment is wordy, but when you include the `Scientist` module, the `science` helper will instantiate an experiment and call `run` for you:
|
36
|
+
|
37
|
+
```ruby
|
38
|
+
require "scientist"
|
39
|
+
|
40
|
+
class MyWidget
|
41
|
+
include Scientist
|
42
|
+
|
43
|
+
def allows?(user)
|
44
|
+
science "widget-permissions" do |e|
|
45
|
+
e.use { model.check_user(user).valid? } # old way
|
46
|
+
e.try { user.can?(:read, model) } # new way
|
47
|
+
end # returns the control value
|
48
|
+
end
|
49
|
+
end
|
50
|
+
```
|
51
|
+
|
52
|
+
If you don't declare any `try` blocks, none of the Scientist machinery is invoked and the control value is always returned.
|
53
|
+
|
54
|
+
## Making science useful
|
55
|
+
|
56
|
+
The examples above will run, but they're not really *doing* anything. The `try` blocks run every time and none of the results get published. Replace the default experiment implementation to control execution and reporting:
|
57
|
+
|
58
|
+
```ruby
|
59
|
+
require "scientist"
|
60
|
+
|
61
|
+
class MyExperiment < ActiveRecord::Base
|
62
|
+
include Scientist::Experiment
|
63
|
+
|
64
|
+
def enabled?
|
65
|
+
# see "Ramping up experiments" below
|
66
|
+
super
|
67
|
+
end
|
68
|
+
|
69
|
+
def publish(result)
|
70
|
+
# see "Publishing results" below
|
71
|
+
super
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# replace `Scientist::Default` as the default implementation
|
76
|
+
def Scientist::Experiment.new(name)
|
77
|
+
MyExperiment.find_or_initialize_by(name: name)
|
78
|
+
end
|
79
|
+
```
|
80
|
+
|
81
|
+
Now calls to the `science` helper will load instances of `MyExperiment`.
|
82
|
+
|
83
|
+
### Controlling comparison
|
84
|
+
|
85
|
+
Scientist compares control and candidate values using `==`. To override this behavior, use `compare` to define how to compare observed values instead:
|
86
|
+
|
87
|
+
```ruby
|
88
|
+
class MyWidget
|
89
|
+
include Scientist
|
90
|
+
|
91
|
+
def users
|
92
|
+
science "users" do |e|
|
93
|
+
e.use { User.all } # returns User instances
|
94
|
+
e.try { UserService.list } # returns UserService::User instances
|
95
|
+
|
96
|
+
e.compare do |control, candidate|
|
97
|
+
control.map(&:login) == candidate.map(&:login)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
```
|
103
|
+
|
104
|
+
### Adding context
|
105
|
+
|
106
|
+
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:
|
107
|
+
|
108
|
+
```ruby
|
109
|
+
science "widget-permissions" do |e|
|
110
|
+
e.context :user => user
|
111
|
+
|
112
|
+
e.use { model.check_user(user).valid? }
|
113
|
+
e.try { user.can?(:read, model) }
|
114
|
+
end
|
115
|
+
```
|
116
|
+
|
117
|
+
`context` takes a Symbol-keyed Hash of extra data. The data is available in `Experiment#publish` via the `context` method. If you're using the `science` helper a lot in a class, you can provide a default context:
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
class MyWidget
|
121
|
+
include Scientist
|
122
|
+
|
123
|
+
def allows?(user)
|
124
|
+
science "widget-permissions" do |e|
|
125
|
+
e.context :user => user
|
126
|
+
|
127
|
+
e.use { model.check_user(user).valid? }
|
128
|
+
e.try { user.can?(:read, model) }
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def destroy
|
133
|
+
science "widget-destruction" do |e|
|
134
|
+
e.use { old_scary_destroy }
|
135
|
+
e.try { new_safe_destroy }
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def default_scientist_context
|
140
|
+
{ :widget => self }
|
141
|
+
end
|
142
|
+
end
|
143
|
+
```
|
144
|
+
|
145
|
+
The `widget-permissions` and `widget-destruction` experiments will both have a `:widget` key in their contexts.
|
146
|
+
|
147
|
+
### Keeping it clean
|
148
|
+
|
149
|
+
Sometimes you don't want to store the full value for later analysis. For example, an experiment may return `User` instances, but when researching a mismatch, all you care about is the logins. You can define how to clean these values in an experiment:
|
150
|
+
|
151
|
+
```ruby
|
152
|
+
class MyWidget
|
153
|
+
include Scientist
|
154
|
+
|
155
|
+
def users
|
156
|
+
science "users" do |e|
|
157
|
+
e.use { User.all }
|
158
|
+
e.try { UserService.list }
|
159
|
+
|
160
|
+
e.clean do |value|
|
161
|
+
value.map(&:login).sort
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
```
|
167
|
+
|
168
|
+
And this cleaned value is available in observations in the final published result:
|
169
|
+
|
170
|
+
```ruby
|
171
|
+
class MyExperiment < ActiveRecord::Base
|
172
|
+
include Scientist::Experiment
|
173
|
+
|
174
|
+
def publish(result)
|
175
|
+
result.control.value # [<User alice>, <User bob>, <User carol>]
|
176
|
+
result.control.cleaned_value # ["alice", "bob", "carol"]
|
177
|
+
end
|
178
|
+
end
|
179
|
+
```
|
180
|
+
|
181
|
+
### Ignoring mismatches
|
182
|
+
|
183
|
+
During the early stages of an experiment, it's possible that some of your code will always generate a mismatch for reasons you know and understand but haven't yet fixed. Instead of these known cases always showing up as mismatches in your metrics or analysis, you can tell an experiment whether or not to ignore a mismatch using the `ignore` method. You may include more than one block if needed:
|
184
|
+
|
185
|
+
```ruby
|
186
|
+
def admin?(user)
|
187
|
+
science "widget-permissions" do |e|
|
188
|
+
e.use { model.check_user(user).admin? }
|
189
|
+
e.try { user.can?(:admin, model) }
|
190
|
+
|
191
|
+
e.ignore { user.staff? } # user is staff, always an admin in the new system
|
192
|
+
e.ignore do |control, candidate|
|
193
|
+
# new system doesn't handle unconfirmed users yet:
|
194
|
+
control && !candidate && !user.confirmed_email?
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
```
|
199
|
+
|
200
|
+
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.
|
201
|
+
|
202
|
+
### Enabling/disabling experiments
|
203
|
+
|
204
|
+
Sometimes you don't want an experiment to run. Say, disabling a new codepath for anyone who isn't staff. You can disable an experiment by setting a `run_if` block. If this returns `false`, the experiment will merely return the control value. Otherwise, it defers to the experiment's configured `enabled?` method.
|
205
|
+
|
206
|
+
```ruby
|
207
|
+
class DashboardController
|
208
|
+
include Scientist
|
209
|
+
|
210
|
+
def dashboard_items
|
211
|
+
science "dashboard-items" do |e|
|
212
|
+
# only run this experiment for staff members
|
213
|
+
e.run_if { current_user.staff? }
|
214
|
+
# ...
|
215
|
+
end
|
216
|
+
end
|
217
|
+
```
|
218
|
+
|
219
|
+
### Ramping up experiments
|
220
|
+
|
221
|
+
As a scientist, you know it's always important to be able to turn your experiment off, lest it run amok and result in villagers with pitchforks on your doorstep. In order to control whether or not an experiment is enabled, you must include the `enabled?` method in your `Scientist::Experiment` implementation.
|
222
|
+
|
223
|
+
```ruby
|
224
|
+
class MyExperiment < ActiveRecord::Base
|
225
|
+
include Scientist::Experiment
|
226
|
+
def enabled?
|
227
|
+
percent_enabled > 0 && rand(100) < percent_enabled
|
228
|
+
end
|
229
|
+
end
|
230
|
+
```
|
231
|
+
|
232
|
+
This code will be invoked for every method with an experiment every time, so be sensitive about its performance. For example, you can store an experiment in the database but wrap it in various levels of caching such as memcache or per-request thread-locals.
|
233
|
+
|
234
|
+
### Publishing results
|
235
|
+
|
236
|
+
What good is science if you can't publish your results?
|
237
|
+
|
238
|
+
You must implement the `publish(result)` method, and can publish data however you like. For example, timing data can be sent to graphite, and mismatches can be placed in a capped collection in redis for debugging later.
|
239
|
+
|
240
|
+
The `publish` method is given a `Scientist::Result` instance with its associated `Scientist::Observation`s:
|
241
|
+
|
242
|
+
```ruby
|
243
|
+
class MyExperiment
|
244
|
+
include Scientist::Experiment
|
245
|
+
|
246
|
+
# ...
|
247
|
+
|
248
|
+
def publish(result)
|
249
|
+
|
250
|
+
# Store the timing for the control value,
|
251
|
+
$statsd.timing "science.#{name}.control", result.control.duration
|
252
|
+
# for the candidate (only the first, see "Breaking the rules" below,
|
253
|
+
$statsd.timing "science.#{name}.candidate", result.candidates.first.duration
|
254
|
+
|
255
|
+
# and counts for match/ignore/mismatch:
|
256
|
+
if result.matched?
|
257
|
+
$statsd.increment "science.#{name}.matched"
|
258
|
+
elsif result.ignored?
|
259
|
+
$statsd.increment "science.#{name}.ignored"
|
260
|
+
else
|
261
|
+
$statsd.increment "science.#{name}.mismatched"
|
262
|
+
# Finally, store mismatches in redis so they can be retrieved and examined
|
263
|
+
# later on, for debugging and research.
|
264
|
+
store_mismatch_data(result)
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
def store_mismatch_data(result)
|
269
|
+
payload = {
|
270
|
+
:name => name,
|
271
|
+
:context => context,
|
272
|
+
:control => observation_payload(result.control),
|
273
|
+
:candidate => observation_payload(result.candidates.first)
|
274
|
+
:execution_order => result.observations.map(&:name),
|
275
|
+
}
|
276
|
+
|
277
|
+
key = "science.#{name}.mismatch"
|
278
|
+
$redis.lpush key, payload
|
279
|
+
$redis.ltrim key, 0, 1000
|
280
|
+
end
|
281
|
+
|
282
|
+
def observation_payload(observation)
|
283
|
+
if observation.raised?
|
284
|
+
{
|
285
|
+
:exception => observation.exeception.class,
|
286
|
+
:message => observation.exeception.message,
|
287
|
+
:backtrace => observation.exception.backtrace
|
288
|
+
}
|
289
|
+
else
|
290
|
+
{
|
291
|
+
# see "Keeping it clean" below
|
292
|
+
:value => observation.cleaned_value
|
293
|
+
}
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
```
|
298
|
+
|
299
|
+
### Testing
|
300
|
+
|
301
|
+
When running your test suite, it's helpful to know that the experimental results always match. To help with testing, Scientist defines a `raise_on_mismatches` class attribute when you include `Scientist::Experiment`. Only do this in your test suite!
|
302
|
+
|
303
|
+
To raise on mismatches:
|
304
|
+
|
305
|
+
```ruby
|
306
|
+
class MyExperiment
|
307
|
+
include Scientist::Experiment
|
308
|
+
# ... implementation
|
309
|
+
end
|
310
|
+
|
311
|
+
MyExperiment.raise_on_mismatches = true
|
312
|
+
```
|
313
|
+
|
314
|
+
Scientist will raise a `Scientist::Experiment::MismatchError` exception if any observations don't match.
|
315
|
+
|
316
|
+
### Handling errors
|
317
|
+
|
318
|
+
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:
|
319
|
+
|
320
|
+
```ruby
|
321
|
+
class MyExperiment
|
322
|
+
include Scientist::Experiment
|
323
|
+
|
324
|
+
# ...
|
325
|
+
|
326
|
+
def raised(operation, error)
|
327
|
+
InternalErrorTracker.track! "science failure in #{name}: #{operation}", error
|
328
|
+
end
|
329
|
+
end
|
330
|
+
```
|
331
|
+
|
332
|
+
The operations that may be handled here are:
|
333
|
+
|
334
|
+
* `:clean` - an exception is raised in a `clean` block
|
335
|
+
* `:compare` - an exception is raised in a `compare` block
|
336
|
+
* `:enabled` - an exception is raised in the `enabled?` method
|
337
|
+
* `:ignore` - an exception is raised in an `ignore` block
|
338
|
+
* `:publish` - an exception is raised in the `publish` method
|
339
|
+
* `:run_if` - an exception is raised in a `run_if` block
|
340
|
+
|
341
|
+
### Designing an experiment
|
342
|
+
|
343
|
+
Because `enabled?` and `run_if` determine when a candidate runs, it's impossible to guarantee that it will run every time. For this reason, Scientist is only safe for wrapping methods that aren't changing data.
|
344
|
+
|
345
|
+
When using Scientist, we've found it most useful to modify both the existing and new systems simultaneously anywhere writes happen, and verify the results at read time with `science`. `raise_on_mismatches` has also been useful to ensure that the correct data was written during tests, and reviewing published mismatches has helped us find any situations we overlooked with our production data at runtime. When writing to and reading from two systems, it's also useful to write some data reconciliation scripts to verify and clean up production data alongside any running experiments.
|
346
|
+
|
347
|
+
### Finishing an experiment
|
348
|
+
|
349
|
+
As your candidate behavior converges on the controls, you'll start thinking about removing an experiment and using the new behavior.
|
350
|
+
|
351
|
+
* If there are any ignore blocks, the candidate behavior is *guaranteed* to be different. If this is unacceptable, you'll need to remove the ignore blocks and resolve any ongoing mismatches in behavior until the observations match perfectly every time.
|
352
|
+
* When removing a read-behavior experiment, it's a good idea to keep any write-side duplication between an old and new system in place until well after the new behavior has been in production, in case you need to roll back.
|
353
|
+
|
354
|
+
## Breaking the rules
|
355
|
+
|
356
|
+
Sometimes scientists just gotta do weird stuff. We understand.
|
357
|
+
|
358
|
+
### Ignoring results entirely
|
359
|
+
|
360
|
+
Science is useful even when all you care about is the timing data or even whether or not a new code path blew up. If you have the ability to incrementally control how often an experiment runs via your `enabled?` method, you can use it to silently and carefully test new code paths and ignore the results altogether. You can do this by setting `ignore { true }`, or for greater efficiency, `compare { true }`.
|
361
|
+
|
362
|
+
This will still log mismatches if any exceptions are raised, but will disregard the values entirely.
|
363
|
+
|
364
|
+
### Trying more than one thing
|
365
|
+
|
366
|
+
It's not usually a good idea to try more than one alternative simultaneously. Behavior isn't guaranteed to be isolated and reporting + visualization get quite a bit harder. Still, it's sometimes useful.
|
367
|
+
|
368
|
+
To try more than one alternative at once, add names to some `try` blocks:
|
369
|
+
|
370
|
+
```ruby
|
371
|
+
require "scientist"
|
372
|
+
|
373
|
+
class MyWidget
|
374
|
+
include Scientist
|
375
|
+
|
376
|
+
def allows?(user)
|
377
|
+
science "widget-permissions" do |e|
|
378
|
+
e.use { model.check_user(user).valid? } # old way
|
379
|
+
|
380
|
+
e.try("api") { user.can?(:read, model) } # new service API
|
381
|
+
e.try("raw-sql") { user.can_sql?(:read, model) } # raw query
|
382
|
+
end
|
383
|
+
end
|
384
|
+
end
|
385
|
+
```
|
386
|
+
|
387
|
+
When the experiment runs, all candidate behaviors are tested and each candidate observation is compared with the control in turn.
|
388
|
+
|
389
|
+
### No control, just candidates
|
390
|
+
|
391
|
+
Define the candidates with named `try` blocks, omit a `use`, and pass a candidate name to `run`:
|
392
|
+
|
393
|
+
```ruby
|
394
|
+
experiment = MyExperiment.new("various-ways") do |e|
|
395
|
+
e.try("first-way") { ... }
|
396
|
+
e.try("second-way") { ... }
|
397
|
+
end
|
398
|
+
|
399
|
+
experiment.run("second-way")
|
400
|
+
```
|
401
|
+
|
402
|
+
The `science` helper also knows this trick:
|
403
|
+
|
404
|
+
```ruby
|
405
|
+
science "various-ways", run: "first-way" do |e|
|
406
|
+
e.try("first-way") { ... }
|
407
|
+
e.try("second-way") { ... }
|
408
|
+
end
|
409
|
+
```
|
4
410
|
|
5
411
|
## Hacking
|
6
412
|
|
7
|
-
Be on a Unixy box. Make sure a modern Bundler is available. script/test runs the unit tests. All development dependencies
|
413
|
+
Be on a Unixy box. Make sure a modern Bundler is available. `script/test` runs the unit tests. All development dependencies are installed automatically. Science requires Ruby 2.1.
|
8
414
|
|
9
415
|
## Maintainers
|
10
416
|
|
11
|
-
[@jbarnette](https://github.com/jbarnette)
|
417
|
+
[@jbarnette](https://github.com/jbarnette),
|
418
|
+
[@jesseplusplus](https://github.com/jesseplusplus),
|
419
|
+
[@rick](https://github.com/rick),
|
420
|
+
and [@zerowidth](https://github.com/zerowidth)
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require "scientist/experiment"
|
2
|
+
|
3
|
+
# A null experiment.
|
4
|
+
class Scientist::Default
|
5
|
+
include Scientist::Experiment
|
6
|
+
|
7
|
+
attr_reader :name
|
8
|
+
|
9
|
+
def initialize(name)
|
10
|
+
@name = name
|
11
|
+
end
|
12
|
+
|
13
|
+
# Run everything every time.
|
14
|
+
def enabled?
|
15
|
+
true
|
16
|
+
end
|
17
|
+
|
18
|
+
# Don't publish anything.
|
19
|
+
def publish(result)
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Scientist
|
2
|
+
|
3
|
+
# Smoking in the bathroom and/or sassing.
|
4
|
+
class BadBehavior < StandardError
|
5
|
+
attr_reader :experiment
|
6
|
+
attr_reader :name
|
7
|
+
|
8
|
+
def initialize(experiment, name, message)
|
9
|
+
@experiment = experiment
|
10
|
+
@name = name
|
11
|
+
|
12
|
+
super message
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class BehaviorMissing < BadBehavior
|
17
|
+
def initialize(experiment, name)
|
18
|
+
super experiment, name,
|
19
|
+
"#{experiment.name} missing #{name} behavior"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
class BehaviorNotUnique < BadBehavior
|
24
|
+
def initialize(experiment, name)
|
25
|
+
super experiment, name,
|
26
|
+
"#{experiment.name} alread has #{name} behavior"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class NoValue < StandardError
|
31
|
+
attr_reader :observation
|
32
|
+
|
33
|
+
def initialize(observation)
|
34
|
+
@observation = observation
|
35
|
+
super "#{observation.name} didn't return a value"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|