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 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
@@ -0,0 +1,8 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+
4
+ Style/StringLiterals:
5
+ EnforcedStyle: double_quotes
6
+
7
+ Style/StringLiteralsInInterpolation:
8
+ EnforcedStyle: double_quotes
data/CHANGELOG.md ADDED
@@ -0,0 +1,2 @@
1
+ ## [0.1.0] - 2024-04-08
2
+ - Initial release
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
+ ![Gem Version](https://img.shields.io/gem/v/lab_coat) ![Gem Total Downloads](https://img.shields.io/gem/dt/lab_coat)
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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LabCoat
4
+ VERSION = "0.1.0"
5
+ 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: []