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,145 @@
1
+ require "stringio"
2
+ require 'flipper/cloud/configuration'
3
+ require 'flipper/cloud/telemetry/submitter'
4
+
5
+ RSpec.describe Flipper::Cloud::Telemetry::Submitter do
6
+ let(:cloud_configuration) {
7
+ Flipper::Cloud::Configuration.new({token: "asdf"})
8
+ }
9
+ let(:fake_backoff_policy) { FakeBackoffPolicy.new }
10
+ let(:subject) { described_class.new(cloud_configuration, backoff_policy: fake_backoff_policy) }
11
+
12
+ describe "#initialize" do
13
+ it "works with cloud_configuration" do
14
+ submitter = described_class.new(cloud_configuration)
15
+ expect(submitter.cloud_configuration).to eq(cloud_configuration)
16
+ end
17
+ end
18
+
19
+ describe "#call" do
20
+ let(:enabled_metrics) {
21
+ {
22
+ Flipper::Cloud::Telemetry::Metric.new(:search, true, 1696793160) => 10,
23
+ Flipper::Cloud::Telemetry::Metric.new(:search, false, 1696793161) => 15,
24
+ Flipper::Cloud::Telemetry::Metric.new(:plausible, true, 1696793162) => 25,
25
+ Flipper::Cloud::Telemetry::Metric.new(:administrator, true, 1696793164) => 1,
26
+ Flipper::Cloud::Telemetry::Metric.new(:administrator, false, 1696793164) => 24,
27
+ }
28
+ }
29
+
30
+ it "does not submit blank metrics" do
31
+ expect(subject.call({})).to be(nil)
32
+ end
33
+
34
+ it "submits present metrics" do
35
+ expected_body = {
36
+ "request_id" => subject.request_id,
37
+ "enabled_metrics" =>[
38
+ {"key" => "search", "time" => 1696793160, "result" => true, "value" => 10},
39
+ {"key" => "search", "time" => 1696793160, "result" => false, "value" => 15},
40
+ {"key" => "plausible", "time" => 1696793160, "result" => true, "value" => 25},
41
+ {"key" => "administrator", "time" => 1696793160, "result" => true, "value" => 1},
42
+ {"key" => "administrator", "time" => 1696793160, "result" => false, "value" => 24},
43
+ ]
44
+ }
45
+ expected_headers = {
46
+ 'accept' => 'application/json',
47
+ 'client-engine' => defined?(RUBY_ENGINE) ? RUBY_ENGINE : "",
48
+ 'client-hostname' => Socket.gethostname,
49
+ 'client-language' => 'ruby',
50
+ 'client-language-version' => "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE})",
51
+ 'client-pid' => Process.pid.to_s,
52
+ 'client-platform' => RUBY_PLATFORM,
53
+ 'client-thread' => Thread.current.object_id.to_s,
54
+ 'content-encoding' => 'gzip',
55
+ 'content-type' => 'application/json',
56
+ 'flipper-cloud-token' => 'asdf',
57
+ 'schema-version' => 'V1',
58
+ 'user-agent' => "Flipper HTTP Adapter v#{Flipper::VERSION}",
59
+ }
60
+ stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
61
+ with(headers: expected_headers) { |request|
62
+ gunzipped = Flipper::Typecast.from_gzip(request.body)
63
+ body = Flipper::Typecast.from_json(gunzipped)
64
+ body == expected_body
65
+ }.to_return(status: 200, body: "{}")
66
+ subject.call(enabled_metrics)
67
+ end
68
+
69
+ it "defaults backoff_policy" do
70
+ stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
71
+ to_return(status: 429, body: "{}").
72
+ to_return(status: 200, body: "{}")
73
+ instance = described_class.new(cloud_configuration)
74
+ expect(instance.backoff_policy.min_timeout_ms).to eq(30_000)
75
+ expect(instance.backoff_policy.max_timeout_ms).to eq(120_000)
76
+ end
77
+
78
+ it "tries 10 times by default" do
79
+ stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
80
+ to_return(status: 500, body: "{}")
81
+ subject.call(enabled_metrics)
82
+ expect(subject.backoff_policy.retries).to eq(4) # 4 retries + 1 initial attempt
83
+ end
84
+
85
+ [
86
+ EOFError,
87
+ Errno::ECONNABORTED,
88
+ Errno::ECONNREFUSED,
89
+ Errno::ECONNRESET,
90
+ Errno::EHOSTUNREACH,
91
+ Errno::EINVAL,
92
+ Errno::ENETUNREACH,
93
+ Errno::ENOTSOCK,
94
+ Errno::EPIPE,
95
+ Errno::ETIMEDOUT,
96
+ Net::HTTPBadResponse,
97
+ Net::HTTPHeaderSyntaxError,
98
+ Net::ProtocolError,
99
+ Net::ReadTimeout,
100
+ OpenSSL::SSL::SSLError,
101
+ SocketError,
102
+ Timeout::Error, # Also covers subclasses like Net::OpenTimeout.
103
+ ].each do |error_class|
104
+ it "retries on #{error_class}" do
105
+ stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
106
+ to_raise(error_class)
107
+ subject.call(enabled_metrics)
108
+ expect(subject.backoff_policy.retries).to eq(4)
109
+ end
110
+ end
111
+
112
+ it "retries on 429" do
113
+ stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
114
+ to_return(status: 429, body: "{}").
115
+ to_return(status: 429, body: "{}").
116
+ to_return(status: 200, body: "{}")
117
+ subject.call(enabled_metrics)
118
+ expect(subject.backoff_policy.retries).to eq(2)
119
+ end
120
+
121
+ it "retries on 500" do
122
+ stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
123
+ to_return(status: 500, body: "{}").
124
+ to_return(status: 503, body: "{}").
125
+ to_return(status: 502, body: "{}").
126
+ to_return(status: 200, body: "{}")
127
+ subject.call(enabled_metrics)
128
+ expect(subject.backoff_policy.retries).to eq(3)
129
+ end
130
+ end
131
+
132
+ def with_telemetry_debug_logging(&block)
133
+ output = StringIO.new
134
+ original_logger = cloud_configuration.logger
135
+
136
+ begin
137
+ cloud_configuration.logger = Logger.new(output)
138
+ block.call
139
+ ensure
140
+ cloud_configuration.logger = original_logger
141
+ end
142
+
143
+ output.string
144
+ end
145
+ end
@@ -0,0 +1,208 @@
1
+ require 'flipper/cloud/telemetry'
2
+ require 'flipper/cloud/configuration'
3
+
4
+ RSpec.describe Flipper::Cloud::Telemetry do
5
+ before do
6
+ # Stub polling for features.
7
+ stub_request(:get, "https://www.flippercloud.io/adapter/features?exclude_gate_names=true").
8
+ to_return(status: 200, body: "{}")
9
+ end
10
+
11
+ it "phones home and does not update telemetry interval if missing" do
12
+ stub = stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
13
+ to_return(status: 200, body: "{}")
14
+
15
+ cloud_configuration = Flipper::Cloud::Configuration.new(token: "test")
16
+
17
+ # Record some telemetry and stop the threads so we submit a response.
18
+ telemetry = described_class.new(cloud_configuration)
19
+ telemetry.record(Flipper::Feature::InstrumentationName, {
20
+ operation: :enabled?,
21
+ feature_name: :foo,
22
+ result: true,
23
+ })
24
+ telemetry.stop
25
+
26
+ expect(telemetry.interval).to eq(60)
27
+ expect(telemetry.timer.execution_interval).to eq(60)
28
+ expect(stub).to have_been_requested.at_least_once
29
+ end
30
+
31
+ it "phones home and updates telemetry interval if present" do
32
+ stub = stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
33
+ to_return(status: 200, body: "{}", headers: {"telemetry-interval" => "120"})
34
+
35
+ cloud_configuration = Flipper::Cloud::Configuration.new(token: "test")
36
+
37
+ # Record some telemetry and stop the threads so we submit a response.
38
+ telemetry = described_class.new(cloud_configuration)
39
+ telemetry.record(Flipper::Feature::InstrumentationName, {
40
+ operation: :enabled?,
41
+ feature_name: :foo,
42
+ result: true,
43
+ })
44
+ telemetry.stop
45
+
46
+ expect(telemetry.interval).to eq(120)
47
+ expect(telemetry.timer.execution_interval).to eq(120)
48
+ expect(stub).to have_been_requested.at_least_once
49
+ end
50
+
51
+ it "phones home and requests shutdown if telemetry-shutdown header is true" do
52
+ stub = stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
53
+ to_return(status: 404, body: "{}", headers: {"telemetry-shutdown" => "true"})
54
+
55
+ output = StringIO.new
56
+ cloud_configuration = Flipper::Cloud::Configuration.new(
57
+ token: "test",
58
+ logger: Logger.new(output),
59
+ logging_enabled: true,
60
+ )
61
+
62
+ # Record some telemetry and stop the threads so we submit a response.
63
+ telemetry = described_class.new(cloud_configuration)
64
+ telemetry.record(Flipper::Feature::InstrumentationName, {
65
+ operation: :enabled?,
66
+ feature_name: :foo,
67
+ result: true,
68
+ })
69
+ telemetry.stop
70
+ expect(stub).to have_been_requested.at_least_once
71
+ expect(output.string).to match(/action=telemetry_shutdown message=The server has requested that telemetry be shut down./)
72
+ end
73
+
74
+ it "phones home and does not shutdown if telemetry shutdown header is missing" do
75
+ stub = stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
76
+ to_return(status: 404, body: "{}", headers: {})
77
+
78
+ output = StringIO.new
79
+ cloud_configuration = Flipper::Cloud::Configuration.new(
80
+ token: "test",
81
+ logger: Logger.new(output),
82
+ logging_enabled: true,
83
+ )
84
+
85
+ # Record some telemetry and stop the threads so we submit a response.
86
+ telemetry = described_class.new(cloud_configuration)
87
+ telemetry.record(Flipper::Feature::InstrumentationName, {
88
+ operation: :enabled?,
89
+ feature_name: :foo,
90
+ result: true,
91
+ })
92
+ telemetry.stop
93
+ expect(stub).to have_been_requested.at_least_once
94
+ expect(output.string).not_to match(/action=telemetry_shutdown message=The server has requested that telemetry be shut down./)
95
+ end
96
+
97
+ it "can update telemetry interval from error" do
98
+ stub = stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
99
+ to_return(status: 500, body: "{}", headers: {"telemetry-interval" => "120"})
100
+
101
+ cloud_configuration = Flipper::Cloud::Configuration.new(token: "test")
102
+ telemetry = described_class.new(cloud_configuration)
103
+
104
+ # Override the submitter to use back off policy that doesn't actually
105
+ # sleep. If we don't then the stop below kills the working thread and the
106
+ # interval is never updated.
107
+ telemetry.submitter = ->(drained) {
108
+ Flipper::Cloud::Telemetry::Submitter.new(
109
+ cloud_configuration,
110
+ backoff_policy: FakeBackoffPolicy.new
111
+ ).call(drained)
112
+ }
113
+
114
+ # Record some telemetry and stop the threads so we submit a response.
115
+ telemetry.record(Flipper::Feature::InstrumentationName, {
116
+ operation: :enabled?,
117
+ feature_name: :foo,
118
+ result: true,
119
+ })
120
+ telemetry.stop
121
+
122
+ # Check the conig interval and the timer interval.
123
+ expect(telemetry.interval).to eq(120)
124
+ expect(telemetry.timer.execution_interval).to eq(120)
125
+ expect(stub).to have_been_requested.times(5)
126
+ end
127
+
128
+ it "doesn't try to update telemetry interval from error if not response error" do
129
+ stub = stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
130
+ to_raise(Net::OpenTimeout)
131
+
132
+ cloud_configuration = Flipper::Cloud::Configuration.new(token: "test")
133
+ telemetry = described_class.new(cloud_configuration)
134
+
135
+ # Override the submitter to use back off policy that doesn't actually
136
+ # sleep. If we don't then the stop below kills the working thread and the
137
+ # interval is never updated.
138
+ telemetry.submitter = ->(drained) {
139
+ Flipper::Cloud::Telemetry::Submitter.new(
140
+ cloud_configuration,
141
+ backoff_policy: FakeBackoffPolicy.new
142
+ ).call(drained)
143
+ }
144
+
145
+ # Record some telemetry and stop the threads so we submit a response.
146
+ telemetry.record(Flipper::Feature::InstrumentationName, {
147
+ operation: :enabled?,
148
+ feature_name: :foo,
149
+ result: true,
150
+ })
151
+ telemetry.stop
152
+
153
+ expect(telemetry.interval).to eq(60)
154
+ expect(telemetry.timer.execution_interval).to eq(60)
155
+ expect(stub).to have_been_requested.times(5)
156
+ end
157
+
158
+ describe '#record' do
159
+ it "increments in metric storage" do
160
+ begin
161
+ config = Flipper::Cloud::Configuration.new(token: "test")
162
+ telemetry = described_class.new(config)
163
+ telemetry.record(Flipper::Feature::InstrumentationName, {
164
+ operation: :enabled?,
165
+ feature_name: :foo,
166
+ result: true,
167
+ })
168
+ telemetry.record(Flipper::Feature::InstrumentationName, {
169
+ operation: :enabled?,
170
+ feature_name: :foo,
171
+ result: true,
172
+ })
173
+ telemetry.record(Flipper::Feature::InstrumentationName, {
174
+ operation: :enabled?,
175
+ feature_name: :bar,
176
+ result: true,
177
+ })
178
+ telemetry.record(Flipper::Feature::InstrumentationName, {
179
+ operation: :enabled?,
180
+ feature_name: :baz,
181
+ result: true,
182
+ })
183
+ telemetry.record(Flipper::Feature::InstrumentationName, {
184
+ operation: :enabled?,
185
+ feature_name: :foo,
186
+ result: false,
187
+ })
188
+
189
+ drained = telemetry.metric_storage.drain
190
+ metrics_by_key = drained.keys.group_by(&:key)
191
+
192
+ foo_true, foo_false = metrics_by_key["foo"].partition { |metric| metric.result }
193
+ foo_true_sum = foo_true.map { |metric| drained[metric] }.sum
194
+ expect(foo_true_sum).to be(2)
195
+ foo_false_sum = foo_false.map { |metric| drained[metric] }.sum
196
+ expect(foo_false_sum).to be(1)
197
+
198
+ bar_true_sum = metrics_by_key["bar"].map { |metric| drained[metric] }.sum
199
+ expect(bar_true_sum).to be(1)
200
+
201
+ baz_true_sum = metrics_by_key["baz"].map { |metric| drained[metric] }.sum
202
+ expect(baz_true_sum).to be(1)
203
+ ensure
204
+ telemetry.stop
205
+ end
206
+ end
207
+ end
208
+ end
@@ -35,13 +35,15 @@ RSpec.describe Flipper::Cloud do
35
35
  expect(client.uri.scheme).to eq('https')
