rails_error_dashboard 0.1.37 → 0.2.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -4
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +2 -5
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +2 -3
  5. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +10 -0
  6. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +19 -15
  7. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +19 -9
  8. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +37 -11
  9. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +44 -0
  10. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +38 -16
  11. data/app/models/rails_error_dashboard/error_log.rb +10 -0
  12. data/app/models/rails_error_dashboard/error_logs_record.rb +11 -6
  13. data/app/views/layouts/rails_error_dashboard.html.erb +16 -0
  14. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +3 -0
  15. data/app/views/rails_error_dashboard/errors/_stats.html.erb +12 -4
  16. data/app/views/rails_error_dashboard/errors/index.html.erb +9 -7
  17. data/app/views/rails_error_dashboard/errors/show.html.erb +138 -7
  18. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +36 -0
  19. data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +1 -1
  20. data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +1 -1
  21. data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +1 -1
  22. data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +1 -1
  23. data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +1 -1
  24. data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +1 -1
  25. data/db/migrate/20251225100236_create_error_occurrences.rb +1 -1
  26. data/db/migrate/20251225101920_create_cascade_patterns.rb +1 -1
  27. data/db/migrate/20251225102500_create_error_baselines.rb +1 -1
  28. data/db/migrate/20251226020000_add_workflow_fields_to_error_logs.rb +1 -1
  29. data/db/migrate/20251226020100_create_error_comments.rb +1 -1
  30. data/db/migrate/20251230075315_cleanup_orphaned_migrations.rb +1 -1
  31. data/db/migrate/20260220000001_add_exception_cause_to_error_logs.rb +9 -0
  32. data/db/migrate/20260220000002_add_enriched_context_to_error_logs.rb +12 -0
  33. data/db/migrate/20260220000003_add_time_series_indexes_to_error_logs.rb +67 -0
  34. data/db/migrate/20260221000001_add_environment_info_to_error_logs.rb +9 -0
  35. data/db/migrate/20260221000002_add_reopened_at_to_error_logs.rb +9 -0
  36. data/lib/generators/rails_error_dashboard/install/install_generator.rb +145 -24
  37. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +12 -8
  38. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +58 -10
  39. data/lib/rails_error_dashboard/commands/log_error.rb +109 -10
  40. data/lib/rails_error_dashboard/configuration.rb +52 -0
  41. data/lib/rails_error_dashboard/manual_error_reporter.rb +12 -0
  42. data/lib/rails_error_dashboard/middleware/error_catcher.rb +3 -0
  43. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +8 -0
  44. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -0
  45. data/lib/rails_error_dashboard/services/backtrace_parser.rb +31 -0
  46. data/lib/rails_error_dashboard/services/backtrace_processor.rb +31 -1
  47. data/lib/rails_error_dashboard/services/cause_chain_extractor.rb +62 -0
  48. data/lib/rails_error_dashboard/services/environment_snapshot.rb +85 -0
  49. data/lib/rails_error_dashboard/services/error_hash_generator.rb +50 -2
  50. data/lib/rails_error_dashboard/services/notification_throttler.rb +109 -0
  51. data/lib/rails_error_dashboard/services/platform_detector.rb +36 -11
  52. data/lib/rails_error_dashboard/services/sensitive_data_filter.rb +176 -0
  53. data/lib/rails_error_dashboard/value_objects/error_context.rb +81 -4
  54. data/lib/rails_error_dashboard/version.rb +1 -1
  55. data/lib/rails_error_dashboard.rb +11 -6
  56. data/lib/tasks/error_dashboard.rake +158 -2
  57. metadata +14 -60
@@ -24,6 +24,7 @@ module RailsErrorDashboard
24
24
  total_month: base_scope.where("occurred_at >= ?", 30.days.ago).count,
25
25
  unresolved: base_scope.unresolved.count,
26
26
  resolved: base_scope.resolved.count,
27
+ reopened: reopened_count,
27
28
  by_platform: base_scope.group(:platform).count,
28
29
  top_errors: top_errors,
29
30
  # Trend visualizations
