dat-science 0.0.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,26 +1,33 @@
1
- # Dat Science!
1
+ # Science!
2
2
 
3
- A Ruby library for carefully refactoring critical paths. Science isn't
4
- a feature flipper or an A/B testing tool, it's a pattern that helps
5
- measure and validate large code changes without altering behavior.
3
+ A Ruby library for carefully refactoring critical paths. Science isn't a feature
4
+ flipper or an A/B testing tool, it's a pattern that helps measure and validate
5
+ large code changes without altering behavior.
6
6
 
7
7
  ## How do I do science?
8
8
 
9
+ Let's pretend you're changing the way you handle permissions in a large web app.
10
+ Tests can help guide your refactoring, but you really want to compare the
11
+ current and new behaviors live, under load.
12
+
9
13
  ```ruby
10
14
  require "dat/science"
11
15
 
12
- include Dat::Science
16
+ class MyApp::Widget
17
+ def allows?(user)
18
+ experiment = Dat::Science::Experiment.new "widget-permissions" do |e|
19
+ e.control { model.check_user(user).valid? } # old way
20
+ e.candidate { user.can? :read, model } # new way
21
+ end
13
22
 
14
- science "user-permissions" do |experiment|
15
- experiment.control { model.check_user(user).valid? }
16
- experiment.candidate { user.can? :read, model }
23
+ experiment.run
24
+ end
17
25
  end
18
26
  ```
19
27
 
20
- Wrap a `control` block around the code's original behavior, and wrap
21
- `candidate` around the new behavior. The `science` block will return
22
- whatever the `control` block returns, but it does a bunch of stuff
23
- behind the scenes:
28
+ Wrap a `control` block around the code's original behavior, and wrap `candidate`
29
+ around the new behavior. `experiment.run` will always return whatever the
30
+ `control` block returns, but it does a bunch of stuff behind the scenes:
24
31
 
25
32
  * Decides whether or not to run `candidate`,
26
33
  * Runs `candidate` before `control` 50% of the time,
@@ -29,32 +36,60 @@ behind the scenes:
29
36
  * Swallows any exceptions raised by the candidate behavior, and
30
37
  * Publishes all this information for tracking and reporting.
31
38
 
32
- ## Making Science Useful
39
+ If you'd like a bit less verbosity, the `Dat::Science#science` helper
40
+ instantiates an experiment and calls `run`:
41
+
42
+ ```ruby
43
+ require "dat/science"
33
44
 
34
- (Talk about subclassing `Dat::Science::Experiment` and setting
35
- `Dat::Science.experiment`)
45
+ class MyApp::Widget
46
+ include Dat::Science
47
+
48
+ def allows?(user)
49
+ science "widget-permissions" do |e|
50
+ e.control { model.check_user(user).valid? } # old way
51
+ e.candidate { user.can? :read, model } # new way
52
+ end
53
+ end
54
+ end
55
+ ```
56
+
57
+ ## Making science useful
58
+
59
+ The examples above will run, but they're not particularly helpful. The
60
+ `candidate` block runs every time, and none of the results get
61
+ published. Let's fix that by creating an app-specific sublass of
62
+ `Dat::Science::Experiment`. This makes it easy to add custom behavior
63
+ for enabling/disabling/throttling experiments and publishing results.
36
64
 
37
65
  ```ruby
38
66
  require "dat/science"
39
67
 
40
- module FooCorp
68
+ module MyApp
41
69
  class Experiment < Dat::Science::Experiment
42
70
  def enabled?
43
- # See "Ramping up Experiments" below.
71
+ # See "Ramping up experiments" below.
44
72
  end
45
73
 
46
74
  def publish(name, payload)
47
- # See "Publishing Results" below.
75
+ # See "Publishing results" below.
48
76
  end
49
77
  end
50
78
  end
51
79
  ```
52
80
 
81
+ After creating a subclass, tell `Dat::Science` to instantiate it any time the
82
+ `science` helper is called:
83
+
53
84
  ```ruby
54
- Dat::Science.experiment = FooCorp::Experiment
85
+ Dat::Science.experiment = MyApp::Experiment
55
86
  ```
56
87
 
57
- ### Ramping up Experiments
88
+ ### Ramping up experiments
89
+
90
+ By default the `candidate` block of an experiment will run 100% of the time.
91
+ This is often a really bad idea when testing live. `Experiment#enabled?` can be
92
+ overridden to run all candidates, say, 10% of the time:
58
93
 
