flipper 1.0.0 → 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/workflows/ci.yml +7 -3
  4. data/.github/workflows/examples.yml +27 -5
  5. data/Changelog.md +326 -272
  6. data/Gemfile +4 -4
  7. data/README.md +13 -11
  8. data/benchmark/typecast_ips.rb +8 -0
  9. data/docs/images/flipper_cloud.png +0 -0
  10. data/examples/cloud/backoff_policy.rb +13 -0
  11. data/examples/cloud/cloud_setup.rb +16 -0
  12. data/examples/cloud/forked.rb +7 -2
  13. data/examples/cloud/threaded.rb +15 -18
  14. data/examples/expressions.rb +213 -0
  15. data/examples/strict.rb +18 -0
  16. data/flipper.gemspec +1 -2
  17. data/lib/flipper/actor.rb +6 -3
  18. data/lib/flipper/adapter.rb +10 -0
  19. data/lib/flipper/adapter_builder.rb +44 -0
  20. data/lib/flipper/adapters/dual_write.rb +1 -3
  21. data/lib/flipper/adapters/failover.rb +0 -4
  22. data/lib/flipper/adapters/failsafe.rb +0 -4
  23. data/lib/flipper/adapters/http/client.rb +26 -7
  24. data/lib/flipper/adapters/http/error.rb +1 -1
  25. data/lib/flipper/adapters/http.rb +18 -13
  26. data/lib/flipper/adapters/instrumented.rb +0 -4
  27. data/lib/flipper/adapters/memoizable.rb +14 -19
  28. data/lib/flipper/adapters/memory.rb +4 -6
  29. data/lib/flipper/adapters/operation_logger.rb +0 -4
  30. data/lib/flipper/adapters/poll.rb +1 -3
  31. data/lib/flipper/adapters/pstore.rb +17 -11
  32. data/lib/flipper/adapters/read_only.rb +4 -4
  33. data/lib/flipper/adapters/strict.rb +47 -0
  34. data/lib/flipper/adapters/sync/feature_synchronizer.rb +10 -1
  35. data/lib/flipper/adapters/sync.rb +0 -4
  36. data/lib/flipper/cloud/configuration.rb +121 -52
  37. data/lib/flipper/cloud/telemetry/backoff_policy.rb +93 -0
  38. data/lib/flipper/cloud/telemetry/instrumenter.rb +26 -0
  39. data/lib/flipper/cloud/telemetry/metric.rb +39 -0
  40. data/lib/flipper/cloud/telemetry/metric_storage.rb +30 -0
  41. data/lib/flipper/cloud/telemetry/submitter.rb +98 -0
  42. data/lib/flipper/cloud/telemetry.rb +183 -0
  43. data/lib/flipper/configuration.rb +25 -4
  44. data/lib/flipper/dsl.rb +51 -0
  45. data/lib/flipper/engine.rb +27 -3
  46. data/lib/flipper/exporters/json/export.rb +1 -1
  47. data/lib/flipper/exporters/json/v1.rb +1 -1
  48. data/lib/flipper/expression/builder.rb +73 -0
  49. data/lib/flipper/expression/constant.rb +25 -0
  50. data/lib/flipper/expression.rb +71 -0
  51. data/lib/flipper/expressions/all.rb +11 -0
  52. data/lib/flipper/expressions/any.rb +9 -0
  53. data/lib/flipper/expressions/boolean.rb +9 -0
  54. data/lib/flipper/expressions/comparable.rb +13 -0
  55. data/lib/flipper/expressions/duration.rb +28 -0
  56. data/lib/flipper/expressions/equal.rb +9 -0
  57. data/lib/flipper/expressions/greater_than.rb +9 -0
  58. data/lib/flipper/expressions/greater_than_or_equal_to.rb +9 -0
  59. data/lib/flipper/expressions/less_than.rb +9 -0
  60. data/lib/flipper/expressions/less_than_or_equal_to.rb +9 -0
  61. data/lib/flipper/expressions/not_equal.rb +9 -0
  62. data/lib/flipper/expressions/now.rb +9 -0
  63. data/lib/flipper/expressions/number.rb +9 -0
  64. data/lib/flipper/expressions/percentage.rb +9 -0
  65. data/lib/flipper/expressions/percentage_of_actors.rb +12 -0
  66. data/lib/flipper/expressions/property.rb +9 -0
  67. data/lib/flipper/expressions/random.rb +9 -0
  68. data/lib/flipper/expressions/string.rb +9 -0
  69. data/lib/flipper/expressions/time.rb +9 -0
  70. data/lib/flipper/feature.rb +55 -0
  71. data/lib/flipper/gate.rb +1 -0
  72. data/lib/flipper/gate_values.rb +5 -2
  73. data/lib/flipper/gates/expression.rb +75 -0
  74. data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
  75. data/lib/flipper/middleware/memoizer.rb +29 -13
  76. data/lib/flipper/model/active_record.rb +23 -0
  77. data/lib/flipper/poller.rb +1 -1
  78. data/lib/flipper/serializers/gzip.rb +24 -0
  79. data/lib/flipper/serializers/json.rb +19 -0
  80. data/lib/flipper/spec/shared_adapter_specs.rb +29 -11
  81. data/lib/flipper/test/shared_adapter_test.rb +24 -5
  82. data/lib/flipper/typecast.rb +34 -6
  83. data/lib/flipper/types/percentage.rb +1 -1
  84. data/lib/flipper/version.rb +1 -1
  85. data/lib/flipper.rb +38 -1
  86. data/spec/flipper/adapter_builder_spec.rb +73 -0
  87. data/spec/flipper/adapter_spec.rb +1 -0
  88. data/spec/flipper/adapters/http_spec.rb +39 -5
  89. data/spec/flipper/adapters/memoizable_spec.rb +15 -15
  90. data/spec/flipper/adapters/read_only_spec.rb +26 -11
  91. data/spec/flipper/adapters/strict_spec.rb +62 -0
  92. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +27 -0
  93. data/spec/flipper/cloud/configuration_spec.rb +6 -23
  94. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +108 -0
  95. data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
  96. data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
  97. data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
  98. data/spec/flipper/cloud/telemetry_spec.rb +156 -0
  99. data/spec/flipper/cloud_spec.rb +12 -12
  100. data/spec/flipper/configuration_spec.rb +17 -0
  101. data/spec/flipper/dsl_spec.rb +39 -0
  102. data/spec/flipper/engine_spec.rb +108 -7
  103. data/spec/flipper/exporters/json/v1_spec.rb +3 -3
  104. data/spec/flipper/expression/builder_spec.rb +248 -0
  105. data/spec/flipper/expression_spec.rb +188 -0
  106. data/spec/flipper/expressions/all_spec.rb +15 -0
  107. data/spec/flipper/expressions/any_spec.rb +15 -0
  108. data/spec/flipper/expressions/boolean_spec.rb +15 -0
  109. data/spec/flipper/expressions/duration_spec.rb +43 -0
  110. data/spec/flipper/expressions/equal_spec.rb +24 -0
  111. data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
  112. data/spec/flipper/expressions/greater_than_spec.rb +28 -0
  113. data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
  114. data/spec/flipper/expressions/less_than_spec.rb +32 -0
  115. data/spec/flipper/expressions/not_equal_spec.rb +15 -0
  116. data/spec/flipper/expressions/now_spec.rb +11 -0
  117. data/spec/flipper/expressions/number_spec.rb +21 -0
  118. data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
  119. data/spec/flipper/expressions/percentage_spec.rb +15 -0
  120. data/spec/flipper/expressions/property_spec.rb +13 -0
  121. data/spec/flipper/expressions/random_spec.rb +9 -0
  122. data/spec/flipper/expressions/string_spec.rb +11 -0
  123. data/spec/flipper/expressions/time_spec.rb +13 -0
  124. data/spec/flipper/feature_spec.rb +360 -1
  125. data/spec/flipper/gate_values_spec.rb +2 -2
  126. data/spec/flipper/gates/expression_spec.rb +108 -0
  127. data/spec/flipper/identifier_spec.rb +4 -5
  128. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +15 -1
  129. data/spec/flipper/middleware/memoizer_spec.rb +67 -0
  130. data/spec/flipper/model/active_record_spec.rb +61 -0
  131. data/spec/flipper/serializers/gzip_spec.rb +13 -0
  132. data/spec/flipper/serializers/json_spec.rb +13 -0
  133. data/spec/flipper/typecast_spec.rb +43 -7
  134. data/spec/flipper/types/actor_spec.rb +18 -1
  135. data/spec/flipper_integration_spec.rb +102 -4
  136. data/spec/flipper_spec.rb +89 -1
  137. data/spec/spec_helper.rb +5 -0
  138. data/spec/support/actor_names.yml +1 -0
  139. data/spec/support/fake_backoff_policy.rb +15 -0
  140. data/spec/support/spec_helpers.rb +11 -3
  141. metadata +107 -18
  142. data/lib/flipper/cloud/instrumenter.rb +0 -48
