molasses 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.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +99 -0
  3. data/lib/molasses.rb +182 -0
  4. data/spec/molasses_spec.rb +279 -0
  5. metadata +60 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 65c1760e6753f23f0b5029971c67d0bd637ec58abe376d8a1a4f151eaa05bb3c
4
+ data.tar.gz: d1c94afb5eb0be2190b7898599375aa9395a0b986d0c3281e497b9243dfed927
5
+ SHA512:
6
+ metadata.gz: 88459445d1b41d504aba89102b05d602587fde046c79a92c305e80097da3601c97fae412c8c2f394a68324aaacc887ce43cd9b5361e0b4d6f93466f1cf917b2d
7
+ data.tar.gz: 364dbb84b69da102d920599d2de04a4371a1e6935b9f8968bc2126b669a25b05294b5db4748f65f28650ad1307717732c4b699ced27ab41a86b7f7f9018f2ef7
@@ -0,0 +1,99 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/molassesapp/molasses-go/main/logo.png" style="margin: 0px auto;" width="200"/></p>
3
+
4
+ <h1 align="center">Molasses-Ruby</h1>
5
+
6
+ A Ruby SDK for Molasses. It allows you to evaluate user's status for a feature. It also helps simplify logging events for A/B testing.
7
+
8
+ Molasses uses polling to check if you have updated features. Once initialized, it takes microseconds to evaluate if a user is active.
9
+
10
+ ## Install
11
+
12
+ ```
13
+ gem install molasses
14
+
15
+ bundle add molasses
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ### Initialization
21
+
22
+ Start by initializing the client with an `APIKey`. This begins the polling for any feature updates. The updates happen every 15 seconds.
23
+
24
+ ```ruby
25
+ require 'molasses'
26
+
27
+ client = Molasses::Client.new("test_api_key")
28
+
29
+ ```
30
+
31
+ If you decide not to track analytics events (experiment started, experiment success) you can turn them off by setting the `send_events` field to `false`
32
+
33
+ ```go
34
+ client = Molasses::Client.new("test_api_key", false)
35
+
36
+ ```
37
+
38
+ ### Check if feature is active
39
+
40
+ You can call `is_active` with the key name and optionally a user's information. The `id` field is used to determine whether a user is part of a percentage of users. If you have other constraints based on user params you can pass those in the `params` field.
41
+
42
+ ```ruby
43
+ client.is_active("FOO_TEST", {
44
+ "id"=>"foo",
45
+ "params"=>{
46
+ "isBetaUser"=>"false",
47
+ "isScaredUser"=>"false"
48
+ }
49
+ })
50
+
51
+ ```
52
+
53
+ You can check if a feature is active for a user who is anonymous by just calling `isActive` with the key. You won't be able to do percentage roll outs or track that user's behavior.
54
+
55
+ ```ruby
56
+ client.is_active("TEST_FEATURE_FOR_USER")
57
+ ```
58
+
59
+ ### Experiments
60
+
61
+ To track whether an experiment was successful you can call `experiment_success`. experiment_success takes the feature's name, any additional parameters for the event and the user.
62
+
63
+ ```ruby
64
+ client.experiment_success("GOOGLE_SSO",{
65
+ "version": "v2.3.0"
66
+ },{
67
+ "id"=>"foo",
68
+ "params"=>{
69
+ "isBetaUser"=>"false",
70
+ "isScaredUser"=>"false"
71
+ }
72
+ })
73
+ ```
74
+
75
+ ## Example
76
+
77
+ ```ruby
78
+ require 'molasses'
79
+
80
+ client = Molasses::Client.new("test_api_key")
81
+
82
+ if client.is_active('NEW_CHECKOUT') {
83
+ puts "we are a go"
84
+ else
85
+ puts "we are a no go"
86
+ end
87
+ foo_test = client.is_active("FOO_TEST", {
88
+ "id"=>"foo",
89
+ "params"=>{
90
+ "isBetaUser"=>"false",
91
+ "isScaredUser"=>"false"
92
+ }
93
+ })
94
+ if foo_test
95
+ puts "we are a go"
96
+ else
97
+ puts "we are a no go"
98
+ end
99
+ ```
@@ -0,0 +1,182 @@
1
+ require 'workers'
2
+ require 'faraday'
3
+ require 'json'
4
+ require 'zlib'
5
+ module Molasses
6
+ class Client
7
+ def initialize(api_key, send_events=true, base_url="")
8
+ @api_key = api_key
9
+ @send_events = send_events
10
+ if base_url != ""
11
+ @base_url = base_url
12
+ else
13
+ @base_url = 'https://us-central1-molasses-36bff.cloudfunctions.net'
14
+ end
15
+ @conn = Faraday.new(
16
+ url: @base_url,
17
+ headers: {
18
+ 'Content-Type' => 'application/json',
19
+ 'Authorization' => "Bearer #{@api_key}"
20
+ }
21
+ )
22
+ @feature_cache = {}
23
+ @initialized = {}
24
+ fetch_features
25
+ @timer = Workers::PeriodicTimer.new(15) do
26
+ fetch_features
27
+ end
28
+ end
29
+
30
+ def is_active(key, user={})
31
+ unless @initialized
32
+ return false
33
+ end
34
+
35
+ if @feature_cache.include?(key)
36
+ feature = @feature_cache[key]
37
+ result = user_active(feature, user)
38
+ if @send_events && user && user.include?("id")
39
+ send_event({
40
+ "event"=> "experiment_started",
41
+ "tags"=> user["params"],
42
+ "userId"=> user["id"],
43
+ "featureId"=> feature["id"],
44
+ "featureName"=> key,
45
+ "testType"=> result ? "experiment" : "control"
46
+ })
47
+ end
48
+ return result
49
+ else
50
+ return false
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def user_active(feature, user)
57
+ unless feature.include?("active")
58
+ return false
59
+ end
60
+
61
+ unless user && user.include?("id")
62
+ return true
63
+ end
64
+
65
+ segment_map = {}
66
+ for feature_segment in feature["segments"]
67
+ segment_type = feature_segment["segmentType"]
68
+ segment_map[segment_type] = feature_segment
69
+ end
70
+
71
+ if segment_map.include?("alwaysControl") and in_segment(user, segment_map["alwaysControl"])
72
+ return false
73
+ end
74
+ if segment_map.include?("alwaysExperiment") and in_segment(user, segment_map["alwaysExperiment"])
75
+ return true
76
+ end
77
+
78
+ return user_percentage(user["id"], segment_map["everyoneElse"]["percentage"])
79
+ end
80
+
81
+ def user_percentage(id="", percentage = 0)
82
+ if percentage == 100
83
+ return true
84
+ end
85
+
86
+ if percentage == 0
87
+ return false
88
+ end
89
+
90
+ c = Zlib.crc32(id)
91
+ v = (c % 100).abs
92
+ return v < percentage
93
+ end
94
+
95
+ def in_segment(user, segment_map)
96
+ user_constraints = segment_map["userConstraints"]
97
+ constraints_length = user_constraints.length
98
+ constraints_to_be_met = segment_map["constraint"] == 'any' ? 1 : constraints_length
99
+ constraints_met = 0
100
+
101
+ for constraint in user_constraints
102
+ param = constraint["userParam"]
103
+ param_exists = user["params"].include?(param)
104
+ user_value = nil
105
+ if param_exists
106
+ user_value = user["params"][param]
107
+ end
108
+
109
+ if param == "id"
110
+ param_exists = true
111
+ user_value = user["id"]
112
+ end
113
+
114
+ if meets_constraint(user_value, param_exists, constraint)
115
+ constraints_met = constraints_met + 1
116
+ end
117
+ end
118
+ return constraints_met >= constraints_to_be_met
119
+ end
120
+
121
+ def meets_constraint(user_value, param_exists, constraint)
122
+ operator = constraint["operator"]
123
+ if param_exists == false
124
+ return false
125
+ end
126
+
127
+ case operator
128
+ when "in"
129
+ list_values = constraint["values"].split(',')
130
+ return list_values.include? user_value
131
+ when "nin"
132
+ list_values = constraint["values"].split(',')
133
+ return !list_values.include?(user_value)
134
+ when "equals"
135
+ return user_value == constraint["values"]
136
+ when "doesNotEqual"
137
+ return user_value != constraint["values"]
138
+ when "contains"
139
+ return constraint["values"].include?(user_value)
140
+ when "doesNotContain"
141
+ return !constraint["values"].include?(user_value)
142
+ else
143
+ return false
144
+ end
145
+ end
146
+ def experiment_success(key, additional_details={}, user=nil)
147
+ if !@initialized || !@send_events || user == nil || user.include?("id")
148
+ return false
149
+ end
150
+ feature = @feature_cache[key]
151
+ result = is_active(feature, user)
152
+ send_event({
153
+ "event"=> "experiment_success",
154
+ "tags"=> user["params"].merge(additional_details),
155
+ "userId"=> user["id"],
156
+ "featureId"=> feature["id"],
157
+ "featureName"=> key,
158
+ "testType"=> result ? "experiment" : "control"
159
+ })
160
+ end
161
+ def send_event(event_options)
162
+ @conn.post('analytics', event_options.to_json)
163
+ end
164
+
165
+ def fetch_features()
166
+ response = @conn.get('get-features')
167
+ if response.status == 200
168
+ data = JSON.parse(response.body)
169
+ if data.include?("data") and data["data"].include?("features")
170
+ features = data["data"]["features"]
171
+ for feature in features do
172
+ @feature_cache[feature["key"]] = feature
173
+ end
174
+ @initialized = true
175
+ end
176
+ else
177
+ puts "Molasses - #{response.status} - #{response.body}"
178
+ end
179
+ end
180
+
181
+ end
182
+ end
@@ -0,0 +1,279 @@
1
+ require 'faraday'
2
+ require 'molasses'
3
+ require 'webmock/rspec'
4
+ responseA = {
5
+ "data": {
6
+ "features": [
7
+ {
8
+ "active": true,
9
+ "description": "foo",
10
+ "key": "FOO_TEST",
11
+ "segments": [
12
+ {
13
+ "constraint": "all",
14
+ "percentage": 100,
15
+ "segmentType": "alwaysControl",
16
+ "userConstraints": [
17
+ {
18
+ "userParam": "isScaredUser",
19
+ "operator": "in",
20
+ "values": "true,maybe",
21
+ },
22
+ ],
23
+ },
24
+ {
25
+ "constraint": "all",
26
+ "percentage": 100,
27
+ "segmentType": "alwaysExperiment",
28
+ "userConstraints": [
29
+ {
30
+ "userParam": "isBetaUser",
31
+ "operator": "equals",
32
+ "values": "true",
33
+ },
34
+ ],
35
+ },
36
+ {
37
+ "constraint": "all",
38
+ "percentage": 100,
39
+ "segmentType": "everyoneElse",
40
+ "userConstraints": [],
41
+ },
42
+ ],
43
+ },
44
+ ],
45
+ }, }
46
+ responseB = {
47
+ "data": {
48
+ "features": [
49
+ {
50
+ "id": "1",
51
+ "active": true,
52
+ "description": "foo",
53
+ "key": "FOO_TEST",
54
+ "segments": [
55
+ {
56
+ "constraint": "all",
57
+ "percentage": 100,
58
+ "segmentType": "alwaysControl",
59
+ "userConstraints": [
60
+ {
61
+ "userParam": "isScaredUser",
62
+ "operator": "nin",
63
+ "values": "false,maybe",
64
+ },
65
+ ],
66
+ },
67
+ {
68
+ "constraint": "all",
69
+ "percentage": 100,
70
+ "segmentType": "alwaysExperiment",
71
+ "userConstraints": [
72
+ {
73
+ "userParam": "isBetaUser",
74
+ "operator": "doesNotEqual",
75
+ "values": "false",
76
+ },
77
+ ],
78
+ },
79
+ {
80
+ "constraint": "all",
81
+ "percentage": 100,
82
+ "segmentType": "everyoneElse",
83
+ "userConstraints": [],
84
+ },
85
+ ],
86
+ },
87
+ ],
88
+ },
89
+ }
90
+
91
+ responseC = {
92
+ "data": {
93
+ "features": [
94
+ {
95
+ "id": "1",
96
+ "active": true,
97
+ "description": "foo",
98
+ "key": "FOO_TEST",
99
+ "segments": [
100
+ {
101
+ "percentage": 100,
102
+ "segmentType": "alwaysControl",
103
+ "constraint": "all",
104
+ "userConstraints": [
105
+ {
106
+ "userParam": "isScaredUser",
107
+ "operator": "contains",
108
+ "values": "scared",
109
+ },
110
+ {
111
+ "userParam": "isDefinitelyScaredUser",
112
+ "operator": "contains",
113
+ "values": "scared",
114
+ },
115
+ {
116
+ "userParam": "isMostDefinitelyScaredUser",
117
+ "operator": "contains",
118
+ "values": "scared",
119
+ },
120
+ ],
121
+ },
122
+ {
123
+ "percentage": 100,
124
+ "segmentType": "alwaysExperiment",
125
+ "constraint": "any",
126
+ "userConstraints": [
127
+ {
128
+ "userParam": "isBetaUser",
129
+ "operator": "doesNotContain",
130
+ "values": "fal",
131
+ },
132
+ {
133
+ "userParam": "isDefinitelyBetaUser",
134
+ "operator": "doesNotContain",
135
+ "values": "fal",
136
+ },
137
+ ],
138
+ },
139
+ {
140
+ "constraint": "all",
141
+ "percentage": 100,
142
+ "segmentType": "everyoneElse",
143
+ "userConstraints": [],
144
+ },
145
+ ],
146
+ },
147
+ ],
148
+ },
149
+ }
150
+
151
+ responseD = {
152
+ "data": {
153
+ "features": [
154
+ {
155
+ "id": "1",
156
+ "active": true,
157
+ "description": "foo",
158
+ "key": "FOO_TEST",
159
+ "segments": [],
160
+ },
161
+ {
162
+ "id": "2",
163
+ "active": false,
164
+ "description": "foo",
165
+ "key": "FOO_FALSE_TEST",
166
+ "segments": [],
167
+ },
168
+ {
169
+ "id": "3",
170
+ "active": true,
171
+ "description": "foo",
172
+ "key": "FOO_50_PERCENT_TEST",
173
+ "segments": [
174
+ {
175
+ "constraint": "all",
176
+ "segmentType": "everyoneElse",
177
+ "percentage": 50,
178
+ "userConstraints": [],
179
+ },
180
+ ],
181
+ },
182
+ {
183
+ "id": "4",
184
+ "active": true,
185
+ "description": "foo",
186
+ "key": "FOO_0_PERCENT_TEST",
187
+ "segments": [
188
+ {
189
+ "constraint": "all",
190
+ "segmentType": "everyoneElse",
191
+ "percentage": 0,
192
+ "userConstraints": [],
193
+ },
194
+ ],
195
+ },
196
+ {
197
+ "id": "5",
198
+ "active": true,
199
+ "description": "foo",
200
+ "key": "FOO_ID_TEST",
201
+ "segments": [
202
+ {
203
+ "constraint": "all",
204
+ "percentage": 100,
205
+ "segmentType": "alwaysControl",
206
+ "userConstraints": [
207
+ {
208
+ "userParam": "id",
209
+ "operator": "equals",
210
+ "values": "123",
211
+ },
212
+ ],
213
+ },
214
+ {
215
+ "constraint": "all",
216
+ "segmentType": "everyoneElse",
217
+ "percentage": 100,
218
+ "userConstraints": [],
219
+ },
220
+ ],
221
+ },
222
+ ],
223
+ },
224
+ }
225
+
226
+ $client = nil
227
+ $conn = nil
228
+ $stubs = nil
229
+ RSpec.describe Molasses::Client do
230
+ let(:stubs) { Faraday::Adapter::Test::Stubs.new }
231
+ let(:conn) { Faraday.new { |b| b.adapter(:test, stubs) } }
232
+ let(:client) { Molasses::Client.new("test_api_key", false) }
233
+ it "can do basic molasses" do
234
+ stub_request(:get, 'https://us-central1-molasses-36bff.cloudfunctions.net/get-features').
235
+ to_return(body:responseA.to_json, headers: {
236
+ 'Content-Type'=>'application/json',
237
+ },)
238
+ expect(client.is_active("FOO_TEST")).to be_truthy
239
+ expect(client.is_active("FOO_TEST", {"foo"=>"foo"})).to be_truthy
240
+ expect(client.is_active("NOT_CHECKOUT")).to be_falsy
241
+ expect(client.is_active("FOO_TEST", {"id"=>"foo", "params"=>{}})).to be_truthy
242
+ expect(client.is_active("FOO_TEST", {"id"=>"food", "params"=>{
243
+ "isScaredUser"=>"true"}})).to be_falsy
244
+ expect(client.is_active("FOO_TEST", {"id"=>"foodie", "params"=>{
245
+ "isBetaUser"=>"true"}})).to be_truthy
246
+ end
247
+ it "can do advanced molasses" do
248
+ stub_request(:get, 'https://us-central1-molasses-36bff.cloudfunctions.net/get-features').
249
+ to_return(body:responseB.to_json, headers: {
250
+ 'Content-Type'=>'application/json',
251
+ },)
252
+
253
+ expect(client.is_active("FOO_TEST")).to be_truthy
254
+ expect(client.is_active("FOO_TEST", {"foo"=>"foo"})).to be_truthy
255
+ expect(client.is_active("NOT_CHECKOUT")).to be_falsy
256
+ expect(client.is_active("FOO_TEST", {"id"=>"foo", "params"=>{
257
+ "isBetaUser"=>"false", "isScaredUser"=>"false"}})).to be_truthy
258
+ expect(client.is_active("FOO_TEST", {"id"=>"food", "params"=>{
259
+ "isScaredUser"=>"true"}})).to be_falsy
260
+ expect(client.is_active("FOO_TEST", {"id"=>"foodie", "params"=>{
261
+ "isBetaUser"=>"true"}})).to be_truthy
262
+ end
263
+ it "can do even more advanced molasses" do
264
+ stub_request(:get, 'https://us-central1-molasses-36bff.cloudfunctions.net/get-features').
265
+ to_return(body:responseC.to_json, headers: {
266
+ 'Content-Type'=>'application/json',
267
+ },)
268
+
269
+ expect(client.is_active("FOO_TEST", {"id"=> "foo", "params" => {
270
+ "isScaredUser"=> "scared",
271
+ "isDefinitelyScaredUser"=> "scared",
272
+ "isMostDefinitelyScaredUser"=> "scared",
273
+ }})).to be_falsy
274
+ expect(client.is_active("FOO_TEST", {"id"=>"food", "params"=>{
275
+ "isDefinitelyBetaUser"=>"true", "isBetaUser"=>"true"}})).to be_truthy
276
+ expect(client.is_active("FOO_TEST", {"id"=>"foodie", "params"=>{
277
+ "isBetaUser"=>"true"}})).to be_truthy
278
+ end
279
+ end
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: molasses
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - James Hrisho
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-09-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ description: Ruby SDK for Molasses. Feature flags as a service
28
+ email: james.hrisho@gmail.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - README.md
34
+ - lib/molasses.rb
35
+ - spec/molasses_spec.rb
36
+ homepage: https://molasses.app
37
+ licenses:
38
+ - MIT
39
+ metadata:
40
+ source_code_uri: https://github.com/molassesapp/molasses-ruby
41
+ post_install_message:
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 3.0.3
57
+ signing_key:
58
+ specification_version: 4
59
+ summary: Ruby SDK for Molasses. Feature flags as a service
60
+ test_files: []