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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2747943c57b6c5a1d42a415ec8321bcab9420438f3425424432a35ada2571c97
4
- data.tar.gz: 375a4a1f9f7d27ec78d35386bb973f37c5723a2ff7a37eb580d5cd9d80f9f686
3
+ metadata.gz: e4505630aa976c8d13a9aa9eb2aa0ac9a5b0a7e8e281b55edaf7f44aa2101441
4
+ data.tar.gz: 65de7f9b0d81538bc4c85286442fc3195c4a2bcb30c77dcfc43e187649a796b7
5
5
  SHA512:
6
- metadata.gz: db442d9496bc8ecaaae6a329c0b2bae1ac6f99a8a4375b57ec3e950941e7257c4a45045d1eb66494c2e74fc3c3075429ddf193173446ae09189b11b4dbfe8d2a
7
- data.tar.gz: 9954bf6fccad1f849cd5fb411c3b79f2516ea46dc7b99da86ee4a373d3b4eb06e68cc14ba07a43c8c960bc4a0e5cf69086b40c5807495b79f3d4d250ce6fd7d6
6
+ metadata.gz: 00276fbc16dc323e4a66aea9873b7848aaf5a0ff708b2985bc7a60ea5f5b6f13749974b18ab6cfd9d41435ba45ca39a3ef03e354f5f274c03694f07b2ed12920
7
+ data.tar.gz: 53606d3810ee3582c07de3872f162ba7b813d0b671e88684e3d90c3d636bd2230ddedd6d94dff5ac3c1a9b81dbebcb9b66eeae5c89132fed4df2d7762089a15d
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.3.0"
3
+ }
data/CHANGELOG.md CHANGED
@@ -1,9 +1,19 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
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
- Runtime transitions reuse `StarGates::Feature`; changes and metadata are process-local,
50
- not persisted or synchronized across workers. `reset!` clears both runtime state and
51
- metadata, not environment/configuration. `StarGates.reset!` resets gates separately.
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(user: current_user, amount: 100)
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. Provide a user with
73
- an `id` for consistent percentage targeting.
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(user: current_user, amount: params[:amount])
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
- pilot = StarGates::Feature::MockUser.new(id: 1, groups: [:pilots])
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 user simulation.
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:, user_context:, eligible:, reason:, **opts)
61
+ def initialize(aggregate_id:, context:, eligible:, reason:, **opts)
62
62
  super(
63
63
  aggregate_id: aggregate_id,
64
64
  data: {
65
- user_context: user_context,
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 user_context = data[:user_context]
73
+ def context = data[:context]
74
74
  def eligible = data[:eligible]
75
75
  def reason = data[:reason]
76
76
  end
@@ -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
- user_id = extract_user_id(context)
130
- return false unless user_id
129
+ key = rollout_key(context)
130
+ return false if key.nil?
131
131
 
132
- user_hash = hash_user_id(user_id)
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 users have access."
177
+ "Feature '#{feature_name}' is DISABLED. No keys are eligible."
179
178
  when 'enabled'
180
- "Feature '#{feature_name}' is ENABLED for all users (100% rollout)."
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
- # Mock user struct for testing (Data.define is Ruby 3.2+)
206
- MockUser = Data.define(:id, :groups) unless defined?(self::MockUser)
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
- user_id: user_id,
218
- user_groups: user_groups,
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
- # Debug why a user is or isn't eligible
228
- def eligibility_debug(user:)
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
- user_id: user_id,
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 ? 'User matches group criteria' : 'User does not match group criteria'
250
- elsif user_id
251
- user_hash = hash_user_id(user_id)
252
- debug_info[:user_hash] = user_hash
253
- debug_info[:hash_modulo] = user_hash % 100
254
- debug_info[:reason] = if (user_hash % 100) < rollout_percentage
255
- "User hash #{user_hash % 100} is within rollout #{rollout_percentage}%"
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
- "User hash #{user_hash % 100} is outside rollout #{rollout_percentage}%"
249
+ "Key hash #{key_hash % 100} is outside rollout #{rollout_percentage}%"
258
250
  end
259
251
  else
260
- debug_info[:reason] = 'No user ID found in context'
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, sample_user_ids: (1..1000).to_a.sample(100))
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
- sample_user_ids.each do |user_id|
274
- user_hash = hash_user_id(user_id)
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: sample_user_ids.size,
281
- eligible_users: eligible_count,
282
- estimated_percentage: (eligible_count.to_f / sample_user_ids.size * 100).round(2)
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) ? 'User is eligible for rollout' : 'User is not eligible for rollout'
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
- user.respond_to?(:groups) && user.groups.include?(group_matcher)
380
+ Array(context[:groups]).include?(group_matcher)
393
381
  when Proc
394
- group_matcher.call(user)
382
+ group_matcher.call(context)
395
383
  else
396
384
  false
397
385
  end
398
386
  end
399
387
 
400
- def extract_user_id(context)
401
- user = context[:user]
402
- return nil unless user
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 hash_user_id(user_id)
414
- user_id.to_s.hash.abs
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
@@ -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
- @mutex.synchronize { @runtime.delete(normalize(name)) }
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
- names = (environment_flags.keys + configured.keys + runtime_names).uniq
49
- names.to_h { |name| [name, resolution(name, configured).slice(:enabled, :source)] }
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: runtime_names.map(&:to_sym),
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
- resolution(normalize(name), configuration_flags)
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
- metadata = @mutex.synchronize { @runtime[normalized]&.fetch(:metadata)&.dup || {} }
73
- { name: normalized&.to_sym, enabled: details[:enabled], source: details[:source], metadata: metadata }
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 runtime_names
105
- @mutex.synchronize { @runtime.keys }
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(user_context, eligible, reason)
76
+ def record_evaluation(context, eligible, reason)
77
77
  event = Events::FeatureEvaluated.new(
78
78
  aggregate_id: @id,
79
- user_context: user_context,
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
- user_context: event.user_context,
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, user_context, eligible, reason)
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(user_context, eligible, reason)
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 userContext])
173
+ validate_required_params(params, %w[featureId context])
174
174
 
175
175
  feature_id = params['featureId']
176
- user_context = params['userContext']
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, user_context, eligible, reason)
180
+ feature_service.record_feature_evaluation(feature_id, context, eligible, reason)
181
181
 
182
182
  {
183
183
  featureId: feature_id,
184
- userContext: user_context,
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, user_context, &block)
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, user_context, execution_time_ms = nil)
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 user_type]
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, user_context, execution_time_ms = nil)
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, user_context, execution_time_ms = nil)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StarGates
4
- VERSION = '0.1.0'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -0,0 +1,11 @@
1
+ {
2
+ "bootstrap-sha": "144ff79fe22abe175830d0c765ddc249c178ddc7",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "ruby",
6
+ "package-name": "star_gates",
7
+ "bump-minor-pre-major": true,
8
+ "include-component-in-tag": false
9
+ }
10
+ }
11
+ }
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.1.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/main/CHANGELOG.md
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.10
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: []