@@ -1,3 +1,4 @@
1
+ require "logger"
1
2
  require "socket"
2
3
  require "flipper/adapters/http"
3
4
  require "flipper/adapters/poll"
@@ -5,8 +6,9 @@ require "flipper/poller"
5
6
  require "flipper/adapters/memory"
6
7
  require "flipper/adapters/dual_write"
7
8
  require "flipper/adapters/sync/synchronizer"
8
- require "flipper/cloud/instrumenter"
9
- require "brow"
9
+ require "flipper/cloud/telemetry"
10
+ require "flipper/cloud/telemetry/instrumenter"
11
+ require "flipper/cloud/telemetry/submitter"
10
12
 
11
13
  module Flipper
12
14
  module Cloud
@@ -19,18 +21,13 @@ module Flipper
19
21
 
20
22
  DEFAULT_URL = "https://www.flippercloud.io/adapter".freeze
21
23
 
22
- # Private: Keeps track of brow instances so they can be shared across
23
- # threads.
24
- def self.brow_instances
25
- @brow_instances ||= Concurrent::Map.new
26
- end
27
-
28
24
  # Public: The token corresponding to an environment on flippercloud.io.
29
25
  attr_accessor :token
30
26
 
31
27
  # Public: The url for http adapter. Really should only be customized for
