rails_nexus 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +430 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/config/rails_nexus_manifest.js +2 -0
  6. data/app/assets/javascripts/rails_nexus/controllers.js +603 -0
  7. data/app/assets/stylesheets/rails_nexus/application.css +1964 -0
  8. data/app/controllers/rails_nexus/analytics_controller.rb +361 -0
  9. data/app/controllers/rails_nexus/application_controller.rb +15 -0
  10. data/app/controllers/rails_nexus/backup_controller.rb +91 -0
  11. data/app/controllers/rails_nexus/cron_jobs_controller.rb +64 -0
  12. data/app/controllers/rails_nexus/database_health_controller.rb +128 -0
  13. data/app/controllers/rails_nexus/logged_exceptions_controller.rb +227 -0
  14. data/app/controllers/rails_nexus/n1_patterns_controller.rb +10 -0
  15. data/app/controllers/rails_nexus/settings_controller.rb +100 -0
  16. data/app/controllers/rails_nexus/source_code_controller.rb +39 -0
  17. data/app/controllers/rails_nexus/stats_controller.rb +494 -0
  18. data/app/controllers/rails_nexus/workflow_controller.rb +96 -0
  19. data/app/helpers/rails_nexus/application_helper.rb +5 -0
  20. data/app/helpers/rails_nexus/logged_exceptions_helper.rb +254 -0
  21. data/app/helpers/rails_nexus/source_code_helper.rb +107 -0
  22. data/app/javascript/controllers/rails_nexus_controller.js +380 -0
  23. data/app/javascript/controllers/rails_nexus_detail_controller.js +165 -0
  24. data/app/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
  25. data/app/javascript/controllers/rails_nexus_theme_controller.js +31 -0
  26. data/app/javascript/rails_nexus/application.js +17 -0
  27. data/app/jobs/rails_nexus/application_job.rb +4 -0
  28. data/app/mailers/rails_nexus/application_mailer.rb +6 -0
  29. data/app/models/rails_nexus/application_record.rb +5 -0
  30. data/app/models/rails_nexus/backup.rb +107 -0
  31. data/app/models/rails_nexus/base_record.rb +26 -0
  32. data/app/models/rails_nexus/comment.rb +13 -0
  33. data/app/models/rails_nexus/cron_job.rb +72 -0
  34. data/app/models/rails_nexus/database_stat.rb +128 -0
  35. data/app/models/rails_nexus/event.rb +101 -0
  36. data/app/models/rails_nexus/logged_exception.rb +864 -0
  37. data/app/models/rails_nexus/logged_exception_n1.rb +87 -0
  38. data/app/models/rails_nexus/metric.rb +107 -0
  39. data/app/models/rails_nexus/nginx_metric.rb +112 -0
  40. data/app/models/rails_nexus/server_metric.rb +168 -0
  41. data/app/models/rails_nexus/webhook_delivery.rb +64 -0
  42. data/app/services/rails_nexus/backup_service.rb +275 -0
  43. data/app/views/layouts/rails_nexus/application.html.erb +185 -0
  44. data/app/views/rails_nexus/analytics/index.html.erb +363 -0
  45. data/app/views/rails_nexus/backup/files.html.erb +53 -0
  46. data/app/views/rails_nexus/backup/index.html.erb +211 -0
  47. data/app/views/rails_nexus/backup/settings.html.erb +158 -0
  48. data/app/views/rails_nexus/cron_jobs/index.html.erb +141 -0
  49. data/app/views/rails_nexus/database_health/index.html.erb +139 -0
  50. data/app/views/rails_nexus/logged_exceptions/_comments_list.html.erb +50 -0
  51. data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +190 -0
  52. data/app/views/rails_nexus/logged_exceptions/_feed.html.erb +3 -0
  53. data/app/views/rails_nexus/logged_exceptions/_filter_group.html.erb +10 -0
  54. data/app/views/rails_nexus/logged_exceptions/_show.html.erb +554 -0
  55. data/app/views/rails_nexus/logged_exceptions/_workflow_panel.html.erb +118 -0
  56. data/app/views/rails_nexus/logged_exceptions/clear.turbo_stream.erb +6 -0
  57. data/app/views/rails_nexus/logged_exceptions/destroy.turbo_stream.erb +2 -0
  58. data/app/views/rails_nexus/logged_exceptions/destroy_all.turbo_stream.erb +6 -0
  59. data/app/views/rails_nexus/logged_exceptions/feed.rss.builder +20 -0
  60. data/app/views/rails_nexus/logged_exceptions/index.html.erb +323 -0
  61. data/app/views/rails_nexus/logged_exceptions/query.html.erb +3 -0
  62. data/app/views/rails_nexus/logged_exceptions/query.turbo_stream.erb +6 -0
  63. data/app/views/rails_nexus/logged_exceptions/show.html.erb +3 -0
  64. data/app/views/rails_nexus/logged_exceptions/show.turbo_stream.erb +5 -0
  65. data/app/views/rails_nexus/n1_patterns/index.html.erb +153 -0
  66. data/app/views/rails_nexus/settings/index.html.erb +140 -0
  67. data/app/views/rails_nexus/stats/index.html.erb +569 -0
  68. data/config/importmap.rb +15 -0
  69. data/config/initializers/date_formats.rb +5 -0
  70. data/config/initializers/rails_nexus.rb +18 -0
  71. data/config/locales/en.yml +47 -0
  72. data/config/routes.rb +58 -0
  73. data/db/migrate/20240330122311_create_rails_nexus_logged_exceptions.rb +22 -0
  74. data/lib/generators/rails_nexus/backup_generator.rb +70 -0
  75. data/lib/generators/rails_nexus/customize_generator.rb +159 -0
  76. data/lib/generators/rails_nexus/install_generator.rb +61 -0
  77. data/lib/generators/rails_nexus/templates/INSTALL.md +9 -0
  78. data/lib/generators/rails_nexus/templates/backup/backup_helper.sh +152 -0
  79. data/lib/generators/rails_nexus/templates/backup/config.rb +27 -0
  80. data/lib/generators/rails_nexus/templates/backup/models/daily_backup.rb +24 -0
  81. data/lib/generators/rails_nexus/templates/backup/models/full_backup.rb +35 -0
  82. data/lib/generators/rails_nexus/templates/backup/models/sync_backup.rb +43 -0
  83. data/lib/generators/rails_nexus/templates/backup/mysql-config/db_config.cnf +15 -0
  84. data/lib/generators/rails_nexus/templates/backup/schedule.rb +28 -0
  85. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_controller.js +310 -0
  86. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_detail_controller.js +98 -0
  87. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
  88. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_theme_controller.js +30 -0
  89. data/lib/generators/rails_nexus/templates/migration.rb +24 -0
  90. data/lib/generators/rails_nexus/templates/migration_advanced_features.rb +30 -0
  91. data/lib/generators/rails_nexus/templates/migration_cron_jobs.rb +22 -0
  92. data/lib/generators/rails_nexus/templates/migration_new_schema.rb +120 -0
  93. data/lib/generators/rails_nexus/templates/migration_platform_detection.rb +11 -0
  94. data/lib/generators/rails_nexus/templates/migration_webhook_deliveries.rb +21 -0
  95. data/lib/generators/rails_nexus/templates/migration_workflow.rb +29 -0
  96. data/lib/generators/rails_nexus/templates/rails_nexus.rb +96 -0
  97. data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +129 -0
  98. data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_exceptions.html.erb +123 -0
  99. data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_show.html.erb +297 -0
  100. data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/index.html.erb +113 -0
  101. data/lib/rails_nexus/breadcrumbs.rb +161 -0
  102. data/lib/rails_nexus/cleanup.rb +32 -0
  103. data/lib/rails_nexus/configuration.rb +319 -0
  104. data/lib/rails_nexus/engine.rb +135 -0
  105. data/lib/rails_nexus/logger.rb +188 -0
  106. data/lib/rails_nexus/notifications.rb +105 -0
  107. data/lib/rails_nexus/ransack_config.rb +4 -0
  108. data/lib/rails_nexus/storm_protection.rb +124 -0
  109. data/lib/rails_nexus/version.rb +3 -0
  110. data/lib/rails_nexus.rb +133 -0
  111. data/lib/tasks/rails_nexus.rake +230 -0
  112. data/lib/tasks/rails_nexus_tasks.rake +37 -0
  113. data/rails_nexus.gemspec +36 -0
  114. metadata +252 -0
