rails_pulse 0.4.2 → 0.5.0.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 (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/README.md +15 -4
  4. data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
  5. data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
  6. data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
  7. data/app/controllers/concerns/chart_table_concern.rb +28 -69
  8. data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
  9. data/app/controllers/concerns/metric_card_concern.rb +6 -7
  10. data/app/controllers/rails_pulse/api/v1/base_controller.rb +130 -0
  11. data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
  12. data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
  13. data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +24 -0
  14. data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +76 -0
  15. data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
  16. data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
  17. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +114 -0
  18. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +58 -0
  19. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +98 -0
  20. data/app/controllers/rails_pulse/application_controller.rb +1 -1
  21. data/app/controllers/rails_pulse/dashboard_controller.rb +20 -20
  22. data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
  23. data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
  24. data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
  25. data/app/controllers/rails_pulse/queries_controller.rb +6 -5
  26. data/app/controllers/rails_pulse/requests_controller.rb +15 -14
  27. data/app/controllers/rails_pulse/routes_controller.rb +4 -8
  28. data/app/helpers/rails_pulse/application_helper.rb +15 -0
  29. data/app/helpers/rails_pulse/chart_helper.rb +9 -2
  30. data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
  31. data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
  32. data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
  33. data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
  34. data/app/models/concerns/rails_pulse/taggable.rb +5 -12
  35. data/app/models/rails_pulse/cards/base.rb +20 -11
  36. data/app/models/rails_pulse/charts/base.rb +2 -6
  37. data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -4
  38. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
  39. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
  40. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +1 -2
  41. data/app/models/rails_pulse/dashboard/health_summary.rb +30 -3
  42. data/app/models/rails_pulse/dashboard/needs_attention.rb +2 -3
  43. data/app/models/rails_pulse/dashboard/storage_pressure.rb +34 -4
  44. data/app/models/rails_pulse/dashboard/storage_status.rb +36 -0
  45. data/app/models/rails_pulse/deployment.rb +2 -7
  46. data/app/models/rails_pulse/event.rb +40 -0
  47. data/app/models/rails_pulse/exception_occurrence.rb +1 -1
  48. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
  49. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
  50. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
  51. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -5
  52. data/app/models/rails_pulse/job_run.rb +1 -1
  53. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
  54. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
  55. data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
  56. data/app/models/rails_pulse/jobs/charts/duration.rb +1 -1
  57. data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
  58. data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -1
  59. data/app/models/rails_pulse/like_pattern.rb +29 -0
  60. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
  61. data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
  62. data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
  63. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
  64. data/app/models/rails_pulse/queries/charts/database_load.rb +5 -6
  65. data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
  66. data/app/models/rails_pulse/request.rb +1 -1
  67. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
  68. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
  69. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
  70. data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
  71. data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
  72. data/app/models/rails_pulse/summary.rb +5 -0
  73. data/app/models/rails_pulse/tables/base.rb +5 -6
  74. data/app/models/rails_pulse/time_range.rb +309 -0
  75. data/app/models/rails_pulse/writer_heartbeat.rb +86 -0
  76. data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +20 -0
  77. data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
  78. data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
  79. data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
  80. data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
  81. data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
  82. data/app/serializers/rails_pulse/api/v1/request_serializer.rb +21 -0
  83. data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
  84. data/app/services/rails_pulse/exception_capture_service.rb +1 -1
  85. data/app/services/rails_pulse/operations/comparison.rb +1 -1
  86. data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
  87. data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
  88. data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
  89. data/app/services/rails_pulse/summary_service.rb +121 -133
  90. data/app/services/rails_pulse/tag_filter_service.rb +5 -5
  91. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
  92. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  93. data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
  94. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
  95. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
  96. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
  97. data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
  98. data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
  99. data/app/views/rails_pulse/jobs/index.html.erb +1 -1
  100. data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
  101. data/app/views/rails_pulse/queries/index.html.erb +1 -1
  102. data/app/views/rails_pulse/queries/show.html.erb +1 -1
  103. data/app/views/rails_pulse/requests/index.html.erb +1 -1
  104. data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
  105. data/app/views/rails_pulse/routes/index.html.erb +1 -1
  106. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  107. data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
  108. data/app/views/rails_pulse/storage/show.html.erb +37 -0
  109. data/config/routes.rb +19 -0
  110. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
  111. data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
  112. data/db/rails_pulse_schema.rb +20 -2
  113. data/exe/rails-pulse +7 -0
  114. data/lib/generators/rails_pulse/base_methods.rb +1 -0
  115. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
  116. data/lib/generators/rails_pulse/templates/rails_pulse.rb +36 -13
  117. data/lib/rails_pulse/cleanup_service.rb +26 -20
  118. data/lib/rails_pulse/cli/agent_files/agents.md +87 -0
  119. data/lib/rails_pulse/cli/agent_files/claude_skill.md +165 -0
  120. data/lib/rails_pulse/cli/base_command.rb +37 -0
  121. data/lib/rails_pulse/cli/client.rb +64 -0
  122. data/lib/rails_pulse/cli/config.rb +87 -0
  123. data/lib/rails_pulse/cli/configure.rb +75 -0
  124. data/lib/rails_pulse/cli/coverage.rb +91 -0
  125. data/lib/rails_pulse/cli/deployments.rb +40 -0
  126. data/lib/rails_pulse/cli/exceptions.rb +111 -0
  127. data/lib/rails_pulse/cli/formatter.rb +31 -0
  128. data/lib/rails_pulse/cli/install.rb +145 -0
  129. data/lib/rails_pulse/cli/job_runs.rb +53 -0
  130. data/lib/rails_pulse/cli/jobs.rb +71 -0
  131. data/lib/rails_pulse/cli/main.rb +72 -0
  132. data/lib/rails_pulse/cli/mcp.rb +47 -0
  133. data/lib/rails_pulse/cli/queries.rb +62 -0
  134. data/lib/rails_pulse/cli/requests.rb +49 -0
  135. data/lib/rails_pulse/cli/routes.rb +66 -0
  136. data/lib/rails_pulse/configuration.rb +23 -1
  137. data/lib/rails_pulse/current.rb +11 -0
  138. data/lib/rails_pulse/engine.rb +12 -3
  139. data/lib/rails_pulse/job_run_collector.rb +15 -15
  140. data/lib/rails_pulse/mcp/server.rb +76 -0
  141. data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
  142. data/lib/rails_pulse/mcp/tools/deployments.rb +80 -0
  143. data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
  144. data/lib/rails_pulse/mcp/tools/errors.rb +95 -0
  145. data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
  146. data/lib/rails_pulse/mcp/tools/exceptions.rb +114 -0
  147. data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
  148. data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
  149. data/lib/rails_pulse/mcp/tools/queries.rb +136 -0
  150. data/lib/rails_pulse/mcp/tools/routes.rb +97 -0
  151. data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
  152. data/lib/rails_pulse/middleware/request_collector.rb +9 -9
  153. data/lib/rails_pulse/scoped_inflector.rb +35 -0
  154. data/lib/rails_pulse/statistics.rb +27 -0
  155. data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
  156. data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
  157. data/lib/rails_pulse/tasks/status_reporter.rb +26 -0
  158. data/lib/rails_pulse/tracker.rb +75 -14
  159. data/lib/rails_pulse/version.rb +1 -1
  160. data/lib/rails_pulse.rb +0 -4
  161. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  162. data/public/rails-pulse-assets/rails-pulse.js +13 -13
  163. metadata +96 -24
  164. data/app/controllers/concerns/page_timings.rb +0 -10
  165. data/app/controllers/concerns/response_range_concern.rb +0 -45
  166. data/app/controllers/concerns/time_range_concern.rb +0 -152
  167. data/app/controllers/concerns/zoom_range_concern.rb +0 -86
@@ -1,6 +1,20 @@
1
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.
2
+ # Aggregates one period (hour, day, week or month) into
3
+ # rails_pulse_summaries rows.
4
+ #
5
+ # Hours are computed from raw rows by FromRawRows. Days are rolled up from
6
+ # their hours, and weeks and months from their days, by FromChildPeriods,
7
+ # so a long period never re-reads raw rows: its cost does not grow with
8
+ # its length, and it stays correct after raw retention has pruned them.
9
+ #
10
+ # Every period gets one overall request row (type RailsPulse::Request,
11
+ # summarizable_id 0), written even when the period is empty (count: 0) so
12
+ # its timestamp keeps advancing every period — and only once the period
13
+ # has ended. It is the heartbeat several
14
+ # health checks use to detect whether SummaryJob is still running
15
+ # (dashboard banner, rails_pulse:status, StoragePressure staleness, and
16
+ # CleanupService's summarized_cutoff), and the marker this service uses to
17
+ # tell whether a child period has been summarized.
4
18
  #
5
19
  # Rows are written with upsert_all against the summaries table's unique
6
20
  # index, one statement per summarizable kind, so re-running a period (the
@@ -10,6 +24,11 @@ module RailsPulse
10
24
  class SummaryService
11
25
  UNIQUE_INDEX = :idx_pulse_summaries_unique
12
26
 
27
+ # The period each longer period is rolled up from. Weeks and months build
28
+ # from days because weeks do not tile months.
29
+ CHILD_PERIOD_TYPES = { "day" => "hour", "week" => "day", "month" => "day" }.freeze
30
+ CHILD_PERIOD_STEPS = { "hour" => 1.hour, "day" => 1.day }.freeze
31
+
13
32
  attr_reader :period_type, :start_time, :end_time
14
33
 
15
34
  def initialize(period_type, start_time)
@@ -19,15 +38,35 @@ module RailsPulse
19
38
  end
20
39
 
21
40
  def perform
41
+ # A period is summarized only once it has ended. The overall request
42
+ # row doubles as the heartbeat that CleanupService's summarized_cutoff
43
+ # and summarize_missing_child_periods trust to mean "fully aggregated";
44
+ # written mid-period, it would let cleanup delete raw rows that were
45
+ # never counted and let a later rollup bake the partial numbers in as
46
+ # final.
47
+ if end_time >= Time.current
48
+ RailsPulse.logger.info "Skipping #{period_type} summary for #{start_time}: period has not ended"
49
+ return
50
+ end
51
+
22
52
  RailsPulse.logger.info "Starting #{period_type} summary for #{start_time}"
23
53
 
54
+ summarize_missing_child_periods if rollup?
55
+
56
+ # Rows are computed before the transaction opens. A busy period's
57
+ # aggregation can take seconds; doing it with the transaction already
58
+ # open leaves it idle from the database's perspective and vulnerable to
59
+ # a configured idle_in_transaction_session_timeout on installs that set
60
+ # one.
61
+ rows = summary_rows
62
+
24
63
  # The engine's own connection: on a separate-database install
25
64
  # ActiveRecord::Base would open the transaction on the host's primary.
26
65
  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
66
+ upsert_summaries(rows[:requests_and_routes])
67
+ upsert_summaries(rows[:queries])
68
+ upsert_summaries(rows[:jobs])
69
+ upsert_exception_summaries(rows[:exceptions])
31
70
  end
32
71
 
33
72
  RailsPulse.logger.info "Completed #{period_type} summary"
@@ -38,126 +77,42 @@ module RailsPulse
38
77
 
39
78
  private
40
79
 
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?
46
-
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
52
-
53
- def summary_row(summarizable_type, summarizable_id)
54
- {
55
- summarizable_type: summarizable_type,
56
- summarizable_id: summarizable_id,
57
- period_type: period_type,
58
- period_start: start_time,
59
- period_end: end_time
60
- }
80
+ def rollup?
81
+ CHILD_PERIOD_TYPES.key?(period_type)
61
82
  end
62
83
 
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
67
-
68
- {
69
- count: durations.size,
70
- avg_duration: avg,
71
- min_duration: durations.first,
72
- max_duration: durations.last,
73
- total_duration: durations.sum,
74
- p50_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.5),
75
- p95_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.95),
76
- p99_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.99),
77
- stddev_duration: RailsPulse::Statistics.calculate_stddev(durations, avg)
78
- }
84
+ def source
85
+ @source ||=
86
+ if rollup?
87
+ FromChildPeriods.new(child_period_type, child_period_starts)
88
+ else
89
+ FromRawRows.new(start_time...end_time)
90
+ end
79
91
  end
