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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9cbc9542b74dde89d9ef8e71bc6d55dc6c15b1bc1d8c0decc3181f83a39ab085
4
- data.tar.gz: 25402cf18e90cbe62350700875c61cd334ae2798838892b5a0861b8fd2864abc
3
+ metadata.gz: 85ee33f8363a0a875c4c00bd2242a7d282dba7502cc78cf8af84a4583f376121
4
+ data.tar.gz: 51183380c9642cbabc19582daa191ee7ee4c161cc2c98fbc3dd6693459e91b40
5
5
  SHA512:
6
- metadata.gz: 8baf9b17c79ba1ec2ac2a2cb1fca9044a5ef995a69993321014dfbd9de1c75f1dd6084deb6a4a35a40ce8d25679bb5557ef2e5a198280dd8d1ba42f13adc8625
7
- data.tar.gz: 1603904cb93c8d7902530e3768d62dd60c747138f40a9dad315c111c0d67c51e843859624eca512234ff0a123887ffdde1568dab15753d942725e998decdcb15
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,600+ tests passing), but the API may change before v1.0. Supports Rails 7.0-8.1 and Ruby 3.2-4.0.
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, and YJIT compilation stats captured automatically.
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 or generate an RSpec test from the captured error context.
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[:process_memory_mb] %>
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[:thread_count] %>
348
+ <% if health[:file_descriptors] %>
349
+ <% fd = health[:file_descriptors] %>
313
350
  <div class="mb-1">
314
- <small class="text-muted">Threads:</small>
315
- <code class="ms-1"><%= health[:thread_count] %></code>
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: 500
213
- t.string :rescue_location, limit: 500
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: 500
8
- t.string :rescue_location, limit: 500
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 which migrations are already installed (by descriptive name, ignoring timestamp)
347
- existing = Dir.glob(File.join(target_dir, "*rails_error_dashboard*.rb")).map { |f|
348
- File.basename(f).sub(/^\d+_/, "")
349
- }.to_set
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, 255),
56
- raise_location: truncate(location, 500),
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, 255),
74
- raise_location: truncate(raise_loc, 500),
75
- rescue_location: rescue_loc.present? ? truncate(rescue_loc, 500) : nil,
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 memory, thread count, connection pool stats,
8
- # and Puma stats. NOT memoized fresh data every call (unlike EnvironmentSnapshot).
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 memory: Linux procfs ONLY, no fork/subprocess ever
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
- process_memory_mb: process_memory_mb,
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
- def process_memory_mb
59
- # Linux ONLY procfs read, no fork, ~0.01ms
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
- content = File.read("/proc/self/status")
62
- match = content.match(/VmRSS:\s+(\d+)\s+kB/)
63
- return nil unless match
64
- (match[1].to_i / 1024.0).round(1)
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
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.5.1"
2
+ VERSION = "0.5.3"
3
3
  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.1
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.1\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n\U0001F195
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