flagger 2.0.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: fd2012064f6365502851698b36a4aca47eb948730956ae20b3ab6fd7431456e0
4
+ data.tar.gz: a7d18728dc84c3d3f17cab164ce8893fe9857b6e832a27ca0b7cee947eed495a
5
+ SHA512:
6
+ metadata.gz: d63e4863cf230e9aabc956e06a8b0a88228bda6234230c2ce4766f2a4e71efc5b7d429608693791c8fdb62bcc050a625b311669b90c952ef818590bcb21e68ed
7
+ data.tar.gz: 2b93f074f5318ab8feffb2ed9bcd9726756c42d6a4d5a059364217407f531a1a84152ad13ba28ce085b220a984bd57a40c53a199db95f2f17144eb623dbacc6a
@@ -0,0 +1,287 @@
1
+ require 'eventmachine'
2
+ require 'faraday'
3
+ require 'faye/websocket'
4
+ require 'json'
5
+ require 'lru_redux'
6
+ require 'set'
7
+ require 'flagger/models'
8
+ require 'flagger/stat'
9
+ require 'flagger/version'
10
+
11
+ module FlaggerEnvironments
12
+ SERVER_URL = 'https://api.airshiphq.com'
13
+ IDENTIFY_ENDPOINT = "#{SERVER_URL}/v2/identify"
14
+ GATING_INFO_ENDPOINT = "#{SERVER_URL}/v2/gating-info"
15
+
16
+ WEBSOCKET_URL = 'wss://ws.airshiphq.com'
17
+ WEBSOCKET_GATING_INFO_ENDPOINT = "#{WEBSOCKET_URL}/v2/ws-events"
18
+
19
+ CLOUDFRONT_URL = 'https://backup-api.airshiphq.com'
20
+ CLOUDFRONT_GATING_INFO_ENDPOINT = "#{CLOUDFRONT_URL}/v2/gating-info"
21
+
22
+ class CloudDelegate
23
+ attr_accessor :gating_info
24
+ attr_accessor :env_key
25
+ attr_accessor :request_timeout
26
+
27
+ def initialize(env_key, request_timeout)
28
+ @entity_lru_hashes = LruRedux::Cache.new(500)
29
+ @entities = []
30
+ @stats = []
31
+ @exposures = []
32
+ @flags = Set[]
33
+ @ingested_flags = Set[]
34
+ @env_key = env_key
35
+ @request_timeout = request_timeout
36
+
37
+ @ingestion_max_items = 500
38
+ @ingestion_interval = 30
39
+
40
+ begin
41
+ get_gating_info("#{GATING_INFO_ENDPOINT}/#{@env_key}", 'duration__gating_info')
42
+ rescue Exception => e
43
+ STDERR.puts 'Failed to retrieve initial gating info from API', e.backtrace
44
+ end
45
+
46
+ begin
47
+ get_gating_info("#{CLOUDFRONT_GATING_INFO_ENDPOINT}/#{@env_key}", 'duration__cloudfront_gating_info') unless !@gating_info.nil?
48
+ rescue Exception => e
49
+ STDERR.puts 'Failed to retrieve initial gating info from CloudFront backup', e.backtrace
50
+ end
51
+
52
+ if EM.reactor_running? then
53
+ em_subscribe_updates
54
+ em_ingestion_timer
55
+ else
56
+ Thread.new {
57
+ EM.run {
58
+ em_subscribe_updates
59
+ em_ingestion_timer
60
+ }
61
+ }
62
+ end
63
+ end
64
+
65
+ def get_gating_info(url, stat_name)
66
+ stat = FlaggerUtils::Stat.new(stat_name)
67
+ stat.start
68
+ conn = Faraday.new(url: url)
69
+ response = conn.get do |req|
70
+ req.options.timeout = @request_timeout
71
+ end
72
+ if response.status == 200
73
+ @gating_info = FlaggerModels::GatingInfo.new(JSON.parse(response.body))
74
+ update_sdk_vars(@gating_info)
75
+ stat.stop
76
+ save_stat(stat)
77
+ else
78
+ raise "Gating info request failed with HTTP code #{response.code}"
79
+ end
80
+ end
81
+
82
+ def em_subscribe_updates()
83
+ @ws_unsubbed = false
84
+ @ws = Faye::WebSocket::Client.new("#{WEBSOCKET_GATING_INFO_ENDPOINT}?envkey=#{@env_key}")
85
+
86
+ @ws.on :message do |event|
87
+ begin
88
+ @gating_info = FlaggerModels::GatingInfo.new(JSON.parse(event.data))
89
+ update_sdk_vars(@gating_info)
90
+ rescue Exception => e
91
+ STDERR.puts 'Failed to update gating information', e
92
+ end
93
+ end
94
+
95
+ @ws.on :close do |event|
96
+ STDERR.puts ['Connection closed to Airship update server', event.code, event.reason] unless @ws_unsubbed
97
+ EventMachine::Timer.new(5) do
98
+ em_subscribe_updates
99
+ end unless @ws_unsubbed
100
+ end
101
+ end
102
+
103
+ def unsubscribe_updates()
104
+ @ws_unsubbed = true
105
+ @ws.close()
106
+ end
107
+
108
+ def update_sdk_vars(gating_info)
109
+ if gating_info&.sdk_info&.ingestion_max_items then
110
+ @ingestion_max_items = gating_info.sdk_info.ingestion_max_items
111
+ end
112
+ if gating_info&.sdk_info&.ingestion_interval then
113
+ @ingestion_interval = gating_info.sdk_info.ingestion_interval
114
+ end
115
+ end
116
+
117
+ def maybe_ingest(should_ingest = false)
118
+ should_ingest |= (
119
+ @entities.length > @ingestion_max_items ||
120
+ @stats.length > @ingestion_max_items ||
121
+ @exposures.length > @ingestion_max_items ||
122
+ @flags.length > 0
123
+ )
124
+
125
+ should_ingest &= (
126
+ @entities.length > 0 ||
127
+ @stats.length > 0 ||
128
+ @exposures.length > 0 ||
129
+ @flags.length > 0
130
+ )
131
+
132
+ if should_ingest then
133
+ begin
134
+ entities = @entities
135
+ stats = @stats
136
+ exposures = @exposures
137
+ flags = @flags.to_a
138
+ @entities = []
139
+ @stats = []
140
+ @exposures = []
141
+ @ingested_flags.merge(@flags)
142
+ @flags = Set[]
143
+
144
+ conn = Faraday.new(url: "#{IDENTIFY_ENDPOINT}/#{@env_key}")
145
+ response = conn.post do |req|
146
+ req.options.timeout = @request_timeout
147
+ req.headers['Content-Type'] = 'application/json'
148
+ req.body = JSON.generate(
149
+ {
150
+ 'sdk_info' => {
151
+ 'name' => 'ruby',
152
+ 'version' => FlaggerUtils::VERSION
153
+ },
154
+ 'objects' => entities,
155
+ 'stats' => stats,
156
+ 'exposures' => exposures,
157
+ 'flags' => flags
158
+ })
159
+ end
160
+ if response.status != 200 then
161
+ STDERR.puts 'Failed to upload entities', response.status
162
+ nil
163
+ end
164
+ rescue Exception => e
165
+ STDERR.puts 'Failed to upload entities', e
166
+ nil
167
+ end
168
+ end
169
+ end
170
+
171
+ def em_ingestion_timer
172
+ seconds = 0
173
+ timer = EventMachine::PeriodicTimer.new(1) do
174
+ timer.cancel unless !@cleaning
175
+
176
+ seconds = (seconds + 1) % @ingestion_interval
177
+ if seconds == 0 then
178
+ EM.defer(proc {
179
+ maybe_ingest(true)
180
+ })
181
+ end
182
+ end
183
+ end
184
+
185
+ def cleanup()
186
+ @cleaning = true
187
+ unsubscribe_updates
188
+ maybe_ingest(true)
189
+ end
190
+
191
+ def save_entity(entity)
192
+ if @entity_lru_hashes[entity.id] != entity.hash then
193
+ @entity_lru_hashes[entity.id] = entity.hash
194
+ @entities.push(entity.attrs)
195
+ maybe_ingest()
196
+ end
197
+ end
198
+
199
+ def save_stat(stat)
200
+ @stats.push(stat)
201
+ @stats = FlaggerUtils::Stat.compact(@stats)
202
+ maybe_ingest()
203
+ end
204
+
205
+ def save_exposure(exposure)
206
+ @exposures.push(exposure)
207
+ maybe_ingest()
208
+ end
209
+
210
+ def save_flag(flag)
211
+ @flags.add(flag)
212
+ maybe_ingest()
213
+ end
214
+
215
+ def identify_entity(entity_json)
216
+ if !entity_json.is_a?(Hash) then
217
+ STDERR.puts 'Entity must be a hash'
218
+ nil
219
+ else
220
+ entity = if entity_json['is_group'] then
221
+ FlaggerModels::GroupEntity.new(entity_json)
222
+ else
223
+ FlaggerModels::SingleEntity.new(entity_json)
224
+ end
225
+
226
+ if !entity.valid? then
227
+ STDERR.puts "Entity validation errors: #{entity.validation_errors}"
228
+ nil
229
+ else
230
+ save_entity(entity)
231
+
232
+ entity
233
+ end
234
+ end
235
+ end
236
+
237
+ {
238
+ 'treatment' => 'duration__get_treatment',
239
+ 'payload' => 'duration__get_payload',
240
+ 'eligible?' => 'duration__is_eligible',
241
+ 'enabled?' => 'duration__is_enabled',
242
+ }.each do |name, stat_name|
243
+ define_method(name.to_sym) do |flagger_flag, entity_json|
244
+ stat = FlaggerUtils::Stat.new(stat_name)
245
+ stat.start()
246
+
247
+ entity = identify_entity(entity_json)
248
+ flag = @gating_info&.flag(flagger_flag.flag_name)
249
+ if flag.nil? && !@ingested_flags.include?(flagger_flag.flag_name) then
250
+ save_flag(flagger_flag.flag_name)
251
+ end
252
+ return_value = flag&.public_send(name, entity)
253
+
254
+ if name == 'treatment' and !return_value.nil? then
255
+ exposure = {
256
+ type: entity.type,
257
+ id: entity.id,
258
+ treatment_id: return_value.treatment_id,
259
+ treatment: return_value.codename
260
+ }
261
+
262
+ return_value = return_value.codename
263
+
264
+ save_exposure(exposure)
265
+ end
266
+
267
+ stat.stop()
268
+ save_stat(stat)
269
+
270
+ return_value
271
+ end
272
+ end
273
+
274
+ def publish(entities)
275
+ if !entities.is_a?(Array) then
276
+ STDERR.puts 'The "publish" method takes an array of objects (aka entities).'
277
+ return nil
278
+ end
279
+
280
+ entities.each do |entity|
281
+ identify_entity(entity)
282
+ end
283
+
284
+ maybe_ingest(true)
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,63 @@
1
+ require 'faraday'
2
+ require 'json'
3
+
4
+ module FlaggerEnvironments
5
+ class MicroserviceDelegate
6
+ attr_accessor :env_key
7
+ attr_accessor :edge_url
8
+ attr_accessor :request_timeout
9
+
10
+ def initialize(env_key, edge_url, request_timeout)
11
+ @env_key = env_key
12
+ @edge_url = edge_url
13
+ @request_timeout = request_timeout
14
+ end
15
+
16
+ def treatment(flag, entity)
17
+ get_object_values(flag, entity)['treatment'] rescue nil
18
+ end
19
+
20
+ def payload(flag, entity)
21
+ get_object_values(flag, entity)['payload'] rescue nil
22
+ end
23
+
24
+ def eligible?(flag, entity)
25
+ get_object_values(flag, entity)['isEligible'] rescue nil
26
+ end
27
+
28
+ def enabled?(flag, entity)
29
+ get_object_values(flag, entity)['isEnabled'] rescue nil
30
+ end
31
+
32
+ def publish(entities)
33
+ end
34
+
35
+ def cleanup()
36
+ end
37
+
38
+ private
39
+ def get_object_values(flag, entity)
40
+ begin
41
+ conn = Faraday.new(url: "#{@edge_url}/v2/object-values/#{@env_key}")
42
+ response = conn.post do |req|
43
+ req.options.timeout = @request_timeout
44
+ req.headers['Content-Type'] = 'application/json'
45
+ req.body = JSON.generate({
46
+ 'flag' => flag.flag_name,
47
+ 'entity' => entity,
48
+ })
49
+ end
50
+ if response.status == 200
51
+ object_values = JSON.parse(response.body)
52
+ object_values
53
+ else
54
+ puts 'Failed to connect to Airship edge server'
55
+ nil
56
+ end
57
+ rescue Exception => e
58
+ puts 'Failed to connect to Airship edge server', e
59
+ nil
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,643 @@
1
+ require 'digest'
2
+ require 'dry-validation'
3
+
4
+ module FlaggerModels
5
+ class BaseEntity
6
+ SCHEMA = Dry::Validation.Schema do
7
+ configure do
8
+ def valid_attribute_names?(attributes)
9
+ attributes.keys.all? do |name|
10
+ name.is_a?(String) && /^[a-zA-Z]$|^[a-zA-Z_]{0,48}[a-zA-Z]$/ =~ name
11
+ end
12
+ end
13
+
14
+ def valid_attribute_values?(attributes)
15
+ attributes.values.all? do |v|
16
+ (v.is_a?(String) && v.length <= 3000) || [true, false].include?(v) || v.is_a?(Numeric)
17
+ end
18
+ end
19
+
20
+ def self.messages
21
+ super.merge(
22
+ en: {
23
+ errors: {
24
+ :valid_attribute_names? => 'Each attribute must begin and end with an alphabet letter (a-z, A-Z). In between, allowed characters are a-z, A-Z, and "_". For example: isStudent or is_student. Preceding or trailing underscore is not allowed (i.e., _is_student or is_student_).',
25
+ :valid_attribute_values? => "An entity's attribute value must be a string no more than 3000 characters, boolean, or number."
26
+ }
27
+ }
28
+ )
29
+ end
30
+ end
31
+
32
+ optional('type') { str? & size?(1..50) & format?(/^([A-Z][a-zA-Z]*)+$/) }
33
+ required('id') { str? & size?(1..250) }
34
+ optional('display_name') { str? & size?(1..250) }
35
+ optional('attributes') { type?(Hash) & size?(0..100) & valid_attribute_names? & valid_attribute_values? }
36
+ required('is_group') { bool? }
37
+ end
38
+
39
+ attr_accessor :type
40
+ attr_accessor :id
41
+ attr_accessor :display_name
42
+ attr_accessor :attributes
43
+ attr_accessor :is_group
44
+
45
+ def initialize(json)
46
+ @type = json['type']
47
+ @id = json['id']
48
+ if @id.is_a?(Numeric) then
49
+ @id = @id.to_s
50
+ end
51
+ @display_name = json['display_name'] || @id if @id.is_a?(String)
52
+ @attributes = json['attributes'] || {}
53
+ end
54
+
55
+ def attrs()
56
+ instance_variables.map do |var|
57
+ if var == :@group then
58
+ group = instance_variable_get(var)
59
+ [var[1..-1], if defined? group.attrs then group.attrs else group end]
60
+ else
61
+ [var[1..-1], instance_variable_get(var)]
62
+ end
63
+ end.to_h.delete_if { |k,v| v.nil? }
64
+ end
65
+
66
+ def hash()
67
+ attrs.hash
68
+ end
69
+
70
+ def validation_errors()
71
+ SCHEMA.(attrs).errors
72
+ end
73
+
74
+ def valid?()
75
+ validation_errors.length === 0
76
+ end
77
+ end
78
+
79
+ class GroupEntity < BaseEntity
80
+ def initialize(json)
81
+ super(json)
82
+ @is_group = true
83
+ end
84
+ end
85
+
86
+ class SingleEntity < BaseEntity
87
+ SCHEMA = Dry::Validation.Schema do
88
+ optional('group').schema(GroupEntity::SCHEMA)
89
+ end
90
+
91
+ attr_accessor :group
92
+
93
+ def initialize(json)
94
+ super(json)
95
+ @group = json['group']&.is_a?(Hash) && GroupEntity.new(json['group'])
96
+ @is_group = false
97
+
98
+ if @type == nil then
99
+ @type = 'User'
100
+ end
101
+
102
+ if @group && @group.type.nil? then
103
+ @group.type = @type + 'Group'
104
+ end
105
+ end
106
+
107
+ def validation_errors()
108
+ if @group != nil then
109
+ group_errors = SingleEntity::SCHEMA.(attrs).errors
110
+ super.merge(group_errors)
111
+ else
112
+ super
113
+ end
114
+ end
115
+ end
116
+
117
+ class GatingInfoModel
118
+ def validate()
119
+ messages = self.class::SCHEMA.(attrs).messages
120
+ raise RuntimeError.new(messages) if messages.length > 0
121
+ end
122
+
123
+ def attrs()
124
+ instance_variables.map do |var|
125
+ value = instance_variable_get(var)
126
+ if value != nil then
127
+ [var, value]
128
+ else
129
+ nil
130
+ end
131
+ end.compact.to_h
132
+ end
133
+ end
134
+
135
+ class GatingInfo
136
+ attr_reader :sdk_info
137
+ attr_reader :flags
138
+ attr_reader :env
139
+
140
+ def initialize(json)
141
+ @sdk_info = SdkInfo.new(json['sdk_info'] || {})
142
+ @env = Env.new(json['env'])
143
+ @flags = Hash[json['flags'].map {|flag| [flag['codename'], Flag.new(flag, @env.hash_key)]}]
144
+ end
145
+
146
+ def flag(name)
147
+ @flags[name]
148
+ end
149
+ end
150
+
151
+ class SdkInfo < GatingInfoModel
152
+ SCHEMA = Dry::Validation.Schema do
153
+ optional(:@ingestion_interval) { int? }
154
+ optional(:@ingestion_max_items) { int? }
155
+ end
156
+
157
+ attr_reader :ingestion_interval
158
+ attr_reader :ingestion_max_items
159
+
160
+ def initialize(json)
161
+ @ingestion_interval = json['SDK_INGESTION_INTERVAL']
162
+ @ingestion_max_items = json['SDK_INGESTION_MAX_ITEMS']
163
+
164
+ validate
165
+ end
166
+ end
167
+
168
+ class Env < GatingInfoModel
169
+ SCHEMA = Dry::Validation.Schema do
170
+ required(:@env_key) { str? }
171
+ required(:@hash_key) { str? }
172
+ end
173
+
174
+ attr_reader :env_key
175
+ attr_reader :hash_key
176
+
177
+ def initialize(json)
178
+ @env_key = json['env_key']
179
+ @hash_key = json['hash_key']
180
+
181
+ validate
182
+ end
183
+ end
184
+
185
+ class Flag < GatingInfoModel
186
+ SCHEMA = Dry::Validation.Schema do
187
+ required(:@codename) { str? }
188
+ required(:@flag_status) { included_in? ['operational', 'archived'] }
189
+ required(:@flag_type) { included_in? ['basic', 'experiment', 'uncategorized'] }
190
+ required(:@hash_key) { str? }
191
+ optional(:@is_paused) { bool? }
192
+ required(:@is_web_accessible) { bool? }
193
+ required(:@overrides) { hash? }
194
+ required(:@populations) { array? }
195
+ required(:@splits) { hash? }
196
+ required(:@treatments) { hash? }
197
+
198
+ required(:@env_hash_key) { str? }
199
+ end
200
+
201
+ attr_reader :codename
202
+ attr_reader :flag_status
203
+ attr_reader :flag_type
204
+ attr_reader :hash_key
205
+ attr_reader :is_paused
206
+ attr_reader :is_web_accessible
207
+ attr_reader :overrides
208
+ attr_reader :populations
209
+ attr_reader :splits
210
+ attr_reader :treatments
211
+
212
+ attr_reader :env_hash_key
213
+
214
+ def initialize(json, env_hash_key)
215
+ @codename = json['codename']
216
+ @flag_status = json['flag_status']
217
+ @flag_type = json['flag_type']
218
+ @hash_key = json['hash_key']
219
+ @is_paused = json['is_paused']
220
+ @is_web_accessible = json['is_web_accessible']
221
+ @overrides = Hash[(json['overrides'] || []).map {|override| ["#{override['entity_type']}_#{override['entity_id']}", Override.new(override)]}]
222
+ @populations = (json['populations'] || []).map {|population| Population.new(population)}
223
+ @splits = Hash[(json['splits'] || []).map {|split| [split['treatment_id'], Split.new(split)]}]
224
+ @treatments = Hash[(json['treatments'] || []).map {|treatment| [treatment['treatment_id'], Treatment.new(treatment)]}]
225
+
226
+ @env_hash_key = env_hash_key
227
+
228
+ validate
229
+ end
230
+
231
+ def treatment(entity)
232
+ if @flag_type == 'uncategorized' or !entity then
233
+ nil
234
+ else
235
+ resolved_allocation(@env_hash_key, entity)[:treatment]
236
+ end
237
+ end
238
+
239
+ def payload(entity)
240
+ if @flag_type == 'uncategorized' or !entity then
241
+ nil
242
+ else
243
+ resolved_allocation(@env_hash_key, entity)[:treatment].payload
244
+ end
245
+ end
246
+
247
+ def eligible?(entity)
248
+ if @flag_type == 'uncategorized' or !entity then
249
+ false
250
+ else
251
+ resolved_allocation(@env_hash_key, entity)[:eligible]
252
+ end
253
+ end
254
+
255
+ def enabled?(entity)
256
+ if @flag_type == 'uncategorized' or !entity then
257
+ false
258
+ else
259
+ not resolved_allocation(@env_hash_key, entity)[:treatment].is_off_treatment
260
+ end
261
+ end
262
+
263
+ def resolved_allocation(env_hash_key, entity)
264
+ resolve_allocations(
265
+ allocation(@env_hash_key, entity),
266
+ allocation(@env_hash_key, (entity.group if defined? entity.group))
267
+ )
268
+ end
269
+
270
+ def allocation(env_hash_key, entity)
271
+ off_allocation = {
272
+ treatment: @treatments.find {|treatment_id, treatment| treatment.is_off_treatment}[1],
273
+ eligible: false
274
+ }
275
+
276
+ if !entity.is_a?(BaseEntity) then
277
+ return off_allocation
278
+ end
279
+
280
+ if @flag_status == 'archived' then
281
+ STDERR.puts 'The flag has been archived'
282
+ return off_allocation
283
+ end
284
+
285
+ if @is_paused then
286
+ return off_allocation
287
+ end
288
+
289
+ treatment = @treatments[@overrides["#{entity.type}_#{entity.id}"]&.treatment_id]
290
+ if treatment then
291
+ return {
292
+ treatment: treatment,
293
+ eligible: !treatment.is_off_treatment,
294
+ from_override: true
295
+ }
296
+ end
297
+
298
+ use_universes = @flag_type == 'experiment'
299
+
300
+ is_eligible = false
301
+ @populations.each do |population|
302
+ eligible, treatment = population.gate_values(
303
+ entity,
304
+ env_hash_key,
305
+ self,
306
+ use_universes
307
+ ).values_at(:eligible, :treatment)
308
+
309
+ if treatment != nil then
310
+ return {
311
+ treatment: treatment,
312
+ eligible: eligible
313
+ }
314
+ end
315
+
316
+ is_eligible ||= eligible
317
+ end
318
+
319
+ {
320
+ treatment: off_allocation[:treatment],
321
+ eligible: is_eligible
322
+ }
323
+ end
324
+
325
+ def resolve_allocations(individual_alloc, group_alloc)
326
+ if individual_alloc[:from_override] then
327
+ individual_alloc
328
+ elsif group_alloc[:from_override] then
329
+ group_alloc
330
+ elsif not individual_alloc[:treatment].is_off_treatment then
331
+ individual_alloc
332
+ elsif not group_alloc[:treatment].is_off_treatment then
333
+ group_alloc
334
+ else
335
+ individual_alloc
336
+ end
337
+ end
338
+ end
339
+
340
+ class Override < GatingInfoModel
341
+ SCHEMA = Dry::Validation.Schema do
342
+ required(:@treatment_id) { str? }
343
+ required(:@entity_id) { str? }
344
+ required(:@entity_type) { str? }
345
+ end
346
+
347
+ attr_reader :treatment_id
348
+ attr_reader :entity_id
349
+ attr_reader :entity_type
350
+
351
+ def initialize(json)
352
+ @treatment_id = json['treatment_id']
353
+ @entity_id = json['entity_id']
354
+ @entity_type = json['entity_type']
355
+
356
+ validate
357
+ end
358
+ end
359
+
360
+ class Population < GatingInfoModel
361
+ SCHEMA = Dry::Validation.Schema do
362
+ required(:@rules) { array? }
363
+ required(:@universes) { array? }
364
+ required(:@percentage) { gteq?(0) & lteq?(1) }
365
+ required(:@hash_key) { str? }
366
+ required(:@entity_type) { str? }
367
+ end
368
+
369
+ attr_reader :rules
370
+ attr_reader :universes
371
+ attr_reader :percentage
372
+ attr_reader :hash_key
373
+ attr_reader :entity_type
374
+
375
+ def initialize(json)
376
+ @rules = (json['rules'] || []).map {|rule| Rule.new(rule)}
377
+ @universes = (json['universes'] || []).map {|universe| Hash[universe.map {|split| [split['treatment_id'], Split.new(split)]}]}
378
+ @percentage = json['percentage']
379
+ @hash_key = json['hash_key']
380
+ @entity_type = json['entity_type']
381
+
382
+ validate
383
+ end
384
+
385
+ def gate_values(entity, env_hash_key, flag, sticky)
386
+ if @entity_type != entity.type
387
+ return {eligible: false}
388
+ end
389
+
390
+ matches = @rules.all? {|rule| rule.match(entity)}
391
+ if matches then
392
+ samplingHashKey = "SAMPLING:control_#{flag.hash_key}:env_#{env_hash_key}:rule_set_#{@hash_key}:client_object_#{entity.type}_#{entity.id}"
393
+ if Population.getHashedPercentage(samplingHashKey) <= @percentage and @percentage > 0 then
394
+
395
+ allocationHashKey = "DISTRIBUTION:control_#{flag.hash_key}:env_#{env_hash_key}:client_object_#{entity.type}_#{entity.id}"
396
+ allocationHashedPercentage = Population.getHashedPercentage(allocationHashKey)
397
+
398
+ splits = if sticky then
399
+ @universes[(@percentage * 100).floor - 1]
400
+ else
401
+ flag.splits
402
+ end
403
+
404
+ sum = 0
405
+ {
406
+ eligible: true,
407
+ treatment: flag.treatments.find {|treatment_id, treatment|
408
+ if treatment.is_off_treatment then
409
+ false
410
+ else
411
+ sum = (sum + (splits[treatment.treatment_id]&.percentage || 0)).round(3)
412
+ allocationHashedPercentage <= sum
413
+ end
414
+ }[1]
415
+ }
416
+ else
417
+ {eligible: true}
418
+ end
419
+ else
420
+ {eligible: false}
421
+ end
422
+ end
423
+
424
+ def self.getHashedPercentage(s)
425
+ Digest::MD5.hexdigest(s).to_i(16).to_f / 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
426
+ end
427
+ end
428
+
429
+ module RuleType
430
+ STRING = 'string'
431
+ INT = 'int'
432
+ FLOAT = 'float'
433
+ BOOLEAN = 'boolean'
434
+ DATE = 'date'
435
+ DATETIME = 'datetime'
436
+ end
437
+
438
+ module RuleOperator
439
+ IS = 'is'
440
+ IS_NOT = 'is_not'
441
+ IN = 'in'
442
+ NOT_IN = 'not_in'
443
+ LT = 'lt'
444
+ LTE = 'lte'
445
+ GT = 'gt'
446
+ GTE = 'gte'
447
+ FROM = 'from'
448
+ UNTIL = 'until'
449
+ AFTER = 'after'
450
+ BEFORE = 'before'
451
+ end
452
+
453
+ class Rule < GatingInfoModel
454
+ SCHEMA = Dry::Validation.Schema do
455
+ required(:@operator) { included_in? RuleOperator.constants(false).map(&RuleOperator.method(:const_get)) }
456
+ required(:@attribute_type) { included_in? RuleType.constants(false).map(&RuleType.method(:const_get)) }
457
+ required(:@attribute_name) { str? }
458
+ optional(:@value_list) { array? { each { str? | bool? | type?(Numeric) } } }
459
+ optional(:@value) { str? | bool? | type?(Numeric) }
460
+ end
461
+
462
+ attr_reader :operator
463
+ attr_reader :attribute_type
464
+ attr_reader :attribute_name
465
+ attr_reader :value_list
466
+ attr_reader :value
467
+
468
+ def initialize(json)
469
+ @operator = json['operator']
470
+ @attribute_type = json['attribute_type']
471
+ @attribute_name = json['attribute_name']
472
+ @value_list = json['value_list']
473
+ @value = json['value']
474
+
475
+ validate
476
+ end
477
+
478
+ def self.categorize_value_type(value)
479
+ case value
480
+ when true
481
+ 'boolean'
482
+ when false
483
+ 'boolean'
484
+ when Float
485
+ 'float'
486
+ when Integer
487
+ 'int'
488
+ when String
489
+ begin
490
+ date = DateTime.parse(value)
491
+ if date.hour == 0 && date.minute == 0 && date.second == 0 && date.second_fraction == 0 then
492
+ 'date'
493
+ else
494
+ 'datetime'
495
+ end
496
+ rescue ArgumentError
497
+ 'string'
498
+ end
499
+ else
500
+ STDERR.puts 'Unexpected attribute value type encountered'
501
+ nil
502
+ end
503
+ end
504
+
505
+ def match(entity)
506
+ begin
507
+ if not entity.attributes.key?(@attribute_name) then
508
+ return false
509
+ end
510
+
511
+ value = entity.attributes[@attribute_name]
512
+ attribute_type = Rule.categorize_value_type(value)
513
+ number_types = [RuleType::INT, RuleType::FLOAT].sort
514
+
515
+ if attribute_type != @attribute_type &&
516
+ [attribute_type, @attribute_type].sort != number_types then
517
+ return false
518
+ end
519
+
520
+ if attribute_type == RuleType::STRING then
521
+ if @operator == RuleOperator::IS then
522
+ value == @value
523
+ elsif @operator == RuleOperator::IS_NOT then
524
+ value != @value
525
+ elsif @operator == RuleOperator::IN then
526
+ @value_list.include?(value)
527
+ elsif @operator == RuleOperator::NOT_IN then
528
+ not @value_list.include?(value)
529
+ else
530
+ STDERR.puts 'Invalid rule operator encountered'
531
+ false
532
+ end
533
+ elsif number_types.include?(attribute_type) then
534
+ if @operator == RuleOperator::IS then
535
+ value == @value
536
+ elsif @operator == RuleOperator::IS_NOT then
537
+ value != @value
538
+ elsif @operator == RuleOperator::IN then
539
+ @value_list.include?(value)
540
+ elsif @operator == RuleOperator::NOT_IN then
541
+ not @value_list.include?(value)
542
+ elsif @operator == RuleOperator::LT then
543
+ value < @value
544
+ elsif @operator == RuleOperator::LTE then
545
+ value <= @value
546
+ elsif @operator == RuleOperator::GT then
547
+ value > @value
548
+ elsif @operator == RuleOperator::GTE then
549
+ value >= @value
550
+ else
551
+ STDERR.puts 'Invalid rule operator encountered'
552
+ false
553
+ end
554
+ elsif attribute_type == RuleType::BOOLEAN then
555
+ if @operator == RuleOperator::IS then
556
+ value == @value
557
+ elsif @operator == RuleOperator::IS_NOT then
558
+ value != @value
559
+ else
560
+ STDERR.puts 'Invalid rule operator encountered'
561
+ false
562
+ end
563
+ elsif attribute_type == RuleType::DATE || attribute_type == RuleType::DATETIME then
564
+ target_time = @value && DateTime.parse(@value)
565
+ target_time_list = @value_list&.map {|tv| DateTime.parse(tv)}
566
+ value_time = DateTime.parse(value)
567
+
568
+ if @operator == RuleOperator::IS then
569
+ value_time == target_time
570
+ elsif @operator == RuleOperator::IS_NOT then
571
+ value_time != target_time
572
+ elsif @operator == RuleOperator::IN then
573
+ target_time_list.include?(value_time)
574
+ elsif @operator == RuleOperator::NOT_IN then
575
+ not target_time_list.include?(value_time)
576
+ elsif @operator == RuleOperator::FROM then
577
+ value_time >= target_time
578
+ elsif @operator == RuleOperator::UNTIL then
579
+ value_time <= target_time
580
+ elsif @operator == RuleOperator::AFTER then
581
+ value_time > target_time
582
+ elsif @operator == RuleOperator::BEFORE then
583
+ value_time < target_time
584
+ else
585
+ STDERR.puts 'Invalid rule operator encountered'
586
+ false
587
+ end
588
+ else
589
+ STDERR.puts 'Invalid attribute type encountered'
590
+ false
591
+ end
592
+ rescue Exception => e
593
+ STDERR.puts "Traceback:"
594
+ STDERR.puts e.backtrace
595
+ STDERR.puts "#{e.class} (#{e.message})"
596
+ false
597
+ end
598
+ end
599
+ end
600
+
601
+ class Split < GatingInfoModel
602
+ SCHEMA = Dry::Validation.Schema do
603
+ required(:@treatment_id) { str? }
604
+ required(:@percentage) { gteq?(0) & lteq?(1) }
605
+ end
606
+
607
+ attr_reader :treatment_id
608
+ attr_reader :percentage
609
+
610
+ def initialize(json)
611
+ @treatment_id = json['treatment_id']
612
+ @percentage = json['percentage']
613
+
614
+ validate
615
+ end
616
+ end
617
+
618
+ class Treatment < GatingInfoModel
619
+ SCHEMA = Dry::Validation.Schema do
620
+ required(:@treatment_id) { str? }
621
+ required(:@is_control) { bool? }
622
+ required(:@codename) { str? }
623
+ required(:@is_off_treatment) { bool? }
624
+ optional(:@payload) { str? }
625
+ end
626
+
627
+ attr_reader :treatment_id
628
+ attr_reader :is_control
629
+ attr_reader :codename
630
+ attr_reader :is_off_treatment
631
+ attr_reader :payload
632
+
633
+ def initialize(json)
634
+ @treatment_id = json['treatment_id']
635
+ @is_control = json['is_control']
636
+ @codename = json['codename']
637
+ @is_off_treatment = json['is_off_treatment']
638
+ @payload = json['payload']
639
+
640
+ validate
641
+ end
642
+ end
643
+ end
@@ -0,0 +1,56 @@
1
+ require 'json'
2
+
3
+ module FlaggerUtils
4
+ class Stat
5
+ attr_reader :name
6
+ attr_accessor :duration
7
+ attr_accessor :count
8
+
9
+ def self.compact(stats)
10
+ groups = stats.group_by {|stat| stat.name}
11
+ groups.values.map do |same_stats|
12
+ new_stat = Stat.new(same_stats[0].name)
13
+ new_stat.count = same_stats.reduce(0) {|count, stat| count + stat.count}
14
+ if same_stats[0].duration then
15
+ new_stat.duration = same_stats.reduce(0) {|duration, stat| duration + stat.duration * stat.count} / new_stat.count
16
+ end
17
+ new_stat
18
+ end
19
+ end
20
+
21
+ def initialize(name)
22
+ @name = name
23
+ @count = 0
24
+ end
25
+
26
+ def start()
27
+ @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+ end
29
+
30
+ def stop()
31
+ @end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
+ @duration = (@end_time - @start_time) * 1e9
33
+ @count = 1
34
+ end
35
+
36
+ def to_json(options={})
37
+ to_h.to_json(options)
38
+ end
39
+
40
+ def to_h()
41
+ if @duration then
42
+ {
43
+ name: name,
44
+ duration: @duration,
45
+ unit: 'ns',
46
+ count: @count
47
+ }
48
+ else
49
+ {
50
+ name: name,
51
+ count: @count
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/flagger.rb ADDED
@@ -0,0 +1,87 @@
1
+ require 'flagger/cloud'
2
+ require 'flagger/microservice'
3
+
4
+ class Flagger
5
+ class Flag
6
+ attr_accessor :flag_name
7
+ attr_accessor :delegate
8
+
9
+ def initialize(flag_name, delegate)
10
+ @flag_name = flag_name
11
+ @delegate = delegate
12
+ end
13
+
14
+ def treatment(entity)
15
+ @delegate&.treatment(self, entity) || 'off'
16
+ end
17
+
18
+ def payload(entity)
19
+ @delegate&.payload(self, entity) || nil
20
+ end
21
+
22
+ def eligible?(entity)
23
+ @delegate&.eligible?(self, entity) || false
24
+ end
25
+
26
+ def enabled?(entity)
27
+ @delegate&.enabled?(self, entity) || false
28
+ end
29
+ end
30
+
31
+ @@instance = Flagger.new
32
+
33
+ attr_accessor :env_key
34
+ attr_accessor :edge_url
35
+ attr_accessor :request_timeout
36
+ attr_accessor :delegate
37
+
38
+ def self.configure(options)
39
+ @@instance.configure(options)
40
+ @@instance
41
+ end
42
+
43
+ def configure(options)
44
+ @env_key = options[:env_key]
45
+ @edge_url = options[:edge_url]
46
+ @request_timeout = options[:request_timeout] || 10
47
+
48
+ @delegate&.cleanup
49
+ if @edge_url.nil? then
50
+ @delegate = FlaggerEnvironments::CloudDelegate.new(@env_key, @request_timeout)
51
+ else
52
+ @delegate = FlaggerEnvironments::MicroserviceDelegate.new(@env_key, @edge_url, @request_timeout)
53
+ end
54
+ end
55
+
56
+ def self.flag(flag_name)
57
+ Flag.new(flag_name, @@instance.delegate)
58
+ end
59
+
60
+ def self.publish(entities)
61
+ @@instance.delegate&.publish(entities)
62
+ end
63
+ end
64
+
65
+ class Airship
66
+ def initialize(options)
67
+ STDERR.puts 'The Airship class is deprecated.'
68
+
69
+ @env_key = options[:env_key]
70
+ end
71
+
72
+ def init()
73
+ Flagger.configure(env_key: @env_key)
74
+ end
75
+
76
+ def variation(flag, entity)
77
+ Flagger.flag(flag).treatment(entity)
78
+ end
79
+
80
+ def eligible?(flag, entity)
81
+ Flagger.flag(flag).eligible?(entity)
82
+ end
83
+
84
+ def enabled?(flag, entity)
85
+ Flagger.flag(flag).enabled?(entity)
86
+ end
87
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flagger
3
+ version: !ruby/object:Gem::Version
4
+ version: 2.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Airship Dev Team
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-10-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: eventmachine
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: faye-websocket
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.10.7
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.10.7
55
+ - !ruby/object:Gem::Dependency
56
+ name: lru_redux
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.1'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.1'
69
+ - !ruby/object:Gem::Dependency
70
+ name: dry-validation
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 0.12.2
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 0.12.2
83
+ description: Ruby SDK
84
+ email: support@airshiphq.com
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - lib/flagger.rb
90
+ - lib/flagger/cloud.rb
91
+ - lib/flagger/microservice.rb
92
+ - lib/flagger/models.rb
93
+ - lib/flagger/stat.rb
94
+ homepage: https://airshiphq.com
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ source_code_uri: https://github.com/airshiphq/flagger-ruby
99
+ post_install_message:
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubyforge_project:
115
+ rubygems_version: 2.7.7
116
+ signing_key:
117
+ specification_version: 4
118
+ summary: Flagger Ruby SDK
119
+ test_files: []