flagger 2.0.9 → 3.0.1

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