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 +7 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/lib/flagkit/client.rb +443 -0
- data/lib/flagkit/core/cache.rb +162 -0
- data/lib/flagkit/core/encrypted_cache.rb +227 -0
- data/lib/flagkit/core/event_persistence.rb +513 -0
- data/lib/flagkit/core/event_queue.rb +190 -0
- data/lib/flagkit/core/polling_manager.rb +112 -0
- data/lib/flagkit/core/streaming_manager.rb +469 -0
- data/lib/flagkit/error/error_code.rb +98 -0
- data/lib/flagkit/error/error_sanitizer.rb +48 -0
- data/lib/flagkit/error/flagkit_error.rb +95 -0
- data/lib/flagkit/http/circuit_breaker.rb +145 -0
- data/lib/flagkit/http/http_client.rb +312 -0
- data/lib/flagkit/options.rb +222 -0
- data/lib/flagkit/types/evaluation_context.rb +121 -0
- data/lib/flagkit/types/evaluation_reason.rb +22 -0
- data/lib/flagkit/types/evaluation_result.rb +77 -0
- data/lib/flagkit/types/flag_state.rb +100 -0
- data/lib/flagkit/types/flag_type.rb +46 -0
- data/lib/flagkit/utils/security.rb +528 -0
- data/lib/flagkit/utils/version.rb +116 -0
- data/lib/flagkit/version.rb +5 -0
- data/lib/flagkit.rb +166 -0
- metadata +200 -0
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
|