rails_error_dashboard 0.5.1 → 0.5.3
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 +5 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +6 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +22 -0
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +93 -4
- data/app/views/rails_error_dashboard/errors/show.html.erb +3 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +3 -3
- data/db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb +3 -3
- data/db/migrate/20260325000001_fix_swallowed_exceptions_index_for_mysql.rb +49 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +37 -4
- data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +5 -5
- data/lib/rails_error_dashboard/configuration.rb +18 -0
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +251 -0
- data/lib/rails_error_dashboard/services/system_health_snapshot.rb +125 -11
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85ee33f8363a0a875c4c00bd2242a7d282dba7502cc78cf8af84a4583f376121
|
|
4
|
+
data.tar.gz: 51183380c9642cbabc19582daa191ee7ee4c161cc2c98fbc3dd6693459e91b40
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 827a994438026c7b42c42927665406c165357de706014596f533d1d23b0121e33a71aa6b2a55f131e274abe2e24dde6d484a4c6b869958363a2ef4fce641c378
|
|
7
|
+
data.tar.gz: ffa7bc26d8f565f10835735e2721589c6c56a13f1ee64f72a06e1b6cd5913c6e5ad8278838368578b57ec99a9cb46ddbf304ee27db4e78c74218db39cc778286
|
data/README.md
CHANGED
|
@@ -22,7 +22,7 @@ gem 'rails_error_dashboard'
|
|
|
22
22
|
|
|
23
23
|
**[rails-error-dashboard.anjan.dev](https://rails-error-dashboard.anjan.dev)** — Username: `gandalf` · Password: `youshallnotpass`
|
|
24
24
|
|
|
25
|
-
> **Beta Software** — Functional and tested (2,
|
|
25
|
+
> **Beta Software** — Functional and tested (2,700+ tests passing), but the API may change before v1.0. Supports Rails 7.0-8.1 and Ruby 3.2-4.0.
|
|
26
26
|
|
|
27
27
|
### Screenshots
|
|
28
28
|
|
|
@@ -87,12 +87,13 @@ config.enable_breadcrumbs = true
|
|
|
87
87
|
<details>
|
|
88
88
|
<summary><strong>System Health Snapshot</strong></summary>
|
|
89
89
|
|
|
90
|
-
Know your app's runtime state at the moment of failure — GC stats, process memory, thread count, connection pool utilization, Puma thread stats, RubyVM cache health,
|
|
90
|
+
Know your app's runtime state at the moment of failure — GC stats, process memory, thread count, connection pool utilization, Puma thread stats, RubyVM cache health, YJIT compilation stats, and deep runtime insights captured automatically.
|
|
91
91
|
|
|
92
92
|
- Sub-millisecond total snapshot, every metric individually rescue-wrapped
|
|
93
93
|
- No ObjectSpace scanning, no Thread backtraces, no subprocess calls
|
|
94
94
|
- RubyVM.stat: constant cache invalidations, shape cache stats
|
|
95
95
|
- YJIT runtime stats: compiled iseqs, invalidation count, code region sizes
|
|
96
|
+
- **v0.5.2** — File descriptor utilization, system load averages, system memory pressure, TCP connection states, GC context (trigger reason, last major/minor), process swap and peak RSS — all with color-coded danger indicators
|
|
96
97
|
|
|
97
98
|
```ruby
|
|
98
99
|
config.enable_system_health = true
|
|
@@ -153,9 +154,9 @@ config.enable_git_blame = true
|
|
|
153
154
|
</details>
|
|
154
155
|
|
|
155
156
|
<details>
|
|
156
|
-
<summary><strong>Error Replay — Copy as cURL / RSpec</strong></summary>
|
|
157
|
+
<summary><strong>Error Replay — Copy as cURL / RSpec / LLM Markdown</strong></summary>
|
|
157
158
|
|
|
158
|
-
Replay failing requests with one click. Copy the request as a cURL command
|
|
159
|
+
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]`.
|
|
159
160
|
|
|
160
161
|
[Complete documentation →](docs/FEATURES.md#error-details-page)
|
|
161
162
|
</details>
|
|
@@ -4,6 +4,7 @@ module RailsErrorDashboard
|
|
|
4
4
|
class ErrorsController < ApplicationController
|
|
5
5
|
before_action :authenticate_dashboard_user!
|
|
6
6
|
before_action :set_application_context
|
|
7
|
+
before_action :check_default_credentials
|
|
7
8
|
|
|
8
9
|
FILTERABLE_PARAMS = %i[
|
|
9
10
|
error_type
|
|
@@ -78,6 +79,7 @@ module RailsErrorDashboard
|
|
|
78
79
|
# - parent_cascade_patterns/child_cascade_patterns: Used if cascade detection is enabled
|
|
79
80
|
@error = ErrorLog.includes(:comments, :parent_cascade_patterns, :child_cascade_patterns).find(params[:id])
|
|
80
81
|
@related_errors = @error.related_errors(limit: 5, application_id: @current_application_id)
|
|
82
|
+
@error_markdown = Services::MarkdownErrorFormatter.call(@error, related_errors: @related_errors)
|
|
81
83
|
|
|
82
84
|
# Dispatch plugin event for error viewed
|
|
83
85
|
RailsErrorDashboard::PluginRegistry.dispatch(:on_error_viewed, @error)
|
|
@@ -508,6 +510,10 @@ module RailsErrorDashboard
|
|
|
508
510
|
@applications = Application.ordered_by_name.pluck(:name, :id)
|
|
509
511
|
end
|
|
510
512
|
|
|
513
|
+
def check_default_credentials
|
|
514
|
+
@default_credentials_warning = RailsErrorDashboard.configuration.default_credentials?
|
|
515
|
+
end
|
|
516
|
+
|
|
511
517
|
def authenticate_dashboard_user!
|
|
512
518
|
auth_lambda = RailsErrorDashboard.configuration.authenticate_with
|
|
513
519
|
|
|
@@ -774,6 +774,16 @@ body.dark-mode .alert-danger {
|
|
|
774
774
|
color: var(--ctp-text) !important;
|
|
775
775
|
}
|
|
776
776
|
|
|
777
|
+
body.dark-mode .alert-warning {
|
|
778
|
+
background-color: rgba(249, 226, 175, 0.15) !important;
|
|
779
|
+
border-color: var(--ctp-yellow) !important;
|
|
780
|
+
color: var(--ctp-text) !important;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
body.dark-mode .alert-warning code {
|
|
784
|
+
color: var(--ctp-peach) !important;
|
|
785
|
+
}
|
|
786
|
+
|
|
777
787
|
/* Chartkick specific - force text visibility */
|
|
778
788
|
body.dark-mode #chart-1 text,
|
|
779
789
|
body.dark-mode [id^="chart-"] text {
|
|
@@ -1747,6 +1757,18 @@ body.dark-mode .sidebar-section-body { background: var(--ctp-mantle); }
|
|
|
1747
1757
|
|
|
1748
1758
|
<!-- Main content -->
|
|
1749
1759
|
<main class="col-md-10 ms-sm-auto px-md-4" id="mainContent">
|
|
1760
|
+
<% if @default_credentials_warning %>
|
|
1761
|
+
<div class="alert alert-warning d-flex align-items-center mt-3 mb-0" role="alert" style="border-left: 4px solid #ffc107;">
|
|
1762
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
|
1763
|
+
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
|
1764
|
+
</svg>
|
|
1765
|
+
<div>
|
|
1766
|
+
<strong>Security Warning:</strong> You are using default credentials (gandalf/youshallnotpass).
|
|
1767
|
+
Set <code>ERROR_DASHBOARD_USER</code> and <code>ERROR_DASHBOARD_PASSWORD</code> environment variables,
|
|
1768
|
+
or configure <code>authenticate_with</code> in your initializer. The app will not boot in production until this is changed.
|
|
1769
|
+
</div>
|
|
1770
|
+
</div>
|
|
1771
|
+
<% end %>
|
|
1750
1772
|
<%= yield %>
|
|
1751
1773
|
</main>
|
|
1752
1774
|
</div>
|
|
@@ -299,7 +299,37 @@
|
|
|
299
299
|
<div class="sidebar-section-title"><i class="bi bi-heart-pulse"></i> System Health</div>
|
|
300
300
|
<small class="sidebar-section-hint">Runtime state when error fired</small>
|
|
301
301
|
<div class="sidebar-section-body">
|
|
302
|
-
<% if health[:
|
|
302
|
+
<% if health[:process_memory] %>
|
|
303
|
+
<% pm = health[:process_memory] %>
|
|
304
|
+
<div class="mb-1">
|
|
305
|
+
<small class="text-muted">Memory (RSS):</small>
|
|
306
|
+
<% mem = pm[:rss_mb].to_f %>
|
|
307
|
+
<code class="ms-1 text-<%= mem > 1024 ? 'danger' : mem > 512 ? 'warning' : 'success' %>">
|
|
308
|
+
<%= mem %> MB
|
|
309
|
+
</code>
|
|
310
|
+
</div>
|
|
311
|
+
<% if pm[:swap_mb].to_f > 0 %>
|
|
312
|
+
<div class="mb-1">
|
|
313
|
+
<small class="text-muted">Process Swap:</small>
|
|
314
|
+
<code class="ms-1 text-danger"><%= pm[:swap_mb] %> MB</code>
|
|
315
|
+
</div>
|
|
316
|
+
<% end %>
|
|
317
|
+
<% if pm[:rss_peak_mb] %>
|
|
318
|
+
<div class="mb-1">
|
|
319
|
+
<small class="text-muted">Peak RSS:</small>
|
|
320
|
+
<code class="ms-1"><%= pm[:rss_peak_mb] %> MB</code>
|
|
321
|
+
</div>
|
|
322
|
+
<% end %>
|
|
323
|
+
<% if pm[:os_threads] %>
|
|
324
|
+
<div class="mb-1">
|
|
325
|
+
<small class="text-muted">OS Threads:</small>
|
|
326
|
+
<code class="ms-1"><%= pm[:os_threads] %></code>
|
|
327
|
+
<% if health[:thread_count] %>
|
|
328
|
+
<small class="text-muted ms-1">(Ruby: <%= health[:thread_count] %>)</small>
|
|
329
|
+
<% end %>
|
|
330
|
+
</div>
|
|
331
|
+
<% end %>
|
|
332
|
+
<% elsif health[:process_memory_mb] %>
|
|
303
333
|
<div class="mb-1">
|
|
304
334
|
<small class="text-muted">Memory (RSS):</small>
|
|
305
335
|
<% mem = health[:process_memory_mb].to_f %>
|
|
@@ -307,12 +337,61 @@
|
|
|
307
337
|
<%= mem %> MB
|
|
308
338
|
</code>
|
|
309
339
|
</div>
|
|
340
|
+
<% if health[:thread_count] %>
|
|
341
|
+
<div class="mb-1">
|
|
342
|
+
<small class="text-muted">Threads:</small>
|
|
343
|
+
<code class="ms-1"><%= health[:thread_count] %></code>
|
|
344
|
+
</div>
|
|
345
|
+
<% end %>
|
|
310
346
|
<% end %>
|
|
311
347
|
|
|
312
|
-
<% if health[:
|
|
348
|
+
<% if health[:file_descriptors] %>
|
|
349
|
+
<% fd = health[:file_descriptors] %>
|
|
313
350
|
<div class="mb-1">
|
|
314
|
-
<small class="text-muted">
|
|
315
|
-
|
|
351
|
+
<small class="text-muted">File Descriptors:</small>
|
|
352
|
+
<% fd_pct = fd[:utilization_pct].to_f %>
|
|
353
|
+
<code class="ms-1 text-<%= fd_pct > 80 ? 'danger' : fd_pct > 60 ? 'warning' : 'success' %>">
|
|
354
|
+
<%= fd[:open] %> / <%= fd[:limit] %> (<%= fd_pct %>%)
|
|
355
|
+
</code>
|
|
356
|
+
</div>
|
|
357
|
+
<% end %>
|
|
358
|
+
|
|
359
|
+
<% if health[:system_load] %>
|
|
360
|
+
<% sl = health[:system_load] %>
|
|
361
|
+
<div class="mb-1">
|
|
362
|
+
<small class="text-muted">Load Average:</small>
|
|
363
|
+
<% lr = sl[:load_ratio].to_f %>
|
|
364
|
+
<code class="ms-1 text-<%= lr > 2.0 ? 'danger' : lr > 1.0 ? 'warning' : 'success' %>">
|
|
365
|
+
<%= sl[:load_1m] %><% if sl[:cpu_count] %> / <%= sl[:cpu_count] %> CPUs<% end %>
|
|
366
|
+
</code>
|
|
367
|
+
<small class="text-muted ms-1">(5m: <%= sl[:load_5m] %>, 15m: <%= sl[:load_15m] %>)</small>
|
|
368
|
+
</div>
|
|
369
|
+
<% end %>
|
|
370
|
+
|
|
371
|
+
<% if health[:system_memory] %>
|
|
372
|
+
<% sm = health[:system_memory] %>
|
|
373
|
+
<div class="mb-1">
|
|
374
|
+
<small class="text-muted">System Memory:</small>
|
|
375
|
+
<% used_pct = sm[:used_pct].to_f %>
|
|
376
|
+
<code class="ms-1 text-<%= used_pct > 90 ? 'danger' : used_pct > 80 ? 'warning' : 'success' %>">
|
|
377
|
+
<%= sm[:available_mb] %> MB free / <%= sm[:total_mb] %> MB (<%= used_pct %>% used)
|
|
378
|
+
</code>
|
|
379
|
+
</div>
|
|
380
|
+
<% if sm[:swap_used_mb].to_i > 0 %>
|
|
381
|
+
<div class="mb-1">
|
|
382
|
+
<small class="text-muted">System Swap:</small>
|
|
383
|
+
<code class="ms-1 text-danger"><%= sm[:swap_used_mb] %> MB used</code>
|
|
384
|
+
</div>
|
|
385
|
+
<% end %>
|
|
386
|
+
<% end %>
|
|
387
|
+
|
|
388
|
+
<% if health[:tcp_connections] %>
|
|
389
|
+
<% tcp = health[:tcp_connections] %>
|
|
390
|
+
<div class="mb-1">
|
|
391
|
+
<small class="text-muted">TCP Connections:</small>
|
|
392
|
+
<code class="ms-1">
|
|
393
|
+
<%= tcp[:established] %> established<% if tcp[:close_wait].to_i > 0 %>, <span class="text-danger"><%= tcp[:close_wait] %> close_wait</span><% end %><% if tcp[:time_wait].to_i > 0 %>, <span class="text-warning"><%= tcp[:time_wait] %> time_wait</span><% end %>
|
|
394
|
+
</code>
|
|
316
395
|
</div>
|
|
317
396
|
<% end %>
|
|
318
397
|
|
|
@@ -327,6 +406,16 @@
|
|
|
327
406
|
</div>
|
|
328
407
|
<% end %>
|
|
329
408
|
|
|
409
|
+
<% if health[:gc_latest] %>
|
|
410
|
+
<% gcl = health[:gc_latest] %>
|
|
411
|
+
<div class="mb-1">
|
|
412
|
+
<small class="text-muted">Last GC:</small>
|
|
413
|
+
<code class="ms-1">
|
|
414
|
+
<%= gcl[:major_by] ? "major (#{gcl[:major_by]})" : "minor" %> by <%= gcl[:gc_by] %><% if gcl[:state].to_s != "none" %>, <span class="text-warning">state: <%= gcl[:state] %></span><% end %>
|
|
415
|
+
</code>
|
|
416
|
+
</div>
|
|
417
|
+
<% end %>
|
|
418
|
+
|
|
330
419
|
<% if health[:connection_pool] %>
|
|
331
420
|
<% cp = health[:connection_pool] %>
|
|
332
421
|
<div class="mb-1">
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
<button type="button" class="btn btn-outline-secondary" onclick="downloadErrorJSON(event)" title="Download error details as JSON">
|
|
33
33
|
<i class="bi bi-download"></i> Export JSON
|
|
34
34
|
</button>
|
|
35
|
+
<button type="button" class="btn btn-outline-secondary" onclick="copyToClipboard(this.dataset.markdown, this)" data-markdown="<%= j @error_markdown %>" title="Copy error details as Markdown for LLM debugging">
|
|
36
|
+
<i class="bi bi-clipboard"></i> Copy for LLM
|
|
37
|
+
</button>
|
|
35
38
|
<% if @error.respond_to?(:muted?) && @error.muted? %>
|
|
36
39
|
<button type="button" class="btn btn-secondary" disabled>
|
|
37
40
|
<i class="bi bi-bell-slash"></i> Muted
|
|
@@ -208,9 +208,9 @@ class CreateRailsErrorDashboardCompleteSchema < ActiveRecord::Migration[7.0]
|
|
|
208
208
|
|
|
209
209
|
# Create swallowed_exceptions table (from 20260306000003)
|
|
210
210
|
create_table :rails_error_dashboard_swallowed_exceptions do |t|
|
|
211
|
-
t.string :exception_class, null: false
|
|
212
|
-
t.string :raise_location, null: false, limit:
|
|
213
|
-
t.string :rescue_location, limit:
|
|
211
|
+
t.string :exception_class, null: false, limit: 250
|
|
212
|
+
t.string :raise_location, null: false, limit: 250
|
|
213
|
+
t.string :rescue_location, limit: 250
|
|
214
214
|
t.datetime :period_hour, null: false
|
|
215
215
|
t.integer :raise_count, null: false, default: 0
|
|
216
216
|
t.integer :rescue_count, null: false, default: 0
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
class CreateRailsErrorDashboardSwallowedExceptions < ActiveRecord::Migration[7.0]
|
|
4
4
|
def change
|
|
5
5
|
create_table :rails_error_dashboard_swallowed_exceptions do |t|
|
|
6
|
-
t.string :exception_class, null: false
|
|
7
|
-
t.string :raise_location, null: false, limit:
|
|
8
|
-
t.string :rescue_location, limit:
|
|
6
|
+
t.string :exception_class, null: false, limit: 250
|
|
7
|
+
t.string :raise_location, null: false, limit: 250
|
|
8
|
+
t.string :rescue_location, limit: 250
|
|
9
9
|
t.datetime :period_hour, null: false
|
|
10
10
|
t.integer :raise_count, null: false, default: 0
|
|
11
11
|
t.integer :rescue_count, null: false, default: 0
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Fix MySQL "Specified key was too long" error on the swallowed_exceptions
|
|
4
|
+
# composite unique index. The original columns (varchar 255 + 500 + 500)
|
|
5
|
+
# total 5042 bytes under utf8mb4, exceeding MySQL's 3072-byte InnoDB limit.
|
|
6
|
+
#
|
|
7
|
+
# Reduces all three string columns to limit: 250, bringing the total to
|
|
8
|
+
# 3022 bytes (250 * 4 * 3 + 6 length prefixes + 16 datetime/bigint).
|
|
9
|
+
#
|
|
10
|
+
# See: https://github.com/AnjanJ/rails_error_dashboard/issues/96
|
|
11
|
+
class FixSwallowedExceptionsIndexForMysql < ActiveRecord::Migration[7.0]
|
|
12
|
+
def up
|
|
13
|
+
return unless table_exists?(:rails_error_dashboard_swallowed_exceptions)
|
|
14
|
+
|
|
15
|
+
# Remove the oversized index if it exists (it may not exist on MySQL
|
|
16
|
+
# since the original migration would have failed at this point)
|
|
17
|
+
if index_exists?(:rails_error_dashboard_swallowed_exceptions, name: "index_swallowed_exceptions_upsert_key")
|
|
18
|
+
remove_index :rails_error_dashboard_swallowed_exceptions, name: "index_swallowed_exceptions_upsert_key"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Shrink columns to fit within MySQL's 3072-byte index key limit
|
|
22
|
+
change_column :rails_error_dashboard_swallowed_exceptions, :exception_class, :string, null: false, limit: 250
|
|
23
|
+
change_column :rails_error_dashboard_swallowed_exceptions, :raise_location, :string, null: false, limit: 250
|
|
24
|
+
change_column :rails_error_dashboard_swallowed_exceptions, :rescue_location, :string, limit: 250
|
|
25
|
+
|
|
26
|
+
# Re-add the index with the smaller columns
|
|
27
|
+
add_index :rails_error_dashboard_swallowed_exceptions,
|
|
28
|
+
[ :exception_class, :raise_location, :rescue_location, :period_hour, :application_id ],
|
|
29
|
+
unique: true,
|
|
30
|
+
name: "index_swallowed_exceptions_upsert_key"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def down
|
|
34
|
+
return unless table_exists?(:rails_error_dashboard_swallowed_exceptions)
|
|
35
|
+
|
|
36
|
+
if index_exists?(:rails_error_dashboard_swallowed_exceptions, name: "index_swallowed_exceptions_upsert_key")
|
|
37
|
+
remove_index :rails_error_dashboard_swallowed_exceptions, name: "index_swallowed_exceptions_upsert_key"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
change_column :rails_error_dashboard_swallowed_exceptions, :exception_class, :string, null: false
|
|
41
|
+
change_column :rails_error_dashboard_swallowed_exceptions, :raise_location, :string, null: false, limit: 500
|
|
42
|
+
change_column :rails_error_dashboard_swallowed_exceptions, :rescue_location, :string, limit: 500
|
|
43
|
+
|
|
44
|
+
add_index :rails_error_dashboard_swallowed_exceptions,
|
|
45
|
+
[ :exception_class, :raise_location, :rescue_location, :period_hour, :application_id ],
|
|
46
|
+
unique: true,
|
|
47
|
+
name: "index_swallowed_exceptions_upsert_key"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -226,7 +226,27 @@ module RailsErrorDashboard
|
|
|
226
226
|
say "\n"
|
|
227
227
|
end
|
|
228
228
|
|
|
229
|
+
def detect_existing_config
|
|
230
|
+
initializer_path = File.join(destination_root, "config/initializers/rails_error_dashboard.rb")
|
|
231
|
+
return unless File.exist?(initializer_path)
|
|
232
|
+
|
|
233
|
+
content = File.read(initializer_path)
|
|
234
|
+
@existing_install_detected = true
|
|
235
|
+
|
|
236
|
+
# Detect separate database from existing config (skip comments)
|
|
237
|
+
if content.match?(/^\s*config\.use_separate_database\s*=\s*true/)
|
|
238
|
+
@database_mode = :separate
|
|
239
|
+
@database_name = content[/^\s*config\.database\s*=\s*:(\w+)/, 1] || "error_dashboard"
|
|
240
|
+
@enable_separate_database = true
|
|
241
|
+
@application_name = detect_application_name
|
|
242
|
+
say_status "detected", "existing separate database configuration", :green
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
229
246
|
def select_database_mode
|
|
247
|
+
# Skip if existing config already detected database mode
|
|
248
|
+
return if @existing_install_detected && @database_mode
|
|
249
|
+
|
|
230
250
|
# Skip if not interactive or if --separate_database was passed via CLI
|
|
231
251
|
if options[:separate_database]
|
|
232
252
|
@database_mode = :separate
|
|
@@ -333,6 +353,12 @@ module RailsErrorDashboard
|
|
|
333
353
|
@enable_crash_capture = @selected_features&.dig(:crash_capture) || options[:crash_capture]
|
|
334
354
|
@enable_diagnostic_dump = @selected_features&.dig(:diagnostic_dump) || options[:diagnostic_dump]
|
|
335
355
|
|
|
356
|
+
# Don't overwrite existing initializer on upgrade — user's config is precious
|
|
357
|
+
if @existing_install_detected
|
|
358
|
+
say_status "skip", "config/initializers/rails_error_dashboard.rb (preserving existing config)", :yellow
|
|
359
|
+
return
|
|
360
|
+
end
|
|
361
|
+
|
|
336
362
|
template "initializer.rb", "config/initializers/rails_error_dashboard.rb"
|
|
337
363
|
end
|
|
338
364
|
|
|
@@ -343,10 +369,17 @@ module RailsErrorDashboard
|
|
|
343
369
|
|
|
344
370
|
FileUtils.mkdir_p(target_dir)
|
|
345
371
|
|
|
346
|
-
# Check
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
372
|
+
# Check BOTH migration directories to prevent cross-directory duplication (#93)
|
|
373
|
+
# A user who installed with separate DB and re-runs without the flag should not
|
|
374
|
+
# get duplicate migrations in db/migrate/
|
|
375
|
+
existing = Set.new
|
|
376
|
+
[ "db/migrate", "db/error_dashboard_migrate" ].each do |dir|
|
|
377
|
+
full_path = File.join(destination_root, dir)
|
|
378
|
+
next unless Dir.exist?(full_path)
|
|
379
|
+
Dir.glob(File.join(full_path, "*rails_error_dashboard*.rb")).each do |f|
|
|
380
|
+
existing.add(File.basename(f).sub(/^\d+_/, ""))
|
|
381
|
+
end
|
|
382
|
+
end
|
|
350
383
|
|
|
351
384
|
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
|
|
352
385
|
|
|
@@ -52,8 +52,8 @@ module RailsErrorDashboard
|
|
|
52
52
|
|
|
53
53
|
def upsert_raise(class_name, location, period, app_id, count)
|
|
54
54
|
record = SwallowedException.find_or_initialize_by(
|
|
55
|
-
exception_class: truncate(class_name,
|
|
56
|
-
raise_location: truncate(location,
|
|
55
|
+
exception_class: truncate(class_name, 250),
|
|
56
|
+
raise_location: truncate(location, 250),
|
|
57
57
|
rescue_location: nil,
|
|
58
58
|
period_hour: period,
|
|
59
59
|
application_id: app_id
|
|
@@ -70,9 +70,9 @@ module RailsErrorDashboard
|
|
|
70
70
|
|
|
71
71
|
def upsert_rescue(class_name, raise_loc, rescue_loc, period, app_id, count)
|
|
72
72
|
record = SwallowedException.find_or_initialize_by(
|
|
73
|
-
exception_class: truncate(class_name,
|
|
74
|
-
raise_location: truncate(raise_loc,
|
|
75
|
-
rescue_location: rescue_loc.present? ? truncate(rescue_loc,
|
|
73
|
+
exception_class: truncate(class_name, 250),
|
|
74
|
+
raise_location: truncate(raise_loc, 250),
|
|
75
|
+
rescue_location: rescue_loc.present? ? truncate(rescue_loc, 250) : nil,
|
|
76
76
|
period_hour: period,
|
|
77
77
|
application_id: app_id
|
|
78
78
|
)
|
|
@@ -332,6 +332,12 @@ module RailsErrorDashboard
|
|
|
332
332
|
errors = []
|
|
333
333
|
warnings = []
|
|
334
334
|
|
|
335
|
+
# Block boot with default or blank credentials in production
|
|
336
|
+
if default_credentials? &&
|
|
337
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
|
|
338
|
+
errors << "Default or blank credentials cannot be used in production. Set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD environment variables, or use authenticate_with for custom auth."
|
|
339
|
+
end
|
|
340
|
+
|
|
335
341
|
# Validate sampling_rate (must be between 0.0 and 1.0)
|
|
336
342
|
if sampling_rate && (sampling_rate < 0.0 || sampling_rate > 1.0)
|
|
337
343
|
errors << "sampling_rate must be between 0.0 and 1.0 (got: #{sampling_rate})"
|
|
@@ -529,6 +535,18 @@ module RailsErrorDashboard
|
|
|
529
535
|
true
|
|
530
536
|
end
|
|
531
537
|
|
|
538
|
+
# Check if using default or blank demo credentials with basic auth
|
|
539
|
+
#
|
|
540
|
+
# @return [Boolean] true if basic auth is active with default gandalf/youshallnotpass or blank credentials
|
|
541
|
+
def default_credentials?
|
|
542
|
+
return false unless authenticate_with.nil?
|
|
543
|
+
|
|
544
|
+
default = dashboard_username == "gandalf" && dashboard_password == "youshallnotpass"
|
|
545
|
+
blank = dashboard_username.to_s.strip.empty? || dashboard_password.to_s.strip.empty?
|
|
546
|
+
|
|
547
|
+
default || blank
|
|
548
|
+
end
|
|
549
|
+
|
|
532
550
|
# Get the effective user model (auto-detected if not configured)
|
|
533
551
|
#
|
|
534
552
|
# @return [String, nil] User model class name
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Pure algorithm: Format error details as clean Markdown for LLM debugging
|
|
6
|
+
#
|
|
7
|
+
# Reads data already stored in ErrorLog — zero runtime cost.
|
|
8
|
+
# Called at display time only. Sections are conditional — only included
|
|
9
|
+
# when data is present.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# RailsErrorDashboard::Services::MarkdownErrorFormatter.call(error, related_errors: related)
|
|
13
|
+
# # => "# NoMethodError\n\nundefined method 'foo' for nil\n\n## Backtrace\n\n..."
|
|
14
|
+
class MarkdownErrorFormatter
|
|
15
|
+
MAX_BACKTRACE_LINES = 15
|
|
16
|
+
MAX_BREADCRUMBS = 10
|
|
17
|
+
MAX_VARIABLES = 10
|
|
18
|
+
|
|
19
|
+
# @param error [ErrorLog] An error log record
|
|
20
|
+
# @param related_errors [Array] Related error results with :error and :similarity
|
|
21
|
+
# @return [String] Markdown-formatted error details, or "" on failure
|
|
22
|
+
def self.call(error, related_errors: [])
|
|
23
|
+
new(error, related_errors).generate
|
|
24
|
+
rescue => e
|
|
25
|
+
""
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(error, related_errors)
|
|
29
|
+
@error = error
|
|
30
|
+
@related_errors = related_errors
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [String]
|
|
34
|
+
def generate
|
|
35
|
+
sections = []
|
|
36
|
+
|
|
37
|
+
sections << heading_section
|
|
38
|
+
sections << backtrace_section
|
|
39
|
+
sections << cause_chain_section
|
|
40
|
+
sections << local_variables_section
|
|
41
|
+
sections << instance_variables_section
|
|
42
|
+
sections << request_context_section
|
|
43
|
+
sections << breadcrumbs_section
|
|
44
|
+
sections << environment_section
|
|
45
|
+
sections << system_health_section
|
|
46
|
+
sections << related_errors_section
|
|
47
|
+
sections << metadata_section
|
|
48
|
+
|
|
49
|
+
sections.compact.join("\n\n")
|
|
50
|
+
rescue => e
|
|
51
|
+
""
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def heading_section
|
|
57
|
+
"# #{@error.error_type}\n\n#{@error.message}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def backtrace_section
|
|
61
|
+
raw = @error.backtrace
|
|
62
|
+
return nil if raw.blank?
|
|
63
|
+
|
|
64
|
+
lines = raw.split("\n")
|
|
65
|
+
app_lines = lines.reject { |l| l.include?("/gems/") || l.include?("/ruby/") || l.include?("/vendor/") }
|
|
66
|
+
app_lines = lines.first(MAX_BACKTRACE_LINES) if app_lines.empty?
|
|
67
|
+
app_lines = app_lines.first(MAX_BACKTRACE_LINES)
|
|
68
|
+
|
|
69
|
+
"## Backtrace\n\n```\n#{app_lines.join("\n")}\n```"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def cause_chain_section
|
|
73
|
+
raw = @error.exception_cause
|
|
74
|
+
return nil if raw.blank?
|
|
75
|
+
|
|
76
|
+
causes = parse_json(raw)
|
|
77
|
+
return nil unless causes.is_a?(Array) && causes.any?
|
|
78
|
+
|
|
79
|
+
items = causes.each_with_index.map { |cause, i|
|
|
80
|
+
"#{i + 1}. **#{cause["class_name"]}** — #{cause["message"]}"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
"## Exception Cause Chain\n\n#{items.join("\n")}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def local_variables_section
|
|
87
|
+
raw = @error.local_variables
|
|
88
|
+
return nil if raw.blank?
|
|
89
|
+
|
|
90
|
+
vars = parse_json(raw)
|
|
91
|
+
return nil unless vars.is_a?(Hash) && vars.any?
|
|
92
|
+
|
|
93
|
+
rows = vars.first(MAX_VARIABLES).map { |name, info|
|
|
94
|
+
if info.is_a?(Hash)
|
|
95
|
+
"| #{name} | #{info["type"]} | #{truncate_value(info["value"])} |"
|
|
96
|
+
else
|
|
97
|
+
"| #{name} | — | #{truncate_value(info)} |"
|
|
98
|
+
end
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
"## Local Variables\n\n| Variable | Type | Value |\n|----------|------|-------|\n#{rows.join("\n")}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def instance_variables_section
|
|
105
|
+
raw = @error.instance_variables
|
|
106
|
+
return nil if raw.blank?
|
|
107
|
+
|
|
108
|
+
vars = parse_json(raw)
|
|
109
|
+
return nil unless vars.is_a?(Hash) && vars.any?
|
|
110
|
+
|
|
111
|
+
self_class = vars.delete("_self_class")
|
|
112
|
+
return nil if vars.empty? && self_class.nil?
|
|
113
|
+
|
|
114
|
+
lines = []
|
|
115
|
+
lines << "**Class:** #{self_class}" if self_class
|
|
116
|
+
|
|
117
|
+
if vars.any?
|
|
118
|
+
rows = vars.first(MAX_VARIABLES).map { |name, info|
|
|
119
|
+
if info.is_a?(Hash)
|
|
120
|
+
"| #{name} | #{info["type"]} | #{truncate_value(info["value"])} |"
|
|
121
|
+
else
|
|
122
|
+
"| #{name} | — | #{truncate_value(info)} |"
|
|
123
|
+
end
|
|
124
|
+
}
|
|
125
|
+
lines << "| Variable | Type | Value |\n|----------|------|-------|\n#{rows.join("\n")}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
"## Instance Variables\n\n#{lines.join("\n\n")}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def request_context_section
|
|
132
|
+
return nil if @error.request_url.blank?
|
|
133
|
+
|
|
134
|
+
items = []
|
|
135
|
+
items << "- **Method:** #{@error.http_method}" if @error.http_method.present?
|
|
136
|
+
items << "- **URL:** #{@error.request_url}"
|
|
137
|
+
items << "- **Hostname:** #{@error.hostname}" if @error.hostname.present?
|
|
138
|
+
items << "- **Content-Type:** #{@error.content_type}" if @error.content_type.present?
|
|
139
|
+
items << "- **Duration:** #{@error.request_duration_ms}ms" if @error.request_duration_ms.present?
|
|
140
|
+
items << "- **IP:** #{@error.ip_address}" if @error.ip_address.present?
|
|
141
|
+
|
|
142
|
+
"## Request Context\n\n#{items.join("\n")}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def breadcrumbs_section
|
|
146
|
+
raw = @error.breadcrumbs
|
|
147
|
+
return nil if raw.blank?
|
|
148
|
+
|
|
149
|
+
crumbs = parse_json(raw)
|
|
150
|
+
return nil unless crumbs.is_a?(Array) && crumbs.any?
|
|
151
|
+
|
|
152
|
+
# Take last N breadcrumbs (most recent, closest to error)
|
|
153
|
+
crumbs = crumbs.last(MAX_BREADCRUMBS)
|
|
154
|
+
|
|
155
|
+
rows = crumbs.map { |c|
|
|
156
|
+
time = c["t"] ? Time.at(c["t"] / 1000.0).utc.strftime("%H:%M:%S.%L") : "—"
|
|
157
|
+
duration = c["d"] ? "#{c["d"]}ms" : "—"
|
|
158
|
+
"| #{time} | #{c["c"]} | #{truncate_value(c["m"], 80)} | #{duration} |"
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
"## Breadcrumbs (last #{crumbs.size})\n\n| Time | Category | Message | Duration |\n|------|----------|---------|----------|\n#{rows.join("\n")}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def environment_section
|
|
165
|
+
raw = @error.environment_info
|
|
166
|
+
return nil if raw.blank?
|
|
167
|
+
|
|
168
|
+
env = parse_json(raw)
|
|
169
|
+
return nil unless env.is_a?(Hash) && env.any?
|
|
170
|
+
|
|
171
|
+
items = []
|
|
172
|
+
items << "- **Ruby:** #{env["ruby_version"]}" if env["ruby_version"]
|
|
173
|
+
items << "- **Rails:** #{env["rails_version"]}" if env["rails_version"]
|
|
174
|
+
items << "- **Env:** #{env["rails_env"]}" if env["rails_env"]
|
|
175
|
+
items << "- **Server:** #{env["server"]}" if env["server"]
|
|
176
|
+
items << "- **DB:** #{env["database_adapter"]}" if env["database_adapter"]
|
|
177
|
+
|
|
178
|
+
version_line = []
|
|
179
|
+
version_line << "- **App Version:** #{@error.app_version}" if @error.app_version.present?
|
|
180
|
+
version_line << "- **Git:** #{@error.git_sha}" if @error.git_sha.present?
|
|
181
|
+
items.concat(version_line)
|
|
182
|
+
|
|
183
|
+
return nil if items.empty?
|
|
184
|
+
|
|
185
|
+
"## Environment\n\n#{items.join("\n")}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def system_health_section
|
|
189
|
+
raw = @error.system_health
|
|
190
|
+
return nil if raw.blank?
|
|
191
|
+
|
|
192
|
+
health = parse_json(raw)
|
|
193
|
+
return nil unless health.is_a?(Hash) && health.any?
|
|
194
|
+
|
|
195
|
+
items = []
|
|
196
|
+
items << "- **Memory:** #{health["process_memory_mb"]} MB RSS" if health["process_memory_mb"]
|
|
197
|
+
items << "- **Threads:** #{health["thread_count"]}" if health["thread_count"]
|
|
198
|
+
|
|
199
|
+
pool = health["connection_pool"]
|
|
200
|
+
if pool.is_a?(Hash)
|
|
201
|
+
items << "- **DB Pool:** #{pool["busy"]}/#{pool["size"]} busy" if pool["size"]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
gc = health["gc_stats"]
|
|
205
|
+
if gc.is_a?(Hash)
|
|
206
|
+
items << "- **GC:** #{gc["major_gc_count"]} major cycles" if gc["major_gc_count"]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
return nil if items.empty?
|
|
210
|
+
|
|
211
|
+
"## System Health at Error Time\n\n#{items.join("\n")}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def related_errors_section
|
|
215
|
+
return nil if @related_errors.nil? || @related_errors.empty?
|
|
216
|
+
|
|
217
|
+
items = @related_errors.map { |r|
|
|
218
|
+
pct = (r.similarity * 100).round(1)
|
|
219
|
+
"- `#{r.error.error_type}` — #{r.error.message} (#{pct}% similar, #{r.error.occurrence_count} occurrences)"
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
"## Related Errors\n\n#{items.join("\n")}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def metadata_section
|
|
226
|
+
items = []
|
|
227
|
+
items << "- **Severity:** #{@error.severity}" if @error.severity.present?
|
|
228
|
+
items << "- **Status:** #{@error.status}" if @error.status.present?
|
|
229
|
+
items << "- **Priority:** P#{3 - @error.priority_level}" if @error.priority_level.present?
|
|
230
|
+
items << "- **Platform:** #{@error.platform}" if @error.platform.present?
|
|
231
|
+
items << "- **First seen:** #{@error.first_seen_at&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC")}" if @error.first_seen_at
|
|
232
|
+
items << "- **Occurrences:** #{@error.occurrence_count}" if @error.occurrence_count
|
|
233
|
+
items << "- **Assigned to:** #{@error.assigned_to}" if @error.assigned_to.present?
|
|
234
|
+
|
|
235
|
+
"## Metadata\n\n#{items.join("\n")}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def parse_json(raw)
|
|
239
|
+
return nil if raw.blank?
|
|
240
|
+
JSON.parse(raw)
|
|
241
|
+
rescue JSON::ParserError
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def truncate_value(value, max_length = 200)
|
|
246
|
+
str = value.to_s
|
|
247
|
+
str.length > max_length ? "#{str[0...max_length]}..." : str
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -4,16 +4,21 @@ module RailsErrorDashboard
|
|
|
4
4
|
module Services
|
|
5
5
|
# Pure algorithm: Capture runtime health metrics at error time
|
|
6
6
|
#
|
|
7
|
-
# Captures GC stats, process RSS
|
|
8
|
-
#
|
|
7
|
+
# Captures GC stats, process memory (RSS/swap/peak), thread count, connection pool,
|
|
8
|
+
# Puma stats, job queue, RubyVM/YJIT, ActionCable, file descriptors, system load,
|
|
9
|
+
# system memory pressure, GC context, and TCP connection states.
|
|
10
|
+
#
|
|
11
|
+
# NOT memoized — fresh data every call (unlike EnvironmentSnapshot).
|
|
9
12
|
# Every metric call individually wrapped in rescue => nil.
|
|
10
13
|
#
|
|
11
14
|
# Safety contract (from HOST_APP_SAFETY.md):
|
|
12
|
-
# - Total snapshot < 1ms budget
|
|
15
|
+
# - Total snapshot < 1ms budget (~0.3ms typical on Linux)
|
|
13
16
|
# - NEVER ObjectSpace.each_object or ObjectSpace.count_objects (heap scan)
|
|
14
17
|
# - NEVER Thread.list.map(&:backtrace) (GVL hold)
|
|
15
18
|
# - Thread.list.count only (O(1), safe)
|
|
16
|
-
# - Process
|
|
19
|
+
# - Process/system metrics: Linux procfs ONLY, no fork/subprocess ever
|
|
20
|
+
# - All procfs reads guarded with File.exist? — returns nil on macOS/non-Linux
|
|
21
|
+
# - TCP file size guard (skip if > 1MB) to protect against connection leak scenarios
|
|
17
22
|
# - No new gems, no global state, no Thread.current, no mutex
|
|
18
23
|
class SystemHealthSnapshot
|
|
19
24
|
# Capture current system health metrics
|
|
@@ -27,9 +32,12 @@ module RailsErrorDashboard
|
|
|
27
32
|
|
|
28
33
|
# @return [Hash] Health snapshot
|
|
29
34
|
def capture
|
|
35
|
+
mem = process_memory
|
|
30
36
|
{
|
|
31
37
|
gc: gc_stats,
|
|
32
|
-
|
|
38
|
+
gc_latest: gc_latest,
|
|
39
|
+
process_memory: mem,
|
|
40
|
+
process_memory_mb: mem&.dig(:rss_mb), # backward compat
|
|
33
41
|
thread_count: thread_count,
|
|
34
42
|
connection_pool: connection_pool_stats,
|
|
35
43
|
puma: puma_stats,
|
|
@@ -37,6 +45,10 @@ module RailsErrorDashboard
|
|
|
37
45
|
ruby_vm: ruby_vm_stats,
|
|
38
46
|
yjit: yjit_stats,
|
|
39
47
|
actioncable: actioncable_stats,
|
|
48
|
+
file_descriptors: file_descriptors,
|
|
49
|
+
system_load: system_load,
|
|
50
|
+
system_memory: system_memory,
|
|
51
|
+
tcp_connections: tcp_connections,
|
|
40
52
|
captured_at: Time.current.iso8601
|
|
41
53
|
}
|
|
42
54
|
end
|
|
@@ -55,13 +67,33 @@ module RailsErrorDashboard
|
|
|
55
67
|
nil
|
|
56
68
|
end
|
|
57
69
|
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
# GC.latest_gc_info — context about the most recent GC run
|
|
71
|
+
# Works on all platforms (Ruby API, no procfs)
|
|
72
|
+
def gc_latest
|
|
73
|
+
info = GC.latest_gc_info
|
|
74
|
+
{
|
|
75
|
+
major_by: info[:major_by]&.to_s,
|
|
76
|
+
gc_by: info[:gc_by]&.to_s,
|
|
77
|
+
state: info[:state]&.to_s,
|
|
78
|
+
immediate_sweep: info[:immediate_sweep]
|
|
79
|
+
}
|
|
80
|
+
rescue => e
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Process memory from /proc/self/status — single file read, 4 fields extracted
|
|
85
|
+
# Linux ONLY — returns nil on macOS/non-Linux (~0.02ms)
|
|
86
|
+
def process_memory
|
|
60
87
|
return nil unless File.exist?("/proc/self/status")
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return nil unless
|
|
64
|
-
|
|
88
|
+
status = File.read("/proc/self/status")
|
|
89
|
+
rss = status[/^VmRSS:\s+(\d+)/, 1]&.to_i
|
|
90
|
+
return nil unless rss
|
|
91
|
+
{
|
|
92
|
+
rss_mb: (rss / 1024.0).round(1),
|
|
93
|
+
swap_mb: (status[/^VmSwap:\s+(\d+)/, 1].to_i / 1024.0).round(1),
|
|
94
|
+
rss_peak_mb: (status[/^VmHWM:\s+(\d+)/, 1].to_i / 1024.0).round(1),
|
|
95
|
+
os_threads: status[/^Threads:\s+(\d+)/, 1]&.to_i
|
|
96
|
+
}
|
|
65
97
|
rescue => e
|
|
66
98
|
nil
|
|
67
99
|
end
|
|
@@ -186,6 +218,88 @@ module RailsErrorDashboard
|
|
|
186
218
|
rescue => e
|
|
187
219
|
nil
|
|
188
220
|
end
|
|
221
|
+
|
|
222
|
+
# File descriptor count vs ulimit — detects FD exhaustion
|
|
223
|
+
# Linux ONLY — /proc/self/fd (~0.05ms)
|
|
224
|
+
def file_descriptors
|
|
225
|
+
return nil unless File.exist?("/proc/self/fd")
|
|
226
|
+
open_count = Dir.children("/proc/self/fd").size
|
|
227
|
+
soft_limit, _hard = Process.getrlimit(:NOFILE)
|
|
228
|
+
{
|
|
229
|
+
open: open_count,
|
|
230
|
+
limit: soft_limit,
|
|
231
|
+
utilization_pct: soft_limit > 0 ? (open_count.to_f / soft_limit * 100).round(1) : nil
|
|
232
|
+
}
|
|
233
|
+
rescue => e
|
|
234
|
+
nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# System load averages from /proc/loadavg + CPU count
|
|
238
|
+
# Linux ONLY — returns nil on macOS (~0.01ms)
|
|
239
|
+
def system_load
|
|
240
|
+
return nil unless File.exist?("/proc/loadavg")
|
|
241
|
+
parts = File.read("/proc/loadavg").split
|
|
242
|
+
require "etc" unless defined?(Etc)
|
|
243
|
+
cpu_count = Etc.nprocessors rescue nil
|
|
244
|
+
load_1m = parts[0].to_f
|
|
245
|
+
{
|
|
246
|
+
load_1m: load_1m,
|
|
247
|
+
load_5m: parts[1].to_f,
|
|
248
|
+
load_15m: parts[2].to_f,
|
|
249
|
+
cpu_count: cpu_count,
|
|
250
|
+
load_ratio: cpu_count && cpu_count > 0 ? (load_1m / cpu_count).round(2) : nil
|
|
251
|
+
}
|
|
252
|
+
rescue => e
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# System-wide memory pressure from /proc/meminfo
|
|
257
|
+
# Linux ONLY — returns nil on macOS (~0.02ms)
|
|
258
|
+
def system_memory
|
|
259
|
+
return nil unless File.exist?("/proc/meminfo")
|
|
260
|
+
meminfo = File.read("/proc/meminfo")
|
|
261
|
+
total = meminfo[/^MemTotal:\s+(\d+)/, 1]&.to_i
|
|
262
|
+
available = meminfo[/^MemAvailable:\s+(\d+)/, 1]&.to_i
|
|
263
|
+
swap_total = meminfo[/^SwapTotal:\s+(\d+)/, 1]&.to_i
|
|
264
|
+
swap_free = meminfo[/^SwapFree:\s+(\d+)/, 1]&.to_i
|
|
265
|
+
{
|
|
266
|
+
total_mb: total ? (total / 1024.0).round(0) : nil,
|
|
267
|
+
available_mb: available ? (available / 1024.0).round(0) : nil,
|
|
268
|
+
used_pct: total && available && total > 0 ? ((1 - available.to_f / total) * 100).round(1) : nil,
|
|
269
|
+
swap_used_mb: swap_total && swap_free ? ((swap_total - swap_free) / 1024.0).round(0) : nil
|
|
270
|
+
}
|
|
271
|
+
rescue => e
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# TCP connection states from /proc/self/net/tcp (+tcp6)
|
|
276
|
+
# Linux ONLY — returns nil on macOS (~0.05ms typical)
|
|
277
|
+
# Safety: skips if file > 1MB (protects against connection leak scenarios)
|
|
278
|
+
def tcp_connections
|
|
279
|
+
path = "/proc/self/net/tcp"
|
|
280
|
+
return nil unless File.exist?(path)
|
|
281
|
+
return nil if File.size(path) > 1_048_576
|
|
282
|
+
lines = File.readlines(path).drop(1)
|
|
283
|
+
states = lines.map { |l| l.strip.split[3] }
|
|
284
|
+
result = {
|
|
285
|
+
established: states.count("01"),
|
|
286
|
+
close_wait: states.count("08"),
|
|
287
|
+
time_wait: states.count("06"),
|
|
288
|
+
listen: states.count("0A")
|
|
289
|
+
}
|
|
290
|
+
path6 = "/proc/self/net/tcp6"
|
|
291
|
+
if File.exist?(path6) && File.size(path6) <= 1_048_576
|
|
292
|
+
lines6 = File.readlines(path6).drop(1)
|
|
293
|
+
states6 = lines6.map { |l| l.strip.split[3] }
|
|
294
|
+
result[:established] += states6.count("01")
|
|
295
|
+
result[:close_wait] += states6.count("08")
|
|
296
|
+
result[:time_wait] += states6.count("06")
|
|
297
|
+
result[:listen] += states6.count("0A")
|
|
298
|
+
end
|
|
299
|
+
result
|
|
300
|
+
rescue => e
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
189
303
|
end
|
|
190
304
|
end
|
|
191
305
|
end
|
|
@@ -55,6 +55,7 @@ require "rails_error_dashboard/services/breadcrumb_collector"
|
|
|
55
55
|
require "rails_error_dashboard/services/n_plus_one_detector"
|
|
56
56
|
require "rails_error_dashboard/services/curl_generator"
|
|
57
57
|
require "rails_error_dashboard/services/rspec_generator"
|
|
58
|
+
require "rails_error_dashboard/services/markdown_error_formatter"
|
|
58
59
|
require "rails_error_dashboard/services/database_health_inspector"
|
|
59
60
|
require "rails_error_dashboard/services/cache_analyzer"
|
|
60
61
|
require "rails_error_dashboard/services/variable_serializer"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_error_dashboard
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anjan Jagirdar
|
|
@@ -340,6 +340,7 @@ files:
|
|
|
340
340
|
- db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb
|
|
341
341
|
- db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb
|
|
342
342
|
- db/migrate/20260323000001_add_muted_to_error_logs.rb
|
|
343
|
+
- db/migrate/20260325000001_fix_swallowed_exceptions_index_for_mysql.rb
|
|
343
344
|
- lib/generators/rails_error_dashboard/install/install_generator.rb
|
|
344
345
|
- lib/generators/rails_error_dashboard/install/templates/README
|
|
345
346
|
- lib/generators/rails_error_dashboard/install/templates/initializer.rb
|
|
@@ -428,6 +429,7 @@ files:
|
|
|
428
429
|
- lib/rails_error_dashboard/services/git_blame_reader.rb
|
|
429
430
|
- lib/rails_error_dashboard/services/github_link_generator.rb
|
|
430
431
|
- lib/rails_error_dashboard/services/local_variable_capturer.rb
|
|
432
|
+
- lib/rails_error_dashboard/services/markdown_error_formatter.rb
|
|
431
433
|
- lib/rails_error_dashboard/services/n_plus_one_detector.rb
|
|
432
434
|
- lib/rails_error_dashboard/services/notification_helpers.rb
|
|
433
435
|
- lib/rails_error_dashboard/services/notification_throttler.rb
|
|
@@ -465,7 +467,7 @@ metadata:
|
|
|
465
467
|
bug_tracker_uri: https://github.com/AnjanJ/rails_error_dashboard/issues
|
|
466
468
|
funding_uri: https://buymeacoffee.com/anjanj
|
|
467
469
|
post_install_message: "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n
|
|
468
|
-
\ Rails Error Dashboard v0.5.
|
|
470
|
+
\ Rails Error Dashboard v0.5.3\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n\U0001F195
|
|
469
471
|
First time? Quick start:\n rails generate rails_error_dashboard:install\n rails
|
|
470
472
|
db:migrate\n # Add to config/routes.rb:\n mount RailsErrorDashboard::Engine
|
|
471
473
|
=> '/error_dashboard'\n\n\U0001F504 Upgrading from v0.1.x?\n rails db:migrate\n
|