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
|
@@ -8,8 +8,11 @@ module RailsPulse
|
|
|
8
8
|
@start_time, @end_time, @selected_time_range, @time_diff = setup_time_range
|
|
9
9
|
populate_deployment_markers
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
|
|
11
|
+
# Rounded rather than truncated, so a range a few seconds short of N
|
|
12
|
+
# whole days still counts as N. Cards/charts also get @start_time/
|
|
13
|
+
# @end_time directly so they bucket the exact range, not "the last
|
|
14
|
+
# @period days ending now".
|
|
15
|
+
@period = RailsPulse::TimeWindow.new(@start_time, @end_time).days
|
|
13
16
|
|
|
14
17
|
# Determine period type based on time range
|
|
15
18
|
# If 24 hours or less, use hourly summaries, otherwise use daily
|
|
@@ -19,22 +22,28 @@ module RailsPulse
|
|
|
19
22
|
disabled_tags = session_disabled_tags
|
|
20
23
|
show_non_tagged = session[:show_non_tagged] != false
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
card_and_chart_options = {
|
|
26
|
+
disabled_tags: disabled_tags, show_non_tagged: show_non_tagged,
|
|
27
|
+
period: @period, period_type: @period_type,
|
|
28
|
+
start_time: @start_time, end_time: @end_time
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil, **card_and_chart_options).to_metric_card
|
|
32
|
+
@request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil, **card_and_chart_options).to_metric_card
|
|
33
|
+
@error_rates_metric_card = RailsPulse::Routes::Cards::ErrorRates.new(route: nil, **card_and_chart_options).to_metric_card
|
|
34
|
+
@job_failure_rate_metric_card = RailsPulse::Jobs::Cards::FailureRate.new(**card_and_chart_options).to_metric_card if RailsPulse.configuration.track_jobs
|
|
26
35
|
|
|
27
36
|
# Generate chart data for inline rendering
|
|
28
|
-
@response_time_percentiles_chart_data = RailsPulse::Dashboard::Charts::ResponseTimePercentiles.new(
|
|
29
|
-
@throughput_and_errors_chart_data = RailsPulse::Dashboard::Charts::ThroughputAndErrors.new(
|
|
37
|
+
@response_time_percentiles_chart_data = RailsPulse::Dashboard::Charts::ResponseTimePercentiles.new(**card_and_chart_options).to_chart_data
|
|
38
|
+
@throughput_and_errors_chart_data = RailsPulse::Dashboard::Charts::ThroughputAndErrors.new(**card_and_chart_options).to_chart_data
|
|
30
39
|
|
|
31
40
|
# Needs Attention panel
|
|
32
|
-
@needs_attention = RailsPulse::Dashboard::NeedsAttention.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period).to_attention_data
|
|
41
|
+
@needs_attention = RailsPulse::Dashboard::NeedsAttention.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, start_time: @start_time, end_time: @end_time).to_attention_data
|
|
33
42
|
|
|
34
43
|
# System Health bar
|
|
35
|
-
@health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period).to_health_data
|
|
44
|
+
@health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, start_time: @start_time, end_time: @end_time).to_health_data
|
|
36
45
|
|
|
37
|
-
@storage_status = RailsPulse::Dashboard::StorageStatus.new
|
|
46
|
+
@storage_status = RailsPulse::Dashboard::StorageStatus.new(cached_sizes: true)
|
|
38
47
|
|
|
39
48
|
# Deployments panel — scoped to the same window as the chart markers so
|
|
40
49
|
# the panel and the markers drawn on the charts always agree.
|
|
@@ -14,6 +14,7 @@ module RailsPulse
|
|
|
14
14
|
def show
|
|
15
15
|
setup_metric_cards
|
|
16
16
|
setup_chart_and_table_data
|
|
17
|
+
setup_archived_summary_data
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
private
|
|
@@ -126,6 +127,51 @@ module RailsPulse
|
|
|
126
127
|
@route = Route.find(params[:id])
|
|
127
128
|
end
|
|
128
129
|
|
|
130
|
+
ARCHIVED_SUMMARY_PAGE_LIMIT = 20
|
|
131
|
+
|
|
132
|
+
# Raw Request rows get purged by CleanupService after full_retention_period.
|
|
133
|
+
# For any part of the selected window older than that cutoff, fall back to
|
|
134
|
+
# pre-aggregated Summary rows (which CleanupService never deletes for
|
|
135
|
+
# day/week/month periods) so the table isn't just blank for old ranges.
|
|
136
|
+
def setup_archived_summary_data
|
|
137
|
+
cutoff = retention_cutoff
|
|
138
|
+
window_start = @page_timings&.table_start_time
|
|
139
|
+
|
|
140
|
+
scope = if cutoff && window_start && Time.at(window_start) < cutoff
|
|
141
|
+
scope = Summary.for_routes
|
|
142
|
+
.where(summarizable_id: @route.id)
|
|
143
|
+
.where(period_type: period_type)
|
|
144
|
+
.where("period_start < ?", cutoff)
|
|
145
|
+
|
|
146
|
+
if @page_timings&.table_end_time
|
|
147
|
+
scope = scope.where("period_start < ?", Time.at(@page_timings.table_end_time))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
scope.order(period_start: :desc)
|
|
151
|
+
else
|
|
152
|
+
Summary.none
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
@archived_pagination, @archived_summary_data =
|
|
156
|
+
paginate_archived(scope, limit: ARCHIVED_SUMMARY_PAGE_LIMIT)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Mirrors PaginationConcern#paginate but keys off its own `archived_page`
|
|
160
|
+
# param so the archived table's pagination doesn't fight over the same
|
|
161
|
+
# `page`/`limit` params as the live requests table above it on the page.
|
|
162
|
+
def paginate_archived(collection, limit:)
|
|
163
|
+
page = [ params[:archived_page].to_i, 1 ].max
|
|
164
|
+
paginator = RailsPulse::Paginator.new(count: collection.count(:all), page: page, limit: limit)
|
|
165
|
+
records = collection.offset((paginator.page - 1) * limit).limit(limit)
|
|
166
|
+
[ paginator, records ]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def retention_cutoff
|
|
170
|
+
config = RailsPulse.configuration rescue nil
|
|
171
|
+
period = config&.full_retention_period
|
|
172
|
+
period ? period.ago : nil
|
|
173
|
+
end
|
|
174
|
+
|
|
129
175
|
def ordering_by_computed_column?
|
|
130
176
|
# Check if we're ordering by status_indicator (computed column)
|
|
131
177
|
@ransack_query.sorts.any? { |sort| sort.name == "status_indicator" }
|
|
@@ -31,5 +31,9 @@ module RailsPulse
|
|
|
31
31
|
def page_url(page_number)
|
|
32
32
|
url_for(request.query_parameters.merge(page: page_number))
|
|
33
33
|
end
|
|
34
|
+
|
|
35
|
+
def archived_page_url(page_number)
|
|
36
|
+
url_for(request.query_parameters.merge(archived_page: page_number, anchor: "archived-data"))
|
|
37
|
+
end
|
|
34
38
|
end
|
|
35
39
|
end
|
|
@@ -160,6 +160,12 @@ export default class extends Controller {
|
|
|
160
160
|
config.xAxis = config.xAxis || {}
|
|
161
161
|
if (isTimePairs) {
|
|
162
162
|
config.xAxis.type = 'time'
|
|
163
|
+
// Without this, ECharts spaces ticks by pixel width, not by the data's
|
|
164
|
+
// actual bucket size — a multi-day "time" axis can end up with more
|
|
165
|
+
// ticks than days, and since the label formatter only shows the date,
|
|
166
|
+
// adjacent ticks on the same day render as duplicate-looking labels.
|
|
167
|
+
const bucketMs = this._minTimestampGapMs(data.series)
|
|
168
|
+
if (bucketMs) config.xAxis.minInterval = bucketMs
|
|
163
169
|
} else {
|
|
164
170
|
config.xAxis.type = 'category'
|
|
165
171
|
config.xAxis.data = data.labels
|
|
@@ -240,6 +246,27 @@ export default class extends Controller {
|
|
|
240
246
|
return Array.isArray(firstPoint) || Array.isArray(firstPoint?.value)
|
|
241
247
|
}
|
|
242
248
|
|
|
249
|
+
// Smallest gap (ms) between any two distinct timestamps across all series,
|
|
250
|
+
// used as xAxis.minInterval so auto-placed ticks never fall closer together
|
|
251
|
+
// than the data actually does.
|
|
252
|
+
_minTimestampGapMs(series) {
|
|
253
|
+
const timestamps = new Set()
|
|
254
|
+
series.forEach(s => {
|
|
255
|
+
(s.data || []).forEach(point => {
|
|
256
|
+
const pair = Array.isArray(point) ? point : (Array.isArray(point?.value) ? point.value : null)
|
|
257
|
+
if (pair && typeof pair[0] === 'number') timestamps.add(pair[0])
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const sorted = Array.from(timestamps).sort((a, b) => a - b)
|
|
262
|
+
let minGap = null
|
|
263
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
264
|
+
const gap = sorted[i] - sorted[i - 1]
|
|
265
|
+
if (gap > 0 && (minGap === null || gap < minGap)) minGap = gap
|
|
266
|
+
}
|
|
267
|
+
return minGap
|
|
268
|
+
}
|
|
269
|
+
|
|
243
270
|
deploymentMarkerSeriesId = 'rails-pulse-deployment-markers'
|
|
244
271
|
|
|
245
272
|
_buildDeploymentMarkerSeries(markers, visible = true) {
|
|
@@ -5,12 +5,20 @@ module RailsPulse
|
|
|
5
5
|
class Base
|
|
6
6
|
private
|
|
7
7
|
|
|
8
|
+
# nil unless the caller passed both start_time and end_time, so the
|
|
9
|
+
# "trailing @period days/hours" fallback below still applies by default.
|
|
10
|
+
def time_window
|
|
11
|
+
return @time_window if defined?(@time_window)
|
|
12
|
+
|
|
13
|
+
@time_window = RailsPulse::TimeWindow.build(@start_time, @end_time)
|
|
14
|
+
end
|
|
15
|
+
|
|
8
16
|
def now
|
|
9
|
-
@now ||= Time.current
|
|
17
|
+
@now ||= time_window&.end_time || Time.current
|
|
10
18
|
end
|
|
11
19
|
|
|
12
20
|
def window_days
|
|
13
|
-
@period || 7
|
|
21
|
+
time_window&.days || @period || 7
|
|
14
22
|
end
|
|
15
23
|
|
|
16
24
|
def period_type_hours?
|
|
@@ -19,7 +27,9 @@ module RailsPulse
|
|
|
19
27
|
|
|
20
28
|
# Enhanced time period helpers (support hour and day)
|
|
21
29
|
def previous_window_start
|
|
22
|
-
if
|
|
30
|
+
if time_window
|
|
31
|
+
time_window.previous(period_type_hours? ? "hour" : "day").start_time
|
|
32
|
+
elsif period_type_hours?
|
|
23
33
|
(now - (window_days * 48).hours).beginning_of_hour
|
|
24
34
|
else
|
|
25
35
|
(now - (window_days * 2).days).beginning_of_day
|
|
@@ -27,7 +37,9 @@ module RailsPulse
|
|
|
27
37
|
end
|
|
28
38
|
|
|
29
39
|
def current_window_start
|
|
30
|
-
if
|
|
40
|
+
if time_window
|
|
41
|
+
time_window.start_time
|
|
42
|
+
elsif period_type_hours?
|
|
31
43
|
(now - (window_days * 24).hours).beginning_of_hour
|
|
32
44
|
else
|
|
33
45
|
(now - window_days.days).beginning_of_day
|
|
@@ -82,26 +94,15 @@ module RailsPulse
|
|
|
82
94
|
end
|
|
83
95
|
|
|
84
96
|
def build_hourly_sparkline(grouped_values)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
{}.tap do |hash|
|
|
89
|
-
current_time = start_time
|
|
90
|
-
while current_time <= end_time
|
|
91
|
-
# Use timestamp in milliseconds for JS compatibility
|
|
92
|
-
hash[current_time.to_i * 1000] = { value: grouped_values[current_time] || 0 }
|
|
93
|
-
current_time += 1.hour
|
|
94
|
-
end
|
|
97
|
+
sparkline_hours.each_with_object({}) do |current_time, hash|
|
|
98
|
+
# Use timestamp in milliseconds for JS compatibility
|
|
99
|
+
hash[current_time.to_i * 1000] = { value: grouped_values[current_time] || 0 }
|
|
95
100
|
end
|
|
96
101
|
end
|
|
97
102
|
|
|
98
103
|
def build_daily_sparkline(grouped_values)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
(start_date..end_date).each_with_object({}) do |day, hash|
|
|
103
|
-
label = day.strftime("%b %-d")
|
|
104
|
-
hash[label] = { value: grouped_values[day] || 0 }
|
|
104
|
+
sparkline_dates.each_with_object({}) do |day, hash|
|
|
105
|
+
hash[day.strftime("%b %-d")] = { value: grouped_values[day] || 0 }
|
|
105
106
|
end
|
|
106
107
|
end
|
|
107
108
|
|
|
@@ -112,6 +113,27 @@ module RailsPulse
|
|
|
112
113
|
current_window_start
|
|
113
114
|
end
|
|
114
115
|
|
|
116
|
+
# Every calendar date in the sparkline window. Via TimeWindow, so a
|
|
117
|
+
# range not starting on a day boundary skips the leading partial day.
|
|
118
|
+
def sparkline_dates
|
|
119
|
+
time_window&.dates || (sparkline_start.to_date..now.to_date).to_a
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Same skip-partial semantics as sparkline_dates, for hours.
|
|
123
|
+
def sparkline_hours
|
|
124
|
+
time_window&.hour_starts || begin
|
|
125
|
+
start_time = sparkline_start.beginning_of_hour
|
|
126
|
+
end_time = now.beginning_of_hour
|
|
127
|
+
hours = []
|
|
128
|
+
current_time = start_time
|
|
129
|
+
while current_time <= end_time
|
|
130
|
+
hours << current_time
|
|
131
|
+
current_time += 1.hour
|
|
132
|
+
end
|
|
133
|
+
hours
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
115
137
|
# Build sparkline query with tag filters and optional subject filter
|
|
116
138
|
def build_sparkline_query(summarizable_type)
|
|
117
139
|
sparkline_query = RailsPulse::Summary
|
|
@@ -127,15 +149,29 @@ module RailsPulse
|
|
|
127
149
|
|
|
128
150
|
# Group sparkline query by period type (hour or day)
|
|
129
151
|
def group_sparkline_by_period(sparkline_query, sum_field)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
152
|
+
bucket_by_period(sparkline_query) { |relation| relation.sum(sum_field) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Aggregates `relation` per calendar day of Time.zone, or per hour when
|
|
156
|
+
# the card's period type is "hour". The block receives the relation
|
|
157
|
+
# grouped by `column` and does the arithmetic in SQL; only the
|
|
158
|
+
# re-bucketing of keys happens here. It is done in Ruby because SQL
|
|
159
|
+
# DATE() / DATE_TRUNC() operate on the stored UTC value, which puts a
|
|
160
|
+
# Melbourne midnight on the previous calendar date and a Kolkata hour
|
|
161
|
+
# thirty minutes off, so the sparkline lookups (keyed by Time.zone
|
|
162
|
+
# dates and hour starts) would miss.
|
|
163
|
+
def bucket_by_period(relation, column: :period_start)
|
|
164
|
+
yield(relation.group(column)).each_with_object({}) do |(time, value), buckets|
|
|
165
|
+
next if time.nil? || value.nil?
|
|
166
|
+
|
|
167
|
+
local = time.in_time_zone
|
|
168
|
+
key = period_type_hours? ? local.beginning_of_hour : local.to_date
|
|
169
|
+
buckets[key] = (buckets[key] || 0) + value
|
|
134
170
|
end
|
|
135
171
|
end
|
|
136
172
|
|
|
137
173
|
def period_date_range
|
|
138
|
-
start_date = current_window_start.to_date
|
|
174
|
+
start_date = sparkline_dates.first || current_window_start.to_date
|
|
139
175
|
end_date = now.to_date
|
|
140
176
|
if start_date.year == end_date.year
|
|
141
177
|
"#{start_date.strftime("%b %-d")} – #{end_date.strftime("%b %-d")}"
|
|
@@ -2,18 +2,21 @@ module RailsPulse
|
|
|
2
2
|
module Dashboard
|
|
3
3
|
module Charts
|
|
4
4
|
class ResponseTimePercentiles
|
|
5
|
-
def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day")
|
|
5
|
+
def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
|
|
6
6
|
@disabled_tags = disabled_tags
|
|
7
7
|
@show_non_tagged = show_non_tagged
|
|
8
8
|
@period = period
|
|
9
9
|
@period_type = period_type
|
|
10
|
+
@start_time = start_time
|
|
11
|
+
@end_time = end_time
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def to_chart_data
|
|
13
15
|
if @period_type == "hour"
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
# Exact selected hours when explicit, else "trailing @period days".
|
|
17
|
+
hours = time_window&.hour_starts || default_hour_range
|
|
18
|
+
start_time = hours.first
|
|
19
|
+
end_time = hours.last
|
|
17
20
|
|
|
18
21
|
# Get the actual data from Summary records (routes)
|
|
19
22
|
summaries = RailsPulse::Summary
|
|
@@ -56,13 +59,7 @@ module RailsPulse
|
|
|
56
59
|
}
|
|
57
60
|
end
|
|
58
61
|
|
|
59
|
-
|
|
60
|
-
time_range = []
|
|
61
|
-
current_time = start_time
|
|
62
|
-
while current_time <= end_time
|
|
63
|
-
time_range << current_time
|
|
64
|
-
current_time += 1.hour
|
|
65
|
-
end
|
|
62
|
+
time_range = hours
|
|
66
63
|
|
|
67
64
|
# Build series data as [timestamp_ms, value] pairs for ECharts time axis
|
|
68
65
|
timestamps_ms = time_range.map { |t| t.to_i * 1000 }
|
|
@@ -70,10 +67,10 @@ module RailsPulse
|
|
|
70
67
|
p95_data = time_range.map { |time| [ time.to_i * 1000, final_data[time]&.[](:p95) ] }
|
|
71
68
|
p99_data = time_range.map { |time| [ time.to_i * 1000, final_data[time]&.[](:p99) ] }
|
|
72
69
|
else
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
# Exact selected days when explicit, else "trailing @period days".
|
|
71
|
+
date_range = time_window&.dates || default_day_range
|
|
72
|
+
start_date = date_range.first
|
|
73
|
+
end_date = date_range.last
|
|
77
74
|
|
|
78
75
|
summaries = RailsPulse::Summary
|
|
79
76
|
.with_tag_filters(@disabled_tags, @show_non_tagged)
|
|
@@ -169,6 +166,32 @@ module RailsPulse
|
|
|
169
166
|
{ labels: labels, series: series }
|
|
170
167
|
end
|
|
171
168
|
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
# nil unless both start_time and end_time were given, so the default
|
|
173
|
+
# "recent" view keeps using the trailing-@period-days fallback below.
|
|
174
|
+
def time_window
|
|
175
|
+
@time_window ||= RailsPulse::TimeWindow.build(@start_time, @end_time)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def default_hour_range
|
|
179
|
+
start_time = (@period * 24).hours.ago.beginning_of_hour
|
|
180
|
+
end_time = Time.current.beginning_of_hour
|
|
181
|
+
hours = []
|
|
182
|
+
current_time = start_time
|
|
183
|
+
while current_time <= end_time
|
|
184
|
+
hours << current_time
|
|
185
|
+
current_time += 1.hour
|
|
186
|
+
end
|
|
187
|
+
hours
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def default_day_range
|
|
191
|
+
start_date = @period.days.ago.beginning_of_day.to_date
|
|
192
|
+
end_date = Time.current.to_date
|
|
193
|
+
(start_date..end_date).to_a
|
|
194
|
+
end
|
|
172
195
|
end
|
|
173
196
|
end
|
|
174
197
|
end
|
|
@@ -2,17 +2,20 @@ module RailsPulse
|
|
|
2
2
|
module Dashboard
|
|
3
3
|
module Charts
|
|
4
4
|
class ThroughputAndErrors
|
|
5
|
-
def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day")
|
|
5
|
+
def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
|
|
6
6
|
@disabled_tags = disabled_tags
|
|
7
7
|
@show_non_tagged = show_non_tagged
|
|
8
8
|
@period = period
|
|
9
9
|
@period_type = period_type
|
|
10
|
+
@start_time = start_time
|
|
11
|
+
@end_time = end_time
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def to_chart_data
|
|
13
15
|
if @period_type == "hour"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
hours = time_window&.hour_starts || default_hour_range
|
|
17
|
+
start_time = hours.first
|
|
18
|
+
end_time = hours.last
|
|
16
19
|
|
|
17
20
|
summaries = RailsPulse::Summary
|
|
18
21
|
.with_tag_filters(@disabled_tags, @show_non_tagged)
|
|
@@ -40,13 +43,7 @@ module RailsPulse
|
|
|
40
43
|
}
|
|
41
44
|
end
|
|
42
45
|
|
|
43
|
-
|
|
44
|
-
time_range = []
|
|
45
|
-
current_time = start_time
|
|
46
|
-
while current_time <= end_time
|
|
47
|
-
time_range << current_time
|
|
48
|
-
current_time += 1.hour
|
|
49
|
-
end
|
|
46
|
+
time_range = hours
|
|
50
47
|
|
|
51
48
|
series = [
|
|
52
49
|
{
|
|
@@ -70,9 +67,9 @@ module RailsPulse
|
|
|
70
67
|
|
|
71
68
|
return { series: series }
|
|
72
69
|
else
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
70
|
+
date_range = time_window&.dates || default_day_range
|
|
71
|
+
start_date = date_range.first
|
|
72
|
+
end_date = date_range.last
|
|
76
73
|
|
|
77
74
|
summaries = RailsPulse::Summary
|
|
78
75
|
.with_tag_filters(@disabled_tags, @show_non_tagged)
|
|
@@ -125,6 +122,32 @@ module RailsPulse
|
|
|
125
122
|
|
|
126
123
|
{ labels: labels, series: series }
|
|
127
124
|
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# nil unless both start_time and end_time were given, so the default
|
|
129
|
+
# "recent" view keeps using the trailing-@period-days fallback below.
|
|
130
|
+
def time_window
|
|
131
|
+
@time_window ||= RailsPulse::TimeWindow.build(@start_time, @end_time)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def default_hour_range
|
|
135
|
+
start_time = (@period * 24).hours.ago.beginning_of_hour
|
|
136
|
+
end_time = Time.current.beginning_of_hour
|
|
137
|
+
hours = []
|
|
138
|
+
current_time = start_time
|
|
139
|
+
while current_time <= end_time
|
|
140
|
+
hours << current_time
|
|
141
|
+
current_time += 1.hour
|
|
142
|
+
end
|
|
143
|
+
hours
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def default_day_range
|
|
147
|
+
start_date = @period.days.ago.beginning_of_day.to_date
|
|
148
|
+
end_date = Time.current.to_date
|
|
149
|
+
(start_date..end_date).to_a
|
|
150
|
+
end
|
|
128
151
|
end
|
|
129
152
|
end
|
|
130
153
|
end
|
|
@@ -4,10 +4,12 @@ module RailsPulse
|
|
|
4
4
|
include Concerns::ThresholdConstants
|
|
5
5
|
include Concerns::TimeRangeHelper
|
|
6
6
|
|
|
7
|
-
def initialize(disabled_tags: [], show_non_tagged: true, period: 7)
|
|
7
|
+
def initialize(disabled_tags: [], show_non_tagged: true, period: 7, start_time: nil, end_time: nil)
|
|
8
8
|
@disabled_tags = disabled_tags
|
|
9
9
|
@show_non_tagged = show_non_tagged
|
|
10
10
|
@period = period
|
|
11
|
+
@start_time = start_time
|
|
12
|
+
@end_time = end_time
|
|
11
13
|
@route_thresholds = RailsPulse.configuration.route_thresholds
|
|
12
14
|
@query_thresholds = RailsPulse.configuration.query_thresholds
|
|
13
15
|
@job_thresholds = RailsPulse.configuration.job_thresholds
|
|
@@ -4,10 +4,12 @@ module RailsPulse
|
|
|
4
4
|
include Concerns::ThresholdConstants
|
|
5
5
|
include Concerns::TimeRangeHelper
|
|
6
6
|
|
|
7
|
-
def initialize(disabled_tags: [], show_non_tagged: true, period: 7)
|
|
7
|
+
def initialize(disabled_tags: [], show_non_tagged: true, period: 7, start_time: nil, end_time: nil)
|
|
8
8
|
@disabled_tags = disabled_tags
|
|
9
9
|
@show_non_tagged = show_non_tagged
|
|
10
10
|
@period = period
|
|
11
|
+
@start_time = start_time
|
|
12
|
+
@end_time = end_time
|
|
11
13
|
@route_thresholds = RailsPulse.configuration.route_thresholds
|
|
12
14
|
@query_thresholds = RailsPulse.configuration.query_thresholds
|
|
13
15
|
@job_thresholds = RailsPulse.configuration.job_thresholds
|
|
@@ -74,51 +74,35 @@ module RailsPulse
|
|
|
74
74
|
}
|
|
75
75
|
].freeze
|
|
76
76
|
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
rails_pulse_job_runs: { count: 31_002, limit: 50_000, recent: 1_940, bytes: 54_525_952, days_old: 14 },
|
|
97
|
-
rails_pulse_exception_occurrences: { count: 4_118, limit: 50_000, recent: 40, bytes: 18_874_368, days_old: 14 },
|
|
98
|
-
rails_pulse_summaries: { count: 1_240, limit: nil, recent: 48, bytes: 4_612_096, days_old: 14 },
|
|
99
|
-
rails_pulse_queries: { count: 812, limit: 10_000, recent: 12, bytes: 3_250_176, days_old: 90 },
|
|
100
|
-
rails_pulse_routes: { count: 268, limit: 1_000, recent: 2, bytes: 491_520, days_old: 90 },
|
|
101
|
-
rails_pulse_jobs: { count: 41, limit: 1_000, recent: 0, bytes: 106_496, days_old: 90 },
|
|
102
|
-
rails_pulse_exception_groups: { count: 91, limit: 10_000, recent: 3, bytes: 225_280, days_old: 90 },
|
|
103
|
-
rails_pulse_deployments: { count: 18, limit: nil, recent: 1, bytes: 65_536, days_old: 90 }
|
|
104
|
-
},
|
|
105
|
-
critical: {
|
|
106
|
-
rails_pulse_operations: { count: 104_280, limit: 100_000, recent: 8_110, bytes: 224_395_264, days_old: 18 },
|
|
107
|
-
rails_pulse_requests: { count: 48_902, limit: 50_000, recent: 3_040, bytes: 106_954_752, days_old: 18 },
|
|
108
|
-
rails_pulse_job_runs: { count: 46_118, limit: 50_000, recent: 2_280, bytes: 84_934_656, days_old: 18 },
|
|
109
|
-
rails_pulse_exception_occurrences: { count: 8_640, limit: 50_000, recent: 90, bytes: 25_165_824, days_old: 18 },
|
|
110
|
-
rails_pulse_summaries: { count: 1_410, limit: nil, recent: 24, bytes: 5_033_984, days_old: 18 },
|
|
111
|
-
rails_pulse_queries: { count: 1_204, limit: 10_000, recent: 18, bytes: 4_300_800, days_old: 120 },
|
|
112
|
-
rails_pulse_routes: { count: 312, limit: 1_000, recent: 4, bytes: 552_960, days_old: 120 },
|
|
113
|
-
rails_pulse_jobs: { count: 44, limit: 1_000, recent: 0, bytes: 114_688, days_old: 120 },
|
|
114
|
-
rails_pulse_exception_groups: { count: 128, limit: 10_000, recent: 5, bytes: 286_720, days_old: 120 },
|
|
115
|
-
rails_pulse_deployments: { count: 22, limit: nil, recent: 1, bytes: 73_728, days_old: 120 }
|
|
116
|
-
}
|
|
117
|
-
}.freeze
|
|
77
|
+
# Table sizes come from dbstat / pg_total_relation_size /
|
|
78
|
+
# information_schema, one statement per table. The dashboard only shows
|
|
79
|
+
# a headline total, so it reads them through a short-lived per-process
|
|
80
|
+
# cache; the Storage page itself always measures afresh.
|
|
81
|
+
SIZE_CACHE_TTL = 5.minutes
|
|
82
|
+
@size_cache = {}
|
|
83
|
+
@size_cache_mutex = Mutex.new
|
|
84
|
+
|
|
85
|
+
class << self
|
|
86
|
+
def cached_table_bytes(table_name)
|
|
87
|
+
@size_cache_mutex.synchronize do
|
|
88
|
+
entry = @size_cache[table_name]
|
|
89
|
+
return entry[:bytes] if entry && entry[:measured_at] > SIZE_CACHE_TTL.ago
|
|
90
|
+
|
|
91
|
+
bytes = yield
|
|
92
|
+
@size_cache[table_name] = { bytes: bytes, measured_at: Time.current }
|
|
93
|
+
bytes
|
|
94
|
+
end
|
|
95
|
+
end
|
|
118
96
|
|
|
119
|
-
|
|
97
|
+
def reset_size_cache!
|
|
98
|
+
@size_cache_mutex.synchronize { @size_cache.clear }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def initialize(cached_sizes: false)
|
|
120
103
|
@config = RailsPulse.configuration
|
|
121
104
|
@pressure = StoragePressure.new
|
|
105
|
+
@cached_sizes = cached_sizes
|
|
122
106
|
end
|
|
123
107
|
|
|
124
108
|
def tables
|
|
@@ -178,33 +162,13 @@ module RailsPulse
|
|
|
178
162
|
RailsPulse::ApplicationRecord.connection
|
|
179
163
|
end
|
|
180
164
|
|
|
181
|
-
def screenshot_row(table_name)
|
|
182
|
-
return unless SCREENSHOT
|
|
183
|
-
|
|
184
|
-
row = SCREENSHOT_TABLES.dig(SCREENSHOT_STATE, table_name)
|
|
185
|
-
return unless row
|
|
186
|
-
|
|
187
|
-
oldest_at = row[:days_old].days.ago
|
|
188
|
-
{
|
|
189
|
-
count: row[:count],
|
|
190
|
-
limit: row[:limit],
|
|
191
|
-
recent_count: row[:recent],
|
|
192
|
-
bytes: row[:bytes],
|
|
193
|
-
oldest_at: oldest_at,
|
|
194
|
-
newest_at: 8.minutes.ago
|
|
195
|
-
}
|
|
196
|
-
end
|
|
197
|
-
|
|
198
165
|
def build_table(definition)
|
|
199
|
-
|
|
200
|
-
unless
|
|
201
|
-
|
|
202
|
-
return unless model
|
|
203
|
-
return unless connection.table_exists?(definition[:name])
|
|
204
|
-
end
|
|
166
|
+
model = definition[:model].safe_constantize
|
|
167
|
+
return unless model
|
|
168
|
+
return unless connection.table_exists?(definition[:name])
|
|
205
169
|
|
|
206
|
-
stats =
|
|
207
|
-
limit =
|
|
170
|
+
stats = fetch_counts(model, definition)
|
|
171
|
+
limit = @config.max_table_records&.[](definition[:name])
|
|
208
172
|
count = stats[:count]
|
|
209
173
|
percent = limit.to_i.positive? ? ((count.to_f / limit) * 100).round(1) : nil
|
|
210
174
|
severity = fill_severity(percent, count, limit)
|
|
@@ -223,7 +187,7 @@ module RailsPulse
|
|
|
223
187
|
newest_at: stats[:newest_at],
|
|
224
188
|
recent_count: stats[:recent_count],
|
|
225
189
|
runway_label: runway_label(count, limit, stats[:recent_count]),
|
|
226
|
-
bytes:
|
|
190
|
+
bytes: measured_table_bytes(definition[:name]),
|
|
227
191
|
history_label: history_label(stats[:oldest_at], stats[:newest_at])
|
|
228
192
|
}
|
|
229
193
|
rescue => error
|
|
@@ -351,6 +315,12 @@ module RailsPulse
|
|
|
351
315
|
row[key] || row[key.to_s] || row[key.to_s.upcase] || row[key.to_sym]
|
|
352
316
|
end
|
|
353
317
|
|
|
318
|
+
def measured_table_bytes(table_name)
|
|
319
|
+
return table_bytes(table_name) unless @cached_sizes
|
|
320
|
+
|
|
321
|
+
self.class.cached_table_bytes(table_name) { table_bytes(table_name) }
|
|
322
|
+
end
|
|
323
|
+
|
|
354
324
|
def table_bytes(table_name)
|
|
355
325
|
adapter = connection.adapter_name.downcase
|
|
356
326
|
|