@@ -55,6 +56,7 @@ module RailsErrorDashboard
55
56
  total_month: 0,
56
57
  unresolved: 0,
57
58
  resolved: 0,
59
+ reopened: 0,
58
60
  by_platform: {},
59
61
  top_errors: {},
60
62
  errors_trend_7d: {},
@@ -93,6 +95,12 @@ module RailsErrorDashboard
93
95
  scope
94
96
  end
95
97
 
98
+ def reopened_count
99
+ return 0 unless ErrorLog.column_names.include?("reopened_at")
100
+
101
+ base_scope.where.not(reopened_at: nil).count
102
+ end
103
+
96
104
  def top_errors
97
105
  base_scope.where("occurred_at >= ?", 7.days.ago)
98
106
  .group(:error_type)
@@ -39,6 +39,7 @@ module RailsErrorDashboard
39
39
  query = filter_by_assignment(query)
40
40
  query = filter_by_priority(query)
41
41
  query = filter_by_snoozed(query)
42
+ query = filter_by_reopened(query)
42
43
  query
43
44
  end
44
45
 
@@ -195,6 +196,13 @@ module RailsErrorDashboard
195
196
  end
196
197
  end
197
198
 
199
+ def filter_by_reopened(query)
200
+ return query unless @filters[:reopened] == "true"
201
+ return query unless ErrorLog.column_names.include?("reopened_at")
202
+
203
+ query.where.not(reopened_at: nil)
204
+ end
205
+
198
206
  def filter_by_timeframe(query)
199
207
  return query unless @filters[:timeframe].present?
200
208
 
@@ -14,6 +14,19 @@ module RailsErrorDashboard
14
14
  new(backtrace_string).parse
15
15
  end
16
16
 
17
+ # Convert Thread::Backtrace::Location objects to frame hashes
18
+ # Uses structured data directly — no regex needed.
19
+ # @param locations [Array<Thread::Backtrace::Location>, nil] Backtrace locations
20
+ # @return [Array<Hash>] Parsed frames with same structure as .parse
21
+ def self.from_locations(locations)
22
+ return [] if locations.nil? || locations.empty?
23
+
24
+ new(nil).send(:convert_locations, locations)
25
+ rescue => e
26
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BacktraceParser.from_locations failed: #{e.message}")
27
+ []
28
+ end
29
+
17
30
  def initialize(backtrace_string)
18
31
  @backtrace_string = backtrace_string
19
32
  end
@@ -29,6 +42,24 @@ module RailsErrorDashboard
29
42
 
30
43
  private
31
44
 
45
+ def convert_locations(locations)
46
+ locations.map.with_index do |loc, index|
47
+ file_path = loc.absolute_path || loc.path
48
+ line_number = loc.lineno
49
+ method_name = loc.label || "(unknown)"
50
+
51
+ {
52
+ index: index,
53
+ file_path: file_path,
54
+ line_number: line_number,
55
+ method_name: method_name,
56
+ category: categorize_frame(file_path),
57
+ full_line: loc.to_s,
58
+ short_path: shorten_path(file_path)
59
+ }
60
+ end
61
+ end
62
+
32
63
  def parse_frame(line, index)
33
64
  match = line.match(FRAME_PATTERN)
34
65
  return nil unless match
@@ -29,8 +29,14 @@ module RailsErrorDashboard
29
29
  # Extracts file paths and method names, ignoring line numbers,
30
30
  # then produces an order-independent SHA256 digest.
31
31
  # @param backtrace [String, Array<String>, nil] The backtrace
32
+ # @param locations [Array<Thread::Backtrace::Location>, nil] Optional structured locations
32
33
  # @return [String, nil] 16-character hex signature
33
- def self.calculate_signature(backtrace)
34
+ def self.calculate_signature(backtrace, locations: nil)
35
+ # Try structured locations first (more reliable, no regex)
36
+ if locations && !locations.empty?
37
+ return signature_from_locations(locations)
38
+ end
39
+
34
40
  return nil if backtrace.blank?
35
41
 
36
42
  lines = backtrace.is_a?(String) ? backtrace.split("\n") : backtrace
