molasses 0.1.0

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