flipper 1.0.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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/workflows/ci.yml +50 -7
  4. data/.github/workflows/examples.yml +50 -8
  5. data/CLAUDE.md +74 -0
  6. data/Changelog.md +1 -584
  7. data/Gemfile +15 -8
  8. data/README.md +31 -27
  9. data/Rakefile +2 -2
  10. data/benchmark/typecast_ips.rb +8 -0
  11. data/docs/images/banner.jpg +0 -0
  12. data/docs/images/flipper_cloud.png +0 -0
  13. data/examples/cloud/backoff_policy.rb +13 -0
  14. data/examples/cloud/cloud_setup.rb +16 -0
  15. data/examples/cloud/forked.rb +7 -2
  16. data/examples/cloud/threaded.rb +15 -18
  17. data/examples/expressions.rb +213 -0
  18. data/examples/strict.rb +18 -0
  19. data/exe/flipper +5 -0
  20. data/flipper.gemspec +6 -3
  21. data/lib/flipper/actor.rb +6 -3
  22. data/lib/flipper/adapter.rb +10 -0
  23. data/lib/flipper/adapter_builder.rb +44 -0
  24. data/lib/flipper/adapters/actor_limit.rb +28 -0
  25. data/lib/flipper/adapters/cache_base.rb +143 -0
  26. data/lib/flipper/adapters/dual_write.rb +1 -3
  27. data/lib/flipper/adapters/failover.rb +0 -4
  28. data/lib/flipper/adapters/failsafe.rb +0 -4
  29. data/lib/flipper/adapters/http/client.rb +40 -12
  30. data/lib/flipper/adapters/http/error.rb +2 -2
  31. data/lib/flipper/adapters/http.rb +19 -14
  32. data/lib/flipper/adapters/instrumented.rb +0 -4
  33. data/lib/flipper/adapters/memoizable.rb +14 -19
  34. data/lib/flipper/adapters/memory.rb +4 -6
  35. data/lib/flipper/adapters/operation_logger.rb +18 -92
  36. data/lib/flipper/adapters/poll.rb +16 -3
  37. data/lib/flipper/adapters/pstore.rb +17 -11
  38. data/lib/flipper/adapters/read_only.rb +8 -41
  39. data/lib/flipper/adapters/strict.rb +45 -0
  40. data/lib/flipper/adapters/sync/feature_synchronizer.rb +10 -1
  41. data/lib/flipper/adapters/sync.rb +0 -4
  42. data/lib/flipper/adapters/wrapper.rb +54 -0
  43. data/lib/flipper/cli.rb +263 -0
  44. data/lib/flipper/cloud/configuration.rb +131 -54
  45. data/lib/flipper/cloud/middleware.rb +5 -5
  46. data/lib/flipper/cloud/telemetry/backoff_policy.rb +96 -0
  47. data/lib/flipper/cloud/telemetry/instrumenter.rb +22 -0
  48. data/lib/flipper/cloud/telemetry/metric.rb +39 -0
  49. data/lib/flipper/cloud/telemetry/metric_storage.rb +30 -0
  50. data/lib/flipper/cloud/telemetry/submitter.rb +100 -0
  51. data/lib/flipper/cloud/telemetry.rb +191 -0
  52. data/lib/flipper/cloud.rb +1 -1
  53. data/lib/flipper/configuration.rb +25 -4
  54. data/lib/flipper/dsl.rb +51 -0
  55. data/lib/flipper/engine.rb +42 -3
  56. data/lib/flipper/export.rb +0 -2
  57. data/lib/flipper/exporters/json/export.rb +1 -1
  58. data/lib/flipper/exporters/json/v1.rb +1 -1
  59. data/lib/flipper/expression/builder.rb +73 -0
  60. data/lib/flipper/expression/constant.rb +25 -0
  61. data/lib/flipper/expression.rb +71 -0
  62. data/lib/flipper/expressions/all.rb +9 -0
  63. data/lib/flipper/expressions/any.rb +9 -0
  64. data/lib/flipper/expressions/boolean.rb +9 -0
  65. data/lib/flipper/expressions/comparable.rb +13 -0
  66. data/lib/flipper/expressions/duration.rb +28 -0
  67. data/lib/flipper/expressions/equal.rb +9 -0
  68. data/lib/flipper/expressions/greater_than.rb +9 -0
  69. data/lib/flipper/expressions/greater_than_or_equal_to.rb +9 -0
  70. data/lib/flipper/expressions/less_than.rb +9 -0
  71. data/lib/flipper/expressions/less_than_or_equal_to.rb +9 -0
  72. data/lib/flipper/expressions/not_equal.rb +9 -0
  73. data/lib/flipper/expressions/now.rb +9 -0
  74. data/lib/flipper/expressions/number.rb +9 -0
  75. data/lib/flipper/expressions/percentage.rb +9 -0
  76. data/lib/flipper/expressions/percentage_of_actors.rb +12 -0
  77. data/lib/flipper/expressions/property.rb +9 -0
  78. data/lib/flipper/expressions/random.rb +9 -0
  79. data/lib/flipper/expressions/string.rb +9 -0
  80. data/lib/flipper/expressions/time.rb +9 -0
  81. data/lib/flipper/feature.rb +63 -1
  82. data/lib/flipper/gate.rb +2 -1
  83. data/lib/flipper/gate_values.rb +5 -2
  84. data/lib/flipper/gates/expression.rb +75 -0
  85. data/lib/flipper/instrumentation/log_subscriber.rb +13 -5
  86. data/lib/flipper/instrumentation/statsd.rb +4 -2
  87. data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
  88. data/lib/flipper/instrumentation/subscriber.rb +0 -4
  89. data/lib/flipper/metadata.rb +4 -1
  90. data/lib/flipper/middleware/memoizer.rb +29 -13
  91. data/lib/flipper/model/active_record.rb +23 -0
  92. data/lib/flipper/poller.rb +9 -8
  93. data/lib/flipper/serializers/gzip.rb +22 -0
  94. data/lib/flipper/serializers/json.rb +17 -0
  95. data/lib/flipper/spec/shared_adapter_specs.rb +46 -27
  96. data/lib/flipper/test/shared_adapter_test.rb +41 -22
  97. data/lib/flipper/test_help.rb +43 -0
  98. data/lib/flipper/typecast.rb +37 -9
  99. data/lib/flipper/types/percentage.rb +1 -1
  100. data/lib/flipper/version.rb +11 -1
  101. data/lib/flipper.rb +41 -2
  102. data/lib/generators/flipper/setup_generator.rb +68 -0
  103. data/lib/generators/flipper/templates/initializer.rb +45 -0
  104. data/lib/generators/flipper/templates/update/migrations/01_create_flipper_tables.rb.erb +22 -0
  105. data/lib/generators/flipper/templates/update/migrations/02_change_flipper_gates_value_to_text.rb.erb +18 -0
  106. data/lib/generators/flipper/update_generator.rb +35 -0
  107. data/package-lock.json +41 -0
  108. data/package.json +10 -0
  109. data/spec/fixtures/environment.rb +1 -0
  110. data/spec/flipper/adapter_builder_spec.rb +72 -0
  111. data/spec/flipper/adapter_spec.rb +1 -0
  112. data/spec/flipper/adapters/actor_limit_spec.rb +20 -0
  113. data/spec/flipper/adapters/http/client_spec.rb +61 -0
  114. data/spec/flipper/adapters/http_spec.rb +135 -74
  115. data/spec/flipper/adapters/memoizable_spec.rb +15 -15
  116. data/spec/flipper/adapters/poll_spec.rb +41 -0
  117. data/spec/flipper/adapters/read_only_spec.rb +26 -11
  118. data/spec/flipper/adapters/strict_spec.rb +64 -0
  119. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +27 -0
  120. data/spec/flipper/cli_spec.rb +166 -0
  121. data/spec/flipper/cloud/configuration_spec.rb +39 -57
  122. data/spec/flipper/cloud/dsl_spec.rb +6 -6
  123. data/spec/flipper/cloud/middleware_spec.rb +8 -8
  124. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +107 -0
  125. data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
  126. data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
  127. data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
  128. data/spec/flipper/cloud/telemetry_spec.rb +208 -0
  129. data/spec/flipper/cloud_spec.rb +31 -25
  130. data/spec/flipper/configuration_spec.rb +17 -0
  131. data/spec/flipper/dsl_spec.rb +39 -3
  132. data/spec/flipper/engine_spec.rb +226 -42
  133. data/spec/flipper/exporters/json/v1_spec.rb +3 -3
  134. data/spec/flipper/expression/builder_spec.rb +248 -0
  135. data/spec/flipper/expression_spec.rb +188 -0
  136. data/spec/flipper/expressions/all_spec.rb +15 -0
  137. data/spec/flipper/expressions/any_spec.rb +15 -0
  138. data/spec/flipper/expressions/boolean_spec.rb +15 -0
  139. data/spec/flipper/expressions/duration_spec.rb +43 -0
  140. data/spec/flipper/expressions/equal_spec.rb +24 -0
  141. data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
  142. data/spec/flipper/expressions/greater_than_spec.rb +28 -0
  143. data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
  144. data/spec/flipper/expressions/less_than_spec.rb +32 -0
  145. data/spec/flipper/expressions/not_equal_spec.rb +15 -0
  146. data/spec/flipper/expressions/now_spec.rb +11 -0
  147. data/spec/flipper/expressions/number_spec.rb +21 -0
  148. data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
  149. data/spec/flipper/expressions/percentage_spec.rb +15 -0
  150. data/spec/flipper/expressions/property_spec.rb +13 -0
  151. data/spec/flipper/expressions/random_spec.rb +9 -0
  152. data/spec/flipper/expressions/string_spec.rb +11 -0
  153. data/spec/flipper/expressions/time_spec.rb +13 -0
  154. data/spec/flipper/feature_spec.rb +380 -10
  155. data/spec/flipper/gate_values_spec.rb +2 -2
  156. data/spec/flipper/gates/expression_spec.rb +108 -0
  157. data/spec/flipper/identifier_spec.rb +4 -5
  158. data/spec/flipper/instrumentation/log_subscriber_spec.rb +10 -2
  159. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +16 -2
  160. data/spec/flipper/middleware/memoizer_spec.rb +79 -10
  161. data/spec/flipper/model/active_record_spec.rb +72 -0
  162. data/spec/flipper/serializers/gzip_spec.rb +13 -0
  163. data/spec/flipper/serializers/json_spec.rb +13 -0
  164. data/spec/flipper/typecast_spec.rb +43 -7
  165. data/spec/flipper/types/actor_spec.rb +18 -1
  166. data/spec/flipper_integration_spec.rb +102 -4
  167. data/spec/flipper_spec.rb +91 -3
  168. data/spec/spec_helper.rb +17 -5
  169. data/spec/support/actor_names.yml +1 -0
  170. data/spec/support/fail_on_output.rb +8 -0
  171. data/spec/support/fake_backoff_policy.rb +15 -0
  172. data/spec/support/spec_helpers.rb +34 -8
  173. data/test/adapters/actor_limit_test.rb +20 -0
  174. data/test_rails/generators/flipper/setup_generator_test.rb +69 -0
  175. data/test_rails/generators/flipper/update_generator_test.rb +96 -0
  176. data/test_rails/helper.rb +22 -2
  177. data/test_rails/system/test_help_test.rb +52 -0
  178. metadata +145 -29
  179. data/lib/flipper/cloud/instrumenter.rb +0 -48
  180. data/spec/support/climate_control.rb +0 -7
