rails_error_dashboard 0.5.9 → 0.5.11
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 +4 -4
- data/README.md +60 -7
- data/app/controllers/rails_error_dashboard/errors_controller.rb +41 -0
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +2 -1
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +12 -0
- data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +40 -0
- data/app/mailers/rails_error_dashboard/digest_mailer.rb +23 -0
- data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +2 -1
- data/app/views/layouts/rails_error_dashboard.html.erb +10 -0
- data/app/views/rails_error_dashboard/digest_mailer/digest_summary.html.erb +172 -0
- data/app/views/rails_error_dashboard/digest_mailer/digest_summary.text.erb +49 -0
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +14 -1
- data/app/views/rails_error_dashboard/errors/_source_code.html.erb +20 -7
- data/app/views/rails_error_dashboard/errors/releases.html.erb +284 -0
- data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +15 -0
- data/app/views/rails_error_dashboard/errors/settings.html.erb +37 -1
- data/app/views/rails_error_dashboard/errors/show.html.erb +21 -0
- data/app/views/rails_error_dashboard/errors/user_impact.html.erb +172 -0
- data/config/routes.rb +4 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +6 -0
- data/lib/generators/rails_error_dashboard/install/templates/README +9 -18
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +32 -2
- data/lib/rails_error_dashboard/configuration.rb +55 -13
- data/lib/rails_error_dashboard/middleware/rate_limiter.rb +16 -12
- data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +2 -1
- data/lib/rails_error_dashboard/queries/release_timeline.rb +181 -0
- data/lib/rails_error_dashboard/queries/user_impact_summary.rb +93 -0
- data/lib/rails_error_dashboard/services/coverage_tracker.rb +139 -0
- data/lib/rails_error_dashboard/services/digest_builder.rb +158 -0
- data/lib/rails_error_dashboard/services/notification_helpers.rb +2 -1
- data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +6 -7
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +4 -0
- data/lib/tasks/error_dashboard.rake +23 -0
- metadata +35 -9
|
@@ -444,10 +444,40 @@ RailsErrorDashboard.configure do |config|
|
|
|
444
444
|
config.git_sha = ENV["GIT_SHA"]
|
|
445
445
|
# config.total_users_for_impact = 10000 # For user impact % calculation
|
|
446
446
|
|
|
447
|
-
# Git repository URL for clickable commit links
|
|
447
|
+
# Git repository URL for clickable commit links and issue tracking
|
|
448
448
|
# Examples:
|
|
449
449
|
# GitHub: "https://github.com/username/repo"
|
|
450
450
|
# GitLab: "https://gitlab.com/username/repo"
|
|
451
|
-
#
|
|
451
|
+
# Codeberg: "https://codeberg.org/username/repo"
|
|
452
452
|
# config.git_repository_url = ENV["GIT_REPOSITORY_URL"]
|
|
453
|
+
|
|
454
|
+
# ============================================================================
|
|
455
|
+
# ISSUE TRACKING (GitHub / GitLab / Codeberg)
|
|
456
|
+
# ============================================================================
|
|
457
|
+
#
|
|
458
|
+
# One switch enables everything: issue creation, auto-create on first
|
|
459
|
+
# occurrence, lifecycle sync (resolve → close, reopen → reopen), platform
|
|
460
|
+
# state mirroring (status, assignees, labels), and comment display.
|
|
461
|
+
#
|
|
462
|
+
# IMPORTANT: When enabled, the dashboard shows platform state instead of
|
|
463
|
+
# internal workflow controls:
|
|
464
|
+
# - "Mark as Resolved" → replaced by issue open/closed from platform
|
|
465
|
+
# - Workflow Status → issue state from platform
|
|
466
|
+
# - Assigned To → assignees from platform (with avatars)
|
|
467
|
+
# - Priority → labels from platform (with colors)
|
|
468
|
+
# - Snooze and Mute remain (no platform equivalent)
|
|
469
|
+
#
|
|
470
|
+
# Setup:
|
|
471
|
+
# 1. Create a RED bot account on GitHub/GitLab/Codeberg
|
|
472
|
+
# 2. Generate a token and set RED_BOT_TOKEN env var
|
|
473
|
+
# 3. Set git_repository_url above (already used for source code linking)
|
|
474
|
+
# 4. Enable:
|
|
475
|
+
#
|
|
476
|
+
# config.enable_issue_tracking = true
|
|
477
|
+
# config.issue_tracker_token = ENV["RED_BOT_TOKEN"]
|
|
478
|
+
#
|
|
479
|
+
# Optional overrides:
|
|
480
|
+
# config.issue_tracker_labels = ["bug"] # Labels added to new issues
|
|
481
|
+
# config.issue_tracker_auto_create_severities = [:critical, :high] # Auto-create threshold
|
|
482
|
+
# config.issue_webhook_secret = ENV["ISSUE_WEBHOOK_SECRET"] # Enables two-way webhook sync
|
|
453
483
|
end
|
|
@@ -34,6 +34,11 @@ module RailsErrorDashboard
|
|
|
34
34
|
attr_accessor :webhook_urls
|
|
35
35
|
attr_accessor :enable_webhook_notifications
|
|
36
36
|
|
|
37
|
+
# Scheduled digests (daily/weekly summary emails)
|
|
38
|
+
attr_accessor :enable_scheduled_digests # Master switch (default: false)
|
|
39
|
+
attr_accessor :digest_frequency # :daily or :weekly (default: :daily)
|
|
40
|
+
attr_accessor :digest_recipients # Array of emails (default: notification_email_recipients)
|
|
41
|
+
|
|
37
42
|
# Separate database configuration
|
|
38
43
|
attr_accessor :use_separate_database
|
|
39
44
|
|
|
@@ -82,17 +87,17 @@ module RailsErrorDashboard
|
|
|
82
87
|
attr_accessor :git_repository_url
|
|
83
88
|
|
|
84
89
|
# Issue tracker integration (GitHub, GitLab, Codeberg/Gitea/Forgejo)
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
# One switch enables everything: issue creation, auto-create, lifecycle sync,
|
|
91
|
+
# platform state mirroring, and comment display. Webhooks activate when
|
|
92
|
+
# issue_webhook_secret is set.
|
|
93
|
+
attr_accessor :enable_issue_tracking # Master switch (default: false) — enables all platform integration
|
|
87
94
|
attr_accessor :issue_tracker_token # String or lambda/proc for Rails credentials
|
|
95
|
+
attr_accessor :issue_tracker_provider # :github, :gitlab, :codeberg (auto-detected from git_repository_url)
|
|
88
96
|
attr_accessor :issue_tracker_repo # "owner/repo" (auto-extracted from git_repository_url)
|
|
89
97
|
attr_accessor :issue_tracker_labels # Array of label strings (default: ["bug"])
|
|
90
98
|
attr_accessor :issue_tracker_api_url # Custom API base URL for self-hosted instances
|
|
91
|
-
attr_accessor :
|
|
92
|
-
attr_accessor :
|
|
93
|
-
attr_accessor :auto_create_issues_for_severities # Array of symbols (default: [:critical, :high])
|
|
94
|
-
attr_accessor :enable_issue_webhooks # Boolean (default: false) — receive webhooks for two-way sync
|
|
95
|
-
attr_accessor :issue_webhook_secret # String — HMAC secret for webhook signature verification
|
|
99
|
+
attr_accessor :issue_tracker_auto_create_severities # Auto-create for these severities (default: [:critical, :high])
|
|
100
|
+
attr_accessor :issue_webhook_secret # HMAC secret — webhooks activate when this is set
|
|
96
101
|
|
|
97
102
|
# Advanced error analysis features
|
|
98
103
|
attr_accessor :enable_similar_errors # Fuzzy error matching
|
|
@@ -168,6 +173,9 @@ module RailsErrorDashboard
|
|
|
168
173
|
# On-demand diagnostic dump (rake task + dashboard endpoint)
|
|
169
174
|
attr_accessor :enable_diagnostic_dump # Master switch (default: false)
|
|
170
175
|
|
|
176
|
+
# Code path coverage (diagnostic mode — Ruby 3.2+)
|
|
177
|
+
attr_accessor :enable_coverage_tracking # Master switch (default: false)
|
|
178
|
+
|
|
171
179
|
# Rack Attack event tracking (requires enable_breadcrumbs = true)
|
|
172
180
|
attr_accessor :enable_rack_attack_tracking # Master switch (default: false)
|
|
173
181
|
|
|
@@ -215,6 +223,11 @@ module RailsErrorDashboard
|
|
|
215
223
|
@webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
|
|
216
224
|
@enable_webhook_notifications = false
|
|
217
225
|
|
|
226
|
+
# Scheduled digest defaults - OFF by default (opt-in)
|
|
227
|
+
@enable_scheduled_digests = false
|
|
228
|
+
@digest_frequency = :daily
|
|
229
|
+
@digest_recipients = nil # falls back to notification_email_recipients
|
|
230
|
+
|
|
218
231
|
@use_separate_database = ENV.fetch("USE_SEPARATE_ERROR_DB", "false") == "true"
|
|
219
232
|
|
|
220
233
|
# Retention policy - days to keep errors before automatic deletion (default: 90)
|
|
@@ -244,17 +257,14 @@ module RailsErrorDashboard
|
|
|
244
257
|
@total_users_for_impact = nil # Auto-detect if not set
|
|
245
258
|
@git_repository_url = ENV["GIT_REPOSITORY_URL"]
|
|
246
259
|
|
|
247
|
-
# Issue tracker integration defaults — OFF by default
|
|
260
|
+
# Issue tracker integration defaults — OFF by default, one switch enables all
|
|
248
261
|
@enable_issue_tracking = false
|
|
262
|
+
@issue_tracker_token = ENV["RED_BOT_TOKEN"] || ENV["ISSUE_TRACKER_TOKEN"]
|
|
249
263
|
@issue_tracker_provider = nil # Auto-detect from git_repository_url
|
|
250
|
-
@issue_tracker_token = ENV["ISSUE_TRACKER_TOKEN"]
|
|
251
264
|
@issue_tracker_repo = nil # Auto-extract from git_repository_url
|
|
252
265
|
@issue_tracker_labels = [ "bug" ]
|
|
253
266
|
@issue_tracker_api_url = nil # For self-hosted instances
|
|
254
|
-
@
|
|
255
|
-
@auto_create_issues_on_first_occurrence = true
|
|
256
|
-
@auto_create_issues_for_severities = [ :critical, :high ]
|
|
257
|
-
@enable_issue_webhooks = false
|
|
267
|
+
@issue_tracker_auto_create_severities = [ :critical, :high ]
|
|
258
268
|
@issue_webhook_secret = ENV["ISSUE_WEBHOOK_SECRET"]
|
|
259
269
|
|
|
260
270
|
# Advanced error analysis features (all OFF by default - opt-in)
|
|
@@ -328,6 +338,9 @@ module RailsErrorDashboard
|
|
|
328
338
|
# Diagnostic dump defaults - OFF by default (opt-in)
|
|
329
339
|
@enable_diagnostic_dump = false # On-demand system state snapshot
|
|
330
340
|
|
|
341
|
+
# Code path coverage defaults - OFF by default (opt-in, Ruby 3.2+)
|
|
342
|
+
@enable_coverage_tracking = false
|
|
343
|
+
|
|
331
344
|
# Rack Attack event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
|
|
332
345
|
@enable_rack_attack_tracking = false
|
|
333
346
|
|
|
@@ -655,6 +668,14 @@ module RailsErrorDashboard
|
|
|
655
668
|
end
|
|
656
669
|
end
|
|
657
670
|
|
|
671
|
+
# Detect the engine's mount path from the host app routes.
|
|
672
|
+
# Falls back to "/red" if detection fails.
|
|
673
|
+
#
|
|
674
|
+
# @return [String] The mount path (e.g. "/red", "/admin/red", "/error_dashboard")
|
|
675
|
+
def engine_mount_path
|
|
676
|
+
@engine_mount_path ||= detect_engine_mount_path
|
|
677
|
+
end
|
|
678
|
+
|
|
658
679
|
# Get the effective user model (auto-detected if not configured)
|
|
659
680
|
#
|
|
660
681
|
# @return [String, nil] User model class name
|
|
@@ -692,5 +713,26 @@ module RailsErrorDashboard
|
|
|
692
713
|
def clear_total_users_cache!
|
|
693
714
|
@total_users_cache = {}
|
|
694
715
|
end
|
|
716
|
+
|
|
717
|
+
# Detect where the engine is mounted in the host app's routes.
|
|
718
|
+
# @return [String] mount path (default: "/red")
|
|
719
|
+
def detect_engine_mount_path
|
|
720
|
+
return "/red" unless defined?(Rails) && Rails.application
|
|
721
|
+
|
|
722
|
+
Rails.application.routes.routes.each do |route|
|
|
723
|
+
app = route.app
|
|
724
|
+
app = app.app if app.respond_to?(:app)
|
|
725
|
+
if app == RailsErrorDashboard::Engine || (app.is_a?(Class) && app <= RailsErrorDashboard::Engine)
|
|
726
|
+
path = route.path.spec.to_s.sub("(.:format)", "").chomp("/")
|
|
727
|
+
return path if path.present?
|
|
728
|
+
end
|
|
729
|
+
rescue => e
|
|
730
|
+
next
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
"/red"
|
|
734
|
+
rescue => e
|
|
735
|
+
"/red"
|
|
736
|
+
end
|
|
695
737
|
end
|
|
696
738
|
end
|
|
@@ -5,14 +5,9 @@ module RailsErrorDashboard
|
|
|
5
5
|
# Rate limiting middleware for Rails Error Dashboard routes
|
|
6
6
|
# Protects both dashboard UI and API endpoints from abuse
|
|
7
7
|
class RateLimiter
|
|
8
|
-
# Rate limits by endpoint type
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"/error_dashboard/api" => { limit: 100, period: 60 }, # 100 req/min
|
|
12
|
-
|
|
13
|
-
# Dashboard pages (human users) - more lenient
|
|
14
|
-
"/error_dashboard" => { limit: 300, period: 60 } # 300 req/min
|
|
15
|
-
}.freeze
|
|
8
|
+
# Rate limits by endpoint type (relative to engine mount path)
|
|
9
|
+
API_LIMIT = { limit: 100, period: 60 }.freeze # 100 req/min
|
|
10
|
+
DASHBOARD_LIMIT = { limit: 300, period: 60 }.freeze # 300 req/min
|
|
16
11
|
|
|
17
12
|
def initialize(app)
|
|
18
13
|
@app = app
|
|
@@ -51,13 +46,22 @@ module RailsErrorDashboard
|
|
|
51
46
|
RailsErrorDashboard.configuration.enable_rate_limiting
|
|
52
47
|
end
|
|
53
48
|
|
|
49
|
+
def engine_mount_path
|
|
50
|
+
@engine_mount_path ||= RailsErrorDashboard.configuration.engine_mount_path
|
|
51
|
+
rescue
|
|
52
|
+
"/red"
|
|
53
|
+
end
|
|
54
|
+
|
|
54
55
|
def error_dashboard_route?(path)
|
|
55
|
-
path.start_with?(
|
|
56
|
+
path.start_with?(engine_mount_path)
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
def find_limit_config(path)
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
if path.start_with?("#{engine_mount_path}/api")
|
|
61
|
+
API_LIMIT
|
|
62
|
+
else
|
|
63
|
+
DASHBOARD_LIMIT
|
|
64
|
+
end
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
def rate_limit_key(request)
|
|
@@ -71,7 +75,7 @@ module RailsErrorDashboard
|
|
|
71
75
|
|
|
72
76
|
def rate_limit_response(request, limit_config)
|
|
73
77
|
# Return JSON for API requests, HTML for dashboard
|
|
74
|
-
if request.path.start_with?("/
|
|
78
|
+
if request.path.start_with?("#{engine_mount_path}/api")
|
|
75
79
|
json_rate_limit_response(limit_config)
|
|
76
80
|
else
|
|
77
81
|
html_rate_limit_response(limit_config)
|
|
@@ -114,7 +114,8 @@ module RailsErrorDashboard
|
|
|
114
114
|
|
|
115
115
|
def dashboard_url(error_log)
|
|
116
116
|
base_url = RailsErrorDashboard.configuration.dashboard_base_url || "http://localhost:3000"
|
|
117
|
-
|
|
117
|
+
mount_path = RailsErrorDashboard.configuration.engine_mount_path
|
|
118
|
+
"#{base_url}#{mount_path}/errors/#{error_log.id}"
|
|
118
119
|
end
|
|
119
120
|
end
|
|
120
121
|
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Build a release timeline from error data, showing per-version health stats,
|
|
6
|
+
# "new in this release" error detection, stability indicators, and release-over-release deltas.
|
|
7
|
+
# Uses existing app_version and git_sha columns on error_logs — no new migration needed.
|
|
8
|
+
class ReleaseTimeline
|
|
9
|
+
def self.call(days = 30, application_id: nil)
|
|
10
|
+
new(days, application_id: application_id).call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(days = 30, application_id: nil)
|
|
14
|
+
@days = days
|
|
15
|
+
@application_id = application_id
|
|
16
|
+
@start_date = days.days.ago
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
return empty_result unless has_version_column?
|
|
21
|
+
|
|
22
|
+
releases = build_releases
|
|
23
|
+
{
|
|
24
|
+
releases: releases,
|
|
25
|
+
summary: build_summary(releases)
|
|
26
|
+
}
|
|
27
|
+
rescue => e
|
|
28
|
+
Rails.logger.error("[RailsErrorDashboard] ReleaseTimeline failed: #{e.class}: #{e.message}")
|
|
29
|
+
empty_result
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def base_scope
|
|
35
|
+
scope = ErrorLog.where("occurred_at >= ?", @start_date)
|
|
36
|
+
.where.not(app_version: [ nil, "" ])
|
|
37
|
+
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
38
|
+
scope
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build_releases
|
|
42
|
+
version_stats = aggregate_version_stats
|
|
43
|
+
return [] if version_stats.empty?
|
|
44
|
+
|
|
45
|
+
new_errors = new_errors_per_version
|
|
46
|
+
avg_errors = version_stats.sum { |_v, s| s[:total_errors] }.to_f / version_stats.size
|
|
47
|
+
|
|
48
|
+
# Sort chronologically by first_seen (oldest first) for delta calculation
|
|
49
|
+
sorted_chrono = version_stats.sort_by { |_v, s| s[:first_seen] }
|
|
50
|
+
|
|
51
|
+
releases = []
|
|
52
|
+
sorted_chrono.each_with_index do |(version, stats), idx|
|
|
53
|
+
previous_count = idx > 0 ? sorted_chrono[idx - 1][1][:total_errors] : nil
|
|
54
|
+
|
|
55
|
+
releases << {
|
|
56
|
+
version: version,
|
|
57
|
+
git_shas: stats[:git_shas],
|
|
58
|
+
first_seen: stats[:first_seen],
|
|
59
|
+
last_seen: stats[:last_seen],
|
|
60
|
+
current: false, # set below
|
|
61
|
+
total_errors: stats[:total_errors],
|
|
62
|
+
unique_error_types: stats[:unique_error_types],
|
|
63
|
+
new_error_count: new_errors[version] || 0,
|
|
64
|
+
stability: stability_indicator(stats[:total_errors], avg_errors),
|
|
65
|
+
problematic: stats[:total_errors] > (avg_errors * 2),
|
|
66
|
+
delta_from_previous: previous_count ? (stats[:total_errors] - previous_count) : nil,
|
|
67
|
+
delta_percentage: previous_count && previous_count > 0 ? ((stats[:total_errors] - previous_count).to_f / previous_count * 100).round(1) : nil
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Mark the most recent release as current
|
|
72
|
+
releases.last[:current] = true if releases.any?
|
|
73
|
+
|
|
74
|
+
# Return in reverse chronological order (newest first)
|
|
75
|
+
releases.reverse
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Single GROUP BY query for per-version aggregates
|
|
79
|
+
def aggregate_version_stats
|
|
80
|
+
rows = base_scope
|
|
81
|
+
.group(:app_version)
|
|
82
|
+
.select(
|
|
83
|
+
:app_version,
|
|
84
|
+
"COUNT(*) AS total_count",
|
|
85
|
+
"COUNT(DISTINCT error_type) AS unique_types",
|
|
86
|
+
"MIN(occurred_at) AS first_seen_at",
|
|
87
|
+
"MAX(occurred_at) AS last_seen_at"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Collect git_shas per version in a second lightweight query
|
|
91
|
+
sha_map = {}
|
|
92
|
+
if has_git_sha_column?
|
|
93
|
+
base_scope.where.not(git_sha: [ nil, "" ])
|
|
94
|
+
.group(:app_version, :git_sha)
|
|
95
|
+
.pluck(:app_version, :git_sha)
|
|
96
|
+
.each do |version, sha|
|
|
97
|
+
(sha_map[version] ||= []) << sha
|
|
98
|
+
end
|
|
99
|
+
sha_map.each_value(&:uniq!)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
rows.each_with_object({}) do |row, result|
|
|
103
|
+
version = row.app_version
|
|
104
|
+
first_seen = row.first_seen_at
|
|
105
|
+
last_seen = row.last_seen_at
|
|
106
|
+
first_seen = Time.zone.parse(first_seen) if first_seen.is_a?(String)
|
|
107
|
+
last_seen = Time.zone.parse(last_seen) if last_seen.is_a?(String)
|
|
108
|
+
|
|
109
|
+
result[version] = {
|
|
110
|
+
total_errors: row.total_count.to_i,
|
|
111
|
+
unique_error_types: row.unique_types.to_i,
|
|
112
|
+
first_seen: first_seen,
|
|
113
|
+
last_seen: last_seen,
|
|
114
|
+
git_shas: sha_map[version] || []
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
rescue => e
|
|
118
|
+
Rails.logger.error("[RailsErrorDashboard] ReleaseTimeline aggregate failed: #{e.class}: #{e.message}")
|
|
119
|
+
{}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# For each error_hash in the window, find which app_version it first appeared in.
|
|
123
|
+
# Count per version to get "new errors introduced in this release".
|
|
124
|
+
def new_errors_per_version
|
|
125
|
+
return {} unless has_error_hash_column?
|
|
126
|
+
|
|
127
|
+
# Get the earliest occurrence per error_hash, with its app_version
|
|
128
|
+
# We need (error_hash, app_version) at MIN(occurred_at)
|
|
129
|
+
earliest = {}
|
|
130
|
+
base_scope.select(:error_hash, :app_version, :occurred_at)
|
|
131
|
+
.where.not(error_hash: [ nil, "" ])
|
|
132
|
+
.order(:occurred_at)
|
|
133
|
+
.each do |row|
|
|
134
|
+
earliest[row.error_hash] ||= row.app_version
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Count how many error_hashes have each version as their earliest
|
|
138
|
+
earliest.values.tally
|
|
139
|
+
rescue => e
|
|
140
|
+
Rails.logger.error("[RailsErrorDashboard] ReleaseTimeline new_errors failed: #{e.class}: #{e.message}")
|
|
141
|
+
{}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def stability_indicator(count, avg)
|
|
145
|
+
return :green if avg <= 0
|
|
146
|
+
ratio = count.to_f / avg
|
|
147
|
+
if ratio <= 1.0
|
|
148
|
+
:green
|
|
149
|
+
elsif ratio <= 2.0
|
|
150
|
+
:yellow
|
|
151
|
+
else
|
|
152
|
+
:red
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def build_summary(releases)
|
|
157
|
+
{
|
|
158
|
+
total_releases: releases.size,
|
|
159
|
+
current_version: releases.first&.dig(:version),
|
|
160
|
+
avg_errors_per_release: releases.any? ? (releases.sum { |r| r[:total_errors] }.to_f / releases.size).round(1) : 0
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def empty_result
|
|
165
|
+
{ releases: [], summary: { total_releases: 0, current_version: nil, avg_errors_per_release: 0 } }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def has_version_column?
|
|
169
|
+
ErrorLog.column_names.include?("app_version")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def has_git_sha_column?
|
|
173
|
+
ErrorLog.column_names.include?("git_sha")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def has_error_hash_column?
|
|
177
|
+
ErrorLog.column_names.include?("error_hash")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Rank errors by unique user impact — how many distinct users each error type affects.
|
|
6
|
+
# Surfaces "this error affected 847 unique users" prominently.
|
|
7
|
+
# An error hitting 1 user 1000 times is different from an error hitting 1000 users once.
|
|
8
|
+
class UserImpactSummary
|
|
9
|
+
def self.call(days = 30, application_id: nil)
|
|
10
|
+
new(days, application_id: application_id).call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(days = 30, application_id: nil)
|
|
14
|
+
@days = days
|
|
15
|
+
@application_id = application_id
|
|
16
|
+
@start_date = days.days.ago
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
all_entries = build_entries
|
|
21
|
+
{
|
|
22
|
+
entries: all_entries,
|
|
23
|
+
summary: build_summary(all_entries)
|
|
24
|
+
}
|
|
25
|
+
rescue => e
|
|
26
|
+
Rails.logger.error("[RailsErrorDashboard] UserImpactSummary failed: #{e.class}: #{e.message}")
|
|
27
|
+
empty_result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def base_scope
|
|
33
|
+
scope = ErrorLog.where("occurred_at >= ?", @start_date)
|
|
34
|
+
.where.not(user_id: nil)
|
|
35
|
+
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
36
|
+
scope
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def build_entries
|
|
40
|
+
# Group by error_type, count distinct users and total occurrences
|
|
41
|
+
user_counts = base_scope
|
|
42
|
+
.group(:error_type)
|
|
43
|
+
.distinct
|
|
44
|
+
.count(:user_id)
|
|
45
|
+
|
|
46
|
+
occurrence_counts = base_scope
|
|
47
|
+
.group(:error_type)
|
|
48
|
+
.count
|
|
49
|
+
|
|
50
|
+
total_users = effective_total_users
|
|
51
|
+
|
|
52
|
+
user_counts.map do |error_type, unique_users|
|
|
53
|
+
occurrences = occurrence_counts[error_type] || 0
|
|
54
|
+
sample = base_scope.where(error_type: error_type).order(occurred_at: :desc).first
|
|
55
|
+
impact_pct = total_users && total_users > 0 ? (unique_users.to_f / total_users * 100).round(1) : nil
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
error_type: error_type,
|
|
59
|
+
message: sample&.message.to_s.truncate(120),
|
|
60
|
+
unique_users: unique_users,
|
|
61
|
+
total_occurrences: occurrences,
|
|
62
|
+
impact_percentage: impact_pct,
|
|
63
|
+
severity: sample&.severity,
|
|
64
|
+
last_seen: sample&.occurred_at,
|
|
65
|
+
id: sample&.id
|
|
66
|
+
}
|
|
67
|
+
end.sort_by { |e| -e[:unique_users] }
|
|
68
|
+
rescue => e
|
|
69
|
+
Rails.logger.error("[RailsErrorDashboard] UserImpactSummary.build_entries failed: #{e.class}: #{e.message}")
|
|
70
|
+
[]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def build_summary(entries)
|
|
74
|
+
{
|
|
75
|
+
total_error_types_with_users: entries.size,
|
|
76
|
+
total_unique_users_affected: entries.sum { |e| e[:unique_users] },
|
|
77
|
+
most_impactful: entries.first&.dig(:error_type),
|
|
78
|
+
total_users: effective_total_users
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def effective_total_users
|
|
83
|
+
RailsErrorDashboard.configuration.effective_total_users
|
|
84
|
+
rescue => e
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def empty_result
|
|
89
|
+
{ entries: [], summary: { total_error_types_with_users: 0, total_unique_users_affected: 0, most_impactful: nil, total_users: nil } }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "coverage"
|
|
4
|
+
|
|
5
|
+
module RailsErrorDashboard
|
|
6
|
+
module Services
|
|
7
|
+
# Diagnostic-mode code path coverage using Ruby's Coverage API.
|
|
8
|
+
#
|
|
9
|
+
# Operator enables coverage via dashboard button, reproduces the error,
|
|
10
|
+
# views source code with executed-line overlay, then disables.
|
|
11
|
+
# Zero overhead when off. Uses oneshot_lines mode (each line fires once).
|
|
12
|
+
#
|
|
13
|
+
# SAFETY:
|
|
14
|
+
# - Coverage is process-global (not thread-local) — data blends across threads
|
|
15
|
+
# - oneshot_lines mode has near-zero ongoing overhead
|
|
16
|
+
# - peek_result is read-only, does not mutate state
|
|
17
|
+
# - Every method rescue-wrapped (never raises)
|
|
18
|
+
# - Ruby 3.2+ required for Coverage.setup with oneshot_lines
|
|
19
|
+
class CoverageTracker
|
|
20
|
+
@active = false
|
|
21
|
+
@we_started = false
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Check if the current Ruby version supports Coverage with oneshot_lines
|
|
26
|
+
def supported?
|
|
27
|
+
RUBY_VERSION >= "3.2"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Start coverage collection in oneshot_lines mode
|
|
31
|
+
# @return [Boolean] true if successfully enabled
|
|
32
|
+
def enable!
|
|
33
|
+
return false unless supported?
|
|
34
|
+
return true if @active
|
|
35
|
+
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
return true if @active
|
|
38
|
+
|
|
39
|
+
Coverage.setup(oneshot_lines: true)
|
|
40
|
+
Coverage.resume
|
|
41
|
+
@we_started = true
|
|
42
|
+
@active = true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
true
|
|
46
|
+
rescue => e
|
|
47
|
+
# Coverage.setup raises RuntimeError if another session is active (e.g. SimpleCov).
|
|
48
|
+
# In that case, we piggyback on the existing session for peek_result.
|
|
49
|
+
if e.is_a?(RuntimeError) && coverage_running?
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@we_started = false
|
|
52
|
+
@active = true
|
|
53
|
+
end
|
|
54
|
+
true
|
|
55
|
+
else
|
|
56
|
+
Rails.logger.error("[RailsErrorDashboard] CoverageTracker.enable! failed: #{e.class}: #{e.message}")
|
|
57
|
+
@active = false
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Stop coverage collection and clear all data
|
|
63
|
+
def disable!
|
|
64
|
+
return unless @active
|
|
65
|
+
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
return unless @active
|
|
68
|
+
# Only call Coverage.result if WE started the session
|
|
69
|
+
# Don't kill another tool's coverage (e.g. SimpleCov)
|
|
70
|
+
if @we_started
|
|
71
|
+
begin
|
|
72
|
+
Coverage.result
|
|
73
|
+
rescue RuntimeError
|
|
74
|
+
# Already stopped
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
@active = false
|
|
78
|
+
@we_started = false
|
|
79
|
+
end
|
|
80
|
+
rescue => e
|
|
81
|
+
Rails.logger.error("[RailsErrorDashboard] CoverageTracker.disable! failed: #{e.class}: #{e.message}")
|
|
82
|
+
@active = false
|
|
83
|
+
@we_started = false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Whether coverage is currently being collected
|
|
87
|
+
def active?
|
|
88
|
+
@active
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get executed line numbers for a specific file
|
|
92
|
+
# @param file_path [String] absolute path to the source file
|
|
93
|
+
# @return [Hash{Integer => Boolean}] line_number => executed?, or nil if inactive
|
|
94
|
+
def peek(file_path)
|
|
95
|
+
return nil if file_path.nil?
|
|
96
|
+
return nil unless @active
|
|
97
|
+
|
|
98
|
+
result = Coverage.peek_result
|
|
99
|
+
file_data = result[file_path]
|
|
100
|
+
return {} unless file_data
|
|
101
|
+
|
|
102
|
+
# Coverage result format varies by mode:
|
|
103
|
+
# - oneshot_lines: { oneshot_lines: [nil, 0, nil, 1, ...] }
|
|
104
|
+
# - lines (SimpleCov): { lines: [0, 1, nil, 2, ...] } or just [0, 1, nil, 2, ...]
|
|
105
|
+
# where index = line_number - 1, nil = not executable, 0 = not hit, N>0 = hit
|
|
106
|
+
lines_data = if file_data.is_a?(Hash)
|
|
107
|
+
file_data[:oneshot_lines] || file_data[:lines]
|
|
108
|
+
elsif file_data.is_a?(Array)
|
|
109
|
+
file_data
|
|
110
|
+
end
|
|
111
|
+
return {} unless lines_data.is_a?(Array)
|
|
112
|
+
|
|
113
|
+
executed = {}
|
|
114
|
+
lines_data.each_with_index do |val, idx|
|
|
115
|
+
next if val.nil? # not an executable line
|
|
116
|
+
|
|
117
|
+
line_number = idx + 1
|
|
118
|
+
executed[line_number] = val > 0
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
executed
|
|
122
|
+
rescue => e
|
|
123
|
+
Rails.logger.error("[RailsErrorDashboard] CoverageTracker.peek failed: #{e.class}: #{e.message}")
|
|
124
|
+
{}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# Check if Coverage is currently running (e.g. via SimpleCov)
|
|
130
|
+
def coverage_running?
|
|
131
|
+
Coverage.peek_result
|
|
132
|
+
true
|
|
133
|
+
rescue RuntimeError
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|