featureflip 1.0.1 → 2.0.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/lib/featureflip/client.rb +43 -204
- data/lib/featureflip/shared_core.rb +367 -0
- data/lib/featureflip/version.rb +1 -1
- data/lib/featureflip.rb +2 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b4009e153b6dc621cee9ed4d8f1aaa7f199396e5d8a8266d949b3367b0f06b8
|
|
4
|
+
data.tar.gz: 1f45c271ce3d180c362cfa095d6027f0d5d48f51cc259fd31215baa8959732d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: debfee24678c85c688e94935396ef142f3b1f31aaec0b7788c6fc09e51e03e805104f273e53bda66686d4eded49baff6e33f58bb7e0b2543cbdf5cfac1cf9289
|
|
7
|
+
data.tar.gz: ff2cc0874823a1dcd23c3fc29dd70deffb46b0a08fc6c9efce620299bb3200b6edbf78d5352579e2b476725e5870d9bcde1f2a48faafc8e5ccca11e113e750ba
|
data/lib/featureflip/client.rb
CHANGED
|
@@ -1,247 +1,86 @@
|
|
|
1
|
-
require "timeout"
|
|
2
|
-
|
|
3
1
|
module Featureflip
|
|
4
2
|
class Client
|
|
5
|
-
|
|
6
|
-
alias_method :initialized?, :initialized
|
|
3
|
+
private_class_method :new
|
|
7
4
|
|
|
8
|
-
def
|
|
9
|
-
|
|
10
|
-
raise ConfigurationError, "SDK key is required. Pass sdk_key parameter or set FEATUREFLIP_SDK_KEY env var." unless
|
|
5
|
+
def self.get(sdk_key = nil, config: nil)
|
|
6
|
+
sdk_key ||= ENV["FEATUREFLIP_SDK_KEY"]
|
|
7
|
+
raise ConfigurationError, "SDK key is required. Pass sdk_key parameter or set FEATUREFLIP_SDK_KEY env var." unless sdk_key
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
initialize!
|
|
9
|
+
config ||= Config.new
|
|
10
|
+
core = SharedCore._get_or_create(sdk_key, config)
|
|
11
|
+
new(core)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.for_testing(flags)
|
|
15
|
+
core = SharedCore._create_for_testing(flags)
|
|
16
|
+
new(core)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialized?
|
|
20
|
+
@core.initialized?
|
|
25
21
|
end
|
|
26
22
|
|
|
27
23
|
def bool_variation(key, context, default_value)
|
|
28
|
-
|
|
24
|
+
return default_value if @closed
|
|
25
|
+
@core.bool_variation(key, context, default_value)
|
|
29
26
|
end
|
|
30
27
|
|
|
31
28
|
def string_variation(key, context, default_value)
|
|
32
|
-
|
|
29
|
+
return default_value if @closed
|
|
30
|
+
@core.string_variation(key, context, default_value)
|
|
33
31
|
end
|
|
34
32
|
|
|
35
33
|
def number_variation(key, context, default_value)
|
|
36
|
-
|
|
34
|
+
return default_value if @closed
|
|
35
|
+
@core.number_variation(key, context, default_value)
|
|
37
36
|
end
|
|
38
37
|
|
|
39
38
|
def json_variation(key, context, default_value)
|
|
40
|
-
|
|
39
|
+
return default_value if @closed
|
|
40
|
+
@core.json_variation(key, context, default_value)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def variation_detail(key, context, default_value)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if @test_mode
|
|
47
|
-
value = @test_values.fetch(key, default_value)
|
|
48
|
-
reason = @test_values.key?(key) ? "Fallthrough" : "FlagNotFound"
|
|
49
|
-
return Models::EvaluationDetail.new(value: value, reason: reason)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
flag = @store.get_flag(key)
|
|
53
|
-
unless flag
|
|
54
|
-
record_evaluation(key, context, nil)
|
|
55
|
-
return Models::EvaluationDetail.new(value: default_value, reason: "FlagNotFound")
|
|
44
|
+
if @closed
|
|
45
|
+
return Models::EvaluationDetail.new(value: default_value, reason: "Error")
|
|
56
46
|
end
|
|
57
|
-
|
|
58
|
-
result = @evaluator.evaluate(flag, context, get_segment: method(:get_segment))
|
|
59
|
-
value = result.value.nil? ? default_value : result.value
|
|
60
|
-
record_evaluation(key, context, result.variation_key)
|
|
61
|
-
|
|
62
|
-
Models::EvaluationDetail.new(
|
|
63
|
-
value: value,
|
|
64
|
-
reason: result.reason,
|
|
65
|
-
rule_id: result.rule_id,
|
|
66
|
-
variation_key: result.variation_key
|
|
67
|
-
)
|
|
68
|
-
rescue StandardError
|
|
69
|
-
Models::EvaluationDetail.new(value: default_value, reason: "Error")
|
|
47
|
+
@core.variation_detail(key, context, default_value)
|
|
70
48
|
end
|
|
71
49
|
|
|
72
50
|
def track(event_key, context, metadata = nil)
|
|
73
|
-
return
|
|
74
|
-
|
|
75
|
-
context = normalize_context(context)
|
|
76
|
-
@event_processor.queue_event({
|
|
77
|
-
type: "Custom",
|
|
78
|
-
flagKey: event_key,
|
|
79
|
-
userId: context["user_id"]&.to_s,
|
|
80
|
-
metadata: metadata || {},
|
|
81
|
-
timestamp: Time.now.utc.iso8601
|
|
82
|
-
})
|
|
51
|
+
return if @closed
|
|
52
|
+
@core.track(event_key, context, metadata)
|
|
83
53
|
end
|
|
84
54
|
|
|
85
55
|
def identify(context)
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
context = normalize_context(context)
|
|
89
|
-
@event_processor.queue_event({
|
|
90
|
-
type: "Identify",
|
|
91
|
-
flagKey: "$identify",
|
|
92
|
-
userId: context["user_id"]&.to_s,
|
|
93
|
-
timestamp: Time.now.utc.iso8601
|
|
94
|
-
})
|
|
56
|
+
return if @closed
|
|
57
|
+
@core.identify(context)
|
|
95
58
|
end
|
|
96
59
|
|
|
97
60
|
def flush
|
|
98
|
-
@
|
|
61
|
+
return if @closed
|
|
62
|
+
@core.flush
|
|
99
63
|
end
|
|
100
64
|
|
|
101
65
|
def close
|
|
102
|
-
@
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
@
|
|
107
|
-
@event_processor&.stop
|
|
108
|
-
@event_processor = nil
|
|
66
|
+
@close_mutex.synchronize do
|
|
67
|
+
return if @closed
|
|
68
|
+
@closed = true
|
|
69
|
+
end
|
|
70
|
+
@core._release
|
|
109
71
|
end
|
|
110
72
|
|
|
111
73
|
def restart
|
|
112
74
|
return if @closed
|
|
113
|
-
|
|
114
|
-
@streaming_handler&.stop
|
|
115
|
-
@polling_handler&.stop
|
|
116
|
-
@event_processor&.stop
|
|
117
|
-
|
|
118
|
-
if @config.streaming
|
|
119
|
-
start_streaming
|
|
120
|
-
else
|
|
121
|
-
start_polling
|
|
122
|
-
end
|
|
123
|
-
start_event_processor if @config.send_events
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def self.for_testing(flags)
|
|
127
|
-
instance = allocate
|
|
128
|
-
instance.instance_variable_set(:@sdk_key, "test-key")
|
|
129
|
-
instance.instance_variable_set(:@config, Config.new)
|
|
130
|
-
instance.instance_variable_set(:@store, Store::FlagStore.new)
|
|
131
|
-
instance.instance_variable_set(:@evaluator, Evaluation::Evaluator.new)
|
|
132
|
-
instance.instance_variable_set(:@initialized, true)
|
|
133
|
-
instance.instance_variable_set(:@closed, false)
|
|
134
|
-
instance.instance_variable_set(:@test_mode, true)
|
|
135
|
-
instance.instance_variable_set(:@test_values, flags.dup)
|
|
136
|
-
instance.instance_variable_set(:@http_client, nil)
|
|
137
|
-
instance.instance_variable_set(:@streaming_handler, nil)
|
|
138
|
-
instance.instance_variable_set(:@polling_handler, nil)
|
|
139
|
-
instance.instance_variable_set(:@event_processor, nil)
|
|
140
|
-
instance
|
|
75
|
+
@core.restart
|
|
141
76
|
end
|
|
142
77
|
|
|
143
78
|
private
|
|
144
79
|
|
|
145
|
-
def initialize
|
|
146
|
-
@
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
start_event_processor if @config.send_events
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def fetch_initial_flags
|
|
153
|
-
Timeout.timeout(@config.init_timeout) do
|
|
154
|
-
flags, segments = @http_client.get_flags
|
|
155
|
-
@store.init(flags, segments)
|
|
156
|
-
@initialized = true
|
|
157
|
-
end
|
|
158
|
-
rescue Timeout::Error
|
|
159
|
-
raise InitializationError, "Initialization timed out after #{@config.init_timeout}s"
|
|
160
|
-
rescue InitializationError
|
|
161
|
-
raise
|
|
162
|
-
rescue StandardError => e
|
|
163
|
-
raise InitializationError, "Failed to initialize: #{e.message}"
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def start_data_source
|
|
167
|
-
return if @closed
|
|
168
|
-
|
|
169
|
-
if @config.streaming
|
|
170
|
-
start_streaming
|
|
171
|
-
else
|
|
172
|
-
start_polling
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def start_streaming
|
|
177
|
-
@streaming_handler = DataSource::StreamingHandler.new(
|
|
178
|
-
sdk_key: @sdk_key,
|
|
179
|
-
config: @config,
|
|
180
|
-
http_client: @http_client,
|
|
181
|
-
on_flag_updated: ->(flag) { @store.upsert(flag) },
|
|
182
|
-
on_flag_deleted: ->(key) { @store.remove_flag(key) },
|
|
183
|
-
on_segment_updated: ->(flags, segments) { @store.init(flags, segments) },
|
|
184
|
-
on_error: ->(_err) { },
|
|
185
|
-
on_give_up: -> { fallback_to_polling }
|
|
186
|
-
)
|
|
187
|
-
@streaming_handler.start
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def fallback_to_polling
|
|
191
|
-
@config.logger&.warn("Featureflip: streaming retries exhausted, falling back to polling")
|
|
192
|
-
@streaming_handler = nil
|
|
193
|
-
start_polling
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def start_polling
|
|
197
|
-
@polling_handler = DataSource::PollingHandler.new(
|
|
198
|
-
http_client: @http_client,
|
|
199
|
-
config: @config,
|
|
200
|
-
on_update: ->(flags, segments) { @store.init(flags, segments) },
|
|
201
|
-
on_error: ->(_err) { }
|
|
202
|
-
)
|
|
203
|
-
@polling_handler.start
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def start_event_processor
|
|
207
|
-
@event_processor = Events::EventProcessor.new(
|
|
208
|
-
@http_client,
|
|
209
|
-
flush_interval: @config.flush_interval,
|
|
210
|
-
flush_batch_size: @config.flush_batch_size
|
|
211
|
-
)
|
|
212
|
-
@event_processor.start
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def evaluate_flag(key, context, default_value)
|
|
216
|
-
if @test_mode
|
|
217
|
-
return @test_values.fetch(key, default_value)
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
detail = variation_detail(key, context, default_value)
|
|
221
|
-
detail.value
|
|
222
|
-
rescue StandardError
|
|
223
|
-
default_value
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def get_segment(key)
|
|
227
|
-
@store.get_segment(key)
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def normalize_context(context)
|
|
231
|
-
return {} if context.nil?
|
|
232
|
-
context.transform_keys(&:to_s)
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
def record_evaluation(key, context, variation_key)
|
|
236
|
-
return unless @event_processor
|
|
237
|
-
|
|
238
|
-
@event_processor.queue_event({
|
|
239
|
-
type: "Evaluation",
|
|
240
|
-
flagKey: key,
|
|
241
|
-
userId: context["user_id"]&.to_s,
|
|
242
|
-
variation: variation_key,
|
|
243
|
-
timestamp: Time.now.utc.iso8601
|
|
244
|
-
})
|
|
80
|
+
def initialize(core)
|
|
81
|
+
@core = core
|
|
82
|
+
@closed = false
|
|
83
|
+
@close_mutex = Mutex.new
|
|
245
84
|
end
|
|
246
85
|
end
|
|
247
86
|
end
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
require "timeout"
|
|
2
|
+
|
|
3
|
+
module Featureflip
|
|
4
|
+
class SharedCore
|
|
5
|
+
LIVE_CORES = {}
|
|
6
|
+
LIVE_CORES_MUTEX = Mutex.new
|
|
7
|
+
|
|
8
|
+
# --- Class-level factory methods ---
|
|
9
|
+
|
|
10
|
+
def self._get_or_create(sdk_key, config)
|
|
11
|
+
LIVE_CORES_MUTEX.synchronize do
|
|
12
|
+
existing = LIVE_CORES[sdk_key]
|
|
13
|
+
|
|
14
|
+
if existing
|
|
15
|
+
if existing._acquire
|
|
16
|
+
unless _configs_equal(existing._config, config)
|
|
17
|
+
config.logger&.warn(
|
|
18
|
+
"Featureflip: Client.get called with different config for same SDK key. " \
|
|
19
|
+
"Using existing configuration. Close all handles first to apply new config."
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
return existing
|
|
23
|
+
else
|
|
24
|
+
# Stale entry — remove and replace
|
|
25
|
+
LIVE_CORES.delete(sdk_key)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
core = new(sdk_key: sdk_key, config: config)
|
|
30
|
+
LIVE_CORES[sdk_key] = core
|
|
31
|
+
core
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self._create_for_testing(flags)
|
|
36
|
+
core = allocate
|
|
37
|
+
core.send(:init_test_mode, flags)
|
|
38
|
+
core
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self._reset_for_testing
|
|
42
|
+
cores_to_release = LIVE_CORES_MUTEX.synchronize do
|
|
43
|
+
snapshot = LIVE_CORES.values.dup
|
|
44
|
+
LIVE_CORES.clear
|
|
45
|
+
snapshot
|
|
46
|
+
end
|
|
47
|
+
cores_to_release.each { |c| c._release }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# --- Instance methods ---
|
|
51
|
+
|
|
52
|
+
def initialize(sdk_key:, config:)
|
|
53
|
+
@sdk_key = sdk_key
|
|
54
|
+
@config = config
|
|
55
|
+
@store = Store::FlagStore.new
|
|
56
|
+
@evaluator = Evaluation::Evaluator.new
|
|
57
|
+
@initialized = false
|
|
58
|
+
@closed = false
|
|
59
|
+
@test_mode = false
|
|
60
|
+
@test_values = {}
|
|
61
|
+
@http_client = nil
|
|
62
|
+
@streaming_handler = nil
|
|
63
|
+
@polling_handler = nil
|
|
64
|
+
@event_processor = nil
|
|
65
|
+
@ref_count = 1
|
|
66
|
+
@ref_mutex = Mutex.new
|
|
67
|
+
@shut_down = false
|
|
68
|
+
|
|
69
|
+
bootstrap!
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def _acquire
|
|
73
|
+
@ref_mutex.synchronize do
|
|
74
|
+
return false if @ref_count <= 0
|
|
75
|
+
@ref_count += 1
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def _release
|
|
81
|
+
run_shutdown = false
|
|
82
|
+
@ref_mutex.synchronize do
|
|
83
|
+
return if @ref_count <= 0
|
|
84
|
+
@ref_count -= 1
|
|
85
|
+
if @ref_count == 0 && !@shut_down
|
|
86
|
+
@shut_down = true
|
|
87
|
+
run_shutdown = true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
_shutdown if run_shutdown
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def _config
|
|
94
|
+
@config
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def _ref_count
|
|
98
|
+
@ref_mutex.synchronize { @ref_count }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def initialized?
|
|
102
|
+
@initialized
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# --- Evaluation methods ---
|
|
106
|
+
|
|
107
|
+
def bool_variation(key, context, default_value)
|
|
108
|
+
evaluate_flag(key, context, default_value)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def string_variation(key, context, default_value)
|
|
112
|
+
evaluate_flag(key, context, default_value)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def number_variation(key, context, default_value)
|
|
116
|
+
evaluate_flag(key, context, default_value)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def json_variation(key, context, default_value)
|
|
120
|
+
evaluate_flag(key, context, default_value)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def variation_detail(key, context, default_value)
|
|
124
|
+
context = normalize_context(context)
|
|
125
|
+
|
|
126
|
+
if @test_mode
|
|
127
|
+
value = @test_values.fetch(key, default_value)
|
|
128
|
+
reason = @test_values.key?(key) ? "Fallthrough" : "FlagNotFound"
|
|
129
|
+
return Models::EvaluationDetail.new(value: value, reason: reason)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
flag = @store.get_flag(key)
|
|
133
|
+
unless flag
|
|
134
|
+
record_evaluation(key, context, nil)
|
|
135
|
+
return Models::EvaluationDetail.new(value: default_value, reason: "FlagNotFound")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
result = @evaluator.evaluate(flag, context, get_segment: method(:get_segment))
|
|
139
|
+
value = result.value.nil? ? default_value : result.value
|
|
140
|
+
record_evaluation(key, context, result.variation_key)
|
|
141
|
+
|
|
142
|
+
Models::EvaluationDetail.new(
|
|
143
|
+
value: value,
|
|
144
|
+
reason: result.reason,
|
|
145
|
+
rule_id: result.rule_id,
|
|
146
|
+
variation_key: result.variation_key
|
|
147
|
+
)
|
|
148
|
+
rescue StandardError
|
|
149
|
+
Models::EvaluationDetail.new(value: default_value, reason: "Error")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# --- Event methods ---
|
|
153
|
+
|
|
154
|
+
def track(event_key, context, metadata = nil)
|
|
155
|
+
return unless @event_processor
|
|
156
|
+
|
|
157
|
+
context = normalize_context(context)
|
|
158
|
+
@event_processor.queue_event({
|
|
159
|
+
type: "Custom",
|
|
160
|
+
flagKey: event_key,
|
|
161
|
+
userId: context["user_id"]&.to_s,
|
|
162
|
+
metadata: metadata || {},
|
|
163
|
+
timestamp: Time.now.utc.iso8601
|
|
164
|
+
})
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def identify(context)
|
|
168
|
+
return unless @event_processor
|
|
169
|
+
|
|
170
|
+
context = normalize_context(context)
|
|
171
|
+
@event_processor.queue_event({
|
|
172
|
+
type: "Identify",
|
|
173
|
+
flagKey: "$identify",
|
|
174
|
+
userId: context["user_id"]&.to_s,
|
|
175
|
+
timestamp: Time.now.utc.iso8601
|
|
176
|
+
})
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def flush
|
|
180
|
+
@event_processor&.flush
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def restart
|
|
184
|
+
return if @shut_down
|
|
185
|
+
|
|
186
|
+
@streaming_handler&.stop
|
|
187
|
+
@polling_handler&.stop
|
|
188
|
+
@event_processor&.stop
|
|
189
|
+
|
|
190
|
+
if @config.streaming
|
|
191
|
+
start_streaming
|
|
192
|
+
else
|
|
193
|
+
start_polling
|
|
194
|
+
end
|
|
195
|
+
start_event_processor if @config.send_events
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def _shutdown
|
|
201
|
+
LIVE_CORES_MUTEX.synchronize do
|
|
202
|
+
LIVE_CORES.delete(@sdk_key) if LIVE_CORES[@sdk_key].equal?(self)
|
|
203
|
+
end
|
|
204
|
+
_shutdown_internal
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def _shutdown_internal
|
|
208
|
+
@closed = true
|
|
209
|
+
begin
|
|
210
|
+
@streaming_handler&.stop
|
|
211
|
+
rescue StandardError
|
|
212
|
+
# ignore
|
|
213
|
+
end
|
|
214
|
+
@streaming_handler = nil
|
|
215
|
+
|
|
216
|
+
begin
|
|
217
|
+
@polling_handler&.stop
|
|
218
|
+
rescue StandardError
|
|
219
|
+
# ignore
|
|
220
|
+
end
|
|
221
|
+
@polling_handler = nil
|
|
222
|
+
|
|
223
|
+
begin
|
|
224
|
+
@event_processor&.stop
|
|
225
|
+
rescue StandardError
|
|
226
|
+
# ignore
|
|
227
|
+
end
|
|
228
|
+
@event_processor = nil
|
|
229
|
+
|
|
230
|
+
@config.logger&.info("Featureflip: core shut down for SDK key #{@sdk_key}")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def bootstrap!
|
|
234
|
+
@http_client = Http::Client.new(@sdk_key, @config)
|
|
235
|
+
fetch_initial_flags
|
|
236
|
+
start_data_source
|
|
237
|
+
start_event_processor if @config.send_events
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def fetch_initial_flags
|
|
241
|
+
Timeout.timeout(@config.init_timeout) do
|
|
242
|
+
flags, segments = @http_client.get_flags
|
|
243
|
+
@store.init(flags, segments)
|
|
244
|
+
@initialized = true
|
|
245
|
+
end
|
|
246
|
+
rescue Timeout::Error
|
|
247
|
+
raise InitializationError, "Initialization timed out after #{@config.init_timeout}s"
|
|
248
|
+
rescue InitializationError
|
|
249
|
+
raise
|
|
250
|
+
rescue StandardError => e
|
|
251
|
+
raise InitializationError, "Failed to initialize: #{e.message}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def start_data_source
|
|
255
|
+
return if @closed
|
|
256
|
+
|
|
257
|
+
if @config.streaming
|
|
258
|
+
start_streaming
|
|
259
|
+
else
|
|
260
|
+
start_polling
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def start_streaming
|
|
265
|
+
@streaming_handler = DataSource::StreamingHandler.new(
|
|
266
|
+
sdk_key: @sdk_key,
|
|
267
|
+
config: @config,
|
|
268
|
+
http_client: @http_client,
|
|
269
|
+
on_flag_updated: ->(flag) { @store.upsert(flag) },
|
|
270
|
+
on_flag_deleted: ->(key) { @store.remove_flag(key) },
|
|
271
|
+
on_segment_updated: ->(flags, segments) { @store.init(flags, segments) },
|
|
272
|
+
on_error: ->(_err) { },
|
|
273
|
+
on_give_up: -> { fallback_to_polling }
|
|
274
|
+
)
|
|
275
|
+
@streaming_handler.start
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def fallback_to_polling
|
|
279
|
+
@config.logger&.warn("Featureflip: streaming retries exhausted, falling back to polling")
|
|
280
|
+
@streaming_handler = nil
|
|
281
|
+
start_polling
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def start_polling
|
|
285
|
+
@polling_handler = DataSource::PollingHandler.new(
|
|
286
|
+
http_client: @http_client,
|
|
287
|
+
config: @config,
|
|
288
|
+
on_update: ->(flags, segments) { @store.init(flags, segments) },
|
|
289
|
+
on_error: ->(_err) { }
|
|
290
|
+
)
|
|
291
|
+
@polling_handler.start
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def start_event_processor
|
|
295
|
+
@event_processor = Events::EventProcessor.new(
|
|
296
|
+
@http_client,
|
|
297
|
+
flush_interval: @config.flush_interval,
|
|
298
|
+
flush_batch_size: @config.flush_batch_size
|
|
299
|
+
)
|
|
300
|
+
@event_processor.start
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def evaluate_flag(key, context, default_value)
|
|
304
|
+
if @test_mode
|
|
305
|
+
return @test_values.fetch(key, default_value)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
detail = variation_detail(key, context, default_value)
|
|
309
|
+
detail.value
|
|
310
|
+
rescue StandardError
|
|
311
|
+
default_value
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def get_segment(key)
|
|
315
|
+
@store.get_segment(key)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def normalize_context(context)
|
|
319
|
+
return {} if context.nil?
|
|
320
|
+
context.transform_keys(&:to_s)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def record_evaluation(key, context, variation_key)
|
|
324
|
+
return unless @event_processor
|
|
325
|
+
|
|
326
|
+
@event_processor.queue_event({
|
|
327
|
+
type: "Evaluation",
|
|
328
|
+
flagKey: key,
|
|
329
|
+
userId: context["user_id"]&.to_s,
|
|
330
|
+
variation: variation_key,
|
|
331
|
+
timestamp: Time.now.utc.iso8601
|
|
332
|
+
})
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def init_test_mode(flags)
|
|
336
|
+
@sdk_key = "test-key"
|
|
337
|
+
@config = Config.new
|
|
338
|
+
@store = Store::FlagStore.new
|
|
339
|
+
@evaluator = Evaluation::Evaluator.new
|
|
340
|
+
@initialized = true
|
|
341
|
+
@closed = false
|
|
342
|
+
@test_mode = true
|
|
343
|
+
@test_values = flags.dup
|
|
344
|
+
@http_client = nil
|
|
345
|
+
@streaming_handler = nil
|
|
346
|
+
@polling_handler = nil
|
|
347
|
+
@event_processor = nil
|
|
348
|
+
@ref_count = 1
|
|
349
|
+
@ref_mutex = Mutex.new
|
|
350
|
+
@shut_down = false
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def self._configs_equal(a, b)
|
|
354
|
+
a.base_url == b.base_url &&
|
|
355
|
+
a.streaming == b.streaming &&
|
|
356
|
+
a.poll_interval == b.poll_interval &&
|
|
357
|
+
a.flush_interval == b.flush_interval &&
|
|
358
|
+
a.flush_batch_size == b.flush_batch_size &&
|
|
359
|
+
a.init_timeout == b.init_timeout &&
|
|
360
|
+
a.connect_timeout == b.connect_timeout &&
|
|
361
|
+
a.read_timeout == b.read_timeout &&
|
|
362
|
+
a.send_events == b.send_events
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
private_class_method :_configs_equal
|
|
366
|
+
end
|
|
367
|
+
end
|
data/lib/featureflip/version.rb
CHANGED
data/lib/featureflip.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "featureflip/events/event"
|
|
|
13
13
|
require_relative "featureflip/events/event_processor"
|
|
14
14
|
require_relative "featureflip/data_source/streaming"
|
|
15
15
|
require_relative "featureflip/data_source/polling"
|
|
16
|
+
require_relative "featureflip/shared_core"
|
|
16
17
|
require_relative "featureflip/client"
|
|
17
18
|
|
|
18
19
|
module Featureflip
|
|
@@ -26,7 +27,7 @@ module Featureflip
|
|
|
26
27
|
@config = Config.new
|
|
27
28
|
yield @config if block_given?
|
|
28
29
|
@config.validate!
|
|
29
|
-
@default_client = Client.
|
|
30
|
+
@default_client = Client.get(@config.sdk_key, config: @config)
|
|
30
31
|
end
|
|
31
32
|
end
|
|
32
33
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: featureflip
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Featureflip
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|
|
@@ -73,6 +73,7 @@ files:
|
|
|
73
73
|
- lib/featureflip/models/evaluation_detail.rb
|
|
74
74
|
- lib/featureflip/models/flag.rb
|
|
75
75
|
- lib/featureflip/models/segment.rb
|
|
76
|
+
- lib/featureflip/shared_core.rb
|
|
76
77
|
- lib/featureflip/store/flag_store.rb
|
|
77
78
|
- lib/featureflip/version.rb
|
|
78
79
|
homepage: https://featureflip.io
|