@@ -47,6 +53,30 @@ module RailsErrorDashboard
47
53
  file_paths = frames.map { |frame| frame.split(":").first }.sort
48
54
  Digest::SHA256.hexdigest(file_paths.join("|"))[0..15]
49
55
  end
56
+
57
+ # Calculate signature directly from Location objects
58
+ # @param locations [Array<Thread::Backtrace::Location>] Backtrace locations
59
+ # @return [String, nil] 16-character hex signature
60
+ def self.signature_from_locations(locations)
61
+ frames = locations.first(20).map do |loc|
62
+ path = loc.absolute_path || loc.path
63
+ next nil unless path&.end_with?(".rb")
64
+ file_name = File.basename(path)
65
+ method_name = loc.label
66
+ method_name ? "#{file_name}:#{method_name}" : file_name
67
+ end.compact.uniq
68
+
69
+ return nil if frames.empty?
70
+
71
+ file_paths = frames.map { |frame| frame.split(":").first }.sort
72
+ Digest::SHA256.hexdigest(file_paths.join("|"))[0..15]
73
+ rescue => e
74
+ RailsErrorDashboard::Logger.debug(
75
+ "[RailsErrorDashboard] signature_from_locations failed: #{e.message}"
76
+ )
77
+ nil
78
+ end
79
+ private_class_method :signature_from_locations
50
80
  end
51
81
  end
52
82
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Pure algorithm: Extract the exception cause chain from an exception.
6
+ #
7
+ # Ruby exceptions can have a `cause` (set automatically by `raise` inside a `rescue`).
8
+ # This service walks the chain recursively with a depth limit to prevent
9
+ # infinite loops from circular cause references.
10
+ #
11
+ # Returns a JSON string of cause entries, or nil if no cause exists.
12
+ #
13
+ # @example
14
+ # CauseChainExtractor.call(exception)
15
+ # # => '[{"class_name":"OriginalError","message":"connection refused","backtrace":["app/models/user.rb:10"]}]'
16
+ class CauseChainExtractor
17
+ MAX_DEPTH = 5
18
+ MAX_MESSAGE_LENGTH = 1000
19
+ MAX_BACKTRACE_LINES = 20
20
+
21
+ # @param exception [Exception] The exception to extract cause chain from
22
+ # @return [String, nil] JSON string of cause chain, or nil if no cause
23
+ def self.call(exception)
24
+ return nil unless exception.respond_to?(:cause) && exception.cause
25
+
26
+ chain = []
27
+ current = exception.cause
28
+ seen = Set.new
29
+ depth = 0
30
+
31
+ while current && depth < MAX_DEPTH
32
+ # Guard against circular cause references
33
+ break if seen.include?(current.object_id)
34
+ seen.add(current.object_id)
35
+
36
+ chain << {
37
+ class_name: current.class.name,
38
+ message: current.message&.to_s&.slice(0, MAX_MESSAGE_LENGTH),
39
+ backtrace: truncate_backtrace(current.backtrace)
40
+ }
41
+
42
+ current = current.respond_to?(:cause) ? current.cause : nil
43
+ depth += 1
44
+ end
45
+
46
+ chain.empty? ? nil : chain.to_json
47
+ rescue => e
48
+ # SAFETY: Never let cause chain extraction break error logging
49
+ RailsErrorDashboard::Logger.debug(
50
+ "[RailsErrorDashboard] CauseChainExtractor failed: #{e.class} - #{e.message}"
51
+ )
52
+ nil
53
+ end
54
+
55
+ def self.truncate_backtrace(backtrace)
56
+ return nil unless backtrace.is_a?(Array)
57
+ backtrace.first(MAX_BACKTRACE_LINES)
58
+ end
59
+ private_class_method :truncate_backtrace
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Pure algorithm: Capture runtime environment info at boot time
6
+ #
7
+ # Snapshots Ruby version, Rails version, loaded gem versions, web server,
8
+ # and database adapter. Memoized — computed once per process lifetime.
9
+ # Stored as JSON on each error so historical errors show the environment
10
+ # that was running when they occurred (not the current environment).
11
+ class EnvironmentSnapshot
12
+ TRACKED_GEMS = %w[
13
+ activerecord actionpack sidekiq solid_queue puma unicorn
14
+ passenger redis pg mysql2 sqlite3 good_job
15
+ ].freeze
16
+
17
+ # Return cached environment snapshot (frozen hash)
18
+ # @return [Hash] Environment info
19
+ def self.snapshot
20
+ @cached_snapshot ||= new.capture.freeze
21
+ end
22
+
23
+ # Clear cached snapshot (for testing)
24
+ def self.reset!
25
+ @cached_snapshot = nil
26
+ end
27
+
28
+ # Capture current environment info
29
+ # @return [Hash] Environment snapshot
30
+ def capture
31
+ {
32
+ ruby_version: RUBY_VERSION,
33
+ ruby_engine: RUBY_ENGINE,
34
+ ruby_platform: RUBY_PLATFORM,
35
+ rails_version: rails_version,
36
+ rails_env: rails_env,
37
+ gem_versions: detect_gem_versions,
38
+ server: detect_server,
39
+ database_adapter: detect_database_adapter
40
+ }
41
+ rescue => e
42
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] EnvironmentSnapshot.capture failed: #{e.message}")
43
+ { ruby_version: RUBY_VERSION, ruby_engine: RUBY_ENGINE }
44
+ end
45
+
46
+ private
47
+
48
+ def rails_version
49
+ Rails.version
50
+ rescue
51
+ "unknown"
52
+ end
53
+
54
+ def rails_env
55
+ Rails.env.to_s
56
+ rescue
57
+ "unknown"
58
+ end
59
+
60
+ def detect_gem_versions
61
+ TRACKED_GEMS.each_with_object({}) do |gem_name, hash|
62
+ spec = Gem.loaded_specs[gem_name]
63
+ hash[gem_name] = spec.version.to_s if spec
64
+ end
65
+ rescue
66
+ {}
67
+ end
68
+
69
+ def detect_server
70
+ return "puma" if defined?(Puma)
71
+ return "unicorn" if defined?(Unicorn)
72
+ return "passenger" if defined?(PhusionPassenger)
73
+ "unknown"
74
+ rescue
75
+ "unknown"
76
+ end
77
+
78
+ def detect_database_adapter
79
+ ActiveRecord::Base.connection_db_config.adapter
80
+ rescue
81
+ "unknown"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -20,10 +20,15 @@ module RailsErrorDashboard
20
20
  # @param controller_name [String, nil] Controller context
