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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_adapter'
4
+
5
+ module StarGates
6
+ module Adapters
7
+ class RedisAdapter < BaseAdapter
8
+ def initialize(config = {})
9
+ super
10
+ @redis = config[:redis] || redis_from_config
11
+ @key_prefix = config[:key_prefix] || 'star_gates'
12
+ @default_ttl = config[:default_ttl] || 3600 # 1 hour
13
+ end
14
+
15
+ def store(key, value)
16
+ serialized = serialize(value)
17
+ @redis.set(prefixed_key(key), serialized)
18
+ end
19
+
20
+ def fetch(key)
21
+ value = @redis.get(prefixed_key(key))
22
+ value ? deserialize(value) : nil
23
+ end
24
+
25
+ def delete(key)
26
+ @redis.del(prefixed_key(key))
27
+ end
28
+
29
+ def exists?(key)
30
+ @redis.exists?(prefixed_key(key)).positive?
31
+ end
32
+
33
+ def cache_fetch(key, expires_in: nil, &block)
34
+ prefixed = prefixed_key(key)
35
+ cached = @redis.get(prefixed)
36
+
37
+ if cached
38
+ deserialize(cached)
39
+ elsif block_given?
40
+ value = block.call
41
+ if value
42
+ serialized = serialize(value)
43
+ ttl = expires_in || @default_ttl
44
+ @redis.setex(prefixed, ttl, serialized)
45
+ end
46
+ value
47
+ end
48
+ end
49
+
50
+ def cache_write(key, value, expires_in: nil)
51
+ serialized = serialize(value)
52
+ ttl = expires_in || @default_ttl
53
+ @redis.setex(prefixed_key(key), ttl, serialized)
54
+ end
55
+
56
+ def cache_delete(key)
57
+ @redis.del(prefixed_key(key))
58
+ end
59
+
60
+ def publish(topic, message)
61
+ @redis.publish(topic_key(topic), serialize(message))
62
+ end
63
+
64
+ def subscribe(topic, &block)
65
+ # Redis pub/sub requires a dedicated connection
66
+ # This is a simplified version - in production, use a separate subscriber thread
67
+ @redis.subscribe(topic_key(topic)) do |on|
68
+ on.message do |_channel, message|
69
+ block.call(deserialize(message))
70
+ end
71
+ end
72
+ end
73
+
74
+ def healthy?
75
+ @redis.ping == 'PONG'
76
+ rescue StandardError
77
+ false
78
+ end
79
+
80
+ private
81
+
82
+ def prefixed_key(key)
83
+ "#{@key_prefix}:#{key}"
84
+ end
85
+
86
+ def topic_key(topic)
87
+ "#{@key_prefix}:topics:#{topic}"
88
+ end
89
+
90
+ def serialize(value)
91
+ case value
92
+ when String
93
+ value
94
+ when Symbol
95
+ value.to_s
96
+ else
97
+ JSON.generate(value)
98
+ end
99
+ end
100
+
101
+ def deserialize(value)
102
+ JSON.parse(value)
103
+ rescue JSON::ParserError
104
+ value
105
+ end
106
+
107
+ def redis_from_config
108
+ raise ArgumentError, "Redis gem not available. Install with: gem 'redis'" unless defined?(Redis)
109
+
110
+ # Try to use existing Redis configuration
111
+ if defined?(Rails) && Rails.application.config.respond_to?(:redis)
112
+ Redis.new(Rails.application.config.redis)
113
+ else
114
+ Redis.new
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/configurable'
4
+ require 'active_support/core_ext/class/attribute'
5
+ require 'active_support/core_ext/hash/deep_merge'
6
+ require 'active_support/core_ext/hash/indifferent_access'
7
+ require 'active_support/concern'
8
+ require 'active_support/notifications'
9
+ require_relative 'configuration'
10
+
11
+ module StarGates
12
+ class ApplicationStarGate < Feature
13
+ include ActiveSupport::Configurable
14
+
15
+ # Use ActiveSupport class attributes instead of custom implementation
16
+ class_attribute :feature_name
17
+ class_attribute :telemetry_tags, default: {}
18
+ class_attribute :state_configs, default: {}
19
+
20
+ def initialize(rollout: nil, group: nil, **options)
21
+ # Use ActiveSupport::HashWithIndifferentAccess for better key handling
22
+ @instance_config = ActiveSupport::HashWithIndifferentAccess.new(options)
23
+
24
+ # Merge with class-level config using deep_merge
25
+ @merged_config = self.class.config.to_h.with_indifferent_access.deep_merge(@instance_config)
26
+
27
+ # Use configuration defaults if not provided
28
+ rollout ||= @merged_config[:default_rollout] || 0
29
+ group ||= @merged_config[:default_group]
30
+
31
+ super(rollout: rollout, group: group)
32
+
33
+ # Initialize state-specific configurations using HashWithIndifferentAccess
34
+ @feature_config = ActiveSupport::HashWithIndifferentAccess.new(state_configs[:enabled] || {})
35
+ @default_config = ActiveSupport::HashWithIndifferentAccess.new(state_configs[:disabled] || {})
36
+ @canary_config = ActiveSupport::HashWithIndifferentAccess.new(state_configs[:canary] || {})
37
+
38
+ # Setup telemetry if enabled
39
+ setup_telemetry if telemetry_enabled?
40
+
41
+ # Setup error handling
42
+ setup_error_handling if error_reporting_enabled?
43
+ end
44
+
45
+ # Enhanced state-specific methods with instrumentation and telemetry
46
+ def execute(context = {}, &block)
47
+ instrument_execution(context) do
48
+ super(context) do
49
+ if block_given?
50
+ with_timeout(@merged_config[:timeout], &block)
51
+ else
52
+ true
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ def ui_component(context = {})
59
+ instrument_ui_access(context) do
60
+ super(context)
61
+ end
62
+ end
63
+
64
+ def config_value(key, context = {})
65
+ instrument_config_access(key, context) do
66
+ super(key, context)
67
+ end
68
+ end
69
+
70
+ # Configuration access using ActiveSupport::Configurable
71
+ def timeout
72
+ @merged_config[:timeout]
73
+ end
74
+
75
+ def retry_count
76
+ @merged_config[:retry_count]
77
+ end
78
+
79
+ def circuit_breaker_threshold
80
+ @merged_config[:circuit_breaker_threshold]
81
+ end
82
+
83
+ def cache_ttl
84
+ @merged_config[:cache_ttl]
85
+ end
86
+
87
+ # Public method for checking performance monitoring status
88
+ def performance_monitoring_enabled?
89
+ @merged_config[:performance_monitoring]
90
+ end
91
+
92
+ # Public method for configuration summary
93
+ def configuration_summary
94
+ {
95
+ timeout: timeout,
96
+ retry_count: retry_count,
97
+ circuit_breaker_threshold: circuit_breaker_threshold,
98
+ cache_ttl: cache_ttl,
99
+ telemetry_enabled: telemetry_enabled?,
100
+ performance_monitoring: performance_monitoring_enabled?,
101
+ error_reporting: error_reporting_enabled?
102
+ }
103
+ end
104
+
105
+ # Public method to get feature identifier
106
+ def feature_identifier
107
+ self.class.feature_name || self.class.name.demodulize.underscore
108
+ end
109
+
110
+ # Feature-specific configuration with state awareness
111
+ def feature_config
112
+ case status
113
+ when 'enabled'
114
+ @feature_config
115
+ when 'canary'
116
+ @canary_config.any? ? @canary_config : @feature_config
117
+ else
118
+ @default_config
119
+ end
120
+ end
121
+
122
+ attr_reader :default_config
123
+
124
+ # Performance monitoring
125
+ def performance_stats
126
+ return {} unless performance_monitoring_enabled?
127
+
128
+ {
129
+ average_execution_time: @execution_times&.sum&./([@execution_times.size, 1].max) || 0,
130
+ total_executions: @execution_count || 0,
131
+ error_rate: calculate_error_rate,
132
+ last_execution: @last_execution_time
133
+ }
134
+ end
135
+
136
+ # Enhanced introspection with application-specific data
137
+ def to_h
138
+ base_data = super
139
+ base_data.merge(
140
+ configuration: configuration_summary,
141
+ performance: performance_stats,
142
+ environment: StarGates.configuration.environment,
143
+ feature_name: self.class.feature_name || feature_name
144
+ )
145
+ end
146
+
147
+ # Class methods for configuration using ActiveSupport::Configurable
148
+ class << self
149
+ def configure(name = nil, **options)
150
+ self.feature_name = name if name
151
+
152
+ # Extract state-specific configs
153
+ state_specific = extract_state_configs(options)
154
+ self.state_configs = state_specific[:states] if state_specific[:states].any?
155
+
156
+ # Extract telemetry tags
157
+ self.telemetry_tags = extract_telemetry_tags(options)
158
+
159
+ # Set configuration using ActiveSupport::Configurable
160
+ remaining_options = options.except(*state_specific[:extracted_keys])
161
+ remaining_options.each do |key, value|
162
+ config.send("#{key}=", value)
163
+ end
164
+ end
165
+
166
+ def with_timeout(seconds, **options)
167
+ configure(timeout: seconds, **options)
168
+ end
169
+
170
+ def with_circuit_breaker(threshold: 5, **options)
171
+ configure(circuit_breaker_threshold: threshold, **options)
172
+ end
173
+
174
+ def for_team(team_name, **options)
175
+ self.telemetry_tags = { team: team_name }
176
+ configure(**options)
177
+ end
178
+
179
+ def critical_feature(**options)
180
+ configure(
181
+ timeout: 1.0,
182
+ retry_count: 5,
183
+ circuit_breaker_threshold: 10,
184
+ performance_monitoring: true,
185
+ **options
186
+ )
187
+ end
188
+
189
+ def experimental_feature(**options)
190
+ configure(
191
+ timeout: 10.0,
192
+ retry_count: 1,
193
+ performance_monitoring: true,
194
+ **options
195
+ )
196
+ end
197
+
198
+ private
199
+
200
+ def extract_state_configs(options)
201
+ state_keys = %i[enabled_config disabled_config canary_config]
202
+ extracted = {}
203
+
204
+ state_keys.each do |key|
205
+ if options.key?(key)
206
+ state_name = key.to_s.sub('_config', '').to_sym
207
+ extracted[state_name] = options[key]
208
+ end
209
+ end
210
+
211
+ {
212
+ states: extracted,
213
+ extracted_keys: state_keys
214
+ }
215
+ end
216
+
217
+ def extract_telemetry_tags(options)
218
+ options.select { |k, _| k.to_s.start_with?('tag_') }
219
+ .transform_keys { |k| k.to_s.sub(/^tag_/, '').to_sym }
220
+ end
221
+ end
222
+
223
+ private
224
+
225
+ def telemetry_enabled?
226
+ @merged_config[:instrumentation] && StarGates.telemetry
227
+ end
228
+
229
+ def error_reporting_enabled?
230
+ @merged_config[:error_reporting]
231
+ end
232
+
233
+ def setup_telemetry
234
+ @execution_times = []
235
+ @execution_count = 0
236
+ @error_count = 0
237
+ @last_execution_time = nil
238
+ end
239
+
240
+ def setup_error_handling
241
+ # Setup error tracking and reporting
242
+ end
243
+
244
+ def instrument_execution(context)
245
+ return yield unless telemetry_enabled?
246
+
247
+ # Use ActiveSupport::Notifications for instrumentation
248
+ ActiveSupport::Notifications.instrument('star_gates.feature.execute',
249
+ feature: feature_identifier,
250
+ status: status,
251
+ context: context) do |payload|
252
+ start_time = Time.now
253
+ begin
254
+ result = yield
255
+ execution_time = Time.now - start_time
256
+ record_successful_execution(execution_time)
257
+
258
+ payload[:success] = true
259
+ payload[:execution_time_ms] = (execution_time * 1000).round(2)
260
+
261
+ result
262
+ rescue StandardError => e
263
+ execution_time = Time.now - start_time
264
+ record_failed_execution(execution_time, e)
265
+
266
+ payload[:success] = false
267
+ payload[:execution_time_ms] = (execution_time * 1000).round(2)
268
+ payload[:error] = e.class.name
269
+
270
+ raise
271
+ ensure
272
+ StarGates.telemetry&.record_feature_execution(
273
+ feature_identifier,
274
+ status,
275
+ !defined?(e),
276
+ payload[:execution_time_ms],
277
+ context
278
+ )
279
+ end
280
+ end
281
+ end
282
+
283
+ def instrument_ui_access(context)
284
+ return yield unless telemetry_enabled?
285
+
286
+ # Use ActiveSupport::Notifications for UI access tracking
287
+ ActiveSupport::Notifications.instrument('star_gates.ui_component.access',
288
+ feature: feature_identifier,
289
+ context: context) do |payload|
290
+ result = yield
291
+ payload[:component] = result
292
+
293
+ StarGates.telemetry&.record_counter(
294
+ 'star_gates.ui_component.access',
295
+ 1,
296
+ tags: telemetry_tags.merge(component: result, feature: feature_identifier)
297
+ )
298
+
299
+ result
300
+ end
301
+ end
302
+
303
+ def instrument_config_access(key, context)
304
+ return yield unless telemetry_enabled?
305
+
306
+ # Use ActiveSupport::Notifications for config access tracking
307
+ ActiveSupport::Notifications.instrument('star_gates.config.access',
308
+ feature: feature_identifier,
309
+ config_key: key,
310
+ context: context) do |payload|
311
+ result = yield
312
+ payload[:config_value] = result
313
+
314
+ StarGates.telemetry&.record_counter(
315
+ 'star_gates.config.access',
316
+ 1,
317
+ tags: telemetry_tags.merge(config_key: key, feature: feature_identifier)
318
+ )
319
+
320
+ result
321
+ end
322
+ end
323
+
324
+ def with_timeout(seconds, &)
325
+ if defined?(Timeout)
326
+ Timeout.timeout(seconds, &)
327
+ else
328
+ yield
329
+ end
330
+ rescue Timeout::Error
331
+ raise StarGates::UnavailableError, "Feature #{feature_identifier} timed out after #{seconds}s"
332
+ end
333
+
334
+ def record_successful_execution(duration)
335
+ return unless performance_monitoring_enabled?
336
+
337
+ @execution_times << duration
338
+ @execution_count += 1
339
+ @last_execution_time = Time.now
340
+
341
+ # Keep only last 100 measurements for memory efficiency
342
+ @execution_times = @execution_times.last(100) if @execution_times.size > 100
343
+ end
344
+
345
+ def record_failed_execution(duration, _error)
346
+ return unless performance_monitoring_enabled?
347
+
348
+ @execution_times << duration
349
+ @execution_count += 1
350
+ @error_count += 1
351
+ @last_execution_time = Time.now
352
+ end
353
+
354
+ def calculate_error_rate
355
+ return 0 if @execution_count.nil? || @execution_count.zero?
356
+
357
+ (@error_count.to_f / @execution_count * 100).round(2)
358
+ end
359
+
360
+ def telemetry_tags
361
+ base_tags = {
362
+ feature: feature_identifier,
363
+ state: status,
364
+ environment: StarGates.configuration.environment
365
+ }
366
+
367
+ class_tags = self.class.telemetry_tags || {}
368
+ base_tags.merge(class_tags)
369
+ end
370
+
371
+ # State-specific configuration overrides
372
+ def apply_state_specific_config
373
+ case status
374
+ when 'disabled'
375
+ @current_config = @default_config
376
+ when 'canary'
377
+ @current_config = @canary_config.any? ? @canary_config : @feature_config
378
+ when 'enabled'
379
+ @current_config = @feature_config
380
+ end
381
+ end
382
+ end
383
+ end
384
+
385
+ # Add string extensions for better naming
386
+ unless String.method_defined?(:demodulize)
387
+ class String
388
+ def demodulize
389
+ split('::').last
390
+ end
391
+
392
+ def underscore
393
+ gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
394
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
395
+ .downcase
396
+ end
397
+ end
398
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chrono_machines'
4
+
5
+ module StarGates
6
+ class Circuit
7
+ attr_reader :breaker, :threshold, :within, :reset_after, :feature
8
+ attr_accessor :retry_policy
9
+
10
+ def initialize(threshold: 5, within: 30, reset_after: 60, retry_config: nil, feature: nil, auto_rollback: true)
11
+ @threshold = threshold
12
+ @within = parse_time(within)
13
+ @reset_after = parse_time(reset_after)
14
+ @feature = feature
15
+ @auto_rollback = auto_rollback
16
+ @breaker = create_breaker
17
+ @retry_policy = build_retry_policy(retry_config)
18
+ end
19
+
20
+ def call(&block)
21
+ # Integrate ChronoMachines for exponential backoff retries
22
+ # This prevents the "retry death spiral" by adding jittered delays
23
+ if @retry_policy
24
+ ChronoMachines.retry(@retry_policy) do
25
+ breaker.call(&block)
26
+ end
27
+ else
28
+ breaker.call(&block)
29
+ end
30
+ rescue ChronoMachines::MaxRetriesExceededError => e
31
+ # Re-raise the original exception after retries exhausted
32
+ raise e.original_exception
33
+ end
34
+
35
+ def open?
36
+ breaker.open?
37
+ end
38
+
39
+ def closed?
40
+ breaker.closed?
41
+ end
42
+
43
+ def half_open?
44
+ breaker.half_open?
45
+ end
46
+
47
+ def last_failure_time
48
+ # BreakerMachines stores this as last_failure_at in stats
49
+ stats = breaker.stats
50
+ stats.last_failure_at
51
+ end
52
+
53
+ def failure_count
54
+ # BreakerMachines stores this in stats
55
+ stats = breaker.stats
56
+ stats.failure_count || 0
57
+ end
58
+
59
+ def success_count
60
+ # BreakerMachines stores this in stats
61
+ stats = breaker.stats
62
+ stats.success_count || 0
63
+ end
64
+
65
+ def reset!
66
+ breaker.reset
67
+ end
68
+
69
+ private
70
+
71
+ def create_breaker
72
+ # Generate a unique circuit name for this instance
73
+ circuit_name = "star_gates_circuit_#{object_id}"
74
+
75
+ config = {
76
+ failure_threshold: threshold,
77
+ failure_window: within,
78
+ reset_timeout: reset_after,
79
+ exceptions: [StandardError]
80
+ }
81
+
82
+ # Self-healing: wire circuit state to feature state
83
+ if @feature && @auto_rollback
84
+ config[:on_open] = -> { handle_circuit_open }
85
+ config[:on_half_open] = -> { handle_circuit_half_open }
86
+ config[:on_close] = -> { handle_circuit_close }
87
+ end
88
+
89
+ BreakerMachines::Circuit.new(circuit_name, config)
90
+ end
91
+
92
+ # Self-healing callbacks
93
+ def handle_circuit_open
94
+ return unless @feature.respond_to?(:status)
95
+
96
+ StarGates.instrument('circuit.auto_rollback', {
97
+ feature_status: @feature.status,
98
+ circuit_state: 'open',
99
+ action: 'rolling_back'
100
+ })
101
+
102
+ # Auto-rollback feature state when circuit opens
103
+ case @feature.status
104
+ when 'enabled'
105
+ # Circuit is failing → rollback to canary
106
+ @feature.rollout_canary if @feature.respond_to?(:rollout_canary)
107
+ when 'canary'
108
+ # Circuit still failing in canary → disable completely
109
+ @feature.disable if @feature.respond_to?(:disable)
110
+ end
111
+ end
112
+
113
+ def handle_circuit_half_open
114
+ return unless @feature
115
+
116
+ StarGates.instrument('circuit.half_open', {
117
+ feature_status: @feature.status,
118
+ circuit_state: 'half_open',
119
+ action: 'testing_recovery'
120
+ })
121
+ end
122
+
123
+ def handle_circuit_close
124
+ return unless @feature
125
+
126
+ StarGates.instrument('circuit.recovered', {
127
+ feature_status: @feature.status,
128
+ circuit_state: 'closed',
129
+ action: 'circuit_healthy'
130
+ })
131
+
132
+ # Optionally: auto-restore to canary when circuit closes
133
+ # (conservative: don't auto-enable to 100%, just canary)
134
+ # Uncomment if you want auto-restoration:
135
+ # if @feature.status == 'disabled'
136
+ # @feature.rollout_canary if @feature.respond_to?(:rollout_canary)
137
+ # end
138
+ end
139
+
140
+ def parse_time(time)
141
+ # Rails duration helpers can return fractional seconds.
142
+ case time
143
+ when Numeric then time
144
+ when ActiveSupport::Duration then time.to_f
145
+ else time.to_i
146
+ end
147
+ end
148
+
149
+ def build_retry_policy(config)
150
+ return nil unless config
151
+
152
+ # Default retry policy for circuit breakers
153
+ defaults = {
154
+ max_attempts: 3,
155
+ base_delay: 0.1,
156
+ multiplier: 2,
157
+ max_delay: 5,
158
+ retryable_exceptions: [StandardError],
159
+ on_retry: lambda { |exception:, attempt:, next_delay:|
160
+ StarGates.instrument('circuit.retry', {
161
+ exception: exception.class.name,
162
+ attempt: attempt,
163
+ next_delay: next_delay
164
+ })
165
+ }
166
+ }
167
+
168
+ defaults.merge(config)
169
+ end
170
+ end
171
+ end