@@ -0,0 +1,263 @@
1
+ require 'optparse'
2
+
3
+ module Flipper
4
+ class CLI < OptionParser
5
+ def self.run(argv = ARGV)
6
+ new.run(argv)
7
+ end
8
+
9
+ # Path to the local Rails application's environment configuration.
10
+ DEFAULT_REQUIRE = "./config/environment"
11
+
12
+ attr_accessor :shell
13
+
14
+ def initialize(stdout: $stdout, stderr: $stderr, shell: Bundler::Thor::Base.shell.new)
15
+ super
16
+
17
+ # Program is always flipper, no matter how it's invoked
18
+ @program_name = 'flipper'
19
+ @require = ENV.fetch("FLIPPER_REQUIRE", DEFAULT_REQUIRE)
20
+ @commands = {}
21
+
22
+ # Extend whatever shell to support output redirection
23
+ @shell = shell.extend(ShellOutput)
24
+ shell.redirect(stdout: stdout, stderr: stderr)
25
+
26
+ %w[enable disable].each do |action|
27
+ command action do |c|
28
+ c.banner = "Usage: #{c.program_name} [options] <feature>"
29
+ c.description = "#{action.to_s.capitalize} a feature"
30
+
31
+ values = []
32
+
33
+ c.on('-a id', '--actor=id', "#{action} for an actor") do |id|
34
+ values << Actor.new(id)
35
+ end
36
+ c.on('-g name', '--group=name', "#{action} for a group") do |name|
37
+ values << Types::Group.new(name)
38
+ end
39
+ c.on('-p NUM', '--percentage-of-actors=NUM', Numeric, "#{action} for a percentage of actors") do |num|
40
+ values << Types::PercentageOfActors.new(num)
41
+ end
42
+ c.on('-t NUM', '--percentage-of-time=NUM', Numeric, "#{action} for a percentage of time") do |num|
43
+ values << Types::PercentageOfTime.new(num)
44
+ end
45
+ c.on('-x expressions', '--expression=NUM', "#{action} for the given expression") do |expression|
46
+ begin
47
+ values << Flipper::Expression.build(JSON.parse(expression))
48
+ rescue JSON::ParserError => e
49
+ ui.error "JSON parse error #{e.message}"
50
+ ui.trace(e)
51
+ exit 1
52
+ rescue ArgumentError => e
53
+ ui.error "Invalid expression: #{e.message}"
54
+ ui.trace(e)
55
+ exit 1
56
+ end
57
+ end
58
+
59
+ c.action do |feature|
60
+ f = Flipper.feature(feature)
61
+
62
+ if values.empty?
63
+ f.send(action)
64
+ else
65
+ values.each { |value| f.send(action, value) }
66
+ end
67
+
68
+ ui.info feature_details(f)
69
+ end
70
+ end
71
+ end
72
+
73
+ command 'list' do |c|
74
+ c.description = "List defined features"
75
+ c.action do
76
+ ui.info feature_summary(Flipper.features)
77
+ end
78
+ end
79
+
80
+ command 'show' do |c|
81
+ c.description = "Show a defined feature"
82
+ c.action do |feature|
83
+ ui.info feature_details(Flipper.feature(feature))
84
+ end
85
+ end
86
+
87
+ command 'help' do |c|
88
+ c.load_environment = false
89
+ c.action do |command = nil|
90
+ ui.info command ? @commands[command].help : help
91
+ end
92
+ end
93
+
94
+ on_tail('-r path', "The path to load your application. Default: #{@require}") do |path|
95
+ @require = path
96
+ end
97
+
98
+ # Options available on all commands
99
+ on_tail('-h', '--help', 'Print help message') do
100
+ ui.info help
101
+ exit
102
+ end
103
+
104
+ # Set help documentation
105
+ self.banner = "Usage: #{program_name} [options] <command>"
106
+ separator ""
107
+ separator "Commands:"
108
+
109
+ pad = @commands.keys.map(&:length).max + 2
110
+ @commands.each do |name, command|
111
+ separator " #{name.to_s.ljust(pad, " ")} #{command.description}" if command.description
112
+ end
113
+
114
+ separator ""
115
+ separator "Options:"
116
+ end
117
+
118
+ def run(argv)
119
+ command, *args = order(argv)
120
+
121
+ if @commands[command]
122
+ load_environment! if @commands[command].load_environment
123
+ @commands[command].run(args)
124
+ else
125
+ ui.info help
126
+
127
+ if command
128
+ ui.error "Unknown command: #{command}"
129
+ exit 1
130
+ end
131
+ end
132
+ rescue OptionParser::InvalidOption => e
133
+ ui.error e.message
134
+ exit 1
135
+ end
136
+
137
+ # Helper method to define a new command
138
+ def command(name, &block)
139
+ @commands[name] = Command.new(program_name: "#{program_name} #{name}")
140
+ block.call(@commands[name])
141
+ end
142
+
143
+ def load_environment!
144
+ ENV["FLIPPER_CLOUD_LOGGING_ENABLED"] ||= "false"
145
+ require File.expand_path(@require)
146
+ # Ensure all of flipper gets loaded if it hasn't already.
147
+ require 'flipper'
148
+ rescue LoadError => e
149
+ ui.error e.message
150
+ exit 1
151
+ end
152
+
153
+ def feature_summary(features)
154
+ features = Array(features)
155
+ padding = features.map { |f| f.key.to_s.length }.max
156
+
157
+ features.map do |feature|
158
+ summary = case feature.state
159
+ when :on
160
+ colorize("⏺ enabled", [:GREEN])
161
+ when :off
162
+ "⦸ disabled"
163
+ else
164
+ "#{colorize("◯ enabled", [:YELLOW])} for " + feature.enabled_gates.map do |gate|
165
+ case gate.name
166
+ when :actor
167
+ pluralize feature.actors_value.size, 'actor', 'actors'
168
+ when :group
169
+ pluralize feature.groups_value.size, 'group', 'groups'
170
+ when :percentage_of_actors
171
+ "#{feature.percentage_of_actors_value}% of actors"
172
+ when :percentage_of_time
173
+ "#{feature.percentage_of_time_value}% of time"
174
+ when :expression
175
+ "an expression"
176
+ end
177
+ end.join(', ')
178
+ end
179
+
180
+ colorize("%-#{padding}s" % feature.key, [:BOLD, :WHITE]) + " is #{summary}"
181
+ end.join("\n")
182
+ end
183
+
184
+ def feature_details(feature)
185
+ summary = case feature.state
186
+ when :on
187
+ colorize("⏺ enabled", [:GREEN])
188
+ when :off
189
+ "⦸ disabled"
190
+ else
191
+ lines = feature.enabled_gates.map do |gate|
192
+ case gate.name
193
+ when :actor
194
+ [ pluralize(feature.actors_value.size, 'actor', 'actors') ] +
195
+ feature.actors_value.map { |actor| "- #{actor}" }
196
+ when :group
197
+ [ pluralize(feature.groups_value.size, 'group', 'groups') ] +
198
+ feature.groups_value.map { |group| " - #{group}" }
199
+ when :percentage_of_actors
200
+ "#{feature.percentage_of_actors_value}% of actors"
201
+ when :percentage_of_time
202
+ "#{feature.percentage_of_time_value}% of time"
203
+ when :expression
204
+ json = indent(JSON.pretty_generate(feature.expression_value), 2)
205
+ "the expression: \n#{colorize(json, [:MAGENTA])}"
206
+ end
207
+ end
208
+
209
+ "#{colorize("◯ conditionally enabled", [:YELLOW])} for:\n" +
210
+ indent(lines.flatten.join("\n"), 2)
211
+ end
212
+
213
+ "#{colorize(feature.key, [:BOLD, :WHITE])} is #{summary}"
214
+ end
215
+
216
+ def pluralize(count, singular, plural)
217
+ "#{count} #{count == 1 ? singular : plural}"
218
+ end
219
+
220
+ def colorize(text, colors)
221
+ ui.add_color(text, *colors)
222
+ end
223
+
224
+ def ui
225
+ @ui ||= Bundler::UI::Shell.new.tap do |ui|
226
+ ui.shell = shell
227
+ end
228
+ end
229
+
230
+ def indent(text, spaces)
231
+ text.gsub(/^/, " " * spaces)
232
+ end
233
+
234
+ # Redirect the shell's output to the given stdout and stderr streams
235
+ module ShellOutput
236
+ attr_reader :stdout, :stderr
237
+
238
+ def redirect(stdout: $stdout, stderr: $stderr)
239
+ @stdout, @stderr = stdout, stderr
240
+ end
241
+ end
242
+
243
+ class Command < OptionParser
244
+ attr_accessor :description, :load_environment
245
+
246
+ def initialize(program_name: nil)
247
+ super()
248
+ @program_name = program_name
249
+ @load_environment = true
250
+ @action = lambda { }
251
+ end
252
+
253
+ def run(argv)
254
+ # Parse argv and call action with arguments
255
+ @action.call(*permute(argv))
256
+ end
257
+
258
+ def action(&block)
259
+ @action = block
260
+ end
261
+ end
262
+ end
263
+ end
@@ -1,12 +1,13 @@
1
+ require "logger"
1
2
  require "socket"
