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



429
430
431
432
433
434
# File 'app/models/rails_nexus/logged_exception.rb', line 429

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



437
438
439
440
441
442
443
444
445
# File 'app/models/rails_nexus/logged_exception.rb', line 437

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



685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
# File 'app/models/rails_nexus/logged_exception.rb', line 685

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_health ⇒ Object

Capture system health snapshot



523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
# File 'app/models/rails_nexus/logged_exception.rb', line 523

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_names ⇒ Object



658
659
660
# File 'app/models/rails_nexus/logged_exception.rb', line 658

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

.controller_actions ⇒ Object



662
663
664
# File 'app/models/rails_nexus/logged_exception.rb', line 662

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



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
114
115
116
117
118
119
120
121
# File 'app/models/rails_nexus/logged_exception.rb', line 41

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

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

  request = safe_call(controller, :request)
  controller_name = safe_call(controller, :controller_path) || controller.class.name
  action_name = safe_call(controller, :action_name)

  # 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 = current_user_for(controller)
  user_context = capture_user_context(user, data)
  user_id = user_context[:id]&.to_s || data[:user_id]&.to_s || data["user_id"]&.to_s
  user_type = user_context[:type]

  # Generate fingerprint for grouping
  fingerprint = generate_fingerprint(
    exception.class.name,
    controller_name,
    action_name,
    message: exception.message,
    backtrace: exception.backtrace
  )

  # 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_name,
    action_name: action_name,
    message: message,
    backtrace: exception.backtrace,
    request: request,
    user_info: user_context.except(:id, :type).presence&.to_json,
    user_agent: safe_call(request, :user_agent),
    remote_ip: safe_call(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],
    app_version: app_version(request, data),
    cause_chain: extract_cause_chain(exception),
    breadcrumbs: breadcrumbs_data.presence,
    system_health: capture_system_health,
    local_variables: data.presence,
    instance_variables: capture_exception_variables(exception).presence,
    occurrence_count: existing ? existing.occurrence_count + 1 : 1
  }

  if existing
    existing.update!(attrs.except(:fingerprint, :occurrence_count).merge(
      occurrence_count: existing.occurrence_count + 1,
      created_at: Time.current
    ))
    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)



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
796
797
798
# File 'app/models/rails_nexus/logged_exception.rb', line 760

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)



706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
# File 'app/models/rails_nexus/logged_exception.rb', line 706

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)



850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
# File 'app/models/rails_nexus/logged_exception.rb', line 850

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



671
672
673
674
675
676
677
678
679
680
681
682
# File 'app/models/rails_nexus/logged_exception.rb', line 671

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)



874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
# File 'app/models/rails_nexus/logged_exception.rb', line 874

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



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
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'app/models/rails_nexus/logged_exception.rb', line 250

def self.detect_platform(controller)
  request = controller.request
  return { platform: "unknown", platform_version: nil, device_type: "unknown", app_version: nil } unless request

  user_agent = request.respond_to?(:user_agent) ? request.user_agent.to_s.downcase : ""

  # API detection: no user_agent or JSON/XML format
  format = request.respond_to?(:format) ? request.format : nil
  format_ref = format.respond_to?(:ref) ? format.ref.to_s : format.to_s
  if user_agent.blank? || %w[json xml].include?(format_ref)
    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 }
rescue StandardError
  { platform: "unknown", platform_version: nil, device_type: "unknown", app_version: nil }
end

.detect_trend(time_series) ⇒ Object

Detect trend (increasing/decreasing/stable)



801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
# File 'app/models/rails_nexus/logged_exception.rb', line 801

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



507
508
509
510
511
512
513
514
515
516
517
518
519
520
# File 'app/models/rails_nexus/logged_exception.rb', line 507

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



448
449
450
451
452
453
454
455
456
457
458
459
460
# File 'app/models/rails_nexus/logged_exception.rb', line 448

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



496
497
498
499
500
501
502
503
504
# File 'app/models/rails_nexus/logged_exception.rb', line 496

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, message: nil, backtrace: nil) ⇒ Object

Generate fingerprint for grouping similar exceptions



236
237
238
239
240
241
242
243
244
245
246
247
# File 'app/models/rails_nexus/logged_exception.rb', line 236

def self.generate_fingerprint(exception_class, controller_name, action_name, message: nil, backtrace: nil)
  require "digest"
  normalized_message = message.to_s
    .gsub(/\b\d+\b/, "?")
    .gsub(/\b[0-9a-f]{8,}\b/i, "?")
    .gsub(/\s+/, " ")
    .strip
  source = Array(backtrace).find { |line| line.to_s.include?(Rails.root.to_s) } || Array(backtrace).first
  normalized_source = source.to_s.sub(/:\d+(?::in .*)?\z/, "")
  parts = [exception_class, controller_name, action_name, normalized_message, normalized_source]
  Digest::SHA256.hexdigest(parts.join("\0"))[0, 16]
end

.host_name ⇒ Object



123
124
125
# File 'app/models/rails_nexus/logged_exception.rb', line 123

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



385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
# File 'app/models/rails_nexus/logged_exception.rb', line 385

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