80
92
 
81
- def status_metrics(statuses)
93
+ def summary_rows
82
94
  {
83
- error_count: statuses.count { |s| s >= 500 },
84
- success_count: statuses.count { |s| s < 500 },
85
- status_2xx: statuses.count { |s| s.between?(200, 299) },
86
- status_3xx: statuses.count { |s| s.between?(300, 399) },
87
- status_4xx: statuses.count { |s| s.between?(400, 499) },
88
- status_5xx: statuses.count { |s| s >= 500 }
95
+ requests_and_routes: with_period(source.request_rows + source.route_rows),
96
+ queries: with_period(source.query_rows),
97
+ jobs: with_period(source.job_rows),
98
+ exceptions: with_period(exception_rows)
89
99
  }
90
100
  end
91
101
 
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)) ]
102
+ def with_period(rows)
103
+ rows.map { |row| row.merge(period_type: period_type, period_start: start_time, period_end: end_time) }
104
104
  end
105
105
 
106
- def route_summary_rows
107
- all_rows = Request
108
- .where(occurred_at: start_time...end_time)
109
- .where.not(route_id: nil)
110
- .pluck(:route_id, :duration, :status)
111
-
112
- all_rows.group_by(&:first).map do |route_id, rows|
113
- durations = rows.map { |_, d, _| d }.compact.sort
114
- statuses = rows.map { |_, _, s| s }
115
-
116
- summary_row("RailsPulse::Route", route_id)
117
- .merge(duration_metrics(durations), status_metrics(statuses), count: rows.size)
118
- end
119
- end
120
-
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
106
+ # Every row in one call must have the same keys, which is why the
107
+ # summarizable kinds are upserted separately: request and route rows carry
108
+ # status columns, query rows do not, exception rows carry only a count.
109
+ def upsert_summaries(rows)
110
+ return if rows.empty?
134
111
 
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
146
-
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
- )
160
- end
112
+ # MySQL resolves the conflict through any unique key and rejects an
113
+ # explicit target.
114
+ unique_by = Summary.connection.supports_insert_conflict_target? ? UNIQUE_INDEX : nil
115
+ Summary.upsert_all(rows, unique_by: unique_by)
161
116
  end
