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,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'telemetry_adapter'
4
+
5
+ module StarGates
6
+ module Telemetry
7
+ class PrometheusAdapter < TelemetryAdapter
8
+ def initialize(config = {})
9
+ super
10
+ @prometheus = config[:prometheus] || default_prometheus
11
+ @metric_prefix = config[:metric_prefix] || 'star_gates'
12
+ @registry = {}
13
+ setup_default_metrics
14
+ end
15
+
16
+ protected
17
+
18
+ def send_metric(name, value, tags, _timestamp)
19
+ metric_name = "#{@metric_prefix}_#{name}".gsub('.', '_')
20
+
21
+ case name
22
+ when /\.gauge$/
23
+ get_or_create_gauge(metric_name, tags).set(value)
24
+ when /\.histogram$/, /\.timing$/
25
+ get_or_create_histogram(metric_name, tags).observe(value)
26
+ else
27
+ # Default to counter
28
+ get_or_create_counter(metric_name, tags).increment(by: value)
29
+ end
30
+ end
31
+
32
+ def healthy?
33
+ super && @prometheus.respond_to?(:registry)
34
+ end
35
+
36
+ private
37
+
38
+ def default_prometheus
39
+ unless defined?(Prometheus)
40
+ raise ArgumentError, "Prometheus gem not available. Install with: gem 'prometheus-client'"
41
+ end
42
+
43
+ Prometheus
44
+ end
45
+
46
+ def setup_default_metrics
47
+ # Pre-register common metrics for better performance
48
+ @registry[:feature_evaluations] = @prometheus.counter(
49
+ :"#{@metric_prefix}_feature_evaluations_total",
50
+ docstring: 'Total number of feature flag evaluations',
51
+ labels: %i[feature_id eligible]
52
+ )
53
+
54
+ @registry[:feature_executions] = @prometheus.counter(
55
+ :"#{@metric_prefix}_feature_executions_total",
56
+ docstring: 'Total number of feature flag executions',
57
+ labels: %i[feature_id state success]
58
+ )
59
+
60
+ @registry[:state_transitions] = @prometheus.counter(
61
+ :"#{@metric_prefix}_state_transitions_total",
62
+ docstring: 'Total number of feature flag state transitions',
63
+ labels: %i[feature_id from_state to_state event]
64
+ )
65
+
66
+ @registry[:execution_duration] = @prometheus.histogram(
67
+ :"#{@metric_prefix}_execution_duration_milliseconds",
68
+ docstring: 'Feature execution duration in milliseconds',
69
+ labels: %i[feature_id state],
70
+ buckets: [0.1, 0.5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500, 1000]
71
+ )
72
+
73
+ @registry[:evaluation_duration] = @prometheus.histogram(
74
+ :"#{@metric_prefix}_evaluation_duration_milliseconds",
75
+ docstring: 'Feature evaluation duration in milliseconds',
76
+ labels: %i[feature_id eligible],
77
+ buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
78
+ )
79
+
80
+ @registry[:system_health] = @prometheus.gauge(
81
+ :"#{@metric_prefix}_system_health",
82
+ docstring: 'System health status (1 = healthy, 0 = unhealthy)',
83
+ labels: [:component]
84
+ )
85
+ end
86
+
87
+ def get_or_create_counter(name, tags)
88
+ key = "#{name}_counter"
89
+ @registry[key] ||= @prometheus.counter(
90
+ name.to_sym,
91
+ docstring: "Counter metric for #{name}",
92
+ labels: tags.keys
93
+ )
94
+ @registry[key].with(tags)
95
+ end
96
+
97
+ def get_or_create_gauge(name, tags)
98
+ key = "#{name}_gauge"
99
+ @registry[key] ||= @prometheus.gauge(
100
+ name.to_sym,
101
+ docstring: "Gauge metric for #{name}",
102
+ labels: tags.keys
103
+ )
104
+ @registry[key].with(tags)
105
+ end
106
+
107
+ def get_or_create_histogram(name, tags)
108
+ key = "#{name}_histogram"
109
+ @registry[key] ||= @prometheus.histogram(
110
+ name.to_sym,
111
+ docstring: "Histogram metric for #{name}",
112
+ labels: tags.keys,
113
+ buckets: default_timing_buckets
114
+ )
115
+ @registry[key].with(tags)
116
+ end
117
+
118
+ def default_timing_buckets
119
+ [0.1, 0.5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500, 1000]
120
+ end
121
+
122
+ # Use pre-registered metrics for better performance
123
+ def record_feature_evaluation(feature_id, eligible, _context, execution_time_ms = nil)
124
+ return unless enabled?
125
+
126
+ tags = {
127
+ feature_id: feature_id.to_s,
128
+ eligible: eligible.to_s
129
+ }
130
+
131
+ @registry[:feature_evaluations].increment(labels: tags)
132
+
133
+ return unless execution_time_ms
134
+
135
+ @registry[:evaluation_duration].observe(execution_time_ms, labels: {
136
+ feature_id: feature_id.to_s,
137
+ eligible: eligible.to_s
138
+ })
139
+ end
140
+
141
+ def record_feature_execution(feature_id, state, success, execution_time_ms, _context = {})
142
+ return unless enabled?
143
+
144
+ execution_tags = {
145
+ feature_id: feature_id.to_s,
146
+ state: state.to_s,
147
+ success: success.to_s
148
+ }
149
+
150
+ duration_tags = {
151
+ feature_id: feature_id.to_s,
152
+ state: state.to_s
153
+ }
154
+
155
+ @registry[:feature_executions].increment(labels: execution_tags)
156
+ @registry[:execution_duration].observe(execution_time_ms, labels: duration_tags)
157
+ end
158
+
159
+ def record_state_transition(feature_id, from_state, to_state, event_name)
160
+ return unless enabled?
161
+
162
+ tags = {
163
+ feature_id: feature_id.to_s,
164
+ from_state: from_state.to_s,
165
+ to_state: to_state.to_s,
166
+ event: event_name.to_s
167
+ }
168
+
169
+ @registry[:state_transitions].increment(labels: tags)
170
+ end
171
+
172
+ def record_system_health(healthy, component_healths = {})
173
+ return unless enabled?
174
+
175
+ @registry[:system_health].set(healthy ? 1 : 0, labels: { component: 'overall' })
176
+
177
+ component_healths.each do |component, health|
178
+ @registry[:system_health].set(health ? 1 : 0, labels: { component: component.to_s })
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StarGates
4
+ module Telemetry
5
+ class TelemetryAdapter
6
+ def initialize(config = {})
7
+ @config = config
8
+ @enabled = config.fetch(:enabled, true)
9
+ end
10
+
11
+ # Core telemetry methods
12
+ def record_metric(name, value, tags: {}, timestamp: Time.now)
13
+ return unless enabled?
14
+
15
+ send_metric(name, value, tags, timestamp)
16
+ end
17
+
18
+ def record_counter(name, value = 1, tags: {})
19
+ return unless enabled?
20
+
21
+ record_metric("#{name}.count", value, tags: tags)
22
+ end
23
+
24
+ def record_gauge(name, value, tags: {})
25
+ return unless enabled?
26
+
27
+ record_metric("#{name}.gauge", value, tags: tags)
28
+ end
29
+
30
+ def record_histogram(name, value, tags: {})
31
+ return unless enabled?
32
+
33
+ record_metric("#{name}.histogram", value, tags: tags)
34
+ end
35
+
36
+ def record_timing(name, duration_ms, tags: {})
37
+ return unless enabled?
38
+
39
+ record_metric("#{name}.timing", duration_ms, tags: tags)
40
+ end
41
+
42
+ # Feature flag specific metrics
43
+ def record_feature_evaluation(feature_id, eligible, _context, execution_time_ms = nil)
44
+ return unless enabled?
45
+
46
+ tags = {
47
+ feature_id: feature_id,
48
+ eligible: eligible
49
+ }
50
+
51
+ record_counter('star_gates.feature.evaluation', 1, tags: tags)
52
+
53
+ return unless execution_time_ms
54
+
55
+ record_timing('star_gates.feature.evaluation.duration', execution_time_ms, tags: tags)
56
+ end
57
+
58
+ def record_feature_execution(feature_id, state, success, execution_time_ms, _context = {})
59
+ return unless enabled?
60
+
61
+ tags = {
62
+ feature_id: feature_id,
63
+ state: state,
64
+ success: success
65
+ }
66
+
67
+ record_counter('star_gates.feature.execution', 1, tags: tags)
68
+ record_timing('star_gates.feature.execution.duration', execution_time_ms, tags: tags)
69
+ end
70
+
71
+ def record_state_transition(feature_id, from_state, to_state, event_name)
72
+ return unless enabled?
73
+
74
+ tags = {
75
+ feature_id: feature_id,
76
+ from_state: from_state,
77
+ to_state: to_state,
78
+ event: event_name
79
+ }
80
+
81
+ record_counter('star_gates.feature.transition', 1, tags: tags)
82
+ end
83
+
84
+ def record_circuit_breaker_event(feature_id, event_type, state)
85
+ return unless enabled?
86
+
87
+ tags = {
88
+ feature_id: feature_id,
89
+ event_type: event_type,
90
+ circuit_state: state
91
+ }
92
+
93
+ record_counter('star_gates.circuit_breaker.event', 1, tags: tags)
94
+ end
95
+
96
+ # System metrics
97
+ def record_system_health(healthy, component_healths = {})
98
+ return unless enabled?
99
+
100
+ record_gauge('star_gates.system.healthy', healthy ? 1 : 0)
101
+
102
+ component_healths.each do |component, health|
103
+ record_gauge('star_gates.system.component.healthy', health ? 1 : 0,
104
+ tags: { component: component })
105
+ end
106
+ end
107
+
108
+ def record_event_store_metrics(event_count, aggregate_count, projection_lag_ms = nil)
109
+ return unless enabled?
110
+
111
+ record_gauge('star_gates.event_store.events', event_count)
112
+ record_gauge('star_gates.event_store.aggregates', aggregate_count)
113
+
114
+ return unless projection_lag_ms
115
+
116
+ record_timing('star_gates.event_store.projection_lag', projection_lag_ms)
117
+ end
118
+
119
+ # Time a block and record the duration
120
+ def time(metric_name, tags: {}, &block)
121
+ return block.call unless enabled?
122
+
123
+ start_time = Time.now
124
+ result = block.call
125
+ duration_ms = ((Time.now - start_time) * 1000).round(2)
126
+
127
+ record_timing(metric_name, duration_ms, tags: tags)
128
+ result
129
+ end
130
+
131
+ # Health check
132
+ def healthy?
133
+ enabled?
134
+ end
135
+
136
+ def enabled?
137
+ @enabled
138
+ end
139
+
140
+ def disable!
141
+ @enabled = false
142
+ end
143
+
144
+ def enable!
145
+ @enabled = true
146
+ end
147
+
148
+ protected
149
+
150
+ # Subclasses must implement this
151
+ def send_metric(name, value, tags, timestamp)
152
+ raise NotImplementedError, 'Subclasses must implement #send_metric'
153
+ end
154
+
155
+ private
156
+
157
+ attr_reader :config
158
+ end
159
+ end
160
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StarGates
4
- VERSION = "0.0.0"
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/star_gates.rb CHANGED
@@ -1,17 +1,317 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "zeitwerk"
4
- require "breaker_machines"
3
+ require 'zeitwerk'
4
+ require 'active_support'
5
+ require 'active_support/core_ext/numeric/time'
6
+ require 'breaker_machines'
7
+ require_relative 'star_gates/errors'
8
+ require_relative 'star_gates/dsl'
9
+
10
+ # Load all foundation modules manually first
11
+ require_relative 'star_gates/events/base_event'
12
+ require_relative 'star_gates/events/feature_events'
13
+ require_relative 'star_gates/event_store/memory_adapter'
14
+ require_relative 'star_gates/event_store/event_store'
15
+ require_relative 'star_gates/adapters/base_adapter'
16
+ require_relative 'star_gates/adapters/memory_adapter'
17
+ require_relative 'star_gates/hexagonal/feature_aggregate'
18
+ require_relative 'star_gates/hexagonal/feature_repository'
19
+ require_relative 'star_gates/projections/feature_projection'
20
+ require_relative 'star_gates/hexagonal/feature_service'
21
+ require_relative 'star_gates/telemetry/telemetry_adapter'
5
22
 
