lab_coat 0.1.1 → 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d819fd05745d82590e7d0f8c2e05f415b3ec6bea1cdc5def244fcfee17b969c7
4
- data.tar.gz: 27819f2af7099608845196b00ae89a0ba59e9bdb8224814585a659601a261e76
3
+ metadata.gz: 35fcc39652cc2ab43879a6a513214962c139d6b5751f5a1d4b4e8dba7b72b5e3
4
+ data.tar.gz: 5b54b1fb3fcabe26a80b2cdd619308f51b7fc44c6b0b93572550eb3e9a50aedd
5
5
  SHA512:
6
- metadata.gz: 711d1510f5d1f4d59a57830567988d74fad50bfe7c4e548cd76e67d3f46bb2dfbbff503914e6129f7023032f7986c2153a1359e600d1f7d2e6aa443d98f27c0d
7
- data.tar.gz: 66ed5879e1e803045806464c6b708b1a355c0d997965e1a6ea43e42b789d4f2904b7c39223b60101c08a1857c35dba72003d9463102962da27d9f39b181c1636
6
+ metadata.gz: 48e1e6af3c47d799f6eecd76dcac57a168da9c1729fc29091742b79ae114d36741a9d72bac6348db0db43a868ee5f75087ced725d270ae55074221a2dc152cd5
7
+ data.tar.gz: 6b98f3702d8a8cc8494bc2c9eb5e09e1ecc6aff21d204be93d54485eba4754fa60aa89aa832b2644910e42d6ac42c4cdc683db0ac4d0b49b7c3fe2025b417488
data/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.1.3] - 2024-04-17
2
+ - `Experiment` now enforces arity at runtime for the `#enabled?`, `control`, and `candidate` methods.
3
+
4
+ ## [0.1.2] - 2024-04-15
5
+ - use `Benchmark` to capture the duration with more details
6
+ - add `to_h` methods to `Result` and `Observation` for convenience
7
+
1
8
  ## [0.1.1] - 2024-04-08
2
9
  - add `#slug` method to `Observation`
3
10
 
