rails_error_dashboard 0.6.3 → 0.7.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +101 -6
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +8 -2
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +66 -14
  5. data/app/helpers/rails_error_dashboard/application_helper.rb +42 -10
  6. data/app/views/layouts/rails_error_dashboard.html.erb +307 -0
  7. data/app/views/rails_error_dashboard/errors/_ai_help_panel.html.erb +36 -0
  8. data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +64 -5
  9. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +2 -2
  10. data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +1 -0
  11. data/app/views/rails_error_dashboard/errors/_llm_summary.html.erb +97 -0
  12. data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +1 -0
  13. data/app/views/rails_error_dashboard/errors/_modals.html.erb +1 -1
  14. data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +21 -20
  15. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +33 -19
  16. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +1 -2
  17. data/app/views/rails_error_dashboard/errors/analytics.html.erb +5 -1
  18. data/app/views/rails_error_dashboard/errors/correlation.html.erb +16 -4
  19. data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +7 -3
  20. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +1 -1
  21. data/app/views/rails_error_dashboard/errors/index.html.erb +7 -1
  22. data/app/views/rails_error_dashboard/errors/overview.html.erb +2 -2
  23. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +3 -1
  24. data/app/views/rails_error_dashboard/errors/releases.html.erb +3 -1
  25. data/app/views/rails_error_dashboard/errors/settings.html.erb +0 -1
  26. data/app/views/rails_error_dashboard/errors/show.html.erb +12 -2
  27. data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +5 -1
  28. data/app/views/rails_error_dashboard/errors/user_impact.html.erb +3 -1
  29. data/config/routes.rb +1 -0
  30. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +27 -0
  31. data/lib/rails_error_dashboard/configuration.rb +101 -1
  32. data/lib/rails_error_dashboard/engine.rb +14 -0
  33. data/lib/rails_error_dashboard/integrations/llm_middleware.rb +276 -0
  34. data/lib/rails_error_dashboard/integrations/llm_span_processor.rb +181 -0
  35. data/lib/rails_error_dashboard/integrations/o_tel.rb +45 -0
  36. data/lib/rails_error_dashboard/queries/analytics_stats.rb +4 -1
  37. data/lib/rails_error_dashboard/queries/error_correlation.rb +17 -13
  38. data/lib/rails_error_dashboard/queries/errors_list.rb +14 -0
  39. data/lib/rails_error_dashboard/services/cascade_detector.rb +28 -18
  40. data/lib/rails_error_dashboard/services/llm_client.rb +368 -0
  41. data/lib/rails_error_dashboard/services/llm_cost_estimator.rb +85 -0
  42. data/lib/rails_error_dashboard/services/llm_summary.rb +91 -0
  43. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +87 -0
  44. data/lib/rails_error_dashboard/subscribers/llm_call_subscriber.rb +150 -0
  45. data/lib/rails_error_dashboard/value_objects/llm_call_event.rb +92 -0
  46. data/lib/rails_error_dashboard/version.rb +1 -1
  47. data/lib/rails_error_dashboard.rb +8 -0
  48. metadata +13 -3
@@ -35,37 +35,49 @@
35
35
  <div class="mb-3">
36
36
  <small class="metadata-label d-block mb-1">Severity Level</small>
37
37
  <% severity = error.severity %>
38
- <% if severity == :critical %>
39
- <span class="badge bg-danger fs-6">CRITICAL</span>
40
- <% elsif severity == :high %>
41
- <span class="badge bg-warning text-dark fs-6">HIGH</span>
42
- <% elsif severity == :medium %>
43
- <span class="badge bg-info text-dark fs-6">MEDIUM</span>
44
- <% else %>
45
- <span class="badge bg-secondary fs-6">LOW</span>
38
+ <%= link_to errors_path(severity: severity, unresolved: '0'), class: "text-decoration-none", title: "View all #{severity} errors" do %>
39
+ <% if severity == :critical %>
40
+ <span class="badge bg-danger fs-6">CRITICAL</span>
41
+ <% elsif severity == :high %>
42
+ <span class="badge bg-warning text-dark fs-6">HIGH</span>
43
+ <% elsif severity == :medium %>
44
+ <span class="badge bg-info text-dark fs-6">MEDIUM</span>
45
+ <% else %>
46
+ <span class="badge bg-secondary fs-6">LOW</span>
47
+ <% end %>
46
48
  <% end %>
