star_gates 0.0.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.
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 +60 -3
  5. data/LICENSE.txt +23 -16
  6. data/README.md +182 -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 +45 -0
  19. data/lib/star_gates/events/feature_events.rb +97 -0
  20. data/lib/star_gates/feature.rb +426 -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 +252 -0
  30. data/lib/star_gates/telemetry/prometheus_adapter.rb +183 -0
  31. data/lib/star_gates/telemetry/telemetry_adapter.rb +160 -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,426 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+ require 'state_machines'
5
+ require 'concurrent'
6
+
7
+ module StarGates
8
+ class Feature
9
+ attr_reader :rollout_percentage, :group_matcher
10
+
11
+ def initialize(rollout: 0, group: nil)
12
+ @rollout_percentage = parse_rollout(rollout)
13
+ @group_matcher = group
14
+ @created_at = Time.now
15
+ @transition_history = Concurrent::Array.new
16
+ super()
17
+ end
18
+
19
+ # State machine for feature lifecycle
20
+ state_machine :status, initial: :disabled do
21
+ # State: Disabled - Feature is completely off
22
+ state :disabled do
23
+ def execute(context = {})
24
+ StarGates.instrument('feature_disabled', feature: self.class.name, context: context)
25
+ nil
26
+ end
27
+
28
+ def ui_component(_context = {})
29
+ 'default_component'
30
+ end
31
+
32
+ def config_value(key, _context = {})
33
+ default_config[key]
34
+ end
35
+ end
36
+
37
+ # State: Enabled - Feature is fully rolled out
38
+ state :enabled do
39
+ def execute(context = {})
40
+ StarGates.instrument('feature_enabled', feature: self.class.name, context: context)
41
+ if block_given?
42
+ yield
43
+ else
44
+ true
45
+ end
46
+ end
47
+
48
+ def ui_component(_context = {})
49
+ 'feature_component'
50
+ end
51
+
52
+ def config_value(key, _context = {})
53
+ feature_config[key]
54
+ end
55
+ end
56
+
57
+ # State: Canary - Feature is in gradual rollout
58
+ state :canary do
59
+ def execute(context = {})
60
+ if eligible_for_rollout?(context)
61
+ StarGates.instrument('feature_canary_enabled', feature: self.class.name, context: context)
62
+ if block_given?
63
+ yield
64
+ else
65
+ true
66
+ end
67
+ else
68
+ StarGates.instrument('feature_canary_disabled', feature: self.class.name, context: context)
69
+ nil
70
+ end
71
+ end
72
+
73
+ def ui_component(context = {})
74
+ if eligible_for_rollout?(context)
75
+ 'feature_component'
76
+ else
77
+ 'default_component'
78
+ end
79
+ end
80
+
81
+ def config_value(key, context = {})
82
+ if eligible_for_rollout?(context)
83
+ feature_config[key]
84
+ else
85
+ default_config[key]
86
+ end
87
+ end
88
+ end
89
+
90
+ # Events for state transitions
91
+ event :enable do
92
+ transition %i[disabled canary] => :enabled
93
+ end
94
+
95
+ event :disable do
96
+ transition %i[enabled canary] => :disabled
97
+ end
98
+
99
+ event :rollout_canary do
100
+ transition %i[disabled enabled] => :canary
101
+ end
102
+
103
+ # Transition tracking callbacks
104
+ after_transition any => any do |feature, transition|
105
+ feature.send(:record_transition, transition)
106
+ end
107
+ end
108
+
109
+ def enabled_for?(context = {})
110
+ case status
111
+ when 'enabled'
112
+ true
113
+ when 'canary'
114
+ eligible_for_rollout?(context)
115
+ else
116
+ false
117
+ end
118
+ end
119
+
120
+ def enabled?
121
+ !disabled?
122
+ end
123
+
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
+ key = rollout_key(context)
130
+ return false if key.nil?
131
+
132
+ (hash_key(key) % 100) < rollout_percentage
133
+ end
134
+
135
+ # Configuration methods for state-specific behavior
136
+ def feature_config
137
+ @feature_config ||= {}
138
+ end
139
+
140
+ def default_config
141
+ @default_config ||= {}
142
+ end
143
+
144
+ # ============================================================================
145
+ # INTROSPECTION METHODS
146
+ # ============================================================================
147
+
148
+ # Get current rollout statistics
149
+ def stats
150
+ {
151
+ name: self.class.name,
152
+ status: status,
153
+ status_name: status_name,
154
+ rollout_percentage: rollout_percentage,
155
+ group_matcher: group_matcher_info,
156
+ created_at: @created_at,
157
+ state_duration: state_duration,
158
+ total_transitions: transition_count
159
+ }
160
+ end
161
+
162
+ # Get complete feature configuration
163
+ def configuration
164
+ {
165
+ rollout_percentage: rollout_percentage,
166
+ group_matcher: group_matcher,
167
+ feature_config: feature_config.dup,
168
+ default_config: default_config.dup,
169
+ state_machine_config: state_machine_config
170
+ }
171
+ end
172
+
173
+ # Human-readable feature status summary
174
+ def summary
175
+ case status
176
+ when 'disabled'
177
+ "Feature '#{feature_name}' is DISABLED. No keys are eligible."
178
+ when 'enabled'
179
+ "Feature '#{feature_name}' is ENABLED for all keys (100% rollout)."
180
+ when 'canary'
181
+ if group_matcher
182
+ "Feature '#{feature_name}' is in CANARY mode for group #{group_matcher} (#{rollout_percentage}% rollout)."
183
+ else
184
+ "Feature '#{feature_name}' is in CANARY mode with #{rollout_percentage}% rollout."
185
+ end
186
+ else
187
+ "Feature '#{feature_name}' is in unknown state: #{status}."
188
+ end
189
+ end
190
+
191
+ # Complete introspection data export
192
+ def to_h
193
+ {
194
+ name: feature_name,
195
+ class: self.class.name,
196
+ status: status,
197
+ stats: stats,
198
+ configuration: configuration,
199
+ summary: summary,
200
+ introspected_at: Time.now
201
+ }
202
+ end
203
+
204
+ def simulate(key:, groups: [], **attributes)
205
+ context = attributes.merge(key: key, groups: groups)
206
+
207
+ eligible = enabled_for?(context)
208
+ ui_component_result = ui_component(context)
209
+
210
+ {
211
+ key: key,
212
+ groups: groups,
213
+ eligible: eligible,
214
+ reason: eligibility_reason(context),
215
+ ui_component: ui_component_result,
216
+ would_execute: eligible,
217
+ feature_status: status
218
+ }
219
+ end
220
+
221
+ def eligibility_debug(**context)
222
+ key = rollout_key(context)
223
+
224
+ debug_info = {
225
+ key: key,
226
+ feature_status: status,
227
+ rollout_percentage: rollout_percentage,
228
+ group_matcher: group_matcher,
229
+ eligible: enabled_for?(context)
230
+ }
231
+
232
+ case status
233
+ when 'disabled'
234
+ debug_info[:reason] = 'Feature is disabled'
235
+ when 'enabled'
236
+ debug_info[:reason] = 'Feature is fully enabled'
237
+ when 'canary'
238
+ if group_matcher
239
+ group_match = group_matches?(context)
240
+ debug_info[:group_match] = group_match
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}%"
248
+ else
249
+ "Key hash #{key_hash % 100} is outside rollout #{rollout_percentage}%"
250
+ end
251
+ else
252
+ debug_info[:reason] = 'No rollout key found in context'
253
+ end
254
+ end
255
+
256
+ debug_info
257
+ end
258
+
259
+ # Test different rollout scenarios
260
+ def test_rollout_scenarios(percentages, sample_keys: (1..1000).to_a.sample(100))
261
+ scenarios = {}
262
+
263
+ percentages.each do |percentage|
264
+ eligible_count = 0
265
+ sample_keys.each do |key|
266
+ eligible_count += 1 if (hash_key(key) % 100) < percentage
267
+ end
268
+
269
+ scenarios[percentage] = {
270
+ percentage: percentage,
271
+ sample_size: sample_keys.size,
272
+ eligible_keys: eligible_count,
273
+ estimated_percentage: (eligible_count.to_f / sample_keys.size * 100).round(2)
274
+ }
275
+ end
276
+
277
+ scenarios
278
+ end
279
+
280
+ # Get transition history for this feature
281
+ def transition_history(limit: 10)
282
+ {
283
+ feature_name: feature_name,
284
+ total_transitions: @transition_history.size,
285
+ transitions: @transition_history.last(limit).reverse.map do |transition|
286
+ {
287
+ timestamp: transition[:timestamp],
288
+ from_state: transition[:from_state],
289
+ to_state: transition[:to_state],
290
+ event: transition[:event],
291
+ feature_name: transition[:feature_name],
292
+ feature_class: transition[:feature_class],
293
+ duration_since: Time.now - transition[:timestamp]
294
+ }
295
+ end,
296
+ limit: limit
297
+ }
298
+ end
299
+
300
+ # Helper methods for introspection
301
+ def feature_name
302
+ @feature_name ||= self.class.name&.split('::')&.last || 'UnnamedFeature'
303
+ end
304
+
305
+ # Get transition count
306
+ def transition_count
307
+ @transition_history.size
308
+ end
309
+
310
+ private
311
+
312
+ def group_matcher_info
313
+ case group_matcher
314
+ when Symbol
315
+ { type: 'symbol', value: group_matcher }
316
+ when Proc
317
+ { type: 'proc', value: '<Proc>' }
318
+ when nil
319
+ nil
320
+ else
321
+ { type: group_matcher.class.name, value: group_matcher.to_s }
322
+ end
323
+ end
324
+
325
+ def state_duration
326
+ last_transition = @transition_history.last
327
+ return 'No transitions yet' unless last_transition
328
+
329
+ duration = Time.now - last_transition[:timestamp]
330
+ if duration < 60
331
+ "#{duration.round(1)}s"
332
+ elsif duration < 3600
333
+ "#{(duration / 60).round(1)}m"
334
+ else
335
+ "#{(duration / 3600).round(1)}h"
336
+ end
337
+ end
338
+
339
+ def state_machine_config
340
+ # Extract state machine configuration
341
+ machine = self.class.state_machines[:status]
342
+ return {} unless machine
343
+
344
+ initial_state_obj = machine.initial_state(self)
345
+ initial_state = initial_state_obj.respond_to?(:name) ? initial_state_obj.name : initial_state_obj
346
+
347
+ {
348
+ initial_state: initial_state,
349
+ states: machine.states.map(&:name),
350
+ events: machine.events.map(&:name)
351
+ }
352
+ end
353
+
354
+ def eligibility_reason(context)
355
+ case status
356
+ when 'disabled'
357
+ 'Feature is disabled'
358
+ when 'enabled'
359
+ 'Feature is fully enabled'
360
+ when 'canary'
361
+ eligible_for_rollout?(context) ? 'Context is eligible for rollout' : 'Context is not eligible for rollout'
362
+ else
363
+ "Unknown status: #{status}"
364
+ end
365
+ end
366
+
367
+ def parse_rollout(rollout)
368
+ case rollout
369
+ when Numeric then rollout.to_i
370
+ when String then rollout.delete_suffix('%').to_i
371
+ else 0
372
+ end
373
+ end
374
+
375
+ def group_matches?(context)
376
+ return false unless group_matcher
377
+
378
+ case group_matcher
379
+ when Symbol
380
+ Array(context[:groups]).include?(group_matcher)
381
+ when Proc
382
+ group_matcher.call(context)
383
+ else
384
+ false
385
+ end
386
+ end
387
+
388
+ def rollout_key(context)
389
+ key = context[:key]
390
+ key if key.is_a?(String) || key.is_a?(Integer)
391
+ end
392
+
393
+ def hash_key(key)
394
+ Zlib.crc32(key.to_s)
395
+ end
396
+
397
+ # Record state transition with timestamp and metadata
398
+ def record_transition(transition)
399
+ # Convert state values to symbols for consistency
400
+ from_state = transition.from.is_a?(String) ? transition.from.to_sym : transition.from
401
+ to_state = transition.to.is_a?(String) ? transition.to.to_sym : transition.to
402
+
403
+ transition_record = {
404
+ timestamp: Time.now,
405
+ from_state: from_state,
406
+ to_state: to_state,
407
+ event: transition.event,
408
+ feature_name: feature_name,
409
+ feature_class: self.class.name
410
+ }
411
+
412
+ @transition_history << transition_record
413
+
414
+ # Keep only the last 50 transitions to prevent memory bloat
415
+ return unless @transition_history.size > 50
416
+
417
+ @transition_history.replace(@transition_history.last(50))
418
+ end
419
+ end
420
+
421
+ class PercentageHelper
422
+ def self.percent(value)
423
+ value
424
+ end
425
+ end
426
+ 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