rails_error_dashboard 0.5.0 → 0.5.2

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: af41ac0baefaf436b68c2db7ac1598a15f409cb76efbbf2eb8fb2daa9ceaab92
4
- data.tar.gz: 22073a8675b855c06622dc34ae649608af129f57f5c391c9562c20bd95db53ec
3
+ metadata.gz: d1826105c19a4a203cf377fca2041763849b7b2ff7bb8a9f3a18601e22150f6b
4
+ data.tar.gz: fdabbc92770c81339e7499d739e941f4f2a7f3fda381a2a55ec4dcab5bb757cc
5
5
  SHA512:
6
- metadata.gz: badaacf9fb78a5d93ee3d545d93877cc59c8685f8800567ad6f84979cec365baebd3f3193b905bbf73b67f99c70308c2480043bfb78c38f0a1b149637f1595c4
7
- data.tar.gz: 548db884ddfcb084293083c3e8b787bdc50d0fd2128b8f68bdc315f578199ce9c36ef805a038657d437864055ee45e9f5bfc41ef6386be6e5b3c750be40ed238
6
+ metadata.gz: 6c365ae991e67cb59dd9e62deaabe694e06c7e9b64896a75bfa36473f3c735556c95482a929e8ade0328ffab05e10128e5d5e6832c94ead44cfffcf6567c05aa
7
+ data.tar.gz: 6b63c5a587bf93ec1599571343c8fcaf8230364266d97564f59217d8308632817557a05fec2398f3e3557ca33d83dbc9968c3f820722e3b1395e046cd58de15a
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
@@ -1664,6 +1664,13 @@ body.dark-mode .sidebar-section-body { background: var(--ctp-mantle); }
1664
1664
  <% end %>
1665
1665
  </li>
1666
1666
  <% end %>
1667
+ <% if RailsErrorDashboard.configuration.enable_actioncable_tracking && RailsErrorDashboard.configuration.enable_breadcrumbs %>
1668
+ <li class="nav-item">
1669
+ <%= link_to actioncable_health_summary_errors_path(nav_params), class: "nav-link #{request.path == actioncable_health_summary_errors_path ? 'active' : ''}" do %>
1670
+ <i class="bi bi-broadcast"></i> ActionCable
1671
+ <% end %>
1672
+ </li>
1673
+ <% end %>
1667
1674
  <% if RailsErrorDashboard.configuration.enable_system_health %>
1668
1675
  <li class="nav-item">
1669
1676
  <%= link_to job_health_summary_errors_path(nav_params), class: "nav-link #{request.path == job_health_summary_errors_path ? 'active' : ''}" do %>
@@ -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">
@@ -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
237
+ if content.match?(/config\.use_separate_database\s*=\s*true/)
238
+ @database_mode = :separate
239
+ @database_name = content[/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
 
@@ -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.0"
2
+ VERSION = "0.5.2"
3
3
  end
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.0
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anjan Jagirdar
@@ -465,7 +465,7 @@ metadata:
465
465
  bug_tracker_uri: https://github.com/AnjanJ/rails_error_dashboard/issues
466
466
  funding_uri: https://buymeacoffee.com/anjanj
467
467
  post_install_message: "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n
468
- \ Rails Error Dashboard v0.5.0\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n\U0001F195
468
+ \ Rails Error Dashboard v0.5.2\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n\U0001F195
469
469
  First time? Quick start:\n rails generate rails_error_dashboard:install\n rails
470
470
  db:migrate\n # Add to config/routes.rb:\n mount RailsErrorDashboard::Engine
471
471
  => '/error_dashboard'\n\n\U0001F504 Upgrading from v0.1.x?\n rails db:migrate\n