data/README.md CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  ![Gem Version](https://img.shields.io/gem/v/lab_coat) ![Gem Total Downloads](https://img.shields.io/gem/dt/lab_coat)
4
4
 
5
- A simple experiment library to safely test new code paths.
5
+ A simple experiment library to safely test new code paths. `LabCoat` is designed to be highly customizable and play nice with your existing tools/services.
6
6
 
7
7
  This library is heavily inspired by [Scientist](https://github.com/github/scientist), with some key differences:
8
8
  - `Experiments` are `classes`, not `modules` which means they are stateful by default.
9
9
  - There is no app wide default experiment that gets magically set.
10
10
  - The `Result` only supports one comparison at a time, i.e. only 1 `candidate` is allowed per run.
11
+ - The `duration` is measured using Ruby's `Benchmark`.
11
12
 
12
13
  ## Installation
13
14
 
@@ -55,8 +56,8 @@ See the [`Experiment`](lib/lab_coat/experiment.rb) class for more details.
55
56
  |`enabled?`|Returns a `Boolean` that controls whether or not the experiment runs.|
56
57
  |`publish!`|This is not _technically_ required, but `Experiments` are not useful unless you can analyze the results. Override this method to record the `Result` however you wish.|
57
58
 
58
- > [!TIP]
59
- > The `#run!` method accepts arbitrary arguments and forwards them to `enabled?`, `control`, and `candidate` in case you need to provide data at runtime.
59
+ > [!IMPORTANT]
60
+ > The `#run!` method accepts arbitrary arguments and forwards them to `enabled?`, `control`, and `candidate` in case you need to provide data at runtime. This means the [arity](https://en.wikipedia.org/wiki/Arity) of the three methods needs to be the same. This is enforced by `LabCoat` at runtime.
60
61
 
61
62
  #### Additional methods
62
63
 
@@ -69,13 +70,11 @@ See the [`Experiment`](lib/lab_coat/experiment.rb) class for more details.
69
70
  > [!TIP]
70
71
  > You should create a shared base class(es) to maintain consistency across experiments within your app.
71
72
 
72
- You may want to give your experiment some context, or state. You can do this via an initializer or writer methods just like any other Ruby class.
73
+ You might want to give your experiment some context, or state. You can do this via an initializer or writer methods just like any other Ruby class.
73
74
 
74
75
  ```ruby
75
76
  # application_experiment.rb
76
77
  class ApplicationExperiment < LabCoat::Experiment
77
- attr_reader :user, :is_admin
78
-
79
78
  def initialize(user)
80
79
  @user = user
81
80
  @is_admin = user.admin?
@@ -83,25 +82,19 @@ class ApplicationExperiment < LabCoat::Experiment
83
82
  end
84
83
  ```
85
84
 
86
- You likely want to `publish!` all experiments in a consistent way, so that you can analyze the data and make decisions. New `Experiment` authors should not have to redo the "plumbing" between your experimentation framework (e.g. `LabCoat`) and your observability (o11y) process.
85
+ You might want to `publish!` all experiments in a consistent way so that you can analyze the data and make decisions. New `Experiment` authors should not have to redo the "plumbing" between your experimentation framework (e.g. `LabCoat`) and your observability (o11y) process.
87
86
 
88
87
  ```ruby
89
88
  # application_experiment.rb
90
89
  class ApplicationExperiment < LabCoat::Experiment
91
90
  def publish!(result)
92
- YourO11yService.track_experiment_result(
93
- name: result.experiment.name,
94
- matched: result.matched?,
95
- observations: {
96
- control: result.control.publishable_value,
97
- candidate: result.candidate.publishable_value,
98
- }
99
- )
91
+ payload = result.to_h.merge(user_id: @user.id)
92
+ YourO11yService.track_experiment_result(payload)
100
93
  end
101
94
  end
102
95
  ```
103
96
 
104
- You might also have a common way to enable experiments such as a feature flag system and/or common guards you want to enforce application wide. These might come from a mix of services and the `Experiment`'s state.
97
+ You might have a common way to enable experiments such as a feature flag system and/or common guards you want to enforce application wide. These might come from a mix of services and the `Experiment`'s state.
105
98
 
106
99
  ```ruby
107
100
  # application_experiment.rb
@@ -112,19 +105,34 @@ class ApplicationExperiment < LabCoat::Experiment
112
105
  end
113
106
  ```
114
107
 
108
+ You might want to track any errors thrown from all your experiments and route them to some service, or log them.
109
+
110
+ ```ruby
111
+ # application_experiment.rb
112
+ class ApplicationExperiment < LabCoat::Experiment
113
+ def raised(observation)
114
+ YourErrorService.report_error(
115
+ observation.error,
116
+ tags: observation.to_h
117
+ )
118
+ end
119
+ end
120
+ ```
121
+
115
122
  ### Make some `Observations` via `run!`
116
123
 
117
124
  You don't have to create an `Observation` yourself; that happens automatically when you call `Experiment#run!`. The control and candidate `Observations` are packaged into a `Result` and [passed to `Experiment#publish!`](#publish-the-result).
118
125
 
119
126
  |Attribute|Description|
120
127
  |---|---|
121
- |`duration`|The duration of the run in `float` seconds.|
128
+ |`duration`|The duration of the run represented as a `Benchmark::Tms` object.|
122
129
  |`error`|If the code path raised, the thrown exception is stored here.|
123
130
  |`experiment`|The `Experiment` instance this `Result` is for.|
124
131
  |`name`|Either `"control"` or `"candidate"`.|
125
132
  |`publishable_value`|A publishable representation of the `value`, as defined by `Experiment#publishable_value`.|
126
133
  |`raised?`|Whether or not the code path raised.|
127
134
  |`slug`|A combination of the `Experiment#name` and `Observation#name`, e.g. `"experiment_name.control"`|
135
+ |`to_h`|A hash representation of the `Observation`. Useful for publishing and/or reporting.|
128
136
  |`value`|The return value of the observed code path.|
129
137
 
130
138
  `Observation` instances are passed to many of the `Experiment` methods that you may override.
@@ -145,18 +153,14 @@ def ignore?(control, candidate)
145
153
  end
146
154
 
147
155
  def publishable_value(observation)
148
- if observation.raised?
149
- {
150
- error_class: observation.error.class.name,
151
- error_message: observation.error.message
152
- }
153
- else
154
- {
155
- type: observation.name,
156
- value: observation.publishable_value,
157
- duration: observation.duration
158
- }
159
- end
156
+ return nil if observation.raised?
157
+
158
+ # Let's say your control and candidate blocks return objects that don't serialize nicely.
159
+ {
160
+ some_attribute: observation.value.some_attribute,
161
+ some_other_attribute: observation.value.some_other_attribute,
162
+ some_count: observation.value.some_array.count
163
+ }
160
164
  end
161
165
 
162
166
  # Elsewhere...
@@ -174,10 +178,26 @@ A `Result` represents a single run of an `Experiment`.
174
178
  |`experiment`|The `Experiment` instance this `Result` is for.|
175
179
  |`ignored?`|Whether or not the result should be ignored, as defined by `Experiment#ignore?`|
176
180
  |`matched?`|Whether or not the `control` and `candidate` match, as defined by `Experiment#compare`|
181
+ |`to_h`|A hash representation of the `Result`. Useful for publishing and/or reporting.|
177
182
 
178
- The `Result` is passed to your implementation of `#publish!` when an `Experiment` is finished running.
183
+ The `Result` is passed to your implementation of `#publish!` when an `Experiment` is finished running. The `to_h` method on a Result is a good place to start and might be sufficient for most experiments.
179
184
 
180
185
  ```ruby
186
+ # your_experiment.rb
187
+ def publish!(result)
188
+ return if result.ignored?
189
+
190
+ puts result.to_h
191
+ end
192
+ ```
193
+
194
+ > [!NOTE]
195
+ > All `Results` are passed to `publish!`, **including ignored ones**. It is your responsibility to call the `ignored?` method and handle those as you wish.
196
+
197
+ You can always access all of the attributes of the `Result` and its `Observations` directly to fully customize what your experiment publishing looks like.
198
+
199
+ ```ruby
200
+ # your_experiment.rb
181
201
  def publish!(result)
182
202
  if result.ignored?
183
203
  puts "🙈"
@@ -189,22 +209,27 @@ def publish!(result)
189
209
  else
190
210
  control = result.control
191
211
  candidate = result.candidate
192
- puts <<~MISMATCH
212
+ puts <<~MSG
193
213
  😮
194
214
 
195
215
  #{control.slug}
196
216
  Value: #{control.publishable_value}
197
- Duration: #{control.duration}
217
+ Duration Real: #{control.duration.real}
218
+ Duration System: #{control.duration.stime}
219
+ Duration User: #{control.duration.utime}
198
220
  Error: #{control.error&.message}
199
221
 
200
222
  #{candidate.slug}
201
223
  Value: #{candidate.publishable_value}
202
- Duration: #{candidate.duration}
224
+ Duration: #{candidate.duration.real}
225
+ Duration System: #{candidate.duration.stime}
226
+ Duration User: #{candidate.duration.utime}
203
227
  Error: #{candidate.error&.message}
204
- MISMATCH
228
+ MSG
205
229
  end
206
230
  end
207
231
  ```
232
+
208
233
  Running a mismatched experiment with this implementation of `publish!` would produce:
209
234
 
210
235
  ```
@@ -212,12 +237,16 @@ Running a mismatched experiment with this implementation of `publish!` would pro
212
237
 
213
238
  my_experiment.control
214
239
  Value: 420
215
- Duration: 12.934
240
+ Duration Real: 12.934
241
+ Duration System: 2.134
242
+ Duration User: 10.800
216
243
  Error:
217
244
 
218
245
  my_experiment.candidate
219
246
  Value: 69
220
- Duration: 9.702
247
+ Duration Real: 9.702
248
+ Duration System: 1.002
249
+ Duration User: 8.700
221
250
  Error:
222
251
  ```
223
252
 
@@ -238,8 +267,10 @@ The `Observation` class can be used as a standalone wrapper for any code that yo
238
267
  if observation.raised?
239
268
  puts "error: #{observation.error.message}"
240
269
  else
241
- puts "duration: #{observation.duration}"
242
- puts "succeeded: #{!observation.raised?}"
270
+ puts <<~MSG
271
+ duration: #{observation.duration.real}
272
+ succeeded: #{!observation.raised?}
273
+ MSG
243
274
  end
244
275
  end
245
276
  ```
@@ -60,24 +60,38 @@ module LabCoat
60
60
  # Runs the control and candidate and publishes the result. Always returns the result of `control`.
61
61
  # @param context [Hash] Any data needed at runtime.
62
62
  def run!(...) # rubocop:disable Metrics/MethodLength
63
+ enforce_arity!
64
+
63
65
  # Run the control and exit early if the experiment is not enabled.
64
- control = Observation.new("control", self) do
66
+ control_obs = Observation.new("control", self) do
65
67
  control(...)
66
68
  end
67
- raised(control) if control.raised?
68
- return control.value unless enabled?(...)
69
+ raised(control_obs) if control_obs.raised?
70
+ return control_obs.value unless enabled?(...)
69
71
 
70
- candidate = Observation.new("candidate", self) do
72
+ candidate_obs = Observation.new("candidate", self) do
71
73
  candidate(...)
72
74
  end
73
- raised(candidate) if candidate.raised?
75
+ raised(candidate_obs) if candidate_obs.raised?
74
76
 
75
77
  # Compare and publish the results.
76
- result = Result.new(self, control, candidate)
78
+ result = Result.new(self, control_obs, candidate_obs)
77
79
  publish!(result)
78
80
 
79
81
  # Always return the control.
80
- control.value
82
+ control_obs.value
83
+ end
84
+
85
+ private
86
+
87
+ # Because `run!` forwards arbitrary args to `#enabled?`, `control`, and `candidate`, the methods must have the same
88
+ # arity. Otherwise
89
+ def enforce_arity!
90
+ return if %i[enabled? control candidate].map { |m| method(m).arity }.uniq.size == 1
91
+
92
+ raise InvalidExperimentError,
93
+ "The `#enabled?`, `#control` and `#candidate` methods must have the same arity. All runtime args passed " \
94
+ "to `#run!` are forwarded to these methods."
81
95
  end
82
96
  end
83
97
  end
@@ -9,13 +9,10 @@ module LabCoat
9
9
  @name = name
10
10
  @experiment = experiment
11
11
 
12
- start_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second)
13
- begin
12
+ @duration = Benchmark.measure(name) do
14
13
  @value = block.call
15
14
  rescue StandardError => e
16
15
  @error = e
17
- ensure
18
- @duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) - start_at
19
16
  end
20
17
  end
21
18
 
@@ -28,9 +25,22 @@ module LabCoat
28
25
  !error.nil?
29
26
  end
30
27
 
31
- # @return [String] String representing this Observation.
28
+ # @return [String] String representing this `Observation`.
32
29
  def slug
33
30
  "#{experiment.name}.#{name}"
34
31
  end
32
+
33
+ # @return [Hash] A hash representation of this `Observation`. Useful when publishing `Results`.
34
+ def to_h
35
+ {
36
+ name: name,
37
+ experiment: experiment.name,
38
+ slug: slug,
39
+ value: publishable_value,
40
+ duration: duration.to_h,
41
+ error_class: error&.class&.name,
42
+ error_message: error&.message
43
+ }.compact
44
+ end
35
45
  end
36
46
  end
@@ -26,5 +26,16 @@ module LabCoat
26
26
  def ignored?
27
27
  @ignored
28
28
  end
29
+
30
+ # @return [Hash] A hash representation of this `Result`. Useful when publishing.
31
+ def to_h
32
+ {
33
+ experiment: experiment.name,
34
+ matched: matched?,
35
+ ignored: ignored?,
36
+ control: control.to_h,
37
+ candidate: candidate.to_h
38
+ }
39
+ end
29
40
  end
30
41
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LabCoat
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/lab_coat.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "benchmark"
4
+
3
5
  require_relative "lab_coat/version"
4
6
  require_relative "lab_coat/observation"
5
7
  require_relative "lab_coat/result"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lab_coat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Omkar Moghe
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-04-09 00:00:00.000000000 Z
11
+ date: 2024-04-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest