flagstack 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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +379 -0
- data/lib/flagstack/client.rb +127 -0
- data/lib/flagstack/configuration.rb +112 -0
- data/lib/flagstack/poller.rb +66 -0
- data/lib/flagstack/railtie.rb +26 -0
- data/lib/flagstack/synchronizer.rb +81 -0
- data/lib/flagstack/telemetry/metric.rb +33 -0
- data/lib/flagstack/telemetry/metric_storage.rb +32 -0
- data/lib/flagstack/telemetry/submitter.rb +83 -0
- data/lib/flagstack/telemetry.rb +99 -0
- data/lib/flagstack/version.rb +3 -0
- data/lib/flagstack.rb +332 -0
- data/lib/generators/flagstack/install_generator.rb +27 -0
- data/lib/generators/flagstack/templates/flagstack.rb.tt +31 -0
- metadata +87 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 45801e9bdf69b3bbd9637ed2002a4510b8d6da904cb168f0e34e54515547f00e
|
|
4
|
+
data.tar.gz: 126b2472e808d34b95549d06739deb4d54a93700dc9a22220487a9f9e4a87fad
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 83eeef5118a376835521710bc2fbb5da7591d792bd6e5992213a5ef01cd5a678d60aa6219eb7fdc90c31358c6c9409d427c4813316bf6f26112542d37f1e7179
|
|
7
|
+
data.tar.gz: 1a173b66ca308fc1b2188307e61eb4ec280f970623aa5766c30826d0b617e5e0ec569b2a8c870c804ade3831ba78c2748c2e379e7c3bff49ecf01bb09bf9d419
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Flagstack
|
|
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,379 @@
|
|
|
1
|
+
# Flagstack Ruby Client
|
|
2
|
+
|
|
3
|
+
Ruby client for [Flagstack](https://flagstack.io) feature flag management. Drop-in replacement for Flipper Cloud.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "flagstack"
|
|
11
|
+
gem "flipper"
|
|
12
|
+
gem "flipper-active_record" # Recommended: for persistent local storage
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then run the generator:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
rails generate flagstack:install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
Set your API token:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export FLAGSTACK_TOKEN=fs_live_your_token_here
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
That's it! Flagstack auto-configures when `FLAGSTACK_TOKEN` is present. Your existing Flipper code works unchanged:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
Flipper.enabled?(:new_checkout)
|
|
33
|
+
Flipper.enabled?(:beta_feature, current_user)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## How It Works
|
|
37
|
+
|
|
38
|
+
Flagstack mirrors Flipper Cloud's architecture:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
+-----------------+
|
|
42
|
+
| Flagstack |
|
|
43
|
+
| (cloud server) |
|
|
44
|
+
+--------+--------+
|
|
45
|
+
|
|
|
46
|
+
| sync (every 10-30s)
|
|
47
|
+
v
|
|
48
|
+
+--------+--------+
|
|
49
|
+
| Local Adapter |
|
|
50
|
+
| (ActiveRecord |
|
|
51
|
+
| or Memory) |
|
|
52
|
+
+--------+--------+
|
|
53
|
+
|
|
|
54
|
+
| all reads
|
|
55
|
+
v
|
|
56
|
+
+--------+--------+
|
|
57
|
+
| Your App |
|
|
58
|
+
| Flipper.enabled?|
|
|
59
|
+
+-----------------+
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
1. **Flagstack is the source of truth** - Manage flags in the Flagstack UI
|
|
63
|
+
2. **Data syncs to your local adapter** - Background poller keeps local data fresh
|
|
64
|
+
3. **All reads are local** - Zero network latency for `enabled?` checks
|
|
65
|
+
4. **Works offline** - If Flagstack is down, reads continue from local adapter
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
### Basic Configuration
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
# config/initializers/flagstack.rb
|
|
73
|
+
Flagstack.configure do |config|
|
|
74
|
+
config.token = ENV["FLAGSTACK_TOKEN"]
|
|
75
|
+
config.sync_interval = 10 # seconds (minimum 10)
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Full Options
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
Flagstack.configure do |config|
|
|
83
|
+
# Required
|
|
84
|
+
config.token = ENV["FLAGSTACK_TOKEN"]
|
|
85
|
+
|
|
86
|
+
# Server (default: https://flagstack.io)
|
|
87
|
+
config.url = ENV["FLAGSTACK_URL"]
|
|
88
|
+
|
|
89
|
+
# Sync interval in seconds (default: 10, minimum: 10)
|
|
90
|
+
config.sync_interval = 30
|
|
91
|
+
|
|
92
|
+
# Sync method: :poll (background thread) or :manual
|
|
93
|
+
config.sync_method = :poll
|
|
94
|
+
|
|
95
|
+
# Telemetry (usage metrics)
|
|
96
|
+
config.telemetry_enabled = true # default: true
|
|
97
|
+
config.telemetry_interval = 60 # seconds between submissions
|
|
98
|
+
|
|
99
|
+
# HTTP timeouts in seconds
|
|
100
|
+
config.read_timeout = 5
|
|
101
|
+
config.open_timeout = 2
|
|
102
|
+
config.write_timeout = 5
|
|
103
|
+
|
|
104
|
+
# Local adapter (auto-detected if flipper-active_record is present)
|
|
105
|
+
# Falls back to Memory adapter if not specified
|
|
106
|
+
config.local_adapter = Flipper::Adapters::ActiveRecord.new
|
|
107
|
+
|
|
108
|
+
# Logging
|
|
109
|
+
config.logger = Rails.logger
|
|
110
|
+
|
|
111
|
+
# Debug HTTP requests
|
|
112
|
+
config.debug_output = $stderr
|
|
113
|
+
|
|
114
|
+
# Instrumentation (for monitoring)
|
|
115
|
+
config.instrumenter = ActiveSupport::Notifications
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Telemetry
|
|
120
|
+
|
|
121
|
+
Flagstack collects anonymous usage metrics to help you understand feature flag usage patterns. This data powers the Analytics dashboard in Flagstack.
|
|
122
|
+
|
|
123
|
+
### What's Collected
|
|
124
|
+
|
|
125
|
+
- Feature key (which flag was checked)
|
|
126
|
+
- Result (enabled/disabled)
|
|
127
|
+
- Timestamp (rounded to the minute)
|
|
128
|
+
- Count (aggregated locally before submission)
|
|
129
|
+
|
|
130
|
+
### Disabling Telemetry
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
Flagstack.configure do |config|
|
|
134
|
+
config.token = ENV["FLAGSTACK_TOKEN"]
|
|
135
|
+
config.telemetry_enabled = false
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Usage
|
|
140
|
+
|
|
141
|
+
### With Rails (Recommended)
|
|
142
|
+
|
|
143
|
+
When `FLAGSTACK_TOKEN` is set, Flagstack automatically configures itself. You can use either the Flagstack API or Flipper directly:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
# Native Flagstack API (recommended for new projects)
|
|
147
|
+
Flagstack.enabled?(:new_checkout)
|
|
148
|
+
Flagstack.enabled?(:beta_feature, current_user)
|
|
149
|
+
|
|
150
|
+
# Flipper-compatible - existing code works unchanged
|
|
151
|
+
Flipper.enabled?(:new_checkout)
|
|
152
|
+
Flipper.enabled?(:beta_feature, current_user)
|
|
153
|
+
|
|
154
|
+
# Feature objects work too
|
|
155
|
+
Flagstack[:new_checkout].enabled?
|
|
156
|
+
Flipper[:new_checkout].enabled?
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Manual Configuration
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
# config/initializers/flagstack.rb
|
|
163
|
+
flipper = Flagstack.configure do |config|
|
|
164
|
+
config.token = ENV["FLAGSTACK_TOKEN"]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Use the returned Flipper instance directly
|
|
168
|
+
flipper.enabled?(:new_checkout)
|
|
169
|
+
|
|
170
|
+
# Or access it later
|
|
171
|
+
Flagstack.flipper.enabled?(:new_checkout)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Without Global State
|
|
175
|
+
|
|
176
|
+
For multi-tenant apps or testing:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
# Create separate instances (doesn't affect Flagstack.configuration)
|
|
180
|
+
flipper = Flagstack.new(token: "fs_live_xxx")
|
|
181
|
+
flipper.enabled?(:feature)
|
|
182
|
+
flipper.enabled?(:feature, current_user)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Actor Setup
|
|
186
|
+
|
|
187
|
+
For percentage rollouts and actor targeting, your user model needs a `flipper_id`:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
class User < ApplicationRecord
|
|
191
|
+
# Flipper expects this method for actor-based features
|
|
192
|
+
def flipper_id
|
|
193
|
+
"User_#{id}"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Then use it
|
|
198
|
+
Flipper.enabled?(:beta_feature, current_user)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Local Features
|
|
202
|
+
|
|
203
|
+
You can still create local-only features that aren't synced from Flagstack:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
# Enable a local feature
|
|
207
|
+
Flipper.enable(:local_only_feature)
|
|
208
|
+
|
|
209
|
+
# It works alongside Flagstack features
|
|
210
|
+
Flipper.enabled?(:local_only_feature) # true
|
|
211
|
+
Flipper.enabled?(:flagstack_feature) # from Flagstack
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Note: Flagstack sync only affects features that exist in Flagstack. Your local features are preserved.
|
|
215
|
+
|
|
216
|
+
## Token Types
|
|
217
|
+
|
|
218
|
+
| Prefix | Environment | Use |
|
|
219
|
+
|--------|-------------|-----|
|
|
220
|
+
| `fs_live_` | Production | Live traffic |
|
|
221
|
+
| `fs_test_` | Staging | Pre-production testing |
|
|
222
|
+
| `fs_dev_` | Development | Shared development |
|
|
223
|
+
| `fs_personal_` | Personal | Your local machine |
|
|
224
|
+
|
|
225
|
+
## API Reference
|
|
226
|
+
|
|
227
|
+
### Configuration
|
|
228
|
+
|
|
229
|
+
#### `Flagstack.configure { |config| }`
|
|
230
|
+
|
|
231
|
+
Configure and return a Flipper instance. Sets `Flagstack.configuration` and `Flagstack.flipper`.
|
|
232
|
+
|
|
233
|
+
#### `Flagstack.new(options)`
|
|
234
|
+
|
|
235
|
+
Create a standalone Flipper instance without affecting global state.
|
|
236
|
+
|
|
237
|
+
#### `Flagstack.flipper`
|
|
238
|
+
|
|
239
|
+
Returns the configured Flipper instance (after `configure`).
|
|
240
|
+
|
|
241
|
+
### Feature Flag Checks
|
|
242
|
+
|
|
243
|
+
#### `Flagstack.enabled?(feature, actor = nil)`
|
|
244
|
+
|
|
245
|
+
Check if a feature is enabled, optionally for a specific actor.
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
Flagstack.enabled?(:new_checkout)
|
|
249
|
+
Flagstack.enabled?(:beta_feature, current_user)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
#### `Flagstack.disabled?(feature, actor = nil)`
|
|
253
|
+
|
|
254
|
+
Check if a feature is disabled.
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
Flagstack.disabled?(:maintenance_mode)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
#### `Flagstack[feature]`
|
|
261
|
+
|
|
262
|
+
Access a feature object for method chaining.
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
Flagstack[:new_checkout].enabled?
|
|
266
|
+
Flagstack[:new_checkout].enabled?(current_user)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Feature Flag Management
|
|
270
|
+
|
|
271
|
+
#### `Flagstack.enable(feature)` / `Flagstack.disable(feature)`
|
|
272
|
+
|
|
273
|
+
Enable or disable a feature globally.
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
Flagstack.enable(:new_feature)
|
|
277
|
+
Flagstack.disable(:old_feature)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
#### `Flagstack.enable_actor(feature, actor)` / `Flagstack.disable_actor(feature, actor)`
|
|
281
|
+
|
|
282
|
+
Enable or disable a feature for a specific actor.
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
Flagstack.enable_actor(:beta_feature, current_user)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
#### `Flagstack.enable_group(feature, group)` / `Flagstack.disable_group(feature, group)`
|
|
289
|
+
|
|
290
|
+
Enable or disable a feature for a registered group.
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
Flagstack.register(:admins) { |actor| actor.admin? }
|
|
294
|
+
Flagstack.enable_group(:admin_tools, :admins)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
#### `Flagstack.enable_percentage_of_actors(feature, percentage)`
|
|
298
|
+
|
|
299
|
+
Enable a feature for a percentage of actors (deterministic based on actor ID).
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
Flagstack.enable_percentage_of_actors(:new_feature, 25) # 25% of users
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
#### `Flagstack.features`
|
|
306
|
+
|
|
307
|
+
List all features.
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
Flagstack.features.each { |f| puts f.key }
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Utilities
|
|
314
|
+
|
|
315
|
+
#### `Flagstack.sync`
|
|
316
|
+
|
|
317
|
+
Force a sync from Flagstack to the local adapter.
|
|
318
|
+
|
|
319
|
+
#### `Flagstack.health_check`
|
|
320
|
+
|
|
321
|
+
Check connectivity to Flagstack server. Returns `{ ok: true/false, message: "..." }`.
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
result = Flagstack.health_check
|
|
325
|
+
if result[:ok]
|
|
326
|
+
puts "Connected: #{result[:message]}"
|
|
327
|
+
else
|
|
328
|
+
puts "Error: #{result[:message]}"
|
|
329
|
+
end
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
#### `Flagstack.shutdown`
|
|
333
|
+
|
|
334
|
+
Gracefully stop the poller and flush any pending telemetry. Called automatically on Rails shutdown.
|
|
335
|
+
|
|
336
|
+
#### `Flagstack.reset!`
|
|
337
|
+
|
|
338
|
+
Reset everything (clears configuration, stops poller). Useful for testing.
|
|
339
|
+
|
|
340
|
+
## Testing
|
|
341
|
+
|
|
342
|
+
In your test setup:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
RSpec.configure do |config|
|
|
346
|
+
config.before(:each) do
|
|
347
|
+
Flagstack.reset!
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
For isolated tests, use `Flagstack.new` with a test token or stub the HTTP calls:
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
# With WebMock
|
|
356
|
+
stub_request(:get, "https://flagstack.io/api/v1/sync")
|
|
357
|
+
.to_return(
|
|
358
|
+
status: 200,
|
|
359
|
+
body: { features: [{ key: "test_feature", enabled: true, gates: {} }] }.to_json
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
stub_request(:post, "https://flagstack.io/api/v1/telemetry")
|
|
363
|
+
.to_return(status: 202)
|
|
364
|
+
|
|
365
|
+
flipper = Flagstack.new(token: "fs_test_xxx")
|
|
366
|
+
expect(flipper.enabled?(:test_feature)).to be true
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
## Migrating from Flipper Cloud
|
|
370
|
+
|
|
371
|
+
Flagstack is designed as a drop-in replacement:
|
|
372
|
+
|
|
373
|
+
1. Replace `FLIPPER_CLOUD_TOKEN` with `FLAGSTACK_TOKEN`
|
|
374
|
+
2. Replace `gem "flipper-cloud"` with `gem "flagstack"`
|
|
375
|
+
3. Your `Flipper.enabled?` calls work unchanged
|
|
376
|
+
|
|
377
|
+
## License
|
|
378
|
+
|
|
379
|
+
MIT
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Flagstack
|
|
5
|
+
class Client
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@config = config
|
|
8
|
+
@etag = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def sync
|
|
12
|
+
uri = URI("#{@config.url}/api/v1/sync")
|
|
13
|
+
|
|
14
|
+
response = request(:get, uri) do |req|
|
|
15
|
+
req["If-None-Match"] = @etag if @etag
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
case response.code.to_i
|
|
19
|
+
when 200
|
|
20
|
+
@etag = response["ETag"]
|
|
21
|
+
@config.log("Sync successful (ETag: #{@etag})", level: :debug)
|
|
22
|
+
JSON.parse(response.body)
|
|
23
|
+
when 304
|
|
24
|
+
@config.log("Sync not modified (304)", level: :debug)
|
|
25
|
+
nil # Cache is current
|
|
26
|
+
when 401
|
|
27
|
+
raise APIError, "Invalid API token"
|
|
28
|
+
when 429
|
|
29
|
+
@config.log("Rate limited, backing off", level: :warn)
|
|
30
|
+
nil
|
|
31
|
+
else
|
|
32
|
+
raise APIError, "API error: #{response.code} #{response.body}"
|
|
33
|
+
end
|
|
34
|
+
rescue Timeout::Error, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED => e
|
|
35
|
+
@config.log("Connection failed: #{e.message}", level: :error)
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def check(feature_key, actor_id: nil)
|
|
40
|
+
uri = URI("#{@config.url}/api/v1/features/#{feature_key}/check")
|
|
41
|
+
uri.query = URI.encode_www_form(actor_id: actor_id) if actor_id
|
|
42
|
+
|
|
43
|
+
response = request(:get, uri)
|
|
44
|
+
data = JSON.parse(response.body)
|
|
45
|
+
data["enabled"]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check if the Flagstack server is reachable and the token is valid
|
|
49
|
+
# Returns a hash with :ok (boolean) and :message (string)
|
|
50
|
+
def health_check
|
|
51
|
+
uri = URI("#{@config.url}/api/v1/sync")
|
|
52
|
+
|
|
53
|
+
response = request(:get, uri)
|
|
54
|
+
|
|
55
|
+
case response.code.to_i
|
|
56
|
+
when 200, 304
|
|
57
|
+
{ ok: true, message: "Connected to Flagstack" }
|
|
58
|
+
when 401
|
|
59
|
+
{ ok: false, message: "Invalid API token" }
|
|
60
|
+
when 429
|
|
61
|
+
{ ok: true, message: "Connected (rate limited)" }
|
|
62
|
+
else
|
|
63
|
+
{ ok: false, message: "Unexpected response: #{response.code}" }
|
|
64
|
+
end
|
|
65
|
+
rescue Timeout::Error, Net::OpenTimeout, Net::ReadTimeout => e
|
|
66
|
+
{ ok: false, message: "Connection timeout: #{e.message}" }
|
|
67
|
+
rescue Errno::ECONNREFUSED => e
|
|
68
|
+
{ ok: false, message: "Connection refused: #{e.message}" }
|
|
69
|
+
rescue => e
|
|
70
|
+
{ ok: false, message: "Connection failed: #{e.message}" }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def post_telemetry(compressed_body)
|
|
74
|
+
uri = URI("#{@config.url}/api/v1/telemetry")
|
|
75
|
+
|
|
76
|
+
response = request(:post, uri) do |req|
|
|
77
|
+
req["Content-Type"] = "application/json"
|
|
78
|
+
req["Content-Encoding"] = "gzip"
|
|
79
|
+
req.body = compressed_body
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
case response.code.to_i
|
|
83
|
+
when 200..299
|
|
84
|
+
@config.log("Telemetry submitted successfully", level: :debug)
|
|
85
|
+
response
|
|
86
|
+
when 429
|
|
87
|
+
@config.log("Telemetry rate limited", level: :warn)
|
|
88
|
+
nil
|
|
89
|
+
else
|
|
90
|
+
@config.log("Telemetry failed: #{response.code}", level: :error)
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
rescue Timeout::Error, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED => e
|
|
94
|
+
@config.log("Telemetry connection failed: #{e.message}", level: :error)
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def request(method, uri)
|
|
101
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
102
|
+
http.use_ssl = uri.scheme == "https"
|
|
103
|
+
http.read_timeout = @config.read_timeout
|
|
104
|
+
http.open_timeout = @config.open_timeout
|
|
105
|
+
http.write_timeout = @config.write_timeout if http.respond_to?(:write_timeout=)
|
|
106
|
+
|
|
107
|
+
# Debug output
|
|
108
|
+
http.set_debug_output(@config.debug_output) if @config.debug_output
|
|
109
|
+
|
|
110
|
+
request = case method
|
|
111
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
112
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
113
|
+
else raise ArgumentError, "Unknown method: #{method}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
request["Authorization"] = "Bearer #{@config.token}"
|
|
117
|
+
request["User-Agent"] = "flagstack-ruby/#{VERSION}"
|
|
118
|
+
request["Accept"] = "application/json"
|
|
119
|
+
|
|
120
|
+
yield request if block_given?
|
|
121
|
+
|
|
122
|
+
@config.instrument("http_request", method: method, uri: uri.to_s) do
|
|
123
|
+
http.request(request)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
|
|
3
|
+
module Flagstack
|
|
4
|
+
class Configuration
|
|
5
|
+
# Authentication
|
|
6
|
+
attr_accessor :token
|
|
7
|
+
|
|
8
|
+
# Server
|
|
9
|
+
attr_accessor :url
|
|
10
|
+
|
|
11
|
+
# HTTP settings
|
|
12
|
+
attr_accessor :read_timeout, :open_timeout, :write_timeout
|
|
13
|
+
|
|
14
|
+
# Sync settings
|
|
15
|
+
attr_accessor :sync_interval, :sync_method
|
|
16
|
+
|
|
17
|
+
# Local adapter for fallback/caching
|
|
18
|
+
attr_accessor :local_adapter
|
|
19
|
+
|
|
20
|
+
# Telemetry settings
|
|
21
|
+
attr_accessor :telemetry_enabled, :telemetry_interval
|
|
22
|
+
|
|
23
|
+
# Logging
|
|
24
|
+
attr_accessor :logger, :debug_output
|
|
25
|
+
|
|
26
|
+
# Instrumentation (for monitoring systems)
|
|
27
|
+
attr_accessor :instrumenter
|
|
28
|
+
|
|
29
|
+
def initialize(options = {})
|
|
30
|
+
setup_auth(options)
|
|
31
|
+
setup_http(options)
|
|
32
|
+
setup_sync(options)
|
|
33
|
+
setup_telemetry(options)
|
|
34
|
+
setup_logging(options)
|
|
35
|
+
setup_instrumentation(options)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate!
|
|
39
|
+
raise ConfigurationError, "Token is required. Set FLAGSTACK_TOKEN or pass token option." if token.nil? || token.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def log(message, level: :debug)
|
|
43
|
+
logger&.send(level, "[Flagstack] #{message}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def instrument(name, payload = {}, &block)
|
|
47
|
+
if instrumenter
|
|
48
|
+
instrumenter.instrument("flagstack.#{name}", payload, &block)
|
|
49
|
+
elsif block
|
|
50
|
+
yield payload
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def environment_from_token
|
|
55
|
+
return :unknown unless token
|
|
56
|
+
|
|
57
|
+
prefix = token.split("_")[1]
|
|
58
|
+
case prefix
|
|
59
|
+
when "live" then :production
|
|
60
|
+
when "test" then :staging
|
|
61
|
+
when "dev" then :development
|
|
62
|
+
when "personal" then :personal
|
|
63
|
+
else :unknown
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def setup_auth(options)
|
|
70
|
+
@token = options.fetch(:token) { ENV["FLAGSTACK_TOKEN"] }
|
|
71
|
+
@url = options.fetch(:url) { ENV.fetch("FLAGSTACK_URL", "https://flagstack.io") }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def setup_http(options)
|
|
75
|
+
@read_timeout = options.fetch(:read_timeout, 5)
|
|
76
|
+
@open_timeout = options.fetch(:open_timeout, 2)
|
|
77
|
+
@write_timeout = options.fetch(:write_timeout, 5)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def setup_sync(options)
|
|
81
|
+
interval = options.fetch(:sync_interval) { ENV.fetch("FLAGSTACK_SYNC_INTERVAL", 10).to_i }
|
|
82
|
+
@sync_interval = [interval, 10].max # Minimum 10 seconds
|
|
83
|
+
|
|
84
|
+
@sync_method = options.fetch(:sync_method, :poll)
|
|
85
|
+
@local_adapter = options[:local_adapter]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def setup_telemetry(options)
|
|
89
|
+
@telemetry_enabled = options.fetch(:telemetry_enabled) {
|
|
90
|
+
ENV.fetch("FLAGSTACK_TELEMETRY_ENABLED", "true") == "true"
|
|
91
|
+
}
|
|
92
|
+
@telemetry_interval = options.fetch(:telemetry_interval) {
|
|
93
|
+
ENV.fetch("FLAGSTACK_TELEMETRY_INTERVAL", 60).to_i
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def setup_logging(options)
|
|
98
|
+
@debug_output = options[:debug_output]
|
|
99
|
+
@logger = options.fetch(:logger) do
|
|
100
|
+
if defined?(Rails) && Rails.respond_to?(:logger)
|
|
101
|
+
Rails.logger
|
|
102
|
+
else
|
|
103
|
+
Logger.new($stdout, level: Logger::INFO)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def setup_instrumentation(options)
|
|
109
|
+
@instrumenter = options[:instrumenter]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Flagstack
|
|
2
|
+
class Poller
|
|
3
|
+
def initialize(synchronizer, config)
|
|
4
|
+
@synchronizer = synchronizer
|
|
5
|
+
@config = config
|
|
6
|
+
@running = false
|
|
7
|
+
@thread = nil
|
|
8
|
+
@pid = Process.pid
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def start
|
|
12
|
+
return if @running
|
|
13
|
+
|
|
14
|
+
@running = true
|
|
15
|
+
@thread = Thread.new { run }
|
|
16
|
+
@thread.abort_on_exception = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def stop
|
|
20
|
+
@running = false
|
|
21
|
+
@thread&.wakeup rescue nil
|
|
22
|
+
@thread&.join(2)
|
|
23
|
+
@thread = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def running?
|
|
27
|
+
@running && @thread&.alive?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def run
|
|
33
|
+
while @running
|
|
34
|
+
# Check for fork (important for Puma, Unicorn, etc.)
|
|
35
|
+
if forked?
|
|
36
|
+
reset_after_fork
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sleep_with_jitter
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
@synchronizer.sync
|
|
44
|
+
rescue => e
|
|
45
|
+
@config.log("Poll sync failed: #{e.message}", level: :error)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def sleep_with_jitter
|
|
51
|
+
# Add 10% jitter to prevent thundering herd
|
|
52
|
+
jitter = @config.sync_interval * 0.1 * rand
|
|
53
|
+
sleep(@config.sync_interval + jitter)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def forked?
|
|
57
|
+
Process.pid != @pid
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reset_after_fork
|
|
61
|
+
@config.log("Fork detected, resetting poller", level: :debug)
|
|
62
|
+
@pid = Process.pid
|
|
63
|
+
@running = false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|