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.
- checksums.yaml +4 -4
- data/README.md +101 -6
- data/app/controllers/rails_error_dashboard/application_controller.rb +8 -2
- data/app/controllers/rails_error_dashboard/errors_controller.rb +66 -14
- data/app/helpers/rails_error_dashboard/application_helper.rb +42 -10
- data/app/views/layouts/rails_error_dashboard.html.erb +307 -0
- data/app/views/rails_error_dashboard/errors/_ai_help_panel.html.erb +36 -0
- data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +64 -5
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +2 -2
- data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +1 -0
- data/app/views/rails_error_dashboard/errors/_llm_summary.html.erb +97 -0
- data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +1 -0
- data/app/views/rails_error_dashboard/errors/_modals.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +21 -20
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +33 -19
- data/app/views/rails_error_dashboard/errors/_timeline.html.erb +1 -2
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +5 -1
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +16 -4
- data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +7 -3
- data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/index.html.erb +7 -1
- data/app/views/rails_error_dashboard/errors/overview.html.erb +2 -2
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/releases.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/settings.html.erb +0 -1
- data/app/views/rails_error_dashboard/errors/show.html.erb +12 -2
- data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +5 -1
- data/app/views/rails_error_dashboard/errors/user_impact.html.erb +3 -1
- data/config/routes.rb +1 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +27 -0
- data/lib/rails_error_dashboard/configuration.rb +101 -1
- data/lib/rails_error_dashboard/engine.rb +14 -0
- data/lib/rails_error_dashboard/integrations/llm_middleware.rb +276 -0
- data/lib/rails_error_dashboard/integrations/llm_span_processor.rb +181 -0
- data/lib/rails_error_dashboard/integrations/o_tel.rb +45 -0
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +4 -1
- data/lib/rails_error_dashboard/queries/error_correlation.rb +17 -13
- data/lib/rails_error_dashboard/queries/errors_list.rb +14 -0
- data/lib/rails_error_dashboard/services/cascade_detector.rb +28 -18
- data/lib/rails_error_dashboard/services/llm_client.rb +368 -0
- data/lib/rails_error_dashboard/services/llm_cost_estimator.rb +85 -0
- data/lib/rails_error_dashboard/services/llm_summary.rb +91 -0
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +87 -0
- data/lib/rails_error_dashboard/subscribers/llm_call_subscriber.rb +150 -0
- data/lib/rails_error_dashboard/value_objects/llm_call_event.rb +92 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +8 -0
- metadata +13 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfd6bfaddc3831f37ef4162c92037dddfb18129068e1ad279449b4fcdf164514
|
|
4
|
+
data.tar.gz: 031a14fd395298e46d95bcbb182c8333dab85d12c64439e7d840a577a7ced989
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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** · **
|
|
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
|

|
|
37
37
|
|
|
38
|
+
**AI Help** — Optional OpenAI or Anthropic assistance streamed directly inside the error detail page.
|
|
39
|
+
|
|
40
|
+

|
|
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.
|
|
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
|

|
|
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
|

|
|
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/) ·
|
|
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
|
[](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),
|
|
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
|
-
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 `&`,
|
|
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=\"#{
|
|
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
|
|
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
|
-
|
|
323
|
-
"<
|
|
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\">#{
|
|
362
|
+
"<code class=\"inline-code-highlight\">#{code_content}</code>"
|
|
331
363
|
end
|
|
332
364
|
|
|
333
365
|
# Preserve line breaks and return as HTML safe
|