Class: RailsNexus::LoggedException

Inherits:
BaseRecord show all
Defined in:
app/models/rails_nexus/logged_exception.rb

Constant Summary collapse

HOSTNAME =
Socket.gethostname

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from BaseRecord

separate_database?

Class Method Details

.backtrace_fingerprint(app_lines) ⇒ Object

Generate a fingerprint from backtrace lines for grouping



329
330
331
332
333
334
# File 'app/models/rails_nexus/logged_exception.rb', line 329

def self.backtrace_fingerprint(app_lines)
  return nil if app_lines.length < 2
  # Use the app-level lines (skip framework) to create a groupable key
  key = app_lines.first(5).map { |l| l.sub(/:\d+/, "").sub(/in .*/, "").strip }.join("\n")
  Digest::SHA256.hexdigest(key)[0..15]
end

.backtrace_to_hint(app_lines) ⇒ Object

Extract a human-readable hint from backtrace



337
338
339
340
341
342
343
344
345
# File 'app/models/rails_nexus/logged_exception.rb', line 337

def self.backtrace_to_hint(app_lines)
  return nil if app_lines.empty?
  line = app_lines.first
  if line =~ /^(.+?):(\d+):in `(\S+)'/
    "#{$1.sub(Rails.root.to_s, "")}:#{$2} in #{$3}"
  else
    line.sub(Rails.root.to_s, "")
  end
end

.build_time_series(days: 30) ⇒ Object

Build hourly time series of error counts



560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
# File 'app/models/rails_nexus/logged_exception.rb', line 560

def self.build_time_series(days: 30)
  days = (Integer(days, exception: false) || 30).clamp(1, 90)
  start_time = days.days.ago.beginning_of_hour
  bucket = RailsNexus::DatabaseAdapter.time_bucket_expression
  raw = where("created_at >= ?", start_time)
    .group(Arel.sql(bucket))
    .count

  # Fill gaps with zeros
  series = {}
  current = start_time
  while current <= Time.current
    key = current.strftime("%Y-%m-%d %H:00")
    series[key] = raw[key] || 0
    current += 1.hour
  end

  series
end

.capture_system_healthObject

Capture system health snapshot



423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
# File 'app/models/rails_nexus/logged_exception.rb', line 423

def self.capture_system_health
  health = {}
  begin
    gc_stat = GC.stat
    health[:gc] = {
      total_allocated: gc_stat[:total_allocated_objects],
      total_freed: gc_stat[:total_freed_objects],
      heap_allocated: gc_stat[:heap_allocated_pages],
      heap_free: gc_stat[:free_slots]
    }
  rescue StandardError
    health[:gc] = { error: "unable to capture" }
  end

  begin
    stdout, _stderr, status = Open3.capture3("ps", "-o", "rss=,vsz=", "-p", Process.pid.to_s)
    memory = status.success? ? stdout.split.map(&:to_i) : []
    health[:memory] = {
      rss_kb: memory[0] || 0,
      vsz_kb: memory[1] || 0
    }
  rescue StandardError
    health[:memory] = { error: "unable to capture" }
  end

  begin
    health[:threads] = Thread.list.size
    health[:process_id] = Process.pid
    health[:ruby_version] = RUBY_VERSION
  rescue StandardError
    # Ignore
  end

  begin
    pool = ActiveRecord::Base.connection_pool
    connections = pool.connections
    active_conns = connections.select(&:active?)
    idle_conns = connections.reject(&:active?)

    # Count dead connections (checked out but not active)
    dead_count = 0
    begin
      dead_count = pool.instance_variable_get(:@dead_connections)&.size || 0
    rescue StandardError
      # Ignore
    end

    # Waiting threads (threads waiting for a connection)
    waiting = pool.instance_variable_get(:@waiters)&.size || 0 rescue 0

    health[:db_pool] = {
      size: pool.size,
      connections: connections.size,
      active: active_conns.size,
      busy: active_conns.size,
      idle: idle_conns.size,
      dead: dead_count,
      waiting: waiting,
      utilization: pool.size > 0 ? (connections.size.to_f / pool.size * 100).round(1) : 0
    }
  rescue StandardError
    health[:db_pool] = { error: "unable to capture" }
  end

  health[:timestamp] = Time.current.iso8601
  health
end

.class_namesObject



533
534
535
# File 'app/models/rails_nexus/logged_exception.rb', line 533

def self.class_names
  select("DISTINCT exception_class").order(:exception_class).collect(&:exception_class)
end

.controller_actionsObject



537
538
539
# File 'app/models/rails_nexus/logged_exception.rb', line 537

def self.controller_actions
  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?)
end

.create_from_exception(controller, exception, data) ⇒ Object



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'app/models/rails_nexus/logged_exception.rb', line 39

def create_from_exception(controller, exception, data)
  # Storm protection: check if we should capture this error
  if defined?(RailsNexus::StormProtection) && RailsNexus::StormProtection.enabled?
    unless RailsNexus::StormProtection.allow_capture?
      RailsNexus::StormProtection.record_shed
      return nil  # Error shed by storm protection
    end
  end

  message = exception.message.to_s
  message += "\n* Extra Data\n\n#{data}" unless data.blank?

  # Detect platform from user agent and request format
  platform_data = detect_platform(controller)

  # Flush breadcrumbs before they're lost
  breadcrumbs_data = if defined?(RailsNexus::Breadcrumbs) && RailsNexus.configuration.breadcrumbs_enabled
    RailsNexus::Breadcrumbs.flush
  end

  # Extract user info for impact scoring
  user = controller.respond_to?(:current_user, true) ? controller.current_user : nil
  user_id = user&.id&.to_s || data[:user_id]&.to_s rescue nil
  user_type = user.class.name rescue nil

  # Generate fingerprint for grouping
  fingerprint = generate_fingerprint(
    exception.class.name,
    controller.controller_path,
    controller.action_name
  )

  # Check if this fingerprint already exists (occurrence counting)
  existing = where(fingerprint: fingerprint).order(created_at: :desc).first

  attrs = {
    exception_class: exception.class.name,
    controller_name: controller.controller_path,
    action_name: controller.action_name,
    message: message,
    backtrace: exception.backtrace,
    request: controller.request,
    user_info: user,
    remote_ip: controller.request.remote_ip,
    user_id: user_id,
    user_type: user_type,
    fingerprint: fingerprint,
    platform: platform_data[:platform],
    platform_version: platform_data[:platform_version],
    device_type: platform_data[:device_type],
    cause_chain: extract_cause_chain(exception),
    breadcrumbs: breadcrumbs_data.presence,
    system_health: capture_system_health,
    occurrence_count: existing ? existing.occurrence_count + 1 : 1
  }

  if existing
    existing.update!(
      message: message,
      backtrace: exception.backtrace,
      request: controller.request,
      user_info: user,
      remote_ip: controller.request.remote_ip,
      occurrence_count: existing.occurrence_count + 1,
      cause_chain: attrs[:cause_chain],
      system_health: attrs[:system_health]
    )
    RailsNexus::StormProtection.record_captured if defined?(RailsNexus::StormProtection)
    existing
  else
    record = create!(attrs)
    RailsNexus::StormProtection.record_captured if defined?(RailsNexus::StormProtection)
    record
  end
end

.detect_bursts(time_series) ⇒ Object

Detect burst periods (sudden spikes)



635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
# File 'app/models/rails_nexus/logged_exception.rb', line 635

def self.detect_bursts(time_series)
  counts = time_series.values
  return [] if counts.length < 6

  # Calculate rolling average and standard deviation
  window = [6, counts.length / 6].max  # 6-hour window or 1/6 of data
  bursts = []

  counts.each_with_index do |count, i|
    next if i < window

    window_slice = counts[(i - window)...i]
    avg = window_slice.sum.to_f / window_slice.length
    std = Math.sqrt(window_slice.map { |v| (v - avg)**2 }.sum / window_slice.length)

    # Burst = count > mean + 2*std (statistical outlier)
    threshold = avg + (2 * std)
    if count > threshold && count >= 5
      time_str = time_series.keys[i]
      bursts << {
        time: time_str,
        count: count,
        threshold: threshold.round(1),
        spike_ratio: avg > 0 ? (count / avg).round(1) : count,
        severity: if count > threshold * 2
                    :critical
                  elsif count > threshold * 1.5
                    :high
                  else
                    :medium
                  end
      }
    end
  end

  # Sort by severity and return last 10
  severity_order = { critical: 0, high: 1, medium: 2 }
  bursts.sort_by { |b| severity_order[b[:severity]] }.last(10).reverse
end

.detect_cycles(time_series) ⇒ Object

Detect cyclical patterns (regular intervals)



581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
# File 'app/models/rails_nexus/logged_exception.rb', line 581

def self.detect_cycles(time_series)
  counts = time_series.values
  return { detected: false } if counts.length < 24

  # Check hourly patterns (24-hour cycle)
  hourly_avg = Array.new(24, 0.0)
  hourly_counts = Array.new(24, 0)
  time_series.each do |time_str, count|
    hour = Time.parse(time_str).hour
    hourly_avg[hour] += count
    hourly_counts[hour] += 1
  end
  hourly_avg = hourly_avg.zip(hourly_counts).map { |a, b| b > 0 ? (a / b).round(2) : 0 }

  # Find peak and off-peak hours
  peak_hour = hourly_avg.each_with_index.max
  off_peak_hour = hourly_avg.each_with_index.reject { |v, _| v == 0 }.min
  peak_hour ||= [0, 0]
  off_peak_hour ||= [0, 0]

  # Calculate cycle strength (ratio of peak to average)
  avg = counts.sum.to_f / counts.length
  cycle_strength = avg > 0 ? (peak_hour[0] / avg).round(2) : 0

  # Check daily patterns (weekly cycle)
  daily_avg = Array.new(7, 0.0)
  daily_counts = Array.new(7, 0)
  time_series.each do |time_str, count|
    wday = Time.parse(time_str).wday
    daily_avg[wday] += count
    daily_counts[wday] += 1
  end
  daily_avg = daily_avg.zip(daily_counts).map { |a, b| b > 0 ? (a / b).round(2) : 0 }

  {
    detected: cycle_strength > 1.5,
    strength: cycle_strength,
    hourly_pattern: hourly_avg,
    daily_pattern: daily_avg,
    peak_hour: peak_hour[1],
    peak_hourly_avg: peak_hour[0],
    off_peak_hour: off_peak_hour[1],
    off_peak_hourly_avg: off_peak_hour[0],
    description: if cycle_strength > 2.0
                   "Strong hourly cycle detected — peak at #{peak_hour[1]}:00 (#{peak_hour[0].round(1)}x average)"
                 elsif cycle_strength > 1.5
                   "Moderate hourly cycle — peak at #{peak_hour[1]}:00 (#{cycle_strength}x average)"
                 else
                   "No significant hourly cycle detected"
                 end
  }
end

.detect_hotspots(time_series) ⇒ Object

Detect hotspots (times of day with highest error rates)



725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
# File 'app/models/rails_nexus/logged_exception.rb', line 725

def self.detect_hotspots(time_series)
  hourly = Hash.new(0)
  hourly_counts = Hash.new(0)

  time_series.each do |time_str, count|
    hour = Time.parse(time_str).hour
    hourly[hour] += count
    hourly_counts[hour] += 1
  end

  # Calculate average per hour
  hourly_avg = hourly.map { |h, c| [h, c.to_f / hourly_counts[h]] }.sort_by { |_, v| -v }

  # Return top 5 hotspot hours
  hourly_avg.first(5).map do |hour, avg|
    {
      hour: hour,
      avg_errors: avg.round(2),
      label: format("%d:00 - %d:00", hour, (hour + 1) % 24)
    }
  end
end

.detect_occurrence_patterns(days: 30) ⇒ Object

Main entry point: analyze error patterns over the last N days



546
547
548
549
550
551
552
553
554
555
556
557
# File 'app/models/rails_nexus/logged_exception.rb', line 546

def self.detect_occurrence_patterns(days: 30)
  time_series = build_time_series(days: days)
  return { patterns: [], hotspots: [], trend: nil } if time_series.empty?

  {
    patterns: detect_patterns(time_series),
    hotspots: detect_hotspots(time_series),
    trend: detect_trend(time_series),
    burst_periods: detect_bursts(time_series),
    cycle_info: detect_cycles(time_series)
  }
end

.detect_patterns(time_series) ⇒ Object

Detect patterns (combo of cyclical + burst)



749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
# File 'app/models/rails_nexus/logged_exception.rb', line 749

def self.detect_patterns(time_series)
  patterns = []

  cycles = detect_cycles(time_series)
  bursts = detect_bursts(time_series)
  trend = detect_trend(time_series)

  if cycles[:detected]
    patterns << {
      type: :cyclical,
      severity: cycles[:strength] > 3 ? :high : :medium,
      description: cycles[:description],
      recommendation: "Consider scheduling maintenance windows during off-peak hours (#{cycles[:off_peak_hour]}:00)"
    }
  end

  if bursts.any?
    critical_bursts = bursts.select { |b| b[:severity] == :critical }
    patterns << {
      type: :burst,
      severity: critical_bursts.any? ? :high : :medium,
      count: bursts.length,
      description: "#{bursts.length} burst period(s) detected, #{critical_bursts.length} critical",
      recommendation: "Investigate root cause of error spikes — check deployment logs around burst times"
    }
  end

  if trend[:direction] == :increasing
    patterns << {
      type: :increasing_trend,
      severity: trend[:pct_change] > 50 ? :high : :medium,
      description: trend[:description],
      recommendation: "Error rate is climbing — review recent code changes and monitor closely"
    }
  end

  if trend[:direction] == :decreasing
    patterns << {
      type: :decreasing_trend,
      severity: :positive,
      description: trend[:description],
      recommendation: "Error rate declining — recent fixes appear effective"
    }
  end

  patterns
end

.detect_platform(controller) ⇒ Object

Detect platform from user_agent and request



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'app/models/rails_nexus/logged_exception.rb', line 156

def self.detect_platform(controller)
  request = controller.request
  user_agent = request.respond_to?(:user_agent) ? request.user_agent.to_s.downcase : ""

  # API detection: no user_agent or JSON/XML format
  if user_agent.blank? || request.format&.ref == "json" || request.format&.ref == "xml"
    return { platform: "api", platform_version: nil, device_type: "server", app_version: nil }
  end

  # iOS detection
  if user_agent.include?("iphone") || user_agent.include?("ipad") || user_agent.include?("ipod")
    version = user_agent[/os (\d+[._]\d+)/i, 1]&.gsub("_", ".")
    return { platform: "ios", platform_version: version, device_type: user_agent.include?("ipad") ? "tablet" : "phone", app_version: nil }
  end

  # Android detection
  if user_agent.include?("android")
    version = user_agent[/android (\d+[.\d]*)/i, 1]
    return { platform: "android", platform_version: version, device_type: "phone", app_version: nil }
  end

  # macOS detection
  if user_agent.include?("macintosh") || user_agent.include?("mac os")
    return { platform: "web", platform_version: "macOS", device_type: "desktop", app_version: nil }
  end

  # Windows detection
  if user_agent.include?("windows")
    return { platform: "web", platform_version: "Windows", device_type: "desktop", app_version: nil }
  end

  # Linux detection
  if user_agent.include?("linux")
    return { platform: "web", platform_version: "Linux", device_type: "desktop", app_version: nil }
  end

  # Bot/crawler detection
  if user_agent.include?("bot") || user_agent.include?("crawler") || user_agent.include?("spider")
    return { platform: "bot", platform_version: nil, device_type: "bot", app_version: nil }
  end

  # Default: web
  { platform: "web", platform_version: nil, device_type: "unknown", app_version: nil }
end

.detect_trend(time_series) ⇒ Object

Detect trend (increasing/decreasing/stable)



676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
# File 'app/models/rails_nexus/logged_exception.rb', line 676

def self.detect_trend(time_series)
  counts = time_series.values
  return { direction: :stable, slope: 0 } if counts.length < 12

  # Split into halves and compare
  midpoint = counts.length / 2
  first_half = counts[0...midpoint]
  second_half = counts[midpoint..]

  first_avg = first_half.sum.to_f / first_half.length
  second_avg = second_half.sum.to_f / second_half.length

  # Linear regression for slope
  n = counts.length
  x_mean = (n - 1) / 2.0
  y_mean = counts.sum.to_f / n
  numerator = counts.each_with_index.map { |y, x| (x - x_mean) * (y - y_mean) }.sum
  denominator = counts.each_with_index.map { |x, _| (x - x_mean)**2 }.sum
  slope = denominator > 0 ? numerator / denominator : 0

  # Percentage change
  pct_change = first_avg > 0 ? ((second_avg - first_avg) / first_avg * 100).round(1) : 0

  direction = if pct_change > 20
                :increasing
              elsif pct_change < -20
                :decreasing
              else
                :stable
              end

  {
    direction: direction,
    slope: slope.round(4),
    first_half_avg: first_avg.round(2),
    second_half_avg: second_avg.round(2),
    pct_change: pct_change,
    description: case direction
                 when :increasing
                   "Errors increasing (+#{pct_change}% over period)"
                 when :decreasing
                   "Errors decreasing (#{pct_change}% over period)"
                 else
                   "Error rate stable over period"
                 end
  }
end

.extract_cause_chain(exception) ⇒ Object

Extract cause chain from exception



407
408
409
410
411
412
413
414
415
416
417
418
419
420
# File 'app/models/rails_nexus/logged_exception.rb', line 407

def self.extract_cause_chain(exception)
  chain = []
  current = exception
  while current && chain.length < 10  # Limit depth
    chain << {
      class_name: current.class.name,
      message: current.message.to_s.truncate(500),
      backtrace: current.backtrace&.first(5)&.join("
")
    }
    current = current.cause
  end
  chain
end

.extract_table_from_backtrace(app_lines) ⇒ Object

Try to extract table name from backtrace lines



348
349
350
351
352
353
354
355
356
357
358
359
360
# File 'app/models/rails_nexus/logged_exception.rb', line 348

def self.extract_table_from_backtrace(app_lines)
  app_lines.each do |line|
    # Match patterns like: `find_all_by_#{table}`, `where_#{table}`
    if line =~ /(?:find|where|select|from|join|has_many|belongs_to)[_s]*(\w+)/i
      return $1.downcase
    end
  end
  # Fall back to the action name from the first app line
  if app_lines.first =~ /#(\w+)\z/
    return $1
  end
  "app"