47
49
  </div>
48
50
 
49
51
  <div class="mb-3">
50
52
  <small class="metadata-label d-block mb-1">Platform</small>
51
- <% if error.platform == 'iOS' %>
52
- <span class="badge badge-ios"><i class="bi bi-apple"></i> iOS</span>
53
- <% elsif error.platform == 'Android' %>
54
- <span class="badge badge-android"><i class="bi bi-android2"></i> Android</span>
55
- <% elsif error.platform == 'Web' %>
56
- <span class="badge badge-web"><i class="bi bi-globe"></i> Web</span>
53
+ <% if error.platform.present? %>
54
+ <%= link_to errors_path(platform: error.platform, unresolved: '0'), class: "text-decoration-none", title: "View all #{error.platform} errors" do %>
55
+ <% if error.platform == 'iOS' %>
56
+ <span class="badge badge-ios"><i class="bi bi-apple"></i> iOS</span>
57
+ <% elsif error.platform == 'Android' %>
58
+ <span class="badge badge-android"><i class="bi bi-android2"></i> Android</span>
59
+ <% elsif error.platform == 'Web' %>
60
+ <span class="badge badge-web"><i class="bi bi-globe"></i> Web</span>
61
+ <% else %>
62
+ <span class="badge badge-api"><i class="bi bi-server"></i> <%= error.platform %></span>
63
+ <% end %>
64
+ <% end %>
57
65
  <% else %>
58
- <span class="badge badge-api"><i class="bi bi-server"></i> <%= error.platform || 'API' %></span>
66
+ <span class="badge badge-api"><i class="bi bi-server"></i> API</span>
59
67
  <% end %>
60
68
  </div>
61
69
 
62
70
  <div class="mb-3">
63
71
  <small class="metadata-label d-block mb-1">User</small>
64
72
  <% if error.respond_to?(:user) && error.user %>
65
- <strong><%= error.user.email %></strong><br>
66
- <small class="text-muted">ID: <%= error.user_id %></small>
73
+ <%= link_to errors_path(user_id: error.user_id, unresolved: '0'), class: "text-decoration-none", title: "View all errors for this user" do %>
74
+ <strong><%= error.user.email %></strong><br>
75
+ <small class="text-muted">ID: <%= error.user_id %></small>
76
+ <% end %>
67
77
  <% elsif error.user_id %>
68
- <span class="text-muted">User ID: <%= error.user_id %></span>
78
+ <%= link_to errors_path(user_id: error.user_id, unresolved: '0'), class: "text-decoration-none text-muted", title: "View all errors for this user" do %>
79
+ User ID: <%= error.user_id %>
80
+ <% end %>
69
81
  <% else %>
70
82
  <span class="text-muted">Guest / Unauthenticated</span>
71
83
  <% end %>
@@ -249,7 +261,9 @@
249
261
  <% if error.app_version.present? %>
250
262
  <div class="mb-1">
251
263
  <small class="text-muted">App Version:</small>
252
- <code class="ms-1"><%= error.app_version %></code>
264
+ <%= link_to errors_path(app_version: error.app_version, unresolved: '0'), class: "ms-1 text-decoration-none", title: "View all errors in #{error.app_version}" do %>
265
+ <code><%= error.app_version %></code>
266
+ <% end %>
253
267
  </div>
254
268
  <% end %>
255
269
 
@@ -78,8 +78,7 @@
78
78
  <% if error.resolution_comment.present? %>
79
79
  data-bs-toggle="tooltip"
80
80
  data-bs-placement="top"
81
- data-bs-html="true"
82
- title="<strong>Resolution Notes:</strong><br><%= error.resolution_comment.truncate(150).gsub('"', '&quot;') %>"
81
+ title="Resolution Notes: <%= error.resolution_comment.truncate(150) %>"
83
82
  <% end %>>
84
83
  <i class="bi bi-check-circle"></i> Resolved
85
84
  </span>
@@ -339,7 +339,11 @@
339
339
  <tbody>
340
340
  <% @recurring_data[:high_frequency_errors].first(10).each do |error| %>
341
341
  <tr>
