rails_error_dashboard 0.1.21 → 0.1.22

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.
@@ -2,20 +2,15 @@
2
2
 
3
3
  RailsErrorDashboard.configure do |config|
4
4
  # ============================================================================
5
- # AUTHENTICATION (Always Required)
5
+ # AUTHENTICATION (Always Required - Cannot Be Disabled)
6
6
  # ============================================================================
7
7
 
8
8
  # Dashboard authentication credentials
9
9
  # ⚠️ CHANGE THESE BEFORE PRODUCTION! ⚠️
10
+ # Authentication is ALWAYS enforced in ALL environments (production, development, test)
10
11
  config.dashboard_username = ENV.fetch("ERROR_DASHBOARD_USER", "gandalf")
11
12
  config.dashboard_password = ENV.fetch("ERROR_DASHBOARD_PASSWORD", "youshallnotpass")
12
13
 
13
- # Require authentication for dashboard access
14
- config.require_authentication = true
15
-
16
- # Require authentication even in development mode
17
- config.require_authentication_in_development = false
18
-
19
14
  # ============================================================================
20
15
  # CORE FEATURES (Always Enabled)
21
16
  # ============================================================================
@@ -46,9 +46,13 @@ module RailsErrorDashboard
46
46
 
47
47
  error_context = ValueObjects::ErrorContext.new(@context, @context[:source])
48
48
 
49
+ # Find or create application (cached lookup)
50
+ application = find_or_create_application
51
+
49
52
  # Build error attributes
50
53
  truncated_backtrace = truncate_backtrace(@exception.backtrace)
51
54
  attributes = {
55
+ application_id: application.id,
52
56
  error_type: @exception.class.name,
53
57
  message: @exception.message,
54
58
  backtrace: truncated_backtrace,
@@ -63,8 +67,8 @@ module RailsErrorDashboard
63
67
  occurred_at: Time.current
64
68
  }
65
69
 
66
- # Generate error hash for deduplication (including controller/action context)
67
- error_hash = generate_error_hash(@exception, error_context.controller_name, error_context.action_name)
70
+ # Generate error hash for deduplication (including controller/action context and application)
71
+ error_hash = generate_error_hash(@exception, error_context.controller_name, error_context.action_name, application.id)
68
72
 
69
73
  # Calculate backtrace signature for fuzzy matching (if column exists)
70
74
  if ErrorLog.column_names.include?("backtrace_signature")
@@ -127,10 +131,6 @@ module RailsErrorDashboard
127
131
  rescue => e
128
132
  # Don't let error logging cause more errors - fail silently
129
133
  # CRITICAL: Log but never propagate exception
130
- # Log to Rails logger for visibility during development
131
- Rails.logger.error("[RailsErrorDashboard] LogError command failed: #{e.class} - #{e.message}")
132
- Rails.logger.error("Backtrace: #{e.backtrace&.first(10)&.join("\n")}")
133
-
134
134
  RailsErrorDashboard::Logger.error("[RailsErrorDashboard] LogError command failed: #{e.class} - #{e.message}")
135
135
  RailsErrorDashboard::Logger.error("Original exception: #{@exception.class} - #{@exception.message}") if @exception
136
136
  RailsErrorDashboard::Logger.error("Context: #{@context.inspect}") if @context
@@ -140,6 +140,20 @@ module RailsErrorDashboard
140
140
 
141
141
  private
142
142
 
143
+ # Find or create application for multi-app support
144
+ def find_or_create_application
145
+ app_name = RailsErrorDashboard.configuration.application_name ||
146
+ ENV['APPLICATION_NAME'] ||
147
+ (defined?(Rails) && Rails.application.class.module_parent_name) ||
148
+ 'Rails Application'
149
+
150
+ Application.find_or_create_by_name(app_name)
151
+ rescue => e
152
+ RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Failed to find/create application: #{e.message}")
153
+ # Fallback: try to find any application or create default
154
+ Application.first || Application.create!(name: 'Default Application')
155
+ end
156
+
143
157
  # Trigger notification callbacks for error logging
144
158
  def trigger_callbacks(error_log)
145
159
  # Trigger general error_logged callbacks
@@ -237,13 +251,14 @@ module RailsErrorDashboard
237
251
  # Generate consistent hash for error deduplication
238
252
  # Same hash = same error type
239
253
  # Note: This is also defined in ErrorLog model for backward compatibility
240
- def generate_error_hash(exception, controller_name = nil, action_name = nil)
254
+ def generate_error_hash(exception, controller_name = nil, action_name = nil, application_id = nil)
241
255
  # Hash components:
