findbug 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +375 -0
- data/Rakefile +12 -0
- data/app/controllers/findbug/application_controller.rb +105 -0
- data/app/controllers/findbug/dashboard_controller.rb +93 -0
- data/app/controllers/findbug/errors_controller.rb +129 -0
- data/app/controllers/findbug/performance_controller.rb +80 -0
- data/app/jobs/findbug/alert_job.rb +40 -0
- data/app/jobs/findbug/cleanup_job.rb +132 -0
- data/app/jobs/findbug/persist_job.rb +158 -0
- data/app/models/findbug/error_event.rb +197 -0
- data/app/models/findbug/performance_event.rb +237 -0
- data/app/views/findbug/dashboard/index.html.erb +199 -0
- data/app/views/findbug/errors/index.html.erb +137 -0
- data/app/views/findbug/errors/show.html.erb +185 -0
- data/app/views/findbug/performance/index.html.erb +168 -0
- data/app/views/findbug/performance/show.html.erb +203 -0
- data/app/views/layouts/findbug/application.html.erb +601 -0
- data/lib/findbug/alerts/channels/base.rb +75 -0
- data/lib/findbug/alerts/channels/discord.rb +155 -0
- data/lib/findbug/alerts/channels/email.rb +179 -0
- data/lib/findbug/alerts/channels/slack.rb +149 -0
- data/lib/findbug/alerts/channels/webhook.rb +143 -0
- data/lib/findbug/alerts/dispatcher.rb +126 -0
- data/lib/findbug/alerts/throttler.rb +110 -0
- data/lib/findbug/background_persister.rb +142 -0
- data/lib/findbug/capture/context.rb +301 -0
- data/lib/findbug/capture/exception_handler.rb +141 -0
- data/lib/findbug/capture/exception_subscriber.rb +228 -0
- data/lib/findbug/capture/message_handler.rb +104 -0
- data/lib/findbug/capture/middleware.rb +247 -0
- data/lib/findbug/configuration.rb +381 -0
- data/lib/findbug/engine.rb +109 -0
- data/lib/findbug/performance/instrumentation.rb +336 -0
- data/lib/findbug/performance/transaction.rb +193 -0
- data/lib/findbug/processing/data_scrubber.rb +163 -0
- data/lib/findbug/rails/controller_methods.rb +152 -0
- data/lib/findbug/railtie.rb +222 -0
- data/lib/findbug/storage/circuit_breaker.rb +223 -0
- data/lib/findbug/storage/connection_pool.rb +134 -0
- data/lib/findbug/storage/redis_buffer.rb +285 -0
- data/lib/findbug/tasks/findbug.rake +167 -0
- data/lib/findbug/version.rb +5 -0
- data/lib/findbug.rb +216 -0
- data/lib/generators/findbug/install_generator.rb +67 -0
- data/lib/generators/findbug/templates/POST_INSTALL +41 -0
- data/lib/generators/findbug/templates/create_findbug_error_events.rb +44 -0
- data/lib/generators/findbug/templates/create_findbug_performance_events.rb +47 -0
- data/lib/generators/findbug/templates/initializer.rb +157 -0
- data/sig/findbug.rbs +4 -0
- metadata +251 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Findbug
|
|
4
|
+
# ErrorEvent stores captured exceptions in the database.
|
|
5
|
+
#
|
|
6
|
+
# DATABASE SCHEMA
|
|
7
|
+
# ===============
|
|
8
|
+
#
|
|
9
|
+
# This model expects a table created by the install generator:
|
|
10
|
+
#
|
|
11
|
+
# create_table :findbug_error_events do |t|
|
|
12
|
+
# t.string :fingerprint, null: false
|
|
13
|
+
# t.string :exception_class, null: false
|
|
14
|
+
# t.text :message
|
|
15
|
+
# t.text :backtrace
|
|
16
|
+
# t.jsonb :context, default: {}
|
|
17
|
+
# t.jsonb :request_data, default: {}
|
|
18
|
+
# t.string :environment
|
|
19
|
+
# t.string :release_version
|
|
20
|
+
# t.string :severity, default: 'error'
|
|
21
|
+
# t.string :source
|
|
22
|
+
# t.boolean :handled, default: false
|
|
23
|
+
# t.integer :occurrence_count, default: 1
|
|
24
|
+
# t.datetime :first_seen_at
|
|
25
|
+
# t.datetime :last_seen_at
|
|
26
|
+
# t.string :status, default: 'unresolved'
|
|
27
|
+
# t.timestamps
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# WHY JSONB FOR CONTEXT?
|
|
31
|
+
# ======================
|
|
32
|
+
#
|
|
33
|
+
# Context is semi-structured - different errors have different context.
|
|
34
|
+
# JSONB (in PostgreSQL) or JSON (in other DBs) lets us store any shape
|
|
35
|
+
# of data without schema migrations.
|
|
36
|
+
#
|
|
37
|
+
# For querying, we create GIN indexes on commonly queried paths.
|
|
38
|
+
#
|
|
39
|
+
class ErrorEvent < ActiveRecord::Base
|
|
40
|
+
self.table_name = "findbug_error_events"
|
|
41
|
+
|
|
42
|
+
# Statuses
|
|
43
|
+
STATUS_UNRESOLVED = "unresolved"
|
|
44
|
+
STATUS_RESOLVED = "resolved"
|
|
45
|
+
STATUS_IGNORED = "ignored"
|
|
46
|
+
|
|
47
|
+
# Severities
|
|
48
|
+
SEVERITY_ERROR = "error"
|
|
49
|
+
SEVERITY_WARNING = "warning"
|
|
50
|
+
SEVERITY_INFO = "info"
|
|
51
|
+
|
|
52
|
+
# Validations
|
|
53
|
+
validates :fingerprint, presence: true
|
|
54
|
+
validates :exception_class, presence: true
|
|
55
|
+
validates :status, inclusion: { in: [STATUS_UNRESOLVED, STATUS_RESOLVED, STATUS_IGNORED] }
|
|
56
|
+
validates :severity, inclusion: { in: [SEVERITY_ERROR, SEVERITY_WARNING, SEVERITY_INFO] }
|
|
57
|
+
|
|
58
|
+
# Scopes
|
|
59
|
+
scope :unresolved, -> { where(status: STATUS_UNRESOLVED) }
|
|
60
|
+
scope :resolved, -> { where(status: STATUS_RESOLVED) }
|
|
61
|
+
scope :ignored, -> { where(status: STATUS_IGNORED) }
|
|
62
|
+
scope :errors, -> { where(severity: SEVERITY_ERROR) }
|
|
63
|
+
scope :warnings, -> { where(severity: SEVERITY_WARNING) }
|
|
64
|
+
scope :recent, -> { order(last_seen_at: :desc) }
|
|
65
|
+
scope :by_occurrence, -> { order(occurrence_count: :desc) }
|
|
66
|
+
|
|
67
|
+
# Time-based scopes
|
|
68
|
+
scope :last_24_hours, -> { where("last_seen_at >= ?", 24.hours.ago) }
|
|
69
|
+
scope :last_7_days, -> { where("last_seen_at >= ?", 7.days.ago) }
|
|
70
|
+
scope :last_30_days, -> { where("last_seen_at >= ?", 30.days.ago) }
|
|
71
|
+
|
|
72
|
+
# Find or create an error event, incrementing count if exists
|
|
73
|
+
#
|
|
74
|
+
# @param event_data [Hash] the error event data from Redis
|
|
75
|
+
# @return [ErrorEvent] the created or updated error event
|
|
76
|
+
#
|
|
77
|
+
# UPSERT PATTERN
|
|
78
|
+
# ==============
|
|
79
|
+
#
|
|
80
|
+
# We use "upsert" logic: if an error with this fingerprint exists,
|
|
81
|
+
# we update it (increment count, update last_seen_at). Otherwise,
|
|
82
|
+
# we create a new record.
|
|
83
|
+
#
|
|
84
|
+
# This groups similar errors together instead of creating thousands
|
|
85
|
+
# of duplicate records.
|
|
86
|
+
#
|
|
87
|
+
def self.upsert_from_event(event_data)
|
|
88
|
+
fingerprint = event_data[:fingerprint]
|
|
89
|
+
|
|
90
|
+
# Use database-level locking to prevent race conditions
|
|
91
|
+
transaction do
|
|
92
|
+
existing = find_by(fingerprint: fingerprint)
|
|
93
|
+
|
|
94
|
+
if existing
|
|
95
|
+
# Update existing error
|
|
96
|
+
existing.occurrence_count += 1
|
|
97
|
+
existing.last_seen_at = Time.current
|
|
98
|
+
|
|
99
|
+
# Update context with latest (might have new info)
|
|
100
|
+
existing.context = merge_contexts(existing.context, event_data[:context])
|
|
101
|
+
|
|
102
|
+
# If it was resolved but happened again, reopen it
|
|
103
|
+
if existing.status == STATUS_RESOLVED
|
|
104
|
+
existing.status = STATUS_UNRESOLVED
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
existing.save!
|
|
108
|
+
existing
|
|
109
|
+
else
|
|
110
|
+
# Create new error
|
|
111
|
+
create!(
|
|
112
|
+
fingerprint: fingerprint,
|
|
113
|
+
exception_class: event_data[:exception_class],
|
|
114
|
+
message: event_data[:message],
|
|
115
|
+
backtrace: serialize_backtrace(event_data[:backtrace]),
|
|
116
|
+
context: event_data[:context] || {},
|
|
117
|
+
request_data: event_data[:context]&.dig(:request) || {},
|
|
118
|
+
environment: event_data[:environment],
|
|
119
|
+
release_version: event_data[:release],
|
|
120
|
+
severity: event_data[:severity] || SEVERITY_ERROR,
|
|
121
|
+
source: event_data[:source],
|
|
122
|
+
handled: event_data[:handled] || false,
|
|
123
|
+
occurrence_count: 1,
|
|
124
|
+
first_seen_at: Time.current,
|
|
125
|
+
last_seen_at: Time.current,
|
|
126
|
+
status: STATUS_UNRESOLVED
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Mark this error as resolved
|
|
133
|
+
def resolve!
|
|
134
|
+
update!(status: STATUS_RESOLVED)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Mark this error as ignored
|
|
138
|
+
def ignore!
|
|
139
|
+
update!(status: STATUS_IGNORED)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Reopen a resolved/ignored error
|
|
143
|
+
def reopen!
|
|
144
|
+
update!(status: STATUS_UNRESOLVED)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Get parsed backtrace as array
|
|
148
|
+
def backtrace_lines
|
|
149
|
+
return [] unless backtrace
|
|
150
|
+
|
|
151
|
+
backtrace.is_a?(Array) ? backtrace : JSON.parse(backtrace)
|
|
152
|
+
rescue JSON::ParserError
|
|
153
|
+
backtrace.to_s.split("\n")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Get user info from context
|
|
157
|
+
def user
|
|
158
|
+
context&.dig("user") || context&.dig(:user)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Get request info from context
|
|
162
|
+
def request
|
|
163
|
+
context&.dig("request") || context&.dig(:request)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Get breadcrumbs from context
|
|
167
|
+
def breadcrumbs
|
|
168
|
+
context&.dig("breadcrumbs") || context&.dig(:breadcrumbs) || []
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Get tags from context
|
|
172
|
+
def tags
|
|
173
|
+
context&.dig("tags") || context&.dig(:tags) || {}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Short summary for lists
|
|
177
|
+
def summary
|
|
178
|
+
"#{exception_class}: #{message&.truncate(100)}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def self.merge_contexts(old_context, new_context)
|
|
184
|
+
return new_context if old_context.blank?
|
|
185
|
+
return old_context if new_context.blank?
|
|
186
|
+
|
|
187
|
+
# Deep merge, preferring new values
|
|
188
|
+
old_context.deep_merge(new_context)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def self.serialize_backtrace(backtrace)
|
|
192
|
+
return nil unless backtrace
|
|
193
|
+
|
|
194
|
+
backtrace.is_a?(Array) ? backtrace.to_json : backtrace
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Findbug
|
|
4
|
+
# PerformanceEvent stores captured performance data in the database.
|
|
5
|
+
#
|
|
6
|
+
# DATABASE SCHEMA
|
|
7
|
+
# ===============
|
|
8
|
+
#
|
|
9
|
+
# create_table :findbug_performance_events do |t|
|
|
10
|
+
# t.string :transaction_name, null: false
|
|
11
|
+
# t.string :transaction_type, default: 'request'
|
|
12
|
+
# t.string :request_method
|
|
13
|
+
# t.string :request_path
|
|
14
|
+
# t.string :format
|
|
15
|
+
# t.integer :status
|
|
16
|
+
# t.float :duration_ms, null: false
|
|
17
|
+
# t.float :db_time_ms, default: 0
|
|
18
|
+
# t.float :view_time_ms, default: 0
|
|
19
|
+
# t.integer :query_count, default: 0
|
|
20
|
+
# t.jsonb :slow_queries, default: []
|
|
21
|
+
# t.jsonb :n_plus_one_queries, default: []
|
|
22
|
+
# t.boolean :has_n_plus_one, default: false
|
|
23
|
+
# t.integer :view_count, default: 0
|
|
24
|
+
# t.jsonb :context, default: {}
|
|
25
|
+
# t.string :environment
|
|
26
|
+
# t.string :release_version
|
|
27
|
+
# t.datetime :captured_at
|
|
28
|
+
# t.timestamps
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# AGGREGATION STRATEGY
|
|
32
|
+
# ====================
|
|
33
|
+
#
|
|
34
|
+
# Unlike errors (which we group by fingerprint), we store every
|
|
35
|
+
# performance event individually. This allows:
|
|
36
|
+
#
|
|
37
|
+
# - Percentile calculations (p50, p95, p99)
|
|
38
|
+
# - Trend analysis over time
|
|
39
|
+
# - Individual slow request investigation
|
|
40
|
+
#
|
|
41
|
+
# For dashboards, we aggregate on read using SQL GROUP BY.
|
|
42
|
+
#
|
|
43
|
+
class PerformanceEvent < ActiveRecord::Base
|
|
44
|
+
self.table_name = "findbug_performance_events"
|
|
45
|
+
|
|
46
|
+
# Transaction types
|
|
47
|
+
TYPE_REQUEST = "request"
|
|
48
|
+
TYPE_CUSTOM = "custom"
|
|
49
|
+
TYPE_JOB = "job"
|
|
50
|
+
|
|
51
|
+
# Validations
|
|
52
|
+
validates :transaction_name, presence: true
|
|
53
|
+
validates :duration_ms, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
54
|
+
|
|
55
|
+
# Scopes
|
|
56
|
+
scope :requests, -> { where(transaction_type: TYPE_REQUEST) }
|
|
57
|
+
scope :custom, -> { where(transaction_type: TYPE_CUSTOM) }
|
|
58
|
+
scope :jobs, -> { where(transaction_type: TYPE_JOB) }
|
|
59
|
+
scope :slow, -> { where("duration_ms >= ?", Findbug.config.slow_request_threshold_ms) }
|
|
60
|
+
scope :with_n_plus_one, -> { where(has_n_plus_one: true) }
|
|
61
|
+
scope :recent, -> { order(captured_at: :desc) }
|
|
62
|
+
|
|
63
|
+
# Time-based scopes
|
|
64
|
+
scope :last_hour, -> { where("captured_at >= ?", 1.hour.ago) }
|
|
65
|
+
scope :last_24_hours, -> { where("captured_at >= ?", 24.hours.ago) }
|
|
66
|
+
scope :last_7_days, -> { where("captured_at >= ?", 7.days.ago) }
|
|
67
|
+
|
|
68
|
+
# Create a performance event from Redis data
|
|
69
|
+
#
|
|
70
|
+
# @param event_data [Hash] the performance event data
|
|
71
|
+
# @return [PerformanceEvent] the created event
|
|
72
|
+
#
|
|
73
|
+
def self.create_from_event(event_data)
|
|
74
|
+
create!(
|
|
75
|
+
transaction_name: event_data[:transaction_name],
|
|
76
|
+
transaction_type: event_data[:transaction_type] || TYPE_REQUEST,
|
|
77
|
+
request_method: event_data[:request_method],
|
|
78
|
+
request_path: event_data[:request_path],
|
|
79
|
+
format: event_data[:format],
|
|
80
|
+
status: event_data[:status],
|
|
81
|
+
duration_ms: event_data[:duration_ms],
|
|
82
|
+
db_time_ms: event_data[:db_time_ms] || 0,
|
|
83
|
+
view_time_ms: event_data[:view_time_ms] || 0,
|
|
84
|
+
query_count: event_data[:query_count] || 0,
|
|
85
|
+
slow_queries: event_data[:slow_queries] || [],
|
|
86
|
+
n_plus_one_queries: event_data[:n_plus_one_queries] || [],
|
|
87
|
+
has_n_plus_one: event_data[:has_n_plus_one] || false,
|
|
88
|
+
view_count: event_data[:view_count] || 0,
|
|
89
|
+
context: event_data[:context] || {},
|
|
90
|
+
environment: event_data[:environment],
|
|
91
|
+
release_version: event_data[:release],
|
|
92
|
+
captured_at: parse_captured_at(event_data[:captured_at])
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Aggregate stats for a transaction
|
|
97
|
+
#
|
|
98
|
+
# @param transaction_name [String] the transaction to aggregate
|
|
99
|
+
# @param since [Time] start time for aggregation
|
|
100
|
+
# @return [Hash] aggregated statistics
|
|
101
|
+
#
|
|
102
|
+
def self.aggregate_for(transaction_name, since: 24.hours.ago)
|
|
103
|
+
events = where(transaction_name: transaction_name)
|
|
104
|
+
.where("captured_at >= ?", since)
|
|
105
|
+
|
|
106
|
+
return nil if events.empty?
|
|
107
|
+
|
|
108
|
+
durations = events.pluck(:duration_ms).sort
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
transaction_name: transaction_name,
|
|
112
|
+
count: events.count,
|
|
113
|
+
avg_duration_ms: durations.sum / durations.size.to_f,
|
|
114
|
+
min_duration_ms: durations.first,
|
|
115
|
+
max_duration_ms: durations.last,
|
|
116
|
+
p50_duration_ms: percentile(durations, 50),
|
|
117
|
+
p95_duration_ms: percentile(durations, 95),
|
|
118
|
+
p99_duration_ms: percentile(durations, 99),
|
|
119
|
+
avg_query_count: events.average(:query_count).to_f.round(1),
|
|
120
|
+
n_plus_one_count: events.where(has_n_plus_one: true).count
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Get slowest transactions
|
|
125
|
+
#
|
|
126
|
+
# @param since [Time] start time
|
|
127
|
+
# @param limit [Integer] max results
|
|
128
|
+
# @return [Array<Hash>] slowest transactions with stats
|
|
129
|
+
#
|
|
130
|
+
def self.slowest_transactions(since: 24.hours.ago, limit: 10)
|
|
131
|
+
where("captured_at >= ?", since)
|
|
132
|
+
.group(:transaction_name)
|
|
133
|
+
.select(
|
|
134
|
+
"transaction_name",
|
|
135
|
+
"AVG(duration_ms) as avg_duration",
|
|
136
|
+
"MAX(duration_ms) as max_duration",
|
|
137
|
+
"COUNT(*) as request_count"
|
|
138
|
+
)
|
|
139
|
+
.order("avg_duration DESC")
|
|
140
|
+
.limit(limit)
|
|
141
|
+
.map do |row|
|
|
142
|
+
{
|
|
143
|
+
transaction_name: row.transaction_name,
|
|
144
|
+
avg_duration_ms: row.avg_duration.round(2),
|
|
145
|
+
max_duration_ms: row.max_duration.round(2),
|
|
146
|
+
count: row.request_count
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Get transactions with most N+1 issues
|
|
152
|
+
#
|
|
153
|
+
# @param since [Time] start time
|
|
154
|
+
# @param limit [Integer] max results
|
|
155
|
+
# @return [Array<Hash>] transactions with N+1 stats
|
|
156
|
+
#
|
|
157
|
+
def self.n_plus_one_hotspots(since: 24.hours.ago, limit: 10)
|
|
158
|
+
with_n_plus_one
|
|
159
|
+
.where("captured_at >= ?", since)
|
|
160
|
+
.group(:transaction_name)
|
|
161
|
+
.select(
|
|
162
|
+
"transaction_name",
|
|
163
|
+
"COUNT(*) as occurrence_count",
|
|
164
|
+
"AVG(query_count) as avg_queries"
|
|
165
|
+
)
|
|
166
|
+
.order("occurrence_count DESC")
|
|
167
|
+
.limit(limit)
|
|
168
|
+
.map do |row|
|
|
169
|
+
{
|
|
170
|
+
transaction_name: row.transaction_name,
|
|
171
|
+
n_plus_one_count: row.occurrence_count,
|
|
172
|
+
avg_queries: row.avg_queries.round(1)
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Get throughput over time (requests per minute)
|
|
178
|
+
#
|
|
179
|
+
# @param since [Time] start time
|
|
180
|
+
# @param interval [String] grouping interval ('minute', 'hour', 'day')
|
|
181
|
+
# @return [Array<Hash>] time series data
|
|
182
|
+
#
|
|
183
|
+
def self.throughput_over_time(since: 24.hours.ago, interval: "hour")
|
|
184
|
+
# This uses database-specific date truncation
|
|
185
|
+
# Works with PostgreSQL; adjust for other databases
|
|
186
|
+
time_column = case interval
|
|
187
|
+
when "minute" then "date_trunc('minute', captured_at)"
|
|
188
|
+
when "hour" then "date_trunc('hour', captured_at)"
|
|
189
|
+
when "day" then "date_trunc('day', captured_at)"
|
|
190
|
+
else "date_trunc('hour', captured_at)"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
where("captured_at >= ?", since)
|
|
194
|
+
.group(Arel.sql(time_column))
|
|
195
|
+
.select(
|
|
196
|
+
Arel.sql("#{time_column} as time_bucket"),
|
|
197
|
+
"COUNT(*) as request_count",
|
|
198
|
+
"AVG(duration_ms) as avg_duration"
|
|
199
|
+
)
|
|
200
|
+
.order(Arel.sql(time_column))
|
|
201
|
+
.map do |row|
|
|
202
|
+
{
|
|
203
|
+
time: row.time_bucket,
|
|
204
|
+
count: row.request_count,
|
|
205
|
+
avg_duration_ms: row.avg_duration.round(2)
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def self.percentile(sorted_array, percentile)
|
|
213
|
+
return 0 if sorted_array.empty?
|
|
214
|
+
|
|
215
|
+
k = (percentile / 100.0) * (sorted_array.length - 1)
|
|
216
|
+
f = k.floor
|
|
217
|
+
c = k.ceil
|
|
218
|
+
|
|
219
|
+
if f == c
|
|
220
|
+
sorted_array[f]
|
|
221
|
+
else
|
|
222
|
+
sorted_array[f] + (k - f) * (sorted_array[c] - sorted_array[f])
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def self.parse_captured_at(value)
|
|
227
|
+
case value
|
|
228
|
+
when Time, DateTime
|
|
229
|
+
value
|
|
230
|
+
when String
|
|
231
|
+
Time.parse(value)
|
|
232
|
+
else
|
|
233
|
+
Time.current
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
<h1>Dashboard</h1>
|
|
2
|
+
<p class="page-description">Monitor errors and performance across your application.</p>
|
|
3
|
+
|
|
4
|
+
<%# Redis connection warning %>
|
|
5
|
+
<% if @stats[:buffer][:circuit_breaker_state] != :closed || @stats[:buffer][:error].present? %>
|
|
6
|
+
<div class="flash flash-error" style="margin-bottom: 1.5rem;">
|
|
7
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zM5.22 5.22a.75.75 0 0 1 1.06 0L8 6.94l1.72-1.72a.75.75 0 1 1 1.06 1.06L9.06 8l1.72 1.72a.75.75 0 1 1-1.06 1.06L8 9.06l-1.72 1.72a.75.75 0 0 1-1.06-1.06L6.94 8 5.22 6.28a.75.75 0 0 1 0-1.06z"/></svg>
|
|
8
|
+
<div>
|
|
9
|
+
<strong>Redis connection failed.</strong> Errors are not being captured.
|
|
10
|
+
<% if @stats[:buffer][:error].present? %>
|
|
11
|
+
<br><span class="text-muted text-xs"><%= @stats[:buffer][:error] %></span>
|
|
12
|
+
<% end %>
|
|
13
|
+
<br><span class="text-muted text-xs">Configured URL: <code style="background: hsl(var(--muted)); padding: 0.125rem 0.375rem; border-radius: 0.25rem;"><%= Findbug.config.redis_url %></code></span>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<%# Stats overview %>
|
|
19
|
+
<div class="stats-grid">
|
|
20
|
+
<div class="stat-card">
|
|
21
|
+
<div class="stat-label">Unresolved Errors</div>
|
|
22
|
+
<div class="stat-value <%= @stats[:errors][:unresolved] > 0 ? 'error' : '' %>">
|
|
23
|
+
<%= @stats[:errors][:unresolved] %>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="stat-change">Total: <%= @stats[:errors][:total] %></div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div class="stat-card">
|
|
29
|
+
<div class="stat-label">Errors (24h)</div>
|
|
30
|
+
<div class="stat-value"><%= @stats[:errors][:last_24h] %></div>
|
|
31
|
+
<div class="stat-change">Last 7d: <%= @stats[:errors][:last_7d] %></div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="stat-card">
|
|
35
|
+
<div class="stat-label">Avg Response Time</div>
|
|
36
|
+
<div class="stat-value"><%= @stats[:performance][:avg_duration] %><span class="text-muted text-sm">ms</span></div>
|
|
37
|
+
<div class="stat-change"><%= @stats[:performance][:last_24h] %> requests (24h)</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="stat-card">
|
|
41
|
+
<div class="stat-label">N+1 Issues (24h)</div>
|
|
42
|
+
<div class="stat-value <%= @stats[:performance][:n_plus_one_count] > 0 ? 'warning' : '' %>">
|
|
43
|
+
<%= @stats[:performance][:n_plus_one_count] %>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="stat-change">Performance issues detected</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div class="grid-2">
|
|
50
|
+
<%# Recent Errors %>
|
|
51
|
+
<div class="card">
|
|
52
|
+
<div class="card-header">
|
|
53
|
+
<div>
|
|
54
|
+
<h2 class="card-title">Recent Errors</h2>
|
|
55
|
+
<p class="card-description">Unresolved errors from your application</p>
|
|
56
|
+
</div>
|
|
57
|
+
<a href="<%= findbug.errors_path %>" class="btn btn-outline btn-sm">View All</a>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<% if @recent_errors.any? %>
|
|
61
|
+
<table class="table">
|
|
62
|
+
<thead>
|
|
63
|
+
<tr>
|
|
64
|
+
<th>Error</th>
|
|
65
|
+
<th>Count</th>
|
|
66
|
+
<th>Last Seen</th>
|
|
67
|
+
</tr>
|
|
68
|
+
</thead>
|
|
69
|
+
<tbody>
|
|
70
|
+
<% @recent_errors.each do |error| %>
|
|
71
|
+
<tr>
|
|
72
|
+
<td>
|
|
73
|
+
<a href="<%= findbug.error_path(error) %>">
|
|
74
|
+
<strong class="font-mono"><%= error.exception_class %></strong>
|
|
75
|
+
</a>
|
|
76
|
+
<br>
|
|
77
|
+
<span class="text-muted text-sm"><%= truncate(error.message, length: 60) %></span>
|
|
78
|
+
</td>
|
|
79
|
+
<td>
|
|
80
|
+
<span class="badge badge-<%= error.occurrence_count > 10 ? 'error' : 'muted' %>">
|
|
81
|
+
<%= error.occurrence_count %>
|
|
82
|
+
</span>
|
|
83
|
+
</td>
|
|
84
|
+
<td class="text-muted text-sm">
|
|
85
|
+
<%= time_ago_in_words(error.last_seen_at) %> ago
|
|
86
|
+
</td>
|
|
87
|
+
</tr>
|
|
88
|
+
<% end %>
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
<% else %>
|
|
92
|
+
<div class="empty-state">
|
|
93
|
+
<div class="empty-state-icon">
|
|
94
|
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
95
|
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
|
96
|
+
<polyline points="22 4 12 14.01 9 11.01"/>
|
|
97
|
+
</svg>
|
|
98
|
+
</div>
|
|
99
|
+
<p>No unresolved errors</p>
|
|
100
|
+
</div>
|
|
101
|
+
<% end %>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<%# Slowest Endpoints %>
|
|
105
|
+
<div class="card">
|
|
106
|
+
<div class="card-header">
|
|
107
|
+
<div>
|
|
108
|
+
<h2 class="card-title">Slowest Endpoints</h2>
|
|
109
|
+
<p class="card-description">Performance data from the last 24 hours</p>
|
|
110
|
+
</div>
|
|
111
|
+
<a href="<%= findbug.performance_index_path %>" class="btn btn-outline btn-sm">View All</a>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<% if @slowest_endpoints.any? %>
|
|
115
|
+
<table class="table">
|
|
116
|
+
<thead>
|
|
117
|
+
<tr>
|
|
118
|
+
<th>Endpoint</th>
|
|
119
|
+
<th>Avg</th>
|
|
120
|
+
<th>Max</th>
|
|
121
|
+
</tr>
|
|
122
|
+
</thead>
|
|
123
|
+
<tbody>
|
|
124
|
+
<% @slowest_endpoints.each do |endpoint| %>
|
|
125
|
+
<tr>
|
|
126
|
+
<td>
|
|
127
|
+
<a href="<%= findbug.performance_path(endpoint[:transaction_name]) %>" class="font-mono text-sm">
|
|
128
|
+
<%= truncate(endpoint[:transaction_name], length: 35) %>
|
|
129
|
+
</a>
|
|
130
|
+
</td>
|
|
131
|
+
<td>
|
|
132
|
+
<span class="badge badge-<%= endpoint[:avg_duration_ms] > 500 ? 'warning' : 'muted' %>">
|
|
133
|
+
<%= endpoint[:avg_duration_ms].round %>ms
|
|
134
|
+
</span>
|
|
135
|
+
</td>
|
|
136
|
+
<td class="text-muted text-sm">
|
|
137
|
+
<%= endpoint[:max_duration_ms].round %>ms
|
|
138
|
+
</td>
|
|
139
|
+
</tr>
|
|
140
|
+
<% end %>
|
|
141
|
+
</tbody>
|
|
142
|
+
</table>
|
|
143
|
+
<% else %>
|
|
144
|
+
<div class="empty-state">
|
|
145
|
+
<div class="empty-state-icon">
|
|
146
|
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
147
|
+
<line x1="12" y1="20" x2="12" y2="10"/>
|
|
148
|
+
<line x1="18" y1="20" x2="18" y2="4"/>
|
|
149
|
+
<line x1="6" y1="20" x2="6" y2="16"/>
|
|
150
|
+
</svg>
|
|
151
|
+
</div>
|
|
152
|
+
<p>No performance data yet</p>
|
|
153
|
+
</div>
|
|
154
|
+
<% end %>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<%# System Status %>
|
|
159
|
+
<div class="card">
|
|
160
|
+
<div class="card-header">
|
|
161
|
+
<div>
|
|
162
|
+
<h2 class="card-title">System Status</h2>
|
|
163
|
+
<p class="card-description">Buffer and system health information</p>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="card-content">
|
|
167
|
+
<div class="stats-grid">
|
|
168
|
+
<div>
|
|
169
|
+
<div class="stat-label">Error Buffer</div>
|
|
170
|
+
<div class="stat-value" style="font-size: 1.5rem;">
|
|
171
|
+
<%= @stats[:buffer][:error_queue_length] || 0 %>
|
|
172
|
+
</div>
|
|
173
|
+
<div class="stat-change">events pending</div>
|
|
174
|
+
</div>
|
|
175
|
+
<div>
|
|
176
|
+
<div class="stat-label">Perf Buffer</div>
|
|
177
|
+
<div class="stat-value" style="font-size: 1.5rem;">
|
|
178
|
+
<%= @stats[:buffer][:performance_queue_length] || 0 %>
|
|
179
|
+
</div>
|
|
180
|
+
<div class="stat-change">events pending</div>
|
|
181
|
+
</div>
|
|
182
|
+
<div>
|
|
183
|
+
<div class="stat-label">Circuit Breaker</div>
|
|
184
|
+
<div style="margin-top: 0.5rem;">
|
|
185
|
+
<span class="status-dot <%= @stats[:buffer][:circuit_breaker_state] == :closed ? 'success' : 'error' %>"></span>
|
|
186
|
+
<span class="text-sm"><%= @stats[:buffer][:circuit_breaker_state] %></span>
|
|
187
|
+
</div>
|
|
188
|
+
<div class="stat-change">Redis connection status</div>
|
|
189
|
+
</div>
|
|
190
|
+
<div>
|
|
191
|
+
<div class="stat-label">Total Events</div>
|
|
192
|
+
<div class="stat-value" style="font-size: 1.5rem;">
|
|
193
|
+
<%= @stats[:errors][:total] + @stats[:performance][:total] %>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="stat-change">all time</div>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|