rails_error_dashboard 0.1.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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +858 -0
- data/Rakefile +3 -0
- data/app/assets/stylesheets/rails_error_dashboard/application.css +15 -0
- data/app/controllers/rails_error_dashboard/application_controller.rb +12 -0
- data/app/controllers/rails_error_dashboard/errors_controller.rb +123 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +4 -0
- data/app/jobs/rails_error_dashboard/application_job.rb +4 -0
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +116 -0
- data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +19 -0
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +105 -0
- data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +166 -0
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +108 -0
- data/app/mailers/rails_error_dashboard/application_mailer.rb +8 -0
- data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +27 -0
- data/app/models/rails_error_dashboard/application_record.rb +5 -0
- data/app/models/rails_error_dashboard/error_log.rb +185 -0
- data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
- data/app/views/layouts/rails_error_dashboard/application.html.erb +17 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +351 -0
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +200 -0
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +32 -0
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +237 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +334 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +294 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20251224000001_create_rails_error_dashboard_error_logs.rb +40 -0
- data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +13 -0
- data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +10 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +27 -0
- data/lib/generators/rails_error_dashboard/install/templates/README +33 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +64 -0
- data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +40 -0
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +60 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +134 -0
- data/lib/rails_error_dashboard/commands/resolve_error.rb +35 -0
- data/lib/rails_error_dashboard/configuration.rb +83 -0
- data/lib/rails_error_dashboard/engine.rb +20 -0
- data/lib/rails_error_dashboard/error_reporter.rb +35 -0
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +41 -0
- data/lib/rails_error_dashboard/plugin.rb +98 -0
- data/lib/rails_error_dashboard/plugin_registry.rb +88 -0
- data/lib/rails_error_dashboard/plugins/audit_log_plugin.rb +96 -0
- data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +122 -0
- data/lib/rails_error_dashboard/plugins/metrics_plugin.rb +78 -0
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +108 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +37 -0
- data/lib/rails_error_dashboard/queries/developer_insights.rb +277 -0
- data/lib/rails_error_dashboard/queries/errors_list.rb +66 -0
- data/lib/rails_error_dashboard/queries/errors_list_v2.rb +149 -0
- data/lib/rails_error_dashboard/queries/filter_options.rb +21 -0
- data/lib/rails_error_dashboard/services/platform_detector.rb +41 -0
- data/lib/rails_error_dashboard/value_objects/error_context.rb +148 -0
- data/lib/rails_error_dashboard/version.rb +3 -0
- data/lib/rails_error_dashboard.rb +60 -0
- data/lib/tasks/rails_error_dashboard_tasks.rake +4 -0
- metadata +318 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Developer-focused insights
|
|
6
|
+
# Provides actionable metrics instead of environment breakdowns
|
|
7
|
+
class DeveloperInsights
|
|
8
|
+
def self.call(days = 7)
|
|
9
|
+
new(days).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(days = 7)
|
|
13
|
+
@days = days
|
|
14
|
+
@start_date = days.days.ago
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
{
|
|
19
|
+
critical_metrics: critical_metrics,
|
|
20
|
+
error_trends: error_trends,
|
|
21
|
+
hot_spots: hot_spots,
|
|
22
|
+
platform_health: platform_health,
|
|
23
|
+
resolution_metrics: resolution_metrics,
|
|
24
|
+
error_velocity: error_velocity,
|
|
25
|
+
top_impacted_users: top_impacted_users,
|
|
26
|
+
recurring_issues: recurring_issues
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def base_query
|
|
33
|
+
@base_query ||= ErrorLog.where("occurred_at >= ?", @start_date)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Critical metrics developers care about
|
|
37
|
+
def critical_metrics
|
|
38
|
+
{
|
|
39
|
+
total_errors: base_query.count,
|
|
40
|
+
unresolved_count: base_query.unresolved.count,
|
|
41
|
+
critical_unresolved: base_query.unresolved.where(
|
|
42
|
+
error_type: critical_error_types
|
|
43
|
+
).count,
|
|
44
|
+
new_error_types: new_error_types_count,
|
|
45
|
+
recurring_errors: base_query.where("occurrence_count > ?", 5).count,
|
|
46
|
+
errors_last_hour: ErrorLog.where("occurred_at >= ?", 1.hour.ago).count,
|
|
47
|
+
errors_trending_up: errors_trending_up?
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Error trends over time
|
|
52
|
+
def error_trends
|
|
53
|
+
{
|
|
54
|
+
hourly: hourly_distribution,
|
|
55
|
+
daily: daily_distribution,
|
|
56
|
+
by_type_over_time: type_trends
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Hot spots - where errors are concentrated
|
|
61
|
+
def hot_spots
|
|
62
|
+
{
|
|
63
|
+
top_error_types: base_query.group(:error_type)
|
|
64
|
+
.order("count_id DESC")
|
|
65
|
+
.limit(10)
|
|
66
|
+
.count,
|
|
67
|
+
most_frequent: base_query.order(occurrence_count: :desc)
|
|
68
|
+
.limit(10)
|
|
69
|
+
.pluck(:error_type, :message, :occurrence_count)
|
|
70
|
+
.map { |type, msg, count|
|
|
71
|
+
{ error_type: type, message: msg.truncate(100), count: count }
|
|
72
|
+
},
|
|
73
|
+
recent_spikes: detect_spikes
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Platform health breakdown
|
|
78
|
+
def platform_health
|
|
79
|
+
platforms = base_query.group(:platform).count
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
by_platform: platforms,
|
|
83
|
+
ios_stability: calculate_stability("iOS"),
|
|
84
|
+
android_stability: calculate_stability("Android"),
|
|
85
|
+
api_stability: calculate_stability("API")
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Resolution metrics
|
|
90
|
+
def resolution_metrics
|
|
91
|
+
total = base_query.count
|
|
92
|
+
resolved = base_query.resolved.count
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
resolution_rate: total.zero? ? 0 : (resolved.to_f / total * 100).round(2),
|
|
96
|
+
average_resolution_time: average_resolution_time,
|
|
97
|
+
unresolved_age: unresolved_age_distribution,
|
|
98
|
+
resolved_today: ErrorLog.resolved
|
|
99
|
+
.where("resolved_at >= ?", Time.current.beginning_of_day)
|
|
100
|
+
.count
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Error velocity - how fast errors are being introduced
|
|
105
|
+
def error_velocity
|
|
106
|
+
current_period = base_query.count
|
|
107
|
+
previous_period = ErrorLog.where(
|
|
108
|
+
"occurred_at >= ? AND occurred_at < ?",
|
|
109
|
+
(@days * 2).days.ago,
|
|
110
|
+
@start_date
|
|
111
|
+
).count
|
|
112
|
+
|
|
113
|
+
change = current_period - previous_period
|
|
114
|
+
change_percent = previous_period.zero? ? 0 : (change.to_f / previous_period * 100).round(2)
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
current_period_count: current_period,
|
|
118
|
+
previous_period_count: previous_period,
|
|
119
|
+
change: change,
|
|
120
|
+
change_percent: change_percent,
|
|
121
|
+
trend: change >= 0 ? "increasing" : "decreasing"
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Users most impacted by errors
|
|
126
|
+
def top_impacted_users
|
|
127
|
+
base_query.where.not(user_id: nil)
|
|
128
|
+
.group(:user_id)
|
|
129
|
+
.order("count_id DESC")
|
|
130
|
+
.limit(10)
|
|
131
|
+
.count
|
|
132
|
+
.transform_keys { |user_id| user_id || "Guest" }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Recurring issues that keep coming back
|
|
136
|
+
def recurring_issues
|
|
137
|
+
base_query.where("occurrence_count > ?", 3)
|
|
138
|
+
.where("(last_seen_at - first_seen_at) > ?", 1.day.to_i)
|
|
139
|
+
.order(occurrence_count: :desc)
|
|
140
|
+
.limit(10)
|
|
141
|
+
.pluck(:error_type, :message, :occurrence_count, :first_seen_at, :last_seen_at)
|
|
142
|
+
.map { |type, msg, count, first, last|
|
|
143
|
+
{
|
|
144
|
+
error_type: type,
|
|
145
|
+
message: msg.truncate(100),
|
|
146
|
+
occurrence_count: count,
|
|
147
|
+
duration_days: ((last - first) / 1.day).round(1),
|
|
148
|
+
first_seen: first,
|
|
149
|
+
last_seen: last
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Helper methods
|
|
155
|
+
|
|
156
|
+
def critical_error_types
|
|
157
|
+
%w[
|
|
158
|
+
SecurityError
|
|
159
|
+
NoMemoryError
|
|
160
|
+
SystemStackError
|
|
161
|
+
SignalException
|
|
162
|
+
ActiveRecord::StatementInvalid
|
|
163
|
+
]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def new_error_types_count
|
|
167
|
+
# Error types that first appeared in this period
|
|
168
|
+
current_types = base_query.distinct.pluck(:error_type)
|
|
169
|
+
all_time_types = ErrorLog.where("occurred_at < ?", @start_date)
|
|
170
|
+
.distinct
|
|
171
|
+
.pluck(:error_type)
|
|
172
|
+
|
|
173
|
+
(current_types - all_time_types).count
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def errors_trending_up?
|
|
177
|
+
last_24h = ErrorLog.where("occurred_at >= ?", 24.hours.ago).count
|
|
178
|
+
prev_24h = ErrorLog.where("occurred_at >= ? AND occurred_at < ?",
|
|
179
|
+
48.hours.ago, 24.hours.ago).count
|
|
180
|
+
|
|
181
|
+
last_24h > prev_24h
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def hourly_distribution
|
|
185
|
+
base_query.group("EXTRACT(HOUR FROM occurred_at)")
|
|
186
|
+
.order("EXTRACT(HOUR FROM occurred_at)")
|
|
187
|
+
.count
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def daily_distribution
|
|
191
|
+
base_query.group("DATE(occurred_at)")
|
|
192
|
+
.order("DATE(occurred_at)")
|
|
193
|
+
.count
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def type_trends
|
|
197
|
+
# Get top 5 error types and their trend over days
|
|
198
|
+
top_types = base_query.group(:error_type)
|
|
199
|
+
.order("count_id DESC")
|
|
200
|
+
.limit(5)
|
|
201
|
+
.pluck(:error_type)
|
|
202
|
+
|
|
203
|
+
trends = {}
|
|
204
|
+
top_types.each do |error_type|
|
|
205
|
+
trends[error_type] = base_query.where(error_type: error_type)
|
|
206
|
+
.group("DATE(occurred_at)")
|
|
207
|
+
.count
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
trends
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def calculate_stability(platform)
|
|
214
|
+
total = base_query.where(platform: platform).count
|
|
215
|
+
return 100.0 if total.zero?
|
|
216
|
+
|
|
217
|
+
# Stability = 100 - (errors per 1000 requests ratio)
|
|
218
|
+
# Simplified: just show error rate
|
|
219
|
+
100.0 - [ (total.to_f / 10), 100.0 ].min
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def average_resolution_time
|
|
223
|
+
resolved_errors = base_query.resolved
|
|
224
|
+
.where.not(resolved_at: nil)
|
|
225
|
+
|
|
226
|
+
return 0 if resolved_errors.count.zero?
|
|
227
|
+
|
|
228
|
+
total_time = resolved_errors.sum { |error|
|
|
229
|
+
(error.resolved_at - error.occurred_at).to_i
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
average_seconds = total_time / resolved_errors.count
|
|
233
|
+
(average_seconds / 3600.0).round(2) # Convert to hours
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def unresolved_age_distribution
|
|
237
|
+
unresolved = base_query.unresolved
|
|
238
|
+
|
|
239
|
+
{
|
|
240
|
+
under_1_hour: unresolved.where("occurred_at >= ?", 1.hour.ago).count,
|
|
241
|
+
"1_24_hours": unresolved.where("occurred_at >= ? AND occurred_at < ?",
|
|
242
|
+
24.hours.ago, 1.hour.ago).count,
|
|
243
|
+
"1_7_days": unresolved.where("occurred_at >= ? AND occurred_at < ?",
|
|
244
|
+
7.days.ago, 24.hours.ago).count,
|
|
245
|
+
over_7_days: unresolved.where("occurred_at < ?", 7.days.ago).count
|
|
246
|
+
}
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def detect_spikes
|
|
250
|
+
# Find error types that suddenly spiked in last 24 hours
|
|
251
|
+
last_24h = ErrorLog.where("occurred_at >= ?", 24.hours.ago)
|
|
252
|
+
prev_24h = ErrorLog.where("occurred_at >= ? AND occurred_at < ?",
|
|
253
|
+
48.hours.ago, 24.hours.ago)
|
|
254
|
+
|
|
255
|
+
current_counts = last_24h.group(:error_type).count
|
|
256
|
+
previous_counts = prev_24h.group(:error_type).count
|
|
257
|
+
|
|
258
|
+
spikes = []
|
|
259
|
+
current_counts.each do |error_type, current_count|
|
|
260
|
+
previous_count = previous_counts[error_type] || 0
|
|
261
|
+
|
|
262
|
+
# Spike if current is > 2x previous AND at least 5 errors
|
|
263
|
+
if current_count > previous_count * 2 && current_count >= 5
|
|
264
|
+
spikes << {
|
|
265
|
+
error_type: error_type,
|
|
266
|
+
current_count: current_count,
|
|
267
|
+
previous_count: previous_count,
|
|
268
|
+
increase_percent: previous_count.zero? ? 999 : ((current_count - previous_count).to_f / previous_count * 100).round(0)
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
spikes.sort_by { |s| -s[:increase_percent] }.first(5)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Fetch errors with filtering and pagination
|
|
6
|
+
# This is a read operation that returns a filtered collection of errors
|
|
7
|
+
class ErrorsList
|
|
8
|
+
def self.call(filters = {})
|
|
9
|
+
new(filters).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(filters = {})
|
|
13
|
+
@filters = filters
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
query = ErrorLog.order(occurred_at: :desc)
|
|
18
|
+
# Only eager load user if User model exists
|
|
19
|
+
query = query.includes(:user) if defined?(::User)
|
|
20
|
+
query = apply_filters(query)
|
|
21
|
+
query
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def apply_filters(query)
|
|
27
|
+
query = filter_by_environment(query)
|
|
28
|
+
query = filter_by_error_type(query)
|
|
29
|
+
query = filter_by_resolved(query)
|
|
30
|
+
query = filter_by_platform(query)
|
|
31
|
+
query = filter_by_search(query)
|
|
32
|
+
query
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def filter_by_environment(query)
|
|
36
|
+
return query unless @filters[:environment].present?
|
|
37
|
+
|
|
38
|
+
query.where(environment: @filters[:environment])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def filter_by_error_type(query)
|
|
42
|
+
return query unless @filters[:error_type].present?
|
|
43
|
+
|
|
44
|
+
query.where(error_type: @filters[:error_type])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def filter_by_resolved(query)
|
|
48
|
+
return query unless @filters[:unresolved] == "true" || @filters[:unresolved] == true
|
|
49
|
+
|
|
50
|
+
query.unresolved
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def filter_by_platform(query)
|
|
54
|
+
return query unless @filters[:platform].present?
|
|
55
|
+
|
|
56
|
+
query.where(platform: @filters[:platform])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def filter_by_search(query)
|
|
60
|
+
return query unless @filters[:search].present?
|
|
61
|
+
|
|
62
|
+
query.where("message ILIKE ?", "%#{@filters[:search]}%")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Fetch errors with improved filtering for developers
|
|
6
|
+
# Removes environment filtering (each env has separate DB)
|
|
7
|
+
# Adds time-based, severity, and frequency filtering
|
|
8
|
+
class ErrorsListV2
|
|
9
|
+
def self.call(filters = {})
|
|
10
|
+
new(filters).call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(filters = {})
|
|
14
|
+
@filters = filters
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
query = ErrorLog.order(occurred_at: :desc)
|
|
19
|
+
# Only eager load user if User model exists
|
|
20
|
+
query = query.includes(:user) if defined?(::User)
|
|
21
|
+
query = apply_filters(query)
|
|
22
|
+
query
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def apply_filters(query)
|
|
28
|
+
query = filter_by_timeframe(query)
|
|
29
|
+
query = filter_by_error_type(query)
|
|
30
|
+
query = filter_by_resolved(query)
|
|
31
|
+
query = filter_by_platform(query)
|
|
32
|
+
query = filter_by_severity(query)
|
|
33
|
+
query = filter_by_frequency(query)
|
|
34
|
+
query = filter_by_search(query)
|
|
35
|
+
query
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Time-based filtering (more useful than environment)
|
|
39
|
+
def filter_by_timeframe(query)
|
|
40
|
+
return query unless @filters[:timeframe].present?
|
|
41
|
+
|
|
42
|
+
case @filters[:timeframe]
|
|
43
|
+
when "last_hour"
|
|
44
|
+
query.where("occurred_at >= ?", 1.hour.ago)
|
|
45
|
+
when "today"
|
|
46
|
+
query.where("occurred_at >= ?", Time.current.beginning_of_day)
|
|
47
|
+
when "yesterday"
|
|
48
|
+
query.where("occurred_at >= ? AND occurred_at < ?",
|
|
49
|
+
1.day.ago.beginning_of_day,
|
|
50
|
+
Time.current.beginning_of_day)
|
|
51
|
+
when "last_7_days"
|
|
52
|
+
query.where("occurred_at >= ?", 7.days.ago)
|
|
53
|
+
when "last_30_days"
|
|
54
|
+
query.where("occurred_at >= ?", 30.days.ago)
|
|
55
|
+
when "last_90_days"
|
|
56
|
+
query.where("occurred_at >= ?", 90.days.ago)
|
|
57
|
+
else
|
|
58
|
+
query
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def filter_by_error_type(query)
|
|
63
|
+
return query unless @filters[:error_type].present?
|
|
64
|
+
|
|
65
|
+
query.where(error_type: @filters[:error_type])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def filter_by_resolved(query)
|
|
69
|
+
return query unless @filters[:unresolved] == "true" || @filters[:unresolved] == true
|
|
70
|
+
|
|
71
|
+
query.unresolved
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def filter_by_platform(query)
|
|
75
|
+
return query unless @filters[:platform].present?
|
|
76
|
+
|
|
77
|
+
query.where(platform: @filters[:platform])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Filter by severity (based on error type)
|
|
81
|
+
def filter_by_severity(query)
|
|
82
|
+
return query unless @filters[:severity].present?
|
|
83
|
+
|
|
84
|
+
case @filters[:severity]
|
|
85
|
+
when "critical"
|
|
86
|
+
# Security, data loss, crashes
|
|
87
|
+
critical_errors = [
|
|
88
|
+
"SecurityError",
|
|
89
|
+
"ActiveRecord::RecordInvalid",
|
|
90
|
+
"NoMemoryError",
|
|
91
|
+
"SystemStackError",
|
|
92
|
+
"SignalException"
|
|
93
|
+
]
|
|
94
|
+
query.where(error_type: critical_errors)
|
|
95
|
+
when "high"
|
|
96
|
+
# Business logic failures
|
|
97
|
+
high_errors = [
|
|
98
|
+
"ActiveRecord::RecordNotFound",
|
|
99
|
+
"ArgumentError",
|
|
100
|
+
"TypeError",
|
|
101
|
+
"NoMethodError"
|
|
102
|
+
]
|
|
103
|
+
query.where(error_type: high_errors)
|
|
104
|
+
when "medium"
|
|
105
|
+
# Validation, timeouts
|
|
106
|
+
medium_errors = [
|
|
107
|
+
"ActiveRecord::RecordInvalid",
|
|
108
|
+
"Timeout::Error",
|
|
109
|
+
"Net::ReadTimeout"
|
|
110
|
+
]
|
|
111
|
+
query.where(error_type: medium_errors)
|
|
112
|
+
else
|
|
113
|
+
query
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Filter by frequency (how often error occurs)
|
|
118
|
+
def filter_by_frequency(query)
|
|
119
|
+
return query unless @filters[:frequency].present?
|
|
120
|
+
|
|
121
|
+
case @filters[:frequency]
|
|
122
|
+
when "high"
|
|
123
|
+
# Occurs more than 10 times
|
|
124
|
+
query.where("occurrence_count > ?", 10)
|
|
125
|
+
when "medium"
|
|
126
|
+
# Occurs 3-10 times
|
|
127
|
+
query.where("occurrence_count >= ? AND occurrence_count <= ?", 3, 10)
|
|
128
|
+
when "low"
|
|
129
|
+
# Occurs 1-2 times
|
|
130
|
+
query.where("occurrence_count <= ?", 2)
|
|
131
|
+
when "recurring"
|
|
132
|
+
# Seen multiple times over more than 1 hour
|
|
133
|
+
query.where("occurrence_count > ? AND (last_seen_at - first_seen_at) > ?",
|
|
134
|
+
1, 1.hour.to_i)
|
|
135
|
+
else
|
|
136
|
+
query
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def filter_by_search(query)
|
|
141
|
+
return query unless @filters[:search].present?
|
|
142
|
+
|
|
143
|
+
search_term = "%#{@filters[:search]}%"
|
|
144
|
+
query.where("message ILIKE ? OR error_type ILIKE ? OR backtrace ILIKE ?",
|
|
145
|
+
search_term, search_term, search_term)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Fetch available filter options
|
|
6
|
+
# This is a read operation that returns distinct values for filters
|
|
7
|
+
class FilterOptions
|
|
8
|
+
def self.call
|
|
9
|
+
new.call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
{
|
|
14
|
+
environments: ErrorLog.distinct.pluck(:environment).compact,
|
|
15
|
+
error_types: ErrorLog.distinct.pluck(:error_type).compact.sort,
|
|
16
|
+
platforms: ErrorLog.distinct.pluck(:platform).compact
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "browser"
|
|
4
|
+
|
|
5
|
+
module RailsErrorDashboard
|
|
6
|
+
module Services
|
|
7
|
+
# Detects the platform (iOS/Android/API) from user agent string
|
|
8
|
+
class PlatformDetector
|
|
9
|
+
def self.detect(user_agent)
|
|
10
|
+
new(user_agent).detect
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(user_agent)
|
|
14
|
+
@user_agent = user_agent
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def detect
|
|
18
|
+
return "API" if @user_agent.blank?
|
|
19
|
+
|
|
20
|
+
browser = Browser.new(@user_agent)
|
|
21
|
+
|
|
22
|
+
if browser.device.iphone? || browser.device.ipad?
|
|
23
|
+
"iOS"
|
|
24
|
+
elsif browser.platform.android?
|
|
25
|
+
"Android"
|
|
26
|
+
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
|
|
35
|
+
else
|
|
36
|
+
"API"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module ValueObjects
|
|
5
|
+
# Immutable value object representing error context
|
|
6
|
+
# Extracts and normalizes context information from various sources
|
|
7
|
+
class ErrorContext
|
|
8
|
+
attr_reader :user_id, :request_url, :request_params, :user_agent, :ip_address, :platform,
|
|
9
|
+
:controller_name, :action_name
|
|
10
|
+
|
|
11
|
+
def initialize(context, source = nil)
|
|
12
|
+
@context = context
|
|
13
|
+
@source = source
|
|
14
|
+
|
|
15
|
+
@user_id = extract_user_id
|
|
16
|
+
@request_url = build_request_url
|
|
17
|
+
@request_params = extract_params
|
|
18
|
+
@user_agent = extract_user_agent
|
|
19
|
+
@ip_address = extract_ip_address
|
|
20
|
+
@platform = detect_platform
|
|
21
|
+
@controller_name = extract_controller_name
|
|
22
|
+
@action_name = extract_action_name
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{
|
|
27
|
+
user_id: user_id,
|
|
28
|
+
request_url: request_url,
|
|
29
|
+
request_params: request_params,
|
|
30
|
+
user_agent: user_agent,
|
|
31
|
+
ip_address: ip_address,
|
|
32
|
+
platform: platform,
|
|
33
|
+
controller_name: controller_name,
|
|
34
|
+
action_name: action_name
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def extract_user_id
|
|
41
|
+
@context[:current_user]&.id ||
|
|
42
|
+
@context[:user_id] ||
|
|
43
|
+
@context[:user]&.id
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_request_url
|
|
47
|
+
return @context[:request]&.fullpath if @context[:request]
|
|
48
|
+
return @context[:request_url] if @context[:request_url]
|
|
49
|
+
return "Background Job: #{@context[:job]&.class}" if @context[:job]
|
|
50
|
+
return "Sidekiq: #{@context[:job_class]}" if @context[:job_class]
|
|
51
|
+
return "Service: #{@context[:service]}" if @context[:service]
|
|
52
|
+
return @source if @source
|
|
53
|
+
|
|
54
|
+
"Rails Application"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def extract_params
|
|
58
|
+
params = {}
|
|
59
|
+
|
|
60
|
+
# HTTP request params
|
|
61
|
+
if @context[:request]
|
|
62
|
+
params = @context[:request].params.except(:controller, :action)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Background job params
|
|
66
|
+
if @context[:job]
|
|
67
|
+
params = {
|
|
68
|
+
job_class: @context[:job].class.name,
|
|
69
|
+
job_id: @context[:job].job_id,
|
|
70
|
+
queue: @context[:job].queue_name,
|
|
71
|
+
arguments: @context[:job].arguments,
|
|
72
|
+
executions: @context[:job].executions
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Sidekiq params
|
|
77
|
+
if @context[:job_class]
|
|
78
|
+
params = {
|
|
79
|
+
job_class: @context[:job_class],
|
|
80
|
+
job_id: @context[:jid],
|
|
81
|
+
queue: @context[:queue],
|
|
82
|
+
retry_count: @context[:retry_count]
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Custom params
|
|
87
|
+
params.merge!(@context[:params]) if @context[:params]
|
|
88
|
+
|
|
89
|
+
# Additional context (from mobile apps, etc.)
|
|
90
|
+
params.merge!(@context[:additional_context]) if @context[:additional_context]
|
|
91
|
+
|
|
92
|
+
params.to_json
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def extract_user_agent
|
|
96
|
+
return @context[:request]&.user_agent if @context[:request]
|
|
97
|
+
return "Sidekiq Worker" if @source&.to_s&.include?("active_job") || @context[:job]
|
|
98
|
+
return @context[:user_agent] if @context[:user_agent]
|
|
99
|
+
|
|
100
|
+
"Rails Application"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def extract_ip_address
|
|
104
|
+
return @context[:request]&.remote_ip if @context[:request]
|
|
105
|
+
return "background_job" if @context[:job]
|
|
106
|
+
return "sidekiq_worker" if @context[:job_class]
|
|
107
|
+
return @context[:ip_address] if @context[:ip_address]
|
|
108
|
+
|
|
109
|
+
"application_layer"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def detect_platform
|
|
113
|
+
# Check if it's from a mobile request
|
|
114
|
+
user_agent = extract_user_agent
|
|
115
|
+
return Services::PlatformDetector.detect(user_agent) if @context[:request]
|
|
116
|
+
|
|
117
|
+
# Everything else is API/backend
|
|
118
|
+
"API"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def extract_controller_name
|
|
122
|
+
# From Rails request params
|
|
123
|
+
return @context[:request].params[:controller] if @context[:request]&.params&.[](:controller)
|
|
124
|
+
|
|
125
|
+
# From explicit context
|
|
126
|
+
return @context[:controller_name] if @context[:controller_name]
|
|
127
|
+
|
|
128
|
+
# From Rails controller instance
|
|
129
|
+
return @context[:controller]&.class&.name if @context[:controller]
|
|
130
|
+
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def extract_action_name
|
|
135
|
+
# From Rails request params
|
|
136
|
+
return @context[:request].params[:action] if @context[:request]&.params&.[](:action)
|
|
137
|
+
|
|
138
|
+
# From explicit context
|
|
139
|
+
return @context[:action_name] if @context[:action_name]
|
|
140
|
+
|
|
141
|
+
# From action parameter
|
|
142
|
+
return @context[:action] if @context[:action]
|
|
143
|
+
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|