2
3
  require "flipper/adapters/http"
3
4
  require "flipper/adapters/poll"
4
5
  require "flipper/poller"
5
- require "flipper/adapters/memory"
6
6
  require "flipper/adapters/dual_write"
7
7
  require "flipper/adapters/sync/synchronizer"
8
- require "flipper/cloud/instrumenter"
9
- require "brow"
8
+ require "flipper/cloud/telemetry"
9
+ require "flipper/cloud/telemetry/instrumenter"
10
+ require "flipper/cloud/telemetry/submitter"
10
11
 
11
12
  module Flipper
12
13
  module Cloud
@@ -19,18 +20,13 @@ module Flipper
19
20
 
20
21
  DEFAULT_URL = "https://www.flippercloud.io/adapter".freeze
21
22
 
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
23
  # Public: The token corresponding to an environment on flippercloud.io.
29
24
  attr_accessor :token
30
25
 
31
26
  # 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
27
+ # development work if you are me and you are not me. Feel free to
28
+ # forget you ever saw this.
29
+ attr_accessor :url
34
30
 
35
31
  # Public: net/http read timeout for all http requests (default: 5).
36
32
  attr_accessor :read_timeout
@@ -73,32 +69,25 @@ module Flipper
73
69
  # occur or not.
74
70
  attr_accessor :sync_secret