59
94
  ```ruby
60
95
  def enabled?
@@ -62,22 +97,39 @@ def enabled?
62
97
  end
63
98
  ```
64
99
 
100
+ Or, even better, use a feature flag library like [Flipper][]. Delegating the
101
+ decision makes it easy to define different rules for each experiment, and can
102
+ help keep all your entropy concerns in one place.
103
+
104
+ [Flipper]: https://github.com/jnunemaker/flipper
105
+
65
106
  ```ruby
66
107
  def enabled?
67
- Flipper[name].enabled?
108
+ MyApp.flipper[name].enabled?
68
109
  end
69
110
  ```
70
111
 
71
- ### Publishing Results
112
+ ### Publishing results
113
+
114
+ By default the results of an experiment are discarded. This isn't very useful.
115
+ `Experiment#publish` can be overridden to publish results via any
116
+ instrumentation mechansim, which makes it easy to graph durations or
117
+ matches/mismatches and store results. The only two events published by an
118
+ experiment are `match` when the result of the control and candidate behaviors
119
+ are the same, and `mismatch` when they aren't.
72
120
 
73
121
  ```ruby
74
- def publish(name, payload)
75
- FooCorp.instrument "science.#{name}", payload
122
+ def publish(event, payload)
123
+ MyApp.instrument "science.#{event}", payload
76
124
  end
77
125
  ```
78
126
 
127
+ The published `payload` is a Symbol-keyed Hash:
128
+
79
129
  ```ruby
80
130
  {
131
+ :experiment => "widget-permissions",
132
+
81
133
  :candidate => {
82
134
  :duration => 2.5,
83
135
  :exception => nil,
@@ -94,13 +146,35 @@ end
94
146
  }
95
147
  ```
96
148
 
97
- #### Adding Context
149
+ The `:candidate` and `:control` Hashes have the same keys:
150
+
151
+ * `:duration` is the execution in ms, expressed as a float.
152
+ * `:exception` is a reference to any raised exception or `nil`.
153
+ * `:value` is the result of the block.
154
+
155
+ `:first` is either `:candidate` or `:control`, depending on which block was run
156
+ first during the experiment. `:experiment` is the name of the experiment.
157
+
158
+ #### Adding context
159
+
160
+ It's often useful to add more information to your results, and
161
+ `Experiment#context` makes it easy:
162
+
163
+ ```ruby
164
+ science "widget-permissions" do |experiment|
165
+ experiment.context :user => user
166
+
167
+ experiment.control { model.check_user(user).valid? } # old way
168
+ experiment.candidate { user.can? :read, model } # new way
169
+ end
170
+ ```
98
171
 
99
- (using `e.context`)
172
+ `context` takes a Symbol-keyed Hash of additional information to publish and
173
+ merges it with the default payload.
100
174
 
101
- ## Hacking on Science
175
+ ## Hacking on science
102
176
 
103
- Make sure a modern Bundler is available.`script/test` runs the unit
104
- tests. All development dependencies will be installed automatically if
105
- they're not available. Dat science happens primarily on Ruby 1.9.3 and
106
- 1.8.7, but science should be universal.
177
+ Be on a Unixy box. Make sure a modern Bundler is available. `script/test` runs
178
+ the unit tests. All development dependencies will be installed automatically if
179
+ they're not available. Dat science happens primarily on Ruby 1.9.3 and 1.8.7,
180
+ but science should be universal.
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |gem|
2
2
  gem.name = "dat-science"
3
- gem.version = "0.0.0"
3
+ gem.version = "1.0.0"
4
4
  gem.authors = ["John Barnette", "Rick Bradley"]
5
5
  gem.email = ["jbarnette@github.com"]
6
6
  gem.description = "Gradually test, measure, and track refactored code."
@@ -9,8 +9,8 @@ module Dat
9
9
  # Public: The name of this experiment.
10
10
  attr_reader :name
11
11
 
12
- # Public: Create a new experiment instance. `self` is yielded to
13
- # an optional `block` if it's provided.
12
+ # Public: Create a new experiment instance. `self` is yielded to an
13
+ # optional `block` if it's provided.
14
14
  def initialize(name, &block)
15
15
  @candidate = nil
16
16
  @context = { :experiment => name }
