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,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StarGates
4
+ module Projections
5
+ class FeatureProjection
6
+ def initialize(adapter)
7
+ @adapter = adapter
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ # Handle events to build read model
12
+ def handle_event(event)
13
+ case event
14
+ when Events::FeatureCreated
15
+ handle_feature_created(event)
16
+ when Events::FeatureStateChanged
17
+ handle_state_changed(event)
18
+ when Events::FeatureConfigurationChanged
19
+ handle_configuration_changed(event)
20
+ when Events::FeatureExecuted
21
+ handle_execution_recorded(event)
22
+ when Events::FeatureEvaluated
23
+ handle_evaluation_recorded(event)
24
+ end
25
+ end
26
+
27
+ # Query methods
28
+ def find_feature(feature_id)
29
+ @adapter.fetch("feature:#{feature_id}")
30
+ end
31
+
32
+ def find_features_by_state(state)
33
+ state_key = "features_by_state:#{state}"
34
+ feature_ids = @adapter.fetch(state_key) || []
35
+ feature_ids.map { |id| find_feature(id) }.compact
36
+ end
37
+
38
+ def all_features
39
+ feature_ids = @adapter.fetch('all_features') || []
40
+ feature_ids.map { |id| find_feature(id) }.compact
41
+ end
42
+
43
+ def feature_stats
44
+ {
45
+ total_features: all_features.size,
46
+ by_state: features_by_state_count,
47
+ recent_transitions: recent_transitions_count,
48
+ total_executions: total_executions_count
49
+ }
50
+ end
51
+
52
+ def recent_transitions(limit: 10)
53
+ transitions = @adapter.fetch('recent_transitions') || []
54
+ transitions.last(limit).reverse
55
+ end
56
+
57
+ def feature_exists?(feature_id)
58
+ @adapter.exists?("feature:#{feature_id}")
59
+ end
60
+
61
+ # Clear projection (for testing)
62
+ def clear!
63
+ return unless @adapter.respond_to?(:clear!)
64
+
65
+ @adapter.clear!
66
+ end
67
+
68
+ private
69
+
70
+ def handle_feature_created(event)
71
+ @mutex.synchronize do
72
+ feature_data = {
73
+ id: event.aggregate_id,
74
+ state: :disabled,
75
+ rollout_percentage: event.data[:rollout_percentage],
76
+ group_matcher: event.data[:group_matcher],
77
+ metadata: event.data[:metadata],
78
+ created_at: event.occurred_at,
79
+ updated_at: event.occurred_at,
80
+ transition_count: 0,
81
+ execution_count: 0,
82
+ evaluation_count: 0
83
+ }
84
+
85
+ # Store feature
86
+ @adapter.store("feature:#{event.aggregate_id}", feature_data)
87
+
88
+ # Update indexes
89
+ update_all_features_index(event.aggregate_id, :add)
90
+ update_state_index(event.aggregate_id, :disabled, :add)
91
+ end
92
+ end
93
+
94
+ def handle_state_changed(event)
95
+ @mutex.synchronize do
96
+ feature_data = @adapter.fetch("feature:#{event.aggregate_id}")
97
+ return unless feature_data
98
+
99
+ old_state = feature_data[:state]
100
+ new_state = event.to_state
101
+
102
+ # Update feature data
103
+ feature_data[:state] = new_state
104
+ feature_data[:updated_at] = event.occurred_at
105
+ feature_data[:transition_count] += 1
106
+
107
+ @adapter.store("feature:#{event.aggregate_id}", feature_data)
108
+
109
+ # Update state indexes
110
+ update_state_index(event.aggregate_id, old_state, :remove)
111
+ update_state_index(event.aggregate_id, new_state, :add)
112
+
113
+ # Update recent transitions
114
+ update_recent_transitions(event)
115
+ end
116
+ end
117
+
118
+ def handle_configuration_changed(event)
119
+ @mutex.synchronize do
120
+ feature_data = @adapter.fetch("feature:#{event.aggregate_id}")
121
+ return unless feature_data
122
+
123
+ feature_data[:updated_at] = event.occurred_at
124
+ @adapter.store("feature:#{event.aggregate_id}", feature_data)
125
+ end
126
+ end
127
+
128
+ def handle_execution_recorded(event)
129
+ @mutex.synchronize do
130
+ feature_data = @adapter.fetch("feature:#{event.aggregate_id}")
131
+ return unless feature_data
132
+
133
+ feature_data[:execution_count] += 1
134
+ feature_data[:updated_at] = event.occurred_at
135
+ @adapter.store("feature:#{event.aggregate_id}", feature_data)
136
+ end
137
+ end
138
+
139
+ def handle_evaluation_recorded(event)
140
+ @mutex.synchronize do
141
+ feature_data = @adapter.fetch("feature:#{event.aggregate_id}")
142
+ return unless feature_data
143
+
144
+ feature_data[:evaluation_count] += 1
145
+ feature_data[:updated_at] = event.occurred_at
146
+ @adapter.store("feature:#{event.aggregate_id}", feature_data)
147
+ end
148
+ end
149
+
150
+ def update_all_features_index(feature_id, operation)
151
+ key = 'all_features'
152
+ feature_ids = @adapter.fetch(key) || []
153
+
154
+ case operation
155
+ when :add
156
+ feature_ids << feature_id unless feature_ids.include?(feature_id)
157
+ when :remove
158
+ feature_ids.delete(feature_id)
159
+ end
160
+
161
+ @adapter.store(key, feature_ids)
162
+ end
163
+
164
+ def update_state_index(feature_id, state, operation)
165
+ key = "features_by_state:#{state}"
166
+ feature_ids = @adapter.fetch(key) || []
167
+
168
+ case operation
169
+ when :add
170
+ feature_ids << feature_id unless feature_ids.include?(feature_id)
171
+ when :remove
172
+ feature_ids.delete(feature_id)
173
+ end
174
+
175
+ @adapter.store(key, feature_ids)
176
+ end
177
+
178
+ def update_recent_transitions(event)
179
+ transitions = @adapter.fetch('recent_transitions') || []
180
+
181
+ transition_data = {
182
+ feature_id: event.aggregate_id,
183
+ from_state: event.from_state,
184
+ to_state: event.to_state,
185
+ event_name: event.event_name,
186
+ context: event.context,
187
+ occurred_at: event.occurred_at
188
+ }
189
+
190
+ transitions << transition_data
191
+
192
+ # Keep only last 100 transitions
193
+ transitions = transitions.last(100) if transitions.size > 100
194
+
195
+ @adapter.store('recent_transitions', transitions)
196
+ end
197
+
198
+ def features_by_state_count
199
+ states = %i[disabled canary enabled]
200
+ counts = {}
201
+
202
+ states.each do |state|
203
+ feature_ids = @adapter.fetch("features_by_state:#{state}") || []
204
+ counts[state] = feature_ids.size
205
+ end
206
+
207
+ counts
208
+ end
209
+
210
+ def recent_transitions_count(days: 7)
211
+ transitions = @adapter.fetch('recent_transitions') || []
212
+ cutoff = Time.now - (days * 24 * 60 * 60)
213
+
214
+ transitions.count do |transition|
215
+ Time.parse(transition[:occurred_at].to_s) > cutoff
216
+ end
217
+ end
218
+
219
+ def total_executions_count
220
+ all_features.sum { |feature| feature[:execution_count] || 0 }
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module StarGates
6
+ class Registry
7
+ def initialize
8
+ # Use Concurrent::Hash for thread-safe operations (already in dependencies)
9
+ @gates = Concurrent::Hash.new
10
+ end
11
+
12
+ def register(name, gate)
13
+ # Concurrent::Hash handles thread-safety automatically
14
+ @gates[name.to_sym] = gate
15
+ end
16
+
17
+ def [](name)
18
+ @gates[name.to_sym]
19
+ end
20
+
21
+ def fetch(name, default = nil)
22
+ @gates.fetch(name.to_sym, default)
23
+ end
24
+
25
+ def include?(name)
26
+ @gates.key?(name.to_sym)
27
+ end
28
+
29
+ def keys
30
+ @gates.keys
31
+ end
32
+
33
+ def values
34
+ @gates.values
35
+ end
36
+
37
+ def each(&)
38
+ @gates.each(&)
39
+ end
40
+
41
+ def clear
42
+ # Concurrent::Hash#clear is already thread-safe
43
+ @gates.clear
44
+ end
45
+
46
+ def size
47
+ @gates.size
48
+ end
49
+
50
+ def empty?
51
+ @gates.empty?
52
+ end
53
+
54
+ def to_h
55
+ @gates.dup
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'telemetry_adapter'
4
+
5
+ module StarGates
6
+ module Telemetry
7
+ class OpentelemetryAdapter < TelemetryAdapter
8
+ def initialize(config = {})
9
+ super
10
+ @tracer = config[:tracer] || default_tracer
11
+ @meter = config[:meter] || default_meter
12
+ @service_name = config[:service_name] || 'star_gates'
13
+ @service_version = config[:service_version] || StarGates::VERSION
14
+ setup_instruments
15
+ end
16
+
17
+ # OpenTelemetry-specific methods
18
+ def trace_feature_evaluation(feature_id, _context, &block)
19
+ return block.call unless enabled?
20
+
21
+ @tracer.in_span('feature.evaluate', attributes: {
22
+ 'feature.id' => feature_id,
23
+ 'service.name' => @service_name,
24
+ 'service.version' => @service_version
25
+ }) do |span|
26
+ start_time = Time.now
27
+ result = block.call
28
+ duration = ((Time.now - start_time) * 1000).round(2)
29
+
30
+ span.set_attribute('feature.eligible', result[:eligible]) if result.is_a?(Hash)
31
+ span.set_attribute('feature.evaluation.duration_ms', duration)
32
+ span.status = OpenTelemetry::Trace::Status.ok
33
+
34
+ result
35
+ end
36
+ end
37
+
38
+ def trace_feature_execution(feature_id, state, &block)
39
+ return block.call unless enabled?
40
+
41
+ @tracer.in_span('feature.execute', attributes: {
42
+ 'feature.id' => feature_id,
43
+ 'feature.state' => state,
44
+ 'service.name' => @service_name,
45
+ 'service.version' => @service_version
46
+ }) do |span|
47
+ start_time = Time.now
48
+ begin
49
+ result = block.call
50
+ duration = ((Time.now - start_time) * 1000).round(2)
51
+
52
+ span.set_attribute('feature.execution.duration_ms', duration)
53
+ span.set_attribute('feature.execution.success', true)
54
+ span.status = OpenTelemetry::Trace::Status.ok
55
+
56
+ result
57
+ rescue StandardError => e
58
+ duration = ((Time.now - start_time) * 1000).round(2)
59
+ span.set_attribute('feature.execution.duration_ms', duration)
60
+ span.set_attribute('feature.execution.success', false)
61
+ span.set_attribute('feature.execution.error', e.message)
62
+ span.status = OpenTelemetry::Trace::Status.error(e.message)
63
+ raise
64
+ end
65
+ end
66
+ end
67
+
68
+ def trace_state_transition(feature_id, from_state, to_state, event_name, &block)
69
+ return block.call unless enabled?
70
+
71
+ @tracer.in_span('feature.state_transition', attributes: {
72
+ 'feature.id' => feature_id,
73
+ 'feature.transition.from' => from_state,
74
+ 'feature.transition.to' => to_state,
75
+ 'feature.transition.event' => event_name,
76
+ 'service.name' => @service_name,
77
+ 'service.version' => @service_version
78
+ }) do |span|
79
+ result = block.call
80
+ span.status = OpenTelemetry::Trace::Status.ok
81
+ result
82
+ end
83
+ end
84
+
85
+ protected
86
+
87
+ def send_metric(name, value, tags, _timestamp)
88
+ # Convert tags to OpenTelemetry attributes
89
+ attributes = tags.transform_keys(&:to_s)
90
+ attributes['service.name'] = @service_name
91
+ attributes['service.version'] = @service_version
92
+
93
+ case name
94
+ when /\.count$/
95
+ instrument_name = name.gsub(/\.count$/, '').gsub('.', '_')
96
+ counter = @instruments["#{instrument_name}_total"] ||=
97
+ @meter.create_counter("star_gates_#{instrument_name}_total",
98
+ unit: '1',
99
+ description: "Total count of #{instrument_name}")
100
+ counter.add(value, attributes: attributes)
101
+
102
+ when /\.gauge$/
103
+ instrument_name = name.gsub(/\.gauge$/, '').gsub('.', '_')
104
+ @instruments[instrument_name] ||=
105
+ @meter.create_observable_gauge("star_gates_#{instrument_name}",
106
+ unit: '1',
107
+ description: "Current value of #{instrument_name}")
108
+ # NOTE: OpenTelemetry gauges are observable, so we store the value
109
+ @gauge_values ||= {}
110
+ @gauge_values["#{instrument_name}_#{attributes.hash}"] = { value: value, attributes: attributes }
111
+
112
+ when /\.histogram$/, /\.timing$/
113
+ instrument_name = name.gsub(/\.(histogram|timing)$/, '').gsub('.', '_')
114
+ histogram = @instruments["#{instrument_name}_duration"] ||=
115
+ @meter.create_histogram("star_gates_#{instrument_name}_duration",
116
+ unit: 'ms',
117
+ description: "Duration histogram for #{instrument_name}")
118
+ histogram.record(value, attributes: attributes)
119
+ end
120
+ end
121
+
122
+ def healthy?
123
+ super && @tracer.respond_to?(:in_span) && @meter.respond_to?(:create_counter)
124
+ end
125
+
126
+ private
127
+
128
+ def default_tracer
129
+ unless defined?(OpenTelemetry)
130
+ raise ArgumentError, "OpenTelemetry gem not available. Install with: gem 'opentelemetry-sdk'"
131
+ end
132
+
133
+ OpenTelemetry.tracer_provider.tracer(@service_name, @service_version)
134
+ end
135
+
136
+ def default_meter
137
+ unless defined?(OpenTelemetry)
138
+ raise ArgumentError, "OpenTelemetry gem not available. Install with: gem 'opentelemetry-sdk'"
139
+ end
140
+
141
+ OpenTelemetry.meter_provider.meter(@service_name, @service_version)
142
+ end
143
+
144
+ def setup_instruments
145
+ @instruments = {}
146
+ @gauge_values = {}
147
+
148
+ # Pre-create common instruments
149
+ @instruments['feature_evaluations_total'] = @meter.create_counter(
150
+ 'star_gates_feature_evaluations_total',
151
+ unit: '1',
152
+ description: 'Total number of feature evaluations'
153
+ )
154
+
155
+ @instruments['feature_executions_total'] = @meter.create_counter(
156
+ 'star_gates_feature_executions_total',
157
+ unit: '1',
158
+ description: 'Total number of feature executions'
159
+ )
160
+
161
+ @instruments['state_transitions_total'] = @meter.create_counter(
162
+ 'star_gates_state_transitions_total',
163
+ unit: '1',
164
+ description: 'Total number of state transitions'
165
+ )
166
+
167
+ @instruments['evaluation_duration'] = @meter.create_histogram(
168
+ 'star_gates_evaluation_duration',
169
+ unit: 'ms',
170
+ description: 'Feature evaluation duration'
171
+ )
172
+
173
+ @instruments['execution_duration'] = @meter.create_histogram(
174
+ 'star_gates_execution_duration',
175
+ unit: 'ms',
176
+ description: 'Feature execution duration'
177
+ )
178
+
179
+ # Setup observable gauge for system health
180
+ @meter.create_observable_gauge(
181
+ 'star_gates_system_health',
182
+ unit: '1',
183
+ description: 'System health status'
184
+ ) do |observer|
185
+ @gauge_values.each do |key, data|
186
+ next unless key.include?('system_health')
187
+
188
+ observer.observe(data[:value], attributes: data[:attributes])
189
+ end
190
+ end
191
+ end
192
+
193
+ # Override specific methods to use pre-created instruments
194
+ def record_feature_evaluation(feature_id, eligible, _context, execution_time_ms = nil)
195
+ return unless enabled?
196
+
197
+ attributes = {
198
+ 'feature.id' => feature_id.to_s,
199
+ 'feature.eligible' => eligible.to_s,
200
+ 'service.name' => @service_name,
201
+ 'service.version' => @service_version
202
+ }
203
+
204
+ @instruments['feature_evaluations_total'].add(1, attributes: attributes)
205
+
206
+ return unless execution_time_ms
207
+
208
+ @instruments['evaluation_duration'].record(execution_time_ms, attributes: {
209
+ 'feature.id' => feature_id.to_s,
210
+ 'feature.eligible' => eligible.to_s,
211
+ 'service.name' => @service_name,
212
+ 'service.version' => @service_version
213
+ })
214
+ end
215
+
216
+ def record_feature_execution(feature_id, state, success, execution_time_ms, _context = {})
217
+ return unless enabled?
218
+
219
+ attributes = {
220
+ 'feature.id' => feature_id.to_s,
221
+ 'feature.state' => state.to_s,
222
+ 'feature.success' => success.to_s,
223
+ 'service.name' => @service_name,
224
+ 'service.version' => @service_version
225
+ }
226
+
227
+ @instruments['feature_executions_total'].add(1, attributes: attributes)
228
+ @instruments['execution_duration'].record(execution_time_ms, attributes: {
229
+ 'feature.id' => feature_id.to_s,
230
+ 'feature.state' => state.to_s,
231
+ 'service.name' => @service_name,
232
+ 'service.version' => @service_version
233
+ })
234
+ end
235
+
236
+ def record_state_transition(feature_id, from_state, to_state, event_name)
237
+ return unless enabled?
238
+
239
+ attributes = {
240
+ 'feature.id' => feature_id.to_s,
241
+ 'feature.transition.from' => from_state.to_s,
242
+ 'feature.transition.to' => to_state.to_s,
243
+ 'feature.transition.event' => event_name.to_s,
244
+ 'service.name' => @service_name,
245
+ 'service.version' => @service_version
246
+ }
247
+
248
+ @instruments['state_transitions_total'].add(1, attributes: attributes)
249
+ end
250
+ end
251
+ end
252
+ end