32
- # development work. Feel free to forget you ever saw this.
33
- attr_reader :url
28
+ # development work if you are me and you are not me. Feel free to
29
+ # forget you ever saw this.
30
+ attr_accessor :url
34
31
 
35
32
  # Public: net/http read timeout for all http requests (default: 5).
36
33
  attr_accessor :read_timeout
@@ -73,32 +70,25 @@ module Flipper
73
70
  # occur or not.
74
71
  attr_accessor :sync_secret
75
72
 
76
- def initialize(options = {})
77
- @token = options.fetch(:token) { ENV["FLIPPER_CLOUD_TOKEN"] }
73
+ # Public: The logger to use for debugging inner workings.
74
+ attr_accessor :logger
78
75
 
79
- if @token.nil?
80
- raise ArgumentError, "Flipper::Cloud token is missing. Please set FLIPPER_CLOUD_TOKEN or provide the token (e.g. Flipper::Cloud.new(token: 'token'))."
81
- end
76
+ # Public: Should the logger log or not (default: true).
77
+ attr_accessor :logging_enabled
82
78
 
83
- @read_timeout = options.fetch(:read_timeout) { ENV.fetch("FLIPPER_CLOUD_READ_TIMEOUT", 5).to_f }
84
- @open_timeout = options.fetch(:open_timeout) { ENV.fetch("FLIPPER_CLOUD_OPEN_TIMEOUT", 5).to_f }
85
- @write_timeout = options.fetch(:write_timeout) { ENV.fetch("FLIPPER_CLOUD_WRITE_TIMEOUT", 5).to_f }
86
- @sync_interval = options.fetch(:sync_interval) { ENV.fetch("FLIPPER_CLOUD_SYNC_INTERVAL", 10).to_f }
87
- @sync_secret = options.fetch(:sync_secret) { ENV["FLIPPER_CLOUD_SYNC_SECRET"] }
88
- @local_adapter = options.fetch(:local_adapter) { Adapters::Memory.new }
89
- @debug_output = options[:debug_output]
90
- @adapter_block = ->(adapter) { adapter }
91
- self.url = options.fetch(:url) { ENV.fetch("FLIPPER_CLOUD_URL", DEFAULT_URL) }
79
+ # Public: The telemetry instance to use for tracking feature usage.
80
+ attr_accessor :telemetry
92
81
 