242
256
  # 1. Error class (NoMethodError, ArgumentError, etc.)
243
257
  # 2. Normalized message (replace numbers, quoted strings)
244
258
  # 3. First stack frame file (ignore line numbers)
245
259
  # 4. Controller name (for context-aware grouping)
246
260
  # 5. Action name (for context-aware grouping)
261
+ # 6. Application ID (for per-app deduplication)
247
262
 
248
263
  normalized_message = exception.message
249
264
  &.gsub(/\d+/, "N") # Replace numbers: "User 123" -> "User N"
@@ -261,13 +276,14 @@ module RailsErrorDashboard
261
276
  # Extract just the file path, not line number
262
277
  file_path = first_app_frame&.split(":")&.first
263
278
 
264
- # Generate hash including controller/action for better grouping
279
+ # Generate hash including controller/action/application for better grouping
265
280
  digest_input = [
266
281
  exception.class.name,
267
282
  normalized_message,
268
283
  file_path,
269
- controller_name, # Context: which controller
270
- action_name # Context: which action
284
+ controller_name, # Context: which controller
285
+ action_name, # Context: which action
286
+ application_id.to_s # Context: which application (for per-app deduplication)
271
287
  ].compact.join("|")
272
288
 
273
289
  Digest::SHA256.hexdigest(digest_input)[0..15]
@@ -2,15 +2,17 @@
2
2
 
3
3
  module RailsErrorDashboard
4
4
  class Configuration
5
- # Dashboard authentication
5
+ # Dashboard authentication (always required)
6
6
  attr_accessor :dashboard_username
7
7
  attr_accessor :dashboard_password
8
- attr_accessor :require_authentication
9
- attr_accessor :require_authentication_in_development
10
8
 
11
9
  # User model (for associations)
12
10
  attr_accessor :user_model
13
11
 
12
+ # Multi-app support - Application name
13
+ attr_accessor :application_name
14
+ attr_accessor :database # Database connection name for shared error dashboard DB
15
+
14
16
  # Notifications
15
17
  attr_accessor :slack_webhook_url
16
18
  attr_accessor :notification_email_recipients
@@ -94,14 +96,16 @@ module RailsErrorDashboard
94
96
  attr_accessor :log_level
95
97
 
96
98
  def initialize
97
- # Default values
99
+ # Default values - Authentication is ALWAYS required
98
100
  @dashboard_username = ENV.fetch("ERROR_DASHBOARD_USER", "gandalf")
99
101
  @dashboard_password = ENV.fetch("ERROR_DASHBOARD_PASSWORD", "youshallnotpass")
100
- @require_authentication = true
101
- @require_authentication_in_development = false
102
102
 
103
103
  @user_model = "User"
104
104
 
105
+ # Multi-app support defaults
106
+ @application_name = ENV["APPLICATION_NAME"] # Auto-detected if not set
107
+ @database = nil # Use primary database by default
108
+
105
109
  # Notification settings (disabled by default - enable during installation or in initializer)
106
110
  @slack_webhook_url = ENV["SLACK_WEBHOOK_URL"]
107
111
  @notification_email_recipients = ENV.fetch("ERROR_NOTIFICATION_EMAILS", "").split(",").map(&:strip)
@@ -5,15 +5,16 @@ module RailsErrorDashboard
5
5
  # Query: Fetch analytics statistics for charts and trends
6
6
  # This is a read operation that aggregates error data over time
7
7
  class AnalyticsStats
8
- def self.call(days = 30)
9
- new(days).call
10
- end
11
-
12
- def initialize(days = 30)
8
+ def initialize(days = 30, application_id: nil)
13
9
  @days = days
10
+ @application_id = application_id
14
11
  @start_date = days.days.ago
15
12
  end
16
13
 
14
+ def self.call(days = 30, application_id: nil)
15
+ new(days, application_id: application_id).call
16
+ end
17
+
17
18
  def call
18
19
  # Cache analytics data for 5 minutes to reduce database load
19
20
  # Cache key includes days parameter and last error update timestamp
@@ -38,20 +39,28 @@ module RailsErrorDashboard
38
39
  # Cache key includes:
39
40
  # - Query class name
40
41
  # - Days parameter (different time ranges = different caches)
42
+ # - Application ID (per-app caching)
41
43
  # - Last error update timestamp (auto-invalidates when errors change)
42
44
  # - Start date (ensures correct time window)
43
45
  [
44
46
  "analytics_stats",
45
47
  @days,
46
- ErrorLog.maximum(:updated_at)&.to_i || 0,
48
+ @application_id || "all",
49
+ base_scope.maximum(:updated_at)&.to_i || 0,
47
50
  @start_date.to_date.to_s
48
51
  ].join("/")