342
- <td><code class="small"><%= error[:error_type] %></code></td>
342
+ <td>
343
+ <%= link_to errors_path(error_type: error[:error_type], unresolved: '0'), class: "text-decoration-none" do %>
344
+ <code class="small"><%= error[:error_type] %></code>
345
+ <% end %>
346
+ </td>
343
347
  <td><span class="badge bg-danger"><%= error[:total_occurrences] %></span></td>
344
348
  <td><%= error[:duration_days] %> days</td>
345
349
  <td><small class="text-muted"><%= local_time(error[:first_seen], format: :date_only) %></small></td>
@@ -90,7 +90,11 @@
90
90
  <tbody>
91
91
  <% @problematic_releases.each do |release| %>
92
92
  <tr>
93
- <td><code><%= release[:version] %></code></td>
93
+ <td>
94
+ <%= link_to errors_path(app_version: release[:version], unresolved: '0'), class: "text-decoration-none" do %>
95
+ <code><%= release[:version] %></code>
96
+ <% end %>
97
+ </td>
94
98
  <td><strong class="text-danger"><%= release[:error_count] %></strong></td>
95
99
  <td>
96
100
  <span class="badge bg-danger">
@@ -105,7 +109,7 @@
105
109
  <% end %>
106
110
  </td>
107
111
  <td>
108
- <%= link_to "View", errors_path(search: release[:version], unresolved: '0'), class: "btn btn-sm btn-outline-primary" %>
112
+ <%= link_to "View", errors_path(app_version: release[:version], unresolved: '0'), class: "btn btn-sm btn-outline-primary" %>
109
113
  </td>
110
114
  </tr>
111
115
  <% end %>
@@ -141,7 +145,11 @@
141
145
  <tbody>
142
146
  <% @errors_by_version.sort_by { |_, v| -v[:count] }.first(10).each do |version, data| %>
143
147
  <tr>
144
- <td><code><%= version %></code></td>
148
+ <td>
149
+ <%= link_to errors_path(app_version: version, unresolved: '0'), class: "text-decoration-none" do %>
150
+ <code><%= version %></code>
151
+ <% end %>
152
+ </td>
145
153
  <td><%= data[:count] %></td>
146
154
  <td><%= data[:error_types] %></td>
147
155
  <td>
@@ -185,7 +193,11 @@
185
193
  <tbody>
186
194
  <% @errors_by_git_sha.sort_by { |_, v| -v[:count] }.first(10).each do |sha, data| %>
187
195
  <tr>
188
- <td><code class="small"><%= sha[0..7] %></code></td>
196
+ <td>
197
+ <%= link_to errors_path(git_sha: sha, unresolved: '0'), class: "text-decoration-none" do %>
198
+ <code class="small"><%= sha[0..7] %></code>
199
+ <% end %>
200
+ </td>
189
201
  <td><%= data[:count] %></td>
190
202
  <td><%= data[:error_types] %></td>
191
203
  <td>
@@ -150,7 +150,7 @@
150
150
  <%= number_with_delimiter(table[:dead_tuples]) %>
151
151
  <% end %>
152
152
  </td>
153
- <td><%= table[:last_autovacuum] ? local_time_ago(Time.parse(table[:last_autovacuum])) : "Never" %></td>
153
+ <td><%= table[:last_autovacuum] ? local_time_ago(parse_pg_timestamp(table[:last_autovacuum])) : "Never" %></td>
154
154
  </tr>
155
155
  <% end %>
156
156
  </tbody>
@@ -203,7 +203,7 @@
203
203
  <%= number_with_delimiter(table[:dead_tuples]) %>
204
204
  <% end %>
205
205
  </td>
206
- <td><%= table[:last_autovacuum] ? local_time_ago(Time.parse(table[:last_autovacuum])) : "Never" %></td>
206
+ <td><%= table[:last_autovacuum] ? local_time_ago(parse_pg_timestamp(table[:last_autovacuum])) : "Never" %></td>
207
207
  </tr>
208
208
  <% end %>
209
209
  </tbody>
@@ -338,7 +338,11 @@
338
338
  <% @entries.each do |entry| %>
339
339
  <tr>
340
340
  <td><%= link_to "##{entry[:error_id]}", error_path(entry[:error_id]), class: "text-decoration-none" %></td>