93
- instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
82
+ # Public: Should telemetry be enabled or not (default: false).
83
+ attr_accessor :telemetry_enabled
94
84
 
95
- # This is alpha. Don't use this unless you are me. And you are not me.
96
- cloud_instrument = options.fetch(:cloud_instrument) { ENV["FLIPPER_CLOUD_INSTRUMENT"] == "1" }
97
- @instrumenter = if cloud_instrument
98
- Instrumenter.new(brow: brow, instrumenter: instrumenter)
99
- else
100
- instrumenter
101
- end
85
+ def initialize(options = {})
86
+ setup_auth options
87
+ setup_log options
88
+ setup_http options
89
+ setup_sync options
90
+ setup_adapter options
91
+ setup_telemetry options
102
92
  end
103
93
 
104
94
  # Public: Read or customize the http adapter. Calling without a block will
@@ -120,38 +110,31 @@ module Flipper
120
110
  end
121
111
  end
122
112
 
123
- # Public: Set url for the http adapter.
124
- attr_writer :url
125
-
113
+ # Public: Force a sync.
126
114
  def sync
127
115
  Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
128
116
  instrumenter: instrumenter,
129
117
  }).call
130
118
  end
131
119
 
132
- def brow
133
- self.class.brow_instances.compute_if_absent(url + token) do
134
- uri = URI.parse(url)
135
- uri.path = "#{uri.path}/events".squeeze("/")
136
-
137
- Brow::Client.new({
138
- url: uri.to_s,
139
- headers: {
140
- "Accept" => "application/json",
141
- "Content-Type" => "application/json",
142
- "User-Agent" => "Flipper v#{VERSION} via Brow v#{Brow::VERSION}",
143
- "Flipper-Cloud-Token" => @token,
144
- }
145
- })
146
- end
147
- end
148
-
149
120
  # Public: The method that will be used to synchronize local adapter with
150
121
  # cloud. (default: :poll, will be :webhook if sync_secret is set).
151
122
  def sync_method
152
123
  sync_secret ? :webhook : :poll
153
124
  end
154
125
 
126
+ # Internal: The http client used by the http adapter. Exposed so we can
127
+ # use the same client for posting telemetry.
128
+ def http_client
129
+ http_adapter.client
130
+ end
131
+
132
+ # Internal: Logs message if logging is enabled.
133
+ def log(message, level: :debug)
134
+ return unless logging_enabled
135
+ logger.send(level, "name=flipper_cloud #{message}")
136
+ end
137
+
155
138
  private
156
139
 
157
140
  def app_adapter
@@ -184,6 +167,92 @@ module Flipper
184
167
  },
185
168
  })
186
169
  end
