flipper 0.26.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 (199) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/workflows/ci.yml +19 -13
  4. data/.github/workflows/examples.yml +32 -15
  5. data/Changelog.md +294 -154
  6. data/Gemfile +15 -10
  7. data/README.md +13 -11
  8. data/benchmark/enabled_ips.rb +10 -0
  9. data/benchmark/enabled_multiple_actors_ips.rb +20 -0
  10. data/benchmark/enabled_profile.rb +20 -0
  11. data/benchmark/instrumentation_ips.rb +21 -0
  12. data/benchmark/typecast_ips.rb +27 -0
  13. data/docs/images/flipper_cloud.png +0 -0
  14. data/examples/api/basic.ru +3 -4
  15. data/examples/api/custom_memoized.ru +3 -4
  16. data/examples/api/memoized.ru +3 -4
  17. data/examples/cloud/app.ru +12 -0
  18. data/examples/cloud/backoff_policy.rb +13 -0
  19. data/examples/cloud/basic.rb +22 -0
  20. data/examples/cloud/cloud_setup.rb +20 -0
  21. data/examples/cloud/forked.rb +36 -0
  22. data/examples/cloud/import.rb +17 -0
  23. data/examples/cloud/threaded.rb +33 -0
  24. data/examples/dsl.rb +1 -15
  25. data/examples/enabled_for_actor.rb +4 -2
  26. data/examples/expressions.rb +213 -0
  27. data/examples/mirroring.rb +59 -0
  28. data/examples/strict.rb +18 -0
  29. data/flipper-cloud.gemspec +19 -0
  30. data/flipper.gemspec +3 -5
  31. data/lib/flipper/actor.rb +6 -3
  32. data/lib/flipper/adapter.rb +33 -7
  33. data/lib/flipper/adapter_builder.rb +44 -0
  34. data/lib/flipper/adapters/dual_write.rb +1 -3
  35. data/lib/flipper/adapters/failover.rb +0 -4
  36. data/lib/flipper/adapters/failsafe.rb +0 -4
  37. data/lib/flipper/adapters/http/client.rb +26 -7
  38. data/lib/flipper/adapters/http/error.rb +1 -1
  39. data/lib/flipper/adapters/http.rb +29 -16
  40. data/lib/flipper/adapters/instrumented.rb +25 -6
  41. data/lib/flipper/adapters/memoizable.rb +33 -21
  42. data/lib/flipper/adapters/memory.rb +81 -46
  43. data/lib/flipper/adapters/operation_logger.rb +16 -7
  44. data/lib/flipper/adapters/poll/poller.rb +2 -125
  45. data/lib/flipper/adapters/poll.rb +5 -3
  46. data/lib/flipper/adapters/pstore.rb +17 -11
  47. data/lib/flipper/adapters/read_only.rb +4 -4
  48. data/lib/flipper/adapters/strict.rb +47 -0
  49. data/lib/flipper/adapters/sync/feature_synchronizer.rb +10 -1
  50. data/lib/flipper/adapters/sync.rb +0 -4
  51. data/lib/flipper/cloud/configuration.rb +258 -0
  52. data/lib/flipper/cloud/dsl.rb +27 -0
  53. data/lib/flipper/cloud/message_verifier.rb +95 -0
  54. data/lib/flipper/cloud/middleware.rb +63 -0
  55. data/lib/flipper/cloud/routes.rb +14 -0
  56. data/lib/flipper/cloud/telemetry/backoff_policy.rb +93 -0
  57. data/lib/flipper/cloud/telemetry/instrumenter.rb +26 -0
  58. data/lib/flipper/cloud/telemetry/metric.rb +39 -0
  59. data/lib/flipper/cloud/telemetry/metric_storage.rb +30 -0
  60. data/lib/flipper/cloud/telemetry/submitter.rb +98 -0
  61. data/lib/flipper/cloud/telemetry.rb +183 -0
  62. data/lib/flipper/cloud.rb +53 -0
  63. data/lib/flipper/configuration.rb +25 -4
  64. data/lib/flipper/dsl.rb +46 -45
  65. data/lib/flipper/engine.rb +88 -0
  66. data/lib/flipper/errors.rb +3 -3
  67. data/lib/flipper/export.rb +26 -0
  68. data/lib/flipper/exporter.rb +17 -0
  69. data/lib/flipper/exporters/json/export.rb +32 -0
  70. data/lib/flipper/exporters/json/v1.rb +33 -0
  71. data/lib/flipper/expression/builder.rb +73 -0
  72. data/lib/flipper/expression/constant.rb +25 -0
  73. data/lib/flipper/expression.rb +71 -0
  74. data/lib/flipper/expressions/all.rb +11 -0
  75. data/lib/flipper/expressions/any.rb +9 -0
  76. data/lib/flipper/expressions/boolean.rb +9 -0
  77. data/lib/flipper/expressions/comparable.rb +13 -0
  78. data/lib/flipper/expressions/duration.rb +28 -0
  79. data/lib/flipper/expressions/equal.rb +9 -0
  80. data/lib/flipper/expressions/greater_than.rb +9 -0
  81. data/lib/flipper/expressions/greater_than_or_equal_to.rb +9 -0
  82. data/lib/flipper/expressions/less_than.rb +9 -0
  83. data/lib/flipper/expressions/less_than_or_equal_to.rb +9 -0
  84. data/lib/flipper/expressions/not_equal.rb +9 -0
  85. data/lib/flipper/expressions/now.rb +9 -0
  86. data/lib/flipper/expressions/number.rb +9 -0
  87. data/lib/flipper/expressions/percentage.rb +9 -0
  88. data/lib/flipper/expressions/percentage_of_actors.rb +12 -0
  89. data/lib/flipper/expressions/property.rb +9 -0
  90. data/lib/flipper/expressions/random.rb +9 -0
  91. data/lib/flipper/expressions/string.rb +9 -0
  92. data/lib/flipper/expressions/time.rb +9 -0
  93. data/lib/flipper/feature.rb +87 -26
  94. data/lib/flipper/feature_check_context.rb +10 -6
  95. data/lib/flipper/gate.rb +13 -11
  96. data/lib/flipper/gate_values.rb +5 -18
  97. data/lib/flipper/gates/actor.rb +10 -17
  98. data/lib/flipper/gates/boolean.rb +1 -1
  99. data/lib/flipper/gates/expression.rb +75 -0
  100. data/lib/flipper/gates/group.rb +5 -7
  101. data/lib/flipper/gates/percentage_of_actors.rb +10 -13
  102. data/lib/flipper/gates/percentage_of_time.rb +1 -2
  103. data/lib/flipper/identifier.rb +2 -2
  104. data/lib/flipper/instrumentation/log_subscriber.rb +24 -5
  105. data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
  106. data/lib/flipper/instrumentation/subscriber.rb +8 -1
  107. data/lib/flipper/metadata.rb +5 -1
  108. data/lib/flipper/middleware/memoizer.rb +30 -14
  109. data/lib/flipper/poller.rb +117 -0
  110. data/lib/flipper/serializers/gzip.rb +24 -0
  111. data/lib/flipper/serializers/json.rb +19 -0
  112. data/lib/flipper/spec/shared_adapter_specs.rb +95 -54
  113. data/lib/flipper/test/shared_adapter_test.rb +91 -48
  114. data/lib/flipper/typecast.rb +56 -15
  115. data/lib/flipper/types/actor.rb +13 -13
  116. data/lib/flipper/types/group.rb +4 -4
  117. data/lib/flipper/types/percentage.rb +1 -1
  118. data/lib/flipper/version.rb +1 -1
  119. data/lib/flipper.rb +47 -10
  120. data/spec/fixtures/flipper_pstore_1679087600.json +46 -0
  121. data/spec/flipper/adapter_builder_spec.rb +73 -0
  122. data/spec/flipper/adapter_spec.rb +30 -2
  123. data/spec/flipper/adapters/dual_write_spec.rb +2 -2
  124. data/spec/flipper/adapters/http_spec.rb +64 -8
  125. data/spec/flipper/adapters/instrumented_spec.rb +29 -11
  126. data/spec/flipper/adapters/memoizable_spec.rb +51 -31
  127. data/spec/flipper/adapters/memory_spec.rb +14 -3
  128. data/spec/flipper/adapters/operation_logger_spec.rb +31 -12
  129. data/spec/flipper/adapters/read_only_spec.rb +32 -17
  130. data/spec/flipper/adapters/strict_spec.rb +62 -0
  131. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +27 -0
  132. data/spec/flipper/cloud/configuration_spec.rb +252 -0
  133. data/spec/flipper/cloud/dsl_spec.rb +82 -0
  134. data/spec/flipper/cloud/message_verifier_spec.rb +104 -0
  135. data/spec/flipper/cloud/middleware_spec.rb +289 -0
  136. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +108 -0
  137. data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
  138. data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
  139. data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
  140. data/spec/flipper/cloud/telemetry_spec.rb +156 -0
  141. data/spec/flipper/cloud_spec.rb +180 -0
  142. data/spec/flipper/configuration_spec.rb +17 -0
  143. data/spec/flipper/dsl_spec.rb +54 -73
  144. data/spec/flipper/engine_spec.rb +291 -0
  145. data/spec/flipper/export_spec.rb +13 -0
  146. data/spec/flipper/exporter_spec.rb +16 -0
  147. data/spec/flipper/exporters/json/export_spec.rb +60 -0
  148. data/spec/flipper/exporters/json/v1_spec.rb +33 -0
  149. data/spec/flipper/expression/builder_spec.rb +248 -0
  150. data/spec/flipper/expression_spec.rb +188 -0
  151. data/spec/flipper/expressions/all_spec.rb +15 -0
  152. data/spec/flipper/expressions/any_spec.rb +15 -0
  153. data/spec/flipper/expressions/boolean_spec.rb +15 -0
  154. data/spec/flipper/expressions/duration_spec.rb +43 -0
  155. data/spec/flipper/expressions/equal_spec.rb +24 -0
  156. data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
  157. data/spec/flipper/expressions/greater_than_spec.rb +28 -0
  158. data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
  159. data/spec/flipper/expressions/less_than_spec.rb +32 -0
  160. data/spec/flipper/expressions/not_equal_spec.rb +15 -0
  161. data/spec/flipper/expressions/now_spec.rb +11 -0
  162. data/spec/flipper/expressions/number_spec.rb +21 -0
  163. data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
  164. data/spec/flipper/expressions/percentage_spec.rb +15 -0
  165. data/spec/flipper/expressions/property_spec.rb +13 -0
  166. data/spec/flipper/expressions/random_spec.rb +9 -0
  167. data/spec/flipper/expressions/string_spec.rb +11 -0
  168. data/spec/flipper/expressions/time_spec.rb +13 -0
  169. data/spec/flipper/feature_check_context_spec.rb +17 -17
  170. data/spec/flipper/feature_spec.rb +436 -33
  171. data/spec/flipper/gate_values_spec.rb +2 -33
  172. data/spec/flipper/gates/boolean_spec.rb +1 -1
  173. data/spec/flipper/gates/expression_spec.rb +108 -0
  174. data/spec/flipper/gates/group_spec.rb +2 -3
  175. data/spec/flipper/gates/percentage_of_actors_spec.rb +61 -5
  176. data/spec/flipper/gates/percentage_of_time_spec.rb +2 -2
  177. data/spec/flipper/identifier_spec.rb +4 -5
  178. data/spec/flipper/instrumentation/log_subscriber_spec.rb +15 -5
  179. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +25 -1
  180. data/spec/flipper/middleware/memoizer_spec.rb +67 -0
  181. data/spec/flipper/poller_spec.rb +47 -0
  182. data/spec/flipper/serializers/gzip_spec.rb +13 -0
  183. data/spec/flipper/serializers/json_spec.rb +13 -0
  184. data/spec/flipper/typecast_spec.rb +121 -6
  185. data/spec/flipper/types/actor_spec.rb +63 -46
  186. data/spec/flipper/types/group_spec.rb +2 -2
  187. data/spec/flipper_integration_spec.rb +168 -58
  188. data/spec/flipper_spec.rb +92 -28
  189. data/spec/spec_helper.rb +6 -13
  190. data/spec/support/actor_names.yml +1 -0
  191. data/spec/support/climate_control.rb +7 -0
  192. data/spec/support/fake_backoff_policy.rb +15 -0
  193. data/spec/support/skippable.rb +18 -0
  194. data/spec/support/spec_helpers.rb +11 -3
  195. metadata +166 -13
  196. data/.github/workflows/release.yml +0 -44
  197. data/.tool-versions +0 -1
  198. data/lib/flipper/railtie.rb +0 -47
  199. data/spec/flipper/railtie_spec.rb +0 -109
