star_gates 0.0.0 → 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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.dictate.toml +19 -0
  3. data/.pre-commit-config.yaml +20 -0
  4. data/CHANGELOG.md +52 -3
  5. data/LICENSE.txt +23 -16
  6. data/README.md +175 -16
  7. data/Rakefile +14 -4
  8. data/lib/star_gates/adapters/base_adapter.rb +65 -0
  9. data/lib/star_gates/adapters/memory_adapter.rb +93 -0
  10. data/lib/star_gates/adapters/redis_adapter.rb +119 -0
  11. data/lib/star_gates/application_star_gate.rb +398 -0
  12. data/lib/star_gates/circuit.rb +171 -0
  13. data/lib/star_gates/configuration.rb +172 -0
  14. data/lib/star_gates/dsl.rb +71 -2
  15. data/lib/star_gates/errors.rb +8 -0
  16. data/lib/star_gates/event_store/event_store.rb +79 -0
  17. data/lib/star_gates/event_store/memory_adapter.rb +78 -0
  18. data/lib/star_gates/events/base_event.rb +46 -0
  19. data/lib/star_gates/events/feature_events.rb +97 -0
  20. data/lib/star_gates/feature.rb +446 -0
  21. data/lib/star_gates/flags.rb +153 -0
  22. data/lib/star_gates/gate.rb +100 -0
  23. data/lib/star_gates/hexagonal/feature_aggregate.rb +250 -0
  24. data/lib/star_gates/hexagonal/feature_repository.rb +67 -0
  25. data/lib/star_gates/hexagonal/feature_service.rb +121 -0
  26. data/lib/star_gates/jsonrpc_rails_adapter.rb +251 -0
  27. data/lib/star_gates/projections/feature_projection.rb +224 -0
  28. data/lib/star_gates/registry.rb +58 -0
  29. data/lib/star_gates/telemetry/opentelemetry_adapter.rb +254 -0
  30. data/lib/star_gates/telemetry/prometheus_adapter.rb +184 -0
  31. data/lib/star_gates/telemetry/telemetry_adapter.rb +174 -0
  32. data/lib/star_gates/version.rb +1 -1
  33. data/lib/star_gates.rb +308 -8
  34. metadata +95 -14
  35. data/sig/star_gates.rbs +0 -4