162
117
 
163
118
  # Exception frequency, per group and overall.
@@ -167,28 +122,61 @@ module RailsPulse
167
122
  # something happened last week — the history is gone as soon as cleanup
168
123
  # runs. Only `count` is meaningful here; the duration columns stay null
169
124
  # because an exception has no duration.
170
- def aggregate_exceptions
171
- return unless RailsPulse.configuration.track_exceptions
172
- return unless ExceptionOccurrence.table_exists?
173
-
174
- counts = ExceptionOccurrence
175
- .where(occurred_at: start_time...end_time)
176
- .group(:exception_group_id)
177
- .count
178
-
179
- return if counts.empty?
180
-
181
- rows = counts.map { |group_id, occurrences| summary_row("RailsPulse::ExceptionGroup", group_id).merge(count: occurrences) }
182
- # A rollup across every group, so the dashboard can chart total exception
183
- # volume without loading one series per group.
184
- rows << summary_row("RailsPulse::ExceptionGroup", 0).merge(count: counts.values.sum)
125
+ #
126
+ # Exceptions are the newest summarizable and the only optional one, so
127
+ # both the query here and the upsert in upsert_exception_summaries are
128
+ # individually rescued: a failure in either must not lose the route,
129
+ # query and job summaries computed or written alongside it.
130
+ def exception_rows
131
+ return [] unless RailsPulse.configuration.track_exceptions
132
+ return [] unless ExceptionOccurrence.table_exists?
133
+
134
+ source.exception_rows
135
+ rescue ActiveRecord::ActiveRecordError => e
136
+ RailsPulse.logger.error "Exception summary skipped: #{e.message}"
137
+ []
138
+ end
185
139
 
