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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 938ab24cad38b8b20bc0dd0f2a1eb6e5c9e46a4634fb97e2c01b3487221c9fc3
4
- data.tar.gz: 6e053a3f8c41e3c3f75f95ab1f9bd8908e9bcea4c473e2d1d3a0c11b7bdf0bdb
3
+ metadata.gz: bfd6bfaddc3831f37ef4162c92037dddfb18129068e1ad279449b4fcdf164514
4
+ data.tar.gz: 031a14fd395298e46d95bcbb182c8333dab85d12c64439e7d840a577a7ced989
5
5
  SHA512:
6
- metadata.gz: 2b84b4a4f917863e7577e253f2324100a06b6fef6f33d56cef211f28a4f1dc4688932f618489f19924baa8aa065135cfcaeffb2c7431f15bea898d3e334395b7
7
- data.tar.gz: 4149d8da317c77751e04db12b46f02924fe46e1a420075232e3fa7c96f12218166249596b33a55f1790e696b5fca3580cd5da62d38a39585e356660a702a518c
6
+ metadata.gz: dae9aaa0bebaf0daf71bd5b3e64590b19b5e48d5a032273304639f49535bc17d5eaa2991a460d9e97403cebcc4dc1f0e70baca2c5291c4c4191d1a5ae092bbf3
7
+ data.tar.gz: 6bc6d8a6fcf5cb0c262688102ec57cd79d24a8d87f3bed2417ef29d9b63102bc67937784217fbf4a54822b06b1691c333b9210f6bc77c1be49e6055a54559708
data/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  gem 'rails_error_dashboard'
14
14
  ```
15
15
 
16
- **5-minute setup** · **Works out-of-the-box** · **100% Rails + Postgres** · **No vendor lock-in**
16
+ **5-minute setup** · **Works out-of-the-box** · **Postgres, MySQL/Trilogy, SQLite** · **No vendor lock-in**
17
17
 
18
18
  [Full Documentation](https://anjanj.github.io/rails_error_dashboard/) · [Live Demo](https://rails-error-dashboard.anjan.dev) · [RubyGems](https://rubygems.org/gems/rails_error_dashboard)
19
19
 
@@ -35,6 +35,18 @@ gem 'rails_error_dashboard'
35
35
 
36
36
  ![Error Detail](docs/images/error-detail.png)
37
37
 
38
+ **AI Help** — Optional OpenAI or Anthropic assistance streamed directly inside the error detail page.
39
+
40
+ ![AI Help](docs/images/ai-help.png)
41
+
42
+ ---
43
+
44
+ ## From the Community
45
+
46
+ > All three [self-hosted alternatives] had an issue with error backtrace when using Turbo — RED did fix it… solid_errors and Faultline are not very active projects, RED is very active and @AnjanJ is very responsive in fixing issues. So, RED was my final choice.
47
+ >
48
+ > — **Gael Marziou** ([@gmarziou](https://github.com/gmarziou)) · [read the full discussion](https://github.com/AnjanJ/rails_error_dashboard/discussions/116)
49
+
38
50
  ---
39
51
 
40
52
  ## Who This Is For
@@ -63,7 +75,7 @@ gem 'rails_error_dashboard'
63
75
 
64
76
  ### Core (Always Enabled)
65
77
 
66
- Error capture from controllers, jobs, and middleware. Beautiful Bootstrap 5 dashboard with dark/light mode, search, filtering, and real-time updates. Analytics with trend charts, severity breakdown, and spike detection. Workflow management with assignment, priority, snooze, mute/unmute (notification suppression), comments, and batch operations. Security via HTTP Basic Auth or custom lambda (Devise, Warden, session-based). Exception cause chains, enriched HTTP context, custom fingerprinting, CurrentAttributes integration, auto-reopen on recurrence, and sensitive data filtering — all built in.
78
+ Error capture from controllers, jobs, and middleware. Custom-designed dashboard with dark/light mode, search, filtering, and real-time updates. Analytics with trend charts, severity breakdown, and spike detection. Workflow management with assignment, priority, snooze, mute/unmute (notification suppression), comments, and batch operations. Security via HTTP Basic Auth or custom lambda (Devise, Warden, session-based). Exception cause chains, enriched HTTP context, custom fingerprinting, CurrentAttributes integration, auto-reopen on recurrence, and sensitive data filtering — all built in.
67
79
 
68
80
  ### Optional Features
69
81
 
@@ -124,7 +136,7 @@ Requires breadcrumbs to be enabled.
124
136
 
125
137
  ![Job Health](docs/images/job-health.png)
126
138
 
127
- **Database Health** — PgHero-style live PostgreSQL stats (table sizes, unused indexes, dead tuples, vacuum timestamps) plus historical connection pool data from error snapshots.
139
+ **Database Health** — PgHero-style live PostgreSQL stats (table sizes, unused indexes, dead tuples, vacuum timestamps) plus historical connection pool data from error snapshots. PostgreSQL-only for the system-table views; MySQL and SQLite show connection pool stats and hide the rest.
128
140
 
129
141
  ![Database Health](docs/images/database-health.png)
130
142
 
@@ -147,6 +159,79 @@ config.enable_activestorage_tracking = true # requires enable_breadcrumbs = tru
147
159
  [Complete documentation →](docs/FEATURES.md#job-health-page)
148
160
  </details>
149
161
 
162
+ <details>
163
+ <summary><strong>LLM Observability — Calls, Tokens, Cost, Tool Use</strong></summary>
164
+
165
+ Capture every LLM call your app makes — model, latency, token counts, estimated USD cost, and tool-use requests — as breadcrumbs on the error that follows. When a request crashes, you see the chat completion that preceded it: which model was called, how long it took, what it cost, and which tools it asked to invoke.
166
+
167
+ - Three capture paths — pick whichever matches your stack
168
+ - Cost estimated from a built-in pricing table (Claude 4.x, GPT-4o/o1, Gemini 2.5) — override per-model via `config.llm_pricing_overrides`
169
+ - Tool-call requests summarized inline; tool *execution* spans captured separately via the OTel path
170
+ - Content capture (prompts/completions) **OFF by default** — only token counts and metadata are recorded
171
+ - Same host-app safety guarantees as the rest of the gem — never raises, never blocks the request, every callback rescue-wrapped
172
+
173
+ ```ruby
174
+ config.enable_breadcrumbs = true # required — LLM crumbs ride the breadcrumb pipeline
175
+ config.enable_llm_observability = true
176
+ # Optional — override the built-in pricing table for your account
177
+ # config.llm_pricing_overrides = { "claude-sonnet-4-6" => { input: 3.0, output: 15.0 } }
178
+ ```
179
+
180
+ **Path A — `ruby-openai` (Faraday middleware)**
181
+
182
+ ```ruby
183
+ # Gemfile already has: gem "ruby-openai"
184
+ client = OpenAI::Client.new do |f|
185
+ f.use RailsErrorDashboard::Integrations::LlmMiddleware
186
+ end
187
+ ```
188
+
189
+ **Path B — `ruby_llm` (OpenTelemetry)**
190
+
191
+ `ruby_llm` doesn't expose a Faraday hook, but the thoughtbot OTel instrumentation gem emits GenAI-semconv spans that our SpanProcessor picks up automatically.
192
+
193
+ ```ruby
194
+ # Gemfile
195
+ gem "ruby_llm"
196
+ gem "opentelemetry-sdk"
197
+ gem "opentelemetry-instrumentation-ruby_llm"
198
+
199
+ # config/initializers/opentelemetry.rb
200
+ OpenTelemetry::SDK.configure do |c|
201
+ c.use "OpenTelemetry::Instrumentation::RubyLLM"
202
+ end
203
+ ```
204
+
205
+ The dashboard's `LlmSpanProcessor` registers itself with `OpenTelemetry.tracer_provider` during engine boot — no extra wiring.
206
+
207
+ **Path C — anything else (Anthropic official SDK, Net::HTTP, gRPC, Ollama, …)**
208
+
209
+ The official `anthropic` gem uses `Net::HTTP` directly (no Faraday hook), and many local-inference setups don't run OTel. Wrap any LLM call in `ActiveSupport::Notifications.instrument` — pass a mutable Hash so token counts can be filled in *after* the call:
210
+
211
+ ```ruby
212
+ payload = { provider: "anthropic", model: "claude-sonnet-4-6" }
213
+
214
+ ActiveSupport::Notifications.instrument("red.llm_call", payload) do
215
+ response = Anthropic::Client.new.messages.create(
216
+ model: "claude-sonnet-4-6",
217
+ messages: [ { role: "user", content: "hi" } ]
218
+ )
219
+ payload[:input_tokens] = response.usage.input_tokens
220
+ payload[:output_tokens] = response.usage.output_tokens
221
+ end
222
+
223
+ # Tool execution — captured as its own llm_tool breadcrumb
224
+ ActiveSupport::Notifications.instrument("red.llm_tool_call",
225
+ tool_name: "search_database",
226
+ tool_arguments: { query: "..." }
227
+ ) do
228
+ # run the tool
229
+ end
230
+ ```
231
+
232
+ Payload contract matches the `LlmCallEvent` value object — see [`docs/LLM_OBSERVABILITY.md`](docs/LLM_OBSERVABILITY.md) for the full field list.
233
+ </details>
234
+
150
235
  <details>
151
236
  <summary><strong>Issue Tracking — GitHub, GitLab, Codeberg</strong></summary>
152
237
 
@@ -234,10 +319,20 @@ config.enable_source_code_integration = true # required for source code viewer
234
319
  </details>
235
320
 
236
321
  <details>
237
- <summary><strong>Error Replay — Copy as cURL / RSpec / LLM Markdown</strong></summary>
322
+ <summary><strong>AI Help + Error Replay — Ask, Copy as cURL / RSpec / LLM Markdown</strong></summary>
238
323
 
239
324
  Replay failing requests with one click. Copy the request as a cURL command, generate an RSpec test, or **copy all error details as clean Markdown** for pasting into an LLM session. The LLM export includes app backtrace, cause chain, local/instance variables, breadcrumbs, environment, system health, and related errors — with framework frames filtered and sensitive data preserved as `[FILTERED]`.
240
325
 
326
+ When an LLM provider is configured, the error detail page also shows an **AI Help** drawer. Users can ask follow-up questions about the current error and receive streamed Markdown answers from OpenAI or Anthropic without leaving the dashboard.
327
+
328
+ ```ruby
329
+ config.llm_provider = :openai # or :anthropic
330
+ config.llm_api_key = -> { Rails.application.credentials.dig(:openai, :api_key) }
331
+ config.llm_model = "gpt-5"
332
+ ```
333
+
334
+ > **Privacy:** AI Help sends the error's details (backtrace, context, and your question) to the configured provider (OpenAI or Anthropic). Keep `config.filter_sensitive_data = true` (the default) so sensitive values are redacted as `[FILTERED]` before they leave your app.
335
+
241
336
  [Complete documentation →](docs/FEATURES.md#error-details-page)
242
337
  </details>
243
338
 
@@ -521,13 +616,13 @@ Available as open source under the [MIT License](https://opensource.org/licenses
521
616
 
522
617
  ## Acknowledgments
523
618
 
524
- Built with [Rails](https://rubyonrails.org/) · UI by [Bootstrap 5](https://getbootstrap.com/) · Charts by [Chart.js](https://www.chartjs.org/) · Pagination by [Pagy](https://github.com/ddnexus/pagy) · Docs theme by [Jekyll VitePress Theme](https://jekyll-vitepress.dev/) by [@crmne](https://github.com/crmne)
619
+ Built with [Rails](https://rubyonrails.org/) · Custom design tokens with [Bootstrap 5 JS](https://getbootstrap.com/) for tooltips and modals · Charts by [Chart.js](https://www.chartjs.org/) · Pagination by [Pagy](https://github.com/ddnexus/pagy) · Docs theme by [Jekyll VitePress Theme](https://jekyll-vitepress.dev/) by [@crmne](https://github.com/crmne)
525
620
 
526
621
  ## Contributors
527
622
 
528
623
  [![Contributors](https://contrib.rocks/image?repo=AnjanJ/rails_error_dashboard)](https://github.com/AnjanJ/rails_error_dashboard/graphs/contributors)
529
624
 
530
- Special thanks to [@bonniesimon](https://github.com/bonniesimon), [@gundestrup](https://github.com/gundestrup), [@midwire](https://github.com/midwire), [@RafaelTurtle](https://github.com/RafaelTurtle), [@j4rs](https://github.com/j4rs), and [@gmarziou](https://github.com/gmarziou). See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
625
+ Special thanks to [@bonniesimon](https://github.com/bonniesimon), [@gundestrup](https://github.com/gundestrup), [@midwire](https://github.com/midwire), [@RafaelTurtle](https://github.com/RafaelTurtle), [@j4rs](https://github.com/j4rs), [@gmarziou](https://github.com/gmarziou), and [@antarr](https://github.com/antarr). See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
531
626
 
532
627
  ---
533
628
 
@@ -46,10 +46,16 @@ module RailsErrorDashboard
46
46
  )
47
47
  end
48
48
 
49
- # Handle Pagy pagination errors — redirect to page 1
49
+ # Handle Pagy pagination errors — redirect to page 1, preserving filters.
50
+ # Drop both :page and :per_page from the preserved query string. Either can
51
+ # trigger the rescue (page out of range, per_page negative or non-numeric);
52
+ # carrying them into the redirect would loop the user right back into the
53
+ # same error.
50
54
  rescue_from Pagy::RangeError, Pagy::OptionError do |exception|
51
55
  Rails.logger.warn("[RailsErrorDashboard] Pagination error: #{exception.message}")
52
- redirect_to request.path, status: :moved_permanently
56
+ preserved = request.query_parameters.except("page", :page, "per_page", :per_page)
57
+ target = preserved.any? ? "#{request.path}?#{preserved.to_query}" : request.path
58
+ redirect_to target, status: :moved_permanently
53
59
  end
54
60
 
55
61
  private
@@ -22,6 +22,9 @@ module RailsErrorDashboard
22
22
  hide_snoozed
23
23
  hide_muted
24
24
  reopened
25
+ user_id
26
+ app_version
27
+ git_sha
25
28
  sort_by
26
29
  sort_direction
27
30
  ].freeze
@@ -170,8 +173,45 @@ module RailsErrorDashboard
170
173
  redirect_to error_path(params[:id], anchor: "issue-tracking", **app_context_params)
171
174
  end
172
175
 
176
+ def ai_help
177
+ unless RailsErrorDashboard.configuration.llm_configured?
178
+ render json: { error: "AI Help is not configured" }, status: :not_found
179
+ return
180
+ end
181
+
182
+ question = params[:question].to_s.strip
183
+ if question.blank?
184
+ render json: { error: "Question cannot be blank" }, status: :unprocessable_entity
185
+ return
186
+ end
187
+
188
+ if question.length > 4000
189
+ render json: { error: "Question is too long. Keep it under 4,000 characters." }, status: :unprocessable_entity
190
+ return
191
+ end
192
+
193
+ error = ErrorLog.includes(:comments, :parent_cascade_patterns, :child_cascade_patterns).find(params[:id])
194
+ related_errors = error.related_errors(limit: 5, application_id: @current_application_id)
195
+ context = Services::MarkdownErrorFormatter.call(error, related_errors: related_errors)
196
+
197
+ response.headers["Content-Type"] = "text/event-stream"
198
+ response.headers["Cache-Control"] = "no-cache"
199
+ response.headers["X-Accel-Buffering"] = "no"
200
+
201
+ self.response_body = Enumerator.new do |stream|
202
+ begin
203
+ result = Services::LlmClient.stream(error: error, question: question, context: context) do |text|
204
+ stream << sse_event("chunk", text: text)
205
+ end
206
+ stream << sse_event("done", result)
207
+ rescue Services::LlmClient::ConfigurationError, Services::LlmClient::RequestError => e
208
+ stream << sse_event("error", error: e.message)
209
+ end
210
+ end
211
+ end
212
+
173
213
  def analytics
174
- days = (params[:days] || 30).to_i
214
+ days = days_param(default: 30)
175
215
  @days = days
176
216
 
177
217
  # Use Query to get analytics data (pass application filter)
@@ -212,7 +252,7 @@ module RailsErrorDashboard
212
252
  return
213
253
  end
214
254
 
215
- days = (params[:days] || 7).to_i
255
+ days = days_param(default: 7)
216
256
  @days = days
217
257
 
218
258
  # Use Query to get platform comparison data (pass application filter)
@@ -266,7 +306,7 @@ module RailsErrorDashboard
266
306
  return
267
307
  end
268
308
 
269
- days = (params[:days] || 30).to_i
309
+ days = days_param(default: 30)
270
310
  @days = days
271
311
  correlation = Queries::ErrorCorrelation.new(days: days, application_id: @current_application_id)
272
312
 
@@ -280,7 +320,7 @@ module RailsErrorDashboard
280
320
  end
281
321
 
282
322
  def releases
283
- days = (params[:days] || 30).to_i
323
+ days = days_param(default: 30)
284
324
  @days = days
285
325
  result = Queries::ReleaseTimeline.call(days, application_id: @current_application_id)
286
326
  all_releases = result[:releases]
@@ -290,7 +330,7 @@ module RailsErrorDashboard
290
330
  end
291
331
 
292
332
  def user_impact
293
- days = (params[:days] || 30).to_i
333
+ days = days_param(default: 30)
294
334
  @days = days
295
335
  result = Queries::UserImpactSummary.call(days, application_id: @current_application_id)
296
336
  all_entries = result[:entries]
@@ -306,7 +346,7 @@ module RailsErrorDashboard
306
346
  return
307
347
  end
308
348
 
309
- days = (params[:days] || 30).to_i
349
+ days = days_param(default: 30)
310
350
  @days = days
311
351
  result = Queries::DeprecationWarnings.call(days, application_id: @current_application_id)
312
352
  all_deprecations = result[:deprecations]
@@ -326,7 +366,7 @@ module RailsErrorDashboard
326
366
  return
327
367
  end
328
368
 
329
- days = (params[:days] || 30).to_i
369
+ days = days_param(default: 30)
330
370
  @days = days
331
371
  result = Queries::NplusOneSummary.call(days, application_id: @current_application_id)
332
372
  all_patterns = result[:patterns]
@@ -346,7 +386,7 @@ module RailsErrorDashboard
346
386
  return
347
387
  end
348
388
 
349
- days = (params[:days] || 30).to_i
389
+ days = days_param(default: 30)
350
390
  @days = days
351
391
  result = Queries::CacheHealthSummary.call(days, application_id: @current_application_id)
352
392
  all_entries = result[:entries]
@@ -367,7 +407,7 @@ module RailsErrorDashboard
367
407
  return
368
408
  end
369
409
 
370
- days = (params[:days] || 30).to_i
410
+ days = days_param(default: 30)
371
411
  @days = days
372
412
  result = Queries::JobHealthSummary.call(days, application_id: @current_application_id)
373
413
  all_entries = result[:entries]
@@ -387,7 +427,7 @@ module RailsErrorDashboard
387
427
  return
388
428
  end
389
429
 
390
- days = (params[:days] || 30).to_i
430
+ days = days_param(default: 30)
391
431
  @days = days
392
432
 
393
433
  # Live database health (display-time only)
@@ -423,7 +463,7 @@ module RailsErrorDashboard
423
463
  return
424
464
  end
425
465
 
426
- days = (params[:days] || 30).to_i
466
+ days = days_param(default: 30)
427
467
  @days = days
428
468
  result = Queries::SwallowedExceptionSummary.call(days, application_id: @current_application_id)
429
469
  all_entries = result[:entries]
@@ -444,7 +484,7 @@ module RailsErrorDashboard
444
484
  return
445
485
  end
446
486
 
447
- days = (params[:days] || 30).to_i
487
+ days = days_param(default: 30)
448
488
  @days = days
449
489
  result = Queries::RackAttackSummary.call(days, application_id: @current_application_id)
450
490
  all_events = result[:events]
@@ -465,7 +505,7 @@ module RailsErrorDashboard
465
505
  return
466
506
  end
467
507
 
468
- days = (params[:days] || 30).to_i
508
+ days = days_param(default: 30)
469
509
  @days = days
470
510
  result = Queries::ActionCableSummary.call(days, application_id: @current_application_id)
471
511
  all_channels = result[:channels]
@@ -486,7 +526,7 @@ module RailsErrorDashboard
486
526
  return
487
527
  end
488
528
 
489
- days = (params[:days] || 30).to_i
529
+ days = days_param(default: 30)
490
530
  @days = days
491
531
  result = Queries::ActiveStorageSummary.call(days, application_id: @current_application_id)
492
532
  all_services = result[:services]
@@ -606,10 +646,22 @@ module RailsErrorDashboard
606
646
  }
607
647
  end
608
648
 
649
+ def sse_event(event, payload)
650
+ "event: #{event}\ndata: #{payload.to_json}\n\n"
651
+ end
652
+
609
653
  def filter_params
610
654
  params.permit(*FILTERABLE_PARAMS).to_h.symbolize_keys
611
655
  end
612
656
 
657
+ # Coerce params[:days] into a sane integer in [1, 365]. Without clamping,
658
+ # a request like ?days=99999999 would scan the full table on every health
659
+ # query, defeating index pruning and burning CPU.
660
+ def days_param(default:)
661
+ raw = params[:days].presence || default
662
+ raw.to_i.clamp(1, 365)
663
+ end
664
+
613
665
  def set_application_context
614
666
  @current_application_id = params[:application_id].presence
615
667
  @applications = Application.ordered_by_name.pluck(:name, :id)
@@ -27,6 +27,16 @@ module RailsErrorDashboard
27
27
  end
28
28
  end
29
29
 
30
+ # Serialize a value to JSON safely for inlining inside a <script> block.
31
+ # Ruby's #to_json escapes JSON special chars but does NOT escape "</" — a
32
+ # value containing the literal string "</script>" would break out of the
33
+ # surrounding <script> tag. Replace "</" with "<\/" (semantically equivalent
34
+ # in JSON and in JavaScript string literals) to neutralize the close tag.
35
+ # Returns html_safe for direct interpolation into a script body.
36
+ def js_safe_json(value)
37
+ value.to_json.gsub("</", '<\/').html_safe
38
+ end
39
+
30
40
  # Returns Bootstrap color class for error severity
31
41
  # Uses Catppuccin Mocha colors in dark theme via CSS variables
32
42
  # @param severity [Symbol] The severity level (:critical, :high, :medium, :low, :info)
@@ -209,6 +219,17 @@ module RailsErrorDashboard
209
219
  )
210
220
  end
211
221
 
222
+ # Raw connection.select_all returns timestamps as Time in some PG configs
223
+ # and as ISO8601 strings in others — accept both shapes.
224
+ def parse_pg_timestamp(value)
225
+ return nil if value.blank?
226
+ return value if value.is_a?(Time) || value.is_a?(DateTime)
227
+
228
+ Time.parse(value.to_s)
229
+ rescue ArgumentError
230
+ nil
231
+ end
232
+
212
233
  # Renders a relative time ("3 hours ago") that updates automatically
213
234
  # @param time [Time, DateTime, nil] The timestamp to display
214
235
  # @param fallback [String] Text to show if time is nil
@@ -242,6 +263,8 @@ module RailsErrorDashboard
242
263
  when "mailer" then "secondary"
243
264
  when "custom" then "dark"
244
265
  when "deprecation" then "danger"
266
+ when "llm" then "info"
267
+ when "llm_tool" then "warning"
245
268
  else "light"
246
269
  end
247
270
  end
@@ -265,6 +288,13 @@ module RailsErrorDashboard
265
288
  def auto_link_urls(text, error: nil)
266
289
  return "" if text.blank?
267
290
 
291
+ # SECURITY: escape HTML special chars in the input before any further
292
+ # processing. simple_format(..., sanitize: false) at the end means
293
+ # whatever survives this pipeline is rendered as raw HTML; any unescaped
294
+ # `<script>` or `<img onerror=>` in the user's text would XSS.
295
+ # We only intentionally inject our own <a> / <code> tags after this point.
296
+ text = ERB::Util.html_escape(text)
297
+
268
298
  # Get repository URL from error's application or global config
269
299
  repo_url = if error&.application.respond_to?(:repository_url) && error.application.repository_url.present?
270
300
  error.application.repository_url
@@ -302,32 +332,34 @@ module RailsErrorDashboard
302
332
  )
303
333
  }xi
304
334
 
305
- # Replace URLs with clickable links
335
+ # Replace URLs with clickable links. The url, code_content, and file_path
336
+ # values below are slices of `text` which we already escaped at function
337
+ # entry, so we don't re-escape them here (would double-escape `&amp;`,
338
+ # breaking visual rendering). repo_url is from config so we escape it
339
+ # explicitly before interpolating.
340
+ escaped_repo_url = repo_url ? ERB::Util.html_escape(repo_url.chomp("/")) : nil
341
+
306
342
  linked_text = text_with_placeholders.gsub(url_regex) do |url|
307
- # Clean up the URL
308
343
  clean_url = url.strip
309
344
 
310
- # Add protocol if missing
311
345
  href = clean_url.start_with?("http://", "https://") ? clean_url : "https://#{clean_url}"
312
346
 
313
- # Truncate display text for very long URLs
314
347
  display_text = clean_url.length > 60 ? "#{clean_url[0..57]}..." : clean_url
315
348
 
316
- "<a href=\"#{ERB::Util.html_escape(href)}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-primary text-decoration-underline\">#{ERB::Util.html_escape(display_text)}</a>"
349
+ "<a href=\"#{href}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-primary text-decoration-underline\">#{display_text}</a>"
317
350
  end
318
351
 
319
- # Restore file paths with GitHub links (elvish magic! 🧝‍♀️)
352
+ # Restore file paths with GitHub links
320
353
  linked_text.gsub!(/###FILE_PATH_(\d+)###/) do
321
354
  file_path = file_paths[Regexp.last_match(1).to_i]
322
- github_url = "#{repo_url.chomp('/')}/blob/main/#{file_path}"
323
- "<a href=\"#{ERB::Util.html_escape(github_url)}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-decoration-none\" title=\"View on GitHub\">" \
324
- "<code class=\"inline-code-highlight file-path-link\">#{ERB::Util.html_escape(file_path)}</code></a>"
355
+ "<a href=\"#{escaped_repo_url}/blob/main/#{file_path}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-decoration-none\" title=\"View on GitHub\">" \
356
+ "<code class=\"inline-code-highlight file-path-link\">#{file_path}</code></a>"
325
357
  end
326
358
 
327
359
  # Restore code blocks with styling
328
360
  linked_text.gsub!(/###CODE_BLOCK_(\d+)###/) do
329
361
  code_content = code_blocks[Regexp.last_match(1).to_i]
330
- "<code class=\"inline-code-highlight\">#{ERB::Util.html_escape(code_content)}</code>"
362
+ "<code class=\"inline-code-highlight\">#{code_content}</code>"
331
363
  end
332
364
 
333
365
  # Preserve line breaks and return as HTML safe