star_gates 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +3 -0
- data/CHANGELOG.md +14 -1
- data/README.md +35 -10
- data/lib/star_gates/events/base_event.rb +0 -1
- data/lib/star_gates/events/feature_events.rb +3 -3
- data/lib/star_gates/feature.rb +36 -56
- data/lib/star_gates/flags.rb +149 -14
- data/lib/star_gates/hexagonal/feature_aggregate.rb +3 -3
- data/lib/star_gates/hexagonal/feature_service.rb +2 -2
- data/lib/star_gates/jsonrpc_rails_adapter.rb +4 -4
- data/lib/star_gates/telemetry/opentelemetry_adapter.rb +2 -4
- data/lib/star_gates/telemetry/prometheus_adapter.rb +3 -4
- data/lib/star_gates/telemetry/telemetry_adapter.rb +2 -16
- data/lib/star_gates/version.rb +1 -1
- data/release-please-config.json +11 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e4505630aa976c8d13a9aa9eb2aa0ac9a5b0a7e8e281b55edaf7f44aa2101441
|
|
4
|
+
data.tar.gz: 65de7f9b0d81538bc4c85286442fc3195c4a2bcb30c77dcfc43e187649a796b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 00276fbc16dc323e4a66aea9873b7848aaf5a0ff708b2985bc7a60ea5f5b6f13749974b18ab6cfd9d41435ba45ca39a3ef03e354f5f274c03694f07b2ed12920
|
|
7
|
+
data.tar.gz: 53606d3810ee3582c07de3872f162ba7b813d0b671e88684e3d90c3d636bd2230ddedd6d94dff5ac3c1a9b81dbebcb9b66eeae5c89132fed4df2d7762089a15d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## [0.3.0](https://github.com/seuros/star_gates/compare/v0.2.0...v0.3.0) (2026-10-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* share runtime flags through an ActiveSupport::Cache store ([edb39ba](https://github.com/seuros/star_gates/commit/edb39ba43f03278a6e3d70770a14671ccc4eb539))
|
|
9
|
+
|
|
10
|
+
## 0.2.0 (2026-10-28)
|
|
4
11
|
|
|
5
12
|
### Changed
|
|
6
13
|
|
|
14
|
+
- Use explicit rollout `key:` and `groups:` context. Group predicates receive the
|
|
15
|
+
full context; simulation, debugging, and scenario analysis use key-based inputs
|
|
16
|
+
and results. Remove user-specific helpers and telemetry labels.
|
|
7
17
|
- Require Ruby 4.0 or newer.
|
|
8
18
|
- Require StateMachines 0.202.0 or newer.
|
|
9
19
|
- Change the license to BSD-3-Clause.
|
|
@@ -17,6 +27,9 @@
|
|
|
17
27
|
|
|
18
28
|
### Fixed
|
|
19
29
|
|
|
30
|
+
- Keep percentage rollout cohorts stable across workers and restarts with CRC32
|
|
31
|
+
key buckets. Upgrading reassigns cohorts previously based on Ruby's
|
|
32
|
+
process-randomized string hashes.
|
|
20
33
|
- Correct dummy-app feature routing, persistent feature transitions, and circuit statistics.
|
|
21
34
|
- Make weather demo failures explicit instead of random.
|
|
22
35
|
|
data/README.md
CHANGED
|
@@ -46,9 +46,27 @@ StarGates.flags.configuration = -> { Rails.application.config.x.feature_flags }
|
|
|
46
46
|
|
|
47
47
|
`StarGates::Flags.new(env: {}, configuration: {})` creates an isolated registry.
|
|
48
48
|
`all`, `enabled_flags`, `summary`, and `flag_status` expose values and their sources.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
By default, runtime transitions reuse `StarGates::Feature`, and changes and metadata are
|
|
50
|
+
process-local. To share them across workers, give the registry an `ActiveSupport::Cache`
|
|
51
|
+
store (Solid Cache, Redis, Memcached):
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
StarGates.flags.store = Rails.cache
|
|
55
|
+
# or: StarGates::Flags.new(store: Rails.cache, prefix: 'myapp:flags')
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Store-backed flags read the store on every check, so a change in one worker is visible
|
|
59
|
+
in the others right away. Each flag's value and metadata are separate keys written
|
|
60
|
+
blindly; `prefix` (default `star_gates:flags`) namespaces them. A failed write raises
|
|
61
|
+
`StarGates::Error`, and a value the store cannot return counts as no runtime override.
|
|
62
|
+
Listing (`all`, `enabled_flags`, `summary`) and `reset!` use a shared name index. Each new
|
|
63
|
+
name takes its own slot from an atomic `increment`, so workers adding flags at the same
|
|
64
|
+
moment never drop each other's names. Stores without `increment` fall back to a
|
|
65
|
+
read-modify-write list, where a concurrent addition can drop a name from listings until
|
|
66
|
+
that flag next changes; lookups by name are unaffected either way. `reset!` and `remove`
|
|
67
|
+
clear runtime state and metadata, not environment/configuration. Switching `store` drops
|
|
68
|
+
process-local runtime state.
|
|
69
|
+
`StarGates.reset!` resets gates separately.
|
|
52
70
|
|
|
53
71
|
## Gates and rollouts
|
|
54
72
|
|
|
@@ -65,12 +83,20 @@ end
|
|
|
65
83
|
|
|
66
84
|
gate = StarGates[:payments]
|
|
67
85
|
gate.feature.rollout_canary
|
|
68
|
-
gate.call(
|
|
86
|
+
gate.call(key: 'account:42', amount: 100)
|
|
69
87
|
```
|
|
70
88
|
|
|
71
89
|
Features start disabled. Use `rollout_canary` for percentage/group targeting,
|
|
72
|
-
`enable` for full rollout, and `disable` to turn a feature off.
|
|
73
|
-
|
|
90
|
+
`enable` for full rollout, and `disable` to turn a feature off. Pass a stable
|
|
91
|
+
string or integer `key:` for percentage targeting, such as an account, resident,
|
|
92
|
+
or device identifier.
|
|
93
|
+
|
|
94
|
+
Percentage targeting stays consistent across workers and restarts. Increasing
|
|
95
|
+
the rollout percentage retains keys already included.
|
|
96
|
+
|
|
97
|
+
For group targeting, pass `groups: [:beta]` to match `feature group: :beta`.
|
|
98
|
+
A predicate such as `feature group: ->(context) { context[:region] == 'eu' }`
|
|
99
|
+
receives the full context passed to the gate.
|
|
74
100
|
|
|
75
101
|
When a circuit opens, its feature rolls back from enabled to canary, or from canary
|
|
76
102
|
to disabled. Gates use their fallback when the feature is unavailable or execution
|
|
@@ -101,7 +127,7 @@ Define gates in an initializer and call them from controllers or services:
|
|
|
101
127
|
```ruby
|
|
102
128
|
class PaymentsController < ApplicationController
|
|
103
129
|
def create
|
|
104
|
-
result = StarGates[:payments].call(
|
|
130
|
+
result = StarGates[:payments].call(key: "account:#{current_account.id}", amount: params[:amount])
|
|
105
131
|
render json: result
|
|
106
132
|
end
|
|
107
133
|
end
|
|
@@ -153,15 +179,14 @@ require_relative 'test/dummy/app/models/spaceship'
|
|
|
153
179
|
ship = Spaceship.new('SG Atlas', 'SG-1')
|
|
154
180
|
warp = ship.gate(:warp_drive) # A StarGates::Gate, with a feature and a circuit.
|
|
155
181
|
warp.feature.rollout_canary
|
|
156
|
-
|
|
157
|
-
ship.engage_warp('Alpha Centauri', pilot: pilot)
|
|
182
|
+
ship.engage_warp('Alpha Centauri', key: 'crew:1', groups: [:pilots])
|
|
158
183
|
```
|
|
159
184
|
|
|
160
185
|
### Application examples
|
|
161
186
|
|
|
162
187
|
- [Payment feature](test/dummy/demos/payment_feature.rb): state-specific feature behavior.
|
|
163
188
|
- [Transition history](test/dummy/demos/transition_demo.rb): recorded feature transitions.
|
|
164
|
-
- [Introspection](test/dummy/demos/introspection_demo.rb): status, analytics, and
|
|
189
|
+
- [Introspection](test/dummy/demos/introspection_demo.rb): status, analytics, and context simulation.
|
|
165
190
|
- [Application payment feature](test/dummy/demos/application_payment_feature.rb): application-level features.
|
|
166
191
|
- [JSON-RPC controller](test/dummy/demos/jsonrpc_rails_controller.rb): optional JSON-RPC integration.
|
|
167
192
|
|
|
@@ -13,7 +13,6 @@ module StarGates
|
|
|
13
13
|
@event_type = self.class.name.split('::').last
|
|
14
14
|
@data = data.freeze
|
|
15
15
|
@metadata = metadata.merge(
|
|
16
|
-
created_by: Thread.current[:current_user_id],
|
|
17
16
|
source: 'star_gates',
|
|
18
17
|
correlation_id: Thread.current[:correlation_id] || SecureRandom.uuid
|
|
19
18
|
).freeze
|
|
@@ -58,11 +58,11 @@ module StarGates
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
class FeatureEvaluated < BaseEvent
|
|
61
|
-
def initialize(aggregate_id:,
|
|
61
|
+
def initialize(aggregate_id:, context:, eligible:, reason:, **opts)
|
|
62
62
|
super(
|
|
63
63
|
aggregate_id: aggregate_id,
|
|
64
64
|
data: {
|
|
65
|
-
|
|
65
|
+
context: context,
|
|
66
66
|
eligible: eligible,
|
|
67
67
|
reason: reason
|
|
68
68
|
},
|
|
@@ -70,7 +70,7 @@ module StarGates
|
|
|
70
70
|
)
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
def
|
|
73
|
+
def context = data[:context]
|
|
74
74
|
def eligible = data[:eligible]
|
|
75
75
|
def reason = data[:reason]
|
|
76
76
|
end
|
data/lib/star_gates/feature.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'zlib'
|
|
3
4
|
require 'state_machines'
|
|
4
5
|
require 'concurrent'
|
|
5
6
|
|
|
@@ -120,17 +121,15 @@ module StarGates
|
|
|
120
121
|
!disabled?
|
|
121
122
|
end
|
|
122
123
|
|
|
123
|
-
# Check if user is eligible for rollout based on percentage and group
|
|
124
124
|
def eligible_for_rollout?(context = {})
|
|
125
125
|
return group_matches?(context) if group_matcher
|
|
126
126
|
return false if rollout_percentage.zero?
|
|
127
127
|
return true if rollout_percentage == 100
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
return false
|
|
129
|
+
key = rollout_key(context)
|
|
130
|
+
return false if key.nil?
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
(user_hash % 100) < rollout_percentage
|
|
132
|
+
(hash_key(key) % 100) < rollout_percentage
|
|
134
133
|
end
|
|
135
134
|
|
|
136
135
|
# Configuration methods for state-specific behavior
|
|
@@ -175,9 +174,9 @@ module StarGates
|
|
|
175
174
|
def summary
|
|
176
175
|
case status
|
|
177
176
|
when 'disabled'
|
|
178
|
-
"Feature '#{feature_name}' is DISABLED. No
|
|
177
|
+
"Feature '#{feature_name}' is DISABLED. No keys are eligible."
|
|
179
178
|
when 'enabled'
|
|
180
|
-
"Feature '#{feature_name}' is ENABLED for all
|
|
179
|
+
"Feature '#{feature_name}' is ENABLED for all keys (100% rollout)."
|
|
181
180
|
when 'canary'
|
|
182
181
|
if group_matcher
|
|
183
182
|
"Feature '#{feature_name}' is in CANARY mode for group #{group_matcher} (#{rollout_percentage}% rollout)."
|
|
@@ -202,20 +201,15 @@ module StarGates
|
|
|
202
201
|
}
|
|
203
202
|
end
|
|
204
203
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
# Test what a specific user would experience
|
|
209
|
-
def simulate_for_user(user_id:, user_groups: [])
|
|
210
|
-
mock_user = MockUser.new(id: user_id, groups: user_groups)
|
|
211
|
-
context = { user: mock_user }
|
|
204
|
+
def simulate(key:, groups: [], **attributes)
|
|
205
|
+
context = attributes.merge(key: key, groups: groups)
|
|
212
206
|
|
|
213
207
|
eligible = enabled_for?(context)
|
|
214
208
|
ui_component_result = ui_component(context)
|
|
215
209
|
|
|
216
210
|
{
|
|
217
|
-
|
|
218
|
-
|
|
211
|
+
key: key,
|
|
212
|
+
groups: groups,
|
|
219
213
|
eligible: eligible,
|
|
220
214
|
reason: eligibility_reason(context),
|
|
221
215
|
ui_component: ui_component_result,
|
|
@@ -224,13 +218,11 @@ module StarGates
|
|
|
224
218
|
}
|
|
225
219
|
end
|
|
226
220
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
context = { user: user }
|
|
230
|
-
user_id = extract_user_id(context)
|
|
221
|
+
def eligibility_debug(**context)
|
|
222
|
+
key = rollout_key(context)
|
|
231
223
|
|
|
232
224
|
debug_info = {
|
|
233
|
-
|
|
225
|
+
key: key,
|
|
234
226
|
feature_status: status,
|
|
235
227
|
rollout_percentage: rollout_percentage,
|
|
236
228
|
group_matcher: group_matcher,
|
|
@@ -246,18 +238,18 @@ module StarGates
|
|
|
246
238
|
if group_matcher
|
|
247
239
|
group_match = group_matches?(context)
|
|
248
240
|
debug_info[:group_match] = group_match
|
|
249
|
-
debug_info[:reason] = group_match ? '
|
|
250
|
-
elsif
|
|
251
|
-
|
|
252
|
-
debug_info[:
|
|
253
|
-
debug_info[:hash_modulo] =
|
|
254
|
-
debug_info[:reason] = if (
|
|
255
|
-
"
|
|
241
|
+
debug_info[:reason] = group_match ? 'Context matches group criteria' : 'Context does not match group criteria'
|
|
242
|
+
elsif !key.nil?
|
|
243
|
+
key_hash = hash_key(key)
|
|
244
|
+
debug_info[:key_hash] = key_hash
|
|
245
|
+
debug_info[:hash_modulo] = key_hash % 100
|
|
246
|
+
debug_info[:reason] = if (key_hash % 100) < rollout_percentage
|
|
247
|
+
"Key hash #{key_hash % 100} is within rollout #{rollout_percentage}%"
|
|
256
248
|
else
|
|
257
|
-
"
|
|
249
|
+
"Key hash #{key_hash % 100} is outside rollout #{rollout_percentage}%"
|
|
258
250
|
end
|
|
259
251
|
else
|
|
260
|
-
debug_info[:reason] = 'No
|
|
252
|
+
debug_info[:reason] = 'No rollout key found in context'
|
|
261
253
|
end
|
|
262
254
|
end
|
|
263
255
|
|
|
@@ -265,21 +257,20 @@ module StarGates
|
|
|
265
257
|
end
|
|
266
258
|
|
|
267
259
|
# Test different rollout scenarios
|
|
268
|
-
def test_rollout_scenarios(percentages,
|
|
260
|
+
def test_rollout_scenarios(percentages, sample_keys: (1..1000).to_a.sample(100))
|
|
269
261
|
scenarios = {}
|
|
270
262
|
|
|
271
263
|
percentages.each do |percentage|
|
|
272
264
|
eligible_count = 0
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
eligible_count += 1 if (user_hash % 100) < percentage
|
|
265
|
+
sample_keys.each do |key|
|
|
266
|
+
eligible_count += 1 if (hash_key(key) % 100) < percentage
|
|
276
267
|
end
|
|
277
268
|
|
|
278
269
|
scenarios[percentage] = {
|
|
279
270
|
percentage: percentage,
|
|
280
|
-
sample_size:
|
|
281
|
-
|
|
282
|
-
estimated_percentage: (eligible_count.to_f /
|
|
271
|
+
sample_size: sample_keys.size,
|
|
272
|
+
eligible_keys: eligible_count,
|
|
273
|
+
estimated_percentage: (eligible_count.to_f / sample_keys.size * 100).round(2)
|
|
283
274
|
}
|
|
284
275
|
end
|
|
285
276
|
|
|
@@ -367,7 +358,7 @@ module StarGates
|
|
|
367
358
|
when 'enabled'
|
|
368
359
|
'Feature is fully enabled'
|
|
369
360
|
when 'canary'
|
|
370
|
-
eligible_for_rollout?(context) ? '
|
|
361
|
+
eligible_for_rollout?(context) ? 'Context is eligible for rollout' : 'Context is not eligible for rollout'
|
|
371
362
|
else
|
|
372
363
|
"Unknown status: #{status}"
|
|
373
364
|
end
|
|
@@ -384,34 +375,23 @@ module StarGates
|
|
|
384
375
|
def group_matches?(context)
|
|
385
376
|
return false unless group_matcher
|
|
386
377
|
|
|
387
|
-
user = context[:user]
|
|
388
|
-
return false unless user
|
|
389
|
-
|
|
390
378
|
case group_matcher
|
|
391
379
|
when Symbol
|
|
392
|
-
|
|
380
|
+
Array(context[:groups]).include?(group_matcher)
|
|
393
381
|
when Proc
|
|
394
|
-
group_matcher.call(
|
|
382
|
+
group_matcher.call(context)
|
|
395
383
|
else
|
|
396
384
|
false
|
|
397
385
|
end
|
|
398
386
|
end
|
|
399
387
|
|
|
400
|
-
def
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if user.respond_to?(:id)
|
|
405
|
-
user.id
|
|
406
|
-
elsif user.respond_to?(:user_id)
|
|
407
|
-
user.user_id
|
|
408
|
-
elsif user.is_a?(String) || user.is_a?(Integer)
|
|
409
|
-
user
|
|
410
|
-
end
|
|
388
|
+
def rollout_key(context)
|
|
389
|
+
key = context[:key]
|
|
390
|
+
key if key.is_a?(String) || key.is_a?(Integer)
|
|
411
391
|
end
|
|
412
392
|
|
|
413
|
-
def
|
|
414
|
-
|
|
393
|
+
def hash_key(key)
|
|
394
|
+
Zlib.crc32(key.to_s)
|
|
415
395
|
end
|
|
416
396
|
|
|
417
397
|
# Record state transition with timestamp and metadata
|
data/lib/star_gates/flags.rb
CHANGED
|
@@ -2,17 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module StarGates
|
|
4
4
|
# Boolean flags with environment > configuration > runtime > false precedence.
|
|
5
|
-
# Runtime state uses Feature's lifecycle and is local to this process
|
|
5
|
+
# Runtime state uses Feature's lifecycle and is local to this process unless
|
|
6
|
+
# a shared store is configured.
|
|
6
7
|
class Flags
|
|
7
8
|
attr_accessor :configuration
|
|
9
|
+
attr_reader :store
|
|
8
10
|
|
|
9
|
-
def initialize(env: ENV, configuration: {})
|
|
11
|
+
def initialize(env: ENV, configuration: {}, store: nil, prefix: 'star_gates:flags')
|
|
10
12
|
@env = env
|
|
11
13
|
@configuration = configuration
|
|
14
|
+
@store = store
|
|
15
|
+
@prefix = prefix
|
|
12
16
|
@runtime = {}
|
|
13
17
|
@mutex = Mutex.new
|
|
14
18
|
end
|
|
15
19
|
|
|
20
|
+
def store=(store)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
@runtime.clear
|
|
23
|
+
@store = store
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
16
27
|
def enabled?(name)
|
|
17
28
|
debug(name)[:enabled]
|
|
18
29
|
end
|
|
@@ -34,19 +45,25 @@ module StarGates
|
|
|
34
45
|
end
|
|
35
46
|
|
|
36
47
|
def remove(name)
|
|
37
|
-
|
|
48
|
+
normalized = normalize(name)
|
|
49
|
+
if @store
|
|
50
|
+
store_remove(normalized) if normalized
|
|
51
|
+
else
|
|
52
|
+
@mutex.synchronize { @runtime.delete(normalized) }
|
|
53
|
+
end
|
|
38
54
|
nil
|
|
39
55
|
end
|
|
40
56
|
|
|
41
57
|
def reset!
|
|
42
|
-
@mutex.synchronize { @runtime.clear }
|
|
58
|
+
@store ? store_reset : @mutex.synchronize { @runtime.clear }
|
|
43
59
|
nil
|
|
44
60
|
end
|
|
45
61
|
|
|
46
62
|
def all
|
|
47
63
|
configured = configuration_flags
|
|
48
|
-
|
|
49
|
-
names
|
|
64
|
+
runtime = runtime_values
|
|
65
|
+
names = (environment_flags.keys + configured.keys + runtime.keys).uniq
|
|
66
|
+
names.to_h { |name| [name, resolution(name, configured, runtime[name]).slice(:enabled, :source)] }
|
|
50
67
|
end
|
|
51
68
|
|
|
52
69
|
def enabled_flags
|
|
@@ -55,7 +72,7 @@ module StarGates
|
|
|
55
72
|
|
|
56
73
|
def summary
|
|
57
74
|
{
|
|
58
|
-
runtime_flags:
|
|
75
|
+
runtime_flags: runtime_values.keys.map(&:to_sym),
|
|
59
76
|
environment_flags: environment_flags.keys.map(&:to_sym),
|
|
60
77
|
configuration_flags: configuration_flags.keys.map(&:to_sym),
|
|
61
78
|
all_enabled: enabled_flags
|
|
@@ -63,14 +80,15 @@ module StarGates
|
|
|
63
80
|
end
|
|
64
81
|
|
|
65
82
|
def debug(name)
|
|
66
|
-
|
|
83
|
+
normalized = normalize(name)
|
|
84
|
+
resolution(normalized, configuration_flags, runtime_value(normalized))
|
|
67
85
|
end
|
|
68
86
|
|
|
69
87
|
def flag_status(name)
|
|
70
88
|
normalized = normalize(name)
|
|
71
89
|
details = debug(normalized)
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
{ name: normalized&.to_sym, enabled: details[:enabled], source: details[:source],
|
|
91
|
+
metadata: runtime_metadata(normalized) }
|
|
74
92
|
end
|
|
75
93
|
|
|
76
94
|
private
|
|
@@ -91,6 +109,8 @@ module StarGates
|
|
|
91
109
|
raise ArgumentError, 'flag name must be a nonempty string or symbol' unless normalized
|
|
92
110
|
|
|
93
111
|
enabled = boolean(value)
|
|
112
|
+
return store_update(normalized, enabled, metadata) if @store
|
|
113
|
+
|
|
94
114
|
@mutex.synchronize do
|
|
95
115
|
entry = @runtime[normalized] ||= { feature: Feature.new, metadata: {} }
|
|
96
116
|
feature = entry.fetch(:feature)
|
|
@@ -101,8 +121,124 @@ module StarGates
|
|
|
101
121
|
enabled
|
|
102
122
|
end
|
|
103
123
|
|
|
104
|
-
def
|
|
105
|
-
|
|
124
|
+
def runtime_value(name)
|
|
125
|
+
return unless name
|
|
126
|
+
return store_values([name])[name] if @store
|
|
127
|
+
|
|
128
|
+
@mutex.synchronize { @runtime[name]&.fetch(:feature)&.enabled_for? }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def runtime_values
|
|
132
|
+
return store_values(store_names) if @store
|
|
133
|
+
|
|
134
|
+
@mutex.synchronize { @runtime.transform_values { |entry| entry.fetch(:feature).enabled_for? } }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def runtime_metadata(name)
|
|
138
|
+
return {} unless name
|
|
139
|
+
|
|
140
|
+
metadata = if @store
|
|
141
|
+
@store.read(metadata_key(name))
|
|
142
|
+
else
|
|
143
|
+
@mutex.synchronize { @runtime[name]&.fetch(:metadata) }
|
|
144
|
+
end
|
|
145
|
+
metadata.is_a?(Hash) ? metadata.dup : {}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def store_update(name, enabled, metadata)
|
|
149
|
+
index_add(name)
|
|
150
|
+
store_write(flag_key(name), enabled)
|
|
151
|
+
store_write(metadata_key(name), metadata.dup) if metadata
|
|
152
|
+
enabled
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def store_remove(name)
|
|
156
|
+
store_delete([flag_key(name), metadata_key(name)])
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def store_reset
|
|
160
|
+
slots = index_slot_keys
|
|
161
|
+
names = slots ? store_read(slots).values.uniq : legacy_index
|
|
162
|
+
keys = names.flat_map { |name| [flag_key(name), metadata_key(name)] }
|
|
163
|
+
store_delete(keys + (slots ? slots + [index_sequence_key] : [index_key]))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def index_add(name)
|
|
167
|
+
return unless @store.read(flag_key(name)).nil?
|
|
168
|
+
return @mutex.synchronize { legacy_index_add(name) } unless @store.respond_to?(:increment)
|
|
169
|
+
|
|
170
|
+
slot = @store.increment(index_sequence_key)
|
|
171
|
+
store_write(index_slot_key(slot), name) if slot
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def store_names
|
|
175
|
+
slots = index_slot_keys
|
|
176
|
+
slots ? store_read(slots).values.uniq : legacy_index
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def index_slot_keys
|
|
180
|
+
return unless @store.respond_to?(:increment)
|
|
181
|
+
|
|
182
|
+
Array.new(@store.read(index_sequence_key, raw: true).to_i) { |slot| index_slot_key(slot + 1) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def legacy_index_add(name)
|
|
186
|
+
names = legacy_index
|
|
187
|
+
store_write(index_key, names + [name]) unless names.include?(name)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def legacy_index
|
|
191
|
+
names = @store.read(index_key)
|
|
192
|
+
names.is_a?(Array) ? names : []
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def store_values(names)
|
|
196
|
+
keys = names.to_h { |name| [flag_key(name), name] }
|
|
197
|
+
store_read(keys.keys).each_with_object({}) do |(key, value), values|
|
|
198
|
+
values[keys.fetch(key)] = value if [true, false].include?(value)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def store_read(keys)
|
|
203
|
+
return {} if keys.empty?
|
|
204
|
+
return @store.read_multi(*keys) if @store.respond_to?(:read_multi)
|
|
205
|
+
|
|
206
|
+
keys.each_with_object({}) do |key, values|
|
|
207
|
+
value = @store.read(key)
|
|
208
|
+
values[key] = value unless value.nil?
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def store_write(key, value)
|
|
213
|
+
return if @store.write(key, value)
|
|
214
|
+
|
|
215
|
+
raise Error, "#{@store.class} did not persist #{key}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def store_delete(keys)
|
|
219
|
+
return @store.delete_multi(keys) if @store.respond_to?(:delete_multi)
|
|
220
|
+
|
|
221
|
+
keys.each { |key| @store.delete(key) }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def flag_key(name)
|
|
225
|
+
"#{@prefix}:flag:#{name}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def metadata_key(name)
|
|
229
|
+
"#{@prefix}:metadata:#{name}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def index_key
|
|
233
|
+
"#{@prefix}:names"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def index_sequence_key
|
|
237
|
+
"#{@prefix}:name_sequence"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def index_slot_key(slot)
|
|
241
|
+
"#{@prefix}:name:#{slot}"
|
|
106
242
|
end
|
|
107
243
|
|
|
108
244
|
def configuration_flags
|
|
@@ -124,10 +260,9 @@ module StarGates
|
|
|
124
260
|
end
|
|
125
261
|
end
|
|
126
262
|
|
|
127
|
-
def resolution(name, configured)
|
|
263
|
+
def resolution(name, configured, runtime_value)
|
|
128
264
|
env_key = "FEATURE_#{name&.upcase}"
|
|
129
265
|
environment_value = name && @env[env_key]
|
|
130
|
-
runtime_value = @mutex.synchronize { @runtime[name]&.fetch(:feature)&.enabled_for? }
|
|
131
266
|
|
|
132
267
|
value, source = if name && !environment_value.to_s.strip.empty?
|
|
133
268
|
[boolean(environment_value), "environment:#{env_key}"]
|
|
@@ -73,10 +73,10 @@ module StarGates
|
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
# Command: Record evaluation
|
|
76
|
-
def record_evaluation(
|
|
76
|
+
def record_evaluation(context, eligible, reason)
|
|
77
77
|
event = Events::FeatureEvaluated.new(
|
|
78
78
|
aggregate_id: @id,
|
|
79
|
-
|
|
79
|
+
context: context,
|
|
80
80
|
eligible: eligible,
|
|
81
81
|
reason: reason
|
|
82
82
|
)
|
|
@@ -213,7 +213,7 @@ module StarGates
|
|
|
213
213
|
def apply_evaluation_recorded(event)
|
|
214
214
|
@state[:evaluations] ||= []
|
|
215
215
|
@state[:evaluations] << {
|
|
216
|
-
|
|
216
|
+
context: event.context,
|
|
217
217
|
eligible: event.eligible,
|
|
218
218
|
reason: event.reason,
|
|
219
219
|
occurred_at: event.occurred_at
|
|
@@ -53,11 +53,11 @@ module StarGates
|
|
|
53
53
|
@repository.save(aggregate)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
def record_feature_evaluation(feature_id,
|
|
56
|
+
def record_feature_evaluation(feature_id, context, eligible, reason)
|
|
57
57
|
aggregate = @repository.find(feature_id)
|
|
58
58
|
return unless aggregate # Silently skip if feature doesn't exist
|
|
59
59
|
|
|
60
|
-
aggregate.record_evaluation(
|
|
60
|
+
aggregate.record_evaluation(context, eligible, reason)
|
|
61
61
|
@repository.save(aggregate)
|
|
62
62
|
end
|
|
63
63
|
|
|
@@ -170,18 +170,18 @@ module StarGates
|
|
|
170
170
|
end
|
|
171
171
|
|
|
172
172
|
def evaluate_feature(params)
|
|
173
|
-
validate_required_params(params, %w[featureId
|
|
173
|
+
validate_required_params(params, %w[featureId context])
|
|
174
174
|
|
|
175
175
|
feature_id = params['featureId']
|
|
176
|
-
|
|
176
|
+
context = params['context']
|
|
177
177
|
eligible = params['eligible'] || false
|
|
178
178
|
reason = params['reason'] || 'No reason provided'
|
|
179
179
|
|
|
180
|
-
feature_service.record_feature_evaluation(feature_id,
|
|
180
|
+
feature_service.record_feature_evaluation(feature_id, context, eligible, reason)
|
|
181
181
|
|
|
182
182
|
{
|
|
183
183
|
featureId: feature_id,
|
|
184
|
-
|
|
184
|
+
context: context,
|
|
185
185
|
eligible: eligible,
|
|
186
186
|
reason: reason,
|
|
187
187
|
recordedAt: Time.now.utc.iso8601
|
|
@@ -15,12 +15,11 @@ module StarGates
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# OpenTelemetry-specific methods
|
|
18
|
-
def trace_feature_evaluation(feature_id,
|
|
18
|
+
def trace_feature_evaluation(feature_id, _context, &block)
|
|
19
19
|
return block.call unless enabled?
|
|
20
20
|
|
|
21
21
|
@tracer.in_span('feature.evaluate', attributes: {
|
|
22
22
|
'feature.id' => feature_id,
|
|
23
|
-
'feature.user_type' => extract_user_type(user_context),
|
|
24
23
|
'service.name' => @service_name,
|
|
25
24
|
'service.version' => @service_version
|
|
26
25
|
}) do |span|
|
|
@@ -192,13 +191,12 @@ module StarGates
|
|
|
192
191
|
end
|
|
193
192
|
|
|
194
193
|
# Override specific methods to use pre-created instruments
|
|
195
|
-
def record_feature_evaluation(feature_id, eligible,
|
|
194
|
+
def record_feature_evaluation(feature_id, eligible, _context, execution_time_ms = nil)
|
|
196
195
|
return unless enabled?
|
|
197
196
|
|
|
198
197
|
attributes = {
|
|
199
198
|
'feature.id' => feature_id.to_s,
|
|
200
199
|
'feature.eligible' => eligible.to_s,
|
|
201
|
-
'feature.user_type' => extract_user_type(user_context),
|
|
202
200
|
'service.name' => @service_name,
|
|
203
201
|
'service.version' => @service_version
|
|
204
202
|
}
|
|
@@ -48,7 +48,7 @@ module StarGates
|
|
|
48
48
|
@registry[:feature_evaluations] = @prometheus.counter(
|
|
49
49
|
:"#{@metric_prefix}_feature_evaluations_total",
|
|
50
50
|
docstring: 'Total number of feature flag evaluations',
|
|
51
|
-
labels: %i[feature_id eligible
|
|
51
|
+
labels: %i[feature_id eligible]
|
|
52
52
|
)
|
|
53
53
|
|
|
54
54
|
@registry[:feature_executions] = @prometheus.counter(
|
|
@@ -120,13 +120,12 @@ module StarGates
|
|
|
120
120
|
end
|
|
121
121
|
|
|
122
122
|
# Use pre-registered metrics for better performance
|
|
123
|
-
def record_feature_evaluation(feature_id, eligible,
|
|
123
|
+
def record_feature_evaluation(feature_id, eligible, _context, execution_time_ms = nil)
|
|
124
124
|
return unless enabled?
|
|
125
125
|
|
|
126
126
|
tags = {
|
|
127
127
|
feature_id: feature_id.to_s,
|
|
128
|
-
eligible: eligible.to_s
|
|
129
|
-
user_type: extract_user_type(user_context)
|
|
128
|
+
eligible: eligible.to_s
|
|
130
129
|
}
|
|
131
130
|
|
|
132
131
|
@registry[:feature_evaluations].increment(labels: tags)
|
|
@@ -40,13 +40,12 @@ module StarGates
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
# Feature flag specific metrics
|
|
43
|
-
def record_feature_evaluation(feature_id, eligible,
|
|
43
|
+
def record_feature_evaluation(feature_id, eligible, _context, execution_time_ms = nil)
|
|
44
44
|
return unless enabled?
|
|
45
45
|
|
|
46
46
|
tags = {
|
|
47
47
|
feature_id: feature_id,
|
|
48
|
-
eligible: eligible
|
|
49
|
-
user_type: extract_user_type(user_context)
|
|
48
|
+
eligible: eligible
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
record_counter('star_gates.feature.evaluation', 1, tags: tags)
|
|
@@ -155,19 +154,6 @@ module StarGates
|
|
|
155
154
|
|
|
156
155
|
private
|
|
157
156
|
|
|
158
|
-
def extract_user_type(user_context)
|
|
159
|
-
user = user_context[:user] if user_context.is_a?(Hash)
|
|
160
|
-
return 'unknown' unless user
|
|
161
|
-
|
|
162
|
-
if user.respond_to?(:groups) && user.groups.is_a?(Array)
|
|
163
|
-
return 'beta' if user.groups.include?(:beta_users)
|
|
164
|
-
return 'admin' if user.groups.include?(:admin)
|
|
165
|
-
return 'premium' if user.groups.include?(:premium)
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
'regular'
|
|
169
|
-
end
|
|
170
|
-
|
|
171
157
|
attr_reader :config
|
|
172
158
|
end
|
|
173
159
|
end
|
data/lib/star_gates/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: star_gates
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -103,6 +103,7 @@ extra_rdoc_files: []
|
|
|
103
103
|
files:
|
|
104
104
|
- ".dictate.toml"
|
|
105
105
|
- ".pre-commit-config.yaml"
|
|
106
|
+
- ".release-please-manifest.json"
|
|
106
107
|
- CHANGELOG.md
|
|
107
108
|
- LICENSE.txt
|
|
108
109
|
- README.md
|
|
@@ -133,14 +134,14 @@ files:
|
|
|
133
134
|
- lib/star_gates/telemetry/prometheus_adapter.rb
|
|
134
135
|
- lib/star_gates/telemetry/telemetry_adapter.rb
|
|
135
136
|
- lib/star_gates/version.rb
|
|
137
|
+
- release-please-config.json
|
|
136
138
|
homepage: https://github.com/seuros/star_gates
|
|
137
139
|
licenses:
|
|
138
140
|
- BSD-3-Clause
|
|
139
141
|
metadata:
|
|
140
|
-
allowed_push_host: https://rubygems.org
|
|
141
142
|
homepage_uri: https://github.com/seuros/star_gates
|
|
142
143
|
source_code_uri: https://github.com/seuros/star_gates
|
|
143
|
-
changelog_uri: https://github.com/seuros/star_gates/blob/
|
|
144
|
+
changelog_uri: https://github.com/seuros/star_gates/blob/master/CHANGELOG.md
|
|
144
145
|
rubygems_mfa_required: 'true'
|
|
145
146
|
rdoc_options: []
|
|
146
147
|
require_paths:
|
|
@@ -156,7 +157,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
156
157
|
- !ruby/object:Gem::Version
|
|
157
158
|
version: '0'
|
|
158
159
|
requirements: []
|
|
159
|
-
rubygems_version: 4.0.
|
|
160
|
+
rubygems_version: 4.0.20
|
|
160
161
|
specification_version: 4
|
|
161
162
|
summary: A Ruby gem for building DSL-based star gate configurations
|
|
162
163
|
test_files: []
|