ruby_llm-agents 0.2.4 → 0.3.1

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +413 -0
  3. data/app/channels/ruby_llm/agents/executions_channel.rb +24 -1
  4. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +81 -0
  5. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +51 -0
  6. data/app/controllers/ruby_llm/agents/agents_controller.rb +228 -59
  7. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +167 -12
  8. data/app/controllers/ruby_llm/agents/executions_controller.rb +189 -31
  9. data/app/controllers/ruby_llm/agents/settings_controller.rb +20 -0
  10. data/app/helpers/ruby_llm/agents/application_helper.rb +307 -7
  11. data/app/models/ruby_llm/agents/execution/analytics.rb +224 -20
  12. data/app/models/ruby_llm/agents/execution/metrics.rb +41 -25
  13. data/app/models/ruby_llm/agents/execution/scopes.rb +234 -14
  14. data/app/models/ruby_llm/agents/execution.rb +259 -16
  15. data/app/services/ruby_llm/agents/agent_registry.rb +49 -12
  16. data/app/views/layouts/rubyllm/agents/application.html.erb +351 -85
  17. data/app/views/rubyllm/agents/agents/_version_comparison.html.erb +186 -0
  18. data/app/views/rubyllm/agents/agents/show.html.erb +233 -10
  19. data/app/views/rubyllm/agents/dashboard/_action_center.html.erb +62 -0
  20. data/app/views/rubyllm/agents/dashboard/_alerts_feed.html.erb +62 -0
  21. data/app/views/rubyllm/agents/dashboard/_breaker_strip.html.erb +47 -0
  22. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +165 -0
  23. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +10 -0
  24. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +71 -0
  25. data/app/views/rubyllm/agents/dashboard/index.html.erb +215 -109
  26. data/app/views/rubyllm/agents/executions/_filters.html.erb +152 -155
  27. data/app/views/rubyllm/agents/executions/_list.html.erb +103 -12
  28. data/app/views/rubyllm/agents/executions/dry_run.html.erb +149 -0
  29. data/app/views/rubyllm/agents/executions/index.html.erb +17 -72
  30. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +16 -2
  31. data/app/views/rubyllm/agents/executions/show.html.erb +693 -14
  32. data/app/views/rubyllm/agents/settings/show.html.erb +369 -0
  33. data/app/views/rubyllm/agents/shared/_filter_dropdown.html.erb +121 -0
  34. data/app/views/rubyllm/agents/shared/_select_dropdown.html.erb +85 -0
  35. data/config/routes.rb +7 -0
  36. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +27 -0
  37. data/lib/generators/ruby_llm_agents/templates/add_caching_migration.rb.tt +23 -0
  38. data/lib/generators/ruby_llm_agents/templates/add_finish_reason_migration.rb.tt +19 -0
  39. data/lib/generators/ruby_llm_agents/templates/add_routing_migration.rb.tt +19 -0
  40. data/lib/generators/ruby_llm_agents/templates/add_streaming_migration.rb.tt +8 -0
  41. data/lib/generators/ruby_llm_agents/templates/add_tracing_migration.rb.tt +34 -0
  42. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +66 -4
  43. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +53 -6
  44. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +143 -8
  45. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +38 -1
  46. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +78 -0
  47. data/lib/ruby_llm/agents/alert_manager.rb +207 -0
  48. data/lib/ruby_llm/agents/attempt_tracker.rb +295 -0
  49. data/lib/ruby_llm/agents/base.rb +597 -112
  50. data/lib/ruby_llm/agents/budget_tracker.rb +360 -0
  51. data/lib/ruby_llm/agents/circuit_breaker.rb +197 -0
  52. data/lib/ruby_llm/agents/configuration.rb +279 -1
  53. data/lib/ruby_llm/agents/engine.rb +58 -6
  54. data/lib/ruby_llm/agents/execution_logger_job.rb +17 -6
  55. data/lib/ruby_llm/agents/inflections.rb +13 -2
  56. data/lib/ruby_llm/agents/instrumentation.rb +538 -87
  57. data/lib/ruby_llm/agents/redactor.rb +130 -0
  58. data/lib/ruby_llm/agents/reliability.rb +185 -0
  59. data/lib/ruby_llm/agents/version.rb +3 -1
  60. data/lib/ruby_llm/agents.rb +52 -0
  61. metadata +41 -2
  62. data/app/controllers/ruby_llm/agents/application_controller.rb +0 -37