75
71
 
76
- def initialize(options = {})
77
- @token = options.fetch(:token) { ENV["FLIPPER_CLOUD_TOKEN"] }
72
+ # Public: The logger to use for debugging inner workings.
73
+ attr_accessor :logger
78
74
 
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
75
+ # Public: Should the logger log or not (default: true).
76
+ attr_accessor :logging_enabled
82
77
 
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) }
78
+ # Public: The telemetry instance to use for tracking feature usage.
79
+ attr_accessor :telemetry
92
80
 
93
- instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
81
+ # Public: Should telemetry be enabled or not (default: false).
82
+ attr_accessor :telemetry_enabled
94
83
 
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
84
+ def initialize(options = {})
85
+ setup_auth options
86
+ setup_log options
87
+ setup_http options
88
+ setup_sync options
89
+ setup_adapter options
90
+ setup_telemetry options
102
91
  end
103
92
 
104
93
  # Public: Read or customize the http adapter. Calling without a block will
@@ -120,38 +109,35 @@ module Flipper
120
109
  end
121
110
  end
122
111
 
123
- # Public: Set url for the http adapter.
124
- attr_writer :url
125
-
112
+ # Public: Force a sync.
126
113
  def sync
127
114
  Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
128
115
  instrumenter: instrumenter,