49
52
  end
50
53
 
51
54
  private
52
55
 
56
+ def base_scope
57
+ scope = ErrorLog.all
58
+ scope = scope.where(application_id: @application_id) if @application_id.present?
59
+ scope
60
+ end
61
+
53
62
  def base_query
54
- ErrorLog.where("occurred_at >= ?", @start_date)
63
+ base_scope.where("occurred_at >= ?", @start_date)
55
64
  end
56
65
 
57
66
  def error_statistics
@@ -5,8 +5,12 @@ module RailsErrorDashboard
5
5
  # Query: Fetch dashboard statistics
6
6
  # This is a read operation that aggregates error data for the dashboard
7
7
  class DashboardStats
8
- def self.call
9
- new.call
8
+ def initialize(application_id: nil)
9
+ @application_id = application_id
10
+ end
11
+
12
+ def self.call(application_id: nil)
13
+ new(application_id: application_id).call
10
14
  end
11
15
 
12
16
  def call
@@ -15,12 +19,12 @@ module RailsErrorDashboard
15
19
  begin
16
20
  Rails.cache.fetch(cache_key, expires_in: 1.minute) do
17
21
  {
18
- total_today: ErrorLog.where("occurred_at >= ?", Time.current.beginning_of_day).count,
19
- total_week: ErrorLog.where("occurred_at >= ?", 7.days.ago).count,
20
- total_month: ErrorLog.where("occurred_at >= ?", 30.days.ago).count,
21
- unresolved: ErrorLog.unresolved.count,
22
- resolved: ErrorLog.resolved.count,
23
- by_platform: ErrorLog.group(:platform).count,
22
+ total_today: base_scope.where("occurred_at >= ?", Time.current.beginning_of_day).count,
23
+ total_week: base_scope.where("occurred_at >= ?", 7.days.ago).count,
24
+ total_month: base_scope.where("occurred_at >= ?", 30.days.ago).count,
25
+ unresolved: base_scope.unresolved.count,
26
+ resolved: base_scope.resolved.count,
27
+ by_platform: base_scope.group(:platform).count,
24
28
  top_errors: top_errors,
25
29
  # Trend visualizations
26
30
  errors_trend_7d: errors_trend_7d,
@@ -40,8 +44,8 @@ module RailsErrorDashboard
40
44
  rescue => e
41
45
  # If Rails.cache or any stats query fails, return empty stats hash
42
46
  # This prevents broadcast failures in API-only mode or when cache is unavailable
43
- Rails.logger.error("[RailsErrorDashboard] DashboardStats failed: #{e.class} - #{e.message}")
44
- Rails.logger.debug("[RailsErrorDashboard] Backtrace: #{e.backtrace&.first(3)&.join("\n")}")
47
+ RailsErrorDashboard::Logger.error("[RailsErrorDashboard] DashboardStats failed: #{e.class} - #{e.message}")
48
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Backtrace: #{e.backtrace&.first(3)&.join("\n")}")
45
49
 
46
50
  # Return minimal stats hash to prevent nil errors in views
47
51
  {
@@ -70,41 +74,49 @@ module RailsErrorDashboard
70
74
  def cache_key
71
75
  # Cache key includes last error update timestamp for auto-invalidation
72
76
  # Also includes current hour to ensure fresh data
77
+ # Uses base_scope to respect application_id filter for proper cache isolation
73
78
  [
74
79
  "dashboard_stats",
75
- ErrorLog.maximum(:updated_at)&.to_i || 0,
80
+ @application_id || "all",
81
+ base_scope.maximum(:updated_at)&.to_i || 0,
76
82
  Time.current.hour
77
83
  ].join("/")
78
84
  end
79
85
 
80
86
  private
81
87
 
88
+ def base_scope
89
+ scope = ErrorLog.all
90
+ scope = scope.where(application_id: @application_id) if @application_id.present?
91
+ scope
92
+ end
93
+
82
94
  def top_errors
83
- ErrorLog.where("occurred_at >= ?", 7.days.ago)
84
- .group(:error_type)
85
- .count
86
- .sort_by { |_, count| -count }
87
- .first(10)
88
- .to_h
95
+ base_scope.where("occurred_at >= ?", 7.days.ago)
96
+ .group(:error_type)
97
+ .count
98
+ .sort_by { |_, count| -count }
99
+ .first(10)
100
+ .to_h
89
101
  end
90
102
 
91
103
  # Get 7-day error trend (daily counts)
92
104
  def errors_trend_7d
93
- ErrorLog.where("occurred_at >= ?", 7.days.ago)
94
- .group_by_day(:occurred_at, range: 7.days.ago.to_date..Date.current, default_value: 0)
95
- .count
105
+ base_scope.where("occurred_at >= ?", 7.days.ago)
106
+ .group_by_day(:occurred_at, range: 7.days.ago.to_date..Date.current, default_value: 0)
107
+ .count
96
108
  end
97
109
 
98
110
  # Get error counts by severity for last 7 days
99
111
  # OPTIMIZED: Use database filtering instead of loading all records into Ruby
100
112
  def errors_by_severity_7d
101
- base_scope = ErrorLog.where("occurred_at >= ?", 7.days.ago)
113
+ scoped_errors = base_scope.where("occurred_at >= ?", 7.days.ago)
102
114
 
103
115
  {
104
- critical: base_scope.where(error_type: ErrorLog::CRITICAL_ERROR_TYPES).count,
105
- high: base_scope.where(error_type: ErrorLog::HIGH_SEVERITY_ERROR_TYPES).count,
106
- medium: base_scope.where(error_type: ErrorLog::MEDIUM_SEVERITY_ERROR_TYPES).count,
107
- low: base_scope.where.not(
116
+ critical: scoped_errors.where(error_type: ErrorLog::CRITICAL_ERROR_TYPES).count,
117
+ high: scoped_errors.where(error_type: ErrorLog::HIGH_SEVERITY_ERROR_TYPES).count,
118
+ medium: scoped_errors.where(error_type: ErrorLog::MEDIUM_SEVERITY_ERROR_TYPES).count,
119
+ low: scoped_errors.where.not(
108
120
  error_type: ErrorLog::CRITICAL_ERROR_TYPES +
109
121
  ErrorLog::HIGH_SEVERITY_ERROR_TYPES +
110
122
  ErrorLog::MEDIUM_SEVERITY_ERROR_TYPES
@@ -117,7 +129,7 @@ module RailsErrorDashboard
117
129
  def spike_detected?
118
130
  return false if errors_trend_7d.empty?
119
131
 
120
- today_count = ErrorLog.where("occurred_at >= ?", Time.current.beginning_of_day).count
132
+ today_count = base_scope.where("occurred_at >= ?", Time.current.beginning_of_day).count
121
133
 
122
134
  # Try baseline-based detection first
123
135
  if baseline_anomaly_detected?(today_count)
@@ -136,7 +148,7 @@ module RailsErrorDashboard
136
148
  def spike_info
137
149
  return nil unless spike_detected?
138
150
 
139
- today_count = ErrorLog.where("occurred_at >= ?", Time.current.beginning_of_day).count
151
+ today_count = base_scope.where("occurred_at >= ?", Time.current.beginning_of_day).count
140
152
  avg_count = (errors_trend_7d.values.sum / 7.0).round(1)
141
153
 
142
154
  info = {
@@ -158,9 +170,9 @@ module RailsErrorDashboard
158
170
  return false unless defined?(Queries::BaselineStats)
159
171
 
160
172
  # Check most common error types for anomalies
161
- ErrorLog.distinct.pluck(:error_type, :platform).compact.any? do |(error_type, platform)|
173
+ base_scope.distinct.pluck(:error_type, :platform).compact.any? do |(error_type, platform)|
162
174
  stats = Queries::BaselineStats.new(error_type, platform)
163
- error_count = ErrorLog.where(
175
+ error_count = base_scope.where(
164
176
  error_type: error_type,
165
177
  platform: platform
166
178
  ).where("occurred_at >= ?", Time.current.beginning_of_day).count
@@ -175,9 +187,9 @@ module RailsErrorDashboard
175
187
  return nil unless defined?(Queries::BaselineStats)
176
188
 
177
189
  # Find the most anomalous error type
178
- anomalies = ErrorLog.distinct.pluck(:error_type, :platform).compact.map do |(error_type, platform)|
190
+ anomalies = base_scope.distinct.pluck(:error_type, :platform).compact.map do |(error_type, platform)|
179
191
  stats = Queries::BaselineStats.new(error_type, platform)
180
- error_count = ErrorLog.where(
192
+ error_count = base_scope.where(
181
193
  error_type: error_type,
182
194
  platform: platform
183
195
  ).where("occurred_at >= ?", Time.current.beginning_of_day).count
@@ -225,7 +237,7 @@ module RailsErrorDashboard
225
237
  # Since we don't track total requests, we'll use error count as proxy
226
238
  # In the future, this could be: (errors / total_requests) * 100
227
239
  def error_rate
228
- today_errors = ErrorLog.where("occurred_at >= ?", Time.current.beginning_of_day).count
240
+ today_errors = base_scope.where("occurred_at >= ?", Time.current.beginning_of_day).count
229
241
  return 0.0 if today_errors.zero?
230
242
 
231
243
  # For now, use a simple heuristic: errors per hour today
@@ -243,20 +255,20 @@ module RailsErrorDashboard
243
255
 
244
256
  # Count distinct users affected by errors today
245
257
  def affected_users_today
246
- ErrorLog.where("occurred_at >= ?", Time.current.beginning_of_day)
247
- .where.not(user_id: nil)
248
- .distinct
249
- .count(:user_id)
258
+ base_scope.where("occurred_at >= ?", Time.current.beginning_of_day)
259
+ .where.not(user_id: nil)
260
+ .distinct
261
+ .count(:user_id)
250
262
  end
251
263
 
252
264
  # Count distinct users affected by errors yesterday
253
265
  def affected_users_yesterday
254
- ErrorLog.where("occurred_at >= ? AND occurred_at < ?",
255
- 1.day.ago.beginning_of_day,
256
- Time.current.beginning_of_day)
257
- .where.not(user_id: nil)
258
- .distinct
259
- .count(:user_id)
266
+ base_scope.where("occurred_at >= ? AND occurred_at < ?",
267
+ 1.day.ago.beginning_of_day,
268
+ Time.current.beginning_of_day)
269
+ .where.not(user_id: nil)
270
+ .distinct
271
+ .count(:user_id)
260
272
  end
261
273
 
262
274
  # Calculate change in affected users (today vs yesterday)
@@ -272,10 +284,10 @@ module RailsErrorDashboard
272
284
 
273
285
  # Calculate percentage change in errors (today vs yesterday)
274
286
  def trend_percentage
275
- today = ErrorLog.where("occurred_at >= ?", Time.current.beginning_of_day).count
276
- yesterday = ErrorLog.where("occurred_at >= ? AND occurred_at < ?",
277
- 1.day.ago.beginning_of_day,
278
- Time.current.beginning_of_day).count
287
+ today = base_scope.where("occurred_at >= ?", Time.current.beginning_of_day).count
288
+ yesterday = base_scope.where("occurred_at >= ? AND occurred_at < ?",
289
+ 1.day.ago.beginning_of_day,
290
+ Time.current.beginning_of_day).count
279
291
 
280
292
  return 0.0 if today.zero? && yesterday.zero?
281
293
  return 100.0 if yesterday.zero? && today.positive?
@@ -299,7 +311,7 @@ module RailsErrorDashboard
299
311
  # Get top 5 errors ranked by impact score
300
312
  # Impact = affected_users_count × occurrence_count
301
313
  def top_errors_by_impact
302
- ErrorLog.where("occurred_at >= ?", 7.days.ago)
314
+ base_scope.where("occurred_at >= ?", 7.days.ago)
303
315
  .group(:error_type, :id)
304
316
  .select("error_type, id, occurrence_count,
305
317
  COUNT(DISTINCT user_id) as affected_users,
@@ -28,6 +28,7 @@ module RailsErrorDashboard
28
28
  query = filter_by_error_type(query)
29
29
  query = filter_by_resolved(query)
30
30
  query = filter_by_platform(query)
31
+ query = filter_by_application(query)
31
32
  query = filter_by_search(query)
32
33
  query = filter_by_severity(query)
33
34
  query = filter_by_timeframe(query)
@@ -74,6 +75,13 @@ module RailsErrorDashboard
74
75
  query.where(platform: @filters[:platform])
75
76
  end
76
77
 
78
+ def filter_by_application(query)
79
+ return query unless @filters[:application_id].present?
80
+
81
+ # ActiveRecord handles both single values and arrays automatically
82
+ query.where(application_id: @filters[:application_id])
83
+ end
84
+
77
85
  def filter_by_search(query)
78
86
  return query unless @filters[:search].present?
79
87
 
@@ -12,7 +12,8 @@ module RailsErrorDashboard
12
12
  def call
13
13
  {
14
14
  error_types: ErrorLog.distinct.pluck(:error_type).compact.sort,
15
- platforms: ErrorLog.distinct.pluck(:platform).compact
15
+ platforms: ErrorLog.distinct.pluck(:platform).compact,
16
+ applications: Application.ordered_by_name.pluck(:name, :id)
16
17
  }
17
18
  end
18
19
  end
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.1.21"
2
+ VERSION = "0.1.22"
3
3
  end