rails_error_dashboard 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +96 -14
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +139 -1
  4. data/app/helpers/rails_error_dashboard/application_helper.rb +25 -0
  5. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +18 -6
  6. data/app/views/layouts/rails_error_dashboard.html.erb +157 -1
  7. data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +236 -0
  8. data/app/views/rails_error_dashboard/errors/_co_occurring_errors.html.erb +70 -0
  9. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +107 -0
  10. data/app/views/rails_error_dashboard/errors/_error_cascades.html.erb +138 -0
  11. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +190 -0
  12. data/app/views/rails_error_dashboard/errors/_modals.html.erb +139 -0
  13. data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +1 -1
  14. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +108 -0
  15. data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +156 -0
  16. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +352 -0
  17. data/app/views/rails_error_dashboard/errors/_similar_errors.html.erb +75 -0
  18. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +1 -1
  19. data/app/views/rails_error_dashboard/errors/cache_health_summary.html.erb +143 -0
  20. data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +450 -0
  21. data/app/views/rails_error_dashboard/errors/deprecations.html.erb +129 -0
  22. data/app/views/rails_error_dashboard/errors/job_health_summary.html.erb +152 -0
  23. data/app/views/rails_error_dashboard/errors/n_plus_one_summary.html.erb +134 -0
  24. data/app/views/rails_error_dashboard/errors/settings.html.erb +17 -0
  25. data/app/views/rails_error_dashboard/errors/show.html.erb +20 -1132
  26. data/config/routes.rb +5 -0
  27. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +6 -0
  28. data/db/migrate/20260303000001_add_breadcrumbs_to_error_logs.rb +9 -0
  29. data/db/migrate/20260304000001_add_system_health_to_error_logs.rb +12 -0
  30. data/lib/generators/rails_error_dashboard/install/install_generator.rb +31 -3
  31. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +67 -5
  32. data/lib/rails_error_dashboard/commands/log_error.rb +33 -0
  33. data/lib/rails_error_dashboard/configuration.rb +45 -3
  34. data/lib/rails_error_dashboard/engine.rb +6 -1
  35. data/lib/rails_error_dashboard/middleware/error_catcher.rb +8 -0
  36. data/lib/rails_error_dashboard/queries/cache_health_summary.rb +72 -0
  37. data/lib/rails_error_dashboard/queries/database_health_summary.rb +82 -0
  38. data/lib/rails_error_dashboard/queries/deprecation_warnings.rb +80 -0
  39. data/lib/rails_error_dashboard/queries/job_health_summary.rb +101 -0
  40. data/lib/rails_error_dashboard/queries/n_plus_one_summary.rb +83 -0
  41. data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +182 -0
  42. data/lib/rails_error_dashboard/services/cache_analyzer.rb +76 -0
  43. data/lib/rails_error_dashboard/services/curl_generator.rb +80 -0
  44. data/lib/rails_error_dashboard/services/database_health_inspector.rb +168 -0
  45. data/lib/rails_error_dashboard/services/n_plus_one_detector.rb +74 -0
  46. data/lib/rails_error_dashboard/services/rspec_generator.rb +145 -0
  47. data/lib/rails_error_dashboard/services/system_health_snapshot.rb +145 -0
  48. data/lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb +210 -0
  49. data/lib/rails_error_dashboard/version.rb +1 -1
  50. data/lib/rails_error_dashboard.rb +24 -0
  51. data/lib/tasks/error_dashboard.rake +68 -2
  52. metadata +33 -2
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # Query: Aggregate job queue health stats from system_health across all errors
6
+ # Scans error_logs system_health JSON, extracts job_queue data per error
7
+ class JobHealthSummary
8
+ def self.call(days = 30, application_id: nil)
9
+ new(days, application_id: application_id).call
10
+ end
11
+
12
+ def initialize(days = 30, application_id: nil)
13
+ @days = days
14
+ @application_id = application_id
15
+ @start_date = days.days.ago
16
+ end
17
+
18
+ def call
19
+ {
20
+ entries: aggregated_entries
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def base_query
27
+ scope = ErrorLog.where("occurred_at >= ?", @start_date)
28
+ .where.not(system_health: nil)
29
+ scope = scope.where(application_id: @application_id) if @application_id.present?
30
+ scope
31
+ end
32
+
33
+ def aggregated_entries
34
+ results = []
35
+
36
+ base_query.select(:id, :system_health, :occurred_at).find_each(batch_size: 500) do |error_log|
37
+ health = parse_system_health(error_log.system_health)
38
+ next if health.blank?
39
+
40
+ job_queue = health["job_queue"]
41
+ next if job_queue.blank?
42
+
43
+ adapter = job_queue["adapter"]
44
+ next if adapter.blank?
45
+
46
+ results << build_entry(error_log, job_queue, adapter)
47
+ end
48
+
49
+ # Sort by failed count descending (worst first)
50
+ results.sort_by { |r| -(r[:failed] || 0) }
51
+ rescue => e
52
+ Rails.logger.error("[RailsErrorDashboard] JobHealthSummary query failed: #{e.class}: #{e.message}")
53
+ []
54
+ end
55
+
56
+ def build_entry(error_log, job_queue, adapter)
57
+ entry = {
58
+ error_id: error_log.id,
59
+ adapter: adapter,
60
+ occurred_at: error_log.occurred_at
61
+ }
62
+
63
+ case adapter
64
+ when "sidekiq"
65
+ entry.merge!(
66
+ enqueued: job_queue["enqueued"],
67
+ processed: job_queue["processed"],
68
+ failed: job_queue["failed"],
69
+ dead: job_queue["dead"],
70
+ scheduled: job_queue["scheduled"],
71
+ retry: job_queue["retry"],
72
+ workers: job_queue["workers"]
73
+ )
74
+ when "solid_queue"
75
+ entry.merge!(
76
+ ready: job_queue["ready"],
77
+ scheduled: job_queue["scheduled"],
78
+ claimed: job_queue["claimed"],
79
+ failed: job_queue["failed"],
80
+ blocked: job_queue["blocked"]
81
+ )
82
+ when "good_job"
83
+ entry.merge!(
84
+ queued: job_queue["queued"],
85
+ errored: job_queue["errored"],
86
+ finished: job_queue["finished"]
87
+ )
88
+ end
89
+
90
+ entry
91
+ end
92
+
93
+ def parse_system_health(raw)
94
+ return nil if raw.blank?
95
+ JSON.parse(raw)
96
+ rescue JSON::ParserError
97
+ nil
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # Query: Aggregate N+1 query patterns from breadcrumbs across all errors
6
+ # Scans error_logs breadcrumbs JSON, runs NplusOneDetector per error, and groups by fingerprint
7
+ class NplusOneSummary
8
+ def self.call(days = 30, application_id: nil)
9
+ new(days, application_id: application_id).call
10
+ end
11
+
12
+ def initialize(days = 30, application_id: nil)
13
+ @days = days
14
+ @application_id = application_id
15
+ @start_date = days.days.ago
16
+ end
17
+
18
+ def call
19
+ {
20
+ patterns: aggregated_patterns
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def base_query
27
+ scope = ErrorLog.where("occurred_at >= ?", @start_date)
28
+ .where.not(breadcrumbs: nil)
29
+ scope = scope.where(application_id: @application_id) if @application_id.present?
30
+ scope
31
+ end
32
+
33
+ def aggregated_patterns
34
+ results = {}
35
+
36
+ base_query.select(:id, :breadcrumbs, :occurred_at).find_each(batch_size: 500) do |error_log|
37
+ crumbs = parse_breadcrumbs(error_log.breadcrumbs)
38
+ next if crumbs.empty?
39
+
40
+ patterns = Services::NplusOneDetector.call(crumbs)
41
+ next if patterns.empty?
42
+
43
+ patterns.each do |pattern|
44
+ fingerprint = pattern[:fingerprint]
45
+
46
+ if results[fingerprint]
47
+ results[fingerprint][:count] += pattern[:count]
48
+ results[fingerprint][:total_duration_ms] += pattern[:total_duration_ms]
49
+ results[fingerprint][:error_ids] << error_log.id
50
+ results[fingerprint][:last_seen] = [ results[fingerprint][:last_seen], error_log.occurred_at ].compact.max
51
+ else
52
+ results[fingerprint] = {
53
+ fingerprint: fingerprint,
54
+ sample_query: pattern[:sample_query],
55
+ count: pattern[:count],
56
+ error_ids: [ error_log.id ],
57
+ total_duration_ms: pattern[:total_duration_ms],
58
+ last_seen: error_log.occurred_at
59
+ }
60
+ end
61
+ end
62
+ end
63
+
64
+ results.values.each do |r|
65
+ r[:error_ids] = r[:error_ids].uniq
66
+ r[:error_count] = r[:error_ids].size
67
+ r[:total_duration_ms] = r[:total_duration_ms].round(2)
68
+ end
69
+ results.values.sort_by { |r| -r[:count] }
70
+ rescue => e
71
+ Rails.logger.error("[RailsErrorDashboard] NplusOneSummary query failed: #{e.class}: #{e.message}")
72
+ []
73
+ end
74
+
75
+ def parse_breadcrumbs(raw)
76
+ return [] if raw.blank?
77
+ JSON.parse(raw)
78
+ rescue JSON::ParserError
79
+ []
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Pure service: Collect breadcrumbs (request activity trail) for error context
6
+ #
7
+ # Uses a thread-local ring buffer to capture events during a request lifecycle.
8
+ # Thread.current isolation means no mutex/lock is needed.
9
+ #
10
+ # SAFETY RULES (HOST_APP_SAFETY.md):
11
+ # - Every public method wrapped in rescue => e; nil
12
+ # - Never raise, never block, never allocate large objects
13
+ # - Messages truncated to 500 chars, metadata values to 200 chars
14
+ # - Ring buffer has fixed max size (no unbounded growth)
15
+ class BreadcrumbCollector
16
+ THREAD_KEY = :red_breadcrumbs
17
+ MAX_MESSAGE_LENGTH = 500
18
+ MAX_METADATA_VALUE_LENGTH = 200
19
+ MAX_METADATA_KEYS = 10
20
+
21
+ # Fixed-size ring buffer — O(1) append, wraps around when full
22
+ class RingBuffer
23
+ def initialize(max_size)
24
+ @max_size = max_size
25
+ @buffer = Array.new(max_size)
26
+ @write_pos = 0
27
+ @count = 0
28
+ end
29
+
30
+ def add(entry)
31
+ @buffer[@write_pos] = entry
32
+ @write_pos = (@write_pos + 1) % @max_size
33
+ @count += 1 if @count < @max_size
34
+ end
35
+
36
+ def to_a
37
+ return [] if @count == 0
38
+
39
+ if @count < @max_size
40
+ @buffer[0...@count]
41
+ else
42
+ # Buffer has wrapped — read from write_pos to end, then start to write_pos
43
+ @buffer[@write_pos...@max_size] + @buffer[0...@write_pos]
44
+ end
45
+ end
46
+
47
+ def clear
48
+ @buffer = Array.new(@max_size)
49
+ @write_pos = 0
50
+ @count = 0
51
+ end
52
+ end
53
+
54
+ # Initialize a new ring buffer for the current thread (start of request)
55
+ def self.init_buffer
56
+ size = RailsErrorDashboard.configuration.breadcrumb_buffer_size || 40
57
+ Thread.current[THREAD_KEY] = RingBuffer.new(size)
58
+ rescue => e
59
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.init_buffer failed: #{e.message}")
60
+ nil
61
+ end
62
+
63
+ # Clear the ring buffer (end of request — MUST be called in ensure block)
64
+ def self.clear_buffer
65
+ Thread.current[THREAD_KEY] = nil
66
+ rescue => e
67
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.clear_buffer failed: #{e.message}")
68
+ nil
69
+ end
70
+
71
+ # Add a breadcrumb to the current buffer
72
+ # @param category [String] Event category (sql, controller, cache, job, mailer, custom)
73
+ # @param message [String] Human-readable description
74
+ # @param duration_ms [Float, nil] Duration in milliseconds
75
+ # @param metadata [Hash, nil] Optional key-value pairs
76
+ def self.add(category, message, duration_ms: nil, metadata: nil)
77
+ buffer = Thread.current[THREAD_KEY]
78
+ return unless buffer
79
+
80
+ # Check category filter
81
+ allowed = RailsErrorDashboard.configuration.breadcrumb_categories
82
+ if allowed
83
+ cat_sym = category.to_s.to_sym
84
+ return unless allowed.include?(cat_sym)
85
+ end
86
+
87
+ # Build breadcrumb entry with compact keys
88
+ entry = {
89
+ t: (Time.now.to_f * 1000).to_i,
90
+ c: category.to_s,
91
+ m: truncate_message(message)
92
+ }
93
+
94
+ entry[:d] = duration_ms if duration_ms
95
+ entry[:meta] = truncate_metadata(metadata) if metadata.is_a?(Hash)
96
+
97
+ buffer.add(entry)
98
+ rescue => e
99
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.add failed: #{e.message}")
100
+ nil
101
+ end
102
+
103
+ # Harvest breadcrumbs from the current buffer and clear it
104
+ # @return [Array<Hash>] Array of breadcrumb hashes (empty if none)
105
+ def self.harvest
106
+ buffer = Thread.current[THREAD_KEY]
107
+ return [] unless buffer
108
+
109
+ result = buffer.to_a
110
+ buffer.clear
111
+ result
112
+ rescue => e
113
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.harvest failed: #{e.message}")
114
+ []
115
+ end
116
+
117
+ # Get the current buffer (for inspection)
118
+ # @return [RingBuffer, nil]
119
+ def self.current_buffer
120
+ Thread.current[THREAD_KEY]
121
+ rescue => e
122
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.current_buffer failed: #{e.message}")
123
+ nil
124
+ end
125
+
126
+ # Filter sensitive data from breadcrumbs before storage
127
+ # Reuses existing SensitiveDataFilter — no new filter logic
128
+ # @param breadcrumbs [Array<Hash>] Raw breadcrumbs
129
+ # @return [Array<Hash>] Filtered breadcrumbs
130
+ def self.filter_sensitive(breadcrumbs)
131
+ return [] unless breadcrumbs.is_a?(Array)
132
+ return breadcrumbs unless RailsErrorDashboard.configuration.filter_sensitive_data
133
+
134
+ filter = SensitiveDataFilter.parameter_filter
135
+ return breadcrumbs unless filter
136
+
137
+ breadcrumbs.map do |crumb|
138
+ filtered = crumb.dup
139
+
140
+ # Filter message (SQL queries, key=value patterns)
141
+ if filtered[:m]
142
+ filtered[:m] = SensitiveDataFilter.send(:filter_message, filter, filtered[:m])
143
+ end
144
+
145
+ # Filter metadata values
146
+ if filtered[:meta].is_a?(Hash)
147
+ filtered[:meta] = filter.filter(filtered[:meta])
148
+ end
149
+
150
+ filtered
151
+ end
152
+ rescue => e
153
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.filter_sensitive failed: #{e.message}")
154
+ breadcrumbs.is_a?(Array) ? breadcrumbs : []
155
+ end
156
+
157
+ # Truncate message to MAX_MESSAGE_LENGTH
158
+ def self.truncate_message(message)
159
+ str = message.to_s
160
+ str.length > MAX_MESSAGE_LENGTH ? str[0, MAX_MESSAGE_LENGTH] : str
161
+ rescue => e
162
+ ""
163
+ end
164
+ private_class_method :truncate_message
165
+
166
+ # Truncate metadata: limit keys and value lengths
167
+ def self.truncate_metadata(metadata)
168
+ return {} unless metadata.is_a?(Hash)
169
+
170
+ result = {}
171
+ metadata.first(MAX_METADATA_KEYS).each do |key, value|
172
+ str_value = value.to_s
173
+ result[key] = str_value.length > MAX_METADATA_VALUE_LENGTH ? str_value[0, MAX_METADATA_VALUE_LENGTH] : str_value
174
+ end
175
+ result
176
+ rescue => e
177
+ {}
178
+ end
179
+ private_class_method :truncate_metadata
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Pure algorithm: Analyze cache breadcrumbs at display time
6
+ #
7
+ # Operates on already-captured breadcrumb data — zero runtime cost.
8
+ # Called at display time only. Similar pattern to NplusOneDetector.
9
+ #
10
+ # @example
11
+ # RailsErrorDashboard::Services::CacheAnalyzer.call(breadcrumbs)
12
+ # # => { reads: 5, writes: 2, hits: 3, misses: 1, unknown: 1,
13
+ # # hit_rate: 75.0, total_duration_ms: 12.5,
14
+ # # slowest: { message: "cache read: users/1", duration_ms: 5.2 } }
15
+ class CacheAnalyzer
16
+ # @param breadcrumbs [Array<Hash>] Parsed breadcrumb array
17
+ # @return [Hash, nil] Cache analysis summary, or nil if no cache breadcrumbs
18
+ def self.call(breadcrumbs)
19
+ return nil unless breadcrumbs.is_a?(Array)
20
+
21
+ cache_crumbs = breadcrumbs.select { |c| c["c"] == "cache" }
22
+ return nil if cache_crumbs.empty?
23
+
24
+ reads = 0
25
+ writes = 0
26
+ hits = 0
27
+ misses = 0
28
+ unknown = 0
29
+ total_duration = 0.0
30
+ slowest = nil
31
+
32
+ cache_crumbs.each do |crumb|
33
+ message = crumb["m"].to_s
34
+ duration = crumb["d"].to_f
35
+
36
+ if message.start_with?("cache read:")
37
+ reads += 1
38
+ hit_status = crumb.dig("meta", "hit")
39
+ if hit_status.nil?
40
+ unknown += 1
41
+ elsif hit_status == true || hit_status == "true"
42
+ hits += 1
43
+ else
44
+ misses += 1
45
+ end
46
+ elsif message.start_with?("cache write:")
47
+ writes += 1
48
+ end
49
+
50
+ total_duration += duration
51
+
52
+ if slowest.nil? || duration > slowest[:duration_ms]
53
+ slowest = { message: message, duration_ms: duration }
54
+ end
55
+ end
56
+
57
+ # Only calculate hit_rate if we have read breadcrumbs with known hit status
58
+ known_reads = hits + misses
59
+ hit_rate = known_reads > 0 ? (hits.to_f / known_reads * 100).round(1) : nil
60
+
61
+ {
62
+ reads: reads,
63
+ writes: writes,
64
+ hits: hits,
65
+ misses: misses,
66
+ unknown: unknown,
67
+ hit_rate: hit_rate,
68
+ total_duration_ms: total_duration.round(1),
69
+ slowest: slowest
70
+ }
71
+ rescue => e
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Pure algorithm: Assemble a curl command from an error log's request data
6
+ #
7
+ # Operates on data already stored in ErrorLog — zero runtime cost.
8
+ # Called at display time only.
9
+ #
10
+ # @example
11
+ # RailsErrorDashboard::Services::CurlGenerator.call(error)
12
+ # # => "curl -X POST 'https://example.com/users' -H 'Content-Type: application/json' -d '{\"name\":\"test\"}'"
13
+ class CurlGenerator
14
+ BODY_METHODS = %w[ POST PUT PATCH ].freeze
15
+
16
+ # @param error [ErrorLog] An error log record
17
+ # @return [String] curl command string, or "" if insufficient data
18
+ def self.call(error)
19
+ new(error).generate
20
+ rescue => e
21
+ ""
22
+ end
23
+
24
+ def initialize(error)
25
+ @error = error
26
+ end
27
+
28
+ # @return [String]
29
+ def generate
30
+ url = build_url
31
+ return "" if url.blank?
32
+
33
+ parts = [ "curl" ]
34
+
35
+ method = @error.respond_to?(:http_method) && @error.http_method.presence
36
+ parts << "-X #{method}" if method && method != "GET"
37
+
38
+ parts << shell_quote(url)
39
+
40
+ content_type = @error.respond_to?(:content_type) && @error.content_type.presence
41
+ parts << "-H #{shell_quote("Content-Type: #{content_type}")}" if content_type
42
+
43
+ user_agent = @error.respond_to?(:user_agent) && @error.user_agent.presence
44
+ parts << "-H #{shell_quote("User-Agent: #{user_agent}")}" if user_agent
45
+
46
+ if method && BODY_METHODS.include?(method.to_s.upcase)
47
+ body = @error.respond_to?(:request_params) && @error.request_params.presence
48
+ parts << "-d #{shell_quote(body)}" if body
49
+ end
50
+
51
+ parts.join(" ")
52
+ rescue => e
53
+ ""
54
+ end
55
+
56
+ private
57
+
58
+ def build_url
59
+ request_url = @error.respond_to?(:request_url) && @error.request_url.presence
60
+ return nil unless request_url
61
+
62
+ # If request_url is already a full URL, use it directly
63
+ return request_url if request_url.start_with?("http://", "https://")
64
+
65
+ # Otherwise, prepend hostname
66
+ hostname = @error.respond_to?(:hostname) && @error.hostname.presence
67
+ return nil unless hostname
68
+
69
+ scheme = hostname.include?("localhost") ? "http" : "https"
70
+ "#{scheme}://#{hostname}#{request_url}"
71
+ end
72
+
73
+ def shell_quote(str)
74
+ # Replace ' with '\'' (end quote, escaped quote, start quote)
75
+ escaped = str.to_s.gsub("'") { "'\\''" }
76
+ "'#{escaped}'"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Display-time only service: queries PostgreSQL system tables when the
6
+ # DB Health page loads. NOT used in the capture path.
7
+ #
8
+ # Feature-detects PostgreSQL — returns nil for tables/indexes/activity
9
+ # on SQLite/MySQL. Connection pool stats work on all adapters.
10
+ # Every method individually rescue-wrapped (returns nil).
11
+ class DatabaseHealthInspector
12
+ def self.call
13
+ new.call
14
+ rescue => e
15
+ Rails.logger.error("[RailsErrorDashboard] DatabaseHealthInspector failed: #{e.class}: #{e.message}")
16
+ { adapter: nil, postgresql: false, connection_pool: nil, tables: nil,
17
+ indexes: nil, unused_indexes: nil, activity: nil }
18
+ end
19
+
20
+ def call
21
+ {
22
+ adapter: adapter_name,
23
+ postgresql: postgresql?,
24
+ connection_pool: connection_pool_stats,
25
+ tables: postgresql? ? table_stats : nil,
26
+ indexes: postgresql? ? index_stats : nil,
27
+ unused_indexes: postgresql? ? unused_index_stats : nil,
28
+ activity: postgresql? ? activity_stats : nil
29
+ }
30
+ end
31
+
32
+ private
33
+
34
+ def connection
35
+ ActiveRecord::Base.connection
36
+ end
37
+
38
+ def adapter_name
39
+ connection.adapter_name
40
+ rescue => e
41
+ nil
42
+ end
43
+
44
+ def postgresql?
45
+ adapter_name == "PostgreSQL"
46
+ end
47
+
48
+ def connection_pool_stats
49
+ pool = ActiveRecord::Base.connection_pool
50
+ stat = pool.stat
51
+ { size: stat[:size], busy: stat[:busy], dead: stat[:dead],
52
+ idle: stat[:idle], waiting: stat[:waiting] }
53
+ rescue => e
54
+ nil
55
+ end
56
+
57
+ def table_stats
58
+ rows = connection.select_all(<<~SQL)
59
+ SELECT
60
+ schemaname,
61
+ relname AS name,
62
+ n_live_tup AS estimated_rows,
63
+ pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) AS total_bytes,
64
+ seq_scan,
65
+ idx_scan,
66
+ n_dead_tup AS dead_tuples,
67
+ last_vacuum,
68
+ last_autovacuum,
69
+ last_analyze
70
+ FROM pg_stat_user_tables
71
+ ORDER BY pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) DESC
72
+ SQL
73
+
74
+ rows.map do |row|
75
+ {
76
+ name: row["name"],
77
+ estimated_rows: row["estimated_rows"].to_i,
78
+ total_bytes: row["total_bytes"].to_i,
79
+ seq_scan: row["seq_scan"].to_i,
80
+ idx_scan: row["idx_scan"].to_i,
81
+ dead_tuples: row["dead_tuples"].to_i,
82
+ last_vacuum: row["last_vacuum"],
83
+ last_autovacuum: row["last_autovacuum"],
84
+ last_analyze: row["last_analyze"],
85
+ gem_table: row["name"].to_s.start_with?("rails_error_dashboard_")
86
+ }
87
+ end
88
+ rescue => e
89
+ nil
90
+ end
91
+
92
+ def index_stats
93
+ rows = connection.select_all(<<~SQL)
94
+ SELECT
95
+ sui.indexrelname AS name,
96
+ sui.relname AS table_name,
97
+ pg_relation_size(sui.indexrelid) AS size_bytes,
98
+ sui.idx_scan AS scans,
99
+ sui.idx_tup_read AS tuples_read,
100
+ sui.idx_tup_fetch AS tuples_fetched
101
+ FROM pg_stat_user_indexes sui
102
+ ORDER BY pg_relation_size(sui.indexrelid) DESC
103
+ SQL
104
+
105
+ rows.map do |row|
106
+ {
107
+ name: row["name"],
108
+ table_name: row["table_name"],
109
+ size_bytes: row["size_bytes"].to_i,
110
+ scans: row["scans"].to_i,
111
+ tuples_read: row["tuples_read"].to_i,
112
+ tuples_fetched: row["tuples_fetched"].to_i
113
+ }
114
+ end
115
+ rescue => e
116
+ nil
117
+ end
118
+
119
+ def unused_index_stats
120
+ rows = connection.select_all(<<~SQL)
121
+ SELECT
122
+ sui.indexrelname AS name,
123
+ sui.relname AS table_name,
124
+ pg_relation_size(sui.indexrelid) AS size_bytes
125
+ FROM pg_stat_user_indexes sui
126
+ WHERE sui.idx_scan = 0
127
+ AND pg_relation_size(sui.indexrelid) > 0
128
+ ORDER BY pg_relation_size(sui.indexrelid) DESC
129
+ SQL
130
+
131
+ rows.map do |row|
132
+ {
133
+ name: row["name"],
134
+ table_name: row["table_name"],
135
+ size_bytes: row["size_bytes"].to_i
136
+ }
137
+ end
138
+ rescue => e
139
+ nil
140
+ end
141
+
142
+ def activity_stats
143
+ rows = connection.select_all(<<~SQL)
144
+ SELECT
145
+ COALESCE(state, 'unknown') AS state,
146
+ COUNT(*) AS count,
147
+ COUNT(*) FILTER (WHERE wait_event_type IS NOT NULL) AS waiting
148
+ FROM pg_stat_activity
149
+ WHERE datname = current_database()
150
+ GROUP BY state
151
+ ORDER BY count DESC
152
+ SQL
153
+
154
+ by_state = rows.map do |row|
155
+ { state: row["state"], count: row["count"].to_i, waiting: row["waiting"].to_i }
156
+ end
157
+
158
+ {
159
+ by_state: by_state,
160
+ total: by_state.sum { |r| r[:count] },
161
+ total_waiting: by_state.sum { |r| r[:waiting] }
162
+ }
163
+ rescue => e
164
+ nil
165
+ end
166
+ end
167
+ end
168
+ end