341
- <td><code><%= truncate(entry[:error_type].to_s, length: 40) %></code></td>
341
+ <td>
342
+ <%= link_to errors_path(error_type: entry[:error_type], unresolved: '0'), class: "text-decoration-none" do %>
343
+ <code><%= truncate(entry[:error_type].to_s, length: 40) %></code>
344
+ <% end %>
345
+ </td>
342
346
  <td>
343
347
  <% util = entry[:utilization] %>
344
348
  <% util_badge = util >= 80 ? "danger" : (util >= 60 ? "warning" : "success") %>
@@ -139,7 +139,7 @@
139
139
  <small class="text-muted d-block">GC</small>
140
140
  <small>
141
141
  <% gc_health = health["gc"] || {} %>
142
- Live: <%= gc_health["heap_live_slots"]&.to_s(:delimited) rescue gc_health["heap_live_slots"] || "?" %> /
142
+ Live: <%= gc_health["heap_live_slots"] ? number_with_delimiter(gc_health["heap_live_slots"]) : "?" %> /
143
143
  Major GC: <%= gc_health["major_gc_count"] || "?" %>
144
144
  </small>
145
145
  </div>
@@ -172,7 +172,13 @@
172
172
  active_filters << { label: "Timeframe: #{params[:timeframe].humanize}", param: :timeframe } if params[:timeframe].present?
173
173
  active_filters << { label: "Frequency: #{params[:frequency].humanize}", param: :frequency } if params[:frequency].present?
174
174
  active_filters << { label: "Status: #{params[:status].humanize}", param: :status } if params[:status].present?
175
- active_filters << { label: "Priority: P#{params[:priority_level]}", param: :priority_level } if params[:priority_level].present?
175
+ if params[:priority_level].present?
176
+ # priority_level is the integer key (3=Critical/P0, 0=Low/P3); look up the short_label
177
+ # rather than interpolating the raw integer, which inverts the P-number.
178
+ priority_data = RailsErrorDashboard::ErrorLog::PRIORITY_LEVELS[params[:priority_level].to_i]
179
+ priority_short = priority_data ? priority_data[:short_label] : "P?"
180
+ active_filters << { label: "Priority: #{priority_short}", param: :priority_level }
181
+ end
176
182
  if params[:assigned_to] == '__unassigned__'
177
183
  active_filters << { label: "Unassigned", param: :assigned_to }
178
184
  elsif params[:assigned_to] == '__assigned__'
@@ -92,7 +92,7 @@
92
92
  <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-bottom: var(--space-6);">
93
93
  <!-- Affected Users -->
94
94
  <div class="card stat-card" style="padding: var(--space-5) var(--space-6);">
95
- <div class="stat-label" style="margin-bottom: 4px;">Affected Users</div>
95
+ <div class="stat-label" style="margin-bottom: 4px;">Affected Users (Today)</div>
96
96
  <div style="display: flex; align-items: baseline; gap: 8px;">
97
97
  <span class="stat-value"><%= @stats[:affected_users_today] %></span>
98
98
  <% if @stats[:affected_users_change] != 0 %>
@@ -149,7 +149,7 @@
149
149
  <div style="font-size: 12px; color: var(--text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"><%= error[:message]&.truncate(80) %></div>
150
150
  </div>
151
151
  <div style="text-align: right; flex-shrink: 0;">
152
- <div style="font-size: 14px; font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums;"><%= error[:occurrence_count]&.to_s(:delimited) rescue error[:occurrence_count] %></div>
152
+ <div style="font-size: 14px; font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums;"><%= number_with_delimiter(error[:occurrence_count]) %></div>
153
153
  <div style="font-size: 11px; color: var(--text-tertiary);"><%= error[:affected_users] %> users</div>
154
154
  </div>
155
155
  <% end %>
@@ -227,7 +227,9 @@
227
227
  <% @cross_platform_errors.first(10).each do |error| %>
228
228
  <tr>
229
229
  <td>
230
- <code class="small"><%= error[:error_type] %></code>
230
+ <%= link_to errors_path(error_type: error[:error_type], unresolved: '0'), class: "text-decoration-none" do %>
231
+ <code class="small"><%= error[:error_type] %></code>
232
+ <% end %>
231
233
  </td>
232
234
  <td>
233
235
  <% error[:platforms].each do |platform| %>