6
23
  loader = Zeitwerk::Loader.for_gem
24
+ loader.ignore("#{__dir__}/star_gates/errors.rb")
25
+ loader.ignore("#{__dir__}/star_gates/dsl.rb")
26
+ loader.ignore("#{__dir__}/star_gates/events")
27
+ loader.ignore("#{__dir__}/star_gates/event_store")
28
+ loader.ignore("#{__dir__}/star_gates/adapters")
29
+ loader.ignore("#{__dir__}/star_gates/hexagonal")
30
+ loader.ignore("#{__dir__}/star_gates/projections")
31
+ loader.ignore("#{__dir__}/star_gates/telemetry")
7
32
  loader.setup
8
33
 
9
34
  module StarGates
10
- class Error < StandardError; end
11
-
12
- # DSL entry point for defining star gates
13
- def self.define(&block)
14
- # Implementation will be added later
15
- raise NotImplementedError, "StarGates.define is not yet implemented"
35
+ @registry = Registry.new
36
+ @test_mode = false
37
+ @feature_service = nil
38
+
39
+ class << self
40
+ attr_reader :registry
41
+
42
+ def flags
43
+ @flags ||= Flags.new
44
+ end
45
+
46
+ def define(&)
47
+ context = DSL::DefineContext.new(registry)
48
+ context.instance_eval(&) if block_given?
49
+ end
50
+
51
+ def [](name)
52
+ gate = registry[name]
53
+ raise GateNotFoundError, "Gate #{name} not found" unless gate
54
+
55
+ gate
56
+ end
57
+
58
+ def activate(name, context = {})
59
+ self[name].call(context)
60
+ end
61
+
62
+ def status(name)
63
+ self[name].status
64
+ end
65
+
66
+ def gates
67
+ registry.keys
68
+ end
69
+
70
+ def test_mode!
71
+ @test_mode = true
72
+ end
73
+
74
+ def test_mode?
75
+ @test_mode
76
+ end
77
+
78
+ def production_mode!
79
+ @test_mode = false
80
+ end
81
+
82
+ def reset!
83
+ registry.clear
84
+ end
85
+
86
+ # Instrumentation for feature flag events
87
+ def instrument(event, payload = {})
88
+ return unless defined?(ActiveSupport::Notifications)
89
+
90
+ ActiveSupport::Notifications.instrument("star_gates.#{event}", payload)
91
+ end
92
+
93
+ # ============================================================================
94
+ # SYSTEM-LEVEL INTROSPECTION METHODS
95
+ # ============================================================================
96
+
97
+ # Overview of all features and their states
98
+ def feature_overview
99
+ gates = registry.to_h
100
+ overview = {
101
+ total_gates: gates.size,
102
+ gates: {},
103
+ summary: {
104
+ by_status: {},
105
+ total_features: 0,
106
+ features_with_circuits: 0
107
+ }
108
+ }
109
+
110
+ gates.each do |name, gate|
111
+ feature = gate.feature
112
+ circuit = gate.circuit
113
+
114
+ gate_info = {
115
+ name: name,
116
+ feature: feature&.to_h,
117
+ circuit: circuit ? circuit_info(circuit) : nil,
118
+ status: gate.status
119
+ }
120
+
121
+ overview[:gates][name] = gate_info
122
+
123
+ # Update summary statistics
124
+ if feature
125
+ overview[:summary][:total_features] += 1
126
+ status = feature.status
127
+ overview[:summary][:by_status][status] = (overview[:summary][:by_status][status] || 0) + 1
128
+ end
129
+
130
+ overview[:summary][:features_with_circuits] += 1 if circuit
131
+ end
132
+
133
+ overview
134
+ end
135
+
136
+ # System-wide rollout statistics
137
+ def rollout_statistics
138
+ gates = registry.to_h
139
+ stats = {
140
+ total_gates: gates.size,
141
+ features_by_status: Hash.new(0),
142
+ average_rollout: 0,
143
+ rollout_distribution: {},
144
+ generated_at: Time.now
145
+ }
146
+
147
+ rollout_percentages = []
148
+
149
+ gates.each_value do |gate|
150
+ feature = gate.feature
151
+ next unless feature
152
+
153
+ status = feature.status
154
+ stats[:features_by_status][status] += 1
155
+
156
+ next unless feature.status == 'canary'
157
+
158
+ rollout_percentages << feature.rollout_percentage
159
+
160
+ range = case feature.rollout_percentage
161
+ when 0...10 then '0-9%'
162
+ when 10...25 then '10-24%'
163
+ when 25...50 then '25-49%'
164
+ when 50...75 then '50-74%'
165
+ when 75...100 then '75-99%'
166
+ else '100%'
167
+ end
168
+
169
+ stats[:rollout_distribution][range] = (stats[:rollout_distribution][range] || 0) + 1
170
+ end
171
+
172
+ stats[:average_rollout] =
173
+ rollout_percentages.any? ? (rollout_percentages.sum / rollout_percentages.size).round(2) : 0
174
+
175
+ stats
176
+ end
177
+
178
+ # Distribution of features across states
179
+ def state_distribution
180
+ gates = registry.to_h
181
+ distribution = Hash.new(0)
182
+
183
+ gates.each_value do |gate|
184
+ feature = gate.feature
185
+ next unless feature
186
+
187
+ distribution[feature.status] += 1
188
+ end
189
+
190
+ {
191
+ distribution: distribution,
192
+ total: distribution.values.sum,
193
+ percentages: distribution.transform_values do |count|
194
+ total = distribution.values.sum
195
+ total.positive? ? (count.to_f / total * 100).round(2) : 0
196
+ end
197
+ }
198
+ end
199
+
200
+ # Recent state transitions across all features
201
+ def recent_transitions(limit: 10)
202
+ if feature_service_enabled?
203
+ feature_service.recent_transitions(limit: limit)
204
+ else
205
+ # Fallback to legacy implementation
206
+ gates = registry.to_h
207
+ all_transitions = []
208
+
209
+ gates.each_value do |gate|
210
+ feature = gate.feature
211
+ next unless feature
212
+
213
+ feature_transitions = feature.instance_variable_get(:@transition_history)
214
+ next unless feature_transitions
215
+
216
+ all_transitions.concat(feature_transitions.to_a)
217
+ end
218
+
219
+ # Sort by timestamp descending and take the most recent
220
+ recent = all_transitions.sort_by { |t| -t[:timestamp].to_f }.first(limit)
221
+
222
+ {
223
+ transitions: recent.map do |transition|
224
+ {
225
+ timestamp: transition[:timestamp],
226
+ feature_name: transition[:feature_name],
227
+ feature_class: transition[:feature_class],
228
+ from_state: transition[:from_state],
229
+ to_state: transition[:to_state],
230
+ event: transition[:event],
231
+ duration_since: Time.now - transition[:timestamp]
232
+ }
233
+ end,
234
+ total_found: all_transitions.size,
235
+ limit: limit,
236
+ generated_at: Time.now
237
+ }
238
+ end
239
+ end
240
+
241
+ # Configure the foundational architecture
242
+ def configure_foundation(event_store: nil, adapter: nil, telemetry: nil)
243
+ event_store ||= EventStore::EventStore.new
244
+ adapter ||= Adapters::MemoryAdapter.new
245
+
246
+ repository = Hexagonal::FeatureRepository.new(event_store)
247
+ projection = Projections::FeatureProjection.new(adapter)
248
+
249
+ @feature_service = Hexagonal::FeatureService.new(
250
+ repository: repository,
251
+ projection: projection,
252
+ event_store: event_store
253
+ )
254
+
255
+ @telemetry_adapter = telemetry if telemetry
256
+
257
+ @feature_service
258
+ end
259
+
260
+ # Access to the feature service (lazy initialization)
261
+ def feature_service
262
+ @feature_service ||= configure_foundation
263
+ end
264
+
265
+ # Check if feature service is enabled
266
+ def feature_service_enabled?
267
+ !@feature_service.nil?
268
+ end
269
+
270
+ # Enable event sourcing mode
271
+ def enable_event_sourcing!
272
+ configure_foundation unless feature_service_enabled?
273
+ end
274
+
275
+ # Telemetry integration
276
+ def telemetry
277
+ @telemetry_adapter
278
+ end
279
+
280
+ def configure_telemetry(adapter)
281
+ @telemetry_adapter = adapter
282
+ end
283
+
284
+ # Check if jsonrpc-rails is available
285
+ def jsonrpc_rails_available?
286
+ defined?(JSON_RPC) && defined?(ActionController::Base)
287
+ end
288
+
289
+ private
290
+
291
+ def circuit_info(circuit)
292
+ if circuit.respond_to?(:to_h)
293
+ circuit.to_h
294
+ elsif circuit.respond_to?(:stats)
295
+ circuit.stats
296
+ else
297
+ { status: circuit.respond_to?(:status) ? circuit.status : 'unknown' }
298
+ end
299
+ end
16
300
  end
17
301
  end
302
+
303
+ # Percentage shorthand for the feature DSL.
304
+ class Numeric
305
+ def percent
306
+ self
307
+ end
308
+ end
309
+
310
+ # Optional jsonrpc-rails integration
311
+ begin
312
+ require 'rails'
313
+ require 'jsonrpc-rails'
314
+ require_relative 'star_gates/jsonrpc_rails_adapter'
315
+ rescue LoadError
316
+ # jsonrpc-rails or Rails not available, skip integration
317
+ end