flagkit 1.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3b5c7ff90342f128e65ed9b5dc73194ed8ba7bdb49dc714504c4c70461c8bedf
4
+ data.tar.gz: 6f28730d3ae15d635ae1180678f2dcbf734b39890b21ac15e4bd2c2825df32ec
5
+ SHA512:
6
+ metadata.gz: 43f83a9bfd0433d3c8dfd15de468052e95d223a08694fa709e94fcc8465289469c8c9f0111885c5191b7d0340370d58b6037b761dce9fc8ef914a47abac9bd08
7
+ data.tar.gz: 194f43bfefd4a7f60988efa6a35683108a8250e864105b6c9ac12a7d9674e0fde2a03fe6a29a678fa8c05624c871346627bf93efa5985552a33ad05ffe37e694
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FlagKit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # FlagKit Ruby SDK
2
+
3
+ Official Ruby SDK for [FlagKit](https://flagkit.dev) feature flag management.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby 3.0+
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'flagkit'
15
+ ```
16
+
17
+ Then execute:
18
+
19
+ ```bash
20
+ bundle install
21
+ ```
22
+
23
+ Or install directly:
24
+
25
+ ```bash
26
+ gem install flagkit
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ```ruby
32
+ require 'flagkit'
33
+
34
+ # Initialize the SDK
35
+ client = FlagKit.initialize('sdk_your_api_key')
36
+
37
+ # Identify the current user
38
+ FlagKit.identify('user-123', plan: 'pro')
39
+
40
+ # Evaluate feature flags
41
+ dark_mode = FlagKit.get_boolean_value('dark-mode', false)
42
+ theme = FlagKit.get_string_value('theme', 'light')
43
+ max_items = FlagKit.get_number_value('max-items', 10)
44
+ config = FlagKit.get_json_value('feature-config', {})
45
+
46
+ # Track events
47
+ FlagKit.track('button_clicked', button: 'signup')
48
+
49
+ # Shutdown when done
50
+ FlagKit.shutdown
51
+ ```
52
+
53
+ ## Features
54
+
55
+ - **Type-safe evaluation** - Boolean, string, number, and JSON flag types
56
+ - **Local caching** - Fast evaluations with configurable TTL and optional encryption
57
+ - **Background polling** - Automatic flag updates
58
+ - **Event tracking** - Analytics with batching and crash-resilient persistence
59
+ - **Resilient** - Circuit breaker, retry with exponential backoff, offline support
60
+ - **Thread-safe** - Safe for concurrent use
61
+ - **Security** - PII detection, request signing, bootstrap verification, timing attack protection
62
+
63
+ ## Configuration Options
64
+
65
+ ```ruby
66
+ client = FlagKit.initialize(
67
+ 'sdk_your_api_key',
68
+ polling_interval: 30, # Seconds between polls
69
+ cache_ttl: 300, # Cache time-to-live in seconds
70
+ cache_enabled: true, # Enable/disable caching
71
+ events_enabled: true, # Enable/disable event tracking
72
+ event_batch_size: 10, # Events per batch
73
+ event_flush_interval: 30, # Seconds between flushes
74
+ timeout: 10, # Request timeout in seconds
75
+ retry_attempts: 3, # Number of retry attempts
76
+ circuit_breaker_threshold: 5, # Failures before circuit opens
77
+ circuit_breaker_reset_timeout: 30, # Seconds before half-open
78
+ local_port: nil # Local dev server port (uses http://localhost:{port}/api/v1)
79
+ )
80
+ ```
81
+
82
+ Security features such as PII detection, request signing, bootstrap verification, cache encryption, evaluation jitter, and error sanitization are also available as configuration options.
83
+
84
+ ## Local Development
85
+
86
+ For local development, use the `local_port` option to connect to a local FlagKit server:
87
+
88
+ ```ruby
89
+ client = FlagKit.initialize(
90
+ 'sdk_your_api_key',
91
+ local_port: 8200 # Uses http://localhost:8200/api/v1
92
+ )
93
+ ```
94
+
95
+ ## Using the Client Directly
96
+
97
+ ```ruby
98
+ client = FlagKit::Client.new(
99
+ FlagKit::Options.new(api_key: 'sdk_your_api_key')
100
+ )
101
+ client.initialize_sdk
102
+
103
+ # Wait for initialization
104
+ client.wait_for_ready(timeout: 5)
105
+
106
+ # Evaluate flags
107
+ result = client.evaluate('my-feature', false)
108
+ puts result.value
109
+ puts result.reason
110
+ puts result.version
111
+
112
+ # Clean up
113
+ client.close
114
+ ```
115
+
116
+ ## Evaluation Context
117
+
118
+ ```ruby
119
+ # Build a context
120
+ context = FlagKit::EvaluationContext.new(
121
+ user_id: 'user-123',
122
+ email: 'user@example.com',
123
+ plan: 'enterprise'
124
+ )
125
+
126
+ # Use with evaluation
127
+ value = FlagKit.get_boolean_value('premium-feature', false, context: context)
128
+
129
+ # Private attributes (stripped before sending to server)
130
+ context = FlagKit::EvaluationContext.new(
131
+ user_id: 'user-123',
132
+ _internal_id: 'hidden' # Underscore prefix = private
133
+ )
134
+ ```
135
+
136
+ ## Error Handling
137
+
138
+ ```ruby
139
+ begin
140
+ FlagKit.initialize('invalid_key')
141
+ rescue FlagKit::Error => e
142
+ puts "Error code: #{e.code}"
143
+ puts "Message: #{e.message}"
144
+ puts "Recoverable: #{e.recoverable?}"
145
+ end
146
+ ```
147
+
148
+ ## API Reference
149
+
150
+ ### Module Methods
151
+
152
+ | Method | Description |
153
+ |--------|-------------|
154
+ | `FlagKit.initialize(api_key, **options)` | Initialize the SDK |
155
+ | `FlagKit.shutdown` | Shutdown and release resources |
156
+ | `FlagKit.initialized?` | Check if SDK is initialized |
157
+ | `FlagKit.identify(user_id, **attributes)` | Set user context |
158
+ | `FlagKit.reset_context` | Clear user context |
159
+ | `FlagKit.get_boolean_value(key, default, context:)` | Get boolean flag |
160
+ | `FlagKit.get_string_value(key, default, context:)` | Get string flag |
161
+ | `FlagKit.get_number_value(key, default, context:)` | Get number flag |
162
+ | `FlagKit.get_json_value(key, default, context:)` | Get JSON flag |
163
+ | `FlagKit.evaluate(key, default, context:)` | Get full evaluation result |
164
+ | `FlagKit.track(event_type, data)` | Track analytics event |
165
+
166
+ ### Client Methods
167
+
168
+ | Method | Description |
169
+ |--------|-------------|
170
+ | `client.initialize_sdk` | Initialize and fetch flags |
171
+ | `client.wait_for_ready(timeout:)` | Wait for initialization |
172
+ | `client.ready?` | Check if ready |
173
+ | `client.identify(user_id, **attributes)` | Set user context |
174
+ | `client.reset_context` | Clear user context |
175
+ | `client.context` | Get current context |
176
+ | `client.evaluate(key, default, context:)` | Evaluate a flag |
177
+ | `client.get_boolean_value(key, default, context:)` | Get boolean value |
178
+ | `client.get_string_value(key, default, context:)` | Get string value |
179
+ | `client.get_number_value(key, default, context:)` | Get number value |
180
+ | `client.get_int_value(key, default, context:)` | Get integer value |
181
+ | `client.get_json_value(key, default, context:)` | Get JSON value |
182
+ | `client.track(event_type, data)` | Track an event |
183
+ | `client.close` | Close and release resources |
184
+
185
+ ## Thread Safety
186
+
187
+ All SDK methods are safe for concurrent use from multiple threads. The client uses internal synchronization (Mutex) to ensure thread-safe access to:
188
+
189
+ - Flag cache
190
+ - Event queue
191
+ - Context management
192
+ - Polling state
193
+
194
+ ## License
195
+
196
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,443 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlagKit
4
+ # The main FlagKit client for evaluating feature flags.
5
+ class Client
6
+ attr_reader :options, :ready
7
+
8
+ # @param options [Options] The client options
9
+ def initialize(options)
10
+ @options = options
11
+ @ready = false
12
+ @ready_mutex = Mutex.new
13
+ @ready_condition = ConditionVariable.new
14
+ @context = EvaluationContext.new
15
+ @context_mutex = Mutex.new
16
+
17
+ setup_components
18
+ end
19
+
20
+ # Initializes the SDK by fetching initial flag state.
21
+ def initialize_sdk
22
+ load_bootstrap if options.bootstrap
23
+ fetch_initial_flags
24
+ start_background_tasks
25
+ mark_ready
26
+ rescue StandardError => e
27
+ log(:error, "Failed to initialize SDK: #{e.message}")
28
+ mark_ready
29
+ raise Error.init_error("Failed to initialize: #{e.message}")
30
+ end
31
+
32
+ # Waits for the SDK to be ready.
33
+ #
34
+ # @param timeout [Integer, nil] Maximum time to wait in seconds
35
+ # @return [Boolean] Whether the SDK is ready
36
+ def wait_for_ready(timeout: nil)
37
+ @ready_mutex.synchronize do
38
+ return true if @ready
39
+
40
+ timeout ? @ready_condition.wait(@ready_mutex, timeout) : wait_until_ready
41
+ @ready
42
+ end
43
+ end
44
+
45
+ # Checks if the SDK is ready.
46
+ #
47
+ # @return [Boolean]
48
+ def ready?
49
+ @ready_mutex.synchronize { @ready }
50
+ end
51
+
52
+ # Sets the user context.
53
+ #
54
+ # @param user_id [String] The user ID
55
+ # @param attributes [Hash] User attributes
56
+ def identify(user_id, **attributes)
57
+ @context_mutex.synchronize do
58
+ @context = EvaluationContext.new(user_id: user_id, **attributes)
59
+ end
60
+ end
61
+
62
+ # Clears the user context.
63
+ def reset_context
64
+ @context_mutex.synchronize { @context = EvaluationContext.new }
65
+ end
66
+
67
+ # Gets the current context.
68
+ #
69
+ # @return [EvaluationContext]
70
+ def context
71
+ @context_mutex.synchronize { @context.dup }
72
+ end
73
+
74
+ # Evaluates a flag and returns the full result.
75
+ #
76
+ # @param key [String] The flag key
77
+ # @param default_value [Object] The default value
78
+ # @param context [EvaluationContext, nil] Optional context override
79
+ # @return [EvaluationResult]
80
+ def evaluate(key, default_value, context: nil)
81
+ apply_evaluation_jitter
82
+ cached_result = try_cached_evaluation(key)
83
+ return cached_result if cached_result
84
+
85
+ fetch_and_cache_flag(key, merge_context(context), default_value)
86
+ end
87
+
88
+ # Gets a boolean flag value.
89
+ #
90
+ # @param key [String] The flag key
91
+ # @param default_value [Boolean] The default value
92
+ # @param context [EvaluationContext, nil] Optional context override
93
+ # @return [Boolean]
94
+ def get_boolean_value(key, default_value, context: nil)
95
+ evaluate(key, default_value, context: context).boolean_value
96
+ end
97
+
98
+ # Gets a string flag value.
99
+ #
100
+ # @param key [String] The flag key
101
+ # @param default_value [String] The default value
102
+ # @param context [EvaluationContext, nil] Optional context override
103
+ # @return [String, nil]
104
+ def get_string_value(key, default_value, context: nil)
105
+ evaluate(key, default_value, context: context).string_value || default_value
106
+ end
107
+
108
+ # Gets a number flag value.
109
+ #
110
+ # @param key [String] The flag key
111
+ # @param default_value [Float] The default value
112
+ # @param context [EvaluationContext, nil] Optional context override
113
+ # @return [Float]
114
+ def get_number_value(key, default_value, context: nil)
115
+ evaluate(key, default_value, context: context).number_value
116
+ end
117
+
118
+ # Gets an integer flag value.
119
+ #
120
+ # @param key [String] The flag key
121
+ # @param default_value [Integer] The default value
122
+ # @param context [EvaluationContext, nil] Optional context override
123
+ # @return [Integer]
124
+ def get_int_value(key, default_value, context: nil)
125
+ evaluate(key, default_value, context: context).int_value
126
+ end
127
+
128
+ # Gets a JSON flag value.
129
+ #
130
+ # @param key [String] The flag key
131
+ # @param default_value [Hash] The default value
132
+ # @param context [EvaluationContext, nil] Optional context override
133
+ # @return [Hash, nil]
134
+ def get_json_value(key, default_value, context: nil)
135
+ evaluate(key, default_value, context: context).json_value || default_value
136
+ end
137
+
138
+ # Checks if a flag exists in the cache.
139
+ #
140
+ # @param key [String] The flag key
141
+ # @return [Boolean]
142
+ def has_flag?(key)
143
+ return false unless options.cache_enabled
144
+
145
+ @cache.has?(key)
146
+ end
147
+
148
+ # Returns all cached flag keys.
149
+ #
150
+ # @return [Array<String>]
151
+ def get_all_flag_keys
152
+ return [] unless options.cache_enabled
153
+
154
+ @cache.keys
155
+ end
156
+
157
+ # Evaluates all cached flags and returns results.
158
+ #
159
+ # @param context [EvaluationContext, nil] Optional context override
160
+ # @return [Hash<String, EvaluationResult>]
161
+ def evaluate_all(context: nil)
162
+ return {} unless options.cache_enabled
163
+
164
+ results = {}
165
+ @cache.keys.each do |key|
166
+ cached = @cache.get(key)
167
+ results[key] = build_result(key, cached, EvaluationReason::CACHED) if cached
168
+ end
169
+ results
170
+ end
171
+
172
+ # Tracks an analytics event.
173
+ #
174
+ # @param event_type [String] The event type
175
+ # @param data [Hash, nil] Optional event data
176
+ def track(event_type, data = nil)
177
+ return unless options.events_enabled && @event_queue
178
+
179
+ @event_queue.enqueue(build_event(event_type, data))
180
+ end
181
+
182
+ # Closes the client and releases resources.
183
+ def close
184
+ @polling_manager.stop
185
+ @event_queue&.stop
186
+ @cache.clear
187
+ log(:info, 'Client closed')
188
+ end
189
+
190
+ private
191
+
192
+ def setup_components
193
+ @circuit_breaker = build_circuit_breaker
194
+ @http_client = build_http_client
195
+ @cache = build_cache
196
+ @polling_manager = build_polling_manager
197
+ @event_queue = build_event_queue if options.events_enabled
198
+ end
199
+
200
+ def build_circuit_breaker
201
+ CircuitBreaker.new(
202
+ failure_threshold: options.circuit_breaker_threshold,
203
+ reset_timeout: options.circuit_breaker_reset_timeout
204
+ )
205
+ end
206
+
207
+ def build_http_client
208
+ HttpClient.new(
209
+ api_key: options.api_key, timeout: options.timeout,
210
+ retry_attempts: options.retry_attempts, circuit_breaker: @circuit_breaker,
211
+ logger: options.logger, secondary_api_key: options.secondary_api_key,
212
+ key_rotation_grace_period: options.key_rotation_grace_period,
213
+ enable_request_signing: options.enable_request_signing
214
+ )
215
+ end
216
+
217
+ def build_cache
218
+ return build_encrypted_cache if options.encrypt_cache
219
+
220
+ Cache.new(ttl: options.cache_ttl, max_size: options.max_cache_size)
221
+ end
222
+
223
+ def build_encrypted_cache
224
+ EncryptedCache.new(
225
+ api_key: options.api_key, ttl: options.cache_ttl,
226
+ max_size: options.max_cache_size, logger: options.logger
227
+ )
228
+ end
229
+
230
+ def build_polling_manager
231
+ PollingManager.new(
232
+ interval: options.polling_interval,
233
+ on_update: method(:poll_for_updates),
234
+ logger: options.logger
235
+ )
236
+ end
237
+
238
+ def build_event_queue
239
+ EventQueue.new(
240
+ batch_size: options.event_batch_size,
241
+ flush_interval: options.event_flush_interval,
242
+ on_flush: method(:send_events),
243
+ logger: options.logger
244
+ )
245
+ end
246
+
247
+ def mark_ready
248
+ @ready_mutex.synchronize do
249
+ @ready = true
250
+ @ready_condition.broadcast
251
+ end
252
+ end
253
+
254
+ def wait_until_ready
255
+ @ready_condition.wait(@ready_mutex) until @ready
256
+ end
257
+
258
+ def try_cached_evaluation(key)
259
+ return nil unless options.cache_enabled
260
+
261
+ cached = @cache.get(key)
262
+ cached ? build_result(key, cached, EvaluationReason::CACHED) : nil
263
+ end
264
+
265
+ def fetch_and_cache_flag(key, effective_context, default_value)
266
+ response = @http_client.post('/sdk/evaluate', {
267
+ key: key, context: effective_context.strip_private_attributes.to_h
268
+ })
269
+ flag_state = FlagState.from_hash(response)
270
+ cache_flag(key, flag_state) if options.cache_enabled
271
+ build_result(key, flag_state, EvaluationReason::SERVER)
272
+ rescue Error => e
273
+ log(:warn, "Evaluation failed for #{key}: #{e.message}")
274
+ EvaluationResult.default_result(key, default_value, EvaluationReason::ERROR)
275
+ end
276
+
277
+ def build_event(event_type, data)
278
+ {
279
+ type: event_type, timestamp: Time.now.utc.iso8601,
280
+ userId: context.user_id, data: data
281
+ }.compact
282
+ end
283
+
284
+ def load_bootstrap
285
+ return unless options.bootstrap.is_a?(Hash)
286
+
287
+ flags = extract_bootstrap_flags(options.bootstrap)
288
+ flags&.each { |flag_data| cache_flag(FlagState.from_hash(flag_data).key, FlagState.from_hash(flag_data)) }
289
+ end
290
+
291
+ def extract_bootstrap_flags(bootstrap)
292
+ return legacy_bootstrap_flags(bootstrap) unless bootstrap_has_flags_key?(bootstrap)
293
+
294
+ flags = bootstrap['flags'] || bootstrap[:flags] || []
295
+ return flags unless should_verify_bootstrap?(bootstrap)
296
+
297
+ verify_and_return_flags(bootstrap, flags)
298
+ end
299
+
300
+ def bootstrap_has_flags_key?(bootstrap)
301
+ bootstrap.key?('flags') || bootstrap.key?(:flags)
302
+ end
303
+
304
+ def should_verify_bootstrap?(bootstrap)
305
+ (bootstrap.key?('signature') || bootstrap.key?(:signature)) && options.bootstrap_verification_enabled
306
+ end
307
+
308
+ def legacy_bootstrap_flags(bootstrap)
309
+ bootstrap.is_a?(Array) ? bootstrap : []
310
+ end
311
+
312
+ def verify_and_return_flags(bootstrap, flags)
313
+ result = Utils::Security.verify_bootstrap_signature(
314
+ bootstrap, options.api_key, max_age_ms: options.bootstrap_verification_max_age
315
+ )
316
+ return flags if result[:valid]
317
+
318
+ handle_bootstrap_verification_failure(result[:error])
319
+ options.bootstrap_verification_on_failure == 'error' ? nil : flags
320
+ end
321
+
322
+ def handle_bootstrap_verification_failure(error_message)
323
+ return raise_bootstrap_error(error_message) if options.bootstrap_verification_on_failure == 'error'
324
+
325
+ log_bootstrap_warning(error_message) unless options.bootstrap_verification_on_failure == 'ignore'
326
+ end
327
+
328
+ def raise_bootstrap_error(error_message)
329
+ raise Error.config_error(ErrorCode::CONFIG_INVALID_BOOTSTRAP, "Bootstrap verification failed: #{error_message}")
330
+ end
331
+
332
+ def log_bootstrap_warning(error_message)
333
+ log(:warn, "Bootstrap verification failed: #{error_message}. Using bootstrap data anyway.")
334
+ end
335
+
336
+ def fetch_initial_flags
337
+ response = @http_client.get('/sdk/init')
338
+ process_flags_response(response)
339
+ check_version_metadata(response)
340
+ rescue Error => e
341
+ log(:warn, "Failed to fetch initial flags: #{e.message}")
342
+ end
343
+
344
+ # Check SDK version metadata from init response and emit appropriate warnings.
345
+ #
346
+ # Per spec, the SDK should parse and surface:
347
+ # - sdkVersionMin: Minimum required version (older may not work)
348
+ # - sdkVersionRecommended: Recommended version for optimal experience
349
+ # - sdkVersionLatest: Latest available version
350
+ # - deprecationWarning: Server-provided deprecation message
351
+ #
352
+ # @param response [Hash] The init response
353
+ def check_version_metadata(response)
354
+ metadata = response['metadata'] || response[:metadata]
355
+ return unless metadata
356
+
357
+ current_version = VERSION
358
+
359
+ # Check for server-provided deprecation warning first
360
+ deprecation_warning = metadata['deprecationWarning'] || metadata[:deprecationWarning]
361
+ if deprecation_warning && !deprecation_warning.empty?
362
+ log(:warn, "Deprecation Warning: #{deprecation_warning}")
363
+ end
364
+
365
+ # Check minimum version requirement
366
+ sdk_version_min = metadata['sdkVersionMin'] || metadata[:sdkVersionMin]
367
+ if sdk_version_min && Utils::Version.less_than?(current_version, sdk_version_min)
368
+ log(:error, "SDK version #{current_version} is below minimum required version #{sdk_version_min}. " \
369
+ "Some features may not work correctly. Please upgrade the SDK.")
370
+ end
371
+
372
+ # Check recommended version
373
+ sdk_version_recommended = metadata['sdkVersionRecommended'] || metadata[:sdkVersionRecommended]
374
+ warned_about_recommended = false
375
+ if sdk_version_recommended && Utils::Version.less_than?(current_version, sdk_version_recommended)
376
+ log(:warn, "SDK version #{current_version} is below recommended version #{sdk_version_recommended}. " \
377
+ "Consider upgrading for the best experience.")
378
+ warned_about_recommended = true
379
+ end
380
+
381
+ # Log if a newer version is available (info level, not a warning)
382
+ # Only log if we haven't already warned about recommended
383
+ sdk_version_latest = metadata['sdkVersionLatest'] || metadata[:sdkVersionLatest]
384
+ if sdk_version_latest &&
385
+ Utils::Version.less_than?(current_version, sdk_version_latest) &&
386
+ !warned_about_recommended
387
+ log(:info, "SDK version #{current_version} - a newer version #{sdk_version_latest} is available.")
388
+ end
389
+ end
390
+
391
+ def start_background_tasks
392
+ @polling_manager.start
393
+ @event_queue&.start
394
+ end
395
+
396
+ def poll_for_updates(last_update_time)
397
+ params = last_update_time ? { since: last_update_time.utc.iso8601 } : {}
398
+ process_flags_response(@http_client.get('/sdk/updates', params))
399
+ end
400
+
401
+ def process_flags_response(response)
402
+ (response['flags'] || []).each do |flag_data|
403
+ cache_flag(FlagState.from_hash(flag_data).key, FlagState.from_hash(flag_data))
404
+ end
405
+ end
406
+
407
+ def send_events(events)
408
+ @http_client.post('/sdk/events/batch', { events: events })
409
+ end
410
+
411
+ def merge_context(override_context)
412
+ current = @context_mutex.synchronize { @context }
413
+ override_context ? current.merge(override_context) : current
414
+ end
415
+
416
+ def get_cached_flag(key)
417
+ return nil unless options.cache_enabled
418
+
419
+ @cache.get(key)
420
+ end
421
+
422
+ def cache_flag(key, flag)
423
+ @cache.set(key, flag) if options.cache_enabled
424
+ end
425
+
426
+ def build_result(key, flag_state, reason)
427
+ EvaluationResult.new(
428
+ flag_key: key, value: flag_state.value, enabled: flag_state.enabled,
429
+ reason: reason, version: flag_state.version
430
+ )
431
+ end
432
+
433
+ def log(level, message)
434
+ options.logger&.send(level, "[FlagKit::Client] #{message}")
435
+ end
436
+
437
+ def apply_evaluation_jitter
438
+ return unless options.evaluation_jitter_enabled
439
+
440
+ sleep(rand(options.evaluation_jitter_min_ms..options.evaluation_jitter_max_ms) / 1000.0)
441
+ end
442
+ end
443
+ end