36
36
  expect(client.uri.host).to eq('www.flippercloud.io')
37
37
  expect(client.uri.path).to eq('/adapter')
38
- expect(client.headers['Flipper-Cloud-Token']).to eq(token)
39
- expect(@instance.instrumenter).to be(Flipper::Instrumenters::Noop)
38
+ expect(client.headers["flipper-cloud-token"]).to eq(token)
39
+ expect(@instance.instrumenter).to be_a(Flipper::Cloud::Telemetry::Instrumenter)
40
+ expect(@instance.instrumenter.instrumenter).to be(Flipper::Instrumenters::Noop)
40
41
  end
41
42
  end
42
43
 
43
44
  context 'initialize with token and options' do
44
45
  it 'sets correct url' do
46
+ stub_request(:any, %r{fakeflipper.com}).to_return(status: 200)
45
47
  instance = described_class.new(token: 'asdf', url: 'https://www.fakeflipper.com/sadpanda')
46
48
  # pardon the nesting...
47
49
  memoized = instance.adapter
@@ -55,15 +57,15 @@ RSpec.describe Flipper::Cloud do
55
57
  end
56
58
 
57
59
  it 'can initialize with no token explicitly provided' do
58
- with_env 'FLIPPER_CLOUD_TOKEN' => 'asdf' do
59
- expect(described_class.new).to be_instance_of(Flipper::Cloud::DSL)
60
- end
60
+ ENV['FLIPPER_CLOUD_TOKEN'] = 'asdf'
61
+ expect(described_class.new).to be_instance_of(Flipper::Cloud::DSL)
61
62
  end