129
116
  }).call
130
117
  end
131
118
 
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
119
  # Public: The method that will be used to synchronize local adapter with
150
120
  # cloud. (default: :poll, will be :webhook if sync_secret is set).
151
121
  def sync_method
152
122
  sync_secret ? :webhook : :poll
153
123
  end
154
124
 
125
+ # Internal: The http client used by the http adapter. Exposed so we can
126
+ # use the same client for posting telemetry.
127
+ def http_client
128
+ http_adapter.client
129
+ end
130
+
131
+ # Internal: Logs message if logging is enabled.
132
+ def log(message, level: :debug)
133
+ return unless logging_enabled
134
+ logger.send(level, "name=flipper_cloud #{message}")
135
+ end
136
+
137
+ def instrument(name, payload = {}, &block)
138
+ instrumenter.instrument(name, payload, &block)
139
+ end
140
+
155
141
  private
156
142
 
157
143
  def app_adapter
@@ -180,10 +166,101 @@ module Flipper
180
166
  max_retries: 0, # we'll handle retries ourselves
181
167
  debug_output: @debug_output,
182
168
  headers: {
183
- "Flipper-Cloud-Token" => @token,
169
+ "flipper-cloud-token" => @token,
170
+ "accept-encoding" => "gzip",
184
171
  },