end

.extract_table_name(sql) ⇒ Object

Extract table name from SQL query



396
397
398
399
400
401
402
403
404
# File 'app/models/rails_nexus/logged_exception.rb', line 396

def self.extract_table_name(sql)
  return "unknown" if sql.blank?
  # Match FROM/INTO/UPDATE/JOIN table patterns
  if sql =~ /(?:FROM|INTO|UPDATE|JOIN)\s+["']?(\w+)/i
    $1.downcase
  else
    "unknown"
  end
end

.generate_fingerprint(exception_class, controller_name, action_name) ⇒ Object

Generate fingerprint for grouping similar exceptions



150
151
152
153
# File 'app/models/rails_nexus/logged_exception.rb', line 150

def self.generate_fingerprint(exception_class, controller_name, action_name)
  require "digest"
  Digest::SHA256.hexdigest("#{exception_class}#{controller_name}#{action_name}")[0..15]
end

.host_nameObject



115
116
117
# File 'app/models/rails_nexus/logged_exception.rb', line 115

def host_name
  HOSTNAME
end

.n_plus_one_from_backtraces(limit: 20) ⇒ Object

Backtrace-based N+1 detection (works without breadcrumbs) Looks at repeated backtrace patterns in application code that suggest N+1 queries



285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'app/models/rails_nexus/logged_exception.rb', line 285

def self.n_plus_one_from_backtraces(limit: 20)
  patterns = {}

  where.not(backtrace: nil)
    .where("created_at >= ?", 7.days.ago)
    .order(created_at: :desc)
    .limit(500)
    .find_each do |exception|
      lines = exception.backtrace.to_s.split("\n")
      next if lines.empty?

      # Detect N+1 signals in backtrace
      app_lines = lines.select { |l| l.include?(Rails.root.to_s) && !l.include?("rails_nexus") }
      next if app_lines.empty?

      # Look for ActiveRecord collection iteration patterns:
      # e.g., .each, .map, .find_each near ActiveRecord calls
      app_lines.any? { |l| l.match?(/\.each|\.map|\.select|\.reject|\.flat_map|\.find_each/) }
      app_lines.any? { |l| l.match?(/ActiveRecord|_callback|association|belongs_to|has_many|load_target|reload/) }

      # Also detect: repeated similar backtrace across multiple exceptions of same class
      fingerprint = backtrace_fingerprint(app_lines)
      next if fingerprint.blank?

      patterns[fingerprint] ||= {
        fingerprint: fingerprint,
        sql_sample: backtrace_to_hint(app_lines),
        table: extract_table_from_backtrace(app_lines),
        count: 0,
        exception_ids: [],
        avg_duration: 0,
        source: :backtrace
      }
      patterns[fingerprint][:count] += 1
      patterns[fingerprint][:exception_ids] << exception.id
    end

  # Only return patterns seen 3+ times (likely real N+1 issues)
  patterns.values.select { |p| p[:count] >= 3 }
    .sort_by { |p| -p[:count] }
    .first(limit)
end

.n_plus_one_patterns(limit: 20) ⇒ Object

N+1 Query Pattern Detection Analyzes breadcrumbs for repeated similar SQL queries



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'app/models/rails_nexus/logged_exception.rb', line 227

def self.n_plus_one_patterns(limit: 20)
  patterns = {}

  # Get recent exceptions with breadcrumbs
  where.not(breadcrumbs: nil)
    .where("created_at >= ?", 7.days.ago)
    .order(created_at: :desc)
    .limit(500)
    .find_each do |exception|
      crumbs = parse_breadcrumbs(exception.breadcrumbs)
      next unless crumbs.is_a?(Array)

      # Extract SQL queries from breadcrumbs
      sql_crumbs = crumbs.select { |c| c[:type] == "sql" }
      next if sql_crumbs.empty?

      # Group by fingerprint (normalized SQL)
      sql_crumbs.group_by { |c| sql_fingerprint(c[:name]) }.each do |fingerprint, queries|
        next if queries.length < 3  # N+1 = 3+ similar queries

        table = extract_table_name(queries.first[:name])
        patterns[fingerprint] ||= {
          fingerprint: fingerprint,
          sql_sample: queries.first[:name]&.truncate(200),
          table: table,
          count: 0,
          exception_ids: [],
          avg_duration: 0,
          source: :breadcrumbs
        }
        patterns[fingerprint][:count] += queries.length
        patterns[fingerprint][:exception_ids] << exception.id
        durations = queries.map { |q| q[:duration] }.compact
        patterns[fingerprint][:avg_duration] = durations.any? ? (durations.sum / durations.size).round(2) : 0
      end
    end

  # Supplement with backtrace-based N+1 detection
  backtrace_patterns = n_plus_one_from_backtraces(limit: limit)
  backtrace_patterns.each do |bp|
    key = bp[:fingerprint]
    if patterns[key]
      patterns[key][:count] += bp[:count]
      patterns[key][:exception_ids] = (patterns[key][:exception_ids] + bp[:exception_ids]).uniq
    else
      patterns[key] = bp
    end
  end

  # Sort by frequency and return top N
  patterns.values
    .sort_by { |p| -p[:count] }
    .first(limit)
    .map { |p| p.merge(exception_ids: p[:exception_ids].uniq.first(5)) }
end

.n_plus_one_summaryObject

N+1 summary stats



363
364
365
366
367
368
369
370
371
372
373
374
375
# File 'app/models/rails_nexus/logged_exception.rb', line 363

def self.n_plus_one_summary
  patterns = n_plus_one_patterns(limit: 100)
  {
    total_patterns: patterns.sum { |p| p[:count] },
    unique_patterns: patterns.size,
    worst_pattern: patterns.first,
    top_tables: patterns.group_by { |p| p[:table] }
      .transform_values { |ps| ps.sum { |p| p[:count] } }
      .sort_by { |_, count| -count }
      .first(5)
      .map { |table, count| { table: table, count: count } }
  }
end

.parse_breadcrumbs(data) ⇒ Object



377
378
379
380
# File 'app/models/rails_nexus/logged_exception.rb', line 377

def self.parse_breadcrumbs(data)
  return data if data.is_a?(Array)
  JSON.parse(data, symbolize_names: true) rescue []
end

.platform_statsObject

Platform analytics



202
203
204
205
206
207
208
209
210
211
212
# File 'app/models/rails_nexus/logged_exception.rb', line 202

def self.platform_stats
  select(<<~SQL
    platform,
    COUNT(*) as total,
    COUNT(DISTINCT exception_class) as unique_classes,
    MAX(created_at) as last_seen
  SQL
  )
  .group(:platform)
  .order("total DESC")
end

.platform_top_errors(platform:, limit: 5) ⇒ Object

Platform-specific top errors



215
216
217
218
219
220
221
# File 'app/models/rails_nexus/logged_exception.rb', line 215

def self.platform_top_errors(platform:, limit: 5)
  by_platform(platform)
    .select(:exception_class, "COUNT(*) as count")
    .group(:exception_class)
    .order("count DESC")
    .limit(limit)
end

.ransackable_associations(_auth_object = nil) ⇒ Object



34
35
36
# File 'app/models/rails_nexus/logged_exception.rb', line 34

def self.ransackable_associations(_auth_object = nil)
  []
end

.ransackable_attributes(_auth_object = nil) ⇒ Object



27
28
29
30
31
32
# File 'app/models/rails_nexus/logged_exception.rb', line 27

def self.ransackable_attributes(_auth_object = nil)
  %w[action_name backtrace cause_chain controller_name created_at device_type environment
     assigned_to comments_count exception_class fingerprint id instance_variables local_variables
     message muted occurrence_count platform platform_version priority remote_ip request
     snoozed_until system_health updated_at user_agent user_id user_info]
end

.sql_fingerprint(sql) ⇒ Object

Normalize SQL fingerprint: remove values, keep structure



383
384
385
386
387
388
389
390
391
392
393
# File 'app/models/rails_nexus/logged_exception.rb', line 383

def self.sql_fingerprint(sql)
  return "" if sql.blank?
  sql.strip
    .gsub(/\s+/, " ")                    # Normalize whitespace
    .gsub(/\d+/, "?")                    # Replace numbers with ?
    .gsub(/'[^']*'/, "?")              # Replace string values with ?
    .gsub(/"[^"]*"/, "?")              # Replace quoted identifiers
    .gsub(/\d+\.\d+/, "?")      # Replace decimals
    .gsub(/0x[0-9a-f]+/i, "?")     # Replace hex values
    .strip
end

.user_impact_ranking(limit: 20) ⇒ Object

User impact scoring: rank by unique users affected



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'app/models/rails_nexus/logged_exception.rb', line 129

def self.user_impact_ranking(limit: 20)
  select(<<~SQL
    exception_class,
    controller_name,
    action_name,
    COUNT(DISTINCT user_id) as unique_users,
    COUNT(*) as total_occurrences,
    MAX(created_at) as last_seen
  SQL
  )
  .where.not(user_id: nil)
  .group(:exception_class, :controller_name, :action_name)
  .order("unique_users DESC, total_occurrences DESC")
  .limit(limit)
end

.workflow_summaryObject

Workflow summary stats



847
848
849
850
851
852
853
854
855
856
857
# File 'app/models/rails_nexus/logged_exception.rb', line 847

def self.workflow_summary
  {
    total: count,
    muted: muted.count,
    snoozed: snoozed.count,
    active: active.count,
    unassigned: unassigned.count,
    by_priority: %w[critical high medium low].map { |p| [p, by_priority(p).count] }.to_h,
    recently_assigned: where("assigned_at > ?", 7.days.ago).count
  }
end

Instance Method Details

#active?Boolean

Check if active (not muted, not snoozed)

Returns:

  • (Boolean)


837
838
839
# File 'app/models/rails_nexus/logged_exception.rb', line 837

def active?
  !muted? && !snoozed?
end

#add_comment(author:, body:, comment_type: "comment") ⇒ Object

Add a comment



842
843
844
# File 'app/models/rails_nexus/logged_exception.rb', line 842

def add_comment(author:, body:, comment_type: "comment")
  comments.create!(author: author, body: body, comment_type: comment_type)
end

#assign_to(user, author: "system") ⇒ Object

Assign this exception to someone



801
802
803
804
# File 'app/models/rails_nexus/logged_exception.rb', line 801

def assign_to(user, author: "system")
  update!(assigned_to: user, assigned_at: Time.current)
  comments.create!(author: author, body: "Assigned to #{user}", comment_type: "assignment")
end

#backtrace=(trace) ⇒ Object



491
492
493
494
# File 'app/models/rails_nexus/logged_exception.rb', line 491

def backtrace=(trace)
  trace = sanitize_backtrace(trace) unless trace.is_a?(String)
  write_attribute :backtrace, trace
end

#controller_actionObject



529
530
531
# File 'app/models/rails_nexus/logged_exception.rb', line 529

def controller_action
  @controller_action ||= "#{controller_name.camelcase}/#{action_name}"
end

#mute!(author: "system") ⇒ Object

Mute (permanently silence)



820
821
822
823
# File 'app/models/rails_nexus/logged_exception.rb', line 820

def mute!(author: "system")
  update!(muted: true, muted_at: Time.current)
  comments.create!(author: author, body: "Muted — notifications silenced", comment_type: "status_change")
end

#nameObject



145
146
147
# File 'app/models/rails_nexus/logged_exception.rb', line 145

def name
  "#{self.exception_class} in #{self.controller_action}"
end

#request=(request) ⇒ Object



496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
# File 'app/models/rails_nexus/logged_exception.rb', line 496

def request=(request)
  if request.is_a?(String)
    write_attribute :request, request
  elsif request.respond_to?(:env) && request.env.is_a?(Hash)
    filtered_env = RailsNexus.filter_sensitive_data(request.env)
    keys = filtered_env.keys.map(&:to_s).sort
    max_length = keys.map(&:length).max || 0
    env = keys.each_with_object([]) do |key, memo|
      value = filtered_env[key] || filtered_env[key.to_sym]
      memo << ("* %-*s: %s" % [max_length, key, value.to_s.strip])
    end
    write_attribute(:environment, (env << "* Process: #{$$}" << "* Server : #{self.class.host_name}").join("\n"))

    method_str = request.respond_to?(:get?) && request.get? ? "" : " #{request.respond_to?(:method) ? request.method.to_s.upcase : "GET"}"
    parameters = request.respond_to?(:parameters) ? RailsNexus.filter_sensitive_data(request.parameters) : {}
    request_path = if request.respond_to?(:path)
                     request.path
                   elsif request.respond_to?(:fullpath)
                     request.fullpath.to_s.split("?", 2).first
                   else
                     "/"
                   end
    write_attribute(:request, [
      "* URL:#{method_str} #{request.respond_to?(:protocol) ? request.protocol : "http://"}#{filtered_env["HTTP_HOST"] || "localhost"}#{request_path}",
      "* Format: #{request.respond_to?(:format) ? request.format.to_s : "html"}",
      "* Parameters: #{parameters.inspect}",
      "* Rails Root: #{rails_root}"
    ].join("\n"))
  else
    write_attribute :request, request.to_s
  end
end

#set_priority(level, author: "system") ⇒ Object

Set priority level



807
808
809
810
811
# File 'app/models/rails_nexus/logged_exception.rb', line 807

def set_priority(level, author: "system")
  old_priority = priority
  update!(priority: level)
  comments.create!(author: author, body: "Priority changed from #{old_priority || 'none'} to #{level}", comment_type: "status_change")
end

#snooze(duration, author: "system") ⇒ Object

Snooze for a duration



814
815
816
817
# File 'app/models/rails_nexus/logged_exception.rb', line 814

def snooze(duration, author: "system")
  update!(snoozed_until: duration.from_now)
  comments.create!(author: author, body: "Snoozed for #{duration.inspect}", comment_type: "status_change")
end

#snoozed?Boolean

Check if currently snoozed

Returns:

  • (Boolean)


832
833
834
# File 'app/models/rails_nexus/logged_exception.rb', line 832

def snoozed?
  snoozed_until.present? && snoozed_until > Time.current
end

#unmute!(author: "system") ⇒ Object

Unmute



826
827
828
829
# File 'app/models/rails_nexus/logged_exception.rb', line 826

def unmute!(author: "system")
  update!(muted: false, muted_at: nil)
  comments.create!(author: author, body: "Unmuted — notifications restored", comment_type: "status_change")
end