@@ -2,98 +2,267 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Agents
5
+ # Controller for viewing agent details and per-agent analytics
6
+ #
7
+ # Provides an overview of all registered agents and detailed views
8
+ # for individual agents including configuration, execution history,
9
+ # and performance metrics.
10
+ #
11
+ # @see AgentRegistry For agent discovery
12
+ # @see Paginatable For pagination implementation
13
+ # @see Filterable For filter parsing and validation
14
+ # @api private
5
15
  class AgentsController < ApplicationController
16
+ include Paginatable
17
+ include Filterable
18
+
19
+ # Lists all registered agents with their details
20
+ #
21
+ # Uses AgentRegistry to discover agents from both file system
22
+ # and execution history, ensuring deleted agents with history
23
+ # are still visible.
24
+ #
25
+ # @return [void]
6
26
  def index
7
27
  @agents = AgentRegistry.all_with_details
28
+ rescue StandardError => e
29
+ Rails.logger.error("[RubyLLM::Agents] Error loading agents: #{e.message}")
30
+ @agents = []
31
+ flash.now[:alert] = "Error loading agents list"
8
32
  end
9
33
 
34
+ # Shows detailed view for a specific agent
35
+ #
36
+ # Loads agent configuration (if class exists), statistics,
37
+ # filtered executions, and chart data for visualization.
38
+ # Works for both active agents and deleted agents with history.
39
+ #
40
+ # @return [void]
10
41
  def show
11
42
  @agent_type = params[:id]
12
43
  @agent_class = AgentRegistry.find(@agent_type)
13
44
  @agent_active = @agent_class.present?
14
45
 
15
- # Get stats for different time periods
46
+ load_agent_stats
47
+ load_filter_options
48
+ load_filtered_executions
49
+ load_chart_data
50
+
51
+ if @agent_class
52
+ load_agent_config
53
+ load_circuit_breaker_status
54
+ end
55
+ rescue StandardError => e
56
+ Rails.logger.error("[RubyLLM::Agents] Error loading agent #{@agent_type}: #{e.message}")
57
+ redirect_to ruby_llm_agents.agents_path, alert: "Error loading agent details"
58
+ end
59
+
60
+ private
61
+
62
+ # Loads all-time and today's statistics for the agent
63
+ #
64
+ # @return [void]
65
+ def load_agent_stats
16
66
  @stats = Execution.stats_for(@agent_type, period: :all_time)
17
67
  @stats_today = Execution.stats_for(@agent_type, period: :today)
18
68
 
19
- # Get available filter options for this agent
20
- @versions = Execution.by_agent(@agent_type).distinct.pluck(:agent_version).compact.sort.reverse
21
- @models = Execution.by_agent(@agent_type).distinct.pluck(:model_id).compact.sort
22
- @temperatures = Execution.by_agent(@agent_type).distinct.pluck(:temperature).compact.sort
69
+ # Additional stats for new schema fields
70
+ agent_scope = Execution.by_agent(@agent_type)
71
+ @cache_hit_rate = agent_scope.cache_hit_rate
72
+ @streaming_rate = agent_scope.streaming_rate
73
+ @avg_ttft = agent_scope.avg_time_to_first_token
74
+ end
23
75
 
24
- # Build filtered scope
25
- base_scope = Execution.by_agent(@agent_type)
76
+ # Loads available filter options from execution history
77
+ #
78
+ # Uses a single optimized query to fetch all filter values
79
+ # (versions, models, temperatures) avoiding N+1 queries.
80
+ #
81
+ # @return [void]
82
+ def load_filter_options
83
+ # Single query to get all filter options (fixes N+1)
84
+ filter_data = Execution.by_agent(@agent_type)
85
+ .where.not(agent_version: nil)
86
+ .or(Execution.by_agent(@agent_type).where.not(model_id: nil))
87
+ .or(Execution.by_agent(@agent_type).where.not(temperature: nil))
88
+ .pluck(:agent_version, :model_id, :temperature)
26
89
 
