flipper 0.26.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/FUNDING.yml +1 -0
- data/.github/workflows/ci.yml +19 -13
- data/.github/workflows/examples.yml +32 -15
- data/Changelog.md +294 -154
- data/Gemfile +15 -10
- data/README.md +13 -11
- data/benchmark/enabled_ips.rb +10 -0
- data/benchmark/enabled_multiple_actors_ips.rb +20 -0
- data/benchmark/enabled_profile.rb +20 -0
- data/benchmark/instrumentation_ips.rb +21 -0
- data/benchmark/typecast_ips.rb +27 -0
- data/docs/images/flipper_cloud.png +0 -0
- data/examples/api/basic.ru +3 -4
- data/examples/api/custom_memoized.ru +3 -4
- data/examples/api/memoized.ru +3 -4
- data/examples/cloud/app.ru +12 -0
- data/examples/cloud/backoff_policy.rb +13 -0
- data/examples/cloud/basic.rb +22 -0
- data/examples/cloud/cloud_setup.rb +20 -0
- data/examples/cloud/forked.rb +36 -0
- data/examples/cloud/import.rb +17 -0
- data/examples/cloud/threaded.rb +33 -0
- data/examples/dsl.rb +1 -15
- data/examples/enabled_for_actor.rb +4 -2
- data/examples/expressions.rb +213 -0
- data/examples/mirroring.rb +59 -0
- data/examples/strict.rb +18 -0
- data/flipper-cloud.gemspec +19 -0
- data/flipper.gemspec +3 -5
- data/lib/flipper/actor.rb +6 -3
- data/lib/flipper/adapter.rb +33 -7
- data/lib/flipper/adapter_builder.rb +44 -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 +26 -7
- data/lib/flipper/adapters/http/error.rb +1 -1
- data/lib/flipper/adapters/http.rb +29 -16
- data/lib/flipper/adapters/instrumented.rb +25 -6
- data/lib/flipper/adapters/memoizable.rb +33 -21
- data/lib/flipper/adapters/memory.rb +81 -46
- data/lib/flipper/adapters/operation_logger.rb +16 -7
- data/lib/flipper/adapters/poll/poller.rb +2 -125
- data/lib/flipper/adapters/poll.rb +5 -3
- data/lib/flipper/adapters/pstore.rb +17 -11
- data/lib/flipper/adapters/read_only.rb +4 -4
- data/lib/flipper/adapters/strict.rb +47 -0
- data/lib/flipper/adapters/sync/feature_synchronizer.rb +10 -1
- data/lib/flipper/adapters/sync.rb +0 -4
- data/lib/flipper/cloud/configuration.rb +258 -0
- data/lib/flipper/cloud/dsl.rb +27 -0
- data/lib/flipper/cloud/message_verifier.rb +95 -0
- data/lib/flipper/cloud/middleware.rb +63 -0
- data/lib/flipper/cloud/routes.rb +14 -0
- data/lib/flipper/cloud/telemetry/backoff_policy.rb +93 -0
- data/lib/flipper/cloud/telemetry/instrumenter.rb +26 -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 +98 -0
- data/lib/flipper/cloud/telemetry.rb +183 -0
- data/lib/flipper/cloud.rb +53 -0
- data/lib/flipper/configuration.rb +25 -4
- data/lib/flipper/dsl.rb +46 -45
- data/lib/flipper/engine.rb +88 -0
- data/lib/flipper/errors.rb +3 -3
- data/lib/flipper/export.rb +26 -0
- data/lib/flipper/exporter.rb +17 -0
- data/lib/flipper/exporters/json/export.rb +32 -0
- data/lib/flipper/exporters/json/v1.rb +33 -0
- 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 +11 -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 +87 -26
- data/lib/flipper/feature_check_context.rb +10 -6
- data/lib/flipper/gate.rb +13 -11
- data/lib/flipper/gate_values.rb +5 -18
- data/lib/flipper/gates/actor.rb +10 -17
- data/lib/flipper/gates/boolean.rb +1 -1
- data/lib/flipper/gates/expression.rb +75 -0
- data/lib/flipper/gates/group.rb +5 -7
- data/lib/flipper/gates/percentage_of_actors.rb +10 -13
- data/lib/flipper/gates/percentage_of_time.rb +1 -2
- data/lib/flipper/identifier.rb +2 -2
- data/lib/flipper/instrumentation/log_subscriber.rb +24 -5
- data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
- data/lib/flipper/instrumentation/subscriber.rb +8 -1
- data/lib/flipper/metadata.rb +5 -1
- data/lib/flipper/middleware/memoizer.rb +30 -14
- data/lib/flipper/poller.rb +117 -0
- data/lib/flipper/serializers/gzip.rb +24 -0
- data/lib/flipper/serializers/json.rb +19 -0
- data/lib/flipper/spec/shared_adapter_specs.rb +95 -54
- data/lib/flipper/test/shared_adapter_test.rb +91 -48
- data/lib/flipper/typecast.rb +56 -15
- data/lib/flipper/types/actor.rb +13 -13
- data/lib/flipper/types/group.rb +4 -4
- data/lib/flipper/types/percentage.rb +1 -1
- data/lib/flipper/version.rb +1 -1
- data/lib/flipper.rb +47 -10
- data/spec/fixtures/flipper_pstore_1679087600.json +46 -0
- data/spec/flipper/adapter_builder_spec.rb +73 -0
- data/spec/flipper/adapter_spec.rb +30 -2
- data/spec/flipper/adapters/dual_write_spec.rb +2 -2
- data/spec/flipper/adapters/http_spec.rb +64 -8
- data/spec/flipper/adapters/instrumented_spec.rb +29 -11
- data/spec/flipper/adapters/memoizable_spec.rb +51 -31
- data/spec/flipper/adapters/memory_spec.rb +14 -3
- data/spec/flipper/adapters/operation_logger_spec.rb +31 -12
- data/spec/flipper/adapters/read_only_spec.rb +32 -17
- data/spec/flipper/adapters/strict_spec.rb +62 -0
- data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +27 -0
- data/spec/flipper/cloud/configuration_spec.rb +252 -0
- data/spec/flipper/cloud/dsl_spec.rb +82 -0
- data/spec/flipper/cloud/message_verifier_spec.rb +104 -0
- data/spec/flipper/cloud/middleware_spec.rb +289 -0
- data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +108 -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 +156 -0
- data/spec/flipper/cloud_spec.rb +180 -0
- data/spec/flipper/configuration_spec.rb +17 -0
- data/spec/flipper/dsl_spec.rb +54 -73
- data/spec/flipper/engine_spec.rb +291 -0
- data/spec/flipper/export_spec.rb +13 -0
- data/spec/flipper/exporter_spec.rb +16 -0
- data/spec/flipper/exporters/json/export_spec.rb +60 -0
- data/spec/flipper/exporters/json/v1_spec.rb +33 -0
- 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_check_context_spec.rb +17 -17
- data/spec/flipper/feature_spec.rb +436 -33
- data/spec/flipper/gate_values_spec.rb +2 -33
- data/spec/flipper/gates/boolean_spec.rb +1 -1
- data/spec/flipper/gates/expression_spec.rb +108 -0
- data/spec/flipper/gates/group_spec.rb +2 -3
- data/spec/flipper/gates/percentage_of_actors_spec.rb +61 -5
- data/spec/flipper/gates/percentage_of_time_spec.rb +2 -2
- data/spec/flipper/identifier_spec.rb +4 -5
- data/spec/flipper/instrumentation/log_subscriber_spec.rb +15 -5
- data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +25 -1
- data/spec/flipper/middleware/memoizer_spec.rb +67 -0
- data/spec/flipper/poller_spec.rb +47 -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 +121 -6
- data/spec/flipper/types/actor_spec.rb +63 -46
- data/spec/flipper/types/group_spec.rb +2 -2
- data/spec/flipper_integration_spec.rb +168 -58
- data/spec/flipper_spec.rb +92 -28
- data/spec/spec_helper.rb +6 -13
- data/spec/support/actor_names.yml +1 -0
- data/spec/support/climate_control.rb +7 -0
- data/spec/support/fake_backoff_policy.rb +15 -0
- data/spec/support/skippable.rb +18 -0
- data/spec/support/spec_helpers.rb +11 -3
- metadata +166 -13
- data/.github/workflows/release.yml +0 -44
- data/.tool-versions +0 -1
- data/lib/flipper/railtie.rb +0 -47
- data/spec/flipper/railtie_spec.rb +0 -109
@@ -0,0 +1,14 @@
|
|
1
|
+
# Default routes loaded by Flipper::Cloud::Engine
|
2
|
+
Rails.application.routes.draw do
|
3
|
+
if ENV["FLIPPER_CLOUD_TOKEN"] && ENV["FLIPPER_CLOUD_SYNC_SECRET"]
|
4
|
+
require 'flipper/cloud'
|
5
|
+
config = Rails.application.config.flipper
|
6
|
+
|
7
|
+
cloud_app = Flipper::Cloud.app(nil,
|
8
|
+
env_key: config.env_key,
|
9
|
+
memoizer_options: { preload: config.preload }
|
10
|
+
)
|
11
|
+
|
12
|
+
mount cloud_app, at: config.cloud_path
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module Flipper
|
2
|
+
module Cloud
|
3
|
+
class Telemetry
|
4
|
+
class BackoffPolicy
|
5
|
+
# Private: The default minimum timeout between intervals in milliseconds.
|
6
|
+
MIN_TIMEOUT_MS = 1_000
|
7
|
+
|
8
|
+
# Private: The default maximum timeout between intervals in milliseconds.
|
9
|
+
MAX_TIMEOUT_MS = 30_000
|
10
|
+
|
11
|
+
# Private: The value to multiply the current interval with for each
|
12
|
+
# retry attempt.
|
13
|
+
MULTIPLIER = 1.5
|
14
|
+
|
15
|
+
# Private: The randomization factor to use to create a range around the
|
16
|
+
# retry interval.
|
17
|
+
RANDOMIZATION_FACTOR = 0.5
|
18
|
+
|
19
|
+
# Private
|
20
|
+
attr_reader :min_timeout_ms, :max_timeout_ms, :multiplier, :randomization_factor
|
21
|
+
|
22
|
+
# Private
|
23
|
+
attr_reader :attempts
|
24
|
+
|
25
|
+
# Public: Create new instance of backoff policy.
|
26
|
+
#
|
27
|
+
# options - The Hash of options.
|
28
|
+
# :min_timeout_ms - The minimum backoff timeout.
|
29
|
+
# :max_timeout_ms - The maximum backoff timeout.
|
30
|
+
# :multiplier - The value to multiply the current interval with for each
|
31
|
+
# retry attempt.
|
32
|
+
# :randomization_factor - The randomization factor to use to create a range
|
33
|
+
# around the retry interval.
|
34
|
+
def initialize(options = {})
|
35
|
+
@min_timeout_ms = options.fetch(:min_timeout_ms) {
|
36
|
+
ENV.fetch("FLIPPER_BACKOFF_MIN_TIMEOUT_MS", MIN_TIMEOUT_MS).to_i
|
37
|
+
}
|
38
|
+
@max_timeout_ms = options.fetch(:max_timeout_ms) {
|
39
|
+
ENV.fetch("FLIPPER_BACKOFF_MAX_TIMEOUT_MS", MAX_TIMEOUT_MS).to_i
|
40
|
+
}
|
41
|
+
@multiplier = options.fetch(:multiplier) {
|
42
|
+
ENV.fetch("FLIPPER_BACKOFF_MULTIPLIER", MULTIPLIER).to_f
|
43
|
+
}
|
44
|
+
@randomization_factor = options.fetch(:randomization_factor) {
|
45
|
+
ENV.fetch("FLIPPER_BACKOFF_RANDOMIZATION_FACTOR", RANDOMIZATION_FACTOR).to_f
|
46
|
+
}
|
47
|
+
|
48
|
+
unless @min_timeout_ms >= 0
|
49
|
+
raise ArgumentError, ":min_timeout_ms must be >= 0 but was #{@min_timeout_ms.inspect}"
|
50
|
+
end
|
51
|
+
|
52
|
+
unless @max_timeout_ms >= 0
|
53
|
+
raise ArgumentError, ":max_timeout_ms must be >= 0 but was #{@max_timeout_ms.inspect}"
|
54
|
+
end
|
55
|
+
|
56
|
+
unless @min_timeout_ms <= max_timeout_ms
|
57
|
+
raise ArgumentError, ":min_timeout_ms (#{@min_timeout_ms.inspect}) must be <= :max_timeout_ms (#{@max_timeout_ms.inspect})"
|
58
|
+
end
|
59
|
+
|
60
|
+
@attempts = 0
|
61
|
+
end
|
62
|
+
|
63
|
+
# Public: Returns the next backoff interval in milliseconds.
|
64
|
+
def next_interval
|
65
|
+
interval = @min_timeout_ms * (@multiplier**@attempts)
|
66
|
+
interval = add_jitter(interval, @randomization_factor)
|
67
|
+
|
68
|
+
@attempts += 1
|
69
|
+
|
70
|
+
[interval, @max_timeout_ms].min
|
71
|
+
end
|
72
|
+
|
73
|
+
def reset
|
74
|
+
@attempts = 0
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
def add_jitter(base, randomization_factor)
|
80
|
+
random_number = rand
|
81
|
+
max_deviation = base * randomization_factor
|
82
|
+
deviation = random_number * max_deviation
|
83
|
+
|
84
|
+
if random_number < 0.5
|
85
|
+
base - deviation
|
86
|
+
else
|
87
|
+
base + deviation
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require "delegate"
|
2
|
+
|
3
|
+
module Flipper
|
4
|
+
module Cloud
|
5
|
+
class Telemetry
|
6
|
+
class Instrumenter < SimpleDelegator
|
7
|
+
def initialize(cloud_configuration, instrumenter)
|
8
|
+
super instrumenter
|
9
|
+
@cloud_configuration = cloud_configuration
|
10
|
+
end
|
11
|
+
|
12
|
+
def instrument(name, payload = {}, &block)
|
13
|
+
return_value = instrumenter.instrument(name, payload, &block)
|
14
|
+
@cloud_configuration.telemetry.record(name, payload)
|
15
|
+
return_value
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def instrumenter
|
21
|
+
__getobj__
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Flipper
|
2
|
+
module Cloud
|
3
|
+
class Telemetry
|
4
|
+
class Metric
|
5
|
+
attr_reader :key, :time, :result
|
6
|
+
|
7
|
+
def initialize(key, result, time = Time.now)
|
8
|
+
@key = key
|
9
|
+
@result = result
|
10
|
+
@time = time.to_i / 60 * 60
|
11
|
+
end
|
12
|
+
|
13
|
+
def as_json(options = {})
|
14
|
+
data = {
|
15
|
+
"key" => key.to_s,
|
16
|
+
"time" => time,
|
17
|
+
"result" => result,
|
18
|
+
}
|
19
|
+
|
20
|
+
if options[:with]
|
21
|
+
data.merge!(options[:with])
|
22
|
+
end
|
23
|
+
|
24
|
+
data
|
25
|
+
end
|
26
|
+
|
27
|
+
def eql?(other)
|
28
|
+
self.class.eql?(other.class) &&
|
29
|
+
@key == other.key && @time == other.time && @result == other.result
|
30
|
+
end
|
31
|
+
alias :== :eql?
|
32
|
+
|
33
|
+
def hash
|
34
|
+
[self.class, @key, @time, @result].hash
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'concurrent/map'
|
2
|
+
require 'concurrent/atomic/atomic_fixnum'
|
3
|
+
|
4
|
+
module Flipper
|
5
|
+
module Cloud
|
6
|
+
class Telemetry
|
7
|
+
class MetricStorage
|
8
|
+
def initialize
|
9
|
+
@storage = Concurrent::Map.new { |h, k| h[k] = Concurrent::AtomicFixnum.new(0) }
|
10
|
+
end
|
11
|
+
|
12
|
+
def increment(metric)
|
13
|
+
@storage[metric].increment
|
14
|
+
end
|
15
|
+
|
16
|
+
def drain
|
17
|
+
metrics = {}
|
18
|
+
@storage.keys.each do |metric|
|
19
|
+
metrics[metric] = @storage.delete(metric).value
|
20
|
+
end
|
21
|
+
metrics.freeze
|
22
|
+
end
|
23
|
+
|
24
|
+
def empty?
|
25
|
+
@storage.empty?
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
require "securerandom"
|
2
|
+
require "flipper/typecast"
|
3
|
+
require "flipper/cloud/telemetry/backoff_policy"
|
4
|
+
|
5
|
+
module Flipper
|
6
|
+
module Cloud
|
7
|
+
class Telemetry
|
8
|
+
class Submitter
|
9
|
+
PATH = "/telemetry".freeze
|
10
|
+
SCHEMA_VERSION = "V1".freeze
|
11
|
+
GZIP_ENCODING = "gzip".freeze
|
12
|
+
|
13
|
+
Error = Class.new(StandardError) do
|
14
|
+
attr_reader :request_id, :response
|
15
|
+
|
16
|
+
def initialize(request_id, response)
|
17
|
+
@request_id = request_id
|
18
|
+
@response = response
|
19
|
+
super "Unexpected response code=#{response.code} request_id=#{request_id}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
attr_reader :cloud_configuration, :request_id, :backoff_policy
|
24
|
+
|
25
|
+
def initialize(cloud_configuration, backoff_policy: nil)
|
26
|
+
@cloud_configuration = cloud_configuration
|
27
|
+
@backoff_policy = backoff_policy || BackoffPolicy.new
|
28
|
+
@request_id = SecureRandom.uuid
|
29
|
+
end
|
30
|
+
|
31
|
+
# Returns Array of [response, error]. response and error could be nil
|
32
|
+
# but usually one or the other will be present.
|
33
|
+
def call(drained)
|
34
|
+
return if drained.empty?
|
35
|
+
body = to_body(drained)
|
36
|
+
return if body.nil? || body.empty?
|
37
|
+
retry_with_backoff(10) { submit(body) }
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def to_body(drained)
|
43
|
+
enabled_metrics = drained.map { |metric, value|
|
44
|
+
metric.as_json(with: {"value" => value})
|
45
|
+
}
|
46
|
+
|
47
|
+
json = Typecast.to_json({
|
48
|
+
request_id: request_id,
|
49
|
+
enabled_metrics: enabled_metrics,
|
50
|
+
})
|
51
|
+
|
52
|
+
Typecast.to_gzip(json)
|
53
|
+
rescue => exception
|
54
|
+
@cloud_configuration.log "action=to_body request_id=#{request_id} error=#{exception.inspect}", level: :error
|
55
|
+
end
|
56
|
+
|
57
|
+
def retry_with_backoff(attempts, &block)
|
58
|
+
result, caught_exception = nil
|
59
|
+
should_retry = false
|
60
|
+
attempts_remaining = attempts - 1
|
61
|
+
|
62
|
+
begin
|
63
|
+
result, should_retry = yield
|
64
|
+
return [result, nil] unless should_retry
|
65
|
+
rescue => error
|
66
|
+
@cloud_configuration.log "action=post_to_cloud attempts_remaining=#{attempts_remaining} error=#{error.inspect}", level: :error
|
67
|
+
should_retry = true
|
68
|
+
caught_exception = error
|
69
|
+
end
|
70
|
+
|
71
|
+
if should_retry && attempts_remaining > 0
|
72
|
+
sleep @backoff_policy.next_interval.to_f / 1000
|
73
|
+
retry_with_backoff attempts_remaining, &block
|
74
|
+
else
|
75
|
+
[result, caught_exception]
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def submit(body)
|
80
|
+
client = @cloud_configuration.http_client
|
81
|
+
client.add_header :schema_version, SCHEMA_VERSION
|
82
|
+
client.add_header :content_encoding, GZIP_ENCODING
|
83
|
+
|
84
|
+
response = client.post PATH, body
|
85
|
+
code = response.code.to_i
|
86
|
+
|
87
|
+
# Raise error and retry for retriable status codes.
|
88
|
+
# FIXME: what about redirects?
|
89
|
+
if code < 200 || code == 429 || code >= 500
|
90
|
+
raise Error.new(request_id, response)
|
91
|
+
end
|
92
|
+
|
93
|
+
response
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,183 @@
|
|
1
|
+
require "concurrent/map"
|
2
|
+
require "concurrent/timer_task"
|
3
|
+
require "concurrent/executor/fixed_thread_pool"
|
4
|
+
require "flipper/typecast"
|
5
|
+
require "flipper/cloud/telemetry/metric"
|
6
|
+
require "flipper/cloud/telemetry/metric_storage"
|
7
|
+
require "flipper/cloud/telemetry/submitter"
|
8
|
+
|
9
|
+
module Flipper
|
10
|
+
module Cloud
|
11
|
+
class Telemetry
|
12
|
+
# Internal: Map of instances of telemetry.
|
13
|
+
def self.instances
|
14
|
+
@instances ||= Concurrent::Map.new
|
15
|
+
end
|
16
|
+
private_class_method :instances
|
17
|
+
|
18
|
+
def self.reset
|
19
|
+
instances.each { |_, instance| instance.stop }.clear
|
20
|
+
end
|
21
|
+
|
22
|
+
# Internal: Fetch an instance of telemetry once per process per url +
|
23
|
+
# token (aka cloud endpoint). Should only ever be one instance unless you
|
24
|
+
# are doing some funky stuff.
|
25
|
+
def self.instance_for(cloud_configuration)
|
26
|
+
instances.compute_if_absent(cloud_configuration.url + cloud_configuration.token) do
|
27
|
+
new(cloud_configuration)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Public: The cloud configuration to use for this telemetry instance.
|
32
|
+
attr_reader :cloud_configuration
|
33
|
+
|
34
|
+
# Internal: Where the metrics are stored between cloud submissions.
|
35
|
+
attr_reader :metric_storage
|
36
|
+
|
37
|
+
# Internal: The pool of background threads that submits metrics to cloud.
|
38
|
+
attr_reader :pool
|
39
|
+
|
40
|
+
# Internal: The timer that triggers draining the metrics to the pool.
|
41
|
+
attr_reader :timer
|
42
|
+
|
43
|
+
# Internal: The interval in seconds for how often telemetry should be sent to cloud.
|
44
|
+
attr_reader :interval
|
45
|
+
|
46
|
+
# Internal: The timeout in seconds for how long to wait for the pool to shutdown.
|
47
|
+
attr_reader :shutdown_timeout
|
48
|
+
|
49
|
+
# Internal: The proc that is called to submit metrics to cloud.
|
50
|
+
attr_accessor :submitter
|
51
|
+
|
52
|
+
def initialize(cloud_configuration)
|
53
|
+
@pid = $$
|
54
|
+
@cloud_configuration = cloud_configuration
|
55
|
+
self.interval = ENV.fetch("FLIPPER_TELEMETRY_INTERVAL", 60).to_f
|
56
|
+
self.shutdown_timeout = ENV.fetch("FLIPPER_TELEMETRY_SHUTDOWN_TIMEOUT", 5).to_f
|
57
|
+
self.submitter = ->(drained) { Submitter.new(@cloud_configuration).call(drained) }
|
58
|
+
start
|
59
|
+
at_exit { stop }
|
60
|
+
end
|
61
|
+
|
62
|
+
# Public: Records telemetry events based on active support notifications.
|
63
|
+
def record(name, payload)
|
64
|
+
return unless name == Flipper::Feature::InstrumentationName
|
65
|
+
return unless payload[:operation] == :enabled?
|
66
|
+
detect_forking
|
67
|
+
|
68
|
+
metric = Metric.new(payload[:feature_name].to_s.freeze, payload[:result])
|
69
|
+
@metric_storage.increment metric
|
70
|
+
end
|
71
|
+
|
72
|
+
# Public: Start all the tasks and setup new metric storage.
|
73
|
+
def start
|
74
|
+
info "action=start"
|
75
|
+
|
76
|
+
@metric_storage = MetricStorage.new
|
77
|
+
|
78
|
+
@pool = Concurrent::FixedThreadPool.new(2, {
|
79
|
+
max_queue: 5,
|
80
|
+
fallback_policy: :discard,
|
81
|
+
name: "flipper-telemetry-post-to-cloud-pool".freeze,
|
82
|
+
})
|
83
|
+
|
84
|
+
@timer = Concurrent::TimerTask.execute({
|
85
|
+
execution_interval: interval,
|
86
|
+
name: "flipper-telemetry-post-to-pool-timer".freeze,
|
87
|
+
}) { post_to_pool }
|
88
|
+
end
|
89
|
+
|
90
|
+
# Public: Shuts down all the tasks and tries to flush any remaining info to Cloud.
|
91
|
+
def stop
|
92
|
+
info "action=stop"
|
93
|
+
|
94
|
+
if @timer
|
95
|
+
debug "action=timer_shutdown_start"
|
96
|
+
@timer.shutdown
|
97
|
+
# no need to wait long for timer, all it does is drain in memory metric
|
98
|
+
# storage and post to the pool of background workers
|
99
|
+
timer_termination_result = @timer.wait_for_termination(1)
|
100
|
+
@timer.kill unless timer_termination_result
|
101
|
+
debug "action=timer_shutdown_end result=#{timer_termination_result}"
|
102
|
+
end
|
103
|
+
|
104
|
+
if @pool
|
105
|
+
post_to_pool # one last drain
|
106
|
+
debug "action=pool_shutdown_start"
|
107
|
+
@pool.shutdown
|
108
|
+
pool_termination_result = @pool.wait_for_termination(@shutdown_timeout)
|
109
|
+
@pool.kill unless pool_termination_result
|
110
|
+
debug "action=pool_shutdown_end result=#{pool_termination_result}"
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Public: Restart all the tasks and reset the storage.
|
115
|
+
def restart
|
116
|
+
stop
|
117
|
+
start
|
118
|
+
end
|
119
|
+
|
120
|
+
# Internal: Sets the interval in seconds for how often telemetry should be sent to cloud.
|
121
|
+
def interval=(value)
|
122
|
+
new_interval = [Typecast.to_float(value), 10].max
|
123
|
+
@timer&.execution_interval = new_interval
|
124
|
+
@interval = new_interval
|
125
|
+
end
|
126
|
+
|
127
|
+
# Internal: Sets the timeout in seconds for how long to wait for the pool to shutdown.
|
128
|
+
def shutdown_timeout=(value)
|
129
|
+
new_shutdown_timeout = [Typecast.to_float(value), 0.1].max
|
130
|
+
@shutdown_timeout = new_shutdown_timeout
|
131
|
+
end
|
132
|
+
|
133
|
+
private
|
134
|
+
|
135
|
+
def detect_forking
|
136
|
+
if @pid != $$
|
137
|
+
info "action=fork_detected pid_was#{@pid} pid_is=#{$$}"
|
138
|
+
restart
|
139
|
+
@pid = $$
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# Drains the metric storage and enqueues the metrics to be posted to cloud.
|
144
|
+
def post_to_pool
|
145
|
+
drained = @metric_storage.drain
|
146
|
+
return if drained.empty?
|
147
|
+
debug "action=post_to_pool metrics=#{drained.size}"
|
148
|
+
@pool.post { post_to_cloud(drained) }
|
149
|
+
rescue => error
|
150
|
+
error "action=post_to_pool error=#{error.inspect}"
|
151
|
+
end
|
152
|
+
|
153
|
+
# Posts the drained metrics to cloud.
|
154
|
+
def post_to_cloud(drained)
|
155
|
+
debug "action=post_to_cloud metrics=#{drained.size}"
|
156
|
+
response, error = submitter.call(drained)
|
157
|
+
debug "action=post_to_cloud response=#{response.inspect} body=#{response&.body.inspect} error=#{error.inspect}"
|
158
|
+
|
159
|
+
# Some of the errors are response code errors which have a response and
|
160
|
+
# thus may have a telemetry-interval header for us to respect.
|
161
|
+
response ||= error.response if error && error.respond_to?(:response)
|
162
|
+
|
163
|
+
if response && interval = response["telemetry-interval"]
|
164
|
+
self.interval = interval.to_f
|
165
|
+
end
|
166
|
+
rescue => error
|
167
|
+
error "action=post_to_cloud error=#{error.inspect}"
|
168
|
+
end
|
169
|
+
|
170
|
+
def error(message)
|
171
|
+
@cloud_configuration.log message, level: :error
|
172
|
+
end
|
173
|
+
|
174
|
+
def info(message)
|
175
|
+
@cloud_configuration.log message, level: :info
|
176
|
+
end
|
177
|
+
|
178
|
+
def debug(message)
|
179
|
+
@cloud_configuration.log message
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require "flipper"
|
2
|
+
require "flipper/middleware/setup_env"
|
3
|
+
require "flipper/middleware/memoizer"
|
4
|
+
require "flipper/cloud/configuration"
|
5
|
+
require "flipper/cloud/dsl"
|
6
|
+
require "flipper/cloud/middleware"
|
7
|
+
|
8
|
+
module Flipper
|
9
|
+
module Cloud
|
10
|
+
# Public: Returns a new Flipper instance with an http adapter correctly
|
11
|
+
# configured for flipper cloud.
|
12
|
+
#
|
13
|
+
# token - The String token for the environment from the website.
|
14
|
+
# options - The Hash of options. See Flipper::Cloud::Configuration.
|
15
|
+
# block - The block that configuration will be yielded to allowing you to
|
16
|
+
# customize this cloud instance and its adapter.
|
17
|
+
def self.new(options = {})
|
18
|
+
configuration = Configuration.new(options)
|
19
|
+
yield configuration if block_given?
|
20
|
+
DSL.new(configuration)
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.app(flipper = nil, options = {})
|
24
|
+
env_key = options.fetch(:env_key, 'flipper')
|
25
|
+
memoizer_options = options.fetch(:memoizer_options, {})
|
26
|
+
|
27
|
+
app = ->(_) { [404, { 'content-type'.freeze => 'application/json'.freeze }, ['{}'.freeze]] }
|
28
|
+
builder = Rack::Builder.new
|
29
|
+
yield builder if block_given?
|
30
|
+
builder.use Flipper::Middleware::SetupEnv, flipper, env_key: env_key
|
31
|
+
builder.use Flipper::Middleware::Memoizer, memoizer_options.merge(env_key: env_key)
|
32
|
+
builder.use Flipper::Cloud::Middleware, env_key: env_key
|
33
|
+
builder.run app
|
34
|
+
klass = self
|
35
|
+
app = builder.to_app
|
36
|
+
app.define_singleton_method(:inspect) { klass.inspect } # pretty rake routes output
|
37
|
+
app
|
38
|
+
end
|
39
|
+
|
40
|
+
# Private: Configure Flipper to use Cloud by default
|
41
|
+
def self.set_default
|
42
|
+
if ENV["FLIPPER_CLOUD_TOKEN"]
|
43
|
+
Flipper.configure do |config|
|
44
|
+
config.default do
|
45
|
+
self.new(local_adapter: config.adapter)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
Flipper::Cloud.set_default
|
@@ -1,8 +1,8 @@
|
|
1
1
|
module Flipper
|
2
2
|
class Configuration
|
3
3
|
def initialize(options = {})
|
4
|
-
@
|
5
|
-
@
|
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
|
-
@
|
27
|
+
@builder.store(block)
|
28
28
|
else
|
29
|
-
@
|
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
|