185
172
  })
186
173
  end
174
+
175
+ def setup_auth(options)
176
+ set_option :token, options, required: true
177
+ end
178
+
179
+ def setup_log(options)
180
+ set_option :logging_enabled, options, default: false, typecast: :boolean
181
+ set_option :logger, options, from_env: false, default: -> {
182
+ if logging_enabled
183
+ Logger.new(STDOUT)
184
+ else
185
+ Logger.new("/dev/null")
186
+ end
187
+ }
188
+ end
189
+
190
+ def setup_http(options)
191
+ set_option :url, options, default: DEFAULT_URL
192
+ set_option :debug_output, options, from_env: false
193
+
194
+ if @debug_output.nil? && Flipper::Typecast.to_boolean(ENV["FLIPPER_CLOUD_DEBUG_OUTPUT_STDOUT"])
195
+ @debug_output = STDOUT
196
+ end
197
+
198
+ set_option :read_timeout, options, default: 5, typecast: :float, minimum: 0.1
199
+ set_option :open_timeout, options, default: 2, typecast: :float, minimum: 0.1
200
+ set_option :write_timeout, options, default: 5, typecast: :float, minimum: 0.1
201
+ end
202
+
203
+ def setup_sync(options)
204
+ set_option :sync_interval, options, default: 10, typecast: :float, minimum: 10
205
+ set_option :sync_secret, options
206
+ end
207
+
208
+ def setup_adapter(options)
209
+ set_option :local_adapter, options, default: -> { Adapters::Memory.new }, from_env: false
210
+ @adapter_block = ->(adapter) { adapter }
211
+ end
212
+
213
+ def setup_telemetry(options)
214
+ # Needs to be after url and token assignments because they are used for
215
+ # uniqueness in Telemetry.instance_for.
216
+ set_option :telemetry, options, from_env: false, default: -> {
217
+ Telemetry.instance_for(self)
218
+ }
219
+
220
+ set_option :telemetry_enabled, options, default: true, typecast: :boolean
221
+ instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
222
+ @instrumenter = if telemetry_enabled
223
+ Telemetry::Instrumenter.new(self, instrumenter)
224
+ else
225
+ instrumenter
226
+ end
227
+ end
228
+
229
+ # Internal: Super helper for defining an option that can be set via
230
+ # options hash or ENV with defaults, typecasting and minimums.
231
+ def set_option(name, options, default: nil, typecast: nil, minimum: nil, from_env: true, required: false)
232
+ env_var = "FLIPPER_CLOUD_#{name.to_s.upcase}"
233
+ value = options.fetch(name) {
234
+ default_value = default.respond_to?(:call) ? default.call : default
235
+ if from_env
236
+ ENV.fetch(env_var, default_value)
237
+ else
238
+ default_value
239
+ end
240
+ }
241
+ value = Flipper::Typecast.send("to_#{typecast}", value) if typecast
242
+ send("#{name}=", value)
243
+ enforce_minimum(name, minimum) if minimum
244
+
245
+ if required
246
+ option_value = send(name)
247
+ if option_value.nil? || option_value.empty?
248
+ message = String.new("Flipper::Cloud #{name} is missing. Please ")
249
+ message << "set #{env_var} or " if from_env
250
+ message << "provide #{name} (e.g. Flipper::Cloud.new(#{name}: value))."
251
+ raise ArgumentError, message
252
+ end
253
+ end
254
+ end
255
+
256
+ # Enforce minimum interval for tasks that run on a timer.
257
+ def enforce_minimum(name, minimum)
258
+ provided = send(name)
259
+ if provided && provided < minimum
260
+ warn "Flipper::Cloud##{name} must be at least #{minimum} seconds but was #{provided}. Using #{minimum} seconds."
261
+ send(:instance_variable_set, "@#{name}", minimum)
262
+ end
263
+ end
187
264
  end
188
265
  end
189
266
  end
@@ -24,7 +24,7 @@ module Flipper
24
24
  if request.post? && (request.path_info.match(ROOT_PATH) || request.path_info.match(WEBHOOK_PATH))
25
25
  status = 200
26
26
  headers = {
27
- "content-type" => "application/json",
27
+ Rack::CONTENT_TYPE => "application/json",
28
28
  }
29
29
  body = "{}"
30
30
  payload = request.body.read
@@ -41,12 +41,12 @@ module Flipper
41
41
  })
42
42
  rescue Flipper::Adapters::Http::Error => error
43
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
44
+ headers["flipper-cloud-response-error-class"] = error.class.name
45
+ headers["flipper-cloud-response-error-message"] = error.message
46
46
  rescue => error
47
47
  status = 500
48
- headers["Flipper-Cloud-Response-Error-Class"] = error.class.name
49
- headers["Flipper-Cloud-Response-Error-Message"] = error.message
48
+ headers["flipper-cloud-response-error-class"] = error.class.name
49
+ headers["flipper-cloud-response-error-message"] = error.message
50
50
  end
51
51
  end
52
52
  rescue MessageVerifier::InvalidSignature
@@ -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