@@ -0,0 +1,14 @@
1
+ # Default routes loaded by Flipper::Cloud::Engine
2
+ Rails.application.routes.draw do
3
+ if ENV["FLIPPER_CLOUD_TOKEN"] && ENV["FLIPPER_CLOUD_SYNC_SECRET"]
4
+ require 'flipper/cloud'
5
+ config = Rails.application.config.flipper
6
+
7
+ cloud_app = Flipper::Cloud.app(nil,
8
+ env_key: config.env_key,
9
+ memoizer_options: { preload: config.preload }
10
+ )
11
+
12
+ mount cloud_app, at: config.cloud_path
13
+ end
14
+ 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
@@ -0,0 +1,183 @@
1
+ require "concurrent/map"
2
+ require "concurrent/timer_task"
3
+ require "concurrent/executor/fixed_thread_pool"
4
+ require "flipper/typecast"
5
+ require "flipper/cloud/telemetry/metric"
6
+ require "flipper/cloud/telemetry/metric_storage"
7
+ require "flipper/cloud/telemetry/submitter"
8
+
9
+ module Flipper
10
+ module Cloud
11
+ class Telemetry
12
+ # Internal: Map of instances of telemetry.
13
+ def self.instances
14
+ @instances ||= Concurrent::Map.new
15
+ end
16
+ private_class_method :instances
17
+
18
+ def self.reset
19
+ instances.each { |_, instance| instance.stop }.clear
20
+ end
21
+
22
+ # Internal: Fetch an instance of telemetry once per process per url +
23
+ # token (aka cloud endpoint). Should only ever be one instance unless you
24
+ # are doing some funky stuff.
25
+ def self.instance_for(cloud_configuration)
26
+ instances.compute_if_absent(cloud_configuration.url + cloud_configuration.token) do
27
+ new(cloud_configuration)
28
+ end
29
+ end
30
+
31
+ # Public: The cloud configuration to use for this telemetry instance.
32
+ attr_reader :cloud_configuration
33
+
34
+ # Internal: Where the metrics are stored between cloud submissions.
35
+ attr_reader :metric_storage
36
+
37
+ # Internal: The pool of background threads that submits metrics to cloud.
38
+ attr_reader :pool
39
+
40
+ # Internal: The timer that triggers draining the metrics to the pool.
41
+ attr_reader :timer
42
+
43
+ # Internal: The interval in seconds for how often telemetry should be sent to cloud.
44
+ attr_reader :interval
45
+
46
+ # Internal: The timeout in seconds for how long to wait for the pool to shutdown.
47
+ attr_reader :shutdown_timeout
48
+
49
+ # Internal: The proc that is called to submit metrics to cloud.
50
+ attr_accessor :submitter
51
+
52
+ def initialize(cloud_configuration)
53
+ @pid = $$
54
+ @cloud_configuration = cloud_configuration
55
+ self.interval = ENV.fetch("FLIPPER_TELEMETRY_INTERVAL", 60).to_f
56
+ self.shutdown_timeout = ENV.fetch("FLIPPER_TELEMETRY_SHUTDOWN_TIMEOUT", 5).to_f
57
+ self.submitter = ->(drained) { Submitter.new(@cloud_configuration).call(drained) }
58
+ start
59
+ at_exit { stop }
60
+ end
61
+
62
+ # Public: Records telemetry events based on active support notifications.
63
+ def record(name, payload)
64
+ return unless name == Flipper::Feature::InstrumentationName
65
+ return unless payload[:operation] == :enabled?
66
+ detect_forking
67
+
68
+ metric = Metric.new(payload[:feature_name].to_s.freeze, payload[:result])
69
+ @metric_storage.increment metric
70
+ end
71
+
72
+ # Public: Start all the tasks and setup new metric storage.
73
+ def start
74
+ info "action=start"
75
+
76
+ @metric_storage = MetricStorage.new
77
+
78
+ @pool = Concurrent::FixedThreadPool.new(2, {
79
+ max_queue: 5,
80
+ fallback_policy: :discard,
81
+ name: "flipper-telemetry-post-to-cloud-pool".freeze,
82
+ })
83
+
84
+ @timer = Concurrent::TimerTask.execute({
85
+ execution_interval: interval,
86
+ name: "flipper-telemetry-post-to-pool-timer".freeze,
87
+ }) { post_to_pool }
88
+ end
89
+
90
+ # Public: Shuts down all the tasks and tries to flush any remaining info to Cloud.
91
+ def stop
92
+ info "action=stop"
93
+
94
+ if @timer
95
+ debug "action=timer_shutdown_start"
96
+ @timer.shutdown
97
+ # no need to wait long for timer, all it does is drain in memory metric
98
+ # storage and post to the pool of background workers
99
+ timer_termination_result = @timer.wait_for_termination(1)
100
+ @timer.kill unless timer_termination_result
101
+ debug "action=timer_shutdown_end result=#{timer_termination_result}"
102
+ end
103
+
104
+ if @pool
105
+ post_to_pool # one last drain
106
+ debug "action=pool_shutdown_start"
107
+ @pool.shutdown
108
+ pool_termination_result = @pool.wait_for_termination(@shutdown_timeout)
109
+ @pool.kill unless pool_termination_result
110
+ debug "action=pool_shutdown_end result=#{pool_termination_result}"
111
+ end
112
+ end
113
+
114
+ # Public: Restart all the tasks and reset the storage.
115
+ def restart
116
+ stop
117
+ start
118
+ end
119
+
120
+ # Internal: Sets the interval in seconds for how often telemetry should be sent to cloud.
121
+ def interval=(value)
122
+ new_interval = [Typecast.to_float(value), 10].max
123
+ @timer&.execution_interval = new_interval
124
+ @interval = new_interval
125
+ end
126
+
127
+ # Internal: Sets the timeout in seconds for how long to wait for the pool to shutdown.
128
+ def shutdown_timeout=(value)
129
+ new_shutdown_timeout = [Typecast.to_float(value), 0.1].max
130
+ @shutdown_timeout = new_shutdown_timeout
131
+ end
132
+
133
+ private
134
+
135
+ def detect_forking
136
+ if @pid != $$
137
+ info "action=fork_detected pid_was#{@pid} pid_is=#{$$}"
138
+ restart
139
+ @pid = $$
140
+ end
141
+ end
142
+
143
+ # Drains the metric storage and enqueues the metrics to be posted to cloud.
144
+ def post_to_pool
145
+ drained = @metric_storage.drain
146
+ return if drained.empty?
147
+ debug "action=post_to_pool metrics=#{drained.size}"
148
+ @pool.post { post_to_cloud(drained) }
149
+ rescue => error
150
+ error "action=post_to_pool error=#{error.inspect}"
151
+ end
152
+
153
+ # Posts the drained metrics to cloud.
154
+ def post_to_cloud(drained)
155
+ debug "action=post_to_cloud metrics=#{drained.size}"
156
+ response, error = submitter.call(drained)
157
+ debug "action=post_to_cloud response=#{response.inspect} body=#{response&.body.inspect} error=#{error.inspect}"
158
+
159
+ # Some of the errors are response code errors which have a response and
160
+ # thus may have a telemetry-interval header for us to respect.
161
+ response ||= error.response if error && error.respond_to?(:response)
162
+
163
+ if response && interval = response["telemetry-interval"]
164
+ self.interval = interval.to_f
165
+ end
166
+ rescue => error
167
+ error "action=post_to_cloud error=#{error.inspect}"
168
+ end
169
+
170
+ def error(message)
171
+ @cloud_configuration.log message, level: :error
172
+ end
173
+
174
+ def info(message)
175
+ @cloud_configuration.log message, level: :info
176
+ end
177
+
178
+ def debug(message)
179
+ @cloud_configuration.log message
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,53 @@
1
+ require "flipper"
2
+ require "flipper/middleware/setup_env"
3
+ require "flipper/middleware/memoizer"
4
+ require "flipper/cloud/configuration"
5
+ require "flipper/cloud/dsl"
6
+ require "flipper/cloud/middleware"
7
+
8
+ module Flipper
9
+ module Cloud
10
+ # Public: Returns a new Flipper instance with an http adapter correctly
11
+ # configured for flipper cloud.
12
+ #
13
+ # token - The String token for the environment from the website.
14
+ # options - The Hash of options. See Flipper::Cloud::Configuration.
15
+ # block - The block that configuration will be yielded to allowing you to
16
+ # customize this cloud instance and its adapter.
17
+ def self.new(options = {})
18
+ configuration = Configuration.new(options)
19
+ yield configuration if block_given?
20
+ DSL.new(configuration)
21
+ end
22
+
23
+ def self.app(flipper = nil, options = {})
24
+ env_key = options.fetch(:env_key, 'flipper')
25
+ memoizer_options = options.fetch(:memoizer_options, {})
26
+
27
+ app = ->(_) { [404, { 'content-type'.freeze => 'application/json'.freeze }, ['{}'.freeze]] }
28
+ builder = Rack::Builder.new
29
+ yield builder if block_given?
30
+ builder.use Flipper::Middleware::SetupEnv, flipper, env_key: env_key
31
+ builder.use Flipper::Middleware::Memoizer, memoizer_options.merge(env_key: env_key)
32
+ builder.use Flipper::Cloud::Middleware, env_key: env_key
33
+ builder.run app
34
+ klass = self
35
+ app = builder.to_app
36
+ app.define_singleton_method(:inspect) { klass.inspect } # pretty rake routes output
37
+ app
38
+ end
39
+
40
+ # Private: Configure Flipper to use Cloud by default
41
+ def self.set_default
42
+ if ENV["FLIPPER_CLOUD_TOKEN"]
43
+ Flipper.configure do |config|
44
+ config.default do
45
+ self.new(local_adapter: config.adapter)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ Flipper::Cloud.set_default
@@ -1,8 +1,8 @@
1
1
  module Flipper