62
63
 
63
64
  it 'can set instrumenter' do
64
65
  instrumenter = Flipper::Instrumenters::Memory.new
65
66
  instance = described_class.new(token: 'asdf', instrumenter: instrumenter)
66
- expect(instance.instrumenter).to be(instrumenter)
67
+ expect(instance.instrumenter).to be_a(Flipper::Cloud::Telemetry::Instrumenter)
68
+ expect(instance.instrumenter.instrumenter).to be(instrumenter)
67
69
  end
68
70
 
69
71
  it 'allows wrapping adapter with another adapter like the instrumenter' do
@@ -77,34 +79,38 @@ RSpec.describe Flipper::Cloud do
77
79
  end
78
80
 
79
81
  it 'can set debug_output' do
82
+ instance = Flipper::Adapters::Http::Client.new(token: 'asdf', url: 'https://www.flippercloud.io/adapter')
80
83
  expect(Flipper::Adapters::Http::Client).to receive(:new)
81
- .with(hash_including(debug_output: STDOUT)).at_least(:once)
84
+ .with(hash_including(debug_output: STDOUT)).at_least(:once).and_return(instance)
82
85
  described_class.new(token: 'asdf', debug_output: STDOUT)
83
86
  end
84
87
 
85
88
  it 'can set read_timeout' do