@@ -0,0 +1,446 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'state_machines'
4
+ require 'concurrent'
5
+
6
+ module StarGates
7
+ class Feature
8
+ attr_reader :rollout_percentage, :group_matcher
9
+
10
+ def initialize(rollout: 0, group: nil)
11
+ @rollout_percentage = parse_rollout(rollout)
12
+ @group_matcher = group
13
+ @created_at = Time.now
14
+ @transition_history = Concurrent::Array.new
15
+ super()
16
+ end
17
+
18
+ # State machine for feature lifecycle
19
+ state_machine :status, initial: :disabled do
20
+ # State: Disabled - Feature is completely off
21
+ state :disabled do
22
+ def execute(context = {})
23
+ StarGates.instrument('feature_disabled', feature: self.class.name, context: context)
24
+ nil
25
+ end
26
+
27
+ def ui_component(_context = {})
28
+ 'default_component'
29
+ end
30
+
31
+ def config_value(key, _context = {})
32
+ default_config[key]
33
+ end
34
+ end
35
+
36
+ # State: Enabled - Feature is fully rolled out
37
+ state :enabled do
38
+ def execute(context = {})
39
+ StarGates.instrument('feature_enabled', feature: self.class.name, context: context)
40
+ if block_given?
41
+ yield
42
+ else
43
+ true
44
+ end
45
+ end
46
+
47
+ def ui_component(_context = {})
48
+ 'feature_component'
49
+ end
50
+
51
+ def config_value(key, _context = {})
52
+ feature_config[key]
53
+ end
54
+ end
55
+
56
+ # State: Canary - Feature is in gradual rollout
57
+ state :canary do
58
+ def execute(context = {})
59
+ if eligible_for_rollout?(context)
60
+ StarGates.instrument('feature_canary_enabled', feature: self.class.name, context: context)
61
+ if block_given?
62
+ yield
63
+ else
64
+ true
65
+ end
66
+ else
67
+ StarGates.instrument('feature_canary_disabled', feature: self.class.name, context: context)
68
+ nil
69
+ end
70
+ end
71
+
72
+ def ui_component(context = {})
73
+ if eligible_for_rollout?(context)
74
+ 'feature_component'
75
+ else
76
+ 'default_component'
77
+ end
78
+ end
79
+
80
+ def config_value(key, context = {})
81
+ if eligible_for_rollout?(context)
82
+ feature_config[key]
83
+ else
84
+ default_config[key]
85
+ end
86
+ end
87
+ end
88
+
89
+ # Events for state transitions
90
+ event :enable do
91
+ transition %i[disabled canary] => :enabled
92
+ end
93
+
94
+ event :disable do
95
+ transition %i[enabled canary] => :disabled
96
+ end
97
+
98
+ event :rollout_canary do
99
+ transition %i[disabled enabled] => :canary
100
+ end
101
+
102
+ # Transition tracking callbacks
103
+ after_transition any => any do |feature, transition|
104
+ feature.send(:record_transition, transition)
105
+ end
106
+ end
107
+
108
+ def enabled_for?(context = {})
109
+ case status
110
+ when 'enabled'
111
+ true
112
+ when 'canary'
113
+ eligible_for_rollout?(context)
114
+ else
115
+ false
116
+ end
117
+ end
118
+
119
+ def enabled?
120
+ !disabled?
121
+ end
122
+
123
+ # Check if user is eligible for rollout based on percentage and group
124
+ def eligible_for_rollout?(context = {})
125
+ return group_matches?(context) if group_matcher
126
+ return false if rollout_percentage.zero?
127
+ return true if rollout_percentage == 100
128
+
129
+ user_id = extract_user_id(context)
130
+ return false unless user_id
131
+
132
+ user_hash = hash_user_id(user_id)
133
+ (user_hash % 100) < rollout_percentage
134
+ end
135
+
136
+ # Configuration methods for state-specific behavior
137
+ def feature_config
138
+ @feature_config ||= {}
139
+ end
140
+
141
+ def default_config
142
+ @default_config ||= {}
143
+ end
144
+
145
+ # ============================================================================
146
+ # INTROSPECTION METHODS
147
+ # ============================================================================
148
+
149
+ # Get current rollout statistics
150
+ def stats
151
+ {
152
+ name: self.class.name,
153
+ status: status,
154
+ status_name: status_name,
155
+ rollout_percentage: rollout_percentage,
156
+ group_matcher: group_matcher_info,
157
+ created_at: @created_at,
158
+ state_duration: state_duration,
159
+ total_transitions: transition_count
160
+ }
161
+ end
162
+
163
+ # Get complete feature configuration
164
+ def configuration
165
+ {
166
+ rollout_percentage: rollout_percentage,
167
+ group_matcher: group_matcher,
168
+ feature_config: feature_config.dup,
169
+ default_config: default_config.dup,
170
+ state_machine_config: state_machine_config
171
+ }
172
+ end
173
+
174
+ # Human-readable feature status summary
175
+ def summary
176
+ case status
177
+ when 'disabled'
178
+ "Feature '#{feature_name}' is DISABLED. No users have access."
179
+ when 'enabled'
180
+ "Feature '#{feature_name}' is ENABLED for all users (100% rollout)."
181
+ when 'canary'
182
+ if group_matcher
183
+ "Feature '#{feature_name}' is in CANARY mode for group #{group_matcher} (#{rollout_percentage}% rollout)."
184
+ else
185
+ "Feature '#{feature_name}' is in CANARY mode with #{rollout_percentage}% rollout."
186
+ end
187
+ else
188
+ "Feature '#{feature_name}' is in unknown state: #{status}."
189
+ end
190
+ end
191
+
192
+ # Complete introspection data export
193
+ def to_h
194
+ {
195
+ name: feature_name,
196
+ class: self.class.name,
197
+ status: status,
198
+ stats: stats,
199
+ configuration: configuration,
200
+ summary: summary,
201
+ introspected_at: Time.now
202
+ }
203
+ end
204
+
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 }
212
+
213
+ eligible = enabled_for?(context)
214
+ ui_component_result = ui_component(context)
215
+
216
+ {
217
+ user_id: user_id,
218
+ user_groups: user_groups,
219
+ eligible: eligible,
220
+ reason: eligibility_reason(context),
221
+ ui_component: ui_component_result,
222
+ would_execute: eligible,
223
+ feature_status: status
224
+ }
225
+ end
226
+
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)
231
+
232
+ debug_info = {
233
+ user_id: user_id,
234
+ feature_status: status,
235
+ rollout_percentage: rollout_percentage,
236
+ group_matcher: group_matcher,
237
+ eligible: enabled_for?(context)
238
+ }
239
+
240
+ case status
241
+ when 'disabled'
242
+ debug_info[:reason] = 'Feature is disabled'
243
+ when 'enabled'
244
+ debug_info[:reason] = 'Feature is fully enabled'
245
+ when 'canary'
246
+ if group_matcher
247
+ group_match = group_matches?(context)
248
+ 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}%"
256
+ else
257
+ "User hash #{user_hash % 100} is outside rollout #{rollout_percentage}%"
258
+ end
259
+ else
260
+ debug_info[:reason] = 'No user ID found in context'
261
+ end
262
+ end
263
+
264
+ debug_info
265
+ end
266
+
267
+ # Test different rollout scenarios
268
+ def test_rollout_scenarios(percentages, sample_user_ids: (1..1000).to_a.sample(100))
269
+ scenarios = {}
270
+
271
+ percentages.each do |percentage|
272
+ 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
276
+ end
277
+
278
+ scenarios[percentage] = {
279
+ 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)
283
+ }
284
+ end
285
+
286
+ scenarios
287
+ end
288
+
289
+ # Get transition history for this feature
290
+ def transition_history(limit: 10)
291
+ {
292
+ feature_name: feature_name,
293
+ total_transitions: @transition_history.size,
294
+ transitions: @transition_history.last(limit).reverse.map do |transition|
295
+ {
296
+ timestamp: transition[:timestamp],
297
+ from_state: transition[:from_state],
298
+ to_state: transition[:to_state],
299
+ event: transition[:event],
300
+ feature_name: transition[:feature_name],
301
+ feature_class: transition[:feature_class],
302
+ duration_since: Time.now - transition[:timestamp]
303
+ }
304
+ end,
305
+ limit: limit
306
+ }
307
+ end
308
+
309
+ # Helper methods for introspection
310
+ def feature_name
311
+ @feature_name ||= self.class.name&.split('::')&.last || 'UnnamedFeature'
312
+ end
313
+
314
+ # Get transition count
315
+ def transition_count
316
+ @transition_history.size
317
+ end
318
+
319
+ private
320
+
321
+ def group_matcher_info
322
+ case group_matcher
323
+ when Symbol
324
+ { type: 'symbol', value: group_matcher }
325
+ when Proc
326
+ { type: 'proc', value: '<Proc>' }
327
+ when nil
328
+ nil
329
+ else
330
+ { type: group_matcher.class.name, value: group_matcher.to_s }
331
+ end
332
+ end
333
+
334
+ def state_duration
335
+ last_transition = @transition_history.last
336
+ return 'No transitions yet' unless last_transition
337
+
338
+ duration = Time.now - last_transition[:timestamp]
339
+ if duration < 60
340
+ "#{duration.round(1)}s"
341
+ elsif duration < 3600
342
+ "#{(duration / 60).round(1)}m"
343
+ else
344
+ "#{(duration / 3600).round(1)}h"
345
+ end
346
+ end
347
+
348
+ def state_machine_config
349
+ # Extract state machine configuration
350
+ machine = self.class.state_machines[:status]
351
+ return {} unless machine
352
+
353
+ initial_state_obj = machine.initial_state(self)
354
+ initial_state = initial_state_obj.respond_to?(:name) ? initial_state_obj.name : initial_state_obj
355
+
356
+ {
357
+ initial_state: initial_state,
358
+ states: machine.states.map(&:name),
359
+ events: machine.events.map(&:name)
360
+ }
361
+ end
362
+
363
+ def eligibility_reason(context)
364
+ case status
365
+ when 'disabled'
366
+ 'Feature is disabled'
367
+ when 'enabled'
368
+ 'Feature is fully enabled'
369
+ when 'canary'
370
+ eligible_for_rollout?(context) ? 'User is eligible for rollout' : 'User is not eligible for rollout'
371
+ else
372
+ "Unknown status: #{status}"
373
+ end
374
+ end
375
+
376
+ def parse_rollout(rollout)
377
+ case rollout
378
+ when Numeric then rollout.to_i
379
+ when String then rollout.delete_suffix('%').to_i
380
+ else 0
381
+ end
382
+ end
383
+
384
+ def group_matches?(context)
385
+ return false unless group_matcher
386
+
387
+ user = context[:user]
388
+ return false unless user
389
+
390
+ case group_matcher
391
+ when Symbol
392
+ user.respond_to?(:groups) && user.groups.include?(group_matcher)
393
+ when Proc
394
+ group_matcher.call(user)
395
+ else
396
+ false
397
+ end
398
+ end
399
+
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
411
+ end
412
+
413
+ def hash_user_id(user_id)
414
+ user_id.to_s.hash.abs
415
+ end
416
+
417
+ # Record state transition with timestamp and metadata
418
+ def record_transition(transition)
419
+ # Convert state values to symbols for consistency
420
+ from_state = transition.from.is_a?(String) ? transition.from.to_sym : transition.from
421
+ to_state = transition.to.is_a?(String) ? transition.to.to_sym : transition.to
422
+
423
+ transition_record = {
424
+ timestamp: Time.now,
425
+ from_state: from_state,
426
+ to_state: to_state,
427
+ event: transition.event,
428
+ feature_name: feature_name,
429
+ feature_class: self.class.name
430
+ }
431
+
432
+ @transition_history << transition_record
433
+
434
+ # Keep only the last 50 transitions to prevent memory bloat
435
+ return unless @transition_history.size > 50
436
+
437
+ @transition_history.replace(@transition_history.last(50))
438
+ end
439
+ end
440
+
441
+ class PercentageHelper
442
+ def self.percent(value)
443
+ value
444
+ end
445
+ end
446
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StarGates
4
+ # Boolean flags with environment > configuration > runtime > false precedence.
5
+ # Runtime state uses Feature's lifecycle and is local to this process.
6
+ class Flags
7
+ attr_accessor :configuration
8
+
9
+ def initialize(env: ENV, configuration: {})
10
+ @env = env
11
+ @configuration = configuration
12
+ @runtime = {}
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def enabled?(name)
17
+ debug(name)[:enabled]
18
+ end
19
+
20
+ def set(name, value)
21
+ update(name, value)
22
+ end
23
+
24
+ def enable(name, metadata: {})
25
+ update(name, true, metadata: metadata)
26
+ end
27
+
28
+ def disable(name)
29
+ update(name, false)
30
+ end
31
+
32
+ def enable_if(name)
33
+ set(name, yield)
34
+ end
35
+
36
+ def remove(name)
37
+ @mutex.synchronize { @runtime.delete(normalize(name)) }
38
+ nil
39
+ end
40
+
41
+ def reset!
42
+ @mutex.synchronize { @runtime.clear }
43
+ nil
44
+ end
45
+
46
+ def all
47
+ 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)] }
50
+ end
51
+
52
+ def enabled_flags
53
+ all.select { |_, flag| flag[:enabled] }.keys.map(&:to_sym)
54
+ end
55
+
56
+ def summary
57
+ {
58
+ runtime_flags: runtime_names.map(&:to_sym),
59
+ environment_flags: environment_flags.keys.map(&:to_sym),
60
+ configuration_flags: configuration_flags.keys.map(&:to_sym),
61
+ all_enabled: enabled_flags
62
+ }
63
+ end
64
+
65
+ def debug(name)
66
+ resolution(normalize(name), configuration_flags)
67
+ end
68
+
69
+ def flag_status(name)
70
+ normalized = normalize(name)
71
+ 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 }
74
+ end
75
+
76
+ private
77
+
78
+ def normalize(name)
79
+ return unless name.is_a?(String) || name.is_a?(Symbol)
80
+
81
+ normalized = name.to_s.tr('-', '_').downcase
82
+ normalized if normalized.match?(/\A[a-z0-9_]+\z/)
83
+ end
84
+
85
+ def boolean(value)
86
+ value == true || (value.is_a?(String) && %w[true 1 yes on].include?(value.downcase))
87
+ end
88
+
89
+ def update(name, value, metadata: nil)
90
+ normalized = normalize(name)
91
+ raise ArgumentError, 'flag name must be a nonempty string or symbol' unless normalized
92
+
93
+ enabled = boolean(value)
94
+ @mutex.synchronize do
95
+ entry = @runtime[normalized] ||= { feature: Feature.new, metadata: {} }
96
+ feature = entry.fetch(:feature)
97
+ feature.enable if enabled && feature.disabled?
98
+ feature.disable if !enabled && !feature.disabled?
99
+ entry[:metadata] = metadata.dup if metadata
100
+ end
101
+ enabled
102
+ end
103
+
104
+ def runtime_names
105
+ @mutex.synchronize { @runtime.keys }
106
+ end
107
+
108
+ def configuration_flags
109
+ values = configuration.is_a?(Proc) ? configuration.call : configuration
110
+ raise ArgumentError, 'configuration must be a Hash or a Proc returning a Hash' unless values.is_a?(Hash)
111
+
112
+ values.each_with_object({}) do |(name, value), flags|
113
+ normalized = normalize(name)
114
+ flags[normalized] = value if normalized && [true, false].include?(value)
115
+ end
116
+ end
117
+
118
+ def environment_flags
119
+ @env.each_pair.with_object({}) do |(key, value), flags|
120
+ next unless key.start_with?('FEATURE_') && !value.to_s.strip.empty?
121
+
122
+ name = normalize(key.delete_prefix('FEATURE_'))
123
+ flags[name] = value if name && key == "FEATURE_#{name.upcase}"
124
+ end
125
+ end
126
+
127
+ def resolution(name, configured)
128
+ env_key = "FEATURE_#{name&.upcase}"
129
+ environment_value = name && @env[env_key]
130
+ runtime_value = @mutex.synchronize { @runtime[name]&.fetch(:feature)&.enabled_for? }
131
+
132
+ value, source = if name && !environment_value.to_s.strip.empty?
133
+ [boolean(environment_value), "environment:#{env_key}"]
134
+ elsif configured.key?(name)
135
+ [configured.fetch(name), "config:#{name}"]
136
+ elsif !runtime_value.nil?
137
+ [runtime_value, 'runtime']
138
+ else
139
+ [false, 'default']
140
+ end
141
+
142
+ {
143
+ flag: name&.to_sym,
144
+ enabled: value,
145
+ value: value,
146
+ source: source,
147
+ environment_value: environment_value,
148
+ configuration_value: configured[name],
149
+ runtime_value: runtime_value
150
+ }
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StarGates
4
+ class Gate
5
+ attr_reader :name, :feature, :circuit, :run_block, :fallback_block
6
+
7
+ def initialize(name, feature: nil, circuit: nil, run_block: nil, fallback_block: nil)
8
+ @name = name
9
+ @feature = feature
10
+ @circuit = circuit
11
+ @run_block = run_block
12
+ @fallback_block = fallback_block
13
+ end
14
+
15
+ def call(context = {})
16
+ # Use the new state-specific execute method if feature supports it
17
+ if feature.respond_to?(:execute)
18
+ result = if circuit
19
+ circuit.call { feature.execute(context) { execute_run_block(context) } }
20
+ else
21
+ feature.execute(context) { execute_run_block(context) }
22
+ end
23
+
24
+ # If feature returns nil (disabled), execute fallback
25
+ return result unless result.nil?
26
+
27
+ execute_fallback(context)
28
+ else
29
+ # Fallback to old behavior for compatibility
30
+ return execute_fallback(context) unless feature_enabled?(context)
31
+
32
+ if circuit
33
+ circuit.call { execute_run_block(context) }
34
+ else
35
+ execute_run_block(context)
36
+ end
37
+ end
38
+ rescue StarGates::UnavailableError, StandardError
39
+ execute_fallback(context)
40
+ end
41
+
42
+ def enabled_for?(context = {})
43
+ feature_enabled?(context) && !circuit_open?
44
+ end
45
+
46
+ def open?
47
+ circuit&.open? || false
48
+ end
49
+
50
+ def closed?
51
+ !open?
52
+ end
53
+
54
+ def status
55
+ {
56
+ name: name,
57
+ feature_enabled: feature&.enabled? || true,
58
+ feature_status: feature.respond_to?(:status) ? feature.status : nil,
59
+ circuit_open: circuit&.open? || false,
60
+ last_failure: circuit&.last_failure_time
61
+ }
62
+ end
63
+
64
+ private
65
+
66
+ def feature_enabled?(context)
67
+ return true unless feature
68
+
69
+ feature.enabled_for?(context)
70
+ end
71
+
72
+ def circuit_open?
73
+ circuit&.open? || false
74
+ end
75
+
76
+ def execute_run_block(context)
77
+ return unless run_block
78
+
79
+ if run_block.arity == 1
80
+ run_block.call(context)
81
+ else
82
+ run_block.call
83
+ end
84
+ end
85
+
86
+ def execute_fallback(context)
87
+ return raise_unavailable unless fallback_block
88
+
89
+ if fallback_block.arity == 1
90
+ fallback_block.call(context)
91
+ else
92
+ fallback_block.call
93
+ end
94
+ end
95
+
96
+ def raise_unavailable
97
+ raise StarGates::UnavailableError, "Gate #{name} is unavailable and no fallback is defined"
98
+ end
99
+ end
100
+ end