327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'app/models/rails_nexus/logged_exception.rb', line 327

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_summary ⇒ Object

N+1 summary stats



463
464
465
466
467
468
469
470
471
472
473
474
475
# File 'app/models/rails_nexus/logged_exception.rb', line 463

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



477
478
479
480
# File 'app/models/rails_nexus/logged_exception.rb', line 477

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

.platform_stats ⇒ Object

Platform analytics



302
303
304
305
306
307
308
309
310
311
312
# File 'app/models/rails_nexus/logged_exception.rb', line 302

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



315
316
317
318
319
320
321
# File 'app/models/rails_nexus/logged_exception.rb', line 315

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



36
37
38
# File 'app/models/rails_nexus/logged_exception.rb', line 36

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

.ransackable_attributes(_auth_object = nil) ⇒ Object



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

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



483
484
485
486
487
488
489
490
491
492
493
# File 'app/models/rails_nexus/logged_exception.rb', line 483

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



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'app/models/rails_nexus/logged_exception.rb', line 215

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_summary ⇒ Object

Workflow summary stats



972
973
974
975
976
977
978
979
980
981
982
# File 'app/models/rails_nexus/logged_exception.rb', line 972

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)


962
963
964
# File 'app/models/rails_nexus/logged_exception.rb', line 962

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

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

Add a comment



967
968
969
# File 'app/models/rails_nexus/logged_exception.rb', line 967

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



926
927
928
929
# File 'app/models/rails_nexus/logged_exception.rb', line 926

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



591
592
593
594
# File 'app/models/rails_nexus/logged_exception.rb', line 591

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

#controller_action ⇒ Object



654
655
656
# File 'app/models/rails_nexus/logged_exception.rb', line 654

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

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

Mute (permanently silence)



945
946
947
948
# File 'app/models/rails_nexus/logged_exception.rb', line 945

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

#name ⇒ Object



231
232
233
# File 'app/models/rails_nexus/logged_exception.rb', line 231

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

#request=(request) ⇒ Object



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
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
# File 'app/models/rails_nexus/logged_exception.rb', line 596

def request=(request)
  if request.is_a?(String)
    write_attribute :request, request
  elsif request.respond_to?(:env) && request.env.is_a?(Hash)
    captured_env_keys = %w[
      REQUEST_METHOD HTTP_HOST SERVER_NAME SERVER_PORT HTTPS
      HTTP_X_FORWARDED_PROTO HTTP_X_FORWARDED_HOST HTTP_X_REQUEST_ID
      HTTP_X_CORRELATION_ID HTTP_TRACEPARENT HTTP_TRACESTATE HTTP_BAGGAGE
      action_dispatch.request_id HTTP_REFERER HTTP_ORIGIN CONTENT_TYPE
      CONTENT_LENGTH HTTP_ACCEPT HTTP_ACCEPT_LANGUAGE HTTP_USER_AGENT
      HTTP_X_APP_VERSION HTTP_X_CLIENT_VERSION HTTP_AUTHORIZATION HTTP_COOKIE
    ]
    captured_env = request.env.slice(*captured_env_keys)
    filtered_env = RailsNexus.filter_sensitive_data(captured_env)
    environment = {
      "Rails Environment" => Rails.env.to_s,
      "Rails Version" => Rails.version,
      "Ruby Version" => RUBY_DESCRIPTION,
      "Process" => Process.pid,
      "Server" => self.class.host_name
    }.merge(filtered_env)
    max_length = environment.keys.map(&:length).max || 0
    write_attribute(:environment, environment.sort.map do |key, value|
      "* %-*s: %s" % [max_length, key, value.to_s.strip.truncate(10_000)]
    end.join("\n"))

    method = if request.respond_to?(:request_method)
               request.request_method
             elsif request.respond_to?(:method)
               request.method
             else
               "GET"
             end
    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
    request_id = request.respond_to?(:request_id) ? request.request_id : filtered_env["HTTP_X_REQUEST_ID"]
    host = filtered_env["HTTP_HOST"] || filtered_env["SERVER_NAME"] || "localhost"
    protocol = request.respond_to?(:protocol) ? request.protocol : "http://"
    write_attribute(:request, [
      "* Method: #{method.to_s.upcase}",
      "* URL: #{protocol}#{host}#{request_path}",
      ("* Request ID: #{request_id}" if request_id.present?),
      "* Format: #{request.respond_to?(:format) ? request.format.to_s : "html"}",
      ("* Content Type: #{request.content_type}" if request.respond_to?(:content_type) && request.content_type.present?),
      "* Parameters: #{parameters.inspect}",
      "* Rails Root: #{rails_root}"
    ].compact.join("\n"))
  else
    write_attribute :request, request.to_s
  end
end

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

Set priority level



932
933
934
935
936
# File 'app/models/rails_nexus/logged_exception.rb', line 932

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



939
940
941
942
# File 'app/models/rails_nexus/logged_exception.rb', line 939

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)


957
958
959
# File 'app/models/rails_nexus/logged_exception.rb', line 957

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

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

Unmute



951
952
953
954
# File 'app/models/rails_nexus/logged_exception.rb', line 951

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