@@ -20,37 +20,37 @@ module Dat
20
20
  yield self if block_given?
21
21
  end
22
22
 
23
- # Public: Add a Hash of `payload` data to be included when events
24
- # are published.
25
- def context(payload)
26
- @context.merge! payload
23
+ # Public: Declare the candidate behavior `block` for this experiment.
24
+ # Returns `block`.
25
+ def candidate(&block)
26
+ @candidate = block if block
27
+ @candidate
27
28
  end
28
29
 
29
- # Public: Declare the control behavior `block` for this
30
- # experiment. Returns `block`.
31
- def control(&block)
32
- @control = block
30
+ # Public: Add a Hash of `payload` data to be included when events are
31
+ # published or returns the current context if `payload` is `nil`.
32
+ def context(payload = nil)
33
+ @context.merge! payload if payload
34
+ @context
33
35
  end
34
36
 
35
- # Public: Declare the candidate behavior `block` for this
36
- # experiment. Returns `block`.
37
- def candidate(&block)
38
- @candidate = block
37
+ # Public: Declare the control behavior `block` for this experiment.
38
+ # Returns `block`.
39
+ def control(&block)
40
+ @control = block if block
41
+ @control
39
42
  end
40
43
 
41
44
  # Public: Run the control and candidate behaviors, timing each and
42
- # comparing the results. The run order is randomized. Returns the
43
- # control behavior's result.
45
+ # comparing the results. The run order is randomized. Returns the control
46
+ # behavior's result.
44
47
  #
45
- # If the experiment is disabled or candidate behavior isn't
46
- # provided the control behavior's result will be returned
47
- # immediately.
48
+ # If the experiment is disabled or candidate behavior isn't provided the
49
+ # control behavior's result will be returned immediately.
48
50
  def run
49
51
  return run_control unless candidate? && enabled?
50
52
 
51
- control_goes_first = rand(2) == 0
52
-
53
- if control_goes_first
53
+ if control_runs_first?
54
54
  control = observe_control
55
55
  candidate = observe_candidate
56
56
  else
@@ -61,10 +61,10 @@ module Dat
61
61
  payload = {
62
62
  :candidate => candidate.payload,
63
63
  :control => control.payload,
64
- :first => control_goes_first ? :control : :candidate
64
+ :first => control_runs_first? ? :control : :candidate
65
65
  }
66
66
 
67
- kind = control == candidate ? "match" : "mismatch"
67
+ kind = control == candidate ? :match : :mismatch
68
68
  publish_with_context kind, payload
69
69
 
70
70
  raise control.exception if control.raised?
@@ -76,7 +76,13 @@ module Dat
76
76
 
77
77
  # Internal: Does this experiment have candidate behavior?
78
78
  def candidate?
79
- !!@candidate
79
+ !!candidate
80
+ end
81
+
82
+ # Internal: Should the control behavior run first?
83
+ def control_runs_first?
84
+ return @control_runs_first if defined? @control_runs_first
85
+ @control_runs_first = rand(2) == 0
80
86
  end
81
87
 
82
88
  # Internal: Is this experiment enabled? More specifically, should
@@ -112,23 +118,24 @@ module Dat
112
118
  observe { run_control }
113
119
  end
114
120
 
115
- # Internal: Broadcast an event `name` and `payload` Hash. The
121
+ # Internal: Broadcast an `event` String and `payload` Hash. The
116
122
  # default implementation is a no-op. Returns nothing.
117
- def publish(name, payload)
123
+ def publish(event, payload)
118
124
  end
119
125
 
120
126
  # Internal: Call `publish`, merging the `payload` with `context`.
121
- def publish_with_context(name, payload)
122
- publish name, @context.merge(payload)
127
+ def publish_with_context(event, payload)
128
+ publish event, context.merge(payload)
123
129
  end
130
+
124
131
  # Internal: Run the candidate behavior and return its result.
125
132
  def run_candidate
126
- @candidate.call
133
+ candidate.call
127
134
  end
128
135
 
129
136
  # Internal: Run the control behavior and return its result.
130
137
  def run_control
131
- @control.call
138
+ control.call
132
139
  end
133
140
  end
134
141
  end
@@ -1,7 +1,7 @@
1
1
  module Dat
2
-
3
- # Internal. The value of a watched behavior.
4
2
  module Science
