flipper 0.26.0 → 1.3.6

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 (228) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/workflows/ci.yml +61 -16
  4. data/.github/workflows/examples.yml +55 -18
  5. data/CLAUDE.md +74 -0
  6. data/Changelog.md +1 -486
  7. data/Gemfile +23 -11
  8. data/README.md +31 -27
  9. data/Rakefile +2 -2
  10. data/benchmark/enabled_ips.rb +10 -0
  11. data/benchmark/enabled_multiple_actors_ips.rb +20 -0
  12. data/benchmark/enabled_profile.rb +20 -0
  13. data/benchmark/instrumentation_ips.rb +21 -0
  14. data/benchmark/typecast_ips.rb +27 -0
  15. data/docs/images/banner.jpg +0 -0
  16. data/docs/images/flipper_cloud.png +0 -0
  17. data/examples/api/basic.ru +3 -4
  18. data/examples/api/custom_memoized.ru +3 -4
  19. data/examples/api/memoized.ru +3 -4
  20. data/examples/cloud/app.ru +12 -0
  21. data/examples/cloud/backoff_policy.rb +13 -0
  22. data/examples/cloud/basic.rb +22 -0
  23. data/examples/cloud/cloud_setup.rb +20 -0
  24. data/examples/cloud/forked.rb +36 -0
  25. data/examples/cloud/import.rb +17 -0
  26. data/examples/cloud/threaded.rb +33 -0
  27. data/examples/dsl.rb +1 -15
  28. data/examples/enabled_for_actor.rb +4 -2
  29. data/examples/expressions.rb +213 -0
  30. data/examples/mirroring.rb +59 -0
  31. data/examples/strict.rb +18 -0
  32. data/exe/flipper +5 -0
  33. data/flipper-cloud.gemspec +19 -0
  34. data/flipper.gemspec +8 -6
  35. data/lib/flipper/actor.rb +6 -3
  36. data/lib/flipper/adapter.rb +33 -7
  37. data/lib/flipper/adapter_builder.rb +44 -0
  38. data/lib/flipper/adapters/actor_limit.rb +28 -0
  39. data/lib/flipper/adapters/cache_base.rb +143 -0
  40. data/lib/flipper/adapters/dual_write.rb +1 -3
  41. data/lib/flipper/adapters/failover.rb +0 -4
  42. data/lib/flipper/adapters/failsafe.rb +0 -4
  43. data/lib/flipper/adapters/http/client.rb +40 -12
  44. data/lib/flipper/adapters/http/error.rb +2 -2
  45. data/lib/flipper/adapters/http.rb +30 -17
  46. data/lib/flipper/adapters/instrumented.rb +25 -6
  47. data/lib/flipper/adapters/memoizable.rb +33 -21
  48. data/lib/flipper/adapters/memory.rb +81 -46
  49. data/lib/flipper/adapters/operation_logger.rb +17 -78
  50. data/lib/flipper/adapters/poll/poller.rb +2 -125
  51. data/lib/flipper/adapters/poll.rb +20 -3
  52. data/lib/flipper/adapters/pstore.rb +17 -11
  53. data/lib/flipper/adapters/read_only.rb +8 -41
  54. data/lib/flipper/adapters/strict.rb +45 -0
  55. data/lib/flipper/adapters/sync/feature_synchronizer.rb +10 -1
  56. data/lib/flipper/adapters/sync.rb +0 -4
  57. data/lib/flipper/adapters/wrapper.rb +54 -0
  58. data/lib/flipper/cli.rb +263 -0
  59. data/lib/flipper/cloud/configuration.rb +266 -0
  60. data/lib/flipper/cloud/dsl.rb +27 -0
  61. data/lib/flipper/cloud/message_verifier.rb +95 -0
  62. data/lib/flipper/cloud/middleware.rb +63 -0
  63. data/lib/flipper/cloud/routes.rb +14 -0
  64. data/lib/flipper/cloud/telemetry/backoff_policy.rb +96 -0
  65. data/lib/flipper/cloud/telemetry/instrumenter.rb +22 -0
  66. data/lib/flipper/cloud/telemetry/metric.rb +39 -0
  67. data/lib/flipper/cloud/telemetry/metric_storage.rb +30 -0
  68. data/lib/flipper/cloud/telemetry/submitter.rb +100 -0
  69. data/lib/flipper/cloud/telemetry.rb +191 -0
  70. data/lib/flipper/cloud.rb +53 -0
  71. data/lib/flipper/configuration.rb +25 -4
  72. data/lib/flipper/dsl.rb +46 -45
  73. data/lib/flipper/engine.rb +102 -0
  74. data/lib/flipper/errors.rb +3 -3
  75. data/lib/flipper/export.rb +24 -0
  76. data/lib/flipper/exporter.rb +17 -0
  77. data/lib/flipper/exporters/json/export.rb +32 -0
  78. data/lib/flipper/exporters/json/v1.rb +33 -0
  79. data/lib/flipper/expression/builder.rb +73 -0
  80. data/lib/flipper/expression/constant.rb +25 -0
  81. data/lib/flipper/expression.rb +71 -0
  82. data/lib/flipper/expressions/all.rb +9 -0
  83. data/lib/flipper/expressions/any.rb +9 -0
  84. data/lib/flipper/expressions/boolean.rb +9 -0
  85. data/lib/flipper/expressions/comparable.rb +13 -0
  86. data/lib/flipper/expressions/duration.rb +28 -0
  87. data/lib/flipper/expressions/equal.rb +9 -0
  88. data/lib/flipper/expressions/greater_than.rb +9 -0
  89. data/lib/flipper/expressions/greater_than_or_equal_to.rb +9 -0
  90. data/lib/flipper/expressions/less_than.rb +9 -0
  91. data/lib/flipper/expressions/less_than_or_equal_to.rb +9 -0
  92. data/lib/flipper/expressions/not_equal.rb +9 -0
  93. data/lib/flipper/expressions/now.rb +9 -0
  94. data/lib/flipper/expressions/number.rb +9 -0
  95. data/lib/flipper/expressions/percentage.rb +9 -0
  96. data/lib/flipper/expressions/percentage_of_actors.rb +12 -0
  97. data/lib/flipper/expressions/property.rb +9 -0
  98. data/lib/flipper/expressions/random.rb +9 -0
  99. data/lib/flipper/expressions/string.rb +9 -0
  100. data/lib/flipper/expressions/time.rb +9 -0
  101. data/lib/flipper/feature.rb +94 -26
  102. data/lib/flipper/feature_check_context.rb +10 -6
  103. data/lib/flipper/gate.rb +13 -11
  104. data/lib/flipper/gate_values.rb +5 -18
  105. data/lib/flipper/gates/actor.rb +10 -17
  106. data/lib/flipper/gates/boolean.rb +1 -1
  107. data/lib/flipper/gates/expression.rb +75 -0
  108. data/lib/flipper/gates/group.rb +5 -7
  109. data/lib/flipper/gates/percentage_of_actors.rb +10 -13
  110. data/lib/flipper/gates/percentage_of_time.rb +1 -2
  111. data/lib/flipper/identifier.rb +2 -2
  112. data/lib/flipper/instrumentation/log_subscriber.rb +35 -8
  113. data/lib/flipper/instrumentation/statsd.rb +4 -2
  114. data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
  115. data/lib/flipper/instrumentation/subscriber.rb +8 -5
  116. data/lib/flipper/metadata.rb +8 -1
  117. data/lib/flipper/middleware/memoizer.rb +30 -14
  118. data/lib/flipper/model/active_record.rb +23 -0
  119. data/lib/flipper/poller.rb +118 -0
  120. data/lib/flipper/serializers/gzip.rb +22 -0
  121. data/lib/flipper/serializers/json.rb +17 -0
  122. data/lib/flipper/spec/shared_adapter_specs.rb +105 -63
  123. data/lib/flipper/test/shared_adapter_test.rb +101 -58
  124. data/lib/flipper/test_help.rb +43 -0
  125. data/lib/flipper/typecast.rb +59 -18
  126. data/lib/flipper/types/actor.rb +13 -13
  127. data/lib/flipper/types/group.rb +4 -4
  128. data/lib/flipper/types/percentage.rb +1 -1
  129. data/lib/flipper/version.rb +11 -1
  130. data/lib/flipper.rb +50 -11
  131. data/lib/generators/flipper/setup_generator.rb +68 -0
  132. data/lib/generators/flipper/templates/initializer.rb +45 -0
  133. data/lib/generators/flipper/templates/update/migrations/01_create_flipper_tables.rb.erb +22 -0
  134. data/lib/generators/flipper/templates/update/migrations/02_change_flipper_gates_value_to_text.rb.erb +18 -0
  135. data/lib/generators/flipper/update_generator.rb +35 -0
  136. data/package-lock.json +41 -0
  137. data/package.json +10 -0
  138. data/spec/fixtures/environment.rb +1 -0
  139. data/spec/fixtures/flipper_pstore_1679087600.json +46 -0
  140. data/spec/flipper/adapter_builder_spec.rb +72 -0
  141. data/spec/flipper/adapter_spec.rb +30 -2
  142. data/spec/flipper/adapters/actor_limit_spec.rb +20 -0
  143. data/spec/flipper/adapters/dual_write_spec.rb +2 -2
  144. data/spec/flipper/adapters/http/client_spec.rb +61 -0
  145. data/spec/flipper/adapters/http_spec.rb +138 -55
  146. data/spec/flipper/adapters/instrumented_spec.rb +29 -11
  147. data/spec/flipper/adapters/memoizable_spec.rb +51 -31
  148. data/spec/flipper/adapters/memory_spec.rb +14 -3
  149. data/spec/flipper/adapters/operation_logger_spec.rb +31 -12
  150. data/spec/flipper/adapters/poll_spec.rb +41 -0
  151. data/spec/flipper/adapters/read_only_spec.rb +32 -17
  152. data/spec/flipper/adapters/strict_spec.rb +64 -0
  153. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +27 -0
  154. data/spec/flipper/cli_spec.rb +166 -0
  155. data/spec/flipper/cloud/configuration_spec.rb +251 -0
  156. data/spec/flipper/cloud/dsl_spec.rb +82 -0
  157. data/spec/flipper/cloud/message_verifier_spec.rb +104 -0
  158. data/spec/flipper/cloud/middleware_spec.rb +289 -0
  159. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +107 -0
  160. data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
  161. data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
  162. data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
  163. data/spec/flipper/cloud/telemetry_spec.rb +208 -0
  164. data/spec/flipper/cloud_spec.rb +186 -0
  165. data/spec/flipper/configuration_spec.rb +17 -0
  166. data/spec/flipper/dsl_spec.rb +54 -76
  167. data/spec/flipper/engine_spec.rb +374 -0
  168. data/spec/flipper/export_spec.rb +13 -0
  169. data/spec/flipper/exporter_spec.rb +16 -0
  170. data/spec/flipper/exporters/json/export_spec.rb +60 -0
  171. data/spec/flipper/exporters/json/v1_spec.rb +33 -0
  172. data/spec/flipper/expression/builder_spec.rb +248 -0
  173. data/spec/flipper/expression_spec.rb +188 -0
  174. data/spec/flipper/expressions/all_spec.rb +15 -0
  175. data/spec/flipper/expressions/any_spec.rb +15 -0
  176. data/spec/flipper/expressions/boolean_spec.rb +15 -0
  177. data/spec/flipper/expressions/duration_spec.rb +43 -0
  178. data/spec/flipper/expressions/equal_spec.rb +24 -0
  179. data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
  180. data/spec/flipper/expressions/greater_than_spec.rb +28 -0
  181. data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
  182. data/spec/flipper/expressions/less_than_spec.rb +32 -0
  183. data/spec/flipper/expressions/not_equal_spec.rb +15 -0
  184. data/spec/flipper/expressions/now_spec.rb +11 -0
  185. data/spec/flipper/expressions/number_spec.rb +21 -0
  186. data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
  187. data/spec/flipper/expressions/percentage_spec.rb +15 -0
  188. data/spec/flipper/expressions/property_spec.rb +13 -0
  189. data/spec/flipper/expressions/random_spec.rb +9 -0
  190. data/spec/flipper/expressions/string_spec.rb +11 -0
  191. data/spec/flipper/expressions/time_spec.rb +13 -0
  192. data/spec/flipper/feature_check_context_spec.rb +17 -17
  193. data/spec/flipper/feature_spec.rb +453 -39
  194. data/spec/flipper/gate_values_spec.rb +2 -33
  195. data/spec/flipper/gates/boolean_spec.rb +1 -1
  196. data/spec/flipper/gates/expression_spec.rb +108 -0
  197. data/spec/flipper/gates/group_spec.rb +2 -3
  198. data/spec/flipper/gates/percentage_of_actors_spec.rb +61 -5
  199. data/spec/flipper/gates/percentage_of_time_spec.rb +2 -2
  200. data/spec/flipper/identifier_spec.rb +4 -5
  201. data/spec/flipper/instrumentation/log_subscriber_spec.rb +24 -6
  202. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +26 -2
  203. data/spec/flipper/middleware/memoizer_spec.rb +79 -10
  204. data/spec/flipper/model/active_record_spec.rb +72 -0
  205. data/spec/flipper/poller_spec.rb +47 -0
  206. data/spec/flipper/serializers/gzip_spec.rb +13 -0
  207. data/spec/flipper/serializers/json_spec.rb +13 -0
  208. data/spec/flipper/typecast_spec.rb +121 -6
  209. data/spec/flipper/types/actor_spec.rb +63 -46
  210. data/spec/flipper/types/group_spec.rb +2 -2
  211. data/spec/flipper_integration_spec.rb +168 -58
  212. data/spec/flipper_spec.rb +94 -30
  213. data/spec/spec_helper.rb +18 -18
  214. data/spec/support/actor_names.yml +1 -0
  215. data/spec/support/fail_on_output.rb +8 -0
  216. data/spec/support/fake_backoff_policy.rb +15 -0
  217. data/spec/support/skippable.rb +18 -0
  218. data/spec/support/spec_helpers.rb +34 -8
  219. data/test/adapters/actor_limit_test.rb +20 -0
  220. data/test_rails/generators/flipper/setup_generator_test.rb +69 -0
  221. data/test_rails/generators/flipper/update_generator_test.rb +96 -0
  222. data/test_rails/helper.rb +22 -2
  223. data/test_rails/system/test_help_test.rb +52 -0
  224. metadata +203 -20
  225. data/.github/workflows/release.yml +0 -44
  226. data/.tool-versions +0 -1
  227. data/lib/flipper/railtie.rb +0 -47
  228. data/spec/flipper/railtie_spec.rb +0 -109
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "flipper/cloud/message_verifier"
4
+
5
+ module Flipper
6
+ module Cloud
7
+ class Middleware
8
+ # Internal: The path to match for webhook requests.
9
+ WEBHOOK_PATH = %r{\A/webhooks\/?\Z}
10
+ # Internal: The root path to match for requests.
11
+ ROOT_PATH = %r{\A/\Z}
12
+
13
+ def initialize(app, options = {})
14
+ @app = app
15
+ @env_key = options.fetch(:env_key, 'flipper')
16
+ end
17
+
18
+ def call(env)
19
+ dup.call!(env)
20
+ end
21
+
22
+ def call!(env)
23
+ request = Rack::Request.new(env)
24
+ if request.post? && (request.path_info.match(ROOT_PATH) || request.path_info.match(WEBHOOK_PATH))
25
+ status = 200
26
+ headers = {
27
+ Rack::CONTENT_TYPE => "application/json",
28
+ }
29
+ body = "{}"
30
+ payload = request.body.read
31
+ signature = request.env["HTTP_FLIPPER_CLOUD_SIGNATURE"]
32
+ flipper = env.fetch(@env_key)
33
+
34
+ begin
35
+ message_verifier = MessageVerifier.new(secret: flipper.sync_secret)
36
+ if message_verifier.verify(payload, signature)
37
+ begin
38
+ flipper.sync
39
+ body = JSON.generate({
40
+ groups: Flipper.group_names.map { |name| {name: name}}
41
+ })
42
+ rescue Flipper::Adapters::Http::Error => error
43
+ status = error.response.code.to_i == 402 ? 402 : 500
44
+ headers["flipper-cloud-response-error-class"] = error.class.name
45
+ headers["flipper-cloud-response-error-message"] = error.message
46
+ rescue => error
47
+ status = 500
48
+ headers["flipper-cloud-response-error-class"] = error.class.name
49
+ headers["flipper-cloud-response-error-message"] = error.message
50
+ end
51
+ end
52
+ rescue MessageVerifier::InvalidSignature
53
+ status = 400
54
+ end
55
+
56
+ [status, headers, [body]]
57
+ else
58
+ @app.call(env)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -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,96 @@
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 = 30_000
7
+
8
+ # Private: The default maximum timeout between intervals in milliseconds.
9
+ MAX_TIMEOUT_MS = 120_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
+ # cap the interval to the max timeout
71
+ result = [interval, @max_timeout_ms].min
72
+ # jitter even when maxed out
73
+ result == @max_timeout_ms ? add_jitter(result, 0.05) : result
74
+ end
75
+
76
+ def reset
77
+ @attempts = 0
78
+ end
79
+
80
+ private
81
+
82
+ def add_jitter(base, randomization_factor)
83
+ random_number = rand
84
+ max_deviation = base * randomization_factor
85
+ deviation = random_number * max_deviation
86
+
87
+ if random_number < 0.5
88
+ base - deviation
89
+ else
90
+ base + deviation
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,22 @@
1
+ require "delegate"
2
+
3
+ module Flipper
4
+ module Cloud
5
+ class Telemetry
6
+ class Instrumenter
7
+ attr_reader :instrumenter
8
+
9
+ def initialize(cloud_configuration, instrumenter)
10
+ @instrumenter = instrumenter
11
+ @cloud_configuration = cloud_configuration
12
+ end
13
+
14
+ def instrument(name, payload = {}, &block)
15
+ return_value = instrumenter.instrument(name, payload, &block)
16
+ @cloud_configuration.telemetry.record(name, payload)
17
+ return_value
18
+ end
19
+ end
20
+ end
21
+ end
22
+ 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,100 @@
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(5) { 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.instrument "telemetry_error.#{Flipper::InstrumentationNamespace}", exception: exception, request_id: request_id
55
+ @cloud_configuration.log "action=to_body request_id=#{request_id} error=#{exception.inspect}", level: :error
56
+ end
57
+
58
+ def retry_with_backoff(attempts, &block)
59
+ result, caught_exception = nil
60
+ should_retry = false
61
+ attempts_remaining = attempts - 1
62
+
63
+ begin
64
+ result, should_retry = yield
65
+ return [result, nil] unless should_retry
66
+ rescue => error
67
+ @cloud_configuration.instrument "telemetry_retry.#{Flipper::InstrumentationNamespace}", attempts_remaining: attempts_remaining, exception: error
68
+ @cloud_configuration.log "action=post_to_cloud attempts_remaining=#{attempts_remaining} error=#{error.inspect}", level: :error
69
+ should_retry = true
70
+ caught_exception = error
71
+ end
72
+
73
+ if should_retry && attempts_remaining > 0
74
+ sleep @backoff_policy.next_interval.to_f / 1000
75
+ retry_with_backoff attempts_remaining, &block
76
+ else
77
+ [result, caught_exception]
78
+ end
79
+ end
80
+
81
+ def submit(body)
82
+ client = @cloud_configuration.http_client
83
+ client.add_header "schema-version", SCHEMA_VERSION
84
+ client.add_header "content-encoding", GZIP_ENCODING
85
+
86
+ response = client.post PATH, body
87
+ code = response.code.to_i
88
+
89
+ # Raise error and retry for retriable status codes.
90
+ # FIXME: what about redirects?
91
+ if code < 200 || code == 429 || code >= 500
92
+ raise Error.new(request_id, response)
93
+ end
94
+
95
+ response
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,191 @@
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(1, {
79
+ max_queue: 20, # ~ 20 minutes of data at 1 minute intervals
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
164
+ if Flipper::Typecast.to_boolean(response["telemetry-shutdown"])
165
+ debug "action=telemetry_shutdown message=The server has requested that telemetry be shut down."
166
+ stop
167
+ return
168
+ end
169
+
170
+ if interval = response["telemetry-interval"]
171
+ self.interval = interval.to_f
172
+ end
173
+ end
174
+ rescue => error
175
+ error "action=post_to_cloud error=#{error.inspect}"
176
+ end
177
+
178
+ def error(message)
179
+ @cloud_configuration.log message, level: :error
180
+ end
181
+
182
+ def info(message)
183
+ @cloud_configuration.log message, level: :info
184
+ end
185
+
186
+ def debug(message)
187
+ @cloud_configuration.log message
188
+ end
189
+ end
190
+ end
191
+ 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, { Rack::CONTENT_TYPE => '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