21
21
  # @param action_name [String, nil] Action context
22
22
  # @param application_id [Integer, nil] Application for per-app deduplication
23
+ # @param context [Hash] Full error context (passed to custom fingerprint lambda)
23
24
  # @return [String] 16-character hex hash
24
- def self.call(exception, controller_name: nil, action_name: nil, application_id: nil)
25
+ def self.call(exception, controller_name: nil, action_name: nil, application_id: nil, context: {})
26
+ # Check for custom fingerprint lambda
27
+ custom = try_custom_fingerprint(exception, context)
28
+ return custom if custom
29
+
25
30
  normalized_message = normalize_message(exception.message)
26
- file_path = extract_app_frame(exception.backtrace)
31
+ file_path = extract_app_frame_from_locations(exception) || extract_app_frame(exception.backtrace)
27
32
 
28
33
  digest_input = [
29
34
  exception.class.name,
@@ -74,6 +79,27 @@ module RailsErrorDashboard
74
79
  &.gsub(/'[^']*'/, "''") # Replace single-quoted strings
75
80
  end
76
81
 
82
+ # Extract first meaningful app code frame using backtrace_locations
83
+ # More reliable than string parsing — uses Location#absolute_path directly.
84
+ # @param exception [Exception] The exception with backtrace_locations
85
+ # @return [String, nil] File path of first app code frame, or nil
86
+ def self.extract_app_frame_from_locations(exception)
87
+ locations = exception.backtrace_locations
88
+ return nil if locations.nil? || locations.empty?
89
+
90
+ first_app_location = locations.find { |loc|
91
+ path = loc.absolute_path || loc.path
92
+ !path&.include?("/gems/")
93
+ }
94
+
95
+ first_app_location && (first_app_location.absolute_path || first_app_location.path)
96
+ rescue => e
97
+ RailsErrorDashboard::Logger.debug(
98
+ "[RailsErrorDashboard] extract_app_frame_from_locations failed: #{e.message}"
99
+ )
100
+ nil
101
+ end
102
+
77
103
  # Extract first meaningful app code frame from backtrace
78
104
  # @param backtrace [Array<String>, nil] Exception backtrace
79
105
  # @return [String, nil] File path of first app code frame
@@ -86,6 +112,28 @@ module RailsErrorDashboard
86
112
 
87
113
  first_app_frame&.split(":")&.first
88
114
  end
115
+
116
+ # Try custom fingerprint lambda if configured
117
+ # Returns 16-char hex hash from custom key, or nil to fall back to default
118
+ # @param exception [Exception] The exception
119
+ # @param context [Hash] Error context
120
+ # @return [String, nil] 16-character hex hash or nil
121
+ def self.try_custom_fingerprint(exception, context)
122
+ fingerprint_fn = RailsErrorDashboard.configuration.custom_fingerprint
123
+ return nil unless fingerprint_fn
124
+
125
+ result = fingerprint_fn.call(exception, context)
126
+ return nil unless result.is_a?(String) && !result.empty?
127
+
128
+ Digest::SHA256.hexdigest(result)[0..15]
129
+ rescue => e
130
+ RailsErrorDashboard::Logger.error(
131
+ "[RailsErrorDashboard] Custom fingerprint lambda failed: #{e.class} - #{e.message}. " \
132
+ "Falling back to default hash."
133
+ )
134
+ nil
135
+ end
136
+ private_class_method :try_custom_fingerprint
89
137
  end
90
138
  end
91
139
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Pure algorithm: Throttle error notifications to prevent alert fatigue
6
+ #
7
+ # Checks severity minimum, per-error cooldown, and threshold milestones.
8
+ # Uses in-memory cache (same pattern as BaselineAlertThrottler).
9
+ # Thread-safe via Mutex. Fail-open: returns true on any error.
10
+ class NotificationThrottler
11
+ # Severity levels ranked from lowest to highest
12
+ SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 }.freeze
13
+
14
+ @last_notification_times = {}
15
+ @mutex = Mutex.new
16
+
17
+ class << self
18
+ # Should we send a notification for this error?
19
+ # Checks: severity minimum + cooldown period
20
+ # @param error_log [ErrorLog] The error to check
21
+ # @return [Boolean] true if notification should be sent
22
+ def should_notify?(error_log)
23
+ return false unless severity_meets_minimum?(error_log)
24
+
25
+ cooldown_ok?(error_log)
26
+ rescue => e
27
+ # Fail-open: if throttler breaks, allow notification
28
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] NotificationThrottler.should_notify? failed: #{e.message}")
29
+ true
30
+ end
31
+
32
+ # Does the error's severity meet the configured minimum?
33
+ # @param error_log [ErrorLog] The error to check
34
+ # @return [Boolean] true if severity is at or above minimum
35
+ def severity_meets_minimum?(error_log)
36
+ config = RailsErrorDashboard.configuration
37
+ minimum = config.notification_minimum_severity || :low
38
+ severity = SeverityClassifier.classify(error_log.error_type)
39
+
40
+ (SEVERITY_RANK[severity] || 0) >= (SEVERITY_RANK[minimum] || 0)
41
+ rescue => e
42
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] NotificationThrottler.severity_meets_minimum? failed: #{e.message}")
43
+ true
44
+ end
45
+
46
+ # Has the error's occurrence count reached a configured threshold milestone?
47
+ # @param error_log [ErrorLog] The error to check
48
+ # @return [Boolean] true if occurrence_count matches a threshold
49
+ def threshold_reached?(error_log)
50
+ thresholds = RailsErrorDashboard.configuration.notification_threshold_alerts
51
+ return false if thresholds.nil? || thresholds.empty?
52
+
53
+ thresholds.include?(error_log.occurrence_count)
54
+ rescue => e
55
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] NotificationThrottler.threshold_reached? failed: #{e.message}")
56
+ false
57
+ end
58
+
59
+ # Record that a notification was sent for this error
60
+ # @param error_log [ErrorLog] The error that was notified about
61
+ def record_notification(error_log)
62
+ key = error_log.error_hash
63
+
64
+ @mutex.synchronize do
65
+ @last_notification_times[key] = Time.current
66
+ end
67
+ rescue => e
68
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] NotificationThrottler.record_notification failed: #{e.message}")
69
+ end
70
+
71
+ # Clear all throttle state (for testing)
72
+ def clear!
73
+ @mutex.synchronize do
74
+ @last_notification_times.clear
75
+ end
76
+ end
77
+
78
+ # Remove old entries to prevent memory growth
79
+ # @param max_age_hours [Integer] Remove entries older than this (default: 24)
80
+ def cleanup!(max_age_hours: 24)
81
+ cutoff_time = max_age_hours.hours.ago
82
+
83
+ @mutex.synchronize do
84
+ @last_notification_times.delete_if { |_, time| time < cutoff_time }
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ # Is the error outside the cooldown window?
91
+ # @param error_log [ErrorLog] The error to check
92
+ # @return [Boolean] true if not in cooldown (ok to notify)
93
+ def cooldown_ok?(error_log)
94
+ cooldown_minutes = RailsErrorDashboard.configuration.notification_cooldown_minutes
95
+ return true if cooldown_minutes.nil? || cooldown_minutes <= 0
96
+
97
+ key = error_log.error_hash
98
+
99
+ @mutex.synchronize do
100
+ last_time = @last_notification_times[key]
101
+ return true if last_time.nil?
102
+
103
+ Time.current > (last_time + cooldown_minutes.minutes)
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -1,10 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "browser"
4
-
5
3
  module RailsErrorDashboard
