star_gates 0.1.0 → 0.2.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: 9150966ca03ffe47ce938c669d38ab3291e25b6b7aaea9560e89099ec255a65d
4
+ data.tar.gz: 0454b43be544e3c50026811f5d44ec3a28903c4ff824fcf3b855aaf70a733aba
5
5
  SHA512:
6
- metadata.gz: db442d9496bc8ecaaae6a329c0b2bae1ac6f99a8a4375b57ec3e950941e7257c4a45045d1eb66494c2e74fc3c3075429ddf193173446ae09189b11b4dbfe8d2a
7
- data.tar.gz: 9954bf6fccad1f849cd5fb411c3b79f2516ea46dc7b99da86ee4a373d3b4eb06e68cc14ba07a43c8c960bc4a0e5cf69086b40c5807495b79f3d4d250ce6fd7d6
6
+ metadata.gz: 4d7802feb0b5ebc3aefcd585224cfd5dda9f95e9e8f23ee5653a087f5326d14764ce7e6a06d16ca01cafd60570325c910cb776464b2abce14b2ef05b0a202407
7
+ data.tar.gz: 3e57ca2506afe07616e9c884f1f74228c2f380601a0ca4779b311a422aa0c215d228835e9160b0689db744ec788252b752f5133703992063a06c6c7db87254d7
data/CHANGELOG.md CHANGED
@@ -2,8 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.0 (2026-10-28)
6
+
5
7
  ### Changed
6
8
 
9
+ - Use explicit rollout `key:` and `groups:` context. Group predicates receive the
10
+ full context; simulation, debugging, and scenario analysis use key-based inputs
11
+ and results. Remove user-specific helpers and telemetry labels.
7
12
  - Require Ruby 4.0 or newer.
8
13
  - Require StateMachines 0.202.0 or newer.
9
14
  - Change the license to BSD-3-Clause.
@@ -17,6 +22,9 @@
17
22
 
18
23
  ### Fixed
19
24
 
25
+ - Keep percentage rollout cohorts stable across workers and restarts with CRC32
26
+ key buckets. Upgrading reassigns cohorts previously based on Ruby's
27
+ process-randomized string hashes.
20
28
  - Correct dummy-app feature routing, persistent feature transitions, and circuit statistics.
21
29
  - Make weather demo failures explicit instead of random.
22
30
 
data/README.md CHANGED
@@ -65,12 +65,20 @@ end
65
65
 
66
66
  gate = StarGates[:payments]
67
67
  gate.feature.rollout_canary
68
- gate.call(user: current_user, amount: 100)
68
+ gate.call(key: 'account:42', amount: 100)
69
69
  ```
70
70
 
71
71
  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.
72
+ `enable` for full rollout, and `disable` to turn a feature off. Pass a stable
73
+ string or integer `key:` for percentage targeting, such as an account, resident,
74
+ or device identifier.
75
+
76
+ Percentage targeting stays consistent across workers and restarts. Increasing
77
+ the rollout percentage retains keys already included.
78
+
79
+ For group targeting, pass `groups: [:beta]` to match `feature group: :beta`.
80
+ A predicate such as `feature group: ->(context) { context[:region] == 'eu' }`
81
+ receives the full context passed to the gate.
74
82
 
75
83
  When a circuit opens, its feature rolls back from enabled to canary, or from canary
76
84
  to disabled. Gates use their fallback when the feature is unavailable or execution
@@ -101,7 +109,7 @@ Define gates in an initializer and call them from controllers or services:
101
109
  ```ruby
102
110
  class PaymentsController < ApplicationController
103
111
  def create
104
- result = StarGates[:payments].call(user: current_user, amount: params[:amount])
112
+ result = StarGates[:payments].call(key: "account:#{current_account.id}", amount: params[:amount])
105
113
  render json: result
106
114
  end
107
115
  end
@@ -153,15 +161,14 @@ require_relative 'test/dummy/app/models/spaceship'
153
161
  ship = Spaceship.new('SG Atlas', 'SG-1')
154
162
  warp = ship.gate(:warp_drive) # A StarGates::Gate, with a feature and a circuit.
155
163
  warp.feature.rollout_canary
156
- pilot = StarGates::Feature::MockUser.new(id: 1, groups: [:pilots])
157
- ship.engage_warp('Alpha Centauri', pilot: pilot)
164
+ ship.engage_warp('Alpha Centauri', key: 'crew:1', groups: [:pilots])
158
165
  ```
159
166
 
160
167
  ### Application examples
161
168
 
162
169
  - [Payment feature](test/dummy/demos/payment_feature.rb): state-specific feature behavior.
163
170
  - [Transition history](test/dummy/demos/transition_demo.rb): recorded feature transitions.
164
- - [Introspection](test/dummy/demos/introspection_demo.rb): status, analytics, and user simulation.
171
+ - [Introspection](test/dummy/demos/introspection_demo.rb): status, analytics, and context simulation.
165
172
  - [Application payment feature](test/dummy/demos/application_payment_feature.rb): application-level features.
166
173
  - [JSON-RPC controller](test/dummy/demos/jsonrpc_rails_controller.rb): optional JSON-RPC integration.
167
174
 
@@ -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
@@ -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.2.0'
5
5
  end
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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -156,7 +156,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
156
156
  - !ruby/object:Gem::Version
157
157
  version: '0'
158
158
  requirements: []
159
- rubygems_version: 4.0.10
159
+ rubygems_version: 4.0.20
160
160
  specification_version: 4
161
161
  summary: A Ruby gem for building DSL-based star gate configurations
162
162
  test_files: []