featbit-server-sdk 0.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.
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module FeatBit
6
+ class Client
7
+ attr_reader :options, :status_provider, :event_processor, :data_store
8
+
9
+ def initialize(options = Options.new)
10
+ @options = options.is_a?(Options) ? options : Options.new(offline: true)
11
+ @data_store = @options.data_store || InMemoryDataStore.new
12
+ initial_status = @options.offline ? Status::OFFLINE : Status::STARTING
13
+ @status_provider = StatusProvider.new(initial_status, logger: @options.logger)
14
+ @listeners = {}
15
+ @listener_id = 0
16
+ @listener_mutex = Mutex.new
17
+ @lifecycle_mutex = Mutex.new
18
+ @closed = false
19
+ @close_result = nil
20
+
21
+ load_bootstrap(@options.bootstrap) if @options.bootstrap
22
+ @event_processor = build_event_processor
23
+ @evaluator = Evaluator.new(
24
+ flag_getter: ->(key) { @data_store.flag(key) },
25
+ segment_getter: ->(id) { @data_store.segment(id) },
26
+ logger: @options.logger
27
+ )
28
+ start_synchronizer unless @options.offline
29
+ @status_provider.update(Status::READY) if @options.offline && @data_store.initialized?
30
+ @status_provider.wait_until_ready(@options.start_wait) if !@options.offline && @options.start_wait.positive?
31
+ rescue StandardError => e
32
+ safe_log(:error, "FeatBit client initialization failed: #{e.message}")
33
+ @status_provider ||= StatusProvider.new(Status::FAILED)
34
+ @status_provider.update(Status::FAILED, message: e.message)
35
+ @event_processor ||= NullEventProcessor.new
36
+ end
37
+
38
+ def initialized?
39
+ @data_store.initialized?
40
+ rescue StandardError
41
+ false
42
+ end
43
+
44
+ def variation(flag_key, user, default_value)
45
+ variation_detail(flag_key, user, default_value).value
46
+ rescue StandardError => e
47
+ safe_log(:error, "FeatBit variation failed: #{e.message}")
48
+ default_value
49
+ end
50
+
51
+ def variation_detail(flag_key, user, default_value)
52
+ return safe_detail(flag_key, default_value, :client_not_ready) if @closed || !evaluable?
53
+
54
+ normalized_user = normalize_user(user)
55
+ detail = @evaluator.evaluate(flag_key, normalized_user, default_value)
56
+ enqueue_evaluation(detail, normalized_user) if detail.success?
57
+ detail
58
+ rescue StandardError => e
59
+ safe_log(:error, "FeatBit variation detail failed: #{e.message}")
60
+ safe_detail(flag_key, default_value, :error, e.message)
61
+ end
62
+
63
+ def bool_variation(flag_key, user, default_value = false)
64
+ typed_variation(flag_key, user, default_value, [TrueClass, FalseClass])
65
+ end
66
+
67
+ def string_variation(flag_key, user, default_value = "")
68
+ typed_variation(flag_key, user, default_value, [String])
69
+ end
70
+
71
+ def number_variation(flag_key, user, default_value = 0)
72
+ typed_variation(flag_key, user, default_value, [Numeric])
73
+ end
74
+
75
+ def json_variation(flag_key, user, default_value = {})
76
+ typed_variation(flag_key, user, default_value, [Hash, Array])
77
+ end
78
+
79
+ def track(user, event_name, numeric_value = 1.0)
80
+ normalized_user = normalize_user(user)
81
+ return false unless normalized_user.valid? && !event_name.to_s.empty?
82
+
83
+ @event_processor.enqueue(
84
+ user: normalized_user.to_h,
85
+ metrics: [{
86
+ eventName: event_name.to_s,
87
+ numericValue: numeric_value.to_f,
88
+ route: "index/metric",
89
+ type: "CustomEvent",
90
+ appType: "ruby-server-side",
91
+ timestamp: timestamp_ms
92
+ }]
93
+ )
94
+ rescue StandardError => e
95
+ safe_log(:warn, "FeatBit track failed: #{e.message}")
96
+ false
97
+ end
98
+
99
+ def flush
100
+ @event_processor.flush
101
+ rescue StandardError
102
+ false
103
+ end
104
+
105
+ def add_flag_change_listener(callable = nil, &block)
106
+ listener = callable || block
107
+ return nil unless listener.respond_to?(:call)
108
+
109
+ @listener_mutex.synchronize do
110
+ @listener_id += 1
111
+ @listeners[@listener_id] = listener
112
+ @listener_id
113
+ end
114
+ rescue StandardError
115
+ nil
116
+ end
117
+
118
+ def remove_flag_change_listener(id)
119
+ @listener_mutex.synchronize { !@listeners.delete(id).nil? }
120
+ rescue StandardError
121
+ false
122
+ end
123
+
124
+ def close
125
+ @lifecycle_mutex.synchronize do
126
+ return @close_result unless @close_result.nil?
127
+ return true if @closed
128
+
129
+ @closed = true
130
+ end
131
+
132
+ results = [safe_close(@synchronizer), safe_close(@event_processor)]
133
+ results << safe_status_update(Status::CLOSED)
134
+ @lifecycle_mutex.synchronize { @close_result = results.all? }
135
+ rescue StandardError => e
136
+ safe_log(:warn, "FeatBit client close failed: #{e.message}")
137
+ false
138
+ end
139
+
140
+ alias stop close
141
+
142
+ private
143
+
144
+ def evaluable?
145
+ @data_store.initialized? && [Status::READY, Status::OFFLINE, Status::INTERRUPTED].include?(@status_provider.status)
146
+ end
147
+
148
+ def load_bootstrap(bootstrap)
149
+ success = @data_store.init(bootstrap)
150
+ @status_provider.update(success ? Status::READY : Status::FAILED, message: success ? nil : "invalid bootstrap")
151
+ rescue StandardError => e
152
+ @status_provider.update(Status::FAILED, message: e.message)
153
+ end
154
+
155
+ def build_event_processor
156
+ return NullEventProcessor.new if @options.offline || @options.disable_events || !@options.valid?
157
+ return @options.event_processor_factory.call(@options) if @options.event_processor_factory
158
+
159
+ EventProcessor.new(@options)
160
+ rescue StandardError => e
161
+ safe_log(:warn, "FeatBit event processor unavailable: #{e.message}")
162
+ NullEventProcessor.new
163
+ end
164
+
165
+ def start_synchronizer
166
+ unless @options.valid?
167
+ @status_provider.update(Status::FAILED, message: "invalid options")
168
+ return
169
+ end
170
+
171
+ @synchronizer = if @options.synchronizer_factory
172
+ @options.synchronizer_factory.call(@options, @data_store, @status_provider, method(:broadcast_flag_change))
173
+ else
174
+ WebSocketDataSynchronizer.new(
175
+ options: @options,
176
+ data_store: @data_store,
177
+ status_provider: @status_provider,
178
+ on_flags_changed: method(:broadcast_flag_change)
179
+ )
180
+ end
181
+ @synchronizer.start
182
+ rescue StandardError => e
183
+ @status_provider.update(Status::FAILED, message: e.message)
184
+ end
185
+
186
+ def typed_variation(flag_key, user, default_value, expected_types)
187
+ value = variation(flag_key, user, default_value)
188
+ expected_types.any? { |type| value.is_a?(type) } ? value : default_value
189
+ rescue StandardError
190
+ default_value
191
+ end
192
+
193
+ def normalize_user(user)
194
+ return user if user.is_a?(User)
195
+ return User.new(user) if user.is_a?(String)
196
+
197
+ if user.is_a?(Hash)
198
+ key = user["key"] || user[:key] || user["keyId"] || user[:keyId] || user["targeting_key"] || user[:targeting_key]
199
+ name = user["name"] || user[:name]
200
+ custom = user.reject { |candidate, _| %w[key keyId targeting_key name].include?(candidate.to_s) }
201
+ return User.new(key, name: name, custom: custom)
202
+ end
203
+
204
+ User.new("")
205
+ rescue StandardError
206
+ User.new("")
207
+ end
208
+
209
+ def enqueue_evaluation(detail, user)
210
+ @event_processor.enqueue(
211
+ user: user.to_h,
212
+ variations: [{
213
+ featureFlagKey: detail.flag_key,
214
+ sendToExperiment: detail.send_to_experiment == true,
215
+ timestamp: timestamp_ms,
216
+ variation: { id: detail.variation_id, value: serialize_variation_value(detail.value), reason: detail.reason }
217
+ }]
218
+ )
219
+ end
220
+
221
+ def serialize_variation_value(value)
222
+ case value
223
+ when String then value
224
+ when TrueClass then "true"
225
+ when FalseClass then "false"
226
+ when Hash, Array then JSON.generate(value)
227
+ when nil then ""
228
+ else value.to_s
229
+ end
230
+ rescue StandardError
231
+ ""
232
+ end
233
+
234
+ def broadcast_flag_change(flag_key)
235
+ listeners = @listener_mutex.synchronize { @listeners.values.dup }
236
+ listeners.each do |listener|
237
+ listener.call(flag_key)
238
+ rescue StandardError => e
239
+ safe_log(:warn, "FeatBit flag listener failed: #{e.message}")
240
+ end
241
+ true
242
+ rescue StandardError
243
+ false
244
+ end
245
+
246
+ def safe_detail(flag_key, default_value, reason, message = nil)
247
+ EvaluationDetail.new(
248
+ value: default_value,
249
+ reason: Evaluator::REASONS.fetch(reason),
250
+ flag_key: flag_key,
251
+ error_kind: reason,
252
+ error_message: message
253
+ )
254
+ end
255
+
256
+ def safe_log(level, message)
257
+ @options&.logger&.public_send(level, message)
258
+ rescue StandardError
259
+ nil
260
+ end
261
+
262
+ def safe_close(component)
263
+ return true unless component
264
+
265
+ component.close != false
266
+ rescue StandardError => e
267
+ safe_log(:warn, "FeatBit component close failed: #{e.message}")
268
+ false
269
+ end
270
+
271
+ def safe_status_update(status)
272
+ @status_provider.update(status) != false
273
+ rescue StandardError => e
274
+ safe_log(:warn, "FeatBit status update failed: #{e.message}")
275
+ false
276
+ end
277
+
278
+ def timestamp_ms
279
+ (Time.now.to_f * 1000).round
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module FeatBit
7
+ class InMemoryDataStore
8
+ def initialize
9
+ @flags = {}.freeze
10
+ @segments = {}.freeze
11
+ @flag_versions = {}.freeze
12
+ @segment_versions = {}.freeze
13
+ @version = 0
14
+ @initialized = false
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def initialized?
19
+ @mutex.synchronize { @initialized }
20
+ rescue StandardError
21
+ false
22
+ end
23
+
24
+ def version
25
+ @mutex.synchronize { @version }
26
+ rescue StandardError
27
+ 0
28
+ end
29
+
30
+ def flag(key)
31
+ item = @mutex.synchronize { @flags[key.to_s] }
32
+ item && !fetch(item, "isArchived", false) ? deep_dup(item) : nil
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ def segment(id)
38
+ item = @mutex.synchronize { @segments[id.to_s] }
39
+ item && !fetch(item, "isArchived", false) ? deep_dup(item) : nil
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ def all_flags
45
+ snapshot = @mutex.synchronize { @flags }
46
+ snapshot.each_with_object({}) do |(key, value), result|
47
+ result[key] = deep_dup(value) unless fetch(value, "isArchived", false)
48
+ end
49
+ rescue StandardError
50
+ {}
51
+ end
52
+
53
+ def init(payload, version: nil)
54
+ data = normalize_payload(payload)
55
+ flags = Array(fetch(data, "featureFlags", [])).to_h do |flag|
56
+ [fetch(flag, "key").to_s, normalize_flag(flag)]
57
+ end
58
+ segments = Array(fetch(data, "segments", [])).to_h do |segment|
59
+ [fetch(segment, "id").to_s, normalize_hash(segment)]
60
+ end
61
+ incoming_version = version || derive_version(flags.values + segments.values)
62
+ incoming_version = 1 if incoming_version.to_i <= 0
63
+ flag_versions = entity_versions(flags, version)
64
+ segment_versions = entity_versions(segments, version)
65
+
66
+ @mutex.synchronize do
67
+ return false if @initialized && incoming_version.to_i <= @version
68
+
69
+ @flags = deep_freeze(flags)
70
+ @segments = deep_freeze(segments)
71
+ @flag_versions = deep_freeze(flag_versions)
72
+ @segment_versions = deep_freeze(segment_versions)
73
+ @version = incoming_version.to_i
74
+ @initialized = true
75
+ end
76
+ true
77
+ rescue StandardError
78
+ false
79
+ end
80
+
81
+ def upsert(kind, item, version: nil)
82
+ normalized = normalize_hash(item)
83
+ key = kind.to_sym == :flags ? fetch(normalized, "key") : fetch(normalized, "id")
84
+ return false if key.to_s.empty?
85
+
86
+ incoming_version = version || item_version(normalized)
87
+ incoming_version = @mutex.synchronize { @version + 1 } if incoming_version.to_i <= 0
88
+ @mutex.synchronize do
89
+ if kind.to_sym == :flags
90
+ return false if incoming_version.to_i <= @flag_versions.fetch(key.to_s, 0)
91
+
92
+ copy = @flags.dup
93
+ version_copy = @flag_versions.dup
94
+ copy[key.to_s] = normalize_flag(normalized)
95
+ version_copy[key.to_s] = incoming_version.to_i
96
+ @flags = deep_freeze(copy)
97
+ @flag_versions = deep_freeze(version_copy)
98
+ else
99
+ return false if incoming_version.to_i <= @segment_versions.fetch(key.to_s, 0)
100
+
101
+ copy = @segments.dup
102
+ version_copy = @segment_versions.dup
103
+ copy[key.to_s] = normalized
104
+ version_copy[key.to_s] = incoming_version.to_i
105
+ @segments = deep_freeze(copy)
106
+ @segment_versions = deep_freeze(version_copy)
107
+ end
108
+ @version = [@version, incoming_version.to_i].max
109
+ @initialized = true
110
+ end
111
+ true
112
+ rescue StandardError
113
+ false
114
+ end
115
+
116
+ private
117
+
118
+ def normalize_payload(payload)
119
+ parsed = payload.is_a?(String) ? JSON.parse(payload) : payload
120
+ parsed = fetch(parsed, "data", parsed)
121
+ normalize_hash(parsed || {})
122
+ end
123
+
124
+ def normalize_flag(flag)
125
+ normalized = normalize_hash(flag)
126
+ variations = Array(fetch(normalized, "variations", []))
127
+ normalized["variationMap"] = variations.to_h do |variation|
128
+ [fetch(variation, "id").to_s, fetch(variation, "value")]
129
+ end
130
+ normalized
131
+ end
132
+
133
+ def normalize_hash(value)
134
+ case value
135
+ when Hash
136
+ value.each_with_object({}) { |(key, item), result| result[key.to_s] = normalize_hash(item) }
137
+ when Array
138
+ value.map { |item| normalize_hash(item) }
139
+ else
140
+ value
141
+ end
142
+ end
143
+
144
+ def derive_version(items)
145
+ items.map { |item| item_version(item) }.max.to_i
146
+ end
147
+
148
+ def entity_versions(items, snapshot_version)
149
+ items.transform_values do |item|
150
+ next snapshot_version.to_i if snapshot_version
151
+
152
+ version = item_version(item)
153
+ version.positive? ? version : 1
154
+ end
155
+ end
156
+
157
+ def item_version(item)
158
+ explicit = fetch(item, "timestamp")
159
+ return explicit.to_i if explicit
160
+
161
+ updated = fetch(item, "updatedAt")
162
+ updated ? (Time.parse(updated.to_s).to_f * 1000).to_i : 0
163
+ rescue StandardError
164
+ 0
165
+ end
166
+
167
+ def fetch(hash, key, default = nil)
168
+ hash.is_a?(Hash) ? hash.fetch(key.to_s, hash.fetch(key.to_sym, default)) : default
169
+ end
170
+
171
+ def deep_dup(value)
172
+ Marshal.load(Marshal.dump(value))
173
+ end
174
+
175
+ def deep_freeze(value)
176
+ case value
177
+ when Hash
178
+ value.each do |key, item|
179
+ deep_freeze(key)
180
+ deep_freeze(item)
181
+ end
182
+ when Array
183
+ value.each { |item| deep_freeze(item) }
184
+ end
185
+ value.freeze
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatBit
4
+ EvaluationDetail = Struct.new(
5
+ :value, :reason, :variation_id, :flag_key, :flag_name, :send_to_experiment, :error_kind, :error_message,
6
+ keyword_init: true
7
+ ) do
8
+ def success?
9
+ error_kind.nil?
10
+ end
11
+ end
12
+ end