@@ -0,0 +1,864 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNexus
4
+ class LoggedException < BaseRecord
5
+ self.table_name = "rails_nexus_exceptions"
6
+ HOSTNAME = Socket.gethostname
7
+
8
+ serialize :cause_chain, coder: JSON unless method_defined?(:cause_chain)
9
+ serialize :breadcrumbs, coder: JSON unless method_defined?(:breadcrumbs)
10
+ serialize :system_health, coder: JSON unless method_defined?(:system_health)
11
+ serialize :local_variables, coder: JSON unless method_defined?(:local_variables)
12
+ serialize :instance_variables, coder: JSON unless method_defined?(:instance_variables)
13
+
14
+ # ─── Associations ──────────────────────────────────────────────
15
+ has_many :comments, class_name: "RailsNexus::Comment", dependent: :destroy
16
+
17
+ # ─── Scopes ────────────────────────────────────────────────────
18
+ scope :muted, -> { where(muted: true) }
19
+ scope :not_muted, -> { where(muted: [false, nil]) }
20
+ scope :snoozed, -> { where("snoozed_until > ?", Time.current) }
21
+ scope :not_snoozed, -> { where("snoozed_until IS NULL OR snoozed_until <= ?", Time.current) }
22
+ scope :by_priority, ->(p) { where(priority: p) }
23
+ scope :assigned_to, ->(user) { where(assigned_to: user) }
24
+ scope :unassigned, -> { where(assigned_to: [nil, ""]) }
25
+ scope :active, -> { not_muted.not_snoozed }
26
+
27
+ def self.ransackable_attributes(auth_object = nil)
28
+ %w[action_name backtrace cause_chain controller_name created_at device_type environment
29
+ assigned_to comments_count exception_class fingerprint id instance_variables local_variables
30
+ message muted occurrence_count platform platform_version priority remote_ip request
31
+ snoozed_until system_health updated_at user_agent user_id user_info]
32
+ end
33
+
34
+ def self.ransackable_associations(auth_object = nil)
35
+ []
36
+ end
37
+
38
+ class << self
39
+ def create_from_exception(controller, exception, data)
40
+ # Storm protection: check if we should capture this error
41
+ if defined?(RailsNexus::StormProtection) && RailsNexus::StormProtection.enabled?
42
+ unless RailsNexus::StormProtection.allow_capture?
43
+ RailsNexus::StormProtection.record_shed
44
+ return nil # Error shed by storm protection
45
+ end
46
+ end
47
+
48
+ message = exception.message.to_s
49
+ message += "\n* Extra Data\n\n#{data}" unless data.blank?
50
+
51
+ # Detect platform from user agent and request format
52
+ platform_data = detect_platform(controller)
53
+
54
+ # Flush breadcrumbs before they're lost
55
+ breadcrumbs_data = if defined?(RailsNexus::Breadcrumbs) && RailsNexus.configuration.breadcrumbs_enabled
56
+ RailsNexus::Breadcrumbs.flush
57
+ end
58
+
59
+ # Extract user info for impact scoring
60
+ user = controller.respond_to?(:current_user, true) ? controller.current_user : nil
61
+ user_id = user&.id&.to_s || data[:user_id]&.to_s rescue nil
62
+ user_type = user.class.name rescue nil
63
+
64
+ # Generate fingerprint for grouping
65
+ fingerprint = generate_fingerprint(
66
+ exception.class.name,
67
+ controller.controller_path,
68
+ controller.action_name
69
+ )
70
+
71
+ # Check if this fingerprint already exists (occurrence counting)
72
+ existing = where(fingerprint: fingerprint).order(created_at: :desc).first
73
+
74
+ attrs = {
75
+ exception_class: exception.class.name,
76
+ controller_name: controller.controller_path,
77
+ action_name: controller.action_name,
78
+ message: message,
79
+ backtrace: exception.backtrace,
80
+ request: controller.request,
81
+ user_info: user,
82
+ remote_ip: controller.request.remote_ip,
83
+ user_id: user_id,
84
+ user_type: user_type,
85
+ fingerprint: fingerprint,
86
+ platform: platform_data[:platform],
87
+ platform_version: platform_data[:platform_version],
88
+ device_type: platform_data[:device_type],
89
+ cause_chain: extract_cause_chain(exception),
90
+ breadcrumbs: breadcrumbs_data.presence,
91
+ system_health: capture_system_health,
92
+ occurrence_count: existing ? existing.occurrence_count + 1 : 1
93
+ }
94
+
95
+ if existing
96
+ existing.update!(
97
+ message: message,
98
+ backtrace: exception.backtrace,
99
+ request: controller.request,
100
+ user_info: user,
101
+ remote_ip: controller.request.remote_ip,
102
+ occurrence_count: existing.occurrence_count + 1,
103
+ cause_chain: attrs[:cause_chain],
104
+ system_health: attrs[:system_health]
105
+ )
106
+ RailsNexus::StormProtection.record_captured if defined?(RailsNexus::StormProtection)
107
+ existing
108
+ else
109
+ record = create!(attrs)
110
+ RailsNexus::StormProtection.record_captured if defined?(RailsNexus::StormProtection)
111
+ record
112
+ end
113
+ end
114
+
115
+ def host_name
116
+ HOSTNAME
117
+ end
118
+ end
119
+
120
+ scope :by_exception_class, lambda { |exception_class| where(:exception_class => exception_class) }
121
+ scope :by_controller_and_action, lambda { |controller_name, action_name| where(:controller_name => controller_name, :action_name => action_name) }
122
+ scope :by_controller, lambda { |controller_name| where(:controller_name => controller_name) }
123
+ scope :by_action, lambda { |action_name| where(:action_name => action_name) }
124
+ scope :message_like, ->(query) { where("message LIKE ?", "%#{sanitize_sql_like(query)}%") }
125
+ scope :days_old, ->(day_number) { where("created_at >= ?", day_number.to_f.days.ago.utc) }
126
+ scope :sorted, -> { order(created_at: :desc) }
127
+
128
+ # User impact scoring: rank by unique users affected
129
+ def self.user_impact_ranking(limit: 20)
130
+ select(<<~SQL
131
+ exception_class,
132
+ controller_name,
133
+ action_name,
134
+ COUNT(DISTINCT user_id) as unique_users,
135
+ COUNT(*) as total_occurrences,
136
+ MAX(created_at) as last_seen
137
+ SQL
138
+ )
139
+ .where.not(user_id: nil)
140
+ .group(:exception_class, :controller_name, :action_name)
141
+ .order("unique_users DESC, total_occurrences DESC")
142
+ .limit(limit)
143
+ end
144
+
145
+ def name
146
+ "#{self.exception_class} in #{self.controller_action}"
147
+ end
148
+
149
+ # Generate fingerprint for grouping similar exceptions
150
+ def self.generate_fingerprint(exception_class, controller_name, action_name)
151
+ require "digest"
152
+ Digest::SHA256.hexdigest("#{exception_class}#{controller_name}#{action_name}")[0..15]
153
+ end
154
+
155
+ # Detect platform from user_agent and request
156
+ def self.detect_platform(controller)
157
+ request = controller.request
158
+ user_agent = request.respond_to?(:user_agent) ? request.user_agent.to_s.downcase : ""
159
+
160
+ # API detection: no user_agent or JSON/XML format
161
+ if user_agent.blank? || request.format&.ref == "json" || request.format&.ref == "xml"
162
+ return { platform: "api", platform_version: nil, device_type: "server", app_version: nil }
163
+ end
164
+
165
+ # iOS detection
166
+ if user_agent.include?("iphone") || user_agent.include?("ipad") || user_agent.include?("ipod")
167
+ version = user_agent[/os (\d+[._]\d+)/i, 1]&.gsub("_", ".")
168
+ return { platform: "ios", platform_version: version, device_type: user_agent.include?("ipad") ? "tablet" : "phone", app_version: nil }
169
+ end
170
+
171
+ # Android detection
172
+ if user_agent.include?("android")
173
+ version = user_agent[/android (\d+[.\d]*)/i, 1]
174
+ return { platform: "android", platform_version: version, device_type: "phone", app_version: nil }
175
+ end
176
+
177
+ # macOS detection
178
+ if user_agent.include?("macintosh") || user_agent.include?("mac os")
179
+ return { platform: "web", platform_version: "macOS", device_type: "desktop", app_version: nil }
180
+ end
181
+
182
+ # Windows detection
183
+ if user_agent.include?("windows")
184
+ return { platform: "web", platform_version: "Windows", device_type: "desktop", app_version: nil }
185
+ end
186
+
187
+ # Linux detection
188
+ if user_agent.include?("linux")
189
+ return { platform: "web", platform_version: "Linux", device_type: "desktop", app_version: nil }
190
+ end
191
+
192
+ # Bot/crawler detection
193
+ if user_agent.include?("bot") || user_agent.include?("crawler") || user_agent.include?("spider")
194
+ return { platform: "bot", platform_version: nil, device_type: "bot", app_version: nil }
195
+ end
196
+
197
+ # Default: web
198
+ { platform: "web", platform_version: nil, device_type: "unknown", app_version: nil }
199
+ end
200
+
201
+ # Platform analytics
202
+ def self.platform_stats
203
+ select(<<~SQL
204
+ platform,
205
+ COUNT(*) as total,
206
+ COUNT(DISTINCT exception_class) as unique_classes,
207
+ MAX(created_at) as last_seen
208
+ SQL
209
+ )
210
+ .group(:platform)
211
+ .order("total DESC")
212
+ end
213
+
214
+ # Platform-specific top errors
215
+ def self.platform_top_errors(platform:, limit: 5)
216
+ by_platform(platform)
217
+ .select(:exception_class, "COUNT(*) as count")
218
+ .group(:exception_class)
219
+ .order("count DESC")
220
+ .limit(limit)
221
+ end
222
+
223
+ scope :by_platform, ->(platform) { where(platform: platform) }
224
+
225
+ # N+1 Query Pattern Detection
226
+ # Analyzes breadcrumbs for repeated similar SQL queries
227
+ def self.n_plus_one_patterns(limit: 20)
228
+ patterns = {}
229
+
230
+ # Get recent exceptions with breadcrumbs
231
+ where.not(breadcrumbs: nil)
232
+ .where("created_at >= ?", 7.days.ago)
233
+ .order(created_at: :desc)
234
+ .limit(500)
235
+ .find_each do |exception|
236
+ crumbs = parse_breadcrumbs(exception.breadcrumbs)
237
+ next unless crumbs.is_a?(Array)
238
+
239
+ # Extract SQL queries from breadcrumbs
240
+ sql_crumbs = crumbs.select { |c| c[:type] == "sql" }
241
+ next if sql_crumbs.empty?
242
+
243
+ # Group by fingerprint (normalized SQL)
244
+ sql_crumbs.group_by { |c| sql_fingerprint(c[:name]) }.each do |fingerprint, queries|
245
+ next if queries.length < 3 # N+1 = 3+ similar queries
246
+
247
+ table = extract_table_name(queries.first[:name])
248
+ patterns[fingerprint] ||= {
249
+ fingerprint: fingerprint,
250
+ sql_sample: queries.first[:name]&.truncate(200),
251
+ table: table,
252
+ count: 0,
253
+ exception_ids: [],
254
+ avg_duration: 0,
255
+ source: :breadcrumbs
256
+ }
257
+ patterns[fingerprint][:count] += queries.length
258
+ patterns[fingerprint][:exception_ids] << exception.id
259
+ durations = queries.map { |q| q[:duration] }.compact
260
+ patterns[fingerprint][:avg_duration] = durations.any? ? (durations.sum / durations.size).round(2) : 0
261
+ end
262
+ end
263
+
264
+ # Supplement with backtrace-based N+1 detection
265
+ backtrace_patterns = n_plus_one_from_backtraces(limit: limit)
266
+ backtrace_patterns.each do |bp|
267
+ key = bp[:fingerprint]
268
+ if patterns[key]
269
+ patterns[key][:count] += bp[:count]
270
+ patterns[key][:exception_ids] = (patterns[key][:exception_ids] + bp[:exception_ids]).uniq
271
+ else
272
+ patterns[key] = bp
273
+ end
274
+ end
275
+
276
+ # Sort by frequency and return top N
277
+ patterns.values
278
+ .sort_by { |p| -p[:count] }
279
+ .first(limit)
280
+ .map { |p| p.merge(exception_ids: p[:exception_ids].uniq.first(5)) }
281
+ end
282
+
283
+ # Backtrace-based N+1 detection (works without breadcrumbs)
284
+ # Looks at repeated backtrace patterns in application code that suggest N+1 queries
285
+ def self.n_plus_one_from_backtraces(limit: 20)
286
+ patterns = {}
287
+
288
+ where.not(backtrace: nil)
289
+ .where("created_at >= ?", 7.days.ago)
290
+ .order(created_at: :desc)
291
+ .limit(500)
292
+ .find_each do |exception|
293
+ lines = exception.backtrace.to_s.split("\n")
294
+ next if lines.empty?
295
+
296
+ # Detect N+1 signals in backtrace
297
+ app_lines = lines.select { |l| l.include?(Rails.root.to_s) && !l.include?("rails_nexus") }
298
+ next if app_lines.empty?
299
+
300
+ # Look for ActiveRecord collection iteration patterns:
301
+ # e.g., .each, .map, .find_each near ActiveRecord calls
302
+ has_iteration = app_lines.any? { |l| l.match?(/\.each|\.map|\.select|\.reject|\.flat_map|\.find_each/) }
303
+ has_ar_call = app_lines.any? { |l| l.match?(/ActiveRecord|_callback|association|belongs_to|has_many|load_target|reload/) }
304
+
305
+ # Also detect: repeated similar backtrace across multiple exceptions of same class
306
+ fingerprint = backtrace_fingerprint(app_lines)
307
+ next if fingerprint.blank?
308
+
309
+ patterns[fingerprint] ||= {
310
+ fingerprint: fingerprint,
311
+ sql_sample: backtrace_to_hint(app_lines),
312
+ table: extract_table_from_backtrace(app_lines),
313
+ count: 0,
314
+ exception_ids: [],
315
+ avg_duration: 0,
316
+ source: :backtrace
317
+ }
318
+ patterns[fingerprint][:count] += 1
319
+ patterns[fingerprint][:exception_ids] << exception.id
320
+ end
321
+
322
+ # Only return patterns seen 3+ times (likely real N+1 issues)
323
+ patterns.values.select { |p| p[:count] >= 3 }
324
+ .sort_by { |p| -p[:count] }
325
+ .first(limit)
326
+ end
327
+
328
+ # Generate a fingerprint from backtrace lines for grouping
329
+ def self.backtrace_fingerprint(app_lines)
330
+ return nil if app_lines.length < 2
331
+ # Use the app-level lines (skip framework) to create a groupable key
332
+ key = app_lines.first(5).map { |l| l.sub(/:\d+/, "").sub(/in .*/, "").strip }.join("\n")
333
+ Digest::SHA256.hexdigest(key)[0..15]
334
+ end
335
+
336
+ # Extract a human-readable hint from backtrace
337
+ def self.backtrace_to_hint(app_lines)
338
+ return nil if app_lines.empty?
339
+ line = app_lines.first
340
+ if line =~ /^(.+?):(\d+):in `(\S+)'/
341
+ "#{$1.sub(Rails.root.to_s, "")}:#{$2} in #{$3}"
342
+ else
343
+ line.sub(Rails.root.to_s, "")
344
+ end
345
+ end
346
+
347
+ # Try to extract table name from backtrace lines
348
+ def self.extract_table_from_backtrace(app_lines)
349
+ app_lines.each do |line|
350
+ # Match patterns like: `find_all_by_#{table}`, `where_#{table}`
351
+ if line =~ /(?:find|where|select|from|join|has_many|belongs_to)[_s]*(\w+)/i
352
+ return $1.downcase
353
+ end
354
+ end
355
+ # Fall back to the action name from the first app line
356
+ if app_lines.first =~ /#(\w+)\z/
357
+ return $1
358
+ end
359
+ "app"
360
+ end
361
+
362
+ # N+1 summary stats
363
+ def self.n_plus_one_summary
364
+ patterns = n_plus_one_patterns(limit: 100)
365
+ {
366
+ total_patterns: patterns.sum { |p| p[:count] },
367
+ unique_patterns: patterns.size,
368
+ worst_pattern: patterns.first,
369
+ top_tables: patterns.group_by { |p| p[:table] }
370
+ .transform_values { |ps| ps.sum { |p| p[:count] } }
371
+ .sort_by { |_, count| -count }
372
+ .first(5)
373
+ .map { |table, count| { table: table, count: count } }
374
+ }
375
+ end
376
+
377
+ def self.parse_breadcrumbs(data)
378
+ return data if data.is_a?(Array)
379
+ JSON.parse(data, symbolize_names: true) rescue []
380
+ end
381
+
382
+ # Normalize SQL fingerprint: remove values, keep structure
383
+ def self.sql_fingerprint(sql)
384
+ return "" if sql.blank?
385
+ sql.strip
386
+ .gsub(/\s+/, " ") # Normalize whitespace
387
+ .gsub(/\d+/, "?") # Replace numbers with ?
388
+ .gsub(/'[^']*'/, "?") # Replace string values with ?
389
+ .gsub(/"[^"]*"/, "?") # Replace quoted identifiers
390
+ .gsub(/\d+\.\d+/, "?") # Replace decimals
391
+ .gsub(/0x[0-9a-f]+/i, "?") # Replace hex values
392
+ .strip
393
+ end
394
+
395
+ # Extract table name from SQL query
396
+ def self.extract_table_name(sql)
397
+ return "unknown" if sql.blank?
398
+ # Match FROM/INTO/UPDATE/JOIN table patterns
399
+ if sql =~ /(?:FROM|INTO|UPDATE|JOIN)\s+["']?(\w+)/i
400
+ $1.downcase
401
+ else
402
+ "unknown"
403
+ end
404
+ end
405
+
406
+ # Extract cause chain from exception
407
+ def self.extract_cause_chain(exception)
408
+ chain = []
409
+ current = exception
410
+ while current && chain.length < 10 # Limit depth
411
+ chain << {
412
+ class_name: current.class.name,
413
+ message: current.message.to_s.truncate(500),
414
+ backtrace: current.backtrace&.first(5)&.join("
415
+ ")
416
+ }
417
+ current = current.cause
418
+ end
419
+ chain
420
+ end
421
+
422
+ # Capture system health snapshot
423
+ def self.capture_system_health
424
+ health = {}
425
+ begin
426
+ gc_stat = GC.stat
427
+ health[:gc] = {
428
+ total_allocated: gc_stat[:total_allocated_objects],
429
+ total_freed: gc_stat[:total_freed_objects],
430
+ heap_allocated: gc_stat[:heap_allocated_pages],
431
+ heap_free: gc_stat[:free_slots]
432
+ }
433
+ rescue StandardError
434
+ health[:gc] = { error: "unable to capture" }
435
+ end
436
+
437
+ begin
438
+ health[:memory] = {
439
+ rss_kb: `ps -o rss= -p #{Process.pid} 2>/dev/null`.strip.to_i,
440
+ vsz_kb: `ps -o vsz= -p #{Process.pid} 2>/dev/null`.strip.to_i
441
+ }
442
+ rescue StandardError
443
+ health[:memory] = { error: "unable to capture" }
444
+ end
445
+
446
+ begin
447
+ health[:threads] = Thread.list.size
448
+ health[:process_id] = Process.pid
449
+ health[:ruby_version] = RUBY_VERSION
450
+ rescue StandardError
451
+ # Ignore
452
+ end
453
+
454
+ begin
455
+ pool = ActiveRecord::Base.connection_pool
456
+ connections = pool.connections
457
+ active_conns = connections.select(&:active?)
458
+ idle_conns = connections.reject(&:active?)
459
+
460
+ # Count dead connections (checked out but not active)
461
+ dead_count = 0
462
+ begin
463
+ dead_count = pool.instance_variable_get(:@dead_connections)&.size || 0
464
+ rescue StandardError
465
+ # Ignore
466
+ end
467
+
468
+ # Waiting threads (threads waiting for a connection)
469
+ waiting = pool.instance_variable_get(:@waiters)&.size || 0 rescue 0
470
+
471
+ health[:db_pool] = {
472
+ size: pool.size,
473
+ connections: connections.size,
474
+ active: active_conns.size,
475
+ busy: active_conns.size,
476
+ idle: idle_conns.size,
477
+ dead: dead_count,
478
+ waiting: waiting,
479
+ utilization: pool.size > 0 ? (connections.size.to_f / pool.size * 100).round(1) : 0
480
+ }
481
+ rescue StandardError
482
+ health[:db_pool] = { error: "unable to capture" }
483
+ end
484
+
485
+ health[:timestamp] = Time.current.iso8601
486
+ health
487
+ end
488
+
489
+ def backtrace=(trace)
490
+ trace = sanitize_backtrace(trace) unless trace.is_a?(String)
491
+ write_attribute :backtrace, trace
492
+ end
493
+
494
+ def request=(request)
495
+ if request.is_a?(String)
496
+ write_attribute :request, request
497
+ elsif request.respond_to?(:env) && request.env.is_a?(Hash)
498
+ max = request.env.keys.max { |a, b| a.length <=> b.length }
499
+ env = request.env.keys.sort.inject [] do |memo, key|
500
+ memo << "* %-*s: %s" % [max.length, key, request.env[key].to_s.strip]
501
+ end
502
+ write_attribute(:environment, (env << "* Process: #{$$}" << "* Server : #{self.class.host_name}").join("\n"))
503
+
504
+ method_str = request.respond_to?(:get?) && request.get? ? "" : " #{request.respond_to?(:method) ? request.method.to_s.upcase : "GET"}"
505
+ write_attribute(:request, [
506
+ "* URL:#{method_str} #{request.respond_to?(:protocol) ? request.protocol : "http://"}#{request.respond_to?(:env) ? request.env["HTTP_HOST"] : "localhost"}#{request.respond_to?(:fullpath) ? request.fullpath : "/"}",
507
+ "* Format: #{request.respond_to?(:format) ? request.format.to_s : "html"}",
508
+ "* Parameters: #{request.respond_to?(:parameters) ? request.parameters.inspect : "{}"}",
509
+ "* Rails Root: #{rails_root}"
510
+ ].join("\n"))
511
+ else
512
+ write_attribute :request, request.to_s
513
+ end
514
+ end
515
+
516
+ def controller_action
517
+ @controller_action ||= "#{controller_name.camelcase}/#{action_name}"
518
+ end
519
+
520
+ def self.class_names
521
+ select("DISTINCT exception_class").order(:exception_class).collect(&:exception_class)
522
+ end
523
+
524
+ def self.controller_actions
525
+ select("DISTINCT controller_name, action_name").order(:controller_name, :action_name).map { |r| [r.controller_name.presence, r.action_name.presence].compact.join("/") }.reject(&:blank?)
526
+ end
527
+
528
+
529
+ # ─── Occurrence Pattern Detection ─────────────────────────────
530
+ # Detects cyclical, burst, and trend patterns in exception occurrences
531
+
532
+ # Main entry point: analyze error patterns over the last N days
533
+ def self.detect_occurrence_patterns(days: 30)
534
+ time_series = build_time_series(days: days)
535
+ return { patterns: [], hotspots: [], trend: nil } if time_series.empty?
536
+
537
+ {
538
+ patterns: detect_patterns(time_series),
539
+ hotspots: detect_hotspots(time_series),
540
+ trend: detect_trend(time_series),
541
+ burst_periods: detect_bursts(time_series),
542
+ cycle_info: detect_cycles(time_series)
543
+ }
544
+ end
545
+
546
+ # Build hourly time series of error counts
547
+ def self.build_time_series(days: 30)
548
+ start_time = days.days.ago.beginning_of_hour
549
+ raw = where("created_at >= ?", start_time)
550
+ .group(Arel.sql("DATE_FORMAT(created_at, '%Y-%m-%d %H:00')"))
551
+ .order(Arel.sql("DATE_FORMAT(created_at, '%Y-%m-%d %H:00')"))
552
+ .count
553
+
554
+ # Fill gaps with zeros
555
+ series = {}
556
+ current = start_time
557
+ while current <= Time.current
558
+ key = current.strftime("%Y-%m-%d %H:00")
559
+ series[key] = raw[key] || 0
560
+ current += 1.hour
561
+ end
562
+
563
+ series
564
+ end
565
+
566
+ # Detect cyclical patterns (regular intervals)
567
+ def self.detect_cycles(time_series)
568
+ counts = time_series.values
569
+ return { detected: false } if counts.length < 24
570
+
571
+ # Check hourly patterns (24-hour cycle)
572
+ hourly_avg = Array.new(24, 0.0)
573
+ hourly_counts = Array.new(24, 0)
574
+ time_series.each do |time_str, count|
575
+ hour = Time.parse(time_str).hour
576
+ hourly_avg[hour] += count
577
+ hourly_counts[hour] += 1
578
+ end
579
+ hourly_avg = hourly_avg.zip(hourly_counts).map { |a, b| b > 0 ? (a / b).round(2) : 0 }
580
+
581
+ # Find peak and off-peak hours
582
+ peak_hour = hourly_avg.each_with_index.max
583
+ off_peak_hour = hourly_avg.each_with_index.reject { |v, _| v == 0 }.min
584
+ peak_hour ||= [0, 0]
585
+ off_peak_hour ||= [0, 0]
586
+
587
+ # Calculate cycle strength (ratio of peak to average)
588
+ avg = counts.sum.to_f / counts.length
589
+ cycle_strength = avg > 0 ? (peak_hour[0] / avg).round(2) : 0
590
+
591
+ # Check daily patterns (weekly cycle)
592
+ daily_avg = Array.new(7, 0.0)
593
+ daily_counts = Array.new(7, 0)
594
+ time_series.each do |time_str, count|
595
+ wday = Time.parse(time_str).wday
596
+ daily_avg[wday] += count
597
+ daily_counts[wday] += 1
598
+ end
599
+ daily_avg = daily_avg.zip(daily_counts).map { |a, b| b > 0 ? (a / b).round(2) : 0 }
600
+
601
+ {
602
+ detected: cycle_strength > 1.5,
603
+ strength: cycle_strength,
604
+ hourly_pattern: hourly_avg,
605
+ daily_pattern: daily_avg,
606
+ peak_hour: peak_hour[1],
607
+ peak_hourly_avg: peak_hour[0],
608
+ off_peak_hour: off_peak_hour[1],
609
+ off_peak_hourly_avg: off_peak_hour[0],
610
+ description: if cycle_strength > 2.0
611
+ "Strong hourly cycle detected — peak at #{peak_hour[1]}:00 (#{peak_hour[0].round(1)}x average)"
612
+ elsif cycle_strength > 1.5
613
+ "Moderate hourly cycle — peak at #{peak_hour[1]}:00 (#{cycle_strength}x average)"
614
+ else
615
+ "No significant hourly cycle detected"
616
+ end
617
+ }
618
+ end
619
+
620
+ # Detect burst periods (sudden spikes)
621
+ def self.detect_bursts(time_series)
622
+ counts = time_series.values
623
+ return [] if counts.length < 6
624
+
625
+ # Calculate rolling average and standard deviation
626
+ window = [6, counts.length / 6].max # 6-hour window or 1/6 of data
627
+ bursts = []
628
+
629
+ counts.each_with_index do |count, i|
630
+ next if i < window
631
+
632
+ window_slice = counts[(i - window)...i]
633
+ avg = window_slice.sum.to_f / window_slice.length
634
+ std = Math.sqrt(window_slice.map { |v| (v - avg)**2 }.sum / window_slice.length)
635
+
636
+ # Burst = count > mean + 2*std (statistical outlier)
637
+ threshold = avg + (2 * std)
638
+ if count > threshold && count >= 5
639
+ time_str = time_series.keys[i]
640
+ bursts << {
641
+ time: time_str,
642
+ count: count,
643
+ threshold: threshold.round(1),
644
+ spike_ratio: avg > 0 ? (count / avg).round(1) : count,
645
+ severity: if count > threshold * 2
646
+ :critical
647
+ elsif count > threshold * 1.5
648
+ :high
649
+ else
650
+ :medium
651
+ end
652
+ }
653
+ end
654
+ end
655
+
656
+ # Sort by severity and return last 10
657
+ severity_order = { critical: 0, high: 1, medium: 2 }
658
+ bursts.sort_by { |b| severity_order[b[:severity]] }.last(10).reverse
659
+ end
660
+
661
+ # Detect trend (increasing/decreasing/stable)
662
+ def self.detect_trend(time_series)
663
+ counts = time_series.values
664
+ return { direction: :stable, slope: 0 } if counts.length < 12
665
+
666
+ # Split into halves and compare
667
+ midpoint = counts.length / 2
668
+ first_half = counts[0...midpoint]
669
+ second_half = counts[midpoint..]
670
+
671
+ first_avg = first_half.sum.to_f / first_half.length
672
+ second_avg = second_half.sum.to_f / second_half.length
673
+
674
+ # Linear regression for slope
675
+ n = counts.length
676
+ x_mean = (n - 1) / 2.0
677
+ y_mean = counts.sum.to_f / n
678
+ numerator = counts.each_with_index.map { |y, x| (x - x_mean) * (y - y_mean) }.sum
679
+ denominator = counts.each_with_index.map { |x, _| (x - x_mean)**2 }.sum
680
+ slope = denominator > 0 ? numerator / denominator : 0
681
+
682
+ # Percentage change
683
+ pct_change = first_avg > 0 ? ((second_avg - first_avg) / first_avg * 100).round(1) : 0
684
+
685
+ direction = if pct_change > 20
686
+ :increasing
687
+ elsif pct_change < -20
688
+ :decreasing
689
+ else
690
+ :stable
691
+ end
692
+
693
+ {
694
+ direction: direction,
695
+ slope: slope.round(4),
696
+ first_half_avg: first_avg.round(2),
697
+ second_half_avg: second_avg.round(2),
698
+ pct_change: pct_change,
699
+ description: case direction
700
+ when :increasing
701
+ "Errors increasing (+#{pct_change}% over period)"
702
+ when :decreasing
703
+ "Errors decreasing (#{pct_change}% over period)"
704
+ else
705
+ "Error rate stable over period"
706
+ end
707
+ }
708
+ end
709
+
710
+ # Detect hotspots (times of day with highest error rates)
711
+ def self.detect_hotspots(time_series)
712
+ hourly = Hash.new(0)
713
+ hourly_counts = Hash.new(0)
714
+
715
+ time_series.each do |time_str, count|
716
+ hour = Time.parse(time_str).hour
717
+ hourly[hour] += count
718
+ hourly_counts[hour] += 1
719
+ end
720
+
721
+ # Calculate average per hour
722
+ hourly_avg = hourly.map { |h, c| [h, c.to_f / hourly_counts[h]] }.sort_by { |_, v| -v }
723
+
724
+ # Return top 5 hotspot hours
725
+ hourly_avg.first(5).map do |hour, avg|
726
+ {
727
+ hour: hour,
728
+ avg_errors: avg.round(2),
729
+ label: format("%d:00 - %d:00", hour, (hour + 1) % 24)
730
+ }
731
+ end
732
+ end
733
+
734
+ # Detect patterns (combo of cyclical + burst)
735
+ def self.detect_patterns(time_series)
736
+ patterns = []
737
+
738
+ cycles = detect_cycles(time_series)
739
+ bursts = detect_bursts(time_series)
740
+ trend = detect_trend(time_series)
741
+
742
+ if cycles[:detected]
743
+ patterns << {
744
+ type: :cyclical,
745
+ severity: cycles[:strength] > 3 ? :high : :medium,
746
+ description: cycles[:description],
747
+ recommendation: "Consider scheduling maintenance windows during off-peak hours (#{cycles[:off_peak_hour]}:00)"
748
+ }
749
+ end
750
+
751
+ if bursts.any?
752
+ critical_bursts = bursts.select { |b| b[:severity] == :critical }
753
+ patterns << {
754
+ type: :burst,
755
+ severity: critical_bursts.any? ? :high : :medium,
756
+ count: bursts.length,
757
+ description: "#{bursts.length} burst period(s) detected, #{critical_bursts.length} critical",
758
+ recommendation: "Investigate root cause of error spikes — check deployment logs around burst times"
759
+ }
760
+ end
761
+
762
+ if trend[:direction] == :increasing
763
+ patterns << {
764
+ type: :increasing_trend,
765
+ severity: trend[:pct_change] > 50 ? :high : :medium,
766
+ description: trend[:description],
767
+ recommendation: "Error rate is climbing — review recent code changes and monitor closely"
768
+ }
769
+ end
770
+
771
+ if trend[:direction] == :decreasing
772
+ patterns << {
773
+ type: :decreasing_trend,
774
+ severity: :positive,
775
+ description: trend[:description],
776
+ recommendation: "Error rate declining — recent fixes appear effective"
777
+ }
778
+ end
779
+
780
+ patterns
781
+ end
782
+
783
+
784
+ # ─── Workflow Management ──────────────────────────────────────
785
+
786
+ # Assign this exception to someone
787
+ def assign_to(user, author: "system")
788
+ update!(assigned_to: user, assigned_at: Time.current)
789
+ comments.create!(author: author, body: "Assigned to #{user}", comment_type: "assignment")
790
+ end
791
+
792
+ # Set priority level
793
+ def set_priority(level, author: "system")
794
+ old_priority = priority
795
+ update!(priority: level)
796
+ comments.create!(author: author, body: "Priority changed from #{old_priority || 'none'} to #{level}", comment_type: "status_change")
797
+ end
798
+
799
+ # Snooze for a duration
800
+ def snooze(duration, author: "system")
801
+ update!(snoozed_until: duration.from_now)
802
+ comments.create!(author: author, body: "Snoozed for #{duration.inspect}", comment_type: "status_change")
803
+ end
804
+
805
+ # Mute (permanently silence)
806
+ def mute!(author: "system")
807
+ update!(muted: true, muted_at: Time.current)
808
+ comments.create!(author: author, body: "Muted — notifications silenced", comment_type: "status_change")
809
+ end
810
+
811
+ # Unmute
812
+ def unmute!(author: "system")
813
+ update!(muted: false, muted_at: nil)
814
+ comments.create!(author: author, body: "Unmuted — notifications restored", comment_type: "status_change")
815
+ end
816
+
817
+ # Check if currently snoozed
818
+ def snoozed?
819
+ snoozed_until.present? && snoozed_until > Time.current
820
+ end
821
+
822
+ # Check if active (not muted, not snoozed)
823
+ def active?
824
+ !muted? && !snoozed?
825
+ end
826
+
827
+ # Add a comment
828
+ def add_comment(author:, body:, comment_type: "comment")
829
+ comments.create!(author: author, body: body, comment_type: comment_type)
830
+ end
831
+
832
+ # Workflow summary stats
833
+ def self.workflow_summary
834
+ {
835
+ total: count,
836
+ muted: muted.count,
837
+ snoozed: snoozed.count,
838
+ active: active.count,
839
+ unassigned: unassigned.count,
840
+ by_priority: %w[critical high medium low].map { |p| [p, by_priority(p).count] }.to_h,
841
+ recently_assigned: where("assigned_at > ?", 7.days.ago).count
842
+ }
843
+ end
844
+
845
+ private
846
+
847
+ @@rails_root = Pathname.new(Rails.root).cleanpath.to_s
848
+ @@backtrace_regex = /^#{Regexp.escape(@@rails_root)}/
849
+
850
+ def sanitize_backtrace(trace)
851
+ return "" if trace.nil?
852
+ return trace unless trace.respond_to?(:reject)
853
+
854
+ gem_path = Bundler.bundle_path.to_s
855
+ trace.reject { |line| line.include?(gem_path) }
856
+ .collect { |line| Pathname.new(line.gsub(@@backtrace_regex, "[RAILS_ROOT]")).cleanpath.to_s }
857
+ .join("\n")
858
+ end
859
+
860
+ def rails_root
861
+ @@rails_root
862
+ end
863
+ end
864
+ end