140
+ def upsert_exception_summaries(rows)
186
141
  upsert_summaries(rows)
187
142
  rescue ActiveRecord::ActiveRecordError => e
188
- # Exceptions are the newest summarizable and the only optional one. A
189
- # failure here must not lose the route, query and job summaries that were
190
- # written in the same transaction.
191
143
  RailsPulse.logger.error "Exception summary skipped: #{e.message}"
192
144
  end
145
+
146
+ def child_period_type
147
+ CHILD_PERIOD_TYPES.fetch(period_type)
148
+ end
149
+
150
+ # Every child boundary inside this period. perform refuses periods that
151
+ # have not ended, so a rollup's children have all ended too.
152
+ def child_period_starts
153
+ @child_period_starts ||= begin
154
+ step = CHILD_PERIOD_STEPS.fetch(child_period_type)
155
+ starts = []
156
+ current = start_time
157
+ while current <= end_time
158
+ starts << current
159
+ current += step
160
+ end
161
+ starts
162
+ end
163
+ end
164
+
165
+ # A child period without its heartbeat row was never summarized
166
+ # (SummaryJob was not running, or a backfill asked only for the longer
167
+ # period), so it is summarized now, recursively, before being rolled up.
168
+ def summarize_missing_child_periods
169
+ summarized = Summary
170
+ .where(summarizable_type: "RailsPulse::Request", summarizable_id: 0, period_type: child_period_type)
171
+ .where(period_start: start_time..end_time)
172
+ .pluck(:period_start)
173
+ .to_set(&:to_i)
174
+
175
+ child_period_starts.each do |child_start|
176
+ next if summarized.include?(child_start.to_i)
177
+
178
+ SummaryService.new(child_period_type, child_start).perform
179
+ end
180
+ end
193
181
  end
