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.
- checksums.yaml +4 -4
- data/.dictate.toml +19 -0
- data/.pre-commit-config.yaml +20 -0
- data/CHANGELOG.md +60 -3
- data/LICENSE.txt +23 -16
- data/README.md +182 -16
- data/Rakefile +14 -4
- data/lib/star_gates/adapters/base_adapter.rb +65 -0
- data/lib/star_gates/adapters/memory_adapter.rb +93 -0
- data/lib/star_gates/adapters/redis_adapter.rb +119 -0
- data/lib/star_gates/application_star_gate.rb +398 -0
- data/lib/star_gates/circuit.rb +171 -0
- data/lib/star_gates/configuration.rb +172 -0
- data/lib/star_gates/dsl.rb +71 -2
- data/lib/star_gates/errors.rb +8 -0
- data/lib/star_gates/event_store/event_store.rb +79 -0
- data/lib/star_gates/event_store/memory_adapter.rb +78 -0
- data/lib/star_gates/events/base_event.rb +45 -0
- data/lib/star_gates/events/feature_events.rb +97 -0
- data/lib/star_gates/feature.rb +426 -0
- data/lib/star_gates/flags.rb +153 -0
- data/lib/star_gates/gate.rb +100 -0
- data/lib/star_gates/hexagonal/feature_aggregate.rb +250 -0
- data/lib/star_gates/hexagonal/feature_repository.rb +67 -0
- data/lib/star_gates/hexagonal/feature_service.rb +121 -0
- data/lib/star_gates/jsonrpc_rails_adapter.rb +251 -0
- data/lib/star_gates/projections/feature_projection.rb +224 -0
- data/lib/star_gates/registry.rb +58 -0
- data/lib/star_gates/telemetry/opentelemetry_adapter.rb +252 -0
- data/lib/star_gates/telemetry/prometheus_adapter.rb +183 -0
- data/lib/star_gates/telemetry/telemetry_adapter.rb +160 -0
- data/lib/star_gates/version.rb +1 -1
- data/lib/star_gates.rb +308 -8
- metadata +95 -14
- 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
|
data/lib/star_gates/version.rb
CHANGED
data/lib/star_gates.rb
CHANGED
|
@@ -1,17 +1,317 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|