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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/app/controllers/rails_pulse/api/v1/base_controller.rb +9 -0
  4. data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +4 -1
  5. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +1 -1
  6. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +1 -1
  7. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +1 -1
  8. data/app/helpers/rails_pulse/application_helper.rb +1 -0
  9. data/app/helpers/rails_pulse/detail_capture_helper.rb +43 -0
  10. data/app/jobs/rails_pulse/summary_job.rb +45 -0
  11. data/app/models/rails_pulse/dashboard/storage_pressure.rb +43 -1
  12. data/app/models/rails_pulse/dashboard/storage_status.rb +9 -1
  13. data/app/models/rails_pulse/operation.rb +45 -19
  14. data/app/models/rails_pulse/rollup.rb +50 -0
  15. data/app/services/rails_pulse/detail_capture_rate.rb +141 -0
  16. data/app/services/rails_pulse/operation_suggestions.rb +3 -0
  17. data/app/services/rails_pulse/query_analysis_service.rb +5 -1
  18. data/app/services/rails_pulse/summary_service/from_raw_rows.rb +160 -10
  19. data/app/views/rails_pulse/components/_panel.html.erb +23 -0
  20. data/app/views/rails_pulse/queries/_diagnostics.html.erb +3 -0
  21. data/app/views/rails_pulse/requests/index.html.erb +4 -1
  22. data/app/views/rails_pulse/routes/show.html.erb +1 -0
  23. data/app/views/rails_pulse/storage/show.html.erb +17 -0
  24. data/db/rails_pulse_migrate/20261007000001_create_rails_pulse_rollups.rb +35 -0
  25. data/db/rails_pulse_schema.rb +34 -2
  26. data/lib/generators/rails_pulse/base_methods.rb +1 -0
  27. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +34 -2
  28. data/lib/generators/rails_pulse/templates/rails_pulse.rb +13 -0
  29. data/lib/rails_pulse/cleanup_service.rb +44 -9
  30. data/lib/rails_pulse/configuration.rb +90 -1
  31. data/lib/rails_pulse/duration_histogram.rb +150 -0
  32. data/lib/rails_pulse/engine.rb +2 -0
  33. data/lib/rails_pulse/middleware/request_collector.rb +88 -21
  34. data/lib/rails_pulse/rollup_accumulator.rb +217 -0
  35. data/lib/rails_pulse/tasks/status_reporter.rb +40 -1
  36. data/lib/rails_pulse/tracker.rb +99 -4
  37. data/lib/rails_pulse/version.rb +1 -1
  38. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  39. 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(Metrics.duration(durations), Metrics.status(statuses)) ]
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
- request_data.slice_when { |previous, current| previous.first != current.first }.map do |rows|
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", rows.first.first).merge(
34
- Metrics.duration(durations),
35
- Metrics.status(statuses),
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).filter_map do |query_id, rows|
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
- row("RailsPulse::Query", query_id).merge(Metrics.duration(durations))
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', { title: 'Requests', } do %>
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
@@ -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
- t.text :backtrace, comment: "JSON array of {file, line, method} frames (first 50)"
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"
@@ -16,6 +16,7 @@ module RailsPulse
16
16
  rails_pulse_exception_groups
17
17
  rails_pulse_exception_occurrences
18
18
  rails_pulse_events
19
+ rails_pulse_rollups
19
20
  ].freeze
20
21
 
21
22
  # Generate next migration number for timestamped migrations
@@ -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
- t.text :backtrace, comment: "JSON array of {file, line, method} frames (first 50)"
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
- RailsPulse::Query
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
- RailsPulse::Route
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
- cleanup_by_count(RailsPulse::Query, :rails_pulse_queries, order_column: :created_at, scope: scope)
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 cleanup_routes_by_count
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
- cleanup_by_count(RailsPulse::Route, :rails_pulse_routes, order_column: :created_at, scope: scope)
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