194
182
  end
@@ -17,12 +17,12 @@ module RailsPulse
17
17
  def self.scope(model_class, disabled_tags, show_non_tagged)
18
18
  relation = model_class.all
19
19
 
20
- # Exclude items with disabled tags. Explicit ESCAPE: SQLite has no
21
- # default escape character, so without it an underscore in a tag is
22
- # never matched literally there and the exclusion matches nothing.
20
+ # Exclude items with disabled tags.
23
21
  disabled_tags.each do |tag|
24
- sanitized_tag = ActiveRecord::Base.sanitize_sql_like(tag.to_s, RailsPulse::Taggable::LIKE_ESCAPE)
25
- relation = relation.where.not("tags LIKE ? ESCAPE '!'", "%#{sanitized_tag}%")
22
+ relation = relation.where.not(
23
+ "tags LIKE ? #{RailsPulse::LikePattern::CLAUSE}",
24
+ RailsPulse::LikePattern.containing(tag)
25
+ )
26
26
  end
27
27
 
28
28
  # Exclude non-tagged items if show_non_tagged is false
@@ -136,7 +136,10 @@ end %>
136
136
  <div class="flex items-start justify-between">
137
137
  <div>
138
138
  <h2 class="text-lg font-semibold mbe-4">Custom Date Range</h2>
139
- <p class="text-sm text-subtle">Select a custom date and time range for filtering data.</p>
139
+ <p class="text-sm text-subtle">
140
+ Select a custom date and time range for filtering data. All times are in
141
+ <%= RailsPulse::TimeRange.aggregation_zone_label %>.
142
+ </p>
140
143
  </div>
141
144
  <%= link_to '#', "aria-label": "Close", data: { action: "click->rails-pulse--time-range#cancelCustom" }, class: "text-subtle hover:text-default" do %>
142
145
  <%= rails_pulse_icon "x", width: "20" %>
@@ -26,7 +26,7 @@
26
26
  <div>
27
27
  <h4 class="text-xs font-medium text-subtle uppercase">Occurred At</h4>
28
28
  <div class="text-sm">
29
- <%= operation.occurred_at.getlocal.strftime("%H:%M:%S.%L") %>
29
+ <%= operation.occurred_at.strftime("%H:%M:%S.%L") %>
30
30
  </div>
31
31
  </div>
32
32
  <% end %>
@@ -6,8 +6,8 @@
6
6
  <%= column[:label] %>
7
7
  <% else %>
8
8
  <% sort_params = {} %>
9
- <% sort_params[:zoom_start_time] = @zoom_start if @zoom_start.present? %>
10
- <% sort_params[:zoom_end_time] = @zoom_end if @zoom_end.present? %>
9
+ <% sort_params[:zoom_start_time] = @time_range&.zoom_start if @time_range&.zoom_start.present? %>
10
+ <% sort_params[:zoom_end_time] = @time_range&.zoom_end if @time_range&.zoom_end.present? %>
11
11
  <% sort_params[:selected_column_time] = params[:selected_column_time] if params[:selected_column_time].present? %>
12
12
  <%= sort_link @ransack_query, column[:field], column[:label],
