rails_error_dashboard 0.6.4 → 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.
@@ -210,13 +210,71 @@
210
210
  </thead>
211
211
  <tbody>
212
212
  <% breadcrumbs.each_with_index do |crumb, i| %>
213
- <tr>
213
+ <% cat = crumb["c"].to_s %>
214
+ <% meta = crumb["meta"].is_a?(Hash) ? crumb["meta"] : {} %>
215
+ <% is_llm = cat == "llm" %>
216
+ <% is_tool = cat == "llm_tool" %>
217
+ <% prev_cat = i > 0 ? breadcrumbs[i - 1]["c"].to_s : nil %>
218
+ <% nested = is_tool && (prev_cat == "llm" || prev_cat == "llm_tool") %>
219
+ <tr<%= nested ? ' class="llm-tool-row"'.html_safe : "".html_safe %>>
214
220
  <td class="text-muted"><%= i + 1 %></td>
215
- <td><span class="badge bg-<%= breadcrumb_badge_color(crumb["c"]) %>"><%= crumb["c"] %></span></td>
216
- <td>
221
+ <td><span class="badge bg-<%= breadcrumb_badge_color(cat) %>"><%= cat %></span></td>
222
+ <td<%= ' style="padding-left: 2rem;"'.html_safe if nested %>>
223
+ <% if nested %><i class="bi bi-arrow-return-right text-muted me-1" aria-hidden="true"></i><% end %>
217
224
  <code style="word-break: break-all; font-size: 0.85em;"><%= truncate(crumb["m"].to_s, length: 200) %></code>
218
- <% if crumb["meta"].present? %>
219
- <br><small class="text-muted"><%= crumb["meta"].inspect %></small>
225
+ <% if is_llm %>
226
+ <% provider = meta["provider"].presence %>
227
+ <% model = meta["model"].presence %>
228
+ <% in_tok = meta["input_tokens"].presence %>
229
+ <% out_tok = meta["output_tokens"].presence %>
230
+ <% cost = meta["cost_usd"].presence %>
231
+ <% status = meta["status"].presence %>
232
+ <% err_class = meta["error_class"].presence %>
233
+ <% err_msg = meta["error_message"].presence %>
234
+ <br><small class="text-muted">
235
+ <% if provider || model %>
236
+ <i class="bi bi-cpu" aria-hidden="true"></i>
237
+ <%= [ provider, model ].compact.join(" · ") %>
238
+ <% end %>
239
+ <% if in_tok || out_tok %>
240
+ · <i class="bi bi-input-cursor-text" aria-hidden="true"></i>
241
+ in:<strong><%= in_tok || "?" %></strong>
242
+ / out:<strong><%= out_tok || "?" %></strong>
243
+ <% end %>
244
+ <% if cost %>
245
+ · <i class="bi bi-currency-dollar" aria-hidden="true"></i>
246
+ <strong>$<%= "%.6f" % cost.to_f %></strong>
247
+ <% end %>
248
+ <% if status && status != "success" %>
249
+ · <span class="badge bg-danger"><%= status %></span>
250
+ <% end %>
251
+ </small>
252
+ <% if err_class || err_msg %>
253
+ <br><small class="text-danger">
254
+ <i class="bi bi-exclamation-triangle" aria-hidden="true"></i>
255
+ <%= [ err_class, err_msg ].compact.join(": ") %>
256
+ </small>
257
+ <% end %>
258
+ <% elsif is_tool %>
259
+ <% tool_name = meta["tool_name"].presence %>
260
+ <% tool_args = meta["tool_arguments"].presence %>
261
+ <% tool_result = meta["tool_result"].presence %>
262
+ <% if tool_name || tool_args || tool_result %>
263
+ <br><small class="text-muted">
264
+ <% if tool_name %>
265
+ <i class="bi bi-wrench-adjustable" aria-hidden="true"></i>
266
+ <strong><%= tool_name %></strong>
267
+ <% end %>
268
+ <% if tool_args %>
269
+ · args: <code style="font-size: 0.85em;"><%= truncate(tool_args, length: 120) %></code>
270
+ <% end %>
271
+ <% if tool_result %>
272
+ · result: <code style="font-size: 0.85em;"><%= truncate(tool_result, length: 120) %></code>
273
+ <% end %>
274
+ </small>
275
+ <% end %>
276
+ <% elsif meta.present? %>
277
+ <br><small class="text-muted"><%= meta.inspect %></small>
220
278
  <% end %>
221
279
  </td>
222
280
  <td>
