flipper 1.0.0 → 1.1.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.
Files changed (140) 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 +42 -0
  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 +28 -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/poller.rb +1 -1
  77. data/lib/flipper/serializers/gzip.rb +24 -0
  78. data/lib/flipper/serializers/json.rb +19 -0
  79. data/lib/flipper/spec/shared_adapter_specs.rb +29 -11
  80. data/lib/flipper/test/shared_adapter_test.rb +24 -5
  81. data/lib/flipper/typecast.rb +34 -6
  82. data/lib/flipper/types/percentage.rb +1 -1
  83. data/lib/flipper/version.rb +1 -1
  84. data/lib/flipper.rb +38 -1
  85. data/spec/flipper/adapter_builder_spec.rb +73 -0
  86. data/spec/flipper/adapter_spec.rb +1 -0
  87. data/spec/flipper/adapters/http_spec.rb +39 -5
  88. data/spec/flipper/adapters/memoizable_spec.rb +15 -15
  89. data/spec/flipper/adapters/read_only_spec.rb +26 -11
  90. data/spec/flipper/adapters/strict_spec.rb +62 -0
  91. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +27 -0
  92. data/spec/flipper/cloud/configuration_spec.rb +6 -23
  93. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +108 -0
  94. data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
  95. data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
  96. data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
  97. data/spec/flipper/cloud/telemetry_spec.rb +156 -0
  98. data/spec/flipper/cloud_spec.rb +12 -12
  99. data/spec/flipper/configuration_spec.rb +17 -0
  100. data/spec/flipper/dsl_spec.rb +39 -0
  101. data/spec/flipper/engine_spec.rb +108 -7
  102. data/spec/flipper/exporters/json/v1_spec.rb +3 -3
  103. data/spec/flipper/expression/builder_spec.rb +248 -0
  104. data/spec/flipper/expression_spec.rb +188 -0
  105. data/spec/flipper/expressions/all_spec.rb +15 -0
  106. data/spec/flipper/expressions/any_spec.rb +15 -0
  107. data/spec/flipper/expressions/boolean_spec.rb +15 -0
  108. data/spec/flipper/expressions/duration_spec.rb +43 -0
  109. data/spec/flipper/expressions/equal_spec.rb +24 -0
  110. data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
  111. data/spec/flipper/expressions/greater_than_spec.rb +28 -0
  112. data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
  113. data/spec/flipper/expressions/less_than_spec.rb +32 -0
  114. data/spec/flipper/expressions/not_equal_spec.rb +15 -0
  115. data/spec/flipper/expressions/now_spec.rb +11 -0
  116. data/spec/flipper/expressions/number_spec.rb +21 -0
  117. data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
  118. data/spec/flipper/expressions/percentage_spec.rb +15 -0
  119. data/spec/flipper/expressions/property_spec.rb +13 -0
  120. data/spec/flipper/expressions/random_spec.rb +9 -0
  121. data/spec/flipper/expressions/string_spec.rb +11 -0
  122. data/spec/flipper/expressions/time_spec.rb +13 -0
  123. data/spec/flipper/feature_spec.rb +360 -1
  124. data/spec/flipper/gate_values_spec.rb +2 -2
  125. data/spec/flipper/gates/expression_spec.rb +108 -0
  126. data/spec/flipper/identifier_spec.rb +4 -5
  127. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +15 -1
  128. data/spec/flipper/middleware/memoizer_spec.rb +67 -0
  129. data/spec/flipper/serializers/gzip_spec.rb +13 -0
  130. data/spec/flipper/serializers/json_spec.rb +13 -0
  131. data/spec/flipper/typecast_spec.rb +43 -7
  132. data/spec/flipper/types/actor_spec.rb +18 -1
  133. data/spec/flipper_integration_spec.rb +102 -4
  134. data/spec/flipper_spec.rb +89 -1
  135. data/spec/spec_helper.rb +5 -0
  136. data/spec/support/actor_names.yml +1 -0
  137. data/spec/support/fake_backoff_policy.rb +15 -0
  138. data/spec/support/spec_helpers.rb +11 -3
  139. metadata +104 -18
  140. 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