27
- # Apply status filter
28
- if params[:statuses].present?
29
- statuses = params[:statuses].is_a?(Array) ? params[:statuses] : params[:statuses].split(",")
30
- base_scope = base_scope.where(status: statuses) if statuses.any?(&:present?)
31
- end
90
+ @versions = filter_data.map(&:first).compact.uniq.sort.reverse
91
+ @models = filter_data.map { |d| d[1] }.compact.uniq.sort
92
+ @temperatures = filter_data.map(&:last).compact.uniq.sort
93
+ end
94
+
95
+ # Loads paginated and filtered executions with statistics
96
+ #
97
+ # Sets @executions, @pagination, and @filter_stats for the view.
98
+ #
99
+ # @return [void]
100
+ def load_filtered_executions
101
+ base_scope = build_filtered_scope
102
+ result = paginate(base_scope)
103
+ @executions = result[:records]
104
+ @pagination = result[:pagination]
105
+
106
+ @filter_stats = {
107
+ total_count: result[:pagination][:total_count],
108
+ total_cost: base_scope.sum(:total_cost),
109
+ total_tokens: base_scope.sum(:total_tokens)
110
+ }
111
+ end
112
+
113
+ # Builds a filtered scope for the current agent's executions
114
+ #
115
+ # Applies filters in order: status, version, model, temperature, time.
116
+ # Each filter is optional and only applied if values are provided.
117
+ #
118
+ # @return [ActiveRecord::Relation] Filtered execution scope
119
+ def build_filtered_scope
120
+ scope = Execution.by_agent(@agent_type)
121
+
122
+ # Apply status filter with validation
123
+ statuses = parse_array_param(:statuses)
124
+ scope = apply_status_filter(scope, statuses) if statuses.any?
32
125
 
33
126
  # Apply version filter
34
- if params[:versions].present?
35
- versions = params[:versions].is_a?(Array) ? params[:versions] : params[:versions].split(",")
36
- base_scope = base_scope.where(agent_version: versions) if versions.any?(&:present?)
37
- end
127
+ versions = parse_array_param(:versions)
128
+ scope = scope.where(agent_version: versions) if versions.any?
38
129
 
39
130
  # Apply model filter
40
- if params[:models].present?
41
- models = params[:models].is_a?(Array) ? params[:models] : params[:models].split(",")
42
- base_scope = base_scope.where(model_id: models) if models.any?(&:present?)
43
- end
131
+ models = parse_array_param(:models)
132
+ scope = scope.where(model_id: models) if models.any?
44
133
 
45
134
  # Apply temperature filter
46
- if params[:temperatures].present?
47
- temps = params[:temperatures].is_a?(Array) ? params[:temperatures] : params[:temperatures].split(",")
48
- base_scope = base_scope.where(temperature: temps) if temps.any?(&:present?)
49
- end
135
+ temperatures = parse_array_param(:temperatures)
136
+ scope = scope.where(temperature: temperatures) if temperatures.any?
50
137
 
51
- # Apply time range filter
52
- base_scope = base_scope.where("created_at >= ?", params[:days].to_i.days.ago) if params[:days].present?
138
+ # Apply time range filter with validation
139
+ days = parse_days_param
140
+ scope = apply_time_filter(scope, days)
53
141
 
54
- # Paginate
55
- page = (params[:page] || 1).to_i
56
- per_page = 25
57
- offset = (page - 1) * per_page
142
+ scope
143
+ end
144
+
145
+ # Loads chart data for agent performance visualization
146
+ #
147
+ # Fetches 30-day trend analysis and status/finish_reason distribution for charts.
148
+ #
149
+ # @return [void]
150
+ def load_chart_data
151
+ @trend_data = Execution.trend_analysis(agent_type: @agent_type, days: 30)
152
+ @status_distribution = Execution.by_agent(@agent_type).group(:status).count
153
+ @finish_reason_distribution = Execution.by_agent(@agent_type).finish_reason_distribution
154
+ load_version_comparison
155
+ end
156
+
157
+ # Loads version comparison data if multiple versions exist
158
+ #
159
+ # Includes trend data for sparkline charts.
160
+ #
161
+ # @return [void]
162
+ def load_version_comparison
163
+ return unless @versions.size >= 2
164
+
165
+ # Default to comparing two most recent versions
166
+ v1 = params[:compare_v1] || @versions[0]
167
+ v2 = params[:compare_v2] || @versions[1]
58
168
 