@@ -0,0 +1,97 @@
1
+ <% if RailsErrorDashboard.configuration.enable_llm_observability &&
2
+ RailsErrorDashboard.configuration.enable_breadcrumbs &&
3
+ error.respond_to?(:breadcrumbs) && error.breadcrumbs.present? %>
4
+ <% breadcrumbs = JSON.parse(error.breadcrumbs) rescue [] %>
5
+ <% breadcrumbs = [] unless breadcrumbs.is_a?(Array) %>
6
+ <% llm_summary = RailsErrorDashboard::Services::LlmSummary.call(breadcrumbs) %>
7
+ <% if llm_summary %>
8
+ <div class="card mb-4" id="section-llm-summary">
9
+ <div class="card-header">
10
+ <h5 class="mb-0">
11
+ <i class="bi bi-robot text-info" aria-hidden="true"></i> LLM Calls
12
+ <span class="badge bg-info text-dark"><%= llm_summary[:total_calls] %></span>
13
+ </h5>
14
+ <small class="text-muted">Aggregated from request breadcrumbs</small>
15
+ </div>
16
+ <div class="card-body">
17
+ <div class="row text-center mb-3">
18
+ <div class="col-4">
19
+ <div class="fs-4 fw-bold"><%= llm_summary[:total_calls] %></div>
20
+ <small class="text-muted">Calls</small>
21
+ </div>
22
+ <div class="col-4">
23
+ <div class="fs-4 fw-bold"><%= number_with_delimiter(llm_summary[:total_tokens]) %></div>
24
+ <small class="text-muted">Tokens</small>
25
+ </div>
26
+ <div class="col-4">
27
+ <div class="fs-4 fw-bold">
28
+ <% if llm_summary[:total_cost_usd] > 0 %>
29
+ $<%= "%.4f" % llm_summary[:total_cost_usd] %>
30
+ <% else %>
31
+ <span class="text-muted">--</span>
32
+ <% end %>
33
+ </div>
34
+ <small class="text-muted">Cost</small>
35
+ </div>
36
+ </div>
37
+
38
+ <div class="mb-3">
39
+ <div class="d-flex justify-content-between mb-1">
40
+ <small class="text-muted">Input</small>
41
+ <small><strong><%= number_with_delimiter(llm_summary[:total_input_tokens]) %></strong></small>
42
+ </div>
43
+ <div class="d-flex justify-content-between mb-1">
44
+ <small class="text-muted">Output</small>
45
+ <small><strong><%= number_with_delimiter(llm_summary[:total_output_tokens]) %></strong></small>
46
+ </div>
47
+ <% if llm_summary[:total_tool_calls] > 0 %>
48
+ <div class="d-flex justify-content-between mb-1">
49
+ <small class="text-muted">Tool calls</small>
50
+ <small><strong><%= llm_summary[:total_tool_calls] %></strong></small>
51
+ </div>
52
+ <% end %>
53
+ <div class="d-flex justify-content-between mb-1">
54
+ <small class="text-muted">Total time</small>
55
+ <small><strong><%= "%.1f" % llm_summary[:total_duration_ms] %>ms</strong></small>
56
+ </div>
57
+ <% if llm_summary[:error_count] > 0 %>
58
+ <div class="d-flex justify-content-between mb-1">
59
+ <small class="text-muted">Errors</small>
60
+ <small><span class="badge bg-danger"><%= llm_summary[:error_count] %></span></small>
61
+ </div>
62
+ <% end %>
63
+ </div>
64
+
65
+ <% if llm_summary[:by_model].any? %>
66
+ <hr class="my-2">
67
+ <small class="text-muted d-block mb-2">By model</small>
68
+ <% llm_summary[:by_model].each do |row| %>
69
+ <div class="d-flex justify-content-between align-items-center mb-1">
70
+ <small style="word-break: break-all;">
71
+ <% if row[:provider].present? %>
72
+ <span class="text-muted"><%= row[:provider] %>·</span>
73
+ <% end %>
74
+ <strong><%= row[:model].presence || "unknown" %></strong>
75
+ </small>
76
+ <small class="text-muted">
77
+ <%= row[:calls] %> · <%= number_with_delimiter(row[:tokens]) %>tok
78
+ <% if row[:cost_usd] > 0 %>
79
+ · $<%= "%.4f" % row[:cost_usd] %>
80
+ <% end %>
81
+ </small>
82
+ </div>
83
+ <% end %>
84
+ <% end %>
85
+
86
+ <% if llm_summary[:error_count] > 0 %>
87
+ <div class="alert alert-danger py-1 px-2 mb-0 mt-2">
88
+ <small>
89
+ <i class="bi bi-exclamation-triangle" aria-hidden="true"></i>
90
+ <%= llm_summary[:error_count] %> failed call<%= llm_summary[:error_count] == 1 ? "" : "s" %> — see breadcrumbs trail for details.
91
+ </small>
92
+ </div>
93
+ <% end %>
94
+ </div>
95
+ </div>
96
+ <% end %>
97
+ <% end %>
@@ -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 %>
@@ -226,6 +233,7 @@
226
233
  </div>
227
234
 
228
235
  <%= render "modals", error: @error %>
236
+ <%= render "ai_help_panel", error: @error if RailsErrorDashboard.configuration.llm_configured? %>
229
237
 
230
238
  <%= red_javascript_tag do %>
231
239
  window.switchTab = function(tabId) {
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