lab_coat 0.1.1 → 0.1.3
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.
- 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
|
 
|
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
|