dat-science 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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