decision_agent 0.1.3 → 0.1.4
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/lib/decision_agent/ab_testing/ab_test.rb +197 -0
- data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
- data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +152 -0
- data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
- data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
- data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +148 -3
- data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
- data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
- data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent.rb +7 -0
- data/lib/generators/decision_agent/install/install_generator.rb +37 -0
- data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
- data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
- data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
- data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
- data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
- data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
- data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
- data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
- data/spec/ab_testing/ab_test_manager_spec.rb +330 -0
- data/spec/ab_testing/ab_test_spec.rb +270 -0
- data/spec/examples.txt +612 -548
- data/spec/issue_verification_spec.rb +95 -21
- data/spec/monitoring/metrics_collector_spec.rb +2 -2
- data/spec/monitoring/monitored_agent_spec.rb +1 -1
- data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +346 -0
- data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
- metadata +26 -2
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
require "monitor"
|
|
2
2
|
require "time"
|
|
3
|
+
require_relative "storage/memory_adapter"
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require_relative "storage/activerecord_adapter"
|
|
7
|
+
rescue LoadError, NameError
|
|
8
|
+
# ActiveRecord adapter not available
|
|
9
|
+
end
|
|
3
10
|
|
|
4
11
|
module DecisionAgent
|
|
5
12
|
module Monitoring
|
|
@@ -7,11 +14,14 @@ module DecisionAgent
|
|
|
7
14
|
class MetricsCollector
|
|
8
15
|
include MonitorMixin
|
|
9
16
|
|
|
10
|
-
attr_reader :metrics, :window_size
|
|
17
|
+
attr_reader :metrics, :window_size, :storage_adapter
|
|
11
18
|
|
|
12
|
-
def initialize(window_size: 3600)
|
|
19
|
+
def initialize(window_size: 3600, storage: :auto)
|
|
13
20
|
super()
|
|
14
21
|
@window_size = window_size # Default: 1 hour window
|
|
22
|
+
@storage_adapter = initialize_storage_adapter(storage, window_size)
|
|
23
|
+
|
|
24
|
+
# Legacy in-memory metrics for backward compatibility with observers
|
|
15
25
|
@metrics = {
|
|
16
26
|
decisions: [],
|
|
17
27
|
evaluations: [],
|
|
@@ -35,8 +45,20 @@ module DecisionAgent
|
|
|
35
45
|
evaluator_names: decision.evaluations.map(&:evaluator_name).uniq
|
|
36
46
|
}
|
|
37
47
|
|
|
48
|
+
# Store in-memory for observers (backward compatibility)
|
|
38
49
|
@metrics[:decisions] << metric
|
|
39
50
|
cleanup_old_metrics!
|
|
51
|
+
|
|
52
|
+
# Persist to storage adapter
|
|
53
|
+
@storage_adapter.record_decision(
|
|
54
|
+
decision.decision,
|
|
55
|
+
context.to_h,
|
|
56
|
+
confidence: decision.confidence,
|
|
57
|
+
evaluations_count: decision.evaluations.size,
|
|
58
|
+
duration_ms: duration_ms,
|
|
59
|
+
status: determine_decision_status(decision)
|
|
60
|
+
)
|
|
61
|
+
|
|
40
62
|
notify_observers(:decision, metric)
|
|
41
63
|
metric
|
|
42
64
|
end
|
|
@@ -52,8 +74,18 @@ module DecisionAgent
|
|
|
52
74
|
evaluator_name: evaluation.evaluator_name
|
|
53
75
|
}
|
|
54
76
|
|
|
77
|
+
# Store in-memory for observers (backward compatibility)
|
|
55
78
|
@metrics[:evaluations] << metric
|
|
56
79
|
cleanup_old_metrics!
|
|
80
|
+
|
|
81
|
+
# Persist to storage adapter
|
|
82
|
+
@storage_adapter.record_evaluation(
|
|
83
|
+
evaluation.evaluator_name,
|
|
84
|
+
score: evaluation.weight,
|
|
85
|
+
success: evaluation.weight.positive?,
|
|
86
|
+
details: { decision: evaluation.decision }
|
|
87
|
+
)
|
|
88
|
+
|
|
57
89
|
notify_observers(:evaluation, metric)
|
|
58
90
|
metric
|
|
59
91
|
end
|
|
@@ -70,8 +102,18 @@ module DecisionAgent
|
|
|
70
102
|
metadata: metadata
|
|
71
103
|
}
|
|
72
104
|
|
|
105
|
+
# Store in-memory for observers (backward compatibility)
|
|
73
106
|
@metrics[:performance] << metric
|
|
74
107
|
cleanup_old_metrics!
|
|
108
|
+
|
|
109
|
+
# Persist to storage adapter
|
|
110
|
+
@storage_adapter.record_performance(
|
|
111
|
+
operation,
|
|
112
|
+
duration_ms: duration_ms,
|
|
113
|
+
status: success ? "success" : "failure",
|
|
114
|
+
metadata: metadata
|
|
115
|
+
)
|
|
116
|
+
|
|
75
117
|
notify_observers(:performance, metric)
|
|
76
118
|
metric
|
|
77
119
|
end
|
|
@@ -87,8 +129,19 @@ module DecisionAgent
|
|
|
87
129
|
context: context
|
|
88
130
|
}
|
|
89
131
|
|
|
132
|
+
# Store in-memory for observers (backward compatibility)
|
|
90
133
|
@metrics[:errors] << metric
|
|
91
134
|
cleanup_old_metrics!
|
|
135
|
+
|
|
136
|
+
# Persist to storage adapter
|
|
137
|
+
@storage_adapter.record_error(
|
|
138
|
+
error.class.name,
|
|
139
|
+
message: error.message,
|
|
140
|
+
stack_trace: error.backtrace,
|
|
141
|
+
severity: determine_error_severity(error),
|
|
142
|
+
context: context
|
|
143
|
+
)
|
|
144
|
+
|
|
92
145
|
notify_observers(:error, metric)
|
|
93
146
|
metric
|
|
94
147
|
end
|
|
@@ -97,6 +150,18 @@ module DecisionAgent
|
|
|
97
150
|
# Get aggregated statistics
|
|
98
151
|
def statistics(time_range: nil)
|
|
99
152
|
synchronize do
|
|
153
|
+
# Use in-memory metrics for MemoryAdapter (to maintain backward compatibility)
|
|
154
|
+
# Only delegate to ActiveRecordAdapter for persistent storage
|
|
155
|
+
use_storage = time_range &&
|
|
156
|
+
@storage_adapter.respond_to?(:statistics) &&
|
|
157
|
+
!@storage_adapter.is_a?(Storage::MemoryAdapter)
|
|
158
|
+
|
|
159
|
+
if use_storage
|
|
160
|
+
stats = @storage_adapter.statistics(time_range: time_range)
|
|
161
|
+
return stats.merge(timestamp: Time.now.utc, storage: @storage_adapter.class.name) if stats
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Use in-memory metrics
|
|
100
165
|
range_start = time_range ? Time.now.utc - time_range : nil
|
|
101
166
|
|
|
102
167
|
decisions = filter_by_time(@metrics[:decisions], range_start)
|
|
@@ -115,7 +180,8 @@ module DecisionAgent
|
|
|
115
180
|
evaluations: compute_evaluation_stats(evaluations),
|
|
116
181
|
performance: compute_performance_stats(performance),
|
|
117
182
|
errors: compute_error_stats(errors),
|
|
118
|
-
timestamp: Time.now.utc
|
|
183
|
+
timestamp: Time.now.utc,
|
|
184
|
+
storage: "memory (fallback)"
|
|
119
185
|
}
|
|
120
186
|
end
|
|
121
187
|
end
|
|
@@ -123,6 +189,17 @@ module DecisionAgent
|
|
|
123
189
|
# Get time-series data for graphing
|
|
124
190
|
def time_series(metric_type:, bucket_size: 60, time_range: 3600)
|
|
125
191
|
synchronize do
|
|
192
|
+
# Use in-memory metrics for MemoryAdapter (to maintain backward compatibility)
|
|
193
|
+
# Only delegate to ActiveRecordAdapter for persistent storage
|
|
194
|
+
use_storage = @storage_adapter.respond_to?(:time_series) &&
|
|
195
|
+
!@storage_adapter.is_a?(Storage::MemoryAdapter)
|
|
196
|
+
|
|
197
|
+
if use_storage
|
|
198
|
+
series = @storage_adapter.time_series(metric_type, bucket_size: bucket_size, time_range: time_range)
|
|
199
|
+
return series if series && series[:timestamps]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Use in-memory metrics
|
|
126
203
|
data = @metrics[metric_type] || []
|
|
127
204
|
range_start = Time.now.utc - time_range
|
|
128
205
|
|
|
@@ -156,22 +233,90 @@ module DecisionAgent
|
|
|
156
233
|
def clear!
|
|
157
234
|
synchronize do
|
|
158
235
|
@metrics.each_value(&:clear)
|
|
236
|
+
# Also clear storage adapter if using MemoryAdapter
|
|
237
|
+
if @storage_adapter.is_a?(Storage::MemoryAdapter)
|
|
238
|
+
# Clear all by using a very large time period (100 years in seconds)
|
|
239
|
+
@storage_adapter.cleanup(older_than: 100 * 365 * 24 * 60 * 60)
|
|
240
|
+
end
|
|
159
241
|
end
|
|
160
242
|
end
|
|
161
243
|
|
|
162
244
|
# Get current metrics count
|
|
163
245
|
def metrics_count
|
|
164
246
|
synchronize do
|
|
247
|
+
# Use in-memory metrics for MemoryAdapter (to maintain backward compatibility)
|
|
248
|
+
# Only delegate to ActiveRecordAdapter for persistent storage
|
|
249
|
+
use_storage = @storage_adapter.respond_to?(:metrics_count) &&
|
|
250
|
+
!@storage_adapter.is_a?(Storage::MemoryAdapter)
|
|
251
|
+
|
|
252
|
+
return @storage_adapter.metrics_count if use_storage
|
|
253
|
+
|
|
254
|
+
# Use in-memory
|
|
165
255
|
@metrics.transform_values(&:size)
|
|
166
256
|
end
|
|
167
257
|
end
|
|
168
258
|
|
|
259
|
+
# Cleanup old metrics from persistent storage
|
|
260
|
+
def cleanup_old_metrics_from_storage(older_than:)
|
|
261
|
+
synchronize do
|
|
262
|
+
return 0 unless @storage_adapter.respond_to?(:cleanup)
|
|
263
|
+
|
|
264
|
+
@storage_adapter.cleanup(older_than: older_than)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
169
268
|
private
|
|
170
269
|
|
|
171
270
|
def freeze_config
|
|
172
271
|
@window_size.freeze
|
|
173
272
|
end
|
|
174
273
|
|
|
274
|
+
def initialize_storage_adapter(storage_option, window_size)
|
|
275
|
+
case storage_option
|
|
276
|
+
when :auto
|
|
277
|
+
# Auto-detect: prefer ActiveRecord if available
|
|
278
|
+
if defined?(DecisionAgent::Monitoring::Storage::ActiveRecordAdapter) &&
|
|
279
|
+
DecisionAgent::Monitoring::Storage::ActiveRecordAdapter.available?
|
|
280
|
+
DecisionAgent::Monitoring::Storage::ActiveRecordAdapter.new
|
|
281
|
+
else
|
|
282
|
+
DecisionAgent::Monitoring::Storage::MemoryAdapter.new(window_size: window_size)
|
|
283
|
+
end
|
|
284
|
+
when :activerecord, :database
|
|
285
|
+
unless defined?(DecisionAgent::Monitoring::Storage::ActiveRecordAdapter)
|
|
286
|
+
raise "ActiveRecord adapter not available. Install models or use :memory storage."
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
DecisionAgent::Monitoring::Storage::ActiveRecordAdapter.new
|
|
290
|
+
when :memory
|
|
291
|
+
DecisionAgent::Monitoring::Storage::MemoryAdapter.new(window_size: window_size)
|
|
292
|
+
when Symbol
|
|
293
|
+
raise ArgumentError, "Unknown storage option: #{storage_option}. Use :auto, :activerecord, or :memory"
|
|
294
|
+
else
|
|
295
|
+
# Custom adapter instance provided
|
|
296
|
+
storage_option
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def determine_decision_status(decision)
|
|
301
|
+
return "success" if decision.confidence >= 0.7
|
|
302
|
+
return "failure" if decision.confidence < 0.3
|
|
303
|
+
|
|
304
|
+
"success" # Default for medium confidence
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def determine_error_severity(error)
|
|
308
|
+
case error
|
|
309
|
+
when ArgumentError, TypeError
|
|
310
|
+
"medium"
|
|
311
|
+
when StandardError
|
|
312
|
+
"low"
|
|
313
|
+
when Exception
|
|
314
|
+
"critical"
|
|
315
|
+
else
|
|
316
|
+
"low"
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
175
320
|
def cleanup_old_metrics!
|
|
176
321
|
cutoff_time = Time.now.utc - @window_size
|
|
177
322
|
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_adapter"
|
|
4
|
+
|
|
5
|
+
module DecisionAgent
|
|
6
|
+
module Monitoring
|
|
7
|
+
module Storage
|
|
8
|
+
# ActiveRecord adapter for persistent database storage
|
|
9
|
+
class ActiveRecordAdapter < BaseAdapter
|
|
10
|
+
def initialize
|
|
11
|
+
super
|
|
12
|
+
validate_models!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def record_decision(decision, context, confidence: nil, evaluations_count: 0, duration_ms: nil, status: nil)
|
|
16
|
+
::DecisionLog.create!(
|
|
17
|
+
decision: decision,
|
|
18
|
+
context: context.to_json,
|
|
19
|
+
confidence: confidence,
|
|
20
|
+
evaluations_count: evaluations_count,
|
|
21
|
+
duration_ms: duration_ms,
|
|
22
|
+
status: status
|
|
23
|
+
)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
warn "Failed to record decision to database: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def record_evaluation(evaluator_name, score: nil, success: nil, duration_ms: nil, details: {})
|
|
29
|
+
::EvaluationMetric.create!(
|
|
30
|
+
evaluator_name: evaluator_name,
|
|
31
|
+
score: score,
|
|
32
|
+
success: success,
|
|
33
|
+
duration_ms: duration_ms,
|
|
34
|
+
details: details.to_json
|
|
35
|
+
)
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
warn "Failed to record evaluation to database: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def record_performance(operation, duration_ms: nil, status: nil, metadata: {})
|
|
41
|
+
::PerformanceMetric.create!(
|
|
42
|
+
operation: operation,
|
|
43
|
+
duration_ms: duration_ms,
|
|
44
|
+
status: status,
|
|
45
|
+
metadata: metadata.to_json
|
|
46
|
+
)
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
warn "Failed to record performance to database: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def record_error(error_type, message: nil, stack_trace: nil, severity: nil, context: {})
|
|
52
|
+
::ErrorMetric.create!(
|
|
53
|
+
error_type: error_type,
|
|
54
|
+
message: message,
|
|
55
|
+
stack_trace: stack_trace&.to_json,
|
|
56
|
+
severity: severity,
|
|
57
|
+
context: context.to_json
|
|
58
|
+
)
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
warn "Failed to record error to database: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def statistics(time_range: 3600)
|
|
64
|
+
decisions = ::DecisionLog.recent(time_range)
|
|
65
|
+
evaluations = ::EvaluationMetric.recent(time_range)
|
|
66
|
+
performance = ::PerformanceMetric.recent(time_range)
|
|
67
|
+
errors = ::ErrorMetric.recent(time_range)
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
decisions: decision_statistics(decisions, time_range),
|
|
71
|
+
evaluations: evaluation_statistics(evaluations),
|
|
72
|
+
performance: performance_statistics(performance, time_range),
|
|
73
|
+
errors: error_statistics(errors)
|
|
74
|
+
}
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
warn "Failed to retrieve statistics from database: #{e.message}"
|
|
77
|
+
default_statistics
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def time_series(metric_type, bucket_size: 60, time_range: 3600)
|
|
81
|
+
case metric_type
|
|
82
|
+
when :decisions
|
|
83
|
+
decisions_time_series(bucket_size, time_range)
|
|
84
|
+
when :evaluations
|
|
85
|
+
evaluations_time_series(bucket_size, time_range)
|
|
86
|
+
when :performance
|
|
87
|
+
performance_time_series(bucket_size, time_range)
|
|
88
|
+
when :errors
|
|
89
|
+
errors_time_series(bucket_size, time_range)
|
|
90
|
+
else
|
|
91
|
+
{ data: [], timestamps: [] }
|
|
92
|
+
end
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
warn "Failed to retrieve time series from database: #{e.message}"
|
|
95
|
+
{ data: [], timestamps: [] }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def metrics_count
|
|
99
|
+
{
|
|
100
|
+
decisions: ::DecisionLog.count,
|
|
101
|
+
evaluations: ::EvaluationMetric.count,
|
|
102
|
+
performance: ::PerformanceMetric.count,
|
|
103
|
+
errors: ::ErrorMetric.count
|
|
104
|
+
}
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
warn "Failed to get metrics count from database: #{e.message}"
|
|
107
|
+
{ decisions: 0, evaluations: 0, performance: 0, errors: 0 }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def cleanup(older_than:)
|
|
111
|
+
cutoff_time = Time.now - older_than
|
|
112
|
+
count = 0
|
|
113
|
+
|
|
114
|
+
count += ::DecisionLog.where("created_at < ?", cutoff_time).delete_all
|
|
115
|
+
count += ::EvaluationMetric.where("created_at < ?", cutoff_time).delete_all
|
|
116
|
+
count += ::PerformanceMetric.where("created_at < ?", cutoff_time).delete_all
|
|
117
|
+
count += ::ErrorMetric.where("created_at < ?", cutoff_time).delete_all
|
|
118
|
+
|
|
119
|
+
count
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
warn "Failed to cleanup old metrics from database: #{e.message}"
|
|
122
|
+
0
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.available?
|
|
126
|
+
defined?(ActiveRecord) &&
|
|
127
|
+
defined?(::DecisionLog) &&
|
|
128
|
+
defined?(::EvaluationMetric) &&
|
|
129
|
+
defined?(::PerformanceMetric) &&
|
|
130
|
+
defined?(::ErrorMetric)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def decision_statistics(decisions, time_range)
|
|
136
|
+
{
|
|
137
|
+
total: decisions.count,
|
|
138
|
+
by_decision: decisions.group(:decision).count,
|
|
139
|
+
average_confidence: decisions.where.not(confidence: nil).average(:confidence).to_f,
|
|
140
|
+
success_rate: ::DecisionLog.success_rate(time_range: time_range)
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def evaluation_statistics(evaluations)
|
|
145
|
+
{
|
|
146
|
+
total: evaluations.count,
|
|
147
|
+
by_evaluator: evaluations.group(:evaluator_name).count,
|
|
148
|
+
average_score: evaluations.where.not(score: nil).average(:score).to_f,
|
|
149
|
+
success_rate_by_evaluator: evaluations.successful.group(:evaluator_name).count
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def performance_statistics(performance, time_range)
|
|
154
|
+
{
|
|
155
|
+
total: performance.count,
|
|
156
|
+
average_duration_ms: performance.average_duration(time_range: time_range),
|
|
157
|
+
p50: performance.p50(time_range: time_range),
|
|
158
|
+
p95: performance.p95(time_range: time_range),
|
|
159
|
+
p99: performance.p99(time_range: time_range),
|
|
160
|
+
success_rate: performance.success_rate(time_range: time_range)
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def error_statistics(errors)
|
|
165
|
+
{
|
|
166
|
+
total: errors.count,
|
|
167
|
+
by_type: errors.group(:error_type).count,
|
|
168
|
+
by_severity: errors.group(:severity).count,
|
|
169
|
+
critical_count: errors.critical.count
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def validate_models!
|
|
174
|
+
required_models = %w[DecisionLog EvaluationMetric PerformanceMetric ErrorMetric]
|
|
175
|
+
missing_models = required_models.reject { |model| Object.const_defined?(model) }
|
|
176
|
+
|
|
177
|
+
return if missing_models.empty?
|
|
178
|
+
|
|
179
|
+
raise "Missing required models: #{missing_models.join(', ')}. " \
|
|
180
|
+
"Run 'rails generate decision_agent:install --monitoring' to create them."
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def decisions_time_series(bucket_size, time_range)
|
|
184
|
+
counts = ::DecisionLog.recent(time_range)
|
|
185
|
+
.group(time_bucket_sql(:created_at, bucket_size))
|
|
186
|
+
.count
|
|
187
|
+
|
|
188
|
+
format_time_series(counts)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def evaluations_time_series(bucket_size, time_range)
|
|
192
|
+
counts = ::EvaluationMetric.recent(time_range)
|
|
193
|
+
.group(time_bucket_sql(:created_at, bucket_size))
|
|
194
|
+
.count
|
|
195
|
+
|
|
196
|
+
format_time_series(counts)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def performance_time_series(bucket_size, time_range)
|
|
200
|
+
durations = ::PerformanceMetric.recent(time_range)
|
|
201
|
+
.where.not(duration_ms: nil)
|
|
202
|
+
.group(time_bucket_sql(:created_at, bucket_size))
|
|
203
|
+
.average(:duration_ms)
|
|
204
|
+
|
|
205
|
+
format_time_series(durations)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def errors_time_series(bucket_size, time_range)
|
|
209
|
+
counts = ::ErrorMetric.recent(time_range)
|
|
210
|
+
.group(time_bucket_sql(:created_at, bucket_size))
|
|
211
|
+
.count
|
|
212
|
+
|
|
213
|
+
format_time_series(counts)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def time_bucket_sql(column, bucket_size)
|
|
217
|
+
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
|
218
|
+
|
|
219
|
+
case adapter
|
|
220
|
+
when /postgres/
|
|
221
|
+
"(EXTRACT(EPOCH FROM #{column})::bigint / #{bucket_size}) * #{bucket_size}"
|
|
222
|
+
when /mysql/
|
|
223
|
+
"(UNIX_TIMESTAMP(#{column}) DIV #{bucket_size}) * #{bucket_size}"
|
|
224
|
+
when /sqlite/
|
|
225
|
+
"(CAST(strftime('%s', #{column}) AS INTEGER) / #{bucket_size}) * #{bucket_size}"
|
|
226
|
+
else
|
|
227
|
+
# Fallback: use group by timestamp truncated to bucket
|
|
228
|
+
column.to_s
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def format_time_series(data)
|
|
233
|
+
timestamps = data.keys.sort
|
|
234
|
+
values = timestamps.map { |ts| data[ts] }
|
|
235
|
+
|
|
236
|
+
{
|
|
237
|
+
timestamps: timestamps.map { |ts| Time.at(ts).iso8601 },
|
|
238
|
+
data: values.map(&:to_f)
|
|
239
|
+
}
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def default_statistics
|
|
243
|
+
{
|
|
244
|
+
decisions: { total: 0, by_decision: {}, average_confidence: 0.0, success_rate: 0.0 },
|
|
245
|
+
evaluations: { total: 0, by_evaluator: {}, average_score: 0.0, success_rate_by_evaluator: {} },
|
|
246
|
+
performance: { total: 0, average_duration_ms: 0.0, p50: 0.0, p95: 0.0, p99: 0.0, success_rate: 0.0 },
|
|
247
|
+
errors: { total: 0, by_type: {}, by_severity: {}, critical_count: 0 }
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module Monitoring
|
|
5
|
+
module Storage
|
|
6
|
+
# Base adapter interface for metrics storage
|
|
7
|
+
# Subclasses must implement all abstract methods
|
|
8
|
+
class BaseAdapter
|
|
9
|
+
# Record a decision
|
|
10
|
+
# @param decision [String] The decision made
|
|
11
|
+
# @param context [Hash] Decision context
|
|
12
|
+
# @param confidence [Float, nil] Confidence score (0-1)
|
|
13
|
+
# @param evaluations_count [Integer] Number of evaluations
|
|
14
|
+
# @param duration_ms [Float, nil] Decision duration in milliseconds
|
|
15
|
+
# @param status [String, nil] Decision status (success, failure, error)
|
|
16
|
+
# @return [void]
|
|
17
|
+
def record_decision(decision, context, confidence: nil, evaluations_count: 0, duration_ms: nil, status: nil)
|
|
18
|
+
raise NotImplementedError, "#{self.class} must implement #record_decision"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Record an evaluation
|
|
22
|
+
# @param evaluator_name [String] Name of the evaluator
|
|
23
|
+
# @param score [Float, nil] Evaluation score
|
|
24
|
+
# @param success [Boolean, nil] Whether evaluation succeeded
|
|
25
|
+
# @param duration_ms [Float, nil] Evaluation duration
|
|
26
|
+
# @param details [Hash] Additional details
|
|
27
|
+
# @return [void]
|
|
28
|
+
def record_evaluation(evaluator_name, score: nil, success: nil, duration_ms: nil, details: {})
|
|
29
|
+
raise NotImplementedError, "#{self.class} must implement #record_evaluation"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Record a performance metric
|
|
33
|
+
# @param operation [String] Operation name
|
|
34
|
+
# @param duration_ms [Float, nil] Duration in milliseconds
|
|
35
|
+
# @param status [String, nil] Status (success, failure, error)
|
|
36
|
+
# @param metadata [Hash] Additional metadata
|
|
37
|
+
# @return [void]
|
|
38
|
+
def record_performance(operation, duration_ms: nil, status: nil, metadata: {})
|
|
39
|
+
raise NotImplementedError, "#{self.class} must implement #record_performance"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Record an error
|
|
43
|
+
# @param error_type [String] Type of error
|
|
44
|
+
# @param message [String, nil] Error message
|
|
45
|
+
# @param stack_trace [Array, nil] Stack trace
|
|
46
|
+
# @param severity [String, nil] Error severity (low, medium, high, critical)
|
|
47
|
+
# @param context [Hash] Error context
|
|
48
|
+
# @return [void]
|
|
49
|
+
def record_error(error_type, message: nil, stack_trace: nil, severity: nil, context: {})
|
|
50
|
+
raise NotImplementedError, "#{self.class} must implement #record_error"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Get statistics for a time range
|
|
54
|
+
# @param time_range [Integer] Time range in seconds
|
|
55
|
+
# @return [Hash] Statistics summary
|
|
56
|
+
def statistics(time_range: 3600)
|
|
57
|
+
raise NotImplementedError, "#{self.class} must implement #statistics"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get time series data
|
|
61
|
+
# @param metric_type [Symbol] Type of metric (:decisions, :evaluations, :performance, :errors)
|
|
62
|
+
# @param bucket_size [Integer] Bucket size in seconds
|
|
63
|
+
# @param time_range [Integer] Time range in seconds
|
|
64
|
+
# @return [Hash] Time series data
|
|
65
|
+
def time_series(metric_type, bucket_size: 60, time_range: 3600)
|
|
66
|
+
raise NotImplementedError, "#{self.class} must implement #time_series"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get count of metrics stored
|
|
70
|
+
# @return [Hash] Count by metric type
|
|
71
|
+
def metrics_count
|
|
72
|
+
raise NotImplementedError, "#{self.class} must implement #metrics_count"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Clean up old metrics
|
|
76
|
+
# @param older_than [Integer] Remove metrics older than this many seconds
|
|
77
|
+
# @return [Integer] Number of metrics removed
|
|
78
|
+
def cleanup(older_than:)
|
|
79
|
+
raise NotImplementedError, "#{self.class} must implement #cleanup"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check if adapter is available (dependencies installed)
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def self.available?
|
|
85
|
+
raise NotImplementedError, "#{self} must implement .available?"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|