13
13
  sort_params.merge(
@@ -1,7 +1,12 @@
1
1
  <div class="grid-item" style="<%= flex_style if defined?(flex_style) %>">
2
2
  <div class="card b-full flex flex-col">
3
3
  <div class="flex mbe-2">
4
- <h2 class="grow font-semibold leading-none uppercase text-xs"><%= title %></h2>
4
+ <h2 class="grow font-semibold leading-none uppercase text-xs">
5
+ <%= title %>
6
+ <% if chart_data %>
7
+ · <%= render "rails_pulse/shared/aggregation_zone_badge" %>
8
+ <% end %>
9
+ </h2>
5
10
  <div class="panel-icon-bar flex gap items-center">
6
11
  <% if chart_data %>
7
12
  <%= series_toggles %>
@@ -34,7 +34,7 @@
34
34
  <div>
35
35
  <p class="font-semibold uppercase text-xs text-subtle mbe-1">In this range</p>
36
36
  <p class="text-lg font-bold"><%= number_with_delimiter(@deployment_count) %></p>
37
- <p class="text-xs text-subtle"><%= humanize_time_range(@selected_time_range) %></p>
37
+ <p class="text-xs text-subtle"><%= humanize_time_range(@time_range.selected_time_range) %></p>
38
38
  </div>
39
39
  </div>
40
40
 
@@ -55,7 +55,7 @@
55
55
  <% end %>
56
56
  <% else %>
57
57
  <p class="text-sm text-subtle">
58
- No deployments recorded in the <%= humanize_time_range(@selected_time_range) %>.
58
+ No deployments recorded in the <%= humanize_time_range(@time_range.selected_time_range) %>.
59
59
  Post to the deployments endpoint from your deploy pipeline, or run
60
60
  <code>rails_pulse:record_deployment</code>, and they will appear here and as markers on the charts.
61
61
  </p>
@@ -1,23 +1,32 @@
1
1
  <%# Exceptions do not have a "slow" state — a group that fired at all is
2
2
  firing — so the two non-critical labels are overridable. %>
3
- <% healthy_label = local_assigns.fetch(:healthy_label, "healthy") %>
4
- <% slow_label = local_assigns.fetch(:slow_label, "slow") %>
5
- <% dot_color = health[:critical] > 0 ? "var(--color-negative)" : (health[:slow] > 0 ? "#eab308" : "var(--color-positive)") %>
6
- <% border_color = health[:critical] > 0 ? "color-mix(in srgb, var(--color-negative) 40%, transparent)" : (health[:slow] > 0 ? "color-mix(in srgb, #eab308 40%, transparent)" : "color-mix(in srgb, var(--color-positive) 40%, transparent)") %>
7
- <% badge = capture do %>
8
- <span class="badge flex items-center gap-half p-2" style="border-color: <%= border_color %>;">
9
- <span class="shrink-0 rounded-full" style="width: 10px; height: 10px; background: <%= dot_color %>;"></span>
10
- <span class="text-xs font-semibold"><%= name %></span>
11
- <% if health[:critical] > 0 %>
12
- <span class="text-xs text-subtle"><%= health[:healthy] %> <%= healthy_label %> · <%= health[:slow] %> <%= slow_label %> ·</span>
13
- <span class="text-xs font-semibold text-negative"><%= health[:critical] %> <%= critical_label %></span>
14
- <% else %>
15
- <span class="text-xs text-subtle"><%= health[:healthy] %> <%= healthy_label %> · <%= health[:slow] %> <%= slow_label %> · 0 <%= critical_label %></span>
16
- <% end %>
17
- </span>
18
- <% end %>
19
- <% if local_assigns[:link_url].present? %>
20
- <%= link_to link_url, class: "health-badge-link" do %><%= badge %><% end %>
21
- <% else %>
22
- <%= badge %>
3
+ <% hide_when_healthy = local_assigns.fetch(:hide_when_healthy, false) %>
4
+ <% is_healthy = health[:critical].zero? && health[:slow].zero? %>
5
+ <% unless hide_when_healthy && is_healthy %>
6
+ <% healthy_label = local_assigns.fetch(:healthy_label, "healthy") %>
7
+ <% slow_label = local_assigns.fetch(:slow_label, "slow") %>
8
+ <% dot_color = health[:critical] > 0 ? "var(--color-negative)" : (health[:slow] > 0 ? "#eab308" : "var(--color-positive)") %>
9
+ <% border_color = health[:critical] > 0 ? "color-mix(in srgb, var(--color-negative) 40%, transparent)" : (health[:slow] > 0 ? "color-mix(in srgb, #eab308 40%, transparent)" : "color-mix(in srgb, var(--color-positive) 40%, transparent)") %>
10
+ <% subtle_parts = [] %>
11
+ <% subtle_parts << "#{health[:healthy]} #{healthy_label}" if health[:healthy] > 0 %>
12
+ <% subtle_parts << "#{health[:slow]} #{slow_label}" if health[:slow] > 0 %>
13
+ <% subtle_parts << "0 #{healthy_label}" if subtle_parts.empty? && health[:critical].zero? %>
14
+ <% critical_part = "#{health[:critical]} #{critical_label}" if health[:critical] > 0 %>
15
+ <% badge = capture do %>
16
+ <span class="badge flex items-center gap-half p-2" style="border-color: <%= border_color %>;">
17
+ <span class="shrink-0 rounded-full" style="width: 10px; height: 10px; background: <%= dot_color %>;"></span>
18
+ <span class="text-xs font-semibold"><%= name %></span>
19
+ <% if subtle_parts.any? %>
20
+ <span class="text-xs text-subtle"><%= subtle_parts.join(" · ") %><%= " ·" if critical_part %></span>
21
+ <% end %>
22
+ <% if critical_part %>
23
+ <span class="text-xs font-semibold text-negative"><%= critical_part %></span>
24
+ <% end %>
25
+ </span>
26
+ <% end %>
27
+ <% if local_assigns[:link_url].present? %>
28
+ <%= link_to link_url, class: "health-badge-link" do %><%= badge %><% end %>
29
+ <% else %>
30
+ <%= badge %>
31
+ <% end %>
23
32
  <% end %>
@@ -29,10 +29,24 @@
29
29
  critical_label: "critical",
30
30
  link_url: exceptions_path %>
31
31
  <% end %>
32
+ <%# Nil until a background writer has reported a heartbeat. Shown only
33
+ when a writer is backlogged or dropping requests. %>
34
+ <% if @health_summary[:tracking] %>
35
+ <%= render "rails_pulse/dashboard/health_badge",
36
+ name: "Tracking",
37
+ health: @health_summary[:tracking],
38
+ healthy_label: "writing",
39
+ slow_label: "backlogged",
40
+ critical_label: "dropping",
41
+ hide_when_healthy: true,
42
+ link_url: storage_path %>
43
+ <% end %>
44
+ <%# Shown only when storage is under warning or critical pressure. %>
32
45
  <%= render "rails_pulse/dashboard/health_badge",
33
46
  name: "Storage",
34
47
  health: @health_summary[:storage],
35
48
  critical_label: "critical",
49
+ hide_when_healthy: true,
36
50
  link_url: storage_path %>
37
51
  </div>
38
52
  </div>
@@ -130,7 +144,7 @@
130
144
  </span>
131
145
  <% end %>
132
146
  <span class="text-xs text-subtle mis-2">Ranked by severity ·
133
- <%= humanize_time_range(@selected_time_range) %></span>
147
+ <%= humanize_time_range(@time_range.selected_time_range) %></span>
134
148
  </div>
135
149
  <div
136
150
  data-controller="rails-pulse--popover"
@@ -179,7 +193,7 @@
179
193
  </span>
180
194
  <p class="font-semibold text-sm text-positive">All clear</p>
181
195
  <p class="text-xs text-subtle">No routes, queries, or jobs need attention in the
182
- <%= humanize_time_range(@selected_time_range) %>.</p>
196
+ <%= humanize_time_range(@time_range.selected_time_range) %>.</p>
183
197
  </div>
184
198
  <% else %>
185
199
  <div class="flex flex-col">
@@ -56,6 +56,7 @@
56
56
  </div>
57
57
  <%= render "rails_pulse/shared/deployment_markers_toggle" %>
58
58
  </div>
59
+ <%= render "rails_pulse/shared/aggregation_zone_badge" %>
59
60
  </div>
60
61
 
61
62
  <!-- Duration Chart -->
@@ -29,7 +29,7 @@
29
29
  %>
30
30
  <%= form.select :avg_duration,
31
31
  duration_options(:job),
32
- { selected: @selected_response_range },
32
+ { selected: @time_range&.selected_response_range },
33
33
  { class: "input" }
34
34
  %>
35
35
  <%= if params.has_key?(:q) || params[:queue_name].present?
@@ -56,6 +56,7 @@
56
56
  </div>
57
57
  <%= render "rails_pulse/shared/deployment_markers_toggle" %>
58
58
  </div>
59
+ <%= render "rails_pulse/shared/aggregation_zone_badge" %>
59
60
  </div>
60
61
 
61
62
  <!-- Query Performance Chart -->
@@ -26,7 +26,7 @@
26
26
  >
27
27
  <%= form.select :avg_duration,
28
28
  duration_options(:query),
29
- { selected: @selected_response_range },
29
+ { selected: @time_range&.selected_response_range },
30
30
  { class: "input" } %>
31
31
  <%= if params.has_key?(:q)
32
32
  link_to "Reset",
@@ -24,7 +24,7 @@
24
24
  >
25
25
  <%= form.select :duration,
26
26
  duration_options(:query),
27
- { selected: @selected_response_range },
27
+ { selected: @time_range&.selected_response_range },
28
28
  { class: "input" }
29
29
  %>
30
30
  <%= link_to "Reset", query_path(@query), class: "btn btn--plain", style: "text-decoration: none; color: var(--color-text-muted);" if params.has_key?(:q) %>
@@ -23,7 +23,7 @@
23
23
 
24
24
  <%= form.select :duration_gteq,
25
25
  duration_options(:request),
26
- { selected: @selected_response_range, prompt: "Min duration" },
26
+ { selected: @time_range&.selected_response_range, prompt: "Min duration" },
27
27
  { class: "input" }
28
28
  %>
29
29
 
@@ -82,6 +82,7 @@
82
82
  </div>
83
83
  <%= render "rails_pulse/shared/deployment_markers_toggle" %>
84
84
  </div>
85
+ <%= render "rails_pulse/shared/aggregation_zone_badge" %>
85
86
  </div>
86
87
  <!-- Response Time Chart -->
87
88
  <div
@@ -34,7 +34,7 @@
34
34
  class: "input" %>
35
35
  <%= form.select :avg_duration,
36
36
  duration_options(:route),
37
- { selected: @selected_response_range },
37
+ { selected: @time_range&.selected_response_range },
38
38
  { class: "input" } %>
39
39
  <%= if params.has_key?(:q)
40
40
  link_to "Reset",
@@ -26,7 +26,7 @@
26
26
  >
27
27
  <%= form.select :avg_duration,
28
28
  duration_options(:route),
29
- { selected: @selected_response_range },
29
+ { selected: @time_range&.selected_response_range },
30
30
  { class: "input" }
31
31
  %>
32
32
  <%= form.select :http_method_eq,
@@ -0,0 +1,4 @@
1
+ <%# Zone every chart on the page is bucketed and labelled in. The short
2
+ label is always visible; the title carries the full zone name and, when
3
+ a controller resolved one, the exact window being shown. %>
4
+ <span class="zone-badge" title="<%= time_range_zone_tooltip %>"><%= RailsPulse::TimeRange.aggregation_zone_short_label %></span>
@@ -120,4 +120,41 @@
120
120
  <% end %>
121
121
  </dl>
122
122
  <% end %>
123
+
124
+ <%= render "rails_pulse/components/panel",
125
+ title: "Tracking writers",
126
+ help_heading: "The background writer",
127
+ help_text: "Each process queues tracked requests for one writer thread, which reports a heartbeat every minute. When a queue is full the newest request is dropped rather than slowing the app; dropped requests mean the charts undercount traffic. Raise async_queue_size or fix database latency." do %>
128
+ <% tracking = @status.tracking %>
129
+ <dl class="descriptive-list mbs-4">
130
+ <dt>Live writers</dt>
131
+ <dd><%= tracking[:live_count] %></dd>
132
+ <dt>Queue depth</dt>
133
+ <dd><%= number_with_delimiter(tracking[:queue_depth]) %><% if tracking[:queue_size].to_i.positive? %> <span class="text-subtle">/ <%= number_with_delimiter(tracking[:queue_size]) %> per process</span><% end %></dd>
134
+ <dt>Dropped in the last hour</dt>
135
+ <dd class="<%= tracking[:dropped].to_i.positive? ? "text-negative font-semibold" : "" %>"><%= number_with_delimiter(tracking[:dropped]) %></dd>
136
+ <dt>Last heartbeat</dt>
137
+ <dd>
138
+ <% if tracking[:last_sampled_at] %>
139
+ <%= human_readable_occurred_at(tracking[:last_sampled_at]) %>
140
+ <span class="text-subtle">(<%= time_ago_in_words(tracking[:last_sampled_at]) %>)</span>
141
+ <% else %>
142
+ Never — heartbeats start when a process with <code>async</code> on tracks its first request
143
+ <% end %>
144
+ </dd>
145
+ <% tracking[:processes].each do |process| %>
146
+ <dt><code><%= process[:label] %></code></dt>
147
+ <dd>
148
+ <%= number_with_delimiter(process[:queue_depth]) %> / <%= number_with_delimiter(process[:queue_size]) %> queued
149
+ <% if process[:dropped_last_hour].positive? %>
150
+ <span class="badge storage-table__percent storage-table__percent--critical"><%= number_with_delimiter(process[:dropped_last_hour]) %> dropped</span>
151
+ <% else %>
152
+ <span class="badge storage-table__percent storage-table__percent--uncapped">0 dropped</span>
153
+ <% end %>
154
+ <br>
155
+ <span class="text-subtle">last heartbeat <%= time_ago_in_words(process[:last_seen_at]) %></span>
156
+ </dd>
157
+ <% end %>
158
+ </dl>
159
+ <% end %>
123
160
  </div>