flagger 2.0.9 → 3.0.1

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.
@@ -1,495 +0,0 @@
1
- require 'eventmachine'
2
- require 'faraday'
3
- require 'faye/websocket'
4
- require 'json'
5
- require 'lru_redux'
6
- require 'set'
7
- require 'thread'
8
- require 'flagger/models'
9
- require 'flagger/stat'
10
- require 'flagger/version'
11
-
12
- module FlaggerEnvironments
13
- SERVER_URL = 'https://api.airshiphq.com'
14
- IDENTIFY_ENDPOINT = "#{SERVER_URL}/v2/identify"
15
- GATING_INFO_ENDPOINT = "#{SERVER_URL}/v2/gating-info"
16
-
17
- WEBSOCKET_URL = 'wss://ws.airshiphq.com'
18
- WEBSOCKET_GATING_INFO_ENDPOINT = "#{WEBSOCKET_URL}/v2/ws-events"
19
-
20
- CLOUDFRONT_URL = 'https://backup-api.airshiphq.com'
21
- CLOUDFRONT_GATING_INFO_ENDPOINT = "#{CLOUDFRONT_URL}/v2/gating-info"
22
-
23
- class CloudDelegate
24
- attr_accessor :gating_info
25
- attr_accessor :env_key
26
- attr_accessor :request_timeout
27
-
28
- def initialize(env_key, request_timeout)
29
- @env_key = env_key
30
- @request_timeout = request_timeout
31
-
32
- @ingestion_max_items = 500
33
- @ingestion_interval = 30
34
- @ingestion_lock = Mutex.new
35
-
36
- @entities = []
37
- @stats = []
38
- @exposures = []
39
- @flags = Set[]
40
- @ingested_flags = Set[]
41
-
42
- @entity_lru_hashes = LruRedux::Cache.new(500)
43
- @first_ingestion = true
44
-
45
- @should_ingest_entities = true
46
- @should_ingest_stats = true
47
- @should_ingest_exposures = true
48
- @should_ingest_flags = true
49
-
50
- begin
51
- get_gating_info("#{GATING_INFO_ENDPOINT}/#{@env_key}", 'duration__gating_info')
52
- rescue Exception => e
53
- STDERR.puts 'Failed to retrieve initial gating info from API', e.backtrace
54
- end
55
-
56
- begin
57
- get_gating_info("#{CLOUDFRONT_GATING_INFO_ENDPOINT}/#{@env_key}", 'duration__cloudfront_gating_info') unless !@gating_info.nil?
58
- rescue Exception => e
59
- STDERR.puts 'Failed to retrieve initial gating info from CloudFront backup', e.backtrace
60
- end
61
-
62
- # ensure EventMachine is running
63
- Thread.new { EventMachine.run } unless EventMachine.reactor_running?
64
- Thread.pass until EventMachine.reactor_running?
65
-
66
- em_subscribe_updates
67
- em_police_updates
68
- em_ingestion_timer
69
- end
70
-
71
- def get_gating_info(url, stat_name)
72
- stat = FlaggerUtils::Stat.new(stat_name)
73
- stat.start
74
- conn = Faraday.new(url: url)
75
- response = conn.get do |req|
76
- req.options.timeout = @request_timeout
77
- end
78
- if response.status == 200
79
- @gating_info = FlaggerModels::GatingInfo.new(JSON.parse(response.body))
80
- update_sdk_vars(@gating_info)
81
- stat.stop
82
- save_stat(stat)
83
- else
84
- raise "Gating info request failed with HTTP code #{response.code}"
85
- end
86
- end
87
-
88
- def em_subscribe_updates()
89
- @ws_unsubbed = false
90
- @ws = Faye::WebSocket::Client.new("#{WEBSOCKET_GATING_INFO_ENDPOINT}?envkey=#{@env_key}")
91
-
92
- @ws.on :message do |event|
93
- begin
94
- @last_ws_update = Time.now
95
- @gating_info = FlaggerModels::GatingInfo.new(JSON.parse(event.data))
96
- update_sdk_vars(@gating_info)
97
- rescue Exception => e
98
- STDERR.puts 'Failed to update gating information', e
99
- end
100
- end
101
-
102
- @ws.on :close do |event|
103
- STDERR.puts ['Connection closed to Airship update server', event.code, event.reason] unless @ws_unsubbed
104
- EventMachine::Timer.new(5) do
105
- em_subscribe_updates unless @ws_unsubbed
106
- end
107
- end
108
- end
109
-
110
- def unsubscribe_updates()
111
- @ws_unsubbed = true
112
- @ws&.close
113
- end
114
-
115
- def em_police_updates
116
- ping_timer = EventMachine::PeriodicTimer.new(5) do
117
- if @cleaning then
118
- ping_timer.cancel
119
- else
120
- @ws&.ping do
121
- @last_ws_update = Time.now
122
- end
123
- end
124
- end
125
-
126
- police_update_timer = EventMachine::PeriodicTimer.new(30) do
127
- if @cleaning then
128
- police_update_timer.cancel
129
- else
130
- if Time.now - (@last_ws_update || Time.new(0)) > 30
131
- STDERR.puts 'Did not receive a keepalive for more than 30 seconds. Reconnecting.'
132
- @ws&.close
133
- end
134
- end
135
- end
136
-
137
- poll_gating_info_timer = EventMachine::PeriodicTimer.new(60) do
138
- if @cleaning then
139
- poll_gating_info_timer.cancel
140
- else
141
- if Time.now - (@last_ws_update || Time.new(0)) > 60
142
- EM.defer(proc {
143
- STDERR.puts 'Did not receive a keepalive for more than 60 seconds. Polling gating info.'
144
- begin
145
- get_gating_info("#{CLOUDFRONT_GATING_INFO_ENDPOINT}/#{@env_key}", 'duration__cloudfront_gating_info')
146
- STDERR.puts 'Polled gating info from CloudFront'
147
- rescue Exception => e
148
- STDERR.puts 'Failed polling gating info from CloudFront', e.backtrace
149
- end
150
- })
151
- end
152
- end
153
- end
154
- end
155
-
156
- def update_sdk_vars(gating_info)
157
- if gating_info&.sdk_info&.ingestion_max_items then
158
- @ingestion_max_items = gating_info.sdk_info.ingestion_max_items
159
- end
160
- if gating_info&.sdk_info&.ingestion_interval then
161
- @ingestion_interval = gating_info.sdk_info.ingestion_interval
162
- end
163
- if gating_info&.sdk_info&.should_ingest_entities then
164
- @should_ingest_entities = gating_info.sdk_info.should_ingest_entities
165
- end
166
- if gating_info&.sdk_info&.should_ingest_stats then
167
- @should_ingest_stats = gating_info&.sdk_info&.should_ingest_stats
168
- end
169
- if gating_info&.sdk_info&.should_ingest_exposures then
170
- @should_ingest_exposures = gating_info&.sdk_info&.should_ingest_exposures
171
- end
172
- if gating_info&.sdk_info&.should_ingest_flags then
173
- @should_ingest_flags = gating_info&.sdk_info&.should_ingest_flags
174
- end
175
- end
176
-
177
- def maybe_ingest(should_ingest = false)
178
- if !@should_ingest_entities then
179
- @entities = []
180
- end
181
-
182
- if !@should_ingest_stats then
183
- @stats = []
184
- end
185
-
186
- if !@should_ingest_exposures then
187
- @exposures = []
188
- end
189
-
190
- if !@should_ingest_flags then
191
- @flags = Set[]
192
- end
193
-
194
- should_ingest |= (
195
- @entities.length > @ingestion_max_items ||
196
- @stats.length > @ingestion_max_items ||
197
- @exposures.length > @ingestion_max_items ||
198
- @flags.length > 0
199
- )
200
-
201
- if @first_ingestion then
202
- should_ingest |= @entities.length > 0
203
- @first_ingestion = !should_ingest
204
- end
205
-
206
- should_ingest &= (
207
- @entities.length > 0 ||
208
- @stats.length > 0 ||
209
- @exposures.length > 0 ||
210
- @flags.length > 0
211
- )
212
-
213
- if should_ingest then
214
- entities = @entities
215
- stats = @stats
216
- exposures = @exposures
217
- flags = @flags.to_a
218
- @entities = []
219
- @stats = []
220
- @exposures = []
221
- @ingested_flags.merge(@flags)
222
- @flags = Set[]
223
-
224
- EM.defer(
225
- proc {
226
- begin
227
- conn = Faraday.new(url: "#{IDENTIFY_ENDPOINT}/#{@env_key}")
228
- response = conn.post do |req|
229
- req.options.timeout = @request_timeout
230
- req.headers['Content-Type'] = 'application/json'
231
- req.body = JSON.generate(
232
- {
233
- 'sdk_info' => {
234
- 'name' => 'ruby',
235
- 'version' => FlaggerUtils::VERSION
236
- },
237
- 'objects' => entities,
238
- 'stats' => stats,
239
- 'exposures' => exposures,
240
- 'flags' => flags
241
- })
242
- end
243
- if response.status != 200 then
244
- STDERR.puts 'Failed to upload entities', response.status
245
- nil
246
- end
247
- rescue Exception => e
248
- STDERR.puts 'Failed to upload entities', e
249
- nil
250
- end
251
- })
252
- end
253
- end
254
-
255
- def em_ingestion_timer
256
- seconds = 0
257
- timer = EventMachine::PeriodicTimer.new(1) do
258
- timer.cancel unless !@cleaning
259
-
260
- seconds = (seconds + 1) % @ingestion_interval
261
- if seconds == 0 then
262
- @ingestion_lock.synchronize {
263
- maybe_ingest(true)
264
- }
265
- end
266
- end
267
- end
268
-
269
- def cleanup()
270
- @cleaning = true
271
- unsubscribe_updates
272
- @ingestion_lock.synchronize {
273
- maybe_ingest(true)
274
- }
275
- end
276
-
277
- def save_entity(entity)
278
- @ingestion_lock.synchronize {
279
- if @entity_lru_hashes[entity.id] != entity.hash then
280
- @entity_lru_hashes[entity.id] = entity.hash
281
- @entities.push(entity.attrs)
282
- maybe_ingest()
283
- end
284
- }
285
- end
286
-
287
- def save_stat(stat)
288
- @ingestion_lock.synchronize {
289
- @stats.push(stat)
290
- @stats = FlaggerUtils::Stat.compact(@stats)
291
- maybe_ingest()
292
- }
293
- end
294
-
295
- def save_exposure(exposure)
296
- @ingestion_lock.synchronize {
297
- @exposures.push(exposure)
298
- maybe_ingest()
299
- }
300
- end
301
-
302
- def save_flag(flag)
303
- @ingestion_lock.synchronize {
304
- @flags.add(flag)
305
- maybe_ingest()
306
- }
307
- end
308
-
309
- def stringify_keys(hash)
310
- if hash.is_a?(Hash) then
311
- hash.map {|k, v|
312
- [k.to_s, stringify_keys(v)]
313
- }.to_h
314
- else
315
- hash
316
- end
317
- end
318
-
319
- def identify_entity(entity_json)
320
- if !entity_json.is_a?(Hash) then
321
- STDERR.puts 'Entity must be a hash'
322
- nil
323
- else
324
- entity_json = stringify_keys(entity_json)
325
-
326
- entity = if entity_json['is_group'] then
327
- FlaggerModels::GroupEntity.new(entity_json)
328
- else
329
- FlaggerModels::SingleEntity.new(entity_json)
330
- end
331
-
332
- if !entity.valid? then
333
- STDERR.puts "Entity validation errors: #{entity.validation_errors}"
334
- nil
335
- else
336
- save_entity(entity)
337
-
338
- entity
339
- end
340
- end
341
- end
342
-
343
- def treatment(flagger_flag, entity_json)
344
- stat = FlaggerUtils::Stat.new('duration__get_treatment')
345
- stat.start()
346
-
347
- entity = identify_entity(entity_json)
348
- flag = @gating_info&.flag(flagger_flag.flag_name)
349
- if flag.nil? && !@ingested_flags.include?(flagger_flag.flag_name) then
350
- save_flag(flagger_flag.flag_name)
351
- end
352
- return_value = flag&.treatment(entity)
353
-
354
- if !return_value then
355
- return return_value
356
- end
357
-
358
- exposure = {
359
- flag: flagger_flag.flag_name,
360
- type: entity.type,
361
- id: entity.id,
362
- treatment: return_value[:treatment].codename,
363
- method_called: 'get_treatment',
364
- eligible: return_value[:eligible]
365
- }
366
-
367
- return_value = return_value[:treatment].codename
368
-
369
- save_exposure(exposure)
370
-
371
- stat.stop()
372
- save_stat(stat)
373
-
374
- return_value
375
- end
376
-
377
-
378
- def payload(flagger_flag, entity_json)
379
- stat = FlaggerUtils::Stat.new('duration__get_payload')
380
- stat.start()
381
-
382
- entity = identify_entity(entity_json)
383
- flag = @gating_info&.flag(flagger_flag.flag_name)
384
- if flag.nil? && !@ingested_flags.include?(flagger_flag.flag_name) then
385
- save_flag(flagger_flag.flag_name)
386
- end
387
- return_value = flag&.payload(entity)
388
-
389
- if !return_value then
390
- return return_value
391
- end
392
-
393
- exposure = {
394
- flag: flagger_flag.flag_name,
395
- type: entity.type,
396
- id: entity.id,
397
- treatment: return_value[:treatment].codename,
398
- method_called: 'get_payload',
399
- eligible: return_value[:eligible]
400
- }
401
-
402
- return_value = return_value[:treatment].payload
403
-
404
- save_exposure(exposure)
405
-
406
- stat.stop()
407
- save_stat(stat)
408
-
409
- return_value
410
- end
411
-
412
- def eligible?(flagger_flag, entity_json)
413
- stat = FlaggerUtils::Stat.new('duration__is_eligible')
414
- stat.start()
415
-
416
- entity = identify_entity(entity_json)
417
- flag = @gating_info&.flag(flagger_flag.flag_name)
418
- if flag.nil? && !@ingested_flags.include?(flagger_flag.flag_name) then
419
- save_flag(flagger_flag.flag_name)
420
- end
421
- return_value = flag&.eligible?(entity)
422
-
423
- if !return_value then
424
- return return_value
425
- end
426
-
427
- exposure = {
428
- flag: flagger_flag.flag_name,
429
- type: entity.type,
430
- id: entity.id,
431
- treatment: return_value[:treatment].codename,
432
- method_called: 'is_eligible',
433
- eligible: return_value[:eligible]
434
- }
435
-
436
- return_value = return_value[:eligible]
437
-
438
- save_exposure(exposure)
439
-
440
- stat.stop()
441
- save_stat(stat)
442
-
443
- return_value
444
- end
445
-
446
- def enabled?(flagger_flag, entity_json)
447
- stat = FlaggerUtils::Stat.new('duration__is_enabled')
448
- stat.start()
449
-
450
- entity = identify_entity(entity_json)
451
- flag = @gating_info&.flag(flagger_flag.flag_name)
452
- if flag.nil? && !@ingested_flags.include?(flagger_flag.flag_name) then
453
- save_flag(flagger_flag.flag_name)
454
- end
455
- return_value = flag&.enabled?(entity)
456
-
457
- if !return_value then
458
- return return_value
459
- end
460
-
461
- exposure = {
462
- flag: flagger_flag.flag_name,
463
- type: entity.type,
464
- id: entity.id,
465
- treatment: return_value[:treatment].codename,
466
- method_called: 'is_enabled',
467
- eligible: return_value[:eligible]
468
- }
469
-
470
- return_value = !return_value[:treatment].is_off_treatment
471
-
472
- save_exposure(exposure)
473
-
474
- stat.stop()
475
- save_stat(stat)
476
-
477
- return_value
478
- end
479
-
480
- def publish(entities)
481
- if !entities.is_a?(Array) then
482
- STDERR.puts 'The "publish" method takes an array of objects (aka entities).'
483
- return nil
484
- end
485
-
486
- entities.each do |entity|
487
- identify_entity(entity)
488
- end
489
-
490
- @ingestion_lock.synchronize {
491
- maybe_ingest(true)
492
- }
493
- end
494
- end
495
- end