89
+ instance = Flipper::Adapters::Http::Client.new(token: 'asdf', url: 'https://www.flippercloud.io/adapter')
86
90
  expect(Flipper::Adapters::Http::Client).to receive(:new)
87
- .with(hash_including(read_timeout: 1)).at_least(:once)
91
+ .with(hash_including(read_timeout: 1)).at_least(:once).and_return(instance)
88
92
  described_class.new(token: 'asdf', read_timeout: 1)
89
93
  end
90
94
 
91
95
  it 'can set open_timeout' do
96
+ instance = Flipper::Adapters::Http::Client.new(token: 'asdf', url: 'https://www.flippercloud.io/adapter')
92
97
  expect(Flipper::Adapters::Http::Client).to receive(:new)
93
- .with(hash_including(open_timeout: 1)).at_least(:once)
98
+ .with(hash_including(open_timeout: 1)).at_least(:once).and_return(instance)
94
99
  described_class.new(token: 'asdf', open_timeout: 1)
95
100
  end
96
101
 
97
102
  if RUBY_VERSION >= '2.6.0'
98
103
  it 'can set write_timeout' do
104
+ instance = Flipper::Adapters::Http::Client.new(token: 'asdf', url: 'https://www.flippercloud.io/adapter')
99
105
  expect(Flipper::Adapters::Http::Client).to receive(:new)
