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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +69 -38
- data/lib/lab_coat/experiment.rb +21 -7
- data/lib/lab_coat/observation.rb +15 -5
- data/lib/lab_coat/result.rb +11 -0
- data/lib/lab_coat/version.rb +1 -1
- data/lib/lab_coat.rb +2 -0
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 35fcc39652cc2ab43879a6a513214962c139d6b5751f5a1d4b4e8dba7b72b5e3
|
4
|
+
data.tar.gz: 5b54b1fb3fcabe26a80b2cdd619308f51b7fc44c6b0b93572550eb3e9a50aedd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
> [!
|
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
|
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
|
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
|
-
|
93
|
-
|
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
|
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
|
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
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
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 <<~
|
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
|
-
|
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
|
242
|
-
|
270
|
+
puts <<~MSG
|
271
|
+
duration: #{observation.duration.real}
|
272
|
+
succeeded: #{!observation.raised?}
|
273
|
+
MSG
|
243
274
|
end
|
244
275
|
end
|
245
276
|
```
|
data/lib/lab_coat/experiment.rb
CHANGED
@@ -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
|
-
|
66
|
+
control_obs = Observation.new("control", self) do
|
65
67
|
control(...)
|
66
68
|
end
|
67
|
-
raised(
|
68
|
-
return
|
69
|
+
raised(control_obs) if control_obs.raised?
|
70
|
+
return control_obs.value unless enabled?(...)
|
69
71
|
|
70
|
-
|
72
|
+
candidate_obs = Observation.new("candidate", self) do
|
71
73
|
candidate(...)
|
72
74
|
end
|
73
|
-
raised(
|
75
|
+
raised(candidate_obs) if candidate_obs.raised?
|
74
76
|
|
75
77
|
# Compare and publish the results.
|
76
|
-
result = Result.new(self,
|
78
|
+
result = Result.new(self, control_obs, candidate_obs)
|
77
79
|
publish!(result)
|
78
80
|
|
79
81
|
# Always return the control.
|
80
|
-
|
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
|
data/lib/lab_coat/observation.rb
CHANGED
@@ -9,13 +9,10 @@ module LabCoat
|
|
9
9
|
@name = name
|
10
10
|
@experiment = experiment
|
11
11
|
|
12
|
-
|
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
|
data/lib/lab_coat/result.rb
CHANGED
@@ -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
|
data/lib/lab_coat/version.rb
CHANGED
data/lib/lab_coat.rb
CHANGED
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.
|
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-
|
11
|
+
date: 2024-04-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: minitest
|