3
+
4
+ # Internal. The output of running of an observed behavior.
5
5
  class Result
6
6
  attr_reader :duration
7
7
  attr_reader :exception
@@ -14,7 +14,7 @@ module Dat
14
14
  end
15
15
 
16
16
  def ==(other)
17
- return false unless other.is_a? Science::Result
17
+ return false unless other.is_a? Dat::Science::Result
18
18
 
19
19
  values_are_equal = other.value == value
20
20
  both_raised = other.raised? && raised?
@@ -0,0 +1,38 @@
1
+ #!/bin/sh
2
+ # Tag and push a release.
3
+
4
+ set -e
5
+
6
+ # Make sure we're in the project root.
7
+
8
+ cd $(dirname "$0")/..
9
+
10
+ # Build a new gem archive.
11
+
12
+ rm -rf dat-science-*.gem
13
+ gem build -q dat-science.gemspec
14
+
15
+ # Make sure we're on the master branch.
16
+
17
+ (git branch | grep -q '* master') || {
18
+ echo "Only release from the master branch."
19
+ exit 1
20
+ }
21
+
22
+ # Figure out what version we're releasing.
23
+
24
+ tag=v`ls dat-science-*.gem | sed 's/^dat-science-\(.*\)\.gem$/\1/'`
25
+
26
+ # Make sure we haven't released this version before.
27
+
28
+ git fetch -t origin
29
+
30
+ (git tag -l | grep -q "$tag") && {
31
+ echo "Whoops, there's already a '${tag}' tag."
32
+ exit 1
33
+ }
34
+
35
+ # Tag it and bag it.
36
+
37
+ gem push dat-science-*.gem && git tag "$tag" &&
38
+ git push origin master && git push origin "$tag"
@@ -2,7 +2,222 @@ require "minitest/autorun"
2
2
  require "dat/science/experiment"
3
3
 
4
4
  class DatScienceExperimentTest < MiniTest::Unit::TestCase