@@ -185,7 +185,9 @@
185
185
  <% @releases.each do |release| %>
186
186
  <tr class="<%= 'table-active' if release[:current] %>">
187
187
  <td>
188
- <strong><%= release[:version] %></strong>
188
+ <%= link_to errors_path(app_version: release[:version], unresolved: '0'), class: "text-decoration-none" do %>
189
+ <strong><%= release[:version] %></strong>
190
+ <% end %>
189
191
  <% if release[:current] %>
190
192
  <span class="badge bg-primary ms-1">Current</span>
191
193
  <% end %>
@@ -213,7 +213,6 @@
213
213
  <div class="card" style="margin-bottom: var(--space-4);">
214
214
  <div style="padding: var(--space-4) var(--space-6); border-bottom: 1px solid var(--border-primary);">
215
215
  <span style="font-size: 14px; font-weight: 600; color: var(--text-primary);"><i class="bi bi-puzzle" style="margin-right: 6px;"></i> Active Plugins</span>
216
- </h5>
217
216
  </div>
218
217
  <div class="card-body">
219
218
  <% if RailsErrorDashboard::PluginRegistry.any? %>
@@ -80,6 +80,11 @@
80
80
  <button type="button" class="btn" data-red-action="copy-markdown" data-markdown="<%= j @error_markdown %>">
81
81
  <i class="bi bi-clipboard" style="margin-right: 4px;"></i>Copy for LLM
82
82
  </button>
83
+ <% if RailsErrorDashboard.configuration.llm_configured? %>
84
+ <button type="button" class="btn" data-red-action="open-ai-help">
85
+ <i class="bi bi-stars" style="margin-right: 4px;"></i>AI Help
86
+ </button>
87
+ <% end %>
83
88
  <button type="button" class="btn" data-red-action="download-json" title="Export JSON">
84
89
  <i class="bi bi-download"></i>
85
90
  </button>
@@ -165,6 +170,8 @@
165
170
  <div class="error-sidebar" style="display: flex; flex-direction: column; gap: var(--space-4); min-width: 0; overflow-wrap: break-word;">
166
171
  <%= render "sidebar_metadata", error: @error, related_errors: @related_errors %>
167
172
 
173
+ <%= render "llm_summary", error: @error %>
174
+
168
175
  <!-- Baseline Statistics -->
169
176
  <% if RailsErrorDashboard.configuration.enable_baseline_alerts %>
170
177
  <% baselines = @error.baselines %>
@@ -211,8 +218,10 @@
211
218
  <i class="bi bi-person"></i> View User's Errors
212
219
  <% end %>
213
220
  <% end %>
214
- <%= link_to errors_path(app_context.merge(platform: @error.platform, unresolved: '0')), class: "btn btn-sm" do %>
215
- <i class="bi bi-phone"></i> View <%= @error.platform %> Errors
221
+ <% if @error.platform.present? %>
222
+ <%= link_to errors_path(app_context.merge(platform: @error.platform, unresolved: '0')), class: "btn btn-sm" do %>
223
+ <i class="bi bi-phone"></i> View <%= @error.platform %> Errors
224
+ <% end %>
216
225
  <% end %>
217
226
  <%= link_to analytics_errors_path(app_context), class: "btn btn-sm" do %>
218
227
  <i class="bi bi-bar-chart-line"></i> Analytics
@@ -224,6 +233,7 @@
224
233
  </div>
225
234
 
226
235
  <%= render "modals", error: @error %>
236
+ <%= render "ai_help_panel", error: @error if RailsErrorDashboard.configuration.llm_configured? %>
227
237
 
228
238
  <%= red_javascript_tag do %>
