rails_pulse 0.4.0.pre.4 → 0.4.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 +4 -4
- data/CHANGELOG.md +64 -87
- data/README.md +83 -111
- data/app/controllers/concerns/session_filters_concern.rb +4 -3
- data/app/controllers/concerns/time_range_concern.rb +29 -19
- data/app/controllers/rails_pulse/application_controller.rb +9 -5
- data/app/controllers/rails_pulse/dashboard_controller.rb +20 -11
- data/app/controllers/rails_pulse/routes_controller.rb +46 -0
- data/app/helpers/rails_pulse/application_helper.rb +4 -0
- data/app/javascript/rails_pulse/controllers/chart_controller.js +27 -0
- data/app/models/rails_pulse/cards/base.rb +61 -25
- data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +38 -15
- data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +36 -13
- data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +3 -0
- data/app/models/rails_pulse/dashboard/health_summary.rb +3 -1
- data/app/models/rails_pulse/dashboard/needs_attention.rb +3 -1
- data/app/models/rails_pulse/dashboard/storage_status.rb +38 -68
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +1 -5
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +4 -4
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -4
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +7 -20
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +5 -8
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +4 -4
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -9
- data/app/models/rails_pulse/queries/cards/database_load.rb +7 -10
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +4 -6
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +5 -8
- data/app/models/rails_pulse/route.rb +7 -1
- data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -11
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +3 -1
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +4 -6
- data/app/models/rails_pulse/summary.rb +13 -18
- data/app/models/rails_pulse/time_range_preference.rb +28 -0
- data/app/models/rails_pulse/time_window.rb +61 -0
- data/app/services/rails_pulse/summary_service.rb +112 -162
- data/app/services/rails_pulse/tag_filter_service.rb +10 -1
- data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/index.html.erb +6 -3
- data/app/views/rails_pulse/routes/_archived_summary_table.html.erb +46 -0
- data/app/views/rails_pulse/routes/_archived_table_pagination.html.erb +28 -0
- data/app/views/rails_pulse/routes/show.html.erb +14 -2
- data/lib/generators/rails_pulse/install_generator.rb +5 -2
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +7 -2
- data/lib/rails_pulse/cleanup_service.rb +7 -7
- data/lib/rails_pulse/configuration.rb +9 -1
- data/lib/rails_pulse/engine.rb +14 -42
- data/lib/rails_pulse/middleware/request_collector.rb +2 -17
- data/lib/rails_pulse/standalone.rb +12 -0
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +45 -3
- data/lib/rails_pulse/tracker.rb +241 -25
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +3 -16
- data/lib/rails_pulse_server.ru +37 -23
- data/public/rails-pulse-assets/rails-pulse.js +1 -1
- metadata +7 -9
- data/app/assets/images/rails_pulse/dashboard.png +0 -0
- data/app/assets/images/rails_pulse/query-show.png +0 -0
- data/app/assets/images/rails_pulse/request-show.png +0 -0
- data/app/assets/images/rails_pulse/request.png +0 -0
- data/app/assets/images/rails_pulse/route-show.png +0 -0
- data/lib/rails_pulse/extensions/active_record.rb +0 -124
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
|
|
2
1
|
module RailsPulse
|
|
2
|
+
# Aggregates one period (hour, day, week or month) of requests, operations,
|
|
3
|
+
# job runs and exception occurrences into rails_pulse_summaries rows.
|
|
4
|
+
#
|
|
5
|
+
# Rows are written with upsert_all against the summaries table's unique
|
|
6
|
+
# index, one statement per summarizable kind, so re-running a period (the
|
|
7
|
+
# backfill task, a retried SummaryJob) overwrites rather than duplicates and
|
|
8
|
+
# a period with hundreds of routes costs a handful of statements instead of
|
|
9
|
+
# two per route.
|
|
3
10
|
class SummaryService
|
|
11
|
+
UNIQUE_INDEX = :idx_pulse_summaries_unique
|
|
12
|
+
|
|
4
13
|
attr_reader :period_type, :start_time, :end_time
|
|
5
14
|
|
|
6
15
|
def initialize(period_type, start_time)
|
|
@@ -12,12 +21,13 @@ module RailsPulse
|
|
|
12
21
|
def perform
|
|
13
22
|
RailsPulse.logger.info "Starting #{period_type} summary for #{start_time}"
|
|
14
23
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
# The engine's own connection: on a separate-database install
|
|
25
|
+
# ActiveRecord::Base would open the transaction on the host's primary.
|
|
26
|
+
RailsPulse::ApplicationRecord.transaction do
|
|
27
|
+
upsert_summaries(request_summary_rows + route_summary_rows) # Overall and per-route
|
|
28
|
+
upsert_summaries(query_summary_rows) # Per-query
|
|
29
|
+
upsert_summaries(job_summary_rows) # Per-job
|
|
30
|
+
aggregate_exceptions # Per-exception-group frequency
|
|
21
31
|
end
|
|
22
32
|
|
|
23
33
|
RailsPulse.logger.info "Completed #{period_type} summary"
|
|
@@ -28,89 +38,125 @@ module RailsPulse
|
|
|
28
38
|
|
|
29
39
|
private
|
|
30
40
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return if
|
|
41
|
+
# Every row in one call must have the same keys, which is why the
|
|
42
|
+
# summarizable kinds are upserted separately: request and route rows carry
|
|
43
|
+
# status columns, query rows do not, exception rows carry only a count.
|
|
44
|
+
def upsert_summaries(rows)
|
|
45
|
+
return if rows.empty?
|
|
36
46
|
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
# MySQL resolves the conflict through any unique key and rejects an
|
|
48
|
+
# explicit target.
|
|
49
|
+
unique_by = Summary.connection.supports_insert_conflict_target? ? UNIQUE_INDEX : nil
|
|
50
|
+
Summary.upsert_all(rows, unique_by: unique_by)
|
|
51
|
+
end
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
summarizable_type:
|
|
45
|
-
summarizable_id:
|
|
53
|
+
def summary_row(summarizable_type, summarizable_id)
|
|
54
|
+
{
|
|
55
|
+
summarizable_type: summarizable_type,
|
|
56
|
+
summarizable_id: summarizable_id,
|
|
46
57
|
period_type: period_type,
|
|
47
|
-
period_start: start_time
|
|
48
|
-
|
|
58
|
+
period_start: start_time,
|
|
59
|
+
period_end: end_time
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# `durations` must be sorted. Empty input yields count 0 and nil
|
|
64
|
+
# percentiles, which is what an idle period should record.
|
|
65
|
+
def duration_metrics(durations)
|
|
66
|
+
avg = durations.any? ? durations.sum.to_f / durations.size : 0
|
|
49
67
|
|
|
50
|
-
|
|
51
|
-
period_end: end_time,
|
|
68
|
+
{
|
|
52
69
|
count: durations.size,
|
|
53
|
-
avg_duration:
|
|
54
|
-
min_duration: durations.
|
|
55
|
-
max_duration: durations.
|
|
70
|
+
avg_duration: avg,
|
|
71
|
+
min_duration: durations.first,
|
|
72
|
+
max_duration: durations.last,
|
|
56
73
|
total_duration: durations.sum,
|
|
57
74
|
p50_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.5),
|
|
58
75
|
p95_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.95),
|
|
59
76
|
p99_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.99),
|
|
60
|
-
stddev_duration: RailsPulse::Statistics.calculate_stddev(durations,
|
|
77
|
+
stddev_duration: RailsPulse::Statistics.calculate_stddev(durations, avg)
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def status_metrics(statuses)
|
|
82
|
+
{
|
|
61
83
|
error_count: statuses.count { |s| s >= 500 },
|
|
62
84
|
success_count: statuses.count { |s| s < 500 },
|
|
63
85
|
status_2xx: statuses.count { |s| s.between?(200, 299) },
|
|
64
86
|
status_3xx: statuses.count { |s| s.between?(300, 399) },
|
|
65
87
|
status_4xx: statuses.count { |s| s.between?(400, 499) },
|
|
66
88
|
status_5xx: statuses.count { |s| s >= 500 }
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
summary.save!
|
|
89
|
+
}
|
|
70
90
|
end
|
|
71
91
|
|
|
72
|
-
|
|
92
|
+
# One row for ALL requests in the period, written even when the period is
|
|
93
|
+
# empty (count: 0) so its timestamp keeps advancing every period — it is
|
|
94
|
+
# the heartbeat several health checks use to detect whether SummaryJob is
|
|
95
|
+
# still running (dashboard banner, rails_pulse:status, StoragePressure
|
|
96
|
+
# staleness, and CleanupService's summarized_cutoff). summarizable_id 0
|
|
97
|
+
# marks the overall rollup.
|
|
98
|
+
def request_summary_rows
|
|
99
|
+
request_data = Request.where(occurred_at: start_time...end_time).pluck(:duration, :status)
|
|
100
|
+
durations = request_data.map(&:first).compact.sort
|
|
101
|
+
statuses = request_data.map(&:second)
|
|
102
|
+
|
|
103
|
+
[ summary_row("RailsPulse::Request", 0).merge(duration_metrics(durations), status_metrics(statuses)) ]
|
|
104
|
+
end
|
|
73
105
|
|
|
74
|
-
def
|
|
106
|
+
def route_summary_rows
|
|
75
107
|
all_rows = Request
|
|
76
108
|
.where(occurred_at: start_time...end_time)
|
|
77
109
|
.where.not(route_id: nil)
|
|
78
110
|
.pluck(:route_id, :duration, :status)
|
|
79
111
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
all_rows.group_by(&:first).each do |route_id, rows|
|
|
112
|
+
all_rows.group_by(&:first).map do |route_id, rows|
|
|
83
113
|
durations = rows.map { |_, d, _| d }.compact.sort
|
|
84
114
|
statuses = rows.map { |_, _, s| s }
|
|
85
|
-
avg = durations.sum.to_f / durations.size
|
|
86
115
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
period_start: start_time
|
|
92
|
-
)
|
|
116
|
+
summary_row("RailsPulse::Route", route_id)
|
|
117
|
+
.merge(duration_metrics(durations), status_metrics(statuses), count: rows.size)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
93
120
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
)
|
|
121
|
+
def query_summary_rows
|
|
122
|
+
all_rows = Operation
|
|
123
|
+
.where(occurred_at: start_time...end_time)
|
|
124
|
+
.where.not(query_id: nil)
|
|
125
|
+
.pluck(:query_id, :duration)
|
|
126
|
+
|
|
127
|
+
all_rows.group_by(&:first).filter_map do |query_id, rows|
|
|
128
|
+
durations = rows.map(&:last).compact.sort
|
|
129
|
+
next if durations.empty?
|
|
130
|
+
|
|
131
|
+
summary_row("RailsPulse::Query", query_id).merge(duration_metrics(durations))
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def job_summary_rows
|
|
136
|
+
all_rows = JobRun
|
|
137
|
+
.where(occurred_at: start_time...end_time)
|
|
138
|
+
.where(status: JobRun::FINAL_STATUSES)
|
|
139
|
+
.where.not(job_id: nil)
|
|
140
|
+
.pluck(:job_id, :duration, :status)
|
|
141
|
+
return [] if all_rows.empty?
|
|
142
|
+
|
|
143
|
+
# A run whose job row has gone (count-based cleanup) has nothing to
|
|
144
|
+
# summarize against.
|
|
145
|
+
known_job_ids = Job.where(id: all_rows.map(&:first).uniq).pluck(:id).to_set
|
|
112
146
|
|
|
113
|
-
|
|
147
|
+
all_rows.group_by(&:first).filter_map do |job_id, runs|
|
|
148
|
+
next unless known_job_ids.include?(job_id)
|
|
149
|
+
|
|
150
|
+
durations = runs.map { |_, d, _| d }.compact.map(&:to_f).sort
|
|
151
|
+
next if durations.empty?
|
|
152
|
+
|
|
153
|
+
statuses = runs.map { |_, _, s| s }
|
|
154
|
+
summary_row("RailsPulse::Job", job_id).merge(
|
|
155
|
+
duration_metrics(durations),
|
|
156
|
+
count: runs.size,
|
|
157
|
+
error_count: statuses.count { |s| s != "success" },
|
|
158
|
+
success_count: statuses.count { |s| s == "success" }
|
|
159
|
+
)
|
|
114
160
|
end
|
|
115
161
|
end
|
|
116
162
|
|
|
@@ -132,113 +178,17 @@ module RailsPulse
|
|
|
132
178
|
|
|
133
179
|
return if counts.empty?
|
|
134
180
|
|
|
135
|
-
counts.
|
|
136
|
-
upsert_exception_summary(group_id, occurrences)
|
|
137
|
-
end
|
|
138
|
-
|
|
181
|
+
rows = counts.map { |group_id, occurrences| summary_row("RailsPulse::ExceptionGroup", group_id).merge(count: occurrences) }
|
|
139
182
|
# A rollup across every group, so the dashboard can chart total exception
|
|
140
183
|
# volume without loading one series per group.
|
|
141
|
-
|
|
184
|
+
rows << summary_row("RailsPulse::ExceptionGroup", 0).merge(count: counts.values.sum)
|
|
185
|
+
|
|
186
|
+
upsert_summaries(rows)
|
|
142
187
|
rescue ActiveRecord::ActiveRecordError => e
|
|
143
188
|
# Exceptions are the newest summarizable and the only optional one. A
|
|
144
189
|
# failure here must not lose the route, query and job summaries that were
|
|
145
190
|
# written in the same transaction.
|
|
146
191
|
RailsPulse.logger.error "Exception summary skipped: #{e.message}"
|
|
147
192
|
end
|
|
148
|
-
|
|
149
|
-
def upsert_exception_summary(group_id, occurrences)
|
|
150
|
-
summary = Summary.find_or_initialize_by(
|
|
151
|
-
summarizable_type: "RailsPulse::ExceptionGroup",
|
|
152
|
-
summarizable_id: group_id,
|
|
153
|
-
period_type: period_type,
|
|
154
|
-
period_start: start_time
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
summary.assign_attributes(period_end: end_time, count: occurrences)
|
|
158
|
-
summary.save!
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def aggregate_queries
|
|
162
|
-
all_rows = Operation
|
|
163
|
-
.where(occurred_at: start_time...end_time)
|
|
164
|
-
.where.not(query_id: nil)
|
|
165
|
-
.pluck(:query_id, :duration)
|
|
166
|
-
|
|
167
|
-
return if all_rows.empty?
|
|
168
|
-
|
|
169
|
-
all_rows.group_by(&:first).each do |query_id, rows|
|
|
170
|
-
durations = rows.map(&:last).compact.sort
|
|
171
|
-
next if durations.empty?
|
|
172
|
-
|
|
173
|
-
avg = durations.sum.to_f / durations.size
|
|
174
|
-
|
|
175
|
-
summary = Summary.find_or_initialize_by(
|
|
176
|
-
summarizable_type: "RailsPulse::Query",
|
|
177
|
-
summarizable_id: query_id,
|
|
178
|
-
period_type: period_type,
|
|
179
|
-
period_start: start_time
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
summary.assign_attributes(
|
|
183
|
-
period_end: end_time,
|
|
184
|
-
count: durations.size,
|
|
185
|
-
avg_duration: avg,
|
|
186
|
-
min_duration: durations.first,
|
|
187
|
-
max_duration: durations.last,
|
|
188
|
-
total_duration: durations.sum,
|
|
189
|
-
p50_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.5),
|
|
190
|
-
p95_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.95),
|
|
191
|
-
p99_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.99),
|
|
192
|
-
stddev_duration: RailsPulse::Statistics.calculate_stddev(durations, avg)
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
summary.save!
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def aggregate_jobs
|
|
200
|
-
job_runs = JobRun
|
|
201
|
-
.includes(:job)
|
|
202
|
-
.where(occurred_at: start_time...end_time)
|
|
203
|
-
.where(status: JobRun::FINAL_STATUSES)
|
|
204
|
-
|
|
205
|
-
return if job_runs.empty?
|
|
206
|
-
|
|
207
|
-
job_runs.group_by(&:job_id).each do |job_id, runs|
|
|
208
|
-
job = runs.first&.job
|
|
209
|
-
next unless job
|
|
210
|
-
|
|
211
|
-
duration_values = runs.map(&:duration).compact.map(&:to_f).sort
|
|
212
|
-
next if duration_values.empty?
|
|
213
|
-
|
|
214
|
-
duration_count = duration_values.size
|
|
215
|
-
total_duration = duration_values.sum
|
|
216
|
-
average_duration = total_duration / duration_count
|
|
217
|
-
|
|
218
|
-
summary = Summary.find_or_initialize_by(
|
|
219
|
-
summarizable_type: "RailsPulse::Job",
|
|
220
|
-
summarizable_id: job.id,
|
|
221
|
-
period_type: period_type,
|
|
222
|
-
period_start: start_time
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
summary.assign_attributes(
|
|
226
|
-
period_end: end_time,
|
|
227
|
-
count: runs.size,
|
|
228
|
-
avg_duration: average_duration,
|
|
229
|
-
min_duration: duration_values.first,
|
|
230
|
-
max_duration: duration_values.last,
|
|
231
|
-
total_duration: total_duration,
|
|
232
|
-
p50_duration: RailsPulse::Statistics.calculate_percentile(duration_values, 0.5),
|
|
233
|
-
p95_duration: RailsPulse::Statistics.calculate_percentile(duration_values, 0.95),
|
|
234
|
-
p99_duration: RailsPulse::Statistics.calculate_percentile(duration_values, 0.99),
|
|
235
|
-
stddev_duration: RailsPulse::Statistics.calculate_stddev(duration_values, average_duration),
|
|
236
|
-
error_count: runs.count(&:failure_like_status?),
|
|
237
|
-
success_count: runs.count { |run| run.status == "success" }
|
|
238
|
-
)
|
|
239
|
-
|
|
240
|
-
summary.save!
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
193
|
end
|
|
244
194
|
end
|
|
@@ -6,6 +6,15 @@ module RailsPulse
|
|
|
6
6
|
# @param show_non_tagged [Boolean] Whether to include non-tagged items
|
|
7
7
|
# @return [Array<Integer>] Filtered IDs
|
|
8
8
|
def self.filter_ids(model_class, disabled_tags, show_non_tagged)
|
|
9
|
+
scope(model_class, disabled_tags, show_non_tagged).pluck(:id)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# The same filter as a relation, for use as a subquery. Summary
|
|
13
|
+
# .with_tag_filters embeds it in an IN (SELECT id ...) so the database
|
|
14
|
+
# applies the tag filter itself instead of the app plucking every route,
|
|
15
|
+
# query and job id and sending them back inline on each card's query.
|
|
16
|
+
# @return [ActiveRecord::Relation]
|
|
17
|
+
def self.scope(model_class, disabled_tags, show_non_tagged)
|
|
9
18
|
relation = model_class.all
|
|
10
19
|
|
|
11
20
|
# Exclude items with disabled tags. Explicit ESCAPE: SQLite has no
|
|
@@ -21,7 +30,7 @@ module RailsPulse
|
|
|
21
30
|
relation = relation.where("tags IS NOT NULL AND tags != '[]'")
|
|
22
31
|
end
|
|
23
32
|
|
|
24
|
-
relation
|
|
33
|
+
relation
|
|
25
34
|
end
|
|
26
35
|
|
|
27
36
|
# Main entry point for tag filtering
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<% preference = session_time_range_preference
|
|
2
2
|
|
|
3
|
-
if
|
|
3
|
+
if RailsPulse::TimeRangePreference.custom?(preference)
|
|
4
4
|
current_selection = :custom
|
|
5
5
|
current_label = "#{preference["start_time"]} to #{preference["end_time"]}"
|
|
6
|
-
elsif preference.present?
|
|
6
|
+
elsif preference.is_a?(String) && preference.present?
|
|
7
7
|
current_selection = preference.to_sym
|
|
8
8
|
current_label =
|
|
9
9
|
case current_selection
|
|
@@ -4,16 +4,19 @@
|
|
|
4
4
|
<%= render "rails_pulse/dashboard/health_badge",
|
|
5
5
|
name: "Routes",
|
|
6
6
|
health: @health_summary[:routes],
|
|
7
|
-
critical_label: "critical"
|
|
7
|
+
critical_label: "critical",
|
|
8
|
+
link_url: routes_path %>
|
|
8
9
|
<%= render "rails_pulse/dashboard/health_badge",
|
|
9
10
|
name: "Queries",
|
|
10
11
|
health: @health_summary[:queries],
|
|
11
|
-
critical_label: "critical"
|
|
12
|
+
critical_label: "critical",
|
|
13
|
+
link_url: queries_path %>
|
|
12
14
|
<% if @health_summary[:jobs] %>
|
|
13
15
|
<%= render "rails_pulse/dashboard/health_badge",
|
|
14
16
|
name: "Jobs",
|
|
15
17
|
health: @health_summary[:jobs],
|
|
16
|
-
critical_label: "failing"
|
|
18
|
+
critical_label: "failing",
|
|
19
|
+
link_url: jobs_path %>
|
|
17
20
|
<% end %>
|
|
18
21
|
<%# Nil when exception tracking is off, or when the tables have not been
|
|
19
22
|
migrated yet — which is also when exceptions_path does not exist. %>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<% columns = [
|
|
2
|
+
{ field: :period_start, label: 'Time Period', class: 'w-auto', sortable: false },
|
|
3
|
+
{ field: :count, label: 'Requests', class: 'w-32', sortable: false },
|
|
4
|
+
{ field: :avg_duration, label: 'Avg Duration', class: 'w-32', sortable: false },
|
|
5
|
+
{ field: :min_duration, label: 'Min Duration', class: 'w-32', sortable: false },
|
|
6
|
+
{ field: :max_duration, label: 'Max Duration', class: 'w-32', sortable: false }
|
|
7
|
+
] %>
|
|
8
|
+
|
|
9
|
+
<table class="table mbs-4">
|
|
10
|
+
<%= render "rails_pulse/components/table_head", columns: columns %>
|
|
11
|
+
|
|
12
|
+
<tbody>
|
|
13
|
+
<% @archived_summary_data.each do |summary| %>
|
|
14
|
+
<%
|
|
15
|
+
avg_duration_ms = summary.avg_duration&.round(2) || 0
|
|
16
|
+
performance_class = case avg_duration_ms
|
|
17
|
+
when 0..100 then "text-green-600"
|
|
18
|
+
when 100..300 then "text-yellow-600"
|
|
19
|
+
when 300..1000 then "text-orange-600"
|
|
20
|
+
else "text-red-600"
|
|
21
|
+
end
|
|
22
|
+
%>
|
|
23
|
+
<tr>
|
|
24
|
+
<td class="whitespace-nowrap">
|
|
25
|
+
<%= human_readable_summary_period(summary) %>
|
|
26
|
+
</td>
|
|
27
|
+
<td class="whitespace-nowrap text-center">
|
|
28
|
+
<span class="font-medium"><%= summary.count %></span>
|
|
29
|
+
</td>
|
|
30
|
+
<td class="whitespace-nowrap">
|
|
31
|
+
<span class="<%= performance_class %> font-medium">
|
|
32
|
+
<%= avg_duration_ms %> ms
|
|
33
|
+
</span>
|
|
34
|
+
</td>
|
|
35
|
+
<td class="whitespace-nowrap text-center">
|
|
36
|
+
<%= summary.min_duration&.round(2) || 0 %> ms
|
|
37
|
+
</td>
|
|
38
|
+
<td class="whitespace-nowrap text-center">
|
|
39
|
+
<%= summary.max_duration&.round(2) || 0 %> ms
|
|
40
|
+
</td>
|
|
41
|
+
</tr>
|
|
42
|
+
<% end %>
|
|
43
|
+
</tbody>
|
|
44
|
+
</table>
|
|
45
|
+
|
|
46
|
+
<%= render "rails_pulse/routes/archived_table_pagination" %>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<div class="flex items-center mbs-4">
|
|
2
|
+
<div class="text-sm text-subtle show@md">Total of <%= @archived_pagination.count %> archived period(s).</div>
|
|
3
|
+
|
|
4
|
+
<div class="flex items-center mis-auto justify-end" style="column-gap: 1rem">
|
|
5
|
+
<div class="text-sm font-medium"><%= "Page #{@archived_pagination.page} of #{@archived_pagination.last}" %></div>
|
|
6
|
+
|
|
7
|
+
<nav class="flex items-center gap shrink-0" style="--btn-padding: .5rem;" aria-label="Archived data pagination">
|
|
8
|
+
<% prev_page = @archived_pagination.previous %>
|
|
9
|
+
<% next_page = @archived_pagination.next %>
|
|
10
|
+
<%= link_to archived_page_url(1), class: "btn", aria: { disabled: prev_page.nil? }.compact_blank do %>
|
|
11
|
+
<%= rails_pulse_icon 'chevrons-left', width: '16', height: '16' %>
|
|
12
|
+
<span class="sr-only">Go to first page</span>
|
|
13
|
+
<% end %>
|
|
14
|
+
<%= link_to archived_page_url(prev_page || @archived_pagination.page), class: "btn", aria: { disabled: prev_page.nil? }.compact_blank do %>
|
|
15
|
+
<%= rails_pulse_icon 'chevron-left', width: '16', height: '16' %>
|
|
16
|
+
<span class="sr-only">Go to previous page</span>
|
|
17
|
+
<% end %>
|
|
18
|
+
<%= link_to archived_page_url(next_page || @archived_pagination.page), class: "btn", aria: { disabled: next_page.nil? }.compact_blank do %>
|
|
19
|
+
<%= rails_pulse_icon 'chevron-right', width: '16', height: '16' %>
|
|
20
|
+
<span class="sr-only">Go to next page</span>
|
|
21
|
+
<% end %>
|
|
22
|
+
<%= link_to archived_page_url(@archived_pagination.last), class: "btn", aria: { disabled: next_page.nil? }.compact_blank do %>
|
|
23
|
+
<%= rails_pulse_icon 'chevrons-right', width: '16', height: '16' %>
|
|
24
|
+
<span class="sr-only">Go to last page</span>
|
|
25
|
+
<% end %>
|
|
26
|
+
</nav>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
@@ -45,9 +45,21 @@
|
|
|
45
45
|
|
|
46
46
|
<div data-controller="rails-pulse--table-sort">
|
|
47
47
|
<div id="index_table" data-rails-pulse--index-target="indexTable" data-rails-pulse--table-sort-target="tableFrame">
|
|
48
|
-
<%
|
|
48
|
+
<% has_requests = @table_data && @table_data.any? %>
|
|
49
|
+
<% has_archived_summaries = @archived_summary_data && @archived_summary_data.any? %>
|
|
50
|
+
|
|
51
|
+
<% if has_requests %>
|
|
49
52
|
<%= render 'rails_pulse/routes/requests_table' %>
|
|
50
|
-
<%
|
|
53
|
+
<% end %>
|
|
54
|
+
|
|
55
|
+
<% if has_archived_summaries %>
|
|
56
|
+
<h3 id="archived-data" class="text-sm font-semibold text-subtle mbs-8 mbe-2">
|
|
57
|
+
Archived data (older than the retention window — aggregated by period)
|
|
58
|
+
</h3>
|
|
59
|
+
<%= render 'rails_pulse/routes/archived_summary_table' %>
|
|
60
|
+
<% end %>
|
|
61
|
+
|
|
62
|
+
<% unless has_requests || has_archived_summaries %>
|
|
51
63
|
<%= render 'rails_pulse/components/empty_state',
|
|
52
64
|
title: 'No route requests found for the selected filters.',
|
|
53
65
|
description: 'Try adjusting your time range or filters to see results.' %>
|
|
@@ -86,8 +86,11 @@ module RailsPulse
|
|
|
86
86
|
db/rails_pulse_structure.sql. Keep database_tasks enabled so
|
|
87
87
|
rails db:migrate:rails_pulse still runs upgrades.
|
|
88
88
|
|
|
89
|
-
2.
|
|
90
|
-
|
|
89
|
+
2. Uncomment config.connects_to in config/initializers/rails_pulse.rb
|
|
90
|
+
so Rails Pulse models use the rails_pulse connection. Without it
|
|
91
|
+
every model keeps writing to your primary database.
|
|
92
|
+
3. Run: rails db:prepare (creates database and loads schema)
|
|
93
|
+
4. Restart your Rails server
|
|
91
94
|
|
|
92
95
|
The schema file db/rails_pulse_schema.rb is your single source of truth.
|
|
93
96
|
Future upgrades will automatically copy new migrations to db/rails_pulse_migrate/
|
|
@@ -314,7 +314,7 @@ RailsPulse.configure do |config|
|
|
|
314
314
|
# end
|
|
315
315
|
# }
|
|
316
316
|
|
|
317
|
-
# STANDALONE DASHBOARD (
|
|
317
|
+
# STANDALONE DASHBOARD (bundle exec rails_pulse_server; see railspulse.com/documentation/deployment-modes):
|
|
318
318
|
# that process has your models but not your app's session, Warden or Devise
|
|
319
319
|
# helpers, and runs on its own hostname, so the hooks above are ignored there.
|
|
320
320
|
# By default it uses HTTP Basic auth (RAILS_PULSE_USERNAME / RAILS_PULSE_PASSWORD).
|
|
@@ -430,10 +430,15 @@ RailsPulse.configure do |config|
|
|
|
430
430
|
# Use a custom logger (default: Rails.logger)
|
|
431
431
|
# config.logger = Logger.new("log/rails_pulse.log")
|
|
432
432
|
|
|
433
|
-
# Perform tracking writes
|
|
433
|
+
# Perform tracking writes on a background thread (default: true).
|
|
434
434
|
# When false, writes happen inline before the response is sent.
|
|
435
435
|
# config.async = true
|
|
436
436
|
|
|
437
|
+
# How many requests the background writer may hold before it starts dropping
|
|
438
|
+
# the newest ones instead of slowing the app down (default: 1000). Drops are
|
|
439
|
+
# counted and logged at most once a minute.
|
|
440
|
+
# config.async_queue_size = 1000
|
|
441
|
+
|
|
437
442
|
# Show a dashboard banner when summary data is stale (default: true)
|
|
438
443
|
# config.warn_on_stale_summaries = true
|
|
439
444
|
|
|
@@ -125,14 +125,14 @@ module RailsPulse
|
|
|
125
125
|
def cleanup_queries_by_time(cutoff_time)
|
|
126
126
|
RailsPulse::Query
|
|
127
127
|
.where("created_at < ?", cutoff_time)
|
|
128
|
-
.where("
|
|
128
|
+
.where("NOT EXISTS (SELECT 1 FROM rails_pulse_operations WHERE rails_pulse_operations.query_id = rails_pulse_queries.id)")
|
|
129
129
|
.delete_all
|
|
130
130
|
end
|
|
131
131
|
|
|
132
132
|
def cleanup_routes_by_time(cutoff_time)
|
|
133
133
|
RailsPulse::Route
|
|
134
134
|
.where("created_at < ?", cutoff_time)
|
|
135
|
-
.where("
|
|
135
|
+
.where("NOT EXISTS (SELECT 1 FROM rails_pulse_requests WHERE rails_pulse_requests.route_id = rails_pulse_routes.id)")
|
|
136
136
|
.delete_all
|
|
137
137
|
end
|
|
138
138
|
|
|
@@ -145,7 +145,7 @@ module RailsPulse
|
|
|
145
145
|
def cleanup_jobs_by_time(cutoff_time)
|
|
146
146
|
RailsPulse::Job
|
|
147
147
|
.where("created_at < ?", cutoff_time)
|
|
148
|
-
.where("
|
|
148
|
+
.where("NOT EXISTS (SELECT 1 FROM rails_pulse_job_runs WHERE rails_pulse_job_runs.job_id = rails_pulse_jobs.id)")
|
|
149
149
|
.delete_all
|
|
150
150
|
end
|
|
151
151
|
|
|
@@ -193,21 +193,21 @@ module RailsPulse
|
|
|
193
193
|
|
|
194
194
|
def cleanup_queries_by_count
|
|
195
195
|
scope = RailsPulse::Query.where(
|
|
196
|
-
"
|
|
196
|
+
"NOT EXISTS (SELECT 1 FROM rails_pulse_operations WHERE rails_pulse_operations.query_id = rails_pulse_queries.id)"
|
|
197
197
|
)
|
|
198
198
|
cleanup_by_count(RailsPulse::Query, :rails_pulse_queries, order_column: :created_at, scope: scope)
|
|
199
199
|
end
|
|
200
200
|
|
|
201
201
|
def cleanup_routes_by_count
|
|
202
202
|
scope = RailsPulse::Route.where(
|
|
203
|
-
"
|
|
203
|
+
"NOT EXISTS (SELECT 1 FROM rails_pulse_requests WHERE rails_pulse_requests.route_id = rails_pulse_routes.id)"
|
|
204
204
|
)
|
|
205
205
|
cleanup_by_count(RailsPulse::Route, :rails_pulse_routes, order_column: :created_at, scope: scope)
|
|
206
206
|
end
|
|
207
207
|
|
|
208
208
|
def cleanup_jobs_by_count
|
|
209
209
|
scope = RailsPulse::Job.where(
|
|
210
|
-
"
|
|
210
|
+
"NOT EXISTS (SELECT 1 FROM rails_pulse_job_runs WHERE rails_pulse_job_runs.job_id = rails_pulse_jobs.id)"
|
|
211
211
|
)
|
|
212
212
|
cleanup_by_count(RailsPulse::Job, :rails_pulse_jobs, order_column: :created_at, scope: scope)
|
|
213
213
|
end
|
|
@@ -276,7 +276,7 @@ module RailsPulse
|
|
|
276
276
|
RailsPulse::ExceptionGroup
|
|
277
277
|
.where(preserve: false)
|
|
278
278
|
.where.not(status: "ignored")
|
|
279
|
-
.where("
|
|
279
|
+
.where("NOT EXISTS (SELECT 1 FROM rails_pulse_exception_occurrences WHERE rails_pulse_exception_occurrences.exception_group_id = rails_pulse_exception_groups.id)")
|
|
280
280
|
.delete_all
|
|
281
281
|
end
|
|
282
282
|
|
|
@@ -30,6 +30,7 @@ module RailsPulse
|
|
|
30
30
|
:mount_dashboard,
|
|
31
31
|
:logger,
|
|
32
32
|
:async,
|
|
33
|
+
:async_queue_size,
|
|
33
34
|
:service_level_objectives,
|
|
34
35
|
:query_service_level_objectives,
|
|
35
36
|
:warn_on_stale_summaries,
|
|
@@ -126,8 +127,11 @@ module RailsPulse
|
|
|
126
127
|
@mount_dashboard = true
|
|
127
128
|
@logger = nil
|
|
128
129
|
|
|
129
|
-
# Tracking mode settings
|
|
130
|
+
# Tracking mode settings. With async, one writer thread per process
|
|
131
|
+
# drains a queue of this many pending requests; when it is full the
|
|
132
|
+
# newest request is dropped rather than blocking the host.
|
|
130
133
|
@async = true
|
|
134
|
+
@async_queue_size = 1_000
|
|
131
135
|
|
|
132
136
|
# Service Level Objectives (default: [] = no SLOs configured)
|
|
133
137
|
@service_level_objectives = []
|
|
@@ -397,6 +401,10 @@ module RailsPulse
|
|
|
397
401
|
unless [ true, false ].include?(@async)
|
|
398
402
|
raise ArgumentError, "async must be true or false, got #{@async}"
|
|
399
403
|
end
|
|
404
|
+
|
|
405
|
+
unless @async_queue_size.is_a?(Integer) && @async_queue_size.positive?
|
|
406
|
+
raise ArgumentError, "async_queue_size must be a positive integer, got #{@async_queue_size.inspect}"
|
|
407
|
+
end
|
|
400
408
|
end
|
|
401
409
|
|
|
402
410
|
def validate_exception_settings!
|