170
+
171
+ def setup_auth(options)
172
+ set_option :token, options, required: true
173
+ end
174
+
175
+ def setup_log(options)
176
+ set_option :logging_enabled, options, default: true, typecast: :boolean
177
+ set_option :logger, options, from_env: false, default: -> {
178
+ if logging_enabled
179
+ Logger.new(STDOUT)
180
+ else
181
+ Logger.new("/dev/null")
182
+ end
183
+ }
184
+ end
185
+
186
+ def setup_http(options)
187
+ set_option :url, options, default: DEFAULT_URL
188
+ set_option :debug_output, options, from_env: false
189
+ set_option :read_timeout, options, default: 5, typecast: :float, minimum: 0.1
190
+ set_option :open_timeout, options, default: 2, typecast: :float, minimum: 0.1
191
+ set_option :write_timeout, options, default: 5, typecast: :float, minimum: 0.1
192
+ end
193
+
194
+ def setup_sync(options)
195
+ set_option :sync_interval, options, default: 10, typecast: :float, minimum: 10
196
+ set_option :sync_secret, options
197
+ end
198
+
199
+ def setup_adapter(options)
200
+ set_option :local_adapter, options, default: -> { Adapters::Memory.new }, from_env: false
201
+ @adapter_block = ->(adapter) { adapter }
202
+ end
203
+
204
+ def setup_telemetry(options)
205
+ # Needs to be after url and token assignments because they are used for
206
+ # uniqueness in Telemetry.instance_for.
207
+ set_option :telemetry, options, from_env: false, default: -> {
208
+ Telemetry.instance_for(self)
209
+ }
210
+
211
+ # This is alpha. Don't use this unless you are me. And you are not me.
212
+ set_option :telemetry_enabled, options, default: false, typecast: :boolean
213
+ instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
214
+ @instrumenter = if telemetry_enabled
215
+ Telemetry::Instrumenter.new(self, instrumenter)
216
+ else
217
+ instrumenter
218
+ end
219
+ end
220
+
221
+ # Internal: Super helper for defining an option that can be set via
222
+ # options hash or ENV with defaults, typecasting and minimums.
223
+ def set_option(name, options, default: nil, typecast: nil, minimum: nil, from_env: true, required: false)
224
+ env_var = "FLIPPER_CLOUD_#{name.to_s.upcase}"
225
+ value = options.fetch(name) {
226
+ default_value = default.respond_to?(:call) ? default.call : default
227
+ if from_env
228
+ ENV.fetch(env_var, default_value)
229
+ else
230
+ default_value
231
+ end
232
+ }
233
+ value = Flipper::Typecast.send("to_#{typecast}", value) if typecast
234
+ send("#{name}=", value)
235
+ enforce_minimum(name, minimum) if minimum
236
+
237
+ if required
238
+ option_value = send(name)
239
+ if option_value.nil? || option_value.empty?
240
+ message = "Flipper::Cloud #{name} is missing. Please "
241
+ message << "set #{env_var} or " if from_env
242
+ message << "provide #{name} (e.g. Flipper::Cloud.new(#{name}: value))."
243
+ raise ArgumentError, message
244
+ end
245
+ end
246
+ end
247
+
248
+ # Enforce minimum interval for tasks that run on a timer.
249
+ def enforce_minimum(name, minimum)
250
+ provided = send(name)
251
+ if provided && provided < minimum
252
+ warn "Flipper::Cloud##{name} must be at least #{minimum} seconds but was #{provided}. Using #{minimum} seconds."
253
+ send(:instance_variable_set, "@#{name}", minimum)
254
+ end
255
+ end
187
256
  end
188
257
  end
189
258
  end
