lab_coat 0.1.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.
- checksums.yaml +7 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +2 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/Rakefile +12 -0
- data/lib/lab_coat/experiment.rb +83 -0
- data/lib/lab_coat/observation.rb +31 -0
- data/lib/lab_coat/result.rb +30 -0
- data/lib/lab_coat/version.rb +5 -0
- data/lib/lab_coat.rb +11 -0
- metadata +84 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: d66afab349e98ec1ce140293e4372772633d9d8565ff66543f502769274614fb
|
4
|
+
data.tar.gz: 683b940631dc537a41049af5c49006f56c80b833d8644957c6cbb8c6b28cff2f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1c492775bf3f0b3c9418934764891a82331761cc507e41e7de7c9085481b2860cc6fe3b5c9d048e008b15519cad72a1226ec9476248f75365ec581d634e59317
|
7
|
+
data.tar.gz: ead0b6e3125f239444654e492ed5414073ba9b56c33e365226b7218548c429b08599c5df4475dbf76eb9375add204126fa7f000dcd59eb1c69dce6cf65234a5b
|
data/.rubocop.yml
ADDED
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Omkar Moghe
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,201 @@
|
|
1
|
+
# LabCoat 🥼
|
2
|
+
|
3
|
+
 
|
4
|
+
|
5
|
+
A simple experiment library to safely test new code paths.
|
6
|
+
|
7
|
+
This library is heavily inspired by [Scientist](https://github.com/github/scientist), with some key differences:
|
8
|
+
- `Experiments` are `classes`, not `modules` which means they are stateful by default.
|
9
|
+
- There is no app wide default experiment that gets magically set.
|
10
|
+
- The `Result` only supports one comparison at a time, i.e. only 1 `candidate` is allowed.
|
11
|
+
|
12
|
+
## Installation
|
13
|
+
|
14
|
+
Install the gem and add to the application's Gemfile by executing:
|
15
|
+
|
16
|
+
`bundle add lab_coat`
|
17
|
+
|
18
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
19
|
+
|
20
|
+
`gem install lab_coat`
|
21
|
+
|
22
|
+
## Usage
|
23
|
+
|
24
|
+
### Create an `Experiment`
|
25
|
+
|
26
|
+
To do some science, i.e. test out a new code path, start by defining an `Experiment`. An experiment is any class that inherits from `LabCoat::Experiment` and implements the required methods.
|
27
|
+
|
28
|
+
#### Required methods
|
29
|
+
|
30
|
+
See the [`Experiment`](lib/lab_coat/experiment.rb) class for more details.
|
31
|
+
|
32
|
+
|Method|Description|
|
33
|
+
|---|---|
|
34
|
+
|`enabled?`|Returns a `Boolean` that controls whether or not the experiment runs.|
|
35
|
+
|`control`|The existing or default behavior. This will always be returned from `#run!`.|
|
36
|
+
|`candidate`|The new behavior you want to test.|
|
37
|
+
|`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.|
|
38
|
+
|
39
|
+
#### Additional methods
|
40
|
+
|
41
|
+
|Method|Description|
|
42
|
+
|---|---|
|
43
|
+
|`compare`|Whether or not the result is a match. This is how you can run complex/custom comparisons.|
|
44
|
+
|`ignore?`|Whether or not the result should be ignored. Ignored `Results` are still passed to `#publish!`|
|
45
|
+
|`raised`|Callback method that's called when an `Observation` raises.|
|
46
|
+
|
47
|
+
Consider creating a shared base class(es) to create consistency across experiments within your app.
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
# application_experiment.rb
|
51
|
+
class ApplicationExperiment < LabCoat::Experiment
|
52
|
+
end
|
53
|
+
```
|
54
|
+
|
55
|
+
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.
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
# application_experiment.rb
|
59
|
+
class ApplicationExperiment < LabCoat::Experiment
|
60
|
+
attr_reader :user, :is_admin
|
61
|
+
|
62
|
+
def initialize(user)
|
63
|
+
@user = user
|
64
|
+
@is_admin = user.admin?
|
65
|
+
end
|
66
|
+
end
|
67
|
+
```
|
68
|
+
|
69
|
+
You likely want to `publish!` all experiments in a uniform way, so that you can analyze the data and make decisions.
|
70
|
+
|
71
|
+
```ruby
|
72
|
+
# application_experiment.rb
|
73
|
+
class ApplicationExperiment < LabCoat::Experiment
|
74
|
+
def publish!(result)
|
75
|
+
YourO11yService.track_experiment_result(
|
76
|
+
name: result.experiment.name,
|
77
|
+
matched: result.matched?,
|
78
|
+
observations: {
|
79
|
+
control: result.control.publishable_value,
|
80
|
+
candidate: result.candidate.publishable_value,
|
81
|
+
}
|
82
|
+
)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
```
|
86
|
+
|
87
|
+
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.
|
88
|
+
|
89
|
+
```ruby
|
90
|
+
# application_experiment.rb
|
91
|
+
class ApplicationExperiment < LabCoat::Experiment
|
92
|
+
def enabled?
|
93
|
+
!@is_admin && YourFeatureFlagService.flag_enabled?(@user.id, name)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
```
|
97
|
+
|
98
|
+
### Make some `Observations` via `run!`
|
99
|
+
|
100
|
+
You don't have to create an `Observation` yourself; that happens automatically when you call `Experiment#run!`.
|
101
|
+
|
102
|
+
|Attribute|Description|
|
103
|
+
|---|---|
|
104
|
+
|`name`|Either `"control"` or `"candidate"`.|
|
105
|
+
|`experiment`|The `Experiment` instance this `Result` is for.|
|
106
|
+
|`duration`|The duration of the run in `float` seconds.|
|
107
|
+
|`value`|The return value of the observed code path.|
|
108
|
+
|`publishable_value`|A publishable representation of the `value`, as defined by `Experiment#publishable_value`.|
|
109
|
+
|`raised?`|Whether or not the code path raised.|
|
110
|
+
|`error`|If the code path raised, the thrown exception is stored here.|
|
111
|
+
|
112
|
+
`Observation` instances are passed to many of the `Experiment` methods that you may override.
|
113
|
+
|
114
|
+
```ruby
|
115
|
+
# your_experiment.rb
|
116
|
+
def compare(control, candidate)
|
117
|
+
return false if control.raised? || candidate.raised?
|
118
|
+
|
119
|
+
control.value.some_method == candidate.value.some_method
|
120
|
+
end
|
121
|
+
|
122
|
+
def ignore?(control, candidate)
|
123
|
+
return true if control.raised? || candidate.raised?
|
124
|
+
return true if candidate.value.some_guard?
|
125
|
+
|
126
|
+
false
|
127
|
+
end
|
128
|
+
|
129
|
+
def publishable_value(observation)
|
130
|
+
if observation.raised?
|
131
|
+
{
|
132
|
+
error_class: observation.error.class.name,
|
133
|
+
error_message: observation.error.message
|
134
|
+
}
|
135
|
+
else
|
136
|
+
{
|
137
|
+
type: observation.name,
|
138
|
+
value: observation.publishable_value,
|
139
|
+
duration: observation.duration
|
140
|
+
}
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Elsewhere...
|
145
|
+
YourExperiment.new(...).run!
|
146
|
+
```
|
147
|
+
|
148
|
+
### Publish the `Result`
|
149
|
+
|
150
|
+
A `Result` represents a single run of an `Experiment`.
|
151
|
+
|
152
|
+
|Attribute|Description|
|
153
|
+
|---|---|
|
154
|
+
|`experiment`|The `Experiment` instance this `Result` is for.|
|
155
|
+
|`control`|An `Observation` instance representing the `Experiment#control` behavior|
|
156
|
+
|`candidate`|An `Observation` instance representing the `Experiment#candidate` behavior|
|
157
|
+
|`matched?`|Whether or not the `control` and `candidate` match, as defined by `Experiment#compare`|
|
158
|
+
|`ignored?`|Whether or not the result should be ignored, as defined by `Experiment#ignore?`|
|
159
|
+
|
160
|
+
The `Result` is passed to your implementation of `#publish!` when an `Experiment` is finished running.
|
161
|
+
|
162
|
+
```ruby
|
163
|
+
def publish!(result)
|
164
|
+
if result.ignored?
|
165
|
+
puts "🙈"
|
166
|
+
return
|
167
|
+
end
|
168
|
+
|
169
|
+
if result.matched?
|
170
|
+
puts "😎"
|
171
|
+
else
|
172
|
+
puts <<~MISMATCH
|
173
|
+
😮
|
174
|
+
|
175
|
+
[Control]
|
176
|
+
Value: #{result.control.publishable_value}
|
177
|
+
Duration: #{result.control.duration}
|
178
|
+
Error: #{result.control.error&.message}
|
179
|
+
|
180
|
+
[Candidate]
|
181
|
+
Value: #{result.candidate.publishable_value}
|
182
|
+
Duration: #{result.candidate.duration}
|
183
|
+
Error: #{result.candidate.error&.message}
|
184
|
+
MISMATCH
|
185
|
+
end
|
186
|
+
end
|
187
|
+
```
|
188
|
+
|
189
|
+
## Development
|
190
|
+
|
191
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
192
|
+
|
193
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
194
|
+
|
195
|
+
## Contributing
|
196
|
+
|
197
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/lab_coat.
|
198
|
+
|
199
|
+
## License
|
200
|
+
|
201
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LabCoat
|
4
|
+
# A base experiment class meant to be subclassed to define various experiments.
|
5
|
+
class Experiment
|
6
|
+
attr_reader :name
|
7
|
+
|
8
|
+
def initialize(name)
|
9
|
+
@name = name
|
10
|
+
end
|
11
|
+
|
12
|
+
# Override this method to control whether or not the experiment runs.
|
13
|
+
# @return [TrueClass, FalseClass]
|
14
|
+
def enabled?(...)
|
15
|
+
raise InvalidExperimentError, "`#enabled?` must be implemented in your Experiment class."
|
16
|
+
end
|
17
|
+
|
18
|
+
# Override this method to define the existing aka "control" behavior. This method is always run, even when
|
19
|
+
# `enabled?` is false.
|
20
|
+
# @return [Object] Anything.
|
21
|
+
def control(...)
|
22
|
+
raise InvalidExperimentError, "`#control` must be implemented in your Experiment class."
|
23
|
+
end
|
24
|
+
|
25
|
+
# Override this method to define the new aka "candidate" behavior. Only run if the experiment is enabled.
|
26
|
+
# @return [Object] Anything.
|
27
|
+
def candidate(...)
|
28
|
+
raise InvalidExperimentError, "`#candidate` must be implemented in your Experiment class."
|
29
|
+
end
|
30
|
+
|
31
|
+
# Override this method to define what is considered a match or mismatch. Must return a boolean.
|
32
|
+
# @param control [LabCoat::Observation] The control `Observation`.
|
33
|
+
# @param candidate [LabCoat::Observation] The candidate `Observation`.
|
34
|
+
# @return [TrueClass, FalseClass]
|
35
|
+
def compare(control, candidate)
|
36
|
+
control.value == candidate.value
|
37
|
+
end
|
38
|
+
|
39
|
+
# Override this method to define which results are ignored. Must return a boolean.
|
40
|
+
def ignore?(_control, _candidate)
|
41
|
+
false
|
42
|
+
end
|
43
|
+
|
44
|
+
# Called when the control and/or candidate observations raise an error.
|
45
|
+
# @param observation [LabCoat::Observation]
|
46
|
+
def raised(observation); end
|
47
|
+
|
48
|
+
# Override this method to transform the value for publishing. This could mean turning the value into something
|
49
|
+
# serializable (e.g. JSON).
|
50
|
+
# @param observation [LabCoat::Observation]
|
51
|
+
def publishable_value(observation)
|
52
|
+
observation.value
|
53
|
+
end
|
54
|
+
|
55
|
+
# Override this method to publish the `Result`. It's recommended to override this once in an application wide base
|
56
|
+
# class.
|
57
|
+
# @param result [LabCoat::Result] The result of this experiment.
|
58
|
+
def publish!(result); end
|
59
|
+
|
60
|
+
# Runs the control and candidate and publishes the result. Always returns the result of `control`.
|
61
|
+
# @param context [Hash] Any data needed at runtime.
|
62
|
+
def run!(...) # rubocop:disable Metrics/MethodLength
|
63
|
+
# Run the control and exit early if the experiment is not enabled.
|
64
|
+
control = Observation.new("control", self) do
|
65
|
+
control(...)
|
66
|
+
end
|
67
|
+
raised(control) if control.raised?
|
68
|
+
return control.value unless enabled?(...)
|
69
|
+
|
70
|
+
candidate = Observation.new("candidate", self) do
|
71
|
+
candidate(...)
|
72
|
+
end
|
73
|
+
raised(candidate) if candidate.raised?
|
74
|
+
|
75
|
+
# Compare and publish the results.
|
76
|
+
result = Result.new(self, control, candidate)
|
77
|
+
publish!(result)
|
78
|
+
|
79
|
+
# Always return the control.
|
80
|
+
control.value
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LabCoat
|
4
|
+
# A wrapper around some behavior that captures the resulting `value` and any exceptions thrown.
|
5
|
+
class Observation
|
6
|
+
attr_reader :name, :experiment, :duration, :value, :error
|
7
|
+
|
8
|
+
def initialize(name, experiment, &block)
|
9
|
+
@name = name
|
10
|
+
@experiment = experiment
|
11
|
+
|
12
|
+
start_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second)
|
13
|
+
begin
|
14
|
+
@value = block.call
|
15
|
+
rescue StandardError => e
|
16
|
+
@error = e
|
17
|
+
ensure
|
18
|
+
@duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) - start_at
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def publishable_value
|
23
|
+
@publishable_value ||= experiment.publishable_value(self)
|
24
|
+
end
|
25
|
+
|
26
|
+
# @return [TrueClass, FalseClass]
|
27
|
+
def raised?
|
28
|
+
!error.nil?
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LabCoat
|
4
|
+
# The result of a single `Experiment` run, that is published by the `Experiment`.
|
5
|
+
class Result
|
6
|
+
attr_reader :experiment, :control, :candidate
|
7
|
+
|
8
|
+
def initialize(experiment, control, candidate)
|
9
|
+
@experiment = experiment
|
10
|
+
@control = control
|
11
|
+
@candidate = candidate
|
12
|
+
@matched = experiment.compare(control, candidate)
|
13
|
+
@ignored = experiment.ignore?(control, candidate)
|
14
|
+
|
15
|
+
freeze
|
16
|
+
end
|
17
|
+
|
18
|
+
# Whether or not the control and candidate match, as defined by `Experiment#compare`.
|
19
|
+
# @return [TrueClass, FalseClass]
|
20
|
+
def matched?
|
21
|
+
@matched
|
22
|
+
end
|
23
|
+
|
24
|
+
# Whether or not the result should be ignored, as defined by `#Experiment#ignore?`.
|
25
|
+
# @return [TrueClass, FalseClass]
|
26
|
+
def ignored?
|
27
|
+
@ignored
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/lib/lab_coat.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lab_coat/version"
|
4
|
+
require_relative "lab_coat/observation"
|
5
|
+
require_relative "lab_coat/result"
|
6
|
+
require_relative "lab_coat/experiment"
|
7
|
+
|
8
|
+
module LabCoat
|
9
|
+
Error = Class.new(StandardError)
|
10
|
+
InvalidExperimentError = Class.new(Error)
|
11
|
+
end
|
metadata
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: lab_coat
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Omkar Moghe
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-04-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: minitest
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '5.16'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '5.16'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rubocop
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.21'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.21'
|
41
|
+
description:
|
42
|
+
email:
|
43
|
+
- yo@omkr.dev
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- ".rubocop.yml"
|
49
|
+
- CHANGELOG.md
|
50
|
+
- LICENSE.txt
|
51
|
+
- README.md
|
52
|
+
- Rakefile
|
53
|
+
- lib/lab_coat.rb
|
54
|
+
- lib/lab_coat/experiment.rb
|
55
|
+
- lib/lab_coat/observation.rb
|
56
|
+
- lib/lab_coat/result.rb
|
57
|
+
- lib/lab_coat/version.rb
|
58
|
+
homepage: https://github.com/omkarmoghe/lab_coat
|
59
|
+
licenses:
|
60
|
+
- MIT
|
61
|
+
metadata:
|
62
|
+
homepage_uri: https://github.com/omkarmoghe/lab_coat
|
63
|
+
source_code_uri: https://github.com/omkarmoghe/lab_coat
|
64
|
+
changelog_uri: https://github.com/omkarmoghe/lab_coat/blob/main/CHANGELOG.md
|
65
|
+
post_install_message:
|
66
|
+
rdoc_options: []
|
67
|
+
require_paths:
|
68
|
+
- lib
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: 3.0.0
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
75
|
+
requirements:
|
76
|
+
- - ">="
|
77
|
+
- !ruby/object:Gem::Version
|
78
|
+
version: '0'
|
79
|
+
requirements: []
|
80
|
+
rubygems_version: 3.4.1
|
81
|
+
signing_key:
|
82
|
+
specification_version: 4
|
83
|
+
summary: A simple experiment library to safely test new code paths.
|
84
|
+
test_files: []
|