lab_coat 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []