rails_nexus 2.0.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 +430 -0
- data/Rakefile +6 -0
- data/app/assets/config/rails_nexus_manifest.js +2 -0
- data/app/assets/javascripts/rails_nexus/controllers.js +603 -0
- data/app/assets/stylesheets/rails_nexus/application.css +1964 -0
- data/app/controllers/rails_nexus/analytics_controller.rb +361 -0
- data/app/controllers/rails_nexus/application_controller.rb +15 -0
- data/app/controllers/rails_nexus/backup_controller.rb +91 -0
- data/app/controllers/rails_nexus/cron_jobs_controller.rb +64 -0
- data/app/controllers/rails_nexus/database_health_controller.rb +128 -0
- data/app/controllers/rails_nexus/logged_exceptions_controller.rb +227 -0
- data/app/controllers/rails_nexus/n1_patterns_controller.rb +10 -0
- data/app/controllers/rails_nexus/settings_controller.rb +100 -0
- data/app/controllers/rails_nexus/source_code_controller.rb +39 -0
- data/app/controllers/rails_nexus/stats_controller.rb +494 -0
- data/app/controllers/rails_nexus/workflow_controller.rb +96 -0
- data/app/helpers/rails_nexus/application_helper.rb +5 -0
- data/app/helpers/rails_nexus/logged_exceptions_helper.rb +254 -0
- data/app/helpers/rails_nexus/source_code_helper.rb +107 -0
- data/app/javascript/controllers/rails_nexus_controller.js +380 -0
- data/app/javascript/controllers/rails_nexus_detail_controller.js +165 -0
- data/app/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
- data/app/javascript/controllers/rails_nexus_theme_controller.js +31 -0
- data/app/javascript/rails_nexus/application.js +17 -0
- data/app/jobs/rails_nexus/application_job.rb +4 -0
- data/app/mailers/rails_nexus/application_mailer.rb +6 -0
- data/app/models/rails_nexus/application_record.rb +5 -0
- data/app/models/rails_nexus/backup.rb +107 -0
- data/app/models/rails_nexus/base_record.rb +26 -0
- data/app/models/rails_nexus/comment.rb +13 -0
- data/app/models/rails_nexus/cron_job.rb +72 -0
- data/app/models/rails_nexus/database_stat.rb +128 -0
- data/app/models/rails_nexus/event.rb +101 -0
- data/app/models/rails_nexus/logged_exception.rb +864 -0
- data/app/models/rails_nexus/logged_exception_n1.rb +87 -0
- data/app/models/rails_nexus/metric.rb +107 -0
- data/app/models/rails_nexus/nginx_metric.rb +112 -0
- data/app/models/rails_nexus/server_metric.rb +168 -0
- data/app/models/rails_nexus/webhook_delivery.rb +64 -0
- data/app/services/rails_nexus/backup_service.rb +275 -0
- data/app/views/layouts/rails_nexus/application.html.erb +185 -0
- data/app/views/rails_nexus/analytics/index.html.erb +363 -0
- data/app/views/rails_nexus/backup/files.html.erb +53 -0
- data/app/views/rails_nexus/backup/index.html.erb +211 -0
- data/app/views/rails_nexus/backup/settings.html.erb +158 -0
- data/app/views/rails_nexus/cron_jobs/index.html.erb +141 -0
- data/app/views/rails_nexus/database_health/index.html.erb +139 -0
- data/app/views/rails_nexus/logged_exceptions/_comments_list.html.erb +50 -0
- data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +190 -0
- data/app/views/rails_nexus/logged_exceptions/_feed.html.erb +3 -0
- data/app/views/rails_nexus/logged_exceptions/_filter_group.html.erb +10 -0
- data/app/views/rails_nexus/logged_exceptions/_show.html.erb +554 -0
- data/app/views/rails_nexus/logged_exceptions/_workflow_panel.html.erb +118 -0
- data/app/views/rails_nexus/logged_exceptions/clear.turbo_stream.erb +6 -0
- data/app/views/rails_nexus/logged_exceptions/destroy.turbo_stream.erb +2 -0
- data/app/views/rails_nexus/logged_exceptions/destroy_all.turbo_stream.erb +6 -0
- data/app/views/rails_nexus/logged_exceptions/feed.rss.builder +20 -0
- data/app/views/rails_nexus/logged_exceptions/index.html.erb +323 -0
- data/app/views/rails_nexus/logged_exceptions/query.html.erb +3 -0
- data/app/views/rails_nexus/logged_exceptions/query.turbo_stream.erb +6 -0
- data/app/views/rails_nexus/logged_exceptions/show.html.erb +3 -0
- data/app/views/rails_nexus/logged_exceptions/show.turbo_stream.erb +5 -0
- data/app/views/rails_nexus/n1_patterns/index.html.erb +153 -0
- data/app/views/rails_nexus/settings/index.html.erb +140 -0
- data/app/views/rails_nexus/stats/index.html.erb +569 -0
- data/config/importmap.rb +15 -0
- data/config/initializers/date_formats.rb +5 -0
- data/config/initializers/rails_nexus.rb +18 -0
- data/config/locales/en.yml +47 -0
- data/config/routes.rb +58 -0
- data/db/migrate/20240330122311_create_rails_nexus_logged_exceptions.rb +22 -0
- data/lib/generators/rails_nexus/backup_generator.rb +70 -0
- data/lib/generators/rails_nexus/customize_generator.rb +159 -0
- data/lib/generators/rails_nexus/install_generator.rb +61 -0
- data/lib/generators/rails_nexus/templates/INSTALL.md +9 -0
- data/lib/generators/rails_nexus/templates/backup/backup_helper.sh +152 -0
- data/lib/generators/rails_nexus/templates/backup/config.rb +27 -0
- data/lib/generators/rails_nexus/templates/backup/models/daily_backup.rb +24 -0
- data/lib/generators/rails_nexus/templates/backup/models/full_backup.rb +35 -0
- data/lib/generators/rails_nexus/templates/backup/models/sync_backup.rb +43 -0
- data/lib/generators/rails_nexus/templates/backup/mysql-config/db_config.cnf +15 -0
- data/lib/generators/rails_nexus/templates/backup/schedule.rb +28 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_controller.js +310 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_detail_controller.js +98 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_theme_controller.js +30 -0
- data/lib/generators/rails_nexus/templates/migration.rb +24 -0
- data/lib/generators/rails_nexus/templates/migration_advanced_features.rb +30 -0
- data/lib/generators/rails_nexus/templates/migration_cron_jobs.rb +22 -0
- data/lib/generators/rails_nexus/templates/migration_new_schema.rb +120 -0
- data/lib/generators/rails_nexus/templates/migration_platform_detection.rb +11 -0
- data/lib/generators/rails_nexus/templates/migration_webhook_deliveries.rb +21 -0
- data/lib/generators/rails_nexus/templates/migration_workflow.rb +29 -0
- data/lib/generators/rails_nexus/templates/rails_nexus.rb +96 -0
- data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +129 -0
- data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_exceptions.html.erb +123 -0
- data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_show.html.erb +297 -0
- data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/index.html.erb +113 -0
- data/lib/rails_nexus/breadcrumbs.rb +161 -0
- data/lib/rails_nexus/cleanup.rb +32 -0
- data/lib/rails_nexus/configuration.rb +319 -0
- data/lib/rails_nexus/engine.rb +135 -0
- data/lib/rails_nexus/logger.rb +188 -0
- data/lib/rails_nexus/notifications.rb +105 -0
- data/lib/rails_nexus/ransack_config.rb +4 -0
- data/lib/rails_nexus/storm_protection.rb +124 -0
- data/lib/rails_nexus/version.rb +3 -0
- data/lib/rails_nexus.rb +133 -0
- data/lib/tasks/rails_nexus.rake +230 -0
- data/lib/tasks/rails_nexus_tasks.rake +37 -0
- data/rails_nexus.gemspec +36 -0
- metadata +252 -0
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
class LoggedException < BaseRecord
|
|
5
|
+
self.table_name = "rails_nexus_exceptions"
|
|
6
|
+
HOSTNAME = Socket.gethostname
|
|
7
|
+
|
|
8
|
+
serialize :cause_chain, coder: JSON unless method_defined?(:cause_chain)
|
|
9
|
+
serialize :breadcrumbs, coder: JSON unless method_defined?(:breadcrumbs)
|
|
10
|
+
serialize :system_health, coder: JSON unless method_defined?(:system_health)
|
|
11
|
+
serialize :local_variables, coder: JSON unless method_defined?(:local_variables)
|
|
12
|
+
serialize :instance_variables, coder: JSON unless method_defined?(:instance_variables)
|
|
13
|
+
|
|
14
|
+
# ─── Associations ──────────────────────────────────────────────
|
|
15
|
+
has_many :comments, class_name: "RailsNexus::Comment", dependent: :destroy
|
|
16
|
+
|
|
17
|
+
# ─── Scopes ────────────────────────────────────────────────────
|
|
18
|
+
scope :muted, -> { where(muted: true) }
|
|
19
|
+
scope :not_muted, -> { where(muted: [false, nil]) }
|
|
20
|
+
scope :snoozed, -> { where("snoozed_until > ?", Time.current) }
|
|
21
|
+
scope :not_snoozed, -> { where("snoozed_until IS NULL OR snoozed_until <= ?", Time.current) }
|
|
22
|
+
scope :by_priority, ->(p) { where(priority: p) }
|
|
23
|
+
scope :assigned_to, ->(user) { where(assigned_to: user) }
|
|
24
|
+
scope :unassigned, -> { where(assigned_to: [nil, ""]) }
|
|
25
|
+
scope :active, -> { not_muted.not_snoozed }
|
|
26
|
+
|
|
27
|
+
def self.ransackable_attributes(auth_object = nil)
|
|
28
|
+
%w[action_name backtrace cause_chain controller_name created_at device_type environment
|
|
29
|
+
assigned_to comments_count exception_class fingerprint id instance_variables local_variables
|
|
30
|
+
message muted occurrence_count platform platform_version priority remote_ip request
|
|
31
|
+
snoozed_until system_health updated_at user_agent user_id user_info]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.ransackable_associations(auth_object = nil)
|
|
35
|
+
[]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
def create_from_exception(controller, exception, data)
|
|
40
|
+
# Storm protection: check if we should capture this error
|
|
41
|
+
if defined?(RailsNexus::StormProtection) && RailsNexus::StormProtection.enabled?
|
|
42
|
+
unless RailsNexus::StormProtection.allow_capture?
|
|
43
|
+
RailsNexus::StormProtection.record_shed
|
|
44
|
+
return nil # Error shed by storm protection
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
message = exception.message.to_s
|
|
49
|
+
message += "\n* Extra Data\n\n#{data}" unless data.blank?
|
|
50
|
+
|
|
51
|
+
# Detect platform from user agent and request format
|
|
52
|
+
platform_data = detect_platform(controller)
|
|
53
|
+
|
|
54
|
+
# Flush breadcrumbs before they're lost
|
|
55
|
+
breadcrumbs_data = if defined?(RailsNexus::Breadcrumbs) && RailsNexus.configuration.breadcrumbs_enabled
|
|
56
|
+
RailsNexus::Breadcrumbs.flush
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Extract user info for impact scoring
|
|
60
|
+
user = controller.respond_to?(:current_user, true) ? controller.current_user : nil
|
|
61
|
+
user_id = user&.id&.to_s || data[:user_id]&.to_s rescue nil
|
|
62
|
+
user_type = user.class.name rescue nil
|
|
63
|
+
|
|
64
|
+
# Generate fingerprint for grouping
|
|
65
|
+
fingerprint = generate_fingerprint(
|
|
66
|
+
exception.class.name,
|
|
67
|
+
controller.controller_path,
|
|
68
|
+
controller.action_name
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Check if this fingerprint already exists (occurrence counting)
|
|
72
|
+
existing = where(fingerprint: fingerprint).order(created_at: :desc).first
|
|
73
|
+
|
|
74
|
+
attrs = {
|
|
75
|
+
exception_class: exception.class.name,
|
|
76
|
+
controller_name: controller.controller_path,
|
|
77
|
+
action_name: controller.action_name,
|
|
78
|
+
message: message,
|
|
79
|
+
backtrace: exception.backtrace,
|
|
80
|
+
request: controller.request,
|
|
81
|
+
user_info: user,
|
|
82
|
+
remote_ip: controller.request.remote_ip,
|
|
83
|
+
user_id: user_id,
|
|
84
|
+
user_type: user_type,
|
|
85
|
+
fingerprint: fingerprint,
|
|
86
|
+
platform: platform_data[:platform],
|
|
87
|
+
platform_version: platform_data[:platform_version],
|
|
88
|
+
device_type: platform_data[:device_type],
|
|
89
|
+
cause_chain: extract_cause_chain(exception),
|
|
90
|
+
breadcrumbs: breadcrumbs_data.presence,
|
|
91
|
+
system_health: capture_system_health,
|
|
92
|
+
occurrence_count: existing ? existing.occurrence_count + 1 : 1
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if existing
|
|
96
|
+
existing.update!(
|
|
97
|
+
message: message,
|
|
98
|
+
backtrace: exception.backtrace,
|
|
99
|
+
request: controller.request,
|
|
100
|
+
user_info: user,
|
|
101
|
+
remote_ip: controller.request.remote_ip,
|
|
102
|
+
occurrence_count: existing.occurrence_count + 1,
|
|
103
|
+
cause_chain: attrs[:cause_chain],
|
|
104
|
+
system_health: attrs[:system_health]
|
|
105
|
+
)
|
|
106
|
+
RailsNexus::StormProtection.record_captured if defined?(RailsNexus::StormProtection)
|
|
107
|
+
existing
|
|
108
|
+
else
|
|
109
|
+
record = create!(attrs)
|
|
110
|
+
RailsNexus::StormProtection.record_captured if defined?(RailsNexus::StormProtection)
|
|
111
|
+
record
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def host_name
|
|
116
|
+
HOSTNAME
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
scope :by_exception_class, lambda { |exception_class| where(:exception_class => exception_class) }
|
|
121
|
+
scope :by_controller_and_action, lambda { |controller_name, action_name| where(:controller_name => controller_name, :action_name => action_name) }
|
|
122
|
+
scope :by_controller, lambda { |controller_name| where(:controller_name => controller_name) }
|
|
123
|
+
scope :by_action, lambda { |action_name| where(:action_name => action_name) }
|
|
124
|
+
scope :message_like, ->(query) { where("message LIKE ?", "%#{sanitize_sql_like(query)}%") }
|
|
125
|
+
scope :days_old, ->(day_number) { where("created_at >= ?", day_number.to_f.days.ago.utc) }
|
|
126
|
+
scope :sorted, -> { order(created_at: :desc) }
|
|
127
|
+
|
|
128
|
+
# User impact scoring: rank by unique users affected
|
|
129
|
+
def self.user_impact_ranking(limit: 20)
|
|
130
|
+
select(<<~SQL
|
|
131
|
+
exception_class,
|
|
132
|
+
controller_name,
|
|
133
|
+
action_name,
|
|
134
|
+
COUNT(DISTINCT user_id) as unique_users,
|
|
135
|
+
COUNT(*) as total_occurrences,
|
|
136
|
+
MAX(created_at) as last_seen
|
|
137
|
+
SQL
|
|
138
|
+
)
|
|
139
|
+
.where.not(user_id: nil)
|
|
140
|
+
.group(:exception_class, :controller_name, :action_name)
|
|
141
|
+
.order("unique_users DESC, total_occurrences DESC")
|
|
142
|
+
.limit(limit)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def name
|
|
146
|
+
"#{self.exception_class} in #{self.controller_action}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Generate fingerprint for grouping similar exceptions
|
|
150
|
+
def self.generate_fingerprint(exception_class, controller_name, action_name)
|
|
151
|
+
require "digest"
|
|
152
|
+
Digest::SHA256.hexdigest("#{exception_class}#{controller_name}#{action_name}")[0..15]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Detect platform from user_agent and request
|
|
156
|
+
def self.detect_platform(controller)
|
|
157
|
+
request = controller.request
|
|
158
|
+
user_agent = request.respond_to?(:user_agent) ? request.user_agent.to_s.downcase : ""
|
|
159
|
+
|
|
160
|
+
# API detection: no user_agent or JSON/XML format
|
|
161
|
+
if user_agent.blank? || request.format&.ref == "json" || request.format&.ref == "xml"
|
|
162
|
+
return { platform: "api", platform_version: nil, device_type: "server", app_version: nil }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# iOS detection
|
|
166
|
+
if user_agent.include?("iphone") || user_agent.include?("ipad") || user_agent.include?("ipod")
|
|
167
|
+
version = user_agent[/os (\d+[._]\d+)/i, 1]&.gsub("_", ".")
|
|
168
|
+
return { platform: "ios", platform_version: version, device_type: user_agent.include?("ipad") ? "tablet" : "phone", app_version: nil }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Android detection
|
|
172
|
+
if user_agent.include?("android")
|
|
173
|
+
version = user_agent[/android (\d+[.\d]*)/i, 1]
|
|
174
|
+
return { platform: "android", platform_version: version, device_type: "phone", app_version: nil }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# macOS detection
|
|
178
|
+
if user_agent.include?("macintosh") || user_agent.include?("mac os")
|
|
179
|
+
return { platform: "web", platform_version: "macOS", device_type: "desktop", app_version: nil }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Windows detection
|
|
183
|
+
if user_agent.include?("windows")
|
|
184
|
+
return { platform: "web", platform_version: "Windows", device_type: "desktop", app_version: nil }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Linux detection
|
|
188
|
+
if user_agent.include?("linux")
|
|
189
|
+
return { platform: "web", platform_version: "Linux", device_type: "desktop", app_version: nil }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Bot/crawler detection
|
|
193
|
+
if user_agent.include?("bot") || user_agent.include?("crawler") || user_agent.include?("spider")
|
|
194
|
+
return { platform: "bot", platform_version: nil, device_type: "bot", app_version: nil }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Default: web
|
|
198
|
+
{ platform: "web", platform_version: nil, device_type: "unknown", app_version: nil }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Platform analytics
|
|
202
|
+
def self.platform_stats
|
|
203
|
+
select(<<~SQL
|
|
204
|
+
platform,
|
|
205
|
+
COUNT(*) as total,
|
|
206
|
+
COUNT(DISTINCT exception_class) as unique_classes,
|
|
207
|
+
MAX(created_at) as last_seen
|
|
208
|
+
SQL
|
|
209
|
+
)
|
|
210
|
+
.group(:platform)
|
|
211
|
+
.order("total DESC")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Platform-specific top errors
|
|
215
|
+
def self.platform_top_errors(platform:, limit: 5)
|
|
216
|
+
by_platform(platform)
|
|
217
|
+
.select(:exception_class, "COUNT(*) as count")
|
|
218
|
+
.group(:exception_class)
|
|
219
|
+
.order("count DESC")
|
|
220
|
+
.limit(limit)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
scope :by_platform, ->(platform) { where(platform: platform) }
|
|
224
|
+
|
|
225
|
+
# N+1 Query Pattern Detection
|
|
226
|
+
# Analyzes breadcrumbs for repeated similar SQL queries
|
|
227
|
+
def self.n_plus_one_patterns(limit: 20)
|
|
228
|
+
patterns = {}
|
|
229
|
+
|
|
230
|
+
# Get recent exceptions with breadcrumbs
|
|
231
|
+
where.not(breadcrumbs: nil)
|
|
232
|
+
.where("created_at >= ?", 7.days.ago)
|
|
233
|
+
.order(created_at: :desc)
|
|
234
|
+
.limit(500)
|
|
235
|
+
.find_each do |exception|
|
|
236
|
+
crumbs = parse_breadcrumbs(exception.breadcrumbs)
|
|
237
|
+
next unless crumbs.is_a?(Array)
|
|
238
|
+
|
|
239
|
+
# Extract SQL queries from breadcrumbs
|
|
240
|
+
sql_crumbs = crumbs.select { |c| c[:type] == "sql" }
|
|
241
|
+
next if sql_crumbs.empty?
|
|
242
|
+
|
|
243
|
+
# Group by fingerprint (normalized SQL)
|
|
244
|
+
sql_crumbs.group_by { |c| sql_fingerprint(c[:name]) }.each do |fingerprint, queries|
|
|
245
|
+
next if queries.length < 3 # N+1 = 3+ similar queries
|
|
246
|
+
|
|
247
|
+
table = extract_table_name(queries.first[:name])
|
|
248
|
+
patterns[fingerprint] ||= {
|
|
249
|
+
fingerprint: fingerprint,
|
|
250
|
+
sql_sample: queries.first[:name]&.truncate(200),
|
|
251
|
+
table: table,
|
|
252
|
+
count: 0,
|
|
253
|
+
exception_ids: [],
|
|
254
|
+
avg_duration: 0,
|
|
255
|
+
source: :breadcrumbs
|
|
256
|
+
}
|
|
257
|
+
patterns[fingerprint][:count] += queries.length
|
|
258
|
+
patterns[fingerprint][:exception_ids] << exception.id
|
|
259
|
+
durations = queries.map { |q| q[:duration] }.compact
|
|
260
|
+
patterns[fingerprint][:avg_duration] = durations.any? ? (durations.sum / durations.size).round(2) : 0
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Supplement with backtrace-based N+1 detection
|
|
265
|
+
backtrace_patterns = n_plus_one_from_backtraces(limit: limit)
|
|
266
|
+
backtrace_patterns.each do |bp|
|
|
267
|
+
key = bp[:fingerprint]
|
|
268
|
+
if patterns[key]
|
|
269
|
+
patterns[key][:count] += bp[:count]
|
|
270
|
+
patterns[key][:exception_ids] = (patterns[key][:exception_ids] + bp[:exception_ids]).uniq
|
|
271
|
+
else
|
|
272
|
+
patterns[key] = bp
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Sort by frequency and return top N
|
|
277
|
+
patterns.values
|
|
278
|
+
.sort_by { |p| -p[:count] }
|
|
279
|
+
.first(limit)
|
|
280
|
+
.map { |p| p.merge(exception_ids: p[:exception_ids].uniq.first(5)) }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Backtrace-based N+1 detection (works without breadcrumbs)
|
|
284
|
+
# Looks at repeated backtrace patterns in application code that suggest N+1 queries
|
|
285
|
+
def self.n_plus_one_from_backtraces(limit: 20)
|
|
286
|
+
patterns = {}
|
|
287
|
+
|
|
288
|
+
where.not(backtrace: nil)
|
|
289
|
+
.where("created_at >= ?", 7.days.ago)
|
|
290
|
+
.order(created_at: :desc)
|
|
291
|
+
.limit(500)
|
|
292
|
+
.find_each do |exception|
|
|
293
|
+
lines = exception.backtrace.to_s.split("\n")
|
|
294
|
+
next if lines.empty?
|
|
295
|
+
|
|
296
|
+
# Detect N+1 signals in backtrace
|
|
297
|
+
app_lines = lines.select { |l| l.include?(Rails.root.to_s) && !l.include?("rails_nexus") }
|
|
298
|
+
next if app_lines.empty?
|
|
299
|
+
|
|
300
|
+
# Look for ActiveRecord collection iteration patterns:
|
|
301
|
+
# e.g., .each, .map, .find_each near ActiveRecord calls
|
|
302
|
+
has_iteration = app_lines.any? { |l| l.match?(/\.each|\.map|\.select|\.reject|\.flat_map|\.find_each/) }
|
|
303
|
+
has_ar_call = app_lines.any? { |l| l.match?(/ActiveRecord|_callback|association|belongs_to|has_many|load_target|reload/) }
|
|
304
|
+
|
|
305
|
+
# Also detect: repeated similar backtrace across multiple exceptions of same class
|
|
306
|
+
fingerprint = backtrace_fingerprint(app_lines)
|
|
307
|
+
next if fingerprint.blank?
|
|
308
|
+
|
|
309
|
+
patterns[fingerprint] ||= {
|
|
310
|
+
fingerprint: fingerprint,
|
|
311
|
+
sql_sample: backtrace_to_hint(app_lines),
|
|
312
|
+
table: extract_table_from_backtrace(app_lines),
|
|
313
|
+
count: 0,
|
|
314
|
+
exception_ids: [],
|
|
315
|
+
avg_duration: 0,
|
|
316
|
+
source: :backtrace
|
|
317
|
+
}
|
|
318
|
+
patterns[fingerprint][:count] += 1
|
|
319
|
+
patterns[fingerprint][:exception_ids] << exception.id
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Only return patterns seen 3+ times (likely real N+1 issues)
|
|
323
|
+
patterns.values.select { |p| p[:count] >= 3 }
|
|
324
|
+
.sort_by { |p| -p[:count] }
|
|
325
|
+
.first(limit)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Generate a fingerprint from backtrace lines for grouping
|
|
329
|
+
def self.backtrace_fingerprint(app_lines)
|
|
330
|
+
return nil if app_lines.length < 2
|
|
331
|
+
# Use the app-level lines (skip framework) to create a groupable key
|
|
332
|
+
key = app_lines.first(5).map { |l| l.sub(/:\d+/, "").sub(/in .*/, "").strip }.join("\n")
|
|
333
|
+
Digest::SHA256.hexdigest(key)[0..15]
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Extract a human-readable hint from backtrace
|
|
337
|
+
def self.backtrace_to_hint(app_lines)
|
|
338
|
+
return nil if app_lines.empty?
|
|
339
|
+
line = app_lines.first
|
|
340
|
+
if line =~ /^(.+?):(\d+):in `(\S+)'/
|
|
341
|
+
"#{$1.sub(Rails.root.to_s, "")}:#{$2} in #{$3}"
|
|
342
|
+
else
|
|
343
|
+
line.sub(Rails.root.to_s, "")
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Try to extract table name from backtrace lines
|
|
348
|
+
def self.extract_table_from_backtrace(app_lines)
|
|
349
|
+
app_lines.each do |line|
|
|
350
|
+
# Match patterns like: `find_all_by_#{table}`, `where_#{table}`
|
|
351
|
+
if line =~ /(?:find|where|select|from|join|has_many|belongs_to)[_s]*(\w+)/i
|
|
352
|
+
return $1.downcase
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
# Fall back to the action name from the first app line
|
|
356
|
+
if app_lines.first =~ /#(\w+)\z/
|
|
357
|
+
return $1
|
|
358
|
+
end
|
|
359
|
+
"app"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# N+1 summary stats
|
|
363
|
+
def self.n_plus_one_summary
|
|
364
|
+
patterns = n_plus_one_patterns(limit: 100)
|
|
365
|
+
{
|
|
366
|
+
total_patterns: patterns.sum { |p| p[:count] },
|
|
367
|
+
unique_patterns: patterns.size,
|
|
368
|
+
worst_pattern: patterns.first,
|
|
369
|
+
top_tables: patterns.group_by { |p| p[:table] }
|
|
370
|
+
.transform_values { |ps| ps.sum { |p| p[:count] } }
|
|
371
|
+
.sort_by { |_, count| -count }
|
|
372
|
+
.first(5)
|
|
373
|
+
.map { |table, count| { table: table, count: count } }
|
|
374
|
+
}
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def self.parse_breadcrumbs(data)
|
|
378
|
+
return data if data.is_a?(Array)
|
|
379
|
+
JSON.parse(data, symbolize_names: true) rescue []
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Normalize SQL fingerprint: remove values, keep structure
|
|
383
|
+
def self.sql_fingerprint(sql)
|
|
384
|
+
return "" if sql.blank?
|
|
385
|
+
sql.strip
|
|
386
|
+
.gsub(/\s+/, " ") # Normalize whitespace
|
|
387
|
+
.gsub(/\d+/, "?") # Replace numbers with ?
|
|
388
|
+
.gsub(/'[^']*'/, "?") # Replace string values with ?
|
|
389
|
+
.gsub(/"[^"]*"/, "?") # Replace quoted identifiers
|
|
390
|
+
.gsub(/\d+\.\d+/, "?") # Replace decimals
|
|
391
|
+
.gsub(/0x[0-9a-f]+/i, "?") # Replace hex values
|
|
392
|
+
.strip
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Extract table name from SQL query
|
|
396
|
+
def self.extract_table_name(sql)
|
|
397
|
+
return "unknown" if sql.blank?
|
|
398
|
+
# Match FROM/INTO/UPDATE/JOIN table patterns
|
|
399
|
+
if sql =~ /(?:FROM|INTO|UPDATE|JOIN)\s+["']?(\w+)/i
|
|
400
|
+
$1.downcase
|
|
401
|
+
else
|
|
402
|
+
"unknown"
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Extract cause chain from exception
|
|
407
|
+
def self.extract_cause_chain(exception)
|
|
408
|
+
chain = []
|
|
409
|
+
current = exception
|
|
410
|
+
while current && chain.length < 10 # Limit depth
|
|
411
|
+
chain << {
|
|
412
|
+
class_name: current.class.name,
|
|
413
|
+
message: current.message.to_s.truncate(500),
|
|
414
|
+
backtrace: current.backtrace&.first(5)&.join("
|
|
415
|
+
")
|
|
416
|
+
}
|
|
417
|
+
current = current.cause
|
|
418
|
+
end
|
|
419
|
+
chain
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Capture system health snapshot
|
|
423
|
+
def self.capture_system_health
|
|
424
|
+
health = {}
|
|
425
|
+
begin
|
|
426
|
+
gc_stat = GC.stat
|
|
427
|
+
health[:gc] = {
|
|
428
|
+
total_allocated: gc_stat[:total_allocated_objects],
|
|
429
|
+
total_freed: gc_stat[:total_freed_objects],
|
|
430
|
+
heap_allocated: gc_stat[:heap_allocated_pages],
|
|
431
|
+
heap_free: gc_stat[:free_slots]
|
|
432
|
+
}
|
|
433
|
+
rescue StandardError
|
|
434
|
+
health[:gc] = { error: "unable to capture" }
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
begin
|
|
438
|
+
health[:memory] = {
|
|
439
|
+
rss_kb: `ps -o rss= -p #{Process.pid} 2>/dev/null`.strip.to_i,
|
|
440
|
+
vsz_kb: `ps -o vsz= -p #{Process.pid} 2>/dev/null`.strip.to_i
|
|
441
|
+
}
|
|
442
|
+
rescue StandardError
|
|
443
|
+
health[:memory] = { error: "unable to capture" }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
begin
|
|
447
|
+
health[:threads] = Thread.list.size
|
|
448
|
+
health[:process_id] = Process.pid
|
|
449
|
+
health[:ruby_version] = RUBY_VERSION
|
|
450
|
+
rescue StandardError
|
|
451
|
+
# Ignore
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
begin
|
|
455
|
+
pool = ActiveRecord::Base.connection_pool
|
|
456
|
+
connections = pool.connections
|
|
457
|
+
active_conns = connections.select(&:active?)
|
|
458
|
+
idle_conns = connections.reject(&:active?)
|
|
459
|
+
|
|
460
|
+
# Count dead connections (checked out but not active)
|
|
461
|
+
dead_count = 0
|
|
462
|
+
begin
|
|
463
|
+
dead_count = pool.instance_variable_get(:@dead_connections)&.size || 0
|
|
464
|
+
rescue StandardError
|
|
465
|
+
# Ignore
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Waiting threads (threads waiting for a connection)
|
|
469
|
+
waiting = pool.instance_variable_get(:@waiters)&.size || 0 rescue 0
|
|
470
|
+
|
|
471
|
+
health[:db_pool] = {
|
|
472
|
+
size: pool.size,
|
|
473
|
+
connections: connections.size,
|
|
474
|
+
active: active_conns.size,
|
|
475
|
+
busy: active_conns.size,
|
|
476
|
+
idle: idle_conns.size,
|
|
477
|
+
dead: dead_count,
|
|
478
|
+
waiting: waiting,
|
|
479
|
+
utilization: pool.size > 0 ? (connections.size.to_f / pool.size * 100).round(1) : 0
|
|
480
|
+
}
|
|
481
|
+
rescue StandardError
|
|
482
|
+
health[:db_pool] = { error: "unable to capture" }
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
health[:timestamp] = Time.current.iso8601
|
|
486
|
+
health
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def backtrace=(trace)
|
|
490
|
+
trace = sanitize_backtrace(trace) unless trace.is_a?(String)
|
|
491
|
+
write_attribute :backtrace, trace
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def request=(request)
|
|
495
|
+
if request.is_a?(String)
|
|
496
|
+
write_attribute :request, request
|
|
497
|
+
elsif request.respond_to?(:env) && request.env.is_a?(Hash)
|
|
498
|
+
max = request.env.keys.max { |a, b| a.length <=> b.length }
|
|
499
|
+
env = request.env.keys.sort.inject [] do |memo, key|
|
|
500
|
+
memo << "* %-*s: %s" % [max.length, key, request.env[key].to_s.strip]
|
|
501
|
+
end
|
|
502
|
+
write_attribute(:environment, (env << "* Process: #{$$}" << "* Server : #{self.class.host_name}").join("\n"))
|
|
503
|
+
|
|
504
|
+
method_str = request.respond_to?(:get?) && request.get? ? "" : " #{request.respond_to?(:method) ? request.method.to_s.upcase : "GET"}"
|
|
505
|
+
write_attribute(:request, [
|
|
506
|
+
"* URL:#{method_str} #{request.respond_to?(:protocol) ? request.protocol : "http://"}#{request.respond_to?(:env) ? request.env["HTTP_HOST"] : "localhost"}#{request.respond_to?(:fullpath) ? request.fullpath : "/"}",
|
|
507
|
+
"* Format: #{request.respond_to?(:format) ? request.format.to_s : "html"}",
|
|
508
|
+
"* Parameters: #{request.respond_to?(:parameters) ? request.parameters.inspect : "{}"}",
|
|
509
|
+
"* Rails Root: #{rails_root}"
|
|
510
|
+
].join("\n"))
|
|
511
|
+
else
|
|
512
|
+
write_attribute :request, request.to_s
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def controller_action
|
|
517
|
+
@controller_action ||= "#{controller_name.camelcase}/#{action_name}"
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def self.class_names
|
|
521
|
+
select("DISTINCT exception_class").order(:exception_class).collect(&:exception_class)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def self.controller_actions
|
|
525
|
+
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?)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# ─── Occurrence Pattern Detection ─────────────────────────────
|
|
530
|
+
# Detects cyclical, burst, and trend patterns in exception occurrences
|
|
531
|
+
|
|
532
|
+
# Main entry point: analyze error patterns over the last N days
|
|
533
|
+
def self.detect_occurrence_patterns(days: 30)
|
|
534
|
+
time_series = build_time_series(days: days)
|
|
535
|
+
return { patterns: [], hotspots: [], trend: nil } if time_series.empty?
|
|
536
|
+
|
|
537
|
+
{
|
|
538
|
+
patterns: detect_patterns(time_series),
|
|
539
|
+
hotspots: detect_hotspots(time_series),
|
|
540
|
+
trend: detect_trend(time_series),
|
|
541
|
+
burst_periods: detect_bursts(time_series),
|
|
542
|
+
cycle_info: detect_cycles(time_series)
|
|
543
|
+
}
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Build hourly time series of error counts
|
|
547
|
+
def self.build_time_series(days: 30)
|
|
548
|
+
start_time = days.days.ago.beginning_of_hour
|
|
549
|
+
raw = where("created_at >= ?", start_time)
|
|
550
|
+
.group(Arel.sql("DATE_FORMAT(created_at, '%Y-%m-%d %H:00')"))
|
|
551
|
+
.order(Arel.sql("DATE_FORMAT(created_at, '%Y-%m-%d %H:00')"))
|
|
552
|
+
.count
|
|
553
|
+
|
|
554
|
+
# Fill gaps with zeros
|
|
555
|
+
series = {}
|
|
556
|
+
current = start_time
|
|
557
|
+
while current <= Time.current
|
|
558
|
+
key = current.strftime("%Y-%m-%d %H:00")
|
|
559
|
+
series[key] = raw[key] || 0
|
|
560
|
+
current += 1.hour
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
series
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Detect cyclical patterns (regular intervals)
|
|
567
|
+
def self.detect_cycles(time_series)
|
|
568
|
+
counts = time_series.values
|
|
569
|
+
return { detected: false } if counts.length < 24
|
|
570
|
+
|
|
571
|
+
# Check hourly patterns (24-hour cycle)
|
|
572
|
+
hourly_avg = Array.new(24, 0.0)
|
|
573
|
+
hourly_counts = Array.new(24, 0)
|
|
574
|
+
time_series.each do |time_str, count|
|
|
575
|
+
hour = Time.parse(time_str).hour
|
|
576
|
+
hourly_avg[hour] += count
|
|
577
|
+
hourly_counts[hour] += 1
|
|
578
|
+
end
|
|
579
|
+
hourly_avg = hourly_avg.zip(hourly_counts).map { |a, b| b > 0 ? (a / b).round(2) : 0 }
|
|
580
|
+
|
|
581
|
+
# Find peak and off-peak hours
|
|
582
|
+
peak_hour = hourly_avg.each_with_index.max
|
|
583
|
+
off_peak_hour = hourly_avg.each_with_index.reject { |v, _| v == 0 }.min
|
|
584
|
+
peak_hour ||= [0, 0]
|
|
585
|
+
off_peak_hour ||= [0, 0]
|
|
586
|
+
|
|
587
|
+
# Calculate cycle strength (ratio of peak to average)
|
|
588
|
+
avg = counts.sum.to_f / counts.length
|
|
589
|
+
cycle_strength = avg > 0 ? (peak_hour[0] / avg).round(2) : 0
|
|
590
|
+
|
|
591
|
+
# Check daily patterns (weekly cycle)
|
|
592
|
+
daily_avg = Array.new(7, 0.0)
|
|
593
|
+
daily_counts = Array.new(7, 0)
|
|
594
|
+
time_series.each do |time_str, count|
|
|
595
|
+
wday = Time.parse(time_str).wday
|
|
596
|
+
daily_avg[wday] += count
|
|
597
|
+
daily_counts[wday] += 1
|
|
598
|
+
end
|
|
599
|
+
daily_avg = daily_avg.zip(daily_counts).map { |a, b| b > 0 ? (a / b).round(2) : 0 }
|
|
600
|
+
|
|
601
|
+
{
|
|
602
|
+
detected: cycle_strength > 1.5,
|
|
603
|
+
strength: cycle_strength,
|
|
604
|
+
hourly_pattern: hourly_avg,
|
|
605
|
+
daily_pattern: daily_avg,
|
|
606
|
+
peak_hour: peak_hour[1],
|
|
607
|
+
peak_hourly_avg: peak_hour[0],
|
|
608
|
+
off_peak_hour: off_peak_hour[1],
|
|
609
|
+
off_peak_hourly_avg: off_peak_hour[0],
|
|
610
|
+
description: if cycle_strength > 2.0
|
|
611
|
+
"Strong hourly cycle detected — peak at #{peak_hour[1]}:00 (#{peak_hour[0].round(1)}x average)"
|
|
612
|
+
elsif cycle_strength > 1.5
|
|
613
|
+
"Moderate hourly cycle — peak at #{peak_hour[1]}:00 (#{cycle_strength}x average)"
|
|
614
|
+
else
|
|
615
|
+
"No significant hourly cycle detected"
|
|
616
|
+
end
|
|
617
|
+
}
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# Detect burst periods (sudden spikes)
|
|
621
|
+
def self.detect_bursts(time_series)
|
|
622
|
+
counts = time_series.values
|
|
623
|
+
return [] if counts.length < 6
|
|
624
|
+
|
|
625
|
+
# Calculate rolling average and standard deviation
|
|
626
|
+
window = [6, counts.length / 6].max # 6-hour window or 1/6 of data
|
|
627
|
+
bursts = []
|
|
628
|
+
|
|
629
|
+
counts.each_with_index do |count, i|
|
|
630
|
+
next if i < window
|
|
631
|
+
|
|
632
|
+
window_slice = counts[(i - window)...i]
|
|
633
|
+
avg = window_slice.sum.to_f / window_slice.length
|
|
634
|
+
std = Math.sqrt(window_slice.map { |v| (v - avg)**2 }.sum / window_slice.length)
|
|
635
|
+
|
|
636
|
+
# Burst = count > mean + 2*std (statistical outlier)
|
|
637
|
+
threshold = avg + (2 * std)
|
|
638
|
+
if count > threshold && count >= 5
|
|
639
|
+
time_str = time_series.keys[i]
|
|
640
|
+
bursts << {
|
|
641
|
+
time: time_str,
|
|
642
|
+
count: count,
|
|
643
|
+
threshold: threshold.round(1),
|
|
644
|
+
spike_ratio: avg > 0 ? (count / avg).round(1) : count,
|
|
645
|
+
severity: if count > threshold * 2
|
|
646
|
+
:critical
|
|
647
|
+
elsif count > threshold * 1.5
|
|
648
|
+
:high
|
|
649
|
+
else
|
|
650
|
+
:medium
|
|
651
|
+
end
|
|
652
|
+
}
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# Sort by severity and return last 10
|
|
657
|
+
severity_order = { critical: 0, high: 1, medium: 2 }
|
|
658
|
+
bursts.sort_by { |b| severity_order[b[:severity]] }.last(10).reverse
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# Detect trend (increasing/decreasing/stable)
|
|
662
|
+
def self.detect_trend(time_series)
|
|
663
|
+
counts = time_series.values
|
|
664
|
+
return { direction: :stable, slope: 0 } if counts.length < 12
|
|
665
|
+
|
|
666
|
+
# Split into halves and compare
|
|
667
|
+
midpoint = counts.length / 2
|
|
668
|
+
first_half = counts[0...midpoint]
|
|
669
|
+
second_half = counts[midpoint..]
|
|
670
|
+
|
|
671
|
+
first_avg = first_half.sum.to_f / first_half.length
|
|
672
|
+
second_avg = second_half.sum.to_f / second_half.length
|
|
673
|
+
|
|
674
|
+
# Linear regression for slope
|
|
675
|
+
n = counts.length
|
|
676
|
+
x_mean = (n - 1) / 2.0
|
|
677
|
+
y_mean = counts.sum.to_f / n
|
|
678
|
+
numerator = counts.each_with_index.map { |y, x| (x - x_mean) * (y - y_mean) }.sum
|
|
679
|
+
denominator = counts.each_with_index.map { |x, _| (x - x_mean)**2 }.sum
|
|
680
|
+
slope = denominator > 0 ? numerator / denominator : 0
|
|
681
|
+
|
|
682
|
+
# Percentage change
|
|
683
|
+
pct_change = first_avg > 0 ? ((second_avg - first_avg) / first_avg * 100).round(1) : 0
|
|
684
|
+
|
|
685
|
+
direction = if pct_change > 20
|
|
686
|
+
:increasing
|
|
687
|
+
elsif pct_change < -20
|
|
688
|
+
:decreasing
|
|
689
|
+
else
|
|
690
|
+
:stable
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
{
|
|
694
|
+
direction: direction,
|
|
695
|
+
slope: slope.round(4),
|
|
696
|
+
first_half_avg: first_avg.round(2),
|
|
697
|
+
second_half_avg: second_avg.round(2),
|
|
698
|
+
pct_change: pct_change,
|
|
699
|
+
description: case direction
|
|
700
|
+
when :increasing
|
|
701
|
+
"Errors increasing (+#{pct_change}% over period)"
|
|
702
|
+
when :decreasing
|
|
703
|
+
"Errors decreasing (#{pct_change}% over period)"
|
|
704
|
+
else
|
|
705
|
+
"Error rate stable over period"
|
|
706
|
+
end
|
|
707
|
+
}
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# Detect hotspots (times of day with highest error rates)
|
|
711
|
+
def self.detect_hotspots(time_series)
|
|
712
|
+
hourly = Hash.new(0)
|
|
713
|
+
hourly_counts = Hash.new(0)
|
|
714
|
+
|
|
715
|
+
time_series.each do |time_str, count|
|
|
716
|
+
hour = Time.parse(time_str).hour
|
|
717
|
+
hourly[hour] += count
|
|
718
|
+
hourly_counts[hour] += 1
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Calculate average per hour
|
|
722
|
+
hourly_avg = hourly.map { |h, c| [h, c.to_f / hourly_counts[h]] }.sort_by { |_, v| -v }
|
|
723
|
+
|
|
724
|
+
# Return top 5 hotspot hours
|
|
725
|
+
hourly_avg.first(5).map do |hour, avg|
|
|
726
|
+
{
|
|
727
|
+
hour: hour,
|
|
728
|
+
avg_errors: avg.round(2),
|
|
729
|
+
label: format("%d:00 - %d:00", hour, (hour + 1) % 24)
|
|
730
|
+
}
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
# Detect patterns (combo of cyclical + burst)
|
|
735
|
+
def self.detect_patterns(time_series)
|
|
736
|
+
patterns = []
|
|
737
|
+
|
|
738
|
+
cycles = detect_cycles(time_series)
|
|
739
|
+
bursts = detect_bursts(time_series)
|
|
740
|
+
trend = detect_trend(time_series)
|
|
741
|
+
|
|
742
|
+
if cycles[:detected]
|
|
743
|
+
patterns << {
|
|
744
|
+
type: :cyclical,
|
|
745
|
+
severity: cycles[:strength] > 3 ? :high : :medium,
|
|
746
|
+
description: cycles[:description],
|
|
747
|
+
recommendation: "Consider scheduling maintenance windows during off-peak hours (#{cycles[:off_peak_hour]}:00)"
|
|
748
|
+
}
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
if bursts.any?
|
|
752
|
+
critical_bursts = bursts.select { |b| b[:severity] == :critical }
|
|
753
|
+
patterns << {
|
|
754
|
+
type: :burst,
|
|
755
|
+
severity: critical_bursts.any? ? :high : :medium,
|
|
756
|
+
count: bursts.length,
|
|
757
|
+
description: "#{bursts.length} burst period(s) detected, #{critical_bursts.length} critical",
|
|
758
|
+
recommendation: "Investigate root cause of error spikes — check deployment logs around burst times"
|
|
759
|
+
}
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
if trend[:direction] == :increasing
|
|
763
|
+
patterns << {
|
|
764
|
+
type: :increasing_trend,
|
|
765
|
+
severity: trend[:pct_change] > 50 ? :high : :medium,
|
|
766
|
+
description: trend[:description],
|
|
767
|
+
recommendation: "Error rate is climbing — review recent code changes and monitor closely"
|
|
768
|
+
}
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
if trend[:direction] == :decreasing
|
|
772
|
+
patterns << {
|
|
773
|
+
type: :decreasing_trend,
|
|
774
|
+
severity: :positive,
|
|
775
|
+
description: trend[:description],
|
|
776
|
+
recommendation: "Error rate declining — recent fixes appear effective"
|
|
777
|
+
}
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
patterns
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
# ─── Workflow Management ──────────────────────────────────────
|
|
785
|
+
|
|
786
|
+
# Assign this exception to someone
|
|
787
|
+
def assign_to(user, author: "system")
|
|
788
|
+
update!(assigned_to: user, assigned_at: Time.current)
|
|
789
|
+
comments.create!(author: author, body: "Assigned to #{user}", comment_type: "assignment")
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
# Set priority level
|
|
793
|
+
def set_priority(level, author: "system")
|
|
794
|
+
old_priority = priority
|
|
795
|
+
update!(priority: level)
|
|
796
|
+
comments.create!(author: author, body: "Priority changed from #{old_priority || 'none'} to #{level}", comment_type: "status_change")
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# Snooze for a duration
|
|
800
|
+
def snooze(duration, author: "system")
|
|
801
|
+
update!(snoozed_until: duration.from_now)
|
|
802
|
+
comments.create!(author: author, body: "Snoozed for #{duration.inspect}", comment_type: "status_change")
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# Mute (permanently silence)
|
|
806
|
+
def mute!(author: "system")
|
|
807
|
+
update!(muted: true, muted_at: Time.current)
|
|
808
|
+
comments.create!(author: author, body: "Muted — notifications silenced", comment_type: "status_change")
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# Unmute
|
|
812
|
+
def unmute!(author: "system")
|
|
813
|
+
update!(muted: false, muted_at: nil)
|
|
814
|
+
comments.create!(author: author, body: "Unmuted — notifications restored", comment_type: "status_change")
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
# Check if currently snoozed
|
|
818
|
+
def snoozed?
|
|
819
|
+
snoozed_until.present? && snoozed_until > Time.current
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# Check if active (not muted, not snoozed)
|
|
823
|
+
def active?
|
|
824
|
+
!muted? && !snoozed?
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
# Add a comment
|
|
828
|
+
def add_comment(author:, body:, comment_type: "comment")
|
|
829
|
+
comments.create!(author: author, body: body, comment_type: comment_type)
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# Workflow summary stats
|
|
833
|
+
def self.workflow_summary
|
|
834
|
+
{
|
|
835
|
+
total: count,
|
|
836
|
+
muted: muted.count,
|
|
837
|
+
snoozed: snoozed.count,
|
|
838
|
+
active: active.count,
|
|
839
|
+
unassigned: unassigned.count,
|
|
840
|
+
by_priority: %w[critical high medium low].map { |p| [p, by_priority(p).count] }.to_h,
|
|
841
|
+
recently_assigned: where("assigned_at > ?", 7.days.ago).count
|
|
842
|
+
}
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
private
|
|
846
|
+
|
|
847
|
+
@@rails_root = Pathname.new(Rails.root).cleanpath.to_s
|
|
848
|
+
@@backtrace_regex = /^#{Regexp.escape(@@rails_root)}/
|
|
849
|
+
|
|
850
|
+
def sanitize_backtrace(trace)
|
|
851
|
+
return "" if trace.nil?
|
|
852
|
+
return trace unless trace.respond_to?(:reject)
|
|
853
|
+
|
|
854
|
+
gem_path = Bundler.bundle_path.to_s
|
|
855
|
+
trace.reject { |line| line.include?(gem_path) }
|
|
856
|
+
.collect { |line| Pathname.new(line.gsub(@@backtrace_regex, "[RAILS_ROOT]")).cleanpath.to_s }
|
|
857
|
+
.join("\n")
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
def rails_root
|
|
861
|
+
@@rails_root
|
|
862
|
+
end
|
|
863
|
+
end
|
|
864
|
+
end
|