6
4
  module Services
7
- # Detects the platform (iOS/Android/API) from user agent string
5
+ # Detects the platform (iOS/Android/API) from user agent string.
6
+ # Uses the browser gem when available, falls back to regex matching.
8
7
  class PlatformDetector
9
8
  def self.detect(user_agent)
10
9
  new(user_agent).detect
@@ -17,6 +16,16 @@ module RailsErrorDashboard
17
16
  def detect
18
17
  return "API" if @user_agent.blank?
19
18
 
19
+ if defined?(Browser)
20
+ detect_with_browser_gem
21
+ else
22
+ detect_with_regex
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def detect_with_browser_gem
20
29
  browser = Browser.new(@user_agent)
21
30
 
22
31
  if browser.device.iphone? || browser.device.ipad?
@@ -24,18 +33,34 @@ module RailsErrorDashboard
24
33
  elsif browser.platform.android?
25
34
  "Android"
26
35
  elsif @user_agent&.include?("Expo")
27
- # Expo apps might have specific patterns
28
- if @user_agent.include?("iOS")
29
- "iOS"
30
- elsif @user_agent.include?("Android")
31
- "Android"
32
- else
33
- "Mobile"
34
- end
36
+ detect_expo_platform
35
37
  else
36
38
  "API"
37
39
  end
38
40
  end
41
+
42
+ def detect_with_regex
43
+ case @user_agent
44
+ when /iPhone|iPad|iPod/i
45
+ "iOS"
46
+ when /Android/i
47
+ "Android"
48
+ when /Expo/
49
+ detect_expo_platform
50
+ else
51
+ "API"
52
+ end
53
+ end
54
+
55
+ def detect_expo_platform
56
+ if @user_agent.include?("iOS")
57
+ "iOS"
58
+ elsif @user_agent.include?("Android")
59
+ "Android"
60
+ else
61
+ "Mobile"
62
+ end
63
+ end
39
64
  end
40
65
  end
41
66
  end