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.
- checksums.yaml +4 -4
- data/.github/FUNDING.yml +1 -0
- data/.github/workflows/ci.yml +50 -7
- data/.github/workflows/examples.yml +50 -8
- data/CLAUDE.md +74 -0
- data/Changelog.md +1 -584
- data/Gemfile +15 -8
- data/README.md +31 -27
- data/Rakefile +2 -2
- data/benchmark/typecast_ips.rb +8 -0
- data/docs/images/banner.jpg +0 -0
- data/docs/images/flipper_cloud.png +0 -0
- data/examples/cloud/backoff_policy.rb +13 -0
- data/examples/cloud/cloud_setup.rb +16 -0
- data/examples/cloud/forked.rb +7 -2
- data/examples/cloud/threaded.rb +15 -18
- data/examples/expressions.rb +213 -0
- data/examples/strict.rb +18 -0
- data/exe/flipper +5 -0
- data/flipper.gemspec +6 -3
- data/lib/flipper/actor.rb +6 -3
- data/lib/flipper/adapter.rb +10 -0
- data/lib/flipper/adapter_builder.rb +44 -0
- data/lib/flipper/adapters/actor_limit.rb +28 -0
- data/lib/flipper/adapters/cache_base.rb +143 -0
- data/lib/flipper/adapters/dual_write.rb +1 -3
- data/lib/flipper/adapters/failover.rb +0 -4
- data/lib/flipper/adapters/failsafe.rb +0 -4
- data/lib/flipper/adapters/http/client.rb +40 -12
- data/lib/flipper/adapters/http/error.rb +2 -2
- data/lib/flipper/adapters/http.rb +19 -14
- data/lib/flipper/adapters/instrumented.rb +0 -4
- data/lib/flipper/adapters/memoizable.rb +14 -19
- data/lib/flipper/adapters/memory.rb +4 -6
- data/lib/flipper/adapters/operation_logger.rb +18 -92
- data/lib/flipper/adapters/poll.rb +16 -3
- data/lib/flipper/adapters/pstore.rb +17 -11
- data/lib/flipper/adapters/read_only.rb +8 -41
- data/lib/flipper/adapters/strict.rb +45 -0
- data/lib/flipper/adapters/sync/feature_synchronizer.rb +10 -1
- data/lib/flipper/adapters/sync.rb +0 -4
- data/lib/flipper/adapters/wrapper.rb +54 -0
- data/lib/flipper/cli.rb +263 -0
- data/lib/flipper/cloud/configuration.rb +131 -54
- data/lib/flipper/cloud/middleware.rb +5 -5
- data/lib/flipper/cloud/telemetry/backoff_policy.rb +96 -0
- data/lib/flipper/cloud/telemetry/instrumenter.rb +22 -0
- data/lib/flipper/cloud/telemetry/metric.rb +39 -0
- data/lib/flipper/cloud/telemetry/metric_storage.rb +30 -0
- data/lib/flipper/cloud/telemetry/submitter.rb +100 -0
- data/lib/flipper/cloud/telemetry.rb +191 -0
- data/lib/flipper/cloud.rb +1 -1
- data/lib/flipper/configuration.rb +25 -4
- data/lib/flipper/dsl.rb +51 -0
- data/lib/flipper/engine.rb +42 -3
- data/lib/flipper/export.rb +0 -2
- data/lib/flipper/exporters/json/export.rb +1 -1
- data/lib/flipper/exporters/json/v1.rb +1 -1
- data/lib/flipper/expression/builder.rb +73 -0
- data/lib/flipper/expression/constant.rb +25 -0
- data/lib/flipper/expression.rb +71 -0
- data/lib/flipper/expressions/all.rb +9 -0
- data/lib/flipper/expressions/any.rb +9 -0
- data/lib/flipper/expressions/boolean.rb +9 -0
- data/lib/flipper/expressions/comparable.rb +13 -0
- data/lib/flipper/expressions/duration.rb +28 -0
- data/lib/flipper/expressions/equal.rb +9 -0
- data/lib/flipper/expressions/greater_than.rb +9 -0
- data/lib/flipper/expressions/greater_than_or_equal_to.rb +9 -0
- data/lib/flipper/expressions/less_than.rb +9 -0
- data/lib/flipper/expressions/less_than_or_equal_to.rb +9 -0
- data/lib/flipper/expressions/not_equal.rb +9 -0
- data/lib/flipper/expressions/now.rb +9 -0
- data/lib/flipper/expressions/number.rb +9 -0
- data/lib/flipper/expressions/percentage.rb +9 -0
- data/lib/flipper/expressions/percentage_of_actors.rb +12 -0
- data/lib/flipper/expressions/property.rb +9 -0
- data/lib/flipper/expressions/random.rb +9 -0
- data/lib/flipper/expressions/string.rb +9 -0
- data/lib/flipper/expressions/time.rb +9 -0
- data/lib/flipper/feature.rb +63 -1
- data/lib/flipper/gate.rb +2 -1
- data/lib/flipper/gate_values.rb +5 -2
- data/lib/flipper/gates/expression.rb +75 -0
- data/lib/flipper/instrumentation/log_subscriber.rb +13 -5
- data/lib/flipper/instrumentation/statsd.rb +4 -2
- data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
- data/lib/flipper/instrumentation/subscriber.rb +0 -4
- data/lib/flipper/metadata.rb +4 -1
- data/lib/flipper/middleware/memoizer.rb +29 -13
- data/lib/flipper/model/active_record.rb +23 -0
- data/lib/flipper/poller.rb +9 -8
- data/lib/flipper/serializers/gzip.rb +22 -0
- data/lib/flipper/serializers/json.rb +17 -0
- data/lib/flipper/spec/shared_adapter_specs.rb +46 -27
- data/lib/flipper/test/shared_adapter_test.rb +41 -22
- data/lib/flipper/test_help.rb +43 -0
- data/lib/flipper/typecast.rb +37 -9
- data/lib/flipper/types/percentage.rb +1 -1
- data/lib/flipper/version.rb +11 -1
- data/lib/flipper.rb +41 -2
- data/lib/generators/flipper/setup_generator.rb +68 -0
- data/lib/generators/flipper/templates/initializer.rb +45 -0
- data/lib/generators/flipper/templates/update/migrations/01_create_flipper_tables.rb.erb +22 -0
- data/lib/generators/flipper/templates/update/migrations/02_change_flipper_gates_value_to_text.rb.erb +18 -0
- data/lib/generators/flipper/update_generator.rb +35 -0
- data/package-lock.json +41 -0
- data/package.json +10 -0
- data/spec/fixtures/environment.rb +1 -0
- data/spec/flipper/adapter_builder_spec.rb +72 -0
- data/spec/flipper/adapter_spec.rb +1 -0
- data/spec/flipper/adapters/actor_limit_spec.rb +20 -0
- data/spec/flipper/adapters/http/client_spec.rb +61 -0
- data/spec/flipper/adapters/http_spec.rb +135 -74
- data/spec/flipper/adapters/memoizable_spec.rb +15 -15
- data/spec/flipper/adapters/poll_spec.rb +41 -0
- data/spec/flipper/adapters/read_only_spec.rb +26 -11
- data/spec/flipper/adapters/strict_spec.rb +64 -0
- data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +27 -0
- data/spec/flipper/cli_spec.rb +166 -0
- data/spec/flipper/cloud/configuration_spec.rb +39 -57
- data/spec/flipper/cloud/dsl_spec.rb +6 -6
- data/spec/flipper/cloud/middleware_spec.rb +8 -8
- data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +107 -0
- data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
- data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
- data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
- data/spec/flipper/cloud/telemetry_spec.rb +208 -0
- data/spec/flipper/cloud_spec.rb +31 -25
- data/spec/flipper/configuration_spec.rb +17 -0
- data/spec/flipper/dsl_spec.rb +39 -3
- data/spec/flipper/engine_spec.rb +226 -42
- data/spec/flipper/exporters/json/v1_spec.rb +3 -3
- data/spec/flipper/expression/builder_spec.rb +248 -0
- data/spec/flipper/expression_spec.rb +188 -0
- data/spec/flipper/expressions/all_spec.rb +15 -0
- data/spec/flipper/expressions/any_spec.rb +15 -0
- data/spec/flipper/expressions/boolean_spec.rb +15 -0
- data/spec/flipper/expressions/duration_spec.rb +43 -0
- data/spec/flipper/expressions/equal_spec.rb +24 -0
- data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
- data/spec/flipper/expressions/greater_than_spec.rb +28 -0
- data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
- data/spec/flipper/expressions/less_than_spec.rb +32 -0
- data/spec/flipper/expressions/not_equal_spec.rb +15 -0
- data/spec/flipper/expressions/now_spec.rb +11 -0
- data/spec/flipper/expressions/number_spec.rb +21 -0
- data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
- data/spec/flipper/expressions/percentage_spec.rb +15 -0
- data/spec/flipper/expressions/property_spec.rb +13 -0
- data/spec/flipper/expressions/random_spec.rb +9 -0
- data/spec/flipper/expressions/string_spec.rb +11 -0
- data/spec/flipper/expressions/time_spec.rb +13 -0
- data/spec/flipper/feature_spec.rb +380 -10
- data/spec/flipper/gate_values_spec.rb +2 -2
- data/spec/flipper/gates/expression_spec.rb +108 -0
- data/spec/flipper/identifier_spec.rb +4 -5
- data/spec/flipper/instrumentation/log_subscriber_spec.rb +10 -2
- data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +16 -2
- data/spec/flipper/middleware/memoizer_spec.rb +79 -10
- data/spec/flipper/model/active_record_spec.rb +72 -0
- data/spec/flipper/serializers/gzip_spec.rb +13 -0
- data/spec/flipper/serializers/json_spec.rb +13 -0
- data/spec/flipper/typecast_spec.rb +43 -7
- data/spec/flipper/types/actor_spec.rb +18 -1
- data/spec/flipper_integration_spec.rb +102 -4
- data/spec/flipper_spec.rb +91 -3
- data/spec/spec_helper.rb +17 -5
- data/spec/support/actor_names.yml +1 -0
- data/spec/support/fail_on_output.rb +8 -0
- data/spec/support/fake_backoff_policy.rb +15 -0
- data/spec/support/spec_helpers.rb +34 -8
- data/test/adapters/actor_limit_test.rb +20 -0
- data/test_rails/generators/flipper/setup_generator_test.rb +69 -0
- data/test_rails/generators/flipper/update_generator_test.rb +96 -0
- data/test_rails/helper.rb +22 -2
- data/test_rails/system/test_help_test.rb +52 -0
- metadata +145 -29
- data/lib/flipper/cloud/instrumenter.rb +0 -48
- data/spec/support/climate_control.rb +0 -7
data/lib/flipper/cli.rb
ADDED
|
@@ -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/
|
|
9
|
-
require "
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
77
|
-
|
|
72
|
+
# Public: The logger to use for debugging inner workings.
|
|
73
|
+
attr_accessor :logger
|
|
78
74
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
end
|
|
75
|
+
# Public: Should the logger log or not (default: true).
|
|
76
|
+
attr_accessor :logging_enabled
|
|
82
77
|
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
81
|
+
# Public: Should telemetry be enabled or not (default: false).
|
|
82
|
+
attr_accessor :telemetry_enabled
|
|
94
83
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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:
|
|
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
|
-
"
|
|
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
|
-
|
|
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["
|
|
45
|
-
headers["
|
|
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["
|
|
49
|
-
headers["
|
|
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
|