59
- filtered_scope = base_scope.order(created_at: :desc)
60
- total_count = filtered_scope.count
61
- @executions = filtered_scope.limit(per_page).offset(offset)
169
+ comparison_data = Execution.compare_versions(@agent_type, v1, v2, period: :this_month)
62
170
 
63
- @pagination = {
64
- current_page: page,
65
- per_page: per_page,
66
- total_count: total_count,
67
- total_pages: (total_count.to_f / per_page).ceil
171
+ # Fetch trend data for sparklines
172
+ v1_trend = Execution.version_trend_data(@agent_type, v1, days: 14)
173
+ v2_trend = Execution.version_trend_data(@agent_type, v2, days: 14)
174
+
175
+ @version_comparison = {
176
+ v1: v1,
177
+ v2: v2,
178
+ data: comparison_data,
179
+ v1_trend: v1_trend,
180
+ v2_trend: v2_trend
68
181
  }
182
+ rescue StandardError => e
183
+ Rails.logger.debug("[RubyLLM::Agents] Version comparison error: #{e.message}")
184
+ @version_comparison = nil
185
+ end
69
186
 
70
- # Filter stats for summary display
71
- @filter_stats = {
72
- total_count: total_count,
73
- total_cost: base_scope.sum(:total_cost),
74
- total_tokens: base_scope.sum(:total_tokens)
187
+ # Loads the current agent class configuration
188
+ #
189
+ # Extracts DSL-configured values from the agent class for display.
190
+ # Only called if the agent class still exists.
191
+ #
192
+ # @return [void]
193
+ def load_agent_config
194
+ @config = {
195
+ # Basic configuration
196
+ model: @agent_class.model,
197
+ temperature: @agent_class.temperature,
198
+ version: @agent_class.version,
199
+ timeout: @agent_class.timeout,
200
+ cache_enabled: @agent_class.cache_enabled?,
201
+ cache_ttl: @agent_class.cache_ttl,
202
+ params: @agent_class.params,
203
+
204
+ # Reliability configuration
205
+ retries: @agent_class.retries,
206
+ fallback_models: @agent_class.fallback_models,
207
+ total_timeout: @agent_class.total_timeout,
208
+ circuit_breaker: @agent_class.circuit_breaker_config
75
209
  }
210
+ end
76
211
 
77
- # Get trend data for charts (30 days)
78
- @trend_data = Execution.trend_analysis(agent_type: @agent_type, days: 30)
212
+ # Loads circuit breaker status for the agent's models
213
+ #
214
+ # Checks the primary model and any fallback models configured.
215
+ # Only returns data if reliability features are enabled.
216
+ #
217
+ # @return [void]
218
+ def load_circuit_breaker_status
219
+ return unless @agent_class.respond_to?(:reliability_config)
79
220
 
80
- # Get status distribution for pie chart
81
- @status_distribution = Execution.by_agent(@agent_type)
82
- .group(:status)
83
- .count
221
+ config = @agent_class.reliability_config rescue nil
222
+ return unless config
84
223
 
85
- # Agent configuration (if class exists)
86
- if @agent_class
87
- @config = {
88
- model: @agent_class.model,
89
- temperature: @agent_class.temperature,
90
- version: @agent_class.version,
91
- timeout: @agent_class.timeout,
92
- cache_enabled: @agent_class.cache_enabled?,
93
- cache_ttl: @agent_class.cache_ttl,
94
- params: @agent_class.params
224
+ # Collect all models: primary + fallbacks
225
+ models_to_check = [@agent_class.model]
226
+ models_to_check.concat(config[:fallback_models]) if config[:fallback_models].present?
227
+ models_to_check = models_to_check.compact.uniq
228
+
229
+ return if models_to_check.empty?
230
+
231
+ breaker_config = config[:circuit_breaker] || {}
232
+ errors_threshold = breaker_config[:errors] || 10
233
+ window = breaker_config[:within] || 60
234
+ cooldown = breaker_config[:cooldown] || 300
235
+
236
+ @circuit_breaker_status = {}
237
+
238
+ models_to_check.each do |model_id|
239
+ breaker = CircuitBreaker.new(
240
+ @agent_type,
241
+ model_id,
242
+ errors: errors_threshold,
243
+ within: window,
244
+ cooldown: cooldown
245
+ )
246
+
247
+ status = {
248
+ open: breaker.open?,
249
+ threshold: errors_threshold
95
250
  }
251
+
252
+ # Get additional details
253
+ if breaker.open?
254
+ # Calculate remaining cooldown (approximate)
255
+ status[:cooldown_remaining] = cooldown
256
+ else
257
+ # Get current failure count if available
258
+ status[:failure_count] = breaker.failure_count
259
+ end
260
+
261
+ @circuit_breaker_status[model_id] = status
96
262
  end
263
+ rescue StandardError => e
264
+ Rails.logger.debug("[RubyLLM::Agents] Could not load circuit breaker status: #{e.message}")
265
+ @circuit_breaker_status = {}
97
266
  end
98
267
  end
99
268
  end
@@ -2,33 +2,188 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Agents
5
+ # Dashboard controller for the RubyLLM::Agents observability UI
6
+ #
7
+ # Displays high-level statistics, recent executions, and activity charts
8
+ # for monitoring agent performance at a glance.
9
+ #
10
+ # @see ExecutionsController For detailed execution browsing
11
+ # @see AgentsController For per-agent analytics
12
+ # @api private
5
13
  class DashboardController < ApplicationController
14
+ # Renders the main dashboard view
15
+ #
16
+ # Loads now strip data, critical alerts, hourly activity,
17
+ # and recent executions for real-time monitoring.
18
+ #
19
+ # @return [void]
6
20
  def index
7
- @stats = daily_stats
8
- @recent_executions = Execution.recent(10)
21
+ @now_strip = Execution.now_strip_data
22
+ @critical_alerts = load_critical_alerts
9
23
  @hourly_activity = Execution.hourly_activity_chart
24
+ @recent_executions = Execution.recent(10)
25
+ end
26
+
27
+ # Returns chart data as JSON for live updates
28
+ #
29
+ # @return [JSON] Chart data with categories and series
30
+ def chart_data
31
+ render json: Execution.hourly_activity_chart_json
10
32
  end
11
33
 
12
34
  private
13
35
 
36
+ # Fetches cached daily statistics for the dashboard
37
+ #
38
+ # Results are cached for 1 minute to reduce database load while
39
+ # keeping the dashboard reasonably up-to-date.
40
+ #
41
+ # @return [Hash] Daily statistics
42
+ # @option return [Integer] :total_executions Total execution count today
43
+ # @option return [Integer] :successful Successful execution count
44
+ # @option return [Integer] :failed Failed execution count
45
+ # @option return [Float] :total_cost Combined cost of all executions
46
+ # @option return [Integer] :total_tokens Combined token usage
47
+ # @option return [Integer] :avg_duration_ms Average execution duration
48
+ # @option return [Float] :success_rate Percentage of successful executions
14
49
  def daily_stats
15
- scope = Execution.today
16
- {
17
- total_executions: scope.count,
18
- successful: scope.successful.count,
19
- failed: scope.failed.count,
20
- total_cost: scope.total_cost_sum || 0,
21
- total_tokens: scope.total_tokens_sum || 0,
22
- avg_duration_ms: scope.avg_duration&.round || 0,
23
- success_rate: calculate_success_rate(scope)
24
- }
50
+ Rails.cache.fetch("ruby_llm_agents/daily_stats/#{Date.current}", expires_in: 1.minute) do
51
+ scope = Execution.today
52
+ {
53
+ total_executions: scope.count,
54
+ successful: scope.successful.count,
55
+ failed: scope.failed.count,
56
+ total_cost: scope.total_cost_sum || 0,
57
+ total_tokens: scope.total_tokens_sum || 0,
58
+ avg_duration_ms: scope.avg_duration&.round || 0,
59
+ success_rate: calculate_success_rate(scope)
60
+ }
61
+ end
25
62
  end
26
63
 
64
+ # Calculates the success rate percentage for a scope
65
+ #
66
+ # @param scope [ActiveRecord::Relation] The execution scope to calculate from
67
+ # @return [Float] Success rate as a percentage (0.0-100.0)
27
68
  def calculate_success_rate(scope)
28
69
  total = scope.count
29
70
  return 0.0 if total.zero?
30
71
  (scope.successful.count.to_f / total * 100).round(1)
31
72
  end
73
+
74
+ # Loads budget status for display on dashboard
75
+ #
76
+ # @return [Hash] Budget status with global daily and monthly info
77
+ def load_budget_status
78
+ BudgetTracker.status
79
+ end
80
+
81
+ # Loads all open circuit breakers across agents
82
+ #
83
+ # @return [Array<Hash>] Array of open breaker information
84
+ def load_open_breakers
85
+ open_breakers = []
86
+
87
+ # Get all agents from execution history
88
+ agent_types = Execution.distinct.pluck(:agent_type)
89
+
90
+ agent_types.each do |agent_type|
91
+ # Get the agent class if available
92
+ agent_class = AgentRegistry.find(agent_type)
93
+ next unless agent_class
94
+
95
+ # Get circuit breaker config from class methods
96
+ cb_config = agent_class.respond_to?(:circuit_breaker_config) ? agent_class.circuit_breaker_config : nil
97
+ next unless cb_config
98
+
99
+ # Get models to check (primary + fallbacks)
100
+ primary_model = agent_class.respond_to?(:model) ? agent_class.model : RubyLLM::Agents.configuration.default_model
101
+ fallbacks = agent_class.respond_to?(:fallback_models) ? agent_class.fallback_models : []
102
+ models_to_check = [primary_model, *fallbacks].compact.uniq
103
+
104
+ models_to_check.each do |model_id|
105
+ breaker = CircuitBreaker.from_config(agent_type, model_id, cb_config)
106
+ next unless breaker
107
+
108
+ if breaker.open?
109
+ open_breakers << {
110
+ agent_type: agent_type,
111
+ model_id: model_id,
112
+ cooldown_remaining: breaker.time_until_close,
113
+ failure_count: breaker.failure_count,
114
+ threshold: cb_config[:errors] || 5
115
+ }
116
+ end
117
+ end
118
+ end
119
+
120
+ open_breakers
121
+ rescue StandardError => e
122
+ Rails.logger.debug("[RubyLLM::Agents] Error loading open breakers: #{e.message}")
123
+ []
124
+ end
125
+
126
+ # Loads recent alerts from cache
127
+ #
128
+ # @return [Array<Hash>] Array of recent alert events
129
+ def load_recent_alerts
130
+ Rails.cache.fetch("ruby_llm_agents/recent_alerts", expires_in: 1.minute) do
131
+ # Fetch from cache-based alert store (ephemeral for Phase 1)
132
+ alerts_key = "ruby_llm_agents:alerts:recent"
133
+ cached_alerts = RubyLLM::Agents.configuration.cache_store.read(alerts_key) || []
134
+ cached_alerts.take(10)
135
+ end
136
+ end
137
+
138
+ # Loads critical alerts for the Action Center
139
+ #
140
+ # Combines open circuit breakers, budget breaches, and error spikes
141
+ # into a single prioritized list (max 3 items).
142
+ #
143
+ # @return [Array<Hash>] Critical alerts with type and data
144
+ def load_critical_alerts
145
+ alerts = []
146
+
147
+ # Open circuit breakers
148
+ load_open_breakers.each do |breaker|
149
+ alerts << { type: :breaker, data: breaker }
150
+ end
151
+
152
+ # Budget breaches (>100% of limit)
153
+ budget_status = load_budget_status
154
+ daily_budget = budget_status&.dig(:global_daily)
155
+ monthly_budget = budget_status&.dig(:global_monthly)
156
+
157
+ if daily_budget && daily_budget[:percentage_used].to_f >= 100
158
+ alerts << {
159
+ type: :budget_breach,
160
+ data: {
161
+ period: :daily,
162
+ current: daily_budget[:current_spend],
163
+ limit: daily_budget[:limit]
164
+ }
165
+ }
166
+ end
167
+
168
+ if monthly_budget && monthly_budget[:percentage_used].to_f >= 100
169
+ alerts << {
170
+ type: :budget_breach,
171
+ data: {
172
+ period: :monthly,
173
+ current: monthly_budget[:current_spend],
174
+ limit: monthly_budget[:limit]
175
+ }
176
+ }
177
+ end
178
+
179
+ # Error spike detection (>5 errors in last 15 minutes)
180
+ error_count_15m = Execution.status_error.where("created_at > ?", 15.minutes.ago).count
181
+ if error_count_15m >= 5
182
+ alerts << { type: :error_spike, data: { count: error_count_15m } }
183
+ end
184
+
185
+ alerts.take(3)
186
+ end
32
187
  end
33
188
  end
34
189
  end