100
- .with(hash_including(open_timeout: 1)).at_least(:once)
106
+ .with(hash_including(open_timeout: 1)).at_least(:once).and_return(instance)
101
107
  described_class.new(token: 'asdf', open_timeout: 1)
102
108
  end
103
109
  end
104
110
 
105
111
  it 'can import' do
106
112
  stub_request(:post, /www\.flippercloud\.io\/adapter\/features.*/).
107
- with(headers: {'Flipper-Cloud-Token'=>'asdf'}).to_return(status: 200, body: "{}", headers: {})
113
+ with(headers: {'flipper-cloud-token'=>'asdf'}).to_return(status: 200, body: "{}", headers: {})
108
114
 
109
115
  flipper = Flipper.new(Flipper::Adapters::Memory.new)
110
116
 
@@ -116,10 +122,10 @@ RSpec.describe Flipper::Cloud do
116
122
  cloud_flipper = Flipper::Cloud.new(token: "asdf")
117
123
 
118
124
  get_all = {
119
- "logging" => {actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"},
120
- "search" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
121
- "stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
122
- "test" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
125
+ "logging" => {actors: Set.new, boolean: nil, groups: Set.new, expression: nil, percentage_of_actors: nil, percentage_of_time: "5"},
126
+ "search" => {actors: Set.new, boolean: "true", groups: Set.new, expression: nil, percentage_of_actors: nil, percentage_of_time: nil},
127
+ "stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, expression: nil, percentage_of_actors: nil, percentage_of_time: nil},
128
+ "test" => {actors: Set.new, boolean: "true", groups: Set.new, expression: nil, percentage_of_actors: nil, percentage_of_time: nil},
123
129
  }
124
130
 
125
131
  expect(flipper.adapter.get_all).to eq(get_all)
@@ -130,7 +136,7 @@ RSpec.describe Flipper::Cloud do
130
136
 
131
137
  it 'raises error for failure while importing' do
132
138
  stub_request(:post, /www\.flippercloud\.io\/adapter\/features.*/).
133
- with(headers: {'Flipper-Cloud-Token'=>'asdf'}).to_return(status: 500, body: "{}")
139
+ with(headers: {'flipper-cloud-token'=>'asdf'}).to_return(status: 500, body: "{}")
134
140
 
135
141
  flipper = Flipper.new(Flipper::Adapters::Memory.new)
136
142
 
@@ -142,10 +148,10 @@ RSpec.describe Flipper::Cloud do
142
148
  cloud_flipper = Flipper::Cloud.new(token: "asdf")
143
149
 
144
150
  get_all = {
145
- "logging" => {actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"},
146
- "search" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
147
- "stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
148
- "test" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
151
+ "logging" => {actors: Set.new, boolean: nil, groups: Set.new, expression: nil, percentage_of_actors: nil, percentage_of_time: "5"},
152
+ "search" => {actors: Set.new, boolean: "true", groups: Set.new, expression: nil, percentage_of_actors: nil, percentage_of_time: nil},
153
+ "stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, expression: nil, percentage_of_actors: nil, percentage_of_time: nil},
154
+ "test" => {actors: Set.new, boolean: "true", groups: Set.new, expression: nil, percentage_of_actors: nil, percentage_of_time: nil},
149
155
  }
150
156
 
151
157
  expect(flipper.adapter.get_all).to eq(get_all)
@@ -155,7 +161,7 @@ RSpec.describe Flipper::Cloud do
155
161
 
156
162
  it 'raises error for timeout while importing' do
157
163
  stub_request(:post, /www\.flippercloud\.io\/adapter\/features.*/).
158
- with(headers: {'Flipper-Cloud-Token'=>'asdf'}).to_timeout
164
+ with(headers: {'flipper-cloud-token'=>'asdf'}).to_timeout
159
165
 
160
166
  flipper = Flipper.new(Flipper::Adapters::Memory.new)
161
167
 
@@ -167,10 +173,10 @@ RSpec.describe Flipper::Cloud do
167
173
  cloud_flipper = Flipper::Cloud.new(token: "asdf")
168
174
 
169
175
  get_all = {
170
- "logging" => {actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"},
171
- "search" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
172
- "stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
173
- "test" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
176
+ "logging" => {actors: Set.new, boolean: nil, groups: Set.new, expression: nil, percentage_of_actors: nil, percentage_of_time: "5"},
177
+ "search" => {actors: Set.new, boolean: "true", groups: Set.new, expression: nil, percentage_of_actors: nil, percentage_of_time: nil},
178
+ "stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, expression: nil, percentage_of_actors: nil, percentage_of_time: nil},
179
+ "test" => {actors: Set.new, boolean: "true", groups: Set.new, expression: nil, percentage_of_actors: nil, percentage_of_time: nil},
174
180
  }
175
181
 
176
182
  expect(flipper.adapter.get_all).to eq(get_all)
@@ -30,4 +30,21 @@ RSpec.describe Flipper::Configuration do
30
30
  expect(subject.default).to be(instance)
31
31
  end
32
32
  end
33
+
34
+ describe '#statsd' do
35
+ let(:statsd) { double(Statsd) }
36
+
37
+ after do
38
+ Flipper::Instrumentation::StatsdSubscriber.client = nil
39
+ end
40
+
41
+ it 'returns nil by default' do
42
+ expect(subject.statsd).to be_nil
43
+ end
44
+
45
+ it 'can be set' do
46
+ subject.statsd = statsd
47
+ expect(subject.statsd).to be(statsd)
48
+ end
49
+ end
33
50
  end
@@ -136,6 +136,18 @@ RSpec.describe Flipper::DSL do
136
136
  end
137
137
  end
138
138
 
139
+ describe '#expression' do
140
+ it "returns nil if feature has no expression" do
141
+ expect(subject.expression(:stats)).to be(nil)
142
+ end
143
+
144
+ it "returns expression if feature has expression" do
145
+ expression = Flipper.property(:plan).eq("basic")
146
+ subject[:stats].enable_expression expression
147
+ expect(subject.expression(:stats)).to eq(expression)
148
+ end
149
+ end
150
+
139
151
  describe '#features' do
140
152
  context 'with no features enabled/disabled' do
141
153
  it 'defaults to empty set' do
@@ -171,6 +183,33 @@ RSpec.describe Flipper::DSL do
171
183
  end
172
184
  end
173
185
 
186
+ describe '#enable_expression/disable_expression' do
187
+ it 'enables and disables the feature for the expression' do
188
+ expression = Flipper.property(:plan).eq("basic")
189
+
190
+ expect(subject[:stats].expression).to be(nil)
191
+ subject.enable_expression(:stats, expression)
192
+ expect(subject[:stats].expression).to eq(expression)
193
+
194
+ subject.disable_expression(:stats)
195
+ expect(subject[:stats].expression).to be(nil)
196
+ end
197
+ end
198
+
199
+ describe '#add_expression/remove_expression' do
200
+ it 'enables and disables the feature for the expression' do
201
+ expression = Flipper.property(:plan).eq("basic")
202
+ any_expression = Flipper.any(expression)
203
+
204
+ expect(subject[:stats].expression).to be(nil)
205
+ subject.add_expression(:stats, any_expression)
206
+ expect(subject[:stats].expression).to eq(any_expression)
207
+
208
+ subject.remove_expression(:stats, expression)
209
+ expect(subject[:stats].expression).to eq(Flipper.any)
210
+ end
211
+ end
212
+
174
213
  describe '#enable_actor/disable_actor' do
175
214
  it 'enables and disables the feature for actor' do
176
215
  actor = Flipper::Actor.new(5)
@@ -186,9 +225,6 @@ RSpec.describe Flipper::DSL do
186
225
 
187
226
  describe '#enable_group/disable_group' do
188
227
  it 'enables and disables the feature for group' do
189
- actor = Flipper::Actor.new(5)
190
- group = Flipper.register(:fives) { |actor| actor.flipper_id == 5 }
191
-
192
228
  expect(subject[:stats].groups_value).to be_empty
193
229
  subject.enable_group(:stats, :fives)
194
230
  expect(subject[:stats].groups_value).to eq(Set['fives'])