5
- def test_sanity
6
- assert Dat::Science::Experiment
5
+ class Experiment < Dat::Science::Experiment
6
+ def self.published
7
+ @published ||= []
8
+ end
9
+
10
+ def publish(name, payload)
11
+ Experiment.published << [name, payload]
12
+ end
13
+ end
14
+
15
+ def setup
16
+ Experiment.published.clear
17
+ end
18
+
19
+ def test_initialize
20
+ in_block = nil
21
+ experiment = Experiment.new("foo") { |e| in_block = e }
22
+
23
+ assert_equal "foo", experiment.name
24
+ assert_equal experiment, in_block
25
+ end
26
+
27
+ def test_candidate_default
28
+ assert_nil Experiment.new("foo").candidate
29
+ end
30
+
31
+ def test_candidate
32
+ e = Experiment.new "foo"
33
+ b = lambda {}
34
+
35
+ e.candidate &b
36
+ assert_same b, e.candidate
37
+ end
38
+
39
+ def test_context_default
40
+ e = Experiment.new "foo"
41
+
42
+ expected = { :experiment => "foo" }
43
+ assert_equal expected, e.context
44
+ end
45
+
46
+ def test_context
47
+ e = Experiment.new "foo"
48
+ e.context :bar => :baz
49
+
50
+ assert_equal :baz, e.context[:bar]
51
+ end
52
+
53
+ def test_control_default
54
+ assert_nil Experiment.new("foo").control
55
+ end
56
+
57
+ def test_control
58
+ e = Experiment.new "foo"
59
+ b = lambda {}
60
+
61
+ e.control &b
62
+ assert_same b, e.control
63
+ end
64
+
65
+ def test_run_with_no_candidate
66
+ e = Experiment.new "foo"
67
+ e.control { :foo }
68
+
69
+ assert_equal :foo, e.run
70
+ assert Experiment.published.empty?
71
+ end
72
+
73
+ def test_run_disabled
74
+ e = Experiment.new "foo"
75
+ e.control { :foo }
76
+ e.candidate { :bar }
77
+
78
+ def e.enabled?
79
+ false
80
+ end
81
+
82
+ assert_equal :foo, e.run
83
+ assert Experiment.published.empty?
84
+ end
85
+
86
+ def test_run
87
+ e = Experiment.new "foo"
88
+ e.control { :foo }
89
+
90
+ candidate_run = false
91
+ e.candidate { candidate_run = true; :bar }
92
+
93
+ def e.control_runs_first?
94
+ true
95
+ end
96
+
97
+ assert_equal :foo, e.run
98
+ assert candidate_run
99
+
100
+ event, payload = Experiment.published.first
101
+ refute_nil event
102
+ refute_nil payload
103
+
104
+ assert_equal :mismatch, event
105
+
106
+ assert_equal "foo", payload[:experiment]
107
+ assert_equal :control, payload[:first]
108
+
109
+ assert payload[:control][:duration]
110
+ assert_nil payload[:control][:exception]
111
+ assert_equal :foo, payload[:control][:value]
112
+
113
+ assert payload[:candidate][:duration]
114
+ assert_nil payload[:candidate][:exception]
115
+ assert_equal :bar, payload[:candidate][:value]
116
+ end
117
+
118
+ def test_run_candidate_first
119
+ e = Experiment.new "foo"
120
+ e.control { :foo }
121
+ e.candidate { :bar }
122
+
123
+ def e.control_runs_first?
124
+ false
125
+ end
126
+
127
+ assert_equal :foo, e.run
128
+
129
+ event, payload = Experiment.published.first
130
+ refute_nil event
131
+ refute_nil payload
132
+
133
+ assert_equal :mismatch, event
134
+ assert_equal :candidate, payload[:first]
135
+ end
136
+
137
+ def test_run_match
138
+ e = Experiment.new "foo"
139
+ e.control { :foo }
140
+ e.candidate { :foo }
141
+
142
+ assert_equal :foo, e.run
143
+
144
+ event, payload = Experiment.published.first
145
+ refute_nil event
146
+ refute_nil payload
147
+
148
+ assert_equal :match, event
149
+ end
150
+
151
+ def test_run_passes_control_exceptions_through
152
+ e = Experiment.new "foo"
153
+ e.control { raise "bar" }
154
+
155
+ candidate_run = false
156
+ e.candidate { candidate_run = true }
157
+
158
+ ex = assert_raises RuntimeError do
159
+ e.run
160
+ end
161
+
162
+ assert candidate_run
163
+ assert_equal "bar", ex.message
164
+
165
+ event, payload = Experiment.published.first
166
+ refute_nil event
167
+ refute_nil payload
168
+
169
+ assert_equal :mismatch, event
170
+ refute_nil payload[:control][:exception]
171
+ end
172
+
173
+ def test_run_swallows_candidate_exceptions
174
+ e = Experiment.new "foo"
175
+ e.control { :foo }
176
+ e.candidate { raise "bar" }
177
+
178
+ assert_equal :foo, e.run
179
+
180
+ event, payload = Experiment.published.first
181
+ refute_nil event
182
+ refute_nil payload
183
+
184
+ assert_equal :mismatch, event
185
+ refute_nil payload[:candidate][:exception]
186
+ end
187
+
188
+ def test_run_similar_exceptions_are_a_match
189
+ e = Experiment.new "foo"
190
+ e.control { raise "foo" }
191
+ e.candidate { raise "foo" }
192
+
193
+ assert_raises RuntimeError do
194
+ e.run
195
+ end
196
+
197
+ event, payload = Experiment.published.first
198
+ refute_nil event
199
+ refute_nil payload
200
+
201
+ assert_equal :match, event
202
+ refute_nil payload[:control][:exception]
203
+ refute_nil payload[:candidate][:exception]
204
+ end
205
+
206
+ def test_run_dissimilar_exceptions_are_a_mismatch
207
+ e = Experiment.new "foo"
208
+ e.control { raise "foo" }
209
+ e.candidate { raise "bar" }
210
+
211
+ assert_raises RuntimeError do
212
+ e.run
213
+ end
214
+
215
+ event, payload = Experiment.published.first
216
+ refute_nil event
217
+ refute_nil payload
218
+
219
+ assert_equal :mismatch, event
220
+ refute_nil payload[:control][:exception]
221
+ refute_nil payload[:candidate][:exception]
7
222
  end
8
223
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dat-science
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 1.0.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -60,6 +60,7 @@ files:
60
60
  - lib/dat/science/experiment.rb
61
61
  - lib/dat/science/result.rb
62
62
  - script/bootstrap
63
+ - script/release
63
64
  - script/test
64
65
  - test/dat_science_experiment_test.rb
65
66
  - test/dat_science_test.rb