rails_pulse 0.5.0 → 0.5.1.pre.1
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 +11 -0
- data/app/controllers/rails_pulse/api/v1/base_controller.rb +9 -0
- data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +4 -1
- data/app/controllers/rails_pulse/api/v1/queries_controller.rb +1 -1
- data/app/controllers/rails_pulse/api/v1/requests_controller.rb +1 -1
- data/app/controllers/rails_pulse/api/v1/routes_controller.rb +1 -1
- data/app/helpers/rails_pulse/application_helper.rb +1 -0
- data/app/helpers/rails_pulse/detail_capture_helper.rb +43 -0
- data/app/jobs/rails_pulse/summary_job.rb +45 -0
- data/app/models/rails_pulse/dashboard/storage_pressure.rb +43 -1
- data/app/models/rails_pulse/dashboard/storage_status.rb +9 -1
- data/app/models/rails_pulse/operation.rb +45 -19
- data/app/models/rails_pulse/rollup.rb +50 -0
- data/app/services/rails_pulse/detail_capture_rate.rb +141 -0
- data/app/services/rails_pulse/operation_suggestions.rb +3 -0
- data/app/services/rails_pulse/query_analysis_service.rb +5 -1
- data/app/services/rails_pulse/summary_service/from_raw_rows.rb +160 -10
- data/app/views/rails_pulse/components/_panel.html.erb +23 -0
- data/app/views/rails_pulse/queries/_diagnostics.html.erb +3 -0
- data/app/views/rails_pulse/requests/index.html.erb +4 -1
- data/app/views/rails_pulse/routes/show.html.erb +1 -0
- data/app/views/rails_pulse/storage/show.html.erb +17 -0
- data/db/rails_pulse_migrate/20261007000001_create_rails_pulse_rollups.rb +35 -0
- data/db/rails_pulse_schema.rb +34 -2
- data/lib/generators/rails_pulse/base_methods.rb +1 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +34 -2
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +13 -0
- data/lib/rails_pulse/cleanup_service.rb +44 -9
- data/lib/rails_pulse/configuration.rb +90 -1
- data/lib/rails_pulse/duration_histogram.rb +150 -0
- data/lib/rails_pulse/engine.rb +2 -0
- data/lib/rails_pulse/middleware/request_collector.rb +88 -21
- data/lib/rails_pulse/rollup_accumulator.rb +217 -0
- data/lib/rails_pulse/tasks/status_reporter.rb +40 -1
- data/lib/rails_pulse/tracker.rb +99 -4
- data/lib/rails_pulse/version.rb +1 -1
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- metadata +9 -3
|
@@ -10,6 +10,13 @@ module RailsPulse
|
|
|
10
10
|
# hour costs one query per kind however many routes, queries or jobs it
|
|
11
11
|
# saw. Percentiles are exact.
|
|
12
12
|
#
|
|
13
|
+
# When detail capture folded part of the hour into rails_pulse_rollups
|
|
14
|
+
# (decision 0022), each subject's raw rows are merged with its rollups:
|
|
15
|
+
# counts, sums, min, max, averages and stddev stay exact, and percentiles
|
|
16
|
+
# come from the rollups' histograms walked together with the exactly-kept
|
|
17
|
+
# durations. A subject with no rollups takes the plain path, so with
|
|
18
|
+
# detail capture off nothing changes.
|
|
19
|
+
#
|
|
13
20
|
# Rows carry the summarizable and its metrics; SummaryService adds the
|
|
14
21
|
# period columns.
|
|
15
22
|
class FromRawRows
|
|
@@ -21,29 +28,38 @@ module RailsPulse
|
|
|
21
28
|
def request_rows
|
|
22
29
|
durations = request_data.map { |_, duration, _| duration }.compact.sort
|
|
23
30
|
statuses = request_data.map { |_, _, status| status }
|
|
31
|
+
rollup = merge_rollups(route_rollups.values)
|
|
24
32
|
|
|
25
|
-
[ row("RailsPulse::Request", 0).merge(
|
|
33
|
+
[ row("RailsPulse::Request", 0).merge(duration_metrics(durations, rollup), status_metrics(statuses, rollup)) ]
|
|
26
34
|
end
|
|
27
35
|
|
|
28
36
|
def route_rows
|
|
29
|
-
|
|
37
|
+
raw_by_route = request_data
|
|
38
|
+
.slice_when { |previous, current| previous.first != current.first }
|
|
39
|
+
.to_h { |rows| [ rows.first.first, rows ] }
|
|
40
|
+
|
|
41
|
+
(raw_by_route.keys | route_rollups.keys).map do |route_id|
|
|
42
|
+
rows = raw_by_route[route_id] || []
|
|
30
43
|
durations = rows.map { |_, duration, _| duration }
|
|
31
44
|
statuses = rows.map { |_, _, status| status }
|
|
45
|
+
rollup = route_rollups[route_id]
|
|
32
46
|
|
|
33
|
-
row("RailsPulse::Route",
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
count: rows.size
|
|
47
|
+
row("RailsPulse::Route", route_id).merge(
|
|
48
|
+
duration_metrics(durations, rollup),
|
|
49
|
+
status_metrics(statuses, rollup)
|
|
37
50
|
)
|
|
38
51
|
end
|
|
39
52
|
end
|
|
40
53
|
|
|
41
54
|
def query_rows
|
|
42
|
-
rows_by_summarizable(Operation.where(occurred_at: @time_range), :query_id).
|
|
43
|
-
durations = rows.map(&:first).compact
|
|
44
|
-
next if durations.empty?
|
|
55
|
+
raw_by_query = rows_by_summarizable(Operation.where(occurred_at: @time_range), :query_id).to_h
|
|
45
56
|
|
|
46
|
-
|
|
57
|
+
(raw_by_query.keys | query_rollups.keys).filter_map do |query_id|
|
|
58
|
+
durations = (raw_by_query[query_id] || []).map(&:first).compact
|
|
59
|
+
rollup = query_rollups[query_id]
|
|
60
|
+
next if durations.empty? && rollup.nil?
|
|
61
|
+
|
|
62
|
+
row("RailsPulse::Query", query_id).merge(duration_metrics(durations, rollup))
|
|
47
63
|
end
|
|
48
64
|
end
|
|
49
65
|
|
|
@@ -84,10 +100,144 @@ module RailsPulse
|
|
|
84
100
|
|
|
85
101
|
private
|
|
86
102
|
|
|
103
|
+
# Everything one subject's rollup rows add up to, with sums as floats.
|
|
104
|
+
MergedRollup = Struct.new(
|
|
105
|
+
:count, :error_count,
|
|
106
|
+
:status_2xx, :status_3xx, :status_4xx, :status_5xx,
|
|
107
|
+
:sum_duration, :sum_square_duration, :min_duration, :max_duration,
|
|
108
|
+
:histogram_counts
|
|
109
|
+
)
|
|
110
|
+
|
|
87
111
|
def row(summarizable_type, summarizable_id)
|
|
88
112
|
{ summarizable_type: summarizable_type, summarizable_id: summarizable_id }
|
|
89
113
|
end
|
|
90
114
|
|
|
115
|
+
# Identical to Metrics.duration when the subject has no rollups.
|
|
116
|
+
def duration_metrics(sorted_durations, rollup)
|
|
117
|
+
return Metrics.duration(sorted_durations) if rollup.nil?
|
|
118
|
+
|
|
119
|
+
count = sorted_durations.size + rollup.count
|
|
120
|
+
total = sorted_durations.sum + rollup.sum_duration
|
|
121
|
+
sum_of_squares = sorted_durations.sum { |d| d * d } + rollup.sum_square_duration
|
|
122
|
+
avg = count.positive? ? total.to_f / count : 0
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
count: count,
|
|
126
|
+
avg_duration: avg,
|
|
127
|
+
min_duration: [ sorted_durations.first, rollup.min_duration ].compact.min,
|
|
128
|
+
max_duration: [ sorted_durations.last, rollup.max_duration ].compact.max,
|
|
129
|
+
total_duration: total,
|
|
130
|
+
p50_duration: merged_percentile(rollup, sorted_durations, 0.5),
|
|
131
|
+
p95_duration: merged_percentile(rollup, sorted_durations, 0.95),
|
|
132
|
+
p99_duration: merged_percentile(rollup, sorted_durations, 0.99),
|
|
133
|
+
# Each value as its own group makes this the exact sample stddev
|
|
134
|
+
# from N, sum(x) and sum(x^2).
|
|
135
|
+
stddev_duration: Statistics.pooled_stddev(
|
|
136
|
+
count: count, within_sum: 0,
|
|
137
|
+
weighted_mean_sum: total, weighted_square_sum: sum_of_squares
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def status_metrics(statuses, rollup)
|
|
143
|
+
return Metrics.status(statuses) if rollup.nil?
|
|
144
|
+
|
|
145
|
+
base = Metrics.status(statuses)
|
|
146
|
+
{
|
|
147
|
+
error_count: base[:error_count] + rollup.error_count,
|
|
148
|
+
success_count: base[:success_count] + (rollup.count - rollup.error_count),
|
|
149
|
+
status_2xx: base[:status_2xx] + rollup.status_2xx,
|
|
150
|
+
status_3xx: base[:status_3xx] + rollup.status_3xx,
|
|
151
|
+
status_4xx: base[:status_4xx] + rollup.status_4xx,
|
|
152
|
+
status_5xx: base[:status_5xx] + rollup.status_5xx
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def merged_percentile(rollup, sorted_durations, percentile)
|
|
157
|
+
DurationHistogram.percentile(
|
|
158
|
+
counts: rollup.histogram_counts,
|
|
159
|
+
exact_sorted: sorted_durations,
|
|
160
|
+
percentile: percentile
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def route_rollups
|
|
165
|
+
rollup_data.fetch("RailsPulse::Route", {})
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def query_rollups
|
|
169
|
+
rollup_data.fetch("RailsPulse::Query", {})
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# { rollupable_type => { rollupable_id => MergedRollup } } for the hour,
|
|
173
|
+
# each subject's writer rows already summed. Empty on installs that have
|
|
174
|
+
# not migrated the table yet.
|
|
175
|
+
def rollup_data
|
|
176
|
+
@rollup_data ||= Rollup.table_available? ? load_rollups : {}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def load_rollups
|
|
180
|
+
Rollup
|
|
181
|
+
.where(period_start: @time_range)
|
|
182
|
+
.pluck(:rollupable_type, :rollupable_id, :count, :error_count,
|
|
183
|
+
:status_2xx, :status_3xx, :status_4xx, :status_5xx,
|
|
184
|
+
:sum_duration, :sum_square_duration, :min_duration, :max_duration,
|
|
185
|
+
:histogram)
|
|
186
|
+
.group_by { |type, _id, *| type }
|
|
187
|
+
.transform_values do |type_rows|
|
|
188
|
+
type_rows
|
|
189
|
+
.group_by { |_type, id, *| id }
|
|
190
|
+
.transform_values { |subject_rows| merge_rollup_rows(subject_rows) }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def merge_rollup_rows(rows)
|
|
195
|
+
merged = MergedRollup.new(0, 0, 0, 0, 0, 0, 0.0, 0.0, nil, nil, {})
|
|
196
|
+
rows.each do |_type, _id, count, error_count, s2, s3, s4, s5, sum, sum_sq, min, max, histogram|
|
|
197
|
+
merged.count += count
|
|
198
|
+
merged.error_count += error_count
|
|
199
|
+
merged.status_2xx += s2
|
|
200
|
+
merged.status_3xx += s3
|
|
201
|
+
merged.status_4xx += s4
|
|
202
|
+
merged.status_5xx += s5
|
|
203
|
+
merged.sum_duration += sum.to_f
|
|
204
|
+
merged.sum_square_duration += sum_sq.to_f
|
|
205
|
+
merged.min_duration = [ merged.min_duration, min&.to_f ].compact.min
|
|
206
|
+
merged.max_duration = [ merged.max_duration, max&.to_f ].compact.max
|
|
207
|
+
add_histogram(merged.histogram_counts, histogram)
|
|
208
|
+
end
|
|
209
|
+
merged
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def add_histogram(accumulated, histogram_json)
|
|
213
|
+
DurationHistogram.load(histogram_json).counts.each do |index, n|
|
|
214
|
+
accumulated[index] = accumulated.fetch(index, 0) + n
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The overall row folds every route's rollups into one.
|
|
219
|
+
def merge_rollups(merged_rollups)
|
|
220
|
+
return nil if merged_rollups.empty?
|
|
221
|
+
|
|
222
|
+
combined = MergedRollup.new(0, 0, 0, 0, 0, 0, 0.0, 0.0, nil, nil, {})
|
|
223
|
+
merged_rollups.each do |rollup|
|
|
224
|
+
combined.count += rollup.count
|
|
225
|
+
combined.error_count += rollup.error_count
|
|
226
|
+
combined.status_2xx += rollup.status_2xx
|
|
227
|
+
combined.status_3xx += rollup.status_3xx
|
|
228
|
+
combined.status_4xx += rollup.status_4xx
|
|
229
|
+
combined.status_5xx += rollup.status_5xx
|
|
230
|
+
combined.sum_duration += rollup.sum_duration
|
|
231
|
+
combined.sum_square_duration += rollup.sum_square_duration
|
|
232
|
+
combined.min_duration = [ combined.min_duration, rollup.min_duration ].compact.min
|
|
233
|
+
combined.max_duration = [ combined.max_duration, rollup.max_duration ].compact.max
|
|
234
|
+
rollup.histogram_counts.each do |index, n|
|
|
235
|
+
combined.histogram_counts[index] = combined.histogram_counts.fetch(index, 0) + n
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
combined
|
|
239
|
+
end
|
|
240
|
+
|
|
91
241
|
# One read serves the overall request row and every route's. route_id
|
|
92
242
|
# and duration are NOT NULL on requests, so ordering by (route_id,
|
|
93
243
|
# duration) hands each route its durations already sorted.
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
title ||= nil
|
|
3
3
|
help_heading ||= nil
|
|
4
4
|
help_text ||= nil
|
|
5
|
+
# { heading:, lines: [...] } — a note about the panel's data, shown from
|
|
6
|
+
# an info icon
|
|
7
|
+
info ||= nil
|
|
5
8
|
link_to ||= nil
|
|
6
9
|
actions ||= nil
|
|
7
10
|
card_classes ||= ""
|
|
@@ -27,6 +30,26 @@
|
|
|
27
30
|
data-action="click->rails-pulse--series-toggle#toggle"
|
|
28
31
|
><%= toggle[:name] %></button>
|
|
29
32
|
<% end %>
|
|
33
|
+
<% if info %>
|
|
34
|
+
<div data-controller="rails-pulse--popover" data-rails-pulse--popover-placement-value="bottom-end">
|
|
35
|
+
<a href="#"
|
|
36
|
+
title="<%= info[:heading] %>"
|
|
37
|
+
data-rails-pulse--popover-target="button"
|
|
38
|
+
data-action="rails-pulse--popover#toggle"
|
|
39
|
+
data-popovertarget="popover">
|
|
40
|
+
<%= rails_pulse_icon 'info', height: "20px" %>
|
|
41
|
+
</a>
|
|
42
|
+
|
|
43
|
+
<div popover class="popover card" data-rails-pulse--popover-target="menu" style="max-width: 20rem">
|
|
44
|
+
<div class="flex flex-col">
|
|
45
|
+
<h3 class="font-semibold leading-none mbe-2 uppercase text-sm"><%= info[:heading] %></h3>
|
|
46
|
+
<% info[:lines].each_with_index do |line, index| %>
|
|
47
|
+
<p class="text-sm text-subtle<%= ' mbe-2' if index < info[:lines].size - 1 %>"><%= line %></p>
|
|
48
|
+
<% end %>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<% end %>
|
|
30
53
|
<% if help_heading %>
|
|
31
54
|
<div data-controller="rails-pulse--popover" data-rails-pulse--popover-placement-value="bottom-start">
|
|
32
55
|
<a href="#"
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
<%= render 'rails_pulse/components/panel', {
|
|
2
2
|
title: 'Diagnostics',
|
|
3
|
+
# Built, like the locations and raw data below, from operation rows, which
|
|
4
|
+
# folded requests never create.
|
|
5
|
+
info: detail_capture_info("Diagnostics, locations and raw data come only from captured requests: slow, with a slow query, errored or sampled. The charts and cards above count all traffic.", subject: query),
|
|
3
6
|
help_heading: 'Query Diagnostics',
|
|
4
7
|
help_text: 'Automatically generated analysis for this query. Includes N+1 detection (from live request data), query characteristics, detected issues, optimization suggestions, and a database EXPLAIN plan. Click Re-analyze to regenerate.',
|
|
5
8
|
actions: [ { url: reanalyze_query_path(query), method: :post, title: 'Re-analyze', icon: 'refresh-cw' } ]
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<%= render 'rails_pulse/components/page_header', show_active_filters: true %>
|
|
2
2
|
|
|
3
|
-
<%= render 'rails_pulse/components/panel', {
|
|
3
|
+
<%= render 'rails_pulse/components/panel', {
|
|
4
|
+
title: 'Requests',
|
|
5
|
+
info: detail_capture_info("This list shows only captured requests: slow, with a slow query, errored or sampled. Charts and totals count all traffic.")
|
|
6
|
+
} do %>
|
|
4
7
|
<%= search_form_for @ransack_query, url: requests_path, class: "flex items-center justify-between gap mb-4" do |form| %>
|
|
5
8
|
<div class="flex items-center grow gap">
|
|
6
9
|
<%= form.search_field :route_path_cont,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<div class="grid-item">
|
|
15
15
|
<%= render 'rails_pulse/components/panel', {
|
|
16
16
|
title: 'Route Requests',
|
|
17
|
+
info: detail_capture_info("This list shows only captured requests: slow, with a slow query, errored or sampled. Charts and totals count all traffic.", subject: @route),
|
|
17
18
|
help_heading: 'Route Performance Charts',
|
|
18
19
|
help_text: 'Toggle between different performance metrics for this route. Response Time shows P95/P99 latency, Request Rate shows traffic patterns, and Error Rate tracks failure rates over time.'
|
|
19
20
|
} do %>
|
|
@@ -92,6 +92,23 @@
|
|
|
92
92
|
</dd>
|
|
93
93
|
<dt>Count caps</dt>
|
|
94
94
|
<dd>Configured in <code>max_table_records</code>. Tables without a key have no count limit.</dd>
|
|
95
|
+
<dt>Full details</dt>
|
|
96
|
+
<dd>
|
|
97
|
+
<% detail_capture = @status.detail_capture %>
|
|
98
|
+
<% if !detail_capture[:enabled] %>
|
|
99
|
+
All requests are stored in full
|
|
100
|
+
<% else %>
|
|
101
|
+
<%= detail_capture[:settings_label].upcase_first %>
|
|
102
|
+
<br>
|
|
103
|
+
<span class="text-subtle">
|
|
104
|
+
<% if (rate = detail_capture[:rate]) %>
|
|
105
|
+
Stored <%= rate.share_label %> of the last day's <%= number_with_delimiter(rate.total) %> requests in full
|
|
106
|
+
<% else %>
|
|
107
|
+
Share kept appears once SummaryJob has summarized an hour with folded requests
|
|
108
|
+
<% end %>
|
|
109
|
+
</span>
|
|
110
|
+
<% end %>
|
|
111
|
+
</dd>
|
|
95
112
|
</dl>
|
|
96
113
|
<% end %>
|
|
97
114
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
class CreateRailsPulseRollups < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
return if table_exists?(:rails_pulse_rollups)
|
|
4
|
+
|
|
5
|
+
create_table :rails_pulse_rollups do |t|
|
|
6
|
+
# Per-writer per-hour aggregates of the requests and SQL operations that
|
|
7
|
+
# config.store_full_details folded instead of persisting
|
|
8
|
+
t.references :rollupable, polymorphic: true, null: false, index: false, comment: "Link to Route or Query"
|
|
9
|
+
t.datetime :period_start, null: false, comment: "Start of the hour the folded requests occurred in"
|
|
10
|
+
t.string :writer_id, null: false, comment: "host:pid:uuid of the writer incarnation that owns the row"
|
|
11
|
+
|
|
12
|
+
t.integer :count, default: 0, null: false, comment: "Folded requests (route rows) or operations (query rows)"
|
|
13
|
+
t.integer :error_count, default: 0, null: false, comment: "Folded 5xx requests; always 0 on query rows"
|
|
14
|
+
t.integer :status_2xx, default: 0, null: false, comment: "Folded 2xx responses; always 0 on query rows"
|
|
15
|
+
t.integer :status_3xx, default: 0, null: false, comment: "Folded 3xx responses; always 0 on query rows"
|
|
16
|
+
t.integer :status_4xx, default: 0, null: false, comment: "Folded 4xx responses; always 0 on query rows"
|
|
17
|
+
t.integer :status_5xx, default: 0, null: false, comment: "Folded 5xx responses; always 0 on query rows"
|
|
18
|
+
|
|
19
|
+
t.decimal :sum_duration, precision: 20, scale: 6, default: 0, null: false, comment: "Exact sum of folded durations in milliseconds"
|
|
20
|
+
t.decimal :sum_square_duration, precision: 30, scale: 6, default: 0, null: false, comment: "Exact sum of squared durations, for pooled stddev"
|
|
21
|
+
t.decimal :min_duration, precision: 15, scale: 6, comment: "Smallest folded duration in milliseconds"
|
|
22
|
+
t.decimal :max_duration, precision: 15, scale: 6, comment: "Largest folded duration in milliseconds"
|
|
23
|
+
t.text :histogram, comment: "JSON of DurationHistogram bucket index => count, for percentiles"
|
|
24
|
+
|
|
25
|
+
t.timestamps
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# upsert_all relies on MySQL resolving conflicts through any unique key,
|
|
29
|
+
# so this must stay the table's only unique index besides the primary key.
|
|
30
|
+
add_index :rails_pulse_rollups, [ :rollupable_type, :rollupable_id, :period_start, :writer_id ],
|
|
31
|
+
unique: true,
|
|
32
|
+
name: "idx_pulse_rollups_unique"
|
|
33
|
+
add_index :rails_pulse_rollups, :period_start, name: "index_rails_pulse_rollups_on_period_start"
|
|
34
|
+
end
|
|
35
|
+
end
|
data/db/rails_pulse_schema.rb
CHANGED
|
@@ -7,7 +7,7 @@ require "rails_pulse/route_indexes" unless defined?(RailsPulse::RouteIndexes)
|
|
|
7
7
|
RailsPulse::Schema = lambda do |connection|
|
|
8
8
|
adapter = connection.adapter_name.downcase
|
|
9
9
|
# Skip if all tables already exist to prevent conflicts
|
|
10
|
-
required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries, :rails_pulse_deployments, :rails_pulse_exception_groups, :rails_pulse_exception_occurrences, :rails_pulse_events ]
|
|
10
|
+
required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries, :rails_pulse_deployments, :rails_pulse_exception_groups, :rails_pulse_exception_occurrences, :rails_pulse_events, :rails_pulse_rollups ]
|
|
11
11
|
|
|
12
12
|
# Check which tables already exist
|
|
13
13
|
existing_tables = required_tables.select { |table| connection.table_exists?(table) }
|
|
@@ -245,7 +245,7 @@ RailsPulse::Schema = lambda do |connection|
|
|
|
245
245
|
comment: "FK to the group this occurrence belongs to"
|
|
246
246
|
t.string :exception_class, null: false
|
|
247
247
|
t.text :message
|
|
248
|
-
|
|
248
|
+
t.text :backtrace, comment: "JSON array of {file, line, method} frames (first 50)"
|
|
249
249
|
t.string :request_url, comment: "Nullable — web requests only"
|
|
250
250
|
t.string :request_method, comment: "GET, POST, etc."
|
|
251
251
|
t.string :environment, comment: "production, staging, etc."
|
|
@@ -277,6 +277,38 @@ RailsPulse::Schema = lambda do |connection|
|
|
|
277
277
|
name: "index_rp_events_on_kind_subject_and_occurred_at"
|
|
278
278
|
end
|
|
279
279
|
|
|
280
|
+
unless connection.table_exists?(:rails_pulse_rollups)
|
|
281
|
+
connection.create_table :rails_pulse_rollups do |t|
|
|
282
|
+
# Per-writer per-hour aggregates of the requests and SQL operations that
|
|
283
|
+
# config.store_full_details folded instead of persisting
|
|
284
|
+
t.references :rollupable, polymorphic: true, null: false, index: false, comment: "Link to Route or Query"
|
|
285
|
+
t.datetime :period_start, null: false, comment: "Start of the hour the folded requests occurred in"
|
|
286
|
+
t.string :writer_id, null: false, comment: "host:pid:uuid of the writer incarnation that owns the row"
|
|
287
|
+
|
|
288
|
+
t.integer :count, default: 0, null: false, comment: "Folded requests (route rows) or operations (query rows)"
|
|
289
|
+
t.integer :error_count, default: 0, null: false, comment: "Folded 5xx requests; always 0 on query rows"
|
|
290
|
+
t.integer :status_2xx, default: 0, null: false, comment: "Folded 2xx responses; always 0 on query rows"
|
|
291
|
+
t.integer :status_3xx, default: 0, null: false, comment: "Folded 3xx responses; always 0 on query rows"
|
|
292
|
+
t.integer :status_4xx, default: 0, null: false, comment: "Folded 4xx responses; always 0 on query rows"
|
|
293
|
+
t.integer :status_5xx, default: 0, null: false, comment: "Folded 5xx responses; always 0 on query rows"
|
|
294
|
+
|
|
295
|
+
t.decimal :sum_duration, precision: 20, scale: 6, default: 0, null: false, comment: "Exact sum of folded durations in milliseconds"
|
|
296
|
+
t.decimal :sum_square_duration, precision: 30, scale: 6, default: 0, null: false, comment: "Exact sum of squared durations, for pooled stddev"
|
|
297
|
+
t.decimal :min_duration, precision: 15, scale: 6, comment: "Smallest folded duration in milliseconds"
|
|
298
|
+
t.decimal :max_duration, precision: 15, scale: 6, comment: "Largest folded duration in milliseconds"
|
|
299
|
+
t.text :histogram, comment: "JSON of DurationHistogram bucket index => count, for percentiles"
|
|
300
|
+
|
|
301
|
+
t.timestamps
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# upsert_all relies on MySQL resolving conflicts through any unique key,
|
|
305
|
+
# so this must stay the table's only unique index besides the primary key.
|
|
306
|
+
connection.add_index :rails_pulse_rollups, [ :rollupable_type, :rollupable_id, :period_start, :writer_id ],
|
|
307
|
+
unique: true,
|
|
308
|
+
name: "idx_pulse_rollups_unique"
|
|
309
|
+
connection.add_index :rails_pulse_rollups, :period_start, name: "index_rails_pulse_rollups_on_period_start"
|
|
310
|
+
end
|
|
311
|
+
|
|
280
312
|
# Add indexes to existing tables for efficient aggregation
|
|
281
313
|
unless connection.index_exists?(:rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation")
|
|
282
314
|
connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation"
|
|
@@ -7,7 +7,7 @@ require "rails_pulse/route_indexes" unless defined?(RailsPulse::RouteIndexes)
|
|
|
7
7
|
RailsPulse::Schema = lambda do |connection|
|
|
8
8
|
adapter = connection.adapter_name.downcase
|
|
9
9
|
# Skip if all tables already exist to prevent conflicts
|
|
10
|
-
required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries, :rails_pulse_deployments, :rails_pulse_exception_groups, :rails_pulse_exception_occurrences, :rails_pulse_events ]
|
|
10
|
+
required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries, :rails_pulse_deployments, :rails_pulse_exception_groups, :rails_pulse_exception_occurrences, :rails_pulse_events, :rails_pulse_rollups ]
|
|
11
11
|
|
|
12
12
|
# Check which tables already exist
|
|
13
13
|
existing_tables = required_tables.select { |table| connection.table_exists?(table) }
|
|
@@ -245,7 +245,7 @@ RailsPulse::Schema = lambda do |connection|
|
|
|
245
245
|
comment: "FK to the group this occurrence belongs to"
|
|
246
246
|
t.string :exception_class, null: false
|
|
247
247
|
t.text :message
|
|
248
|
-
|
|
248
|
+
t.text :backtrace, comment: "JSON array of {file, line, method} frames (first 50)"
|
|
249
249
|
t.string :request_url, comment: "Nullable — web requests only"
|
|
250
250
|
t.string :request_method, comment: "GET, POST, etc."
|
|
251
251
|
t.string :environment, comment: "production, staging, etc."
|
|
@@ -277,6 +277,38 @@ RailsPulse::Schema = lambda do |connection|
|
|
|
277
277
|
name: "index_rp_events_on_kind_subject_and_occurred_at"
|
|
278
278
|
end
|
|
279
279
|
|
|
280
|
+
unless connection.table_exists?(:rails_pulse_rollups)
|
|
281
|
+
connection.create_table :rails_pulse_rollups do |t|
|
|
282
|
+
# Per-writer per-hour aggregates of the requests and SQL operations that
|
|
283
|
+
# config.store_full_details folded instead of persisting
|
|
284
|
+
t.references :rollupable, polymorphic: true, null: false, index: false, comment: "Link to Route or Query"
|
|
285
|
+
t.datetime :period_start, null: false, comment: "Start of the hour the folded requests occurred in"
|
|
286
|
+
t.string :writer_id, null: false, comment: "host:pid:uuid of the writer incarnation that owns the row"
|
|
287
|
+
|
|
288
|
+
t.integer :count, default: 0, null: false, comment: "Folded requests (route rows) or operations (query rows)"
|
|
289
|
+
t.integer :error_count, default: 0, null: false, comment: "Folded 5xx requests; always 0 on query rows"
|
|
290
|
+
t.integer :status_2xx, default: 0, null: false, comment: "Folded 2xx responses; always 0 on query rows"
|
|
291
|
+
t.integer :status_3xx, default: 0, null: false, comment: "Folded 3xx responses; always 0 on query rows"
|
|
292
|
+
t.integer :status_4xx, default: 0, null: false, comment: "Folded 4xx responses; always 0 on query rows"
|
|
293
|
+
t.integer :status_5xx, default: 0, null: false, comment: "Folded 5xx responses; always 0 on query rows"
|
|
294
|
+
|
|
295
|
+
t.decimal :sum_duration, precision: 20, scale: 6, default: 0, null: false, comment: "Exact sum of folded durations in milliseconds"
|
|
296
|
+
t.decimal :sum_square_duration, precision: 30, scale: 6, default: 0, null: false, comment: "Exact sum of squared durations, for pooled stddev"
|
|
297
|
+
t.decimal :min_duration, precision: 15, scale: 6, comment: "Smallest folded duration in milliseconds"
|
|
298
|
+
t.decimal :max_duration, precision: 15, scale: 6, comment: "Largest folded duration in milliseconds"
|
|
299
|
+
t.text :histogram, comment: "JSON of DurationHistogram bucket index => count, for percentiles"
|
|
300
|
+
|
|
301
|
+
t.timestamps
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# upsert_all relies on MySQL resolving conflicts through any unique key,
|
|
305
|
+
# so this must stay the table's only unique index besides the primary key.
|
|
306
|
+
connection.add_index :rails_pulse_rollups, [ :rollupable_type, :rollupable_id, :period_start, :writer_id ],
|
|
307
|
+
unique: true,
|
|
308
|
+
name: "idx_pulse_rollups_unique"
|
|
309
|
+
connection.add_index :rails_pulse_rollups, :period_start, name: "index_rails_pulse_rollups_on_period_start"
|
|
310
|
+
end
|
|
311
|
+
|
|
280
312
|
# Add indexes to existing tables for efficient aggregation
|
|
281
313
|
unless connection.index_exists?(:rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation")
|
|
282
314
|
connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation"
|
|
@@ -420,6 +420,19 @@ RailsPulse.configure do |config|
|
|
|
420
420
|
# Time-based retention - delete records older than this period
|
|
421
421
|
config.full_retention_period = 30.days
|
|
422
422
|
|
|
423
|
+
# Which requests are stored in full, with every query and render. :all
|
|
424
|
+
# stores every request. At high traffic (millions of requests a day), name
|
|
425
|
+
# a threshold instead: a request is then stored in full only when it is at
|
|
426
|
+
# or above that request threshold, runs a query at or above that query
|
|
427
|
+
# threshold, errors, or falls in the random sample_rate_below (default 1%).
|
|
428
|
+
# Everything else is folded into compact per-route and per-query
|
|
429
|
+
# aggregates, so dashboard counts, error rates and percentiles still cover
|
|
430
|
+
# ALL traffic; the requests list then shows the stored requests only.
|
|
431
|
+
#
|
|
432
|
+
# config.store_full_details = { above_threshold: :slow }
|
|
433
|
+
# config.store_full_details = { above_threshold: :critical, sample_rate_below: 0.001 }
|
|
434
|
+
config.store_full_details = :all
|
|
435
|
+
|
|
423
436
|
# How long rows in rails_pulse_events are kept. The background writer's
|
|
424
437
|
# heartbeats are pruned after a day regardless.
|
|
425
438
|
config.event_retention_period = 90.days
|
|
@@ -109,6 +109,10 @@ module RailsPulse
|
|
|
109
109
|
run_stage(:time_based, :operations) { cleanup_operations_by_time(cutoff_time) }
|
|
110
110
|
run_stage(:time_based, :job_runs) { cleanup_job_runs_by_time(cutoff_time) }
|
|
111
111
|
run_stage(:time_based, :requests) { cleanup_requests_by_time(cutoff_time) }
|
|
112
|
+
# Rollups substitute for the raw rows detail capture never wrote, and
|
|
113
|
+
# BackfillSummariesJob reads them, so they live exactly as long as raw
|
|
114
|
+
# requests do.
|
|
115
|
+
run_stage(:time_based, :rollups) { cleanup_rollups_by_time(cutoff_time) } if rollups_table_exists?
|
|
112
116
|
run_stage(:time_based, :queries) { cleanup_queries_by_time(cutoff_time) }
|
|
113
117
|
run_stage(:time_based, :routes) { cleanup_routes_by_time(cutoff_time) }
|
|
114
118
|
run_stage(:time_based, :jobs) { cleanup_jobs_by_time(cutoff_time) }
|
|
@@ -195,19 +199,25 @@ module RailsPulse
|
|
|
195
199
|
)
|
|
196
200
|
end
|
|
197
201
|
|
|
202
|
+
# A rollup row covers a whole hour, so only hours that ended before the
|
|
203
|
+
# cutoff go: the hour the cutoff falls in may not be summarized yet, and
|
|
204
|
+
# its summary needs every rollup to count the folded traffic.
|
|
205
|
+
def cleanup_rollups_by_time(cutoff_time)
|
|
206
|
+
delete_in_batches(
|
|
207
|
+
RailsPulse::Rollup.where("period_start <= ?", cutoff_time - 1.hour),
|
|
208
|
+
order_column: :period_start
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
|
|
198
212
|
def cleanup_queries_by_time(cutoff_time)
|
|
199
213
|
delete_in_batches(
|
|
200
|
-
|
|
201
|
-
.where("created_at < ?", cutoff_time)
|
|
202
|
-
.where("NOT EXISTS (SELECT 1 FROM rails_pulse_operations WHERE rails_pulse_operations.query_id = rails_pulse_queries.id)")
|
|
214
|
+
orphaned_queries_scope.where("created_at < ?", cutoff_time)
|
|
203
215
|
)
|
|
204
216
|
end
|
|
205
217
|
|
|
206
218
|
def cleanup_routes_by_time(cutoff_time)
|
|
207
219
|
delete_in_batches(
|
|
208
|
-
|
|
209
|
-
.where("created_at < ?", cutoff_time)
|
|
210
|
-
.where("NOT EXISTS (SELECT 1 FROM rails_pulse_requests WHERE rails_pulse_requests.route_id = rails_pulse_routes.id)")
|
|
220
|
+
orphaned_routes_scope.where("created_at < ?", cutoff_time)
|
|
211
221
|
)
|
|
212
222
|
end
|
|
213
223
|
|
|
@@ -282,17 +292,42 @@ module RailsPulse
|
|
|
282
292
|
end
|
|
283
293
|
|
|
284
294
|
def cleanup_queries_by_count
|
|
295
|
+
cleanup_by_count(RailsPulse::Query, :rails_pulse_queries, order_column: :created_at, scope: orphaned_queries_scope)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def cleanup_routes_by_count
|
|
299
|
+
cleanup_by_count(RailsPulse::Route, :rails_pulse_routes, order_column: :created_at, scope: orphaned_routes_scope)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# A route or query with rollup rows is still feeding hourly summaries
|
|
303
|
+
# (detail capture folded its traffic instead of persisting it), so it is
|
|
304
|
+
# not an orphan until its rollups are pruned.
|
|
305
|
+
def orphaned_queries_scope
|
|
285
306
|
scope = RailsPulse::Query.where(
|
|
286
307
|
"NOT EXISTS (SELECT 1 FROM rails_pulse_operations WHERE rails_pulse_operations.query_id = rails_pulse_queries.id)"
|
|
287
308
|
)
|
|
288
|
-
|
|
309
|
+
if rollups_table_exists?
|
|
310
|
+
scope = scope.where(
|
|
311
|
+
"NOT EXISTS (SELECT 1 FROM rails_pulse_rollups WHERE rails_pulse_rollups.rollupable_type = 'RailsPulse::Query' AND rails_pulse_rollups.rollupable_id = rails_pulse_queries.id)"
|
|
312
|
+
)
|
|
313
|
+
end
|
|
314
|
+
scope
|
|
289
315
|
end
|
|
290
316
|
|
|
291
|
-
def
|
|
317
|
+
def orphaned_routes_scope
|
|
292
318
|
scope = RailsPulse::Route.where(
|
|
293
319
|
"NOT EXISTS (SELECT 1 FROM rails_pulse_requests WHERE rails_pulse_requests.route_id = rails_pulse_routes.id)"
|
|
294
320
|
)
|
|
295
|
-
|
|
321
|
+
if rollups_table_exists?
|
|
322
|
+
scope = scope.where(
|
|
323
|
+
"NOT EXISTS (SELECT 1 FROM rails_pulse_rollups WHERE rails_pulse_rollups.rollupable_type = 'RailsPulse::Route' AND rails_pulse_rollups.rollupable_id = rails_pulse_routes.id)"
|
|
324
|
+
)
|
|
325
|
+
end
|
|
326
|
+
scope
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def rollups_table_exists?
|
|
330
|
+
RailsPulse::Rollup.table_available?
|
|
296
331
|
end
|
|
297
332
|
|
|
298
333
|
def cleanup_jobs_by_count
|