John Barnette 2014-11-17 13:38:03 -06:00
ΠšΠΎΠΌΠΌΠΈΡ‚ 37071bc02b
20 ΠΈΠ·ΠΌΠ΅Π½Ρ‘Π½Π½Ρ‹Ρ… Ρ„Π°ΠΉΠ»ΠΎΠ²: 1703 Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΠΉ ΠΈ 0 ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠΉ

4
.gitignore поставляСмый Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,4 @@
/*.gem
/.bundle
/.ruby-version
/Gemfile.lock

3
Gemfile Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,3 @@
source "https://rubygems.org"
gemspec

22
LICENSE.txt Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,22 @@
Copyright 2013, 2014 GitHub, Inc.
MIT License
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

420
README.md Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,420 @@
# Scientist!
A Ruby library for carefully refactoring critical paths.
## How do I do science?
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.
```ruby
require "scientist"
class MyWidget
def allows?(user)
experiment = Scientist::Default.new "widget-permissions" do |e|
e.use { model.check_user?(user).valid? } # old way
e.try { user.can?(:read, model) } # new way
end
experiment.run
end
end
```
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:
* It decides whether or not to run the `try` block,
* Randomizes the order in which `use` and `try` blocks are run,
* Measures the durations of all behaviors,
* Compares the result of `try` to the result of `use`,
* Swallows (but records) any exceptions raise in the `try` block, and
* Publishes all this information.
The `use` block is called the **control**. The `try` block is called the **candidate**.
Creating an experiment is wordy, but when you include the `Scientist` module, the `science` helper will instantiate an experiment and call `run` for you:
```ruby
require "scientist"
class MyWidget
include Scientist
def allows?(user)
science "widget-permissions" do |e|
e.use { model.check_user(user).valid? } # old way
e.try { user.can?(:read, model) } # new way
end # returns the control value
end
end
```
If you don't declare any `try` blocks, none of the Scientist machinery is invoked and the control value is always returned.
## Making science useful
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:
```ruby
require "scientist"
class MyExperiment < ActiveRecord::Base
include Scientist::Experiment
def enabled?
# see "Ramping up experiments" below
super
end
def publish(result)
# see "Publishing results" below
super
end
end
# replace `Scientist::Default` as the default implementation
def Scientist::Experiment.new(name)
MyExperiment.find_or_initialize_by(name: name)
end
```
Now calls to the `science` helper will load instances of `MyExperiment`.
### Controlling comparison
Scientist compares control and candidate values using `==`. To override this behavior, use `compare` to define how to compare observed values instead:
```ruby
class MyWidget
include Scientist
def users
science "users" do |e|
e.use { User.all } # returns User instances
e.try { UserService.list } # returns UserService::User instances
e.compare do |control, candidate|
control.map(&:login) == candidate.map(&:login)
end
end
end
end
```
### Adding context
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:
```ruby
science "widget-permissions" do |e|
e.context :user => user
e.use { model.check_user(user).valid? }
e.try { user.can?(:read, model) }
end
```
`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:
```ruby
class MyWidget
include Scientist
def allows?(user)
science "widget-permissions" do |e|
e.context :user => user
e.use { model.check_user(user).valid? }
e.try { user.can?(:read, model) }
end
end
def destroy
science "widget-destruction" do |e|
e.use { old_scary_destroy }
e.try { new_safe_destroy }
end
end
def default_scientist_context
{ :widget => self }
end
end
```
The `widget-permissions` and `widget-destruction` experiments will both have a `:widget` key in their contexts.
### Keeping it clean
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:
```ruby
class MyWidget
include Scientist
def users
science "users" do |e|
e.use { User.all }
e.try { UserService.list }
e.clean do |value|
value.map(&:login).sort
end
end
end
end
```
And this cleaned value is available in observations in the final published result:
```ruby
class MyExperiment < ActiveRecord::Base
include Scientist::Experiment
def publish(result)
result.control.value # [<User alice>, <User bob>, <User carol>]
result.control.cleaned_value # ["alice", "bob", "carol"]
end
end
```
### Ignoring mismatches
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:
```ruby
def admin?(user)
science "widget-permissions" do |e|
e.use { model.check_user(user).admin? }
e.try { user.can?(:admin, model) }
e.ignore { user.staff? } # user is staff, always an admin in the new system
e.ignore do |control, candidate|
# new system doesn't handle unconfirmed users yet:
control && !candidate && !user.confirmed_email?
end
end
end
```
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.
### Enabling/disabling experiments
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.
```ruby
class DashboardController
include Scientist
def dashboard_items
science "dashboard-items" do |e|
# only run this experiment for staff members
e.run_if { current_user.staff? }
# ...
end
end
```
### Ramping up experiments
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.
```ruby
class MyExperiment < ActiveRecord::Base
include Scientist::Experiment
def enabled?
percent_enabled > 0 && rand(100) < percent_enabled
end
end
```
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.
### Publishing results
What good is science if you can't publish your results?
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.
The `publish` method is given a `Scientist::Result` instance with its associated `Scientist::Observation`s:
```ruby
class MyExperiment
include Scientist::Experiment
# ...
def publish(result)
# Store the timing for the control value,
$statsd.timing "science.#{name}.control", result.control.duration
# for the candidate (only the first, see "Breaking the rules" below,
$statsd.timing "science.#{name}.candidate", result.candidates.first.duration
# and counts for match/ignore/mismatch:
if result.matched?
$statsd.increment "science.#{name}.matched"
elsif result.ignored?
$statsd.increment "science.#{name}.ignored"
else
$statsd.increment "science.#{name}.mismatched"
# Finally, store mismatches in redis so they can be retrieved and examined
# later on, for debugging and research.
store_mismatch_data(result)
end
end
def store_mismatch_data(result)
payload = {
:name => name,
:context => context,
:control => observation_payload(result.control),
:candidate => observation_payload(result.candidates.first)
:execution_order => result.observations.map(&:name),
}
key = "science.#{name}.mismatch"
$redis.lpush key, payload
$redis.ltrim key, 0, 1000
end
def observation_payload(observation)
if observation.raised?
{
:exception => observation.exeception.class,
:message => observation.exeception.message,
:backtrace => observation.exception.backtrace
}
else
{
# see "Keeping it clean" below
:value => observation.cleaned_value
}
end
end
end
```
### Testing
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!
To raise on mismatches:
```ruby
class MyExperiment
include Scientist::Experiment
# ... implementation
end
MyExperiment.raise_on_mismatches = true
```
Scientist will raise a `Scientist::Experiment::MismatchError` exception if any observations don't match.
### Handling errors
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:
```ruby
class MyExperiment
include Scientist::Experiment
# ...
def raised(operation, error)
InternalErrorTracker.track! "science failure in #{name}: #{operation}", error
end
end
```
The operations that may be handled here are:
* `:clean` - an exception is raised in a `clean` block
* `:compare` - an exception is raised in a `compare` block
* `:enabled` - an exception is raised in the `enabled?` method
* `:ignore` - an exception is raised in an `ignore` block
* `:publish` - an exception is raised in the `publish` method
* `:run_if` - an exception is raised in a `run_if` block
### Designing an experiment
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.
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.
### Finishing an experiment
As your candidate behavior converges on the controls, you'll start thinking about removing an experiment and using the new behavior.
* 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.
* 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.
## Breaking the rules
Sometimes scientists just gotta do weird stuff. We understand.
### Ignoring results entirely
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 }`.
This will still log mismatches if any exceptions are raised, but will disregard the values entirely.
### Trying more than one thing
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.
To try more than one alternative at once, add names to some `try` blocks:
```ruby
require "scientist"
class MyWidget
include Scientist
def allows?(user)
science "widget-permissions" do |e|
e.use { model.check_user(user).valid? } # old way
e.try("api") { user.can?(:read, model) } # new service API
e.try("raw-sql") { user.can_sql?(:read, model) } # raw query
end
end
end
```
When the experiment runs, all candidate behaviors are tested and each candidate observation is compared with the control in turn.
### No control, just candidates
Define the candidates with named `try` blocks, omit a `use`, and pass a candidate name to `run`:
```ruby
experiment = MyExperiment.new("various-ways") do |e|
e.try("first-way") { ... }
e.try("second-way") { ... }
end
experiment.run("second-way")
```
The `science` helper also knows this trick:
```ruby
science "various-ways", run: "first-way" do |e|
e.try("first-way") { ... }
e.try("second-way") { ... }
end
```
## Hacking
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.
## Maintainers
[@jbarnette](https://github.com/jbarnette),
[@jesseplusplus](https://github.com/jesseplusplus),
[@rick](https://github.com/rick),
and [@zerowidth](https://github.com/zerowidth)

41
lib/scientist.rb Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,41 @@
# Include this module into any class which requires science experiments in its
# methods. Provides the `science` and `default_scientist_context` methods for
# defining and running experiments.
#
# If you need to run science on class methods, extend this module instead.
module Scientist
# Define and run a science experiment.
#
# name - a String name for this experiment.
# run: - optional argument for which named test to run instead of "control".
#
# Yields an object which implements the Scientist::Experiment interface.
# See `Scientist::Experiment.new` for how this is defined.
#
# Returns the calculated value of the control experiment, or raises if an
# exception was raised.
def science(name, run: nil)
experiment = Experiment.new(name)
experiment.context(default_scientist_context)
yield experiment
experiment.run(run)
end
# Public: the default context data for an experiment created and run via the
# `science` helper method. Override this in any class that includes Scientist
# to define your own behavior.
#
# Returns a Hash.
def default_scientist_context
{}
end
end
require "scientist/default"
require "scientist/errors"
require "scientist/experiment"
require "scientist/observation"
require "scientist/result"
require "scientist/version"

21
lib/scientist/default.rb Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,21 @@
require "scientist/experiment"
# A null experiment.
class Scientist::Default
include Scientist::Experiment
attr_reader :name
def initialize(name)
@name = name
end
# Run everything every time.
def enabled?
true
end
# Don't publish anything.
def publish(result)
end
end

38
lib/scientist/errors.rb Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,38 @@
module Scientist
# Smoking in the bathroom and/or sassing.
class BadBehavior < StandardError
attr_reader :experiment
attr_reader :name
def initialize(experiment, name, message)
@experiment = experiment
@name = name
super message
end
end
class BehaviorMissing < BadBehavior
def initialize(experiment, name)
super experiment, name,
"#{experiment.name} missing #{name} behavior"
end
end
class BehaviorNotUnique < BadBehavior
def initialize(experiment, name)
super experiment, name,
"#{experiment.name} alread has #{name} behavior"
end
end
class NoValue < StandardError
attr_reader :observation
def initialize(observation)
@observation = observation
super "#{observation.name} didn't return a value"
end
end
end

233
lib/scientist/experiment.rb Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,233 @@
# This mixin provides shared behavior for experiments. Includers must implement
# `enabled?` and `publish(result)`.
#
# Override Scientist::Experiment.new to set your own class which includes and
# implements Scientist::Experiment's interface.
module Scientist::Experiment
# Create a new instance of a class that implements the Scientist::Experiment
# interface.
#
# Override this method directly to change the default implementation.
def self.new(name)
Scientist::Default.new(name)
end
# A mismatch, raised when raise_on_mismatches is enabled.
class MismatchError < StandardError
def initialize(name, result)
super "#{name}: control #{result.control.inspect}, candidates #{result.candidates.map(&:inspect)}"
end
end
module RaiseOnMismatch
# Set this flag to raise on experiment mismatches.
#
# This causes all science mismatches to raise a MismatchError. This is
# intended for test environments and should not be enabled in a production
# environment.
#
# bool - true/false - whether to raise when the control and candidate mismatch.
def raise_on_mismatches=(bool)
@raise_on_mismatches = bool
end
# Whether or not to raise a mismatch error when a mismatch occurs.
def raise_on_mismatches?
@raise_on_mismatches
end
end
def self.included(base)
base.extend RaiseOnMismatch
end
# A Hash of behavior blocks, keyed by String name. Register behavior blocks
# with the `try` and `use` methods.
def behaviors
@_scientist_behaviors ||= {}
end
# A block to clean an observed value for publishing or storing.
#
# The block takes one argument, the observed value which will be cleaned.
#
# Returns the configured block.
def clean(&block)
@_scientist_cleaner = block
end
# Internal: Clean a value with the configured clean block, or return the value
# if no clean block is configured.
#
# Rescues and reports exceptions in the clean block if they occur.
def clean_value(value)
if @_scientist_cleaner
@_scientist_cleaner.call value
else
value
end
rescue StandardError => ex
raised :clean, ex
value
end
# A block which compares two experimental values.
#
# The block must take two arguments, the control value and a candidate value,
# and return true or false.
#
# Returns the block.
def compare(*args, &block)
@_scientist_comparator = block
end
# A Symbol-keyed Hash of extra experiment data.
def context(context = nil)
@_scientist_context ||= {}
@_scientist_context.merge!(context) if !context.nil?
@_scientist_context
end
# Configure this experiment to ignore an observation with the given block.
#
# The block takes two arguments, the control observation and the candidate
# observation which didn't match the control. If the block returns true, the
# mismatch is disregarded.
#
# This can be called more than once with different blocks to use.
def ignore(&block)
@_scientist_ignores ||= []
@_scientist_ignores << block
end
# Internal: ignore a mismatched observation?
#
# Iterates through the configured ignore blocks and calls each of them with
# the given control and mismatched candidate observations.
#
# Returns true or false.
def ignore_mismatched_observation?(control, candidate)
return false unless @_scientist_ignores
@_scientist_ignores.any? do |ignore|
begin
ignore.call control.value, candidate.value
rescue StandardError => ex
raised :ignore, ex
false
end
end
end
# The String name of this experiment. Default is "experiment". See
# Scientist::Default for an example of how to override this default.
def name
"experiment"
end
# Internal: compare two observations, using the configured compare block if present.
def observations_are_equivalent?(a, b)
if @_scientist_comparator
a.equivalent_to?(b, &@_scientist_comparator)
else
a.equivalent_to? b
end
rescue StandardError => ex
raised :compare, ex
false
end
# Called when an exception is raised while running an internal operation,
# like :publish. Override this method to track these exceptions. The
# default implementation re-raises the exception.
def raised(operation, error)
raise error
end
# Internal: Run all the behaviors for this experiment, observing each and
# publishing the results. Return the result of the named behavior, default
# "control".
def run(name = nil)
behaviors.freeze
context.freeze
name = (name || "control").to_s
block = behaviors[name]
if block.nil?
raise Scientist::BehaviorMissing.new(self, name)
end
return block.call unless should_experiment_run?
observations = []
behaviors.keys.shuffle.each do |key|
block = behaviors[key]
observations << Scientist::Observation.new(key, self, &block)
end
control = observations.detect { |o| o.name == name }
result = Scientist::Result.new self,
observations: observations,
control: control
begin
publish(result)
rescue StandardError => ex
raised :publish, ex
end
if control.raised?
raise control.exception
end
if self.class.raise_on_mismatches? && result.mismatched?
raise MismatchError.new(name, result)
end
control.value
end
# Define a block that determines whether or not the experiment should run.
def run_if(&block)
@_scientist_run_if_block = block
end
# Internal: does a run_if block allow the experiment to run?
#
# Rescues and reports exceptions in a run_if block if they occur.
def run_if_block_allows?
(@_scientist_run_if_block ? @_scientist_run_if_block.call : true)
rescue StandardError => ex
raised :run_if, ex
return false
end
# Internal: determine whether or not an experiment should run.
#
# Rescues and reports exceptions in the enabled method if they occur.
def should_experiment_run?
behaviors.size > 1 && enabled? && run_if_block_allows?
rescue StandardError => ex
raised :enabled, ex
return false
end
# Register a named behavior for this experiment, default "candidate".
def try(name = nil, &block)
name = (name || "candidate").to_s
if behaviors.include?(name)
raise Scientist::BehaviorNotUnique.new(self, name)
end
behaviors[name] = block
end
# Register the control behavior for this experiment.
def use(&block)
try "control", &block
end
end

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,92 @@
# What happened when this named behavior was executed? Immutable.
class Scientist::Observation
# The experiment this observation is for
attr_reader :experiment
# The instant observation began.
attr_reader :now
# The String name of the behavior.
attr_reader :name
# The value returned, if any.
attr_reader :value
# The raised exception, if any.
attr_reader :exception
# The Float seconds elapsed.
attr_reader :duration
def initialize(name, experiment, &block)
@name = name
@experiment = experiment
@now = Time.now
begin
@value = block.call
rescue Object => e
@exception = e
end
@duration = (Time.now - @now).to_f
freeze
end
# Return a cleaned value suitable for publishing. Uses the experiment's
# defined cleaner block to clean the observed value.
def cleaned_value
if value
experiment.clean_value value
end
end
# Is this observation equivalent to another?
#
# other - the other Observation in question
# comparator - an optional comparison block. This observation's value and the
# other observation's value are yielded to this to determine
# their equivalency. Block should return true/false.
#
# Returns true if:
#
# * The values of the observation are equal (using `==`)
# * The values of the observations are equal according to a comparison
# block, if given
# * Both observations raised an exception with the same class and message.
#
# Returns false otherwise.
def equivalent_to?(other, &comparator)
return false unless other.is_a?(Scientist::Observation)
values_are_equal = false
both_raised = other.raised? && raised?
neither_raised = !other.raised? && !raised?
if neither_raised
if block_given?
values_are_equal = yield value, other.value
else
values_are_equal = value == other.value
end
end
exceptions_are_equivalent = # backtraces will differ, natch
both_raised &&
other.exception.class == exception.class &&
other.exception.message == exception.message
(neither_raised && values_are_equal) ||
(both_raised && exceptions_are_equivalent)
end
def hash
[value, exception, self.class].compact.map(&:hash).inject(:^)
end
def raised?
!exception.nil?
end
end

77
lib/scientist/result.rb Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,77 @@
# The immutable result of running an experiment.
class Scientist::Result
# An Array of candidate Observations.
attr_reader :candidates
# The control Observation to which the rest are compared.
attr_reader :control
# An Experiment.
attr_reader :experiment
# An Array of observations which didn't match the control, but were ignored.
attr_reader :ignored
# An Array of observations which didn't match the control.
attr_reader :mismatched
# An Array of Observations in execution order.
attr_reader :observations
# Internal: Create a new result.
#
# experiment - the Experiment this result is for
# observations: - an Array of Observations, in execution order
# control: - the control Observation
#
def initialize(experiment, observations:, control:)
@experiment = experiment
@observations = observations
@control = control
@candidates = observations - [control]
evaluate_candidates
freeze
end
# Public: the experiment's context
def context
experiment.context
end
# Public: the name of the experiment
def experiment_name
experiment.name
end
# Public: was the result a match between all behaviors?
def matched?
mismatched.empty?
end
# Public: were there mismatches in the behaviors?
def mismatched?
mismatched.any?
end
# Public: were there any ignored mismatches?
def ignored?
ignored.any?
end
# Internal: evaluate the candidates to find mismatched and ignored results
#
# Sets @ignored and @mismatched with the ignored and mismatched candidates.
def evaluate_candidates
mismatched = candidates.reject do |candidate|
experiment.observations_are_equivalent?(control, candidate)
end
@ignored = mismatched.select do |candidate|
experiment.ignore_mismatched_observation? control, candidate
end
@mismatched = mismatched - @ignored
end
end

3
lib/scientist/version.rb Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,3 @@
module Scientist
VERSION = "0.0.1"
end

21
scientist.gemspec Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,21 @@
$: << "lib" and require "scientist/version"
Gem::Specification.new do |gem|
gem.name = "scientist"
gem.description = "A Ruby library for carefully refactoring critical paths"
gem.version = Scientist::VERSION
gem.authors = ["John Barnette", "Rick Bradley"]
gem.email = ["jbarnette@github.com", "rick@github.com"]
gem.summary = "Carefully test, measure, and track refactored code."
gem.homepage = "https://github.com/github/scientist"
gem.license = "MIT"
gem.required_ruby_version = ">= 2.1.0"
gem.files = `git ls-files`.split($/)
gem.executables = []
gem.test_files = gem.files.grep(/^test/)
gem.require_paths = ["lib"]
gem.add_development_dependency "minitest", "~> 5.2"
end

9
script/bootstrap Executable file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,9 @@
#!/bin/sh
# Ensure local dependencies are available.
set -e
cd $(dirname "$0")/..
rm -f .bundle/config
bundle install --path .bundle --quiet "$@"

38
script/release Executable file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,38 @@
#!/bin/sh
# Tag and push a release.
set -e
# Make sure we're in the project root.
cd $(dirname "$0")/..
# Build a new gem archive.
rm -rf scientist-*.gem
gem build -q scientist.gemspec
# Make sure we're on the master branch.
(git branch | grep -q '* master') || {
echo "Only release from the master branch."
exit 1
}
# Figure out what version we're releasing.
tag=v`ls scientist-*.gem | sed 's/^scientist-\(.*\)\.gem$/\1/'`
# Make sure we haven't released this version before.
git fetch -t origin
(git tag -l | grep -q "$tag") && {
echo "Whoops, there's already a '${tag}' tag."
exit 1
}
# Tag it and bag it.
gem push scientist-*.gem && git tag "$tag" &&
git push origin master && git push origin "$tag"

11
script/test Executable file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,11 @@
#!/bin/sh
# Run the unit tests.
set -e
cd $(dirname "$0")/..
script/bootstrap && ruby -I lib \
-e 'require "bundler/setup"' \
-e 'require "minitest/autorun"' \
-e 'require "scientist"' \
-e '(ARGV.empty? ? Dir["test/**/*_test.rb"] : ARGV).each { |f| load f }' -- "$@"

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,23 @@
describe Scientist::Default do
before do
@ex = Scientist::Default.new "default"
end
it "is always enabled" do
assert @ex.enabled?
end
it "noops publish" do
assert_nil @ex.publish("data")
end
it "is an experiment" do
assert Scientist::Default < Scientist::Experiment
end
it "reraises when an internal action raises" do
assert_raises RuntimeError do
@ex.raised :publish, RuntimeError.new("kaboom")
end
end
end

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,398 @@
describe Scientist::Experiment do
class Fake
include Scientist::Experiment
def initialize(*args)
end
def enabled?
true
end
attr_reader :published_result
def exceptions
@exceptions ||= []
end
def raised(op, exception)
exceptions << [op, exception]
end
def publish(result)
@published_result = result
end
end
before do
@ex = Fake.new
end
it "has a default implementation" do
ex = Scientist::Experiment.new("hello")
assert_kind_of Scientist::Default, ex
assert_equal "hello", ex.name
end
it "provides a static default name" do
assert_equal "experiment", Fake.new.name
end
it "requires includers to implement enabled?" do
obj = Object.new
obj.extend Scientist::Experiment
assert_raises NoMethodError do
obj.enabled?
end
end
it "requires includers to implement publish" do
obj = Object.new
obj.extend Scientist::Experiment
assert_raises NoMethodError do
obj.publish("result")
end
end
it "can't be run without a control behavior" do
e = assert_raises Scientist::BehaviorMissing do
@ex.run
end
assert_equal "control", e.name
end
it "is a straight pass-through with only a control behavior" do
@ex.use { "control" }
assert_equal "control", @ex.run
end
it "runs other behaviors but always returns the control" do
@ex.use { "control" }
@ex.try { "candidate" }
assert_equal "control", @ex.run
end
it "complains about duplicate behavior names" do
@ex.use { "control" }
e = assert_raises Scientist::BehaviorNotUnique do
@ex.use { "control-again" }
end
assert_equal @ex, e.experiment
assert_equal "control", e.name
end
it "swallows exceptions raised by candidate behaviors" do
@ex.use { "control" }
@ex.try { raise "candidate" }
assert_equal "control", @ex.run
end
it "passes through exceptions raised by the control behavior" do
@ex.use { raise "control" }
@ex.try { "candidate" }
exception = assert_raises RuntimeError do
@ex.run
end
assert_equal "control", exception.message
end
it "shuffles behaviors before running" do
last = nil
runs = []
@ex.use { last = "control" }
@ex.try { last = "candidate" }
10000.times do
@ex.run
runs << last
end
assert runs.uniq.size > 1
end
it "re-raises exceptions raised during publish by default" do
ex = Scientist::Experiment.new("hello")
assert_kind_of Scientist::Default, ex
def ex.publish(result)
raise "boomtown"
end
ex.use { "control" }
ex.try { "candidate" }
exception = assert_raises RuntimeError do
ex.run
end
assert_equal "boomtown", exception.message
end
it "reports publishing errors" do
def @ex.publish(result)
raise "boomtown"
end
@ex.use { "control" }
@ex.try { "candidate" }
assert_equal "control", @ex.run
op, exception = @ex.exceptions.pop
assert_equal :publish, op
assert_equal "boomtown", exception.message
end
it "publishes results" do
@ex.use { 1 }
@ex.try { 1 }
assert_equal 1, @ex.run
assert @ex.published_result
end
it "does not publish results when there is only a control value" do
@ex.use { 1 }
assert_equal 1, @ex.run
assert_nil @ex.published_result
end
it "compares results with a comparator block if provided" do
@ex.compare { |a, b| a == b.to_s }
@ex.use { "1" }
@ex.try { 1 }
assert_equal "1", @ex.run
assert @ex.published_result.matched?
end
it "knows how to compare two experiments" do
a = Scientist::Observation.new(@ex, "a") { 1 }
b = Scientist::Observation.new(@ex, "b") { 2 }
assert @ex.observations_are_equivalent?(a, a)
refute @ex.observations_are_equivalent?(a, b)
end
it "uses a compare block to determine if observations are equivalent" do
a = Scientist::Observation.new(@ex, "a") { "1" }
b = Scientist::Observation.new(@ex, "b") { 1 }
@ex.compare { |x, y| x == y.to_s }
assert @ex.observations_are_equivalent?(a, b)
end
it "reports errors in a compare block" do
@ex.compare { raise "boomtown" }
@ex.use { "control" }
@ex.try { "candidate" }
assert_equal "control", @ex.run
op, exception = @ex.exceptions.pop
assert_equal :compare, op
assert_equal "boomtown", exception.message
end
it "reports errors in the enabled? method" do
def @ex.enabled?
raise "kaboom"
end
@ex.use { "control" }
@ex.try { "candidate" }
assert_equal "control", @ex.run
op, exception = @ex.exceptions.pop
assert_equal :enabled, op
assert_equal "kaboom", exception.message
end
it "reports errors in a run_if block" do
@ex.run_if { raise "kaboom" }
@ex.use { "control" }
@ex.try { "candidate" }
assert_equal "control", @ex.run
op, exception = @ex.exceptions.pop
assert_equal :run_if, op
assert_equal "kaboom", exception.message
end
it "returns the given value when no clean block is configured" do
assert_equal 10, @ex.clean_value(10)
end
it "calls the configured clean block with a value when configured" do
@ex.clean do |value|
value.upcase
end
assert_equal "TEST", @ex.clean_value("test")
end
it "reports an error and returns the original value when an error is raised in a clean block" do
@ex.clean { |value| raise "kaboom" }
@ex.use { "control" }
@ex.try { "candidate" }
assert_equal "control", @ex.run
assert_equal "control", @ex.published_result.control.cleaned_value
op, exception = @ex.exceptions.pop
assert_equal :clean, op
assert_equal "kaboom", exception.message
end
describe "#run_if" do
it "does not run the experiment if the given block returns false" do
candidate_ran = false
run_check_ran = false
@ex.use { 1 }
@ex.try { candidate_ran = true; 1 }
@ex.run_if { run_check_ran = true; false }
@ex.run
assert run_check_ran
refute candidate_ran
end
it "runs the experiment if the given block returns true" do
candidate_ran = false
run_check_ran = false
@ex.use { true }
@ex.try { candidate_ran = true }
@ex.run_if { run_check_ran = true }
@ex.run
assert run_check_ran
assert candidate_ran
end
end
describe "#ignore_mismatched_observation?" do
before do
@a = Scientist::Observation.new(@ex, "a") { 1 }
@b = Scientist::Observation.new(@ex, "b") { 2 }
end
it "does not ignore an observation if no ignores are configured" do
refute @ex.ignore_mismatched_observation?(@a, @b)
end
it "calls a configured ignore block with the given observed values" do
called = false
@ex.ignore do |a, b|
called = true
assert_equal @a.value, a
assert_equal @b.value, b
true
end
assert @ex.ignore_mismatched_observation?(@a, @b)
assert called
end
it "calls multiple ignore blocks to see if any match" do
called_one = called_two = called_three = false
@ex.ignore { |a, b| called_one = true; false }
@ex.ignore { |a, b| called_two = true; false }
@ex.ignore { |a, b| called_three = true; false }
refute @ex.ignore_mismatched_observation?(@a, @b)
assert called_one
assert called_two
assert called_three
end
it "only calls ignore blocks until one matches" do
called_one = called_two = called_three = false
@ex.ignore { |a, b| called_one = true; false }
@ex.ignore { |a, b| called_two = true; true }
@ex.ignore { |a, b| called_three = true; false }
assert @ex.ignore_mismatched_observation?(@a, @b)
assert called_one
assert called_two
refute called_three
end
it "reports exceptions raised in an ignore block and returns false" do
def @ex.exceptions
@exceptions ||= []
end
def @ex.raised(op, exception)
exceptions << [op, exception]
end
@ex.ignore { raise "kaboom" }
refute @ex.ignore_mismatched_observation?(@a, @b)
op, exception = @ex.exceptions.pop
assert_equal :ignore, op
assert_equal "kaboom", exception.message
end
it "skips ignore blocks that raise and tests any remaining blocks if an exception is swallowed" do
def @ex.exceptions
@exceptions ||= []
end
# this swallows the exception rather than re-raising
def @ex.raised(op, exception)
exceptions << [op, exception]
end
@ex.ignore { raise "kaboom" }
@ex.ignore { true }
assert @ex.ignore_mismatched_observation?(@a, @b)
assert_equal 1, @ex.exceptions.size
end
end
describe "raising on mismatches" do
before do
@old_raise_on_mismatches = Fake.raise_on_mismatches?
end
after do
Fake.raise_on_mismatches = @old_raise_on_mismatches
end
it "raises when there is a mismatch if raise on mismatches is enabled" do
Fake.raise_on_mismatches = true
@ex.use { "fine" }
@ex.try { "not fine" }
assert_raises(Scientist::Experiment::MismatchError) { @ex.run }
end
it "doesn't raise when there is a mismatch if raise on mismatches is disabled" do
Fake.raise_on_mismatches = false
@ex.use { "fine" }
@ex.try { "not fine" }
@ex.run
end
end
end

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,93 @@
describe Scientist::Observation do
before do
@experiment = Scientist::Experiment.new "test"
end
it "observes and records the execution of a block" do
ob = Scientist::Observation.new("test", @experiment) do
sleep 0.1
"ret"
end
assert_equal "ret", ob.value
refute ob.raised?
assert_in_delta 0.1, ob.duration, 0.01
end
it "stashes exceptions" do
ob = Scientist::Observation.new("test", @experiment) do
raise "exception"
end
assert ob.raised?
assert_equal "exception", ob.exception.message
assert_nil ob.value
end
it "compares values" do
a = Scientist::Observation.new("test", @experiment) { 1 }
b = Scientist::Observation.new("test", @experiment) { 1 }
assert a.equivalent_to?(b)
x = Scientist::Observation.new("test", @experiment) { 1 }
y = Scientist::Observation.new("test", @experiment) { 2 }
refute x.equivalent_to?(y)
end
it "compares exception messages" do
a = Scientist::Observation.new("test", @experiment) { raise "error" }
b = Scientist::Observation.new("test", @experiment) { raise "error" }
assert a.equivalent_to?(b)
x = Scientist::Observation.new("test", @experiment) { raise "error" }
y = Scientist::Observation.new("test", @experiment) { raise "ERROR" }
refute x.equivalent_to?(y)
end
FirstErrror = Class.new(StandardError)
SecondError = Class.new(StandardError)
it "compares exception classes" do
x = Scientist::Observation.new("test", @experiment) { raise FirstError, "error" }
y = Scientist::Observation.new("test", @experiment) { raise SecondError, "error" }
z = Scientist::Observation.new("test", @experiment) { raise FirstError, "error" }
assert x.equivalent_to?(z)
refute x.equivalent_to?(y)
end
it "compares values using a comparator block" do
a = Scientist::Observation.new("test", @experiment) { 1 }
b = Scientist::Observation.new("test", @experiment) { "1" }
refute a.equivalent_to?(b)
assert a.equivalent_to?(b) { |x, y| x.to_s == y.to_s }
yielded = []
a.equivalent_to?(b) do |x, y|
yielded << x
yielded << y
true
end
assert_equal [a.value, b.value], yielded
end
describe "#cleaned_value" do
it "returns the observation's value by default" do
a = Scientist::Observation.new("test", @experiment) { 1 }
assert_equal 1, a.cleaned_value
end
it "uses the experiment's clean block to clean a value when configured" do
@experiment.clean { |value| value.upcase }
a = Scientist::Observation.new("test", @experiment) { "test" }
assert_equal "TEST", a.cleaned_value
end
end
end

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,111 @@
describe Scientist::Result do
before do
@experiment = Scientist::Experiment.new "experiment"
end
it "is immutable" do
control = Scientist::Observation.new("control", @experiment)
candidate = Scientist::Observation.new("candidate", @experiment)
result = Scientist::Result.new @experiment,
observations: [control, candidate], control: control
assert result.frozen?
end
it "evaluates its observations" do
a = Scientist::Observation.new("a", @experiment) { 1 }
b = Scientist::Observation.new("b", @experiment) { 1 }
assert a.equivalent_to?(b)
result = Scientist::Result.new @experiment, observations: [a, b], control: a
assert result.matched?
refute result.mismatched?
assert_equal [], result.mismatched
x = Scientist::Observation.new("x", @experiment) { 1 }
y = Scientist::Observation.new("y", @experiment) { 2 }
z = Scientist::Observation.new("z", @experiment) { 3 }
result = Scientist::Result.new @experiment, observations: [x, y, z], control: x
refute result.matched?
assert result.mismatched?
assert_equal [y, z], result.mismatched
end
it "has no mismatches if there is only a control observation" do
a = Scientist::Observation.new("a", @experiment) { 1 }
result = Scientist::Result.new @experiment, observations: [a], control: a
assert result.matched?
end
it "evaluates observations using the experiment's compare block" do
a = Scientist::Observation.new("a", @experiment) { "1" }
b = Scientist::Observation.new("b", @experiment) { 1 }
@experiment.compare { |x, y| x == y.to_s }
result = Scientist::Result.new @experiment, observations: [a, b], control: a
assert result.matched?, result.mismatched
end
it "does not ignore any mismatches when nothing's ignored" do
x = Scientist::Observation.new("x", @experiment) { 1 }
y = Scientist::Observation.new("y", @experiment) { 2 }
result = Scientist::Result.new @experiment, observations: [x, y], control: x
assert result.mismatched?
refute result.ignored?
end
it "uses the experiment's ignore block to ignore mismatched observations" do
x = Scientist::Observation.new("x", @experiment) { 1 }
y = Scientist::Observation.new("y", @experiment) { 2 }
called = false
@experiment.ignore { called = true }
result = Scientist::Result.new @experiment, observations: [x, y], control: x
refute result.mismatched?
assert result.ignored?
assert_equal [], result.mismatched
assert_equal [y], result.ignored
assert called
end
it "partitions observations into mismatched and ignored when applicable" do
x = Scientist::Observation.new("x", @experiment) { :x }
y = Scientist::Observation.new("y", @experiment) { :y }
z = Scientist::Observation.new("z", @experiment) { :z }
@experiment.ignore { |control, candidate| candidate == :y }
result = Scientist::Result.new @experiment, observations: [x, y, z], control: x
assert result.mismatched?
assert result.ignored?
assert_equal [y], result.ignored
assert_equal [z], result.mismatched
end
it "knows the experiment's name" do
a = Scientist::Observation.new("a", @experiment) { 1 }
b = Scientist::Observation.new("b", @experiment) { 1 }
result = Scientist::Result.new @experiment, observations: [a, b], control: a
assert_equal @experiment.name, result.experiment_name
end
it "has the context from an experiment" do
@experiment.context :foo => :bar
a = Scientist::Observation.new("a", @experiment) { 1 }
b = Scientist::Observation.new("b", @experiment) { 1 }
result = Scientist::Result.new @experiment, observations: [a, b], control: a
assert_equal({:foo => :bar}, result.context)
end
end

45
test/scientist_test.rb Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,45 @@
describe Scientist do
it "has a version or whatever" do
assert Scientist::VERSION
end
it "provides a helper to instantiate and run experiments" do
obj = Object.new
obj.extend(Scientist)
r = obj.science "test" do |e|
e.use { :control }
e.try { :candidate }
end
assert_equal :control, r
end
it "provides an empty default_scientist_context" do
obj = Object.new
obj.extend(Scientist)
assert_equal Hash.new, obj.default_scientist_context
end
it "respects default_scientist_context" do
obj = Object.new
obj.extend(Scientist)
def obj.default_scientist_context
{ :default => true }
end
experiment = nil
obj.science "test" do |e|
experiment = e
e.context :inline => true
e.use { }
end
refute_nil experiment
assert_equal true, experiment.context[:default]
assert_equal true, experiment.context[:inline]
end
end