@@ -0,0 +1,93 @@
1
+ module Flipper
2
+ module Cloud
3
+ class Telemetry
4
+ class BackoffPolicy
5
+ # Private: The default minimum timeout between intervals in milliseconds.
6
+ MIN_TIMEOUT_MS = 1_000
7
+
8
+ # Private: The default maximum timeout between intervals in milliseconds.
9
+ MAX_TIMEOUT_MS = 30_000
10
+
11
+ # Private: The value to multiply the current interval with for each
12
+ # retry attempt.
13
+ MULTIPLIER = 1.5
14
+
15
+ # Private: The randomization factor to use to create a range around the
16
+ # retry interval.
17
+ RANDOMIZATION_FACTOR = 0.5
18
+
19
+ # Private
20
+ attr_reader :min_timeout_ms, :max_timeout_ms, :multiplier, :randomization_factor
21
+
22
+ # Private
23
+ attr_reader :attempts
24
+
25
+ # Public: Create new instance of backoff policy.
26
+ #
27
+ # options - The Hash of options.
28
+ # :min_timeout_ms - The minimum backoff timeout.
29
+ # :max_timeout_ms - The maximum backoff timeout.
30
+ # :multiplier - The value to multiply the current interval with for each
31
+ # retry attempt.
32
+ # :randomization_factor - The randomization factor to use to create a range
33
+ # around the retry interval.
34
+ def initialize(options = {})
35
+ @min_timeout_ms = options.fetch(:min_timeout_ms) {
36
+ ENV.fetch("FLIPPER_BACKOFF_MIN_TIMEOUT_MS", MIN_TIMEOUT_MS).to_i
37
+ }
38
+ @max_timeout_ms = options.fetch(:max_timeout_ms) {
39
+ ENV.fetch("FLIPPER_BACKOFF_MAX_TIMEOUT_MS", MAX_TIMEOUT_MS).to_i
40
+ }
41
+ @multiplier = options.fetch(:multiplier) {
42
+ ENV.fetch("FLIPPER_BACKOFF_MULTIPLIER", MULTIPLIER).to_f
43
+ }
44
+ @randomization_factor = options.fetch(:randomization_factor) {
45
+ ENV.fetch("FLIPPER_BACKOFF_RANDOMIZATION_FACTOR", RANDOMIZATION_FACTOR).to_f
46
+ }
47
+
48
+ unless @min_timeout_ms >= 0
49
+ raise ArgumentError, ":min_timeout_ms must be >= 0 but was #{@min_timeout_ms.inspect}"
50
+ end
51
+
52
+ unless @max_timeout_ms >= 0
53
+ raise ArgumentError, ":max_timeout_ms must be >= 0 but was #{@max_timeout_ms.inspect}"
54
+ end
55
+
56
+ unless @min_timeout_ms <= max_timeout_ms
57
+ raise ArgumentError, ":min_timeout_ms (#{@min_timeout_ms.inspect}) must be <= :max_timeout_ms (#{@max_timeout_ms.inspect})"
58
+ end
59
+
60
+ @attempts = 0
61
+ end
62
+
63
+ # Public: Returns the next backoff interval in milliseconds.
64
+ def next_interval
65
+ interval = @min_timeout_ms * (@multiplier**@attempts)
66
+ interval = add_jitter(interval, @randomization_factor)
67
+
68
+ @attempts += 1
69
+
70
+ [interval, @max_timeout_ms].min
71
+ end
72
+
73
+ def reset
74
+ @attempts = 0
75
+ end
76
+
77
+ private
78
+
79
+ def add_jitter(base, randomization_factor)
80
+ random_number = rand
81
+ max_deviation = base * randomization_factor
82
+ deviation = random_number * max_deviation
83
+
84
+ if random_number < 0.5
85
+ base - deviation
86
+ else
87
+ base + deviation
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,26 @@
1
+ require "delegate"
2
+
3
+ module Flipper
4
+ module Cloud
5
+ class Telemetry
6
+ class Instrumenter < SimpleDelegator
7
+ def initialize(cloud_configuration, instrumenter)
8
+ super instrumenter
9
+ @cloud_configuration = cloud_configuration
10
+ end
11
+
12
+ def instrument(name, payload = {}, &block)
13
+ return_value = instrumenter.instrument(name, payload, &block)
14
+ @cloud_configuration.telemetry.record(name, payload)
15
+ return_value
16
+ end
17
+
18
+ private
19
+
20
+ def instrumenter
21
+ __getobj__
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,39 @@
1
+ module Flipper
2
+ module Cloud
3
+ class Telemetry
4
+ class Metric
5
+ attr_reader :key, :time, :result
6
+
7
+ def initialize(key, result, time = Time.now)
8
+ @key = key
9
+ @result = result
10
+ @time = time.to_i / 60 * 60
11
+ end
12
+
13
+ def as_json(options = {})
14
+ data = {
15
+ "key" => key.to_s,
16
+ "time" => time,
17
+ "result" => result,
18
+ }
19
+
20
+ if options[:with]
21
+ data.merge!(options[:with])
22
+ end
23
+
24
+ data
25
+ end
26
+
27
+ def eql?(other)
28
+ self.class.eql?(other.class) &&
29
+ @key == other.key && @time == other.time && @result == other.result
30
+ end
31
+ alias :== :eql?
32
+
33
+ def hash
34
+ [self.class, @key, @time, @result].hash
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ require 'concurrent/map'
2
+ require 'concurrent/atomic/atomic_fixnum'
3
+
4
+ module Flipper
5
+ module Cloud
6
+ class Telemetry
7
+ class MetricStorage
8
+ def initialize
9
+ @storage = Concurrent::Map.new { |h, k| h[k] = Concurrent::AtomicFixnum.new(0) }
10
+ end
11
+
12
+ def increment(metric)
13
+ @storage[metric].increment
14
+ end
15
+
16
+ def drain
17
+ metrics = {}
18
+ @storage.keys.each do |metric|
19
+ metrics[metric] = @storage.delete(metric).value
20
+ end
21
+ metrics.freeze
22
+ end
23
+
24
+ def empty?
25
+ @storage.empty?
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,98 @@
1
+ require "securerandom"
2
+ require "flipper/typecast"
3
+ require "flipper/cloud/telemetry/backoff_policy"
4
+
5
+ module Flipper
6
+ module Cloud
7
+ class Telemetry
8
+ class Submitter
9
+ PATH = "/telemetry".freeze
10
+ SCHEMA_VERSION = "V1".freeze
11
+ GZIP_ENCODING = "gzip".freeze
12
+
13
+ Error = Class.new(StandardError) do
14
+ attr_reader :request_id, :response
15
+
16
+ def initialize(request_id, response)
17
+ @request_id = request_id
18
+ @response = response
19
+ super "Unexpected response code=#{response.code} request_id=#{request_id}"
20
+ end
21
+ end
22
+
23
+ attr_reader :cloud_configuration, :request_id, :backoff_policy
24
+
25
+ def initialize(cloud_configuration, backoff_policy: nil)
26
+ @cloud_configuration = cloud_configuration
27
+ @backoff_policy = backoff_policy || BackoffPolicy.new
28
+ @request_id = SecureRandom.uuid
29
+ end
30
+
31
+ # Returns Array of [response, error]. response and error could be nil
32
+ # but usually one or the other will be present.
33
+ def call(drained)
34
+ return if drained.empty?
35
+ body = to_body(drained)
36
+ return if body.nil? || body.empty?
37
+ retry_with_backoff(10) { submit(body) }
38
+ end
39
+
40
+ private
41
+
42
+ def to_body(drained)
43
+ enabled_metrics = drained.map { |metric, value|
44
+ metric.as_json(with: {"value" => value})
45
+ }
46
+
47
+ json = Typecast.to_json({
48
+ request_id: request_id,
49
+ enabled_metrics: enabled_metrics,
50
+ })
51
+
52
+ Typecast.to_gzip(json)
53
+ rescue => exception
54
+ @cloud_configuration.log "action=to_body request_id=#{request_id} error=#{exception.inspect}", level: :error
55
+ end
56
+
57
+ def retry_with_backoff(attempts, &block)
58
+ result, caught_exception = nil
59
+ should_retry = false
60
+ attempts_remaining = attempts - 1
61
+
62
+ begin
63
+ result, should_retry = yield
64
+ return [result, nil] unless should_retry
65
+ rescue => error
66
+ @cloud_configuration.log "action=post_to_cloud attempts_remaining=#{attempts_remaining} error=#{error.inspect}", level: :error
67
+ should_retry = true
68
+ caught_exception = error
69
+ end
70
+
71
+ if should_retry && attempts_remaining > 0
72
+ sleep @backoff_policy.next_interval.to_f / 1000
73
+ retry_with_backoff attempts_remaining, &block
74
+ else
75
+ [result, caught_exception]
76
+ end
77
+ end
78
+
79
+ def submit(body)
80
+ client = @cloud_configuration.http_client
81
+ client.add_header :schema_version, SCHEMA_VERSION
82
+ client.add_header :content_encoding, GZIP_ENCODING
83
+
84
+ response = client.post PATH, body
85
+ code = response.code.to_i
86
+
87
+ # Raise error and retry for retriable status codes.
88
+ # FIXME: what about redirects?
89
+ if code < 200 || code == 429 || code >= 500
90
+ raise Error.new(request_id, response)
91
+ end
92
+
93
+ response
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end