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