229
239
  window.switchTab = function(tabId) {
@@ -93,7 +93,11 @@
93
93
  <tbody>
94
94
  <% @entries.each do |entry| %>
95
95
  <tr>
96
- <td><code style="font-size: 0.85em;"><%= entry[:exception_class] %></code></td>
96
+ <td>
97
+ <%= link_to errors_path(error_type: entry[:exception_class], unresolved: '0'), class: "text-decoration-none", title: "View any unrescued #{entry[:exception_class]} errors that leaked into error_log" do %>
98
+ <code style="font-size: 0.85em;"><%= entry[:exception_class] %></code>
99
+ <% end %>
100
+ </td>
97
101
  <td style="color: var(--text-secondary); font-size: 12px; word-break: break-all;"><%= entry[:raise_location] %></td>
98
102
  <td style="color: var(--text-secondary); font-size: 12px; word-break: break-all;"><%= entry[:rescue_location] || "Unknown" %></td>
99
103
  <td><span class="badge bg-info text-dark"><%= number_with_delimiter(entry[:raise_count]) %></span></td>
@@ -107,7 +107,9 @@
107
107
  <tr>
108
108
  <td><strong><%= (@pagy.from || 1) + index %></strong></td>
109
109
  <td>
110
- <strong><%= entry[:error_type] %></strong>
110
+ <%= link_to errors_path(error_type: entry[:error_type], unresolved: '0'), class: "text-decoration-none" do %>
111
+ <strong><%= entry[:error_type] %></strong>
112
+ <% end %>
111
113
  <br><small class="text-muted"><%= entry[:message] %></small>
112
114
  </td>
113
115
  <td>
data/config/routes.rb CHANGED
@@ -23,6 +23,7 @@ RailsErrorDashboard::Engine.routes.draw do
23
23
  post :update_status
24
24
  post :create_issue
25
25
  post :link_issue
26
+ post :ai_help
26
27
  end
27
28
  collection do
28
29
  get :analytics
@@ -456,6 +456,33 @@ RailsErrorDashboard.configure do |config|
456
456
  # Codeberg: "https://codeberg.org/username/repo"
457
457
  # config.git_repository_url = ENV["GIT_REPOSITORY_URL"]
458
458
 
459
+ # ============================================================================
460
+ # AI HELP (OpenAI / Anthropic)
461
+ # ============================================================================
462
+ #
463
+ # When provider and API key are configured, error detail pages show an
464
+ # "AI Help" drawer where dashboard users can ask questions about the current
465
+ # error. Error details, backtrace, and related dashboard context are sent to
466
+ # the configured provider.
467
+ #
468
+ # OpenAI uses the Responses API by default and supports GPT-5 and GPT-4 family
469
+ # models such as "gpt-5" and "gpt-4.1". Set llm_openai_endpoint to
470
+ # :chat_completions only if you need the older Chat Completions endpoint.
471
+ #
472
+ # config.llm_provider = :openai
473
+ # config.llm_api_key = -> { Rails.application.credentials.dig(:openai, :api_key) }
474
+ # config.llm_model = "gpt-5"
475
+ # config.llm_openai_endpoint = :auto # :auto, :responses, or :chat_completions
476
+ #
477
+ # config.llm_provider = :anthropic
478
+ # config.llm_api_key = -> { Rails.application.credentials.dig(:anthropic, :api_key) }
479
+ # config.llm_model = "claude-sonnet-4-20250514"
480
+ #
481
+ # Shared options:
482
+ # config.llm_timeout_seconds = 30
483
+ # config.llm_max_output_tokens = 900
484
+ # config.llm_system_prompt = "Prefer concise answers with file-level next steps."
485
+
459
486
  # ============================================================================
460
487
  # ISSUE TRACKING (GitHub / GitLab / Codeberg)
461
488
  # ============================================================================
@@ -184,9 +184,23 @@ module RailsErrorDashboard
184
184
  # ActiveStorage event tracking (requires enable_breadcrumbs = true)
185
185
  attr_accessor :enable_activestorage_tracking # Master switch (default: false)
186
186
 
187
+ # LLM observability (requires enable_breadcrumbs = true)
188
+ attr_accessor :enable_llm_observability # Master switch (default: false)
189
+ attr_accessor :llm_observability_content_capture # Capture prompt/completion text (default: false — PII risk)
190
+ attr_accessor :llm_pricing_overrides # Hash of { "model-name" => { input: usd_per_1m, output: usd_per_1m } }
191
+
187
192
  # Dashboard UI appearance
188
193
  attr_accessor :accent_color # :crimson (default), :ruby, :ember, :violet
189
194
 
195
+ # LLM-powered AI help (disabled unless provider and API key are configured)
196
+ attr_accessor :llm_provider # :openai or :anthropic
197
+ attr_accessor :llm_api_key # String or lambda/proc
198
+ attr_accessor :llm_model # e.g. "gpt-5", "gpt-4.1", "claude-sonnet-4-20250514"
199
+ attr_accessor :llm_openai_endpoint # :responses, :chat_completions, or :auto
200
+ attr_accessor :llm_timeout_seconds # HTTP timeout for provider calls
201
+ attr_accessor :llm_max_output_tokens # Response length cap
202
+ attr_accessor :llm_system_prompt # Optional prompt override/addition
203
+
190
204
  # Notification callbacks (managed via helper methods, not set directly)
191
205
  attr_reader :notification_callbacks
192
206
 
@@ -304,7 +318,7 @@ module RailsErrorDashboard
304
318
  # Breadcrumbs defaults - OFF by default (opt-in)
305
319
  @enable_breadcrumbs = false # Master switch
306
320
  @breadcrumb_buffer_size = 40 # Max events per request (Sentry uses 100, we're conservative)
307
- @breadcrumb_categories = nil # nil = all; or [:sql, :controller, :cache, :job, :mailer, :custom, :deprecation]
321
+ @breadcrumb_categories = nil # nil = all; or [:sql, :controller, :cache, :job, :mailer, :custom, :deprecation, :llm, :llm_tool]
308
322
 
309
323
  # N+1 query detection defaults - ON by default (lightweight display-time analysis)
310
324
  @enable_n_plus_one_detection = true # Analyze SQL breadcrumbs for repeated patterns
@@ -352,6 +366,12 @@ module RailsErrorDashboard
352
366
  # ActiveStorage event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
353
367
  @enable_activestorage_tracking = false
354
368
 
369
+ # LLM observability defaults - OFF by default (opt-in, requires breadcrumbs)
370
+ # content_capture stays OFF even when master switch is on (privacy: prompts may contain PII/secrets)
371
+ @enable_llm_observability = false
372
+ @llm_observability_content_capture = false
373
+ @llm_pricing_overrides = {}
374
+
355
375
  # Internal logging defaults - SILENT by default
356
376
  @enable_internal_logging = false # Opt-in for debugging
357
377
  @log_level = :silent # Silent by default, use :debug, :info, :warn, :error, or :silent
@@ -359,6 +379,15 @@ module RailsErrorDashboard
359
379
  # Dashboard UI
360
380
  @accent_color = :crimson # :crimson, :ruby, :ember, :violet
361
381
 
382
+ # LLM-powered AI help defaults - OFF until provider and API key are configured
383
+ @llm_provider = ENV["RED_LLM_PROVIDER"]&.to_sym
384
+ @llm_api_key = ENV["RED_LLM_API_KEY"]
385
+ @llm_model = ENV["RED_LLM_MODEL"]
386
+ @llm_openai_endpoint = (ENV["RED_LLM_OPENAI_ENDPOINT"] || "auto").to_sym
387
+ @llm_timeout_seconds = 30
388
+ @llm_max_output_tokens = 900
389
+ @llm_system_prompt = nil
390
+
362
391
  @notification_callbacks = {
363
392
  error_logged: [],
364
393
  critical_error: [],
@@ -517,6 +546,13 @@ module RailsErrorDashboard
517
546
  @enable_activestorage_tracking = false
518
547
  end
519
548
 
549
+ # Validate llm observability requires breadcrumbs
550
+ if enable_llm_observability && !enable_breadcrumbs
551
+ warnings << "enable_llm_observability requires enable_breadcrumbs = true. " \
552
+ "LLM observability has been auto-disabled."
553
+ @enable_llm_observability = false
554
+ end
555
+
520
556
  # Skip credential/service-dependent validations during Docker builds.
521
557
  # SECRET_KEY_BASE_DUMMY=1 means no credentials or external services available.
522
558
  build_env = ENV["SECRET_KEY_BASE_DUMMY"].present?
@@ -582,6 +618,33 @@ module RailsErrorDashboard
582
618
  end
583
619
  end
584
620
 
621
+ # Validate LLM configuration only when partially or fully configured.
622
+ if llm_provider.present? || effective_llm_api_key.present? || llm_model.present?
623
+ valid_llm_providers = %i[openai anthropic]
624
+ unless effective_llm_provider && valid_llm_providers.include?(effective_llm_provider)
625
+ errors << "llm_provider must be one of #{valid_llm_providers.inspect} (got: #{llm_provider.inspect})"
626
+ end
627
+
628
+ if effective_llm_api_key.blank? && !build_env
629
+ warnings << "llm_provider is configured but no LLM API key is set. " \
630
+ "Set llm_api_key or RED_LLM_API_KEY to enable AI Help."
631
+ end
632
+
633
+ valid_openai_endpoints = %i[auto responses chat_completions]
634
+ if llm_openai_endpoint && !valid_openai_endpoints.include?(llm_openai_endpoint.to_sym)
635
+ errors << "llm_openai_endpoint must be one of #{valid_openai_endpoints.inspect} " \
636
+ "(got: #{llm_openai_endpoint.inspect})"
637
+ end
638
+
639
+ if llm_timeout_seconds && llm_timeout_seconds.to_i < 1
640
+ errors << "llm_timeout_seconds must be at least 1 (got: #{llm_timeout_seconds})"
641
+ end
642
+
643
+ if llm_max_output_tokens && llm_max_output_tokens.to_i < 1
644
+ errors << "llm_max_output_tokens must be at least 1 (got: #{llm_max_output_tokens})"
645
+ end
646
+ end
647
+
585
648
  # Validate total_users_for_impact (must be positive if set)
586
649
  if total_users_for_impact && total_users_for_impact < 1
587
650
  errors << "total_users_for_impact must be at least 1 (got: #{total_users_for_impact})"
@@ -686,6 +749,43 @@ module RailsErrorDashboard
686
749
  end
687
750
  end
688
751
 
752
+ # Whether the dashboard can show AI Help controls.
753
+ #
754
+ # @return [Boolean]
755
+ def llm_configured?
756
+ effective_llm_provider.present? && effective_llm_api_key.present?
757
+ end
758
+
759
+ # Resolve the configured LLM provider.
760
+ #
761
+ # @return [Symbol, nil] :openai, :anthropic, or nil
762
+ def effective_llm_provider
763
+ provider = llm_provider.presence
764
+ provider&.to_sym
765
+ end
766
+
767
+ # Resolve the configured LLM API key (supports string or lambda).
768
+ #
769
+ # @return [String, nil]
770
+ def effective_llm_api_key
771
+ return nil if llm_api_key.nil?
772
+ llm_api_key.respond_to?(:call) ? llm_api_key.call : llm_api_key
773
+ rescue => e
774
+ nil
775
+ end
776
+
777
+ # Resolve the configured model with provider-specific defaults.
778
+ #
779
+ # @return [String, nil]
780
+ def effective_llm_model
781
+ return llm_model if llm_model.present?
782
+
783
+ case effective_llm_provider
784
+ when :openai then "gpt-5"
785
+ when :anthropic then "claude-sonnet-4-20250514"
786
+ end
787
+ end
788
+
689
789
  # Detect the engine's mount path from the host app routes.
690
790
  # Falls back to "/red" if detection fails.
691
791
  #
@@ -101,6 +101,20 @@ module RailsErrorDashboard
101
101
  RailsErrorDashboard::Subscribers::ActiveStorageSubscriber.subscribe!
102
102
  end
103
103
 
104
+ # Register OpenTelemetry SpanProcessor for LLM observability — Tier 1 path
105
+ # for hosts already running OTel (ruby_llm, thoughtbot/instrumentation).
106
+ # Internally guards on Integrations::OTel.available? + tracer provider
107
+ # capability, so this is safe to call unconditionally.
108
+ RailsErrorDashboard::Integrations::LlmSpanProcessor.register!
109
+
110
+ # Subscribe to red.llm_call / red.llm_tool_call AS::Notifications — Tier 3
111
+ # path for hosts using direct Net::HTTP / gRPC / local inference servers
112
+ # that aren't covered by OTel or the Faraday middleware.
113
+ if RailsErrorDashboard.configuration.enable_llm_observability &&
114
+ RailsErrorDashboard.configuration.enable_breadcrumbs
115
+ RailsErrorDashboard::Subscribers::LlmCallSubscriber.subscribe!
116
+ end
117
+
104
118
  # Enable TracePoint(:raise) for local variable and/or instance variable capture
105
119
  if RailsErrorDashboard.configuration.enable_local_variables ||
106
120
  RailsErrorDashboard.configuration.enable_instance_variables