2
2
  class Configuration
3
3
  def initialize(options = {})
4
- @default = -> { Flipper.new(adapter) }
5
- @adapter = -> { Flipper::Adapters::Memory.new }
4
+ @builder = AdapterBuilder.new { store Flipper::Adapters::Memory }
5
+ @default = -> { Flipper.new(@builder.to_adapter) }
6
6
  end
7
7
 
8
8
  # The default adapter to use.
@@ -24,9 +24,20 @@ module Flipper
24
24
  #
25
25
  def adapter(&block)
26
26
  if block_given?
27
- @adapter = block
27
+ @builder.store(block)
28
28
  else
29
- @adapter.call
29
+ @builder.to_adapter
30
+ end
31
+ end
32
+
33
+ # An adapter to use to augment the primary storage adapter. See `AdapterBuilder#use`
34
+ if RUBY_VERSION >= '3.0'
35
+ def use(klass, *args, **kwargs, &block)
36
+ @builder.use(klass, *args, **kwargs, &block)
37
+ end
38
+ else
39
+ def use(klass, *args, &block)
40
+ @builder.use(klass, *args, &block)
30
41
  end
31
42
  end
32
43
 
@@ -54,5 +65,15 @@ module Flipper
54
65
  @default.call
55
66
  end
56
67
  end
68
+
69
+ def statsd
70
+ require 'flipper/instrumentation/statsd_subscriber'
71
+ Flipper::Instrumentation::StatsdSubscriber.client
72
+ end
73
+
74
+ def statsd=(client)
75
+ require "flipper/instrumentation/statsd"
76
+ Flipper::Instrumentation::StatsdSubscriber.client = client
77
+ end
57
78
  end
58
79
  end