rails_pulse 0.4.2 → 0.5.0.pre.2

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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -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 +133 -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 +40 -0
  14. data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +77 -0
  15. data/app/controllers/rails_pulse/api/v1/insights_controller.rb +62 -0
  16. data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
  17. data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
  18. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +123 -0
  19. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +74 -0
  20. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +103 -0
  21. data/app/controllers/rails_pulse/application_controller.rb +1 -1
  22. data/app/controllers/rails_pulse/dashboard_controller.rb +25 -21
  23. data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
  24. data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
  25. data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
  26. data/app/controllers/rails_pulse/queries_controller.rb +6 -5
  27. data/app/controllers/rails_pulse/requests_controller.rb +15 -14
  28. data/app/controllers/rails_pulse/routes_controller.rb +4 -8
  29. data/app/helpers/rails_pulse/application_helper.rb +15 -0
  30. data/app/helpers/rails_pulse/chart_helper.rb +9 -2
  31. data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
  32. data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
  33. data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
  34. data/app/jobs/rails_pulse/cloud_health_job.rb +11 -0
  35. data/app/jobs/rails_pulse/cloud_sync_job.rb +11 -0
  36. data/app/jobs/rails_pulse/summary_job.rb +13 -0
  37. data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
  38. data/app/models/concerns/rails_pulse/taggable.rb +5 -12
  39. data/app/models/rails_pulse/cards/base.rb +20 -11
  40. data/app/models/rails_pulse/charts/base.rb +5 -18
  41. data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -5
  42. data/app/models/rails_pulse/cleanup_run.rb +32 -0
  43. data/app/models/rails_pulse/cloud/buffered_batch.rb +55 -0
  44. data/app/models/rails_pulse/cloud/installation.rb +55 -0
  45. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
  46. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
  47. data/app/models/rails_pulse/dashboard/concerns/attention_classification.rb +104 -0
  48. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +25 -2
  49. data/app/models/rails_pulse/dashboard/health_summary.rb +38 -13
  50. data/app/models/rails_pulse/dashboard/needs_attention.rb +31 -98
  51. data/app/models/rails_pulse/dashboard/storage_pressure.rb +39 -63
  52. data/app/models/rails_pulse/dashboard/storage_status.rb +130 -39
  53. data/app/models/rails_pulse/deployment.rb +2 -7
  54. data/app/models/rails_pulse/event.rb +41 -0
  55. data/app/models/rails_pulse/exception_occurrence.rb +1 -1
  56. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
  57. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
  58. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
  59. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -7
  60. data/app/models/rails_pulse/job_run.rb +1 -1
  61. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
  62. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
  63. data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
  64. data/app/models/rails_pulse/jobs/charts/duration.rb +1 -2
  65. data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
  66. data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -2
  67. data/app/models/rails_pulse/like_pattern.rb +35 -0
  68. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
  69. data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
  70. data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
  71. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
  72. data/app/models/rails_pulse/queries/charts/database_load.rb +5 -8
  73. data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
  74. data/app/models/rails_pulse/request.rb +1 -1
  75. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
  76. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
  77. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
  78. data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
  79. data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
  80. data/app/models/rails_pulse/summary.rb +5 -0
  81. data/app/models/rails_pulse/tables/base.rb +5 -6
  82. data/app/models/rails_pulse/time_range.rb +309 -0
  83. data/app/models/rails_pulse/time_window.rb +15 -0
  84. data/app/models/rails_pulse/writer_heartbeat.rb +90 -0
  85. data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +23 -0
  86. data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
  87. data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
  88. data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
  89. data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
  90. data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
  91. data/app/serializers/rails_pulse/api/v1/request_serializer.rb +22 -0
  92. data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
  93. data/app/services/rails_pulse/cloud/batch.rb +83 -0
  94. data/app/services/rails_pulse/cloud/client.rb +68 -0
  95. data/app/services/rails_pulse/cloud/combined_metrics.rb +49 -0
  96. data/app/services/rails_pulse/cloud/health_item.rb +77 -0
  97. data/app/services/rails_pulse/cloud/path_prefix.rb +50 -0
  98. data/app/services/rails_pulse/cloud/preview.rb +83 -0
  99. data/app/services/rails_pulse/cloud/query_shape.rb +195 -0
  100. data/app/services/rails_pulse/cloud/record_items.rb +60 -0
  101. data/app/services/rails_pulse/cloud/route_pattern.rb +111 -0
  102. data/app/services/rails_pulse/cloud/summary_items.rb +188 -0
  103. data/app/services/rails_pulse/cloud/sync.rb +275 -0
  104. data/app/services/rails_pulse/config_recommendations.rb +132 -0
  105. data/app/services/rails_pulse/deployment_comparison.rb +177 -0
  106. data/app/services/rails_pulse/exception_capture_service.rb +1 -1
  107. data/app/services/rails_pulse/operations/change_point.rb +1 -1
  108. data/app/services/rails_pulse/operations/comparison.rb +1 -1
  109. data/app/services/rails_pulse/period_insights.rb +131 -0
  110. data/app/services/rails_pulse/sql_query_normalizer.rb +222 -122
  111. data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
  112. data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
  113. data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
  114. data/app/services/rails_pulse/summary_service.rb +121 -133
  115. data/app/services/rails_pulse/tag_filter_service.rb +5 -5
  116. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
  117. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  118. data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
  119. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
  120. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
  121. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
  122. data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +13 -3
  123. data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
  124. data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
  125. data/app/views/rails_pulse/jobs/index.html.erb +1 -1
  126. data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
  127. data/app/views/rails_pulse/queries/index.html.erb +1 -1
  128. data/app/views/rails_pulse/queries/show.html.erb +1 -1
  129. data/app/views/rails_pulse/requests/index.html.erb +1 -1
  130. data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
  131. data/app/views/rails_pulse/routes/index.html.erb +1 -1
  132. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  133. data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
  134. data/app/views/rails_pulse/storage/show.html.erb +39 -2
  135. data/config/routes.rb +23 -0
  136. data/db/rails_pulse_cloud_schema.rb +58 -0
  137. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
  138. data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
  139. data/db/rails_pulse_schema.rb +20 -2
  140. data/exe/rails-pulse +7 -0
  141. data/lib/generators/rails_pulse/base_methods.rb +19 -0
  142. data/lib/generators/rails_pulse/install_cloud_generator.rb +84 -0
  143. data/lib/generators/rails_pulse/templates/db/rails_pulse_cloud_schema.rb +58 -0
  144. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
  145. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_cloud_tables.rb +30 -0
  146. data/lib/generators/rails_pulse/templates/rails_pulse.rb +71 -19
  147. data/lib/generators/rails_pulse/upgrade_generator.rb +9 -16
  148. data/lib/rails_pulse/cleanup_service.rb +37 -20
  149. data/lib/rails_pulse/cli/agent_files/agents.md +89 -0
  150. data/lib/rails_pulse/cli/agent_files/claude_skill.md +182 -0
  151. data/lib/rails_pulse/cli/base_command.rb +37 -0
  152. data/lib/rails_pulse/cli/client.rb +64 -0
  153. data/lib/rails_pulse/cli/config.rb +87 -0
  154. data/lib/rails_pulse/cli/configure.rb +75 -0
  155. data/lib/rails_pulse/cli/coverage.rb +91 -0
  156. data/lib/rails_pulse/cli/deployments.rb +54 -0
  157. data/lib/rails_pulse/cli/exceptions.rb +116 -0
  158. data/lib/rails_pulse/cli/formatter.rb +31 -0
  159. data/lib/rails_pulse/cli/insights.rb +70 -0
  160. data/lib/rails_pulse/cli/install.rb +145 -0
  161. data/lib/rails_pulse/cli/job_runs.rb +53 -0
  162. data/lib/rails_pulse/cli/jobs.rb +71 -0
  163. data/lib/rails_pulse/cli/main.rb +76 -0
  164. data/lib/rails_pulse/cli/mcp.rb +47 -0
  165. data/lib/rails_pulse/cli/queries.rb +67 -0
  166. data/lib/rails_pulse/cli/requests.rb +57 -0
  167. data/lib/rails_pulse/cli/routes.rb +73 -0
  168. data/lib/rails_pulse/configuration.rb +100 -2
  169. data/lib/rails_pulse/current.rb +11 -0
  170. data/lib/rails_pulse/engine.rb +12 -3
  171. data/lib/rails_pulse/installers/config_updater.rb +9 -2
  172. data/lib/rails_pulse/job_run_collector.rb +15 -15
  173. data/lib/rails_pulse/mcp/server.rb +78 -0
  174. data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
  175. data/lib/rails_pulse/mcp/tools/deployments.rb +117 -0
  176. data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
  177. data/lib/rails_pulse/mcp/tools/errors.rb +104 -0
  178. data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
  179. data/lib/rails_pulse/mcp/tools/exceptions.rb +128 -0
  180. data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
  181. data/lib/rails_pulse/mcp/tools/insights.rb +99 -0
  182. data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
  183. data/lib/rails_pulse/mcp/tools/queries.rb +142 -0
  184. data/lib/rails_pulse/mcp/tools/routes.rb +108 -0
  185. data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
  186. data/lib/rails_pulse/middleware/request_collector.rb +9 -9
  187. data/lib/rails_pulse/scoped_inflector.rb +35 -0
  188. data/lib/rails_pulse/statistics.rb +27 -0
  189. data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
  190. data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
  191. data/lib/rails_pulse/tasks/status_reporter.rb +201 -0
  192. data/lib/rails_pulse/tracker.rb +76 -14
  193. data/lib/rails_pulse/version.rb +1 -1
  194. data/lib/rails_pulse.rb +0 -4
  195. data/lib/tasks/rails_pulse.rake +6 -0
  196. data/lib/tasks/rails_pulse_tasks.rake +7 -0
  197. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  198. data/public/rails-pulse-assets/rails-pulse.js +13 -13
  199. metadata +123 -24
  200. data/app/controllers/concerns/page_timings.rb +0 -10
  201. data/app/controllers/concerns/response_range_concern.rb +0 -45
  202. data/app/controllers/concerns/time_range_concern.rb +0 -152
  203. data/app/controllers/concerns/zoom_range_concern.rb +0 -86
@@ -0,0 +1,35 @@
1
+ module RailsPulse
2
+ # Zeitwerk inflector that pins the engine's own file names and defers
3
+ # everything else to the host's inflector.
4
+ #
5
+ # Rails' autoloader inflector is one map for the whole application, keyed
6
+ # only on a file's basename. Registering "api" => "Api" there would also
7
+ # rename a host's app/controllers/api/ directory, and a host that declares
8
+ # `inflect.acronym "API"` would then fail to find API::V1::... Checking the
9
+ # absolute path keeps the overrides inside the engine.
10
+ class ScopedInflector
11
+ # @param fallback [#camelize] the inflector the autoloader had before
12
+ # @param root [String] absolute path whose files the overrides apply to
13
+ # @param overrides [Hash{String => String}] basename => constant name
14
+ def initialize(fallback, root:, overrides:)
15
+ @fallback = fallback
16
+ @root = File.join(root.to_s, "")
17
+ @overrides = overrides
18
+ end
19
+
20
+ def camelize(basename, abspath)
21
+ if abspath.to_s.start_with?(@root) && @overrides.key?(basename)
22
+ @overrides[basename]
23
+ else
24
+ @fallback.camelize(basename, abspath)
25
+ end
26
+ end
27
+
28
+ # Hosts register their own overrides with
29
+ # `Rails.autoloaders.main.inflector.inflect(...)`; those still reach the
30
+ # inflector they were written for.
31
+ def inflect(overrides)
32
+ @fallback.inflect(overrides)
33
+ end
34
+ end
35
+ end
@@ -42,5 +42,32 @@ module RailsPulse
42
42
  sum_of_squares = values.sum { |v| (v - mean) ** 2 }
43
43
  Math.sqrt(sum_of_squares / (values.size - 1))
44
44
  end
45
+
46
+ # Sample standard deviation of the union of several groups, from three
47
+ # sums over the groups that a database can compute in one GROUP BY, so
48
+ # the groups never have to be loaded. Exact: equal to calculate_stddev
49
+ # over all the groups' values together. For groups i with count n_i,
50
+ # mean m_i and sample standard deviation s_i (nil for a group of one):
51
+ #
52
+ # within_sum = sum of (n_i - 1) * s_i^2
53
+ # weighted_mean_sum = sum of n_i * m_i
54
+ # weighted_square_sum = sum of n_i * m_i^2
55
+ #
56
+ # @param count [Integer] Total number of values across the groups
57
+ # @return [Float, nil] The standard deviation or nil if fewer than two values
58
+ #
59
+ # @example Groups [10, 20, 30] (n 3, mean 20, s 10) and [100, 400] (n 2, mean 250, s 212.13)
60
+ # Statistics.pooled_stddev(count: 5, within_sum: 45_200, weighted_mean_sum: 560, weighted_square_sum: 126_200)
61
+ # # => 164.83 (approximately; calculate_stddev of [10, 20, 30, 100, 400])
62
+ def self.pooled_stddev(count:, within_sum:, weighted_mean_sum:, weighted_square_sum:)
63
+ return nil if count < 2
64
+
65
+ mean = weighted_mean_sum.to_f / count
66
+ # The between-group term, sum of n_i * (m_i - mean)^2, expanded so it
67
+ # needs only the two sums. Clamped because that expansion can come out
68
+ # a hair below zero in floating point when every value is equal.
69
+ sum_of_squares = within_sum.to_f + [ weighted_square_sum.to_f - (count * mean * mean), 0 ].max
70
+ Math.sqrt(sum_of_squares / (count - 1))
71
+ end
45
72
  end
46
73
  end
@@ -15,11 +15,11 @@ module RailsPulse
15
15
  def process
16
16
  return unless RailsPulse.configuration.enabled
17
17
  return unless RailsPulse.configuration.track_exceptions
18
- return if RequestStore.store[:skip_recording_rails_pulse_activity]
18
+ return if RailsPulse::Current.skip_recording_rails_pulse_activity
19
19
 
20
20
  exception = @event.payload[:exception_object]
21
21
  return unless exception
22
- return if RequestStore.store[:rails_pulse_captured_exception].equal?(exception)
22
+ return if RailsPulse::Current.rails_pulse_captured_exception.equal?(exception)
23
23
 
24
24
  RailsPulse::ExceptionCaptureService.capture(
25
25
  exception,
@@ -1,8 +1,6 @@
1
1
  module RailsPulse
2
2
  module Subscribers
3
3
  class OperationSubscriber
4
- FRAME_SLICE = 40
5
-
6
4
  class << self
7
5
  def subscribe!
8
6
  subscribe_sql_queries!
@@ -48,24 +46,12 @@ module RailsPulse
48
46
  # not materialise the whole stack: a bare caller_locations on a
49
47
  # typical 150-frame controller action costs ~30 µs per call, which is
50
48
  # ~15 ms on a 500-query request before the response goes out.
51
- # Thread.each_caller_location (Ruby 3.2+) yields frames lazily and
52
- # stops at the first match; older Rubies walk the stack in slices.
49
+ # Thread.each_caller_location yields frames lazily and stops at the
50
+ # first match.
53
51
  def find_app_frame
54
- if Thread.respond_to?(:each_caller_location)
55
- Thread.each_caller_location do |loc|
56
- path = loc.path
57
- return path if path&.start_with?(app_path)
58
- end
59
- return nil
60
- end
61
-
62
- offset = 2
63
- while (frames = caller_locations(offset, FRAME_SLICE)) && !frames.empty?
64
- frames.each do |loc|
65
- path = loc.path
66
- return path if path&.start_with?(app_path)
67
- end
68
- offset += FRAME_SLICE
52
+ Thread.each_caller_location do |loc|
53
+ path = loc.path
54
+ return path if path&.start_with?(app_path)
69
55
  end
70
56
  nil
71
57
  end
@@ -100,10 +86,10 @@ module RailsPulse
100
86
 
101
87
  def capture_operation(event_name, start, finish, payload, operation_type, label_key = nil, extra: {})
102
88
  return unless RailsPulse.configuration.enabled
103
- return if RequestStore.store[:skip_recording_rails_pulse_activity]
89
+ return if RailsPulse::Current.skip_recording_rails_pulse_activity
104
90
 
105
- request_id = RequestStore.store[:rails_pulse_request_id]
106
- job_run_id = RequestStore.store[:rails_pulse_job_run_id]
91
+ request_id = RailsPulse::Current.rails_pulse_request_id
92
+ job_run_id = RailsPulse::Current.rails_pulse_job_run_id
107
93
  return unless request_id || job_run_id
108
94
 
109
95
  # Skip RailsPulse-related operations to prevent recursion
@@ -149,16 +135,16 @@ module RailsPulse
149
135
  occurred_at: Time.zone.at(start)
150
136
  }.merge(extra)
151
137
 
152
- RequestStore.store[:rails_pulse_operations] ||= []
153
- RequestStore.store[:rails_pulse_operations] << operation_data
138
+ RailsPulse::Current.rails_pulse_operations ||= []
139
+ RailsPulse::Current.rails_pulse_operations << operation_data
154
140
  end
155
141
 
156
142
  def store_operation(label:, operation_type:, start:, finish:, codebase_location:)
157
143
  return unless RailsPulse.configuration.enabled
158
- return if RequestStore.store[:skip_recording_rails_pulse_activity]
144
+ return if RailsPulse::Current.skip_recording_rails_pulse_activity
159
145
 
160
- request_id = RequestStore.store[:rails_pulse_request_id]
161
- job_run_id = RequestStore.store[:rails_pulse_job_run_id]
146
+ request_id = RailsPulse::Current.rails_pulse_request_id
147
+ job_run_id = RailsPulse::Current.rails_pulse_job_run_id
162
148
  return unless request_id || job_run_id
163
149
 
164
150
  operation_data = {
@@ -172,8 +158,8 @@ module RailsPulse
172
158
  occurred_at: Time.zone.at(start)
173
159
  }
174
160
 
175
- RequestStore.store[:rails_pulse_operations] ||= []
176
- RequestStore.store[:rails_pulse_operations] << operation_data
161
+ RailsPulse::Current.rails_pulse_operations ||= []
162
+ RailsPulse::Current.rails_pulse_operations << operation_data
177
163
  end
178
164
 
179
165
  def subscribe_sql_queries!
@@ -11,7 +11,20 @@ module RailsPulse
11
11
  # Anything that needs action is repeated in a closing "Action needed"
12
12
  # list, and `report` returns false in that case so release scripts can
13
13
  # stop on it.
14
+ #
15
+ # Below that, "Suggestions" covers how the install is run rather than
16
+ # whether it is current: SummaryJob or CleanupJob not running, hourly
17
+ # summaries kept too briefly, jobs not tracked. Development and test
18
+ # environments rarely schedule either job, so these never change the
19
+ # exit status.
14
20
  class StatusReporter
21
+ # Hourly summaries back deployment comparisons and hour-precise change
22
+ # points; below a week, last week's deploys can no longer be compared.
23
+ RECOMMENDED_HOURLY_RETENTION = 7.days
24
+
25
+ # CleanupJob is meant to run daily.
26
+ CLEANUP_STALE_AFTER = 2.days
27
+
15
28
  attr_reader :output, :config
16
29
 
17
30
  def self.report(output: $stdout)
@@ -22,6 +35,7 @@ module RailsPulse
22
35
  @output = output
23
36
  @config = RailsPulse.configuration
24
37
  @actions = []
38
+ @suggestions = []
25
39
  end
26
40
 
27
41
  # Returns true when nothing needs attention.
@@ -35,8 +49,14 @@ module RailsPulse
35
49
  print_route_backfill
36
50
  print_initializer
37
51
  print_tracking
52
+ print_writer
38
53
  print_summaries
54
+ print_retention
55
+ print_cleanup
56
+ print_jobs
57
+ print_cloud
39
58
  print_actions
59
+ print_suggestions
40
60
 
41
61
  @actions.empty?
42
62
  end
@@ -47,6 +67,10 @@ module RailsPulse
47
67
  @actions << text
48
68
  end
49
69
 
70
+ def suggest(text)
71
+ @suggestions << text
72
+ end
73
+
50
74
  # -- sections -----------------------------------------------------------
51
75
 
52
76
  def print_database
@@ -149,21 +173,185 @@ module RailsPulse
149
173
  output.puts "Tracking: enabled=#{config.enabled} requests=#{config.enabled} jobs=#{config.track_jobs} " \
150
174
  "exceptions=#{config.track_exceptions} async=#{config.async}"
151
175
  output.puts "Dashboard: mount_dashboard=#{config.mount_dashboard} authentication=#{auth}"
176
+ output.puts "API: api_token #{config.api_token.to_s.empty? ? 'NOT set (rails-pulse CLI and MCP server refuse every request)' : 'set'}, " \
177
+ "deployment_token #{config.deployment_token.to_s.empty? ? 'NOT set (POST deployments refuses every request; the rake tasks still work)' : 'set'}"
178
+ end
179
+
180
+ # This process has no writer of its own; read every process's heartbeats
181
+ def print_writer
182
+ unless RailsPulse::Event.table_available?
183
+ output.puts "Writer: (skipped — events table missing, schema is behind)"
184
+ return
185
+ end
186
+
187
+ summary = RailsPulse::WriterHeartbeat.summary
188
+ if summary[:last_sampled_at].nil?
189
+ output.puts "Writer: no heartbeats yet (they start when a process with config.async tracks its first request)"
190
+ return
191
+ end
192
+
193
+ dropped = summary[:dropped].to_i
194
+ output.puts "Writer: #{summary[:processes]} live process(es), queue depth #{summary[:queue_depth]}/#{summary[:queue_size]}, " \
195
+ "#{dropped} request(s) dropped in the last hour, last heartbeat #{time_ago(summary[:last_sampled_at])}"
196
+ return if dropped.zero?
197
+
198
+ action "The writer dropped #{dropped} request(s) in the last hour. Raise config.async_queue_size or check database latency."
199
+ rescue StandardError => e
200
+ output.puts "Writer: could not check (#{e.class}: #{e.message})"
152
201
  end
153
202
 
154
203
  def print_summaries
155
204
  last = RailsPulse::Summary.maximum(:updated_at)
156
205
  if last.nil?
157
206
  output.puts "Summaries: none generated yet (schedule RailsPulse::SummaryJob hourly; backfill with rails rails_pulse:backfill_summaries)"
207
+ suggest "Schedule RailsPulse::SummaryJob hourly (see the README); the dashboard, the API and cleanup all read its summaries."
158
208
  elsif last < 2.hours.ago
159
209
  output.puts "Summaries: last generated #{time_ago(last)} — stale (is RailsPulse::SummaryJob scheduled?)"
210
+ suggest "RailsPulse::SummaryJob last ran #{time_ago(last)}; check it is scheduled hourly and the worker is running."
160
211
  else
161
212
  output.puts "Summaries: last generated #{time_ago(last)}"
162
213
  end
214
+ print_hourly_coverage
163
215
  rescue StandardError => e
164
216
  output.puts "Summaries: could not check (#{e.class}: #{e.message})"
165
217
  end
166
218
 
219
+ # Hours SummaryJob skipped inside the hourly retention window. It writes
220
+ # the overall request row for every hour, even an empty one, so a gap
221
+ # between the oldest and newest row is an hour it did not run.
222
+ def print_hourly_coverage
223
+ hours = RailsPulse::Summary.overall_requests
224
+ .where(period_type: "hour")
225
+ .where(period_start: config.hourly_summary_retention.ago..)
226
+ .distinct
227
+ .pluck(:period_start)
228
+ return if hours.empty?
229
+
230
+ span = ((hours.max - hours.min) / 1.hour).round + 1
231
+ missing = span - hours.size
232
+ if missing.zero?
233
+ output.puts " hourly: #{hours.size} consecutive hour(s) summarized"
234
+ else
235
+ output.puts " hourly: #{hours.size} of the last #{span} hours summarized, #{missing} missing"
236
+ suggest "RailsPulse::SummaryJob skipped #{missing} hour(s) in the last #{span}. Check its schedule; " \
237
+ "rails rails_pulse:backfill_summaries fills the gaps."
238
+ end
239
+ end
240
+
241
+ def print_retention
242
+ hourly = config.hourly_summary_retention
243
+ raw = config.full_retention_period ? days(config.full_retention_period) : "no age limit"
244
+ output.puts "Retention: raw data #{raw}, hourly summaries #{days(hourly)}"
245
+ return if hourly >= RECOMMENDED_HOURLY_RETENTION
246
+
247
+ suggest "Keep hourly summaries for #{days(RECOMMENDED_HOURLY_RETENTION)}: deployment comparisons and hour-precise " \
248
+ "change points read them, and at #{days(hourly)} a deploy from last week can no longer be compared. " \
249
+ "Add config.hourly_summary_retention = #{RECOMMENDED_HOURLY_RETENTION.in_days.to_i}.days to config/initializers/rails_pulse.rb."
250
+ end
251
+
252
+ def print_cleanup
253
+ unless config.archiving_enabled
254
+ output.puts "Cleanup: off (config.archiving_enabled = false); nothing is pruned"
255
+ return
256
+ end
257
+ unless RailsPulse::Event.table_available?
258
+ output.puts "Cleanup: (skipped — events table missing, schema is behind)"
259
+ return
260
+ end
261
+
262
+ run = RailsPulse::CleanupRun.latest
263
+ if run.nil?
264
+ print_cleanup_never_run
265
+ elsif run.outcome == "failed"
266
+ stages = run.metadata_hash["failed_stages"].to_a.join(", ")
267
+ output.puts "Cleanup: last ran #{time_ago(run.occurred_at)} and failed (#{stages})"
268
+ suggest "The last cleanup failed in #{stages}; the log has the error. Run rails rails_pulse:cleanup to retry."
269
+ elsif run.occurred_at < CLEANUP_STALE_AFTER.ago
270
+ output.puts "Cleanup: last ran #{time_ago(run.occurred_at)} — stale (is RailsPulse::CleanupJob scheduled?)"
271
+ suggest "Cleanup last ran #{time_ago(run.occurred_at)}; schedule RailsPulse::CleanupJob daily so retention is enforced."
272
+ else
273
+ output.puts "Cleanup: last ran #{time_ago(run.occurred_at)}, #{run.value.to_i} row(s) deleted"
274
+ end
275
+ rescue StandardError => e
276
+ output.puts "Cleanup: could not check (#{e.class}: #{e.message})"
277
+ end
278
+
279
+ # No run recorded is only a problem once there is something to prune.
280
+ def print_cleanup_never_run
281
+ overdue = config.full_retention_period &&
282
+ RailsPulse::Request.where(occurred_at: ...(config.full_retention_period + 1.day).ago).exists?
283
+
284
+ if overdue
285
+ output.puts "Cleanup: no run recorded, and requests older than #{days(config.full_retention_period)} are still held"
286
+ suggest "Schedule RailsPulse::CleanupJob daily (see the README); data past retention is not being pruned."
287
+ else
288
+ output.puts "Cleanup: no run recorded yet (schedule RailsPulse::CleanupJob daily)"
289
+ end
290
+ end
291
+
292
+ def print_jobs
293
+ unless config.track_jobs
294
+ output.puts "Jobs: not tracked"
295
+ suggest "Track background jobs to see failures and slow jobs alongside requests: " \
296
+ "config.track_jobs = true in config/initializers/rails_pulse.rb."
297
+ return
298
+ end
299
+
300
+ unless RailsPulse::SchemaCheck.current?
301
+ output.puts "Jobs: tracked (count skipped — schema is behind)"
302
+ return
303
+ end
304
+
305
+ count = RailsPulse::Job.count
306
+ output.puts(count.zero? ? "Jobs: tracked, none recorded yet" : "Jobs: tracked, #{count} job class(es) recorded")
307
+ rescue StandardError => e
308
+ output.puts "Jobs: could not check (#{e.class}: #{e.message})"
309
+ end
310
+
311
+ # Reported, never an action: a Cloud outage or a refused key must not
312
+ # fail a deploy that runs this task.
313
+ def print_cloud
314
+ cloud = config.cloud
315
+ unless cloud.enabled?
316
+ output.puts "Cloud: off (set config.cloud.api_key and config.cloud.application to send to Rails Pulse Cloud)"
317
+ return
318
+ end
319
+
320
+ unless RailsPulse::Cloud::Installation.table_exists? && RailsPulse::Cloud::BufferedBatch.table_exists?
321
+ output.puts "Cloud: configured, but its tables are not installed, so nothing is sent"
322
+ suggest "Install the Rails Pulse Cloud tables: rails generate rails_pulse:install_cloud, then #{migrate_command}."
323
+ return
324
+ end
325
+
326
+ installation = RailsPulse::Cloud::Installation.existing
327
+ buffered = RailsPulse::Cloud::BufferedBatch.count
328
+ buffer = "#{buffered} batch(es) buffered (#{(RailsPulse::Cloud::BufferedBatch.sum(:byte_size) / 1024.0).round(1)} KB)"
329
+ output.puts "Cloud: #{cloud.application} (#{cloud.environment}) to #{cloud.url}: #{cloud_state(installation)}; #{buffer}"
330
+ return suggest_cloud_health_job if installation.nil?
331
+
332
+ output.puts " last error #{time_ago(installation.last_error_at)}: #{installation.last_error}" if installation.last_error.present?
333
+ suggest installation.pause_reason if installation.paused?
334
+ if installation.contract_deprecated_on.present?
335
+ suggest "Rails Pulse Cloud stops accepting this gem's sync contract on #{installation.contract_deprecated_on}; upgrade rails_pulse before then."
336
+ end
337
+ suggest_cloud_health_job if installation.last_health_at.nil? || installation.last_health_at < 5.minutes.ago
338
+ rescue StandardError => e
339
+ output.puts "Cloud: could not check (#{e.class}: #{e.message})"
340
+ end
341
+
342
+ def cloud_state(installation)
343
+ return "not synced yet" if installation.nil?
344
+ return "paused until #{installation.paused_until.utc.iso8601}" if installation.paused?
345
+ return "connected, last accepted #{time_ago(installation.last_success_at)}" if installation.last_success_at
346
+
347
+ "nothing accepted yet"
348
+ end
349
+
350
+ def suggest_cloud_health_job
351
+ suggest "Schedule RailsPulse::CloudHealthJob every minute (see config/initializers/rails_pulse.rb). It sends health " \
352
+ "updates and retries buffered batches; the hourly sync follows RailsPulse::SummaryJob on its own."
353
+ end
354
+
167
355
  def print_actions
168
356
  output.puts
169
357
  if @actions.empty?
@@ -174,6 +362,14 @@ module RailsPulse
174
362
  end
175
363
  end
176
364
 
365
+ def print_suggestions
366
+ return if @suggestions.empty?
367
+
368
+ output.puts
369
+ output.puts "Suggestions:"
370
+ @suggestions.each { |text| output.puts " - #{text}" }
371
+ end
372
+
177
373
  # -- helpers ------------------------------------------------------------
178
374
 
179
375
  def gem_migrations_not_in_host
@@ -204,6 +400,11 @@ module RailsPulse
204
400
  config.connects_to.present? ? "rails db:migrate:rails_pulse" : "rails db:migrate"
205
401
  end
206
402
 
403
+ def days(duration)
404
+ value = (duration / 1.day.to_f).round(1)
405
+ "#{value == value.to_i ? value.to_i : value} day#{'s' unless value == 1}"
406
+ end
407
+
207
408
  def time_ago(time)
208
409
  seconds = (Time.current - time).to_i
209
410
  return "#{seconds}s ago" if seconds < 60
@@ -1,3 +1,5 @@
1
+ require "socket"
2
+
1
3
  module RailsPulse
2
4
  # Persists the data RequestCollector gathers for one request: the route,
3
5
  # the request row and its operations.
@@ -12,6 +14,10 @@ module RailsPulse
12
14
  #
13
15
  # With `config.async = false`, or whenever the pool's connection is pinned
14
16
  # to the current thread (transactional tests), writes happen inline.
17
+ #
18
+ # Once a minute the writer also records a WriterHeartbeat event (queue
19
+ # depth, drops since the last sample) so the dashboard and rails_pulse:status
20
+ # can see every process's writer, not just the one serving the page.
15
21
  module Tracker
16
22
  # PG::Connection::PQTRANS_INERROR (libpq enum value 3) — the connection's transaction
17
23
  # was aborted by a DB-level error and no ROLLBACK has been issued yet. Defined as a
@@ -20,11 +26,16 @@ module RailsPulse
20
26
  PG_TRANSACTION_INERROR = 3
21
27
  private_constant :PG_TRANSACTION_INERROR
22
28
 
29
+ # How often record_heartbeat prunes stale rows, rather than every heartbeat
30
+ PRUNE_INTERVAL = 1.hour
31
+ private_constant :PRUNE_INTERVAL
32
+
23
33
  # The background writer: one thread, one bounded queue, one connection.
24
34
  class Writer
25
35
  BATCH_SIZE = 50
26
36
  DROP_LOG_INTERVAL = 60 # seconds between "queue full" log lines
27
37
  SHUTDOWN_TIMEOUT = 5 # seconds to wait for the queue to drain at exit
38
+ HEARTBEAT_INTERVAL = 60 # seconds between WriterHeartbeat samples
28
39
 
29
40
  attr_reader :queue_size, :dropped
30
41
 
@@ -38,6 +49,8 @@ module RailsPulse
38
49
  @dropped = 0
39
50
  @drop_window_count = 0
40
51
  @drop_window_started = nil
52
+ @dropped_at_last_heartbeat = 0
53
+ @last_heartbeat_at = nil
41
54
  @exit_hook_installed = false
42
55
  end
43
56
 
@@ -59,6 +72,15 @@ module RailsPulse
59
72
  @thread&.alive? || false
60
73
  end
61
74
 
75
+ # Taking a sample resets the drop count for the next window
76
+ def take_heartbeat_sample
77
+ @mutex.synchronize do
78
+ since_last = @dropped - @dropped_at_last_heartbeat
79
+ @dropped_at_last_heartbeat = @dropped
80
+ { queue_size: @queue_size, queue_depth: @queue.size, dropped: since_last, dropped_total: @dropped }
81
+ end
82
+ end
83
+
62
84
  # Persist everything queued so far on the calling thread. Used by
63
85
  # shutdown when the writer thread is gone, and by tests.
64
86
  def drain
@@ -110,11 +132,17 @@ module RailsPulse
110
132
  end
111
133
 
112
134
  def run
113
- # pop returns nil once the queue is closed and empty.
114
- while (first = @queue.pop)
115
- batch = [ first ]
116
- batch << @queue.pop(true) while batch.size < BATCH_SIZE && !@queue.empty?
117
- Tracker.perform_tracking_batch(batch)
135
+ # pop returns nil both on timeout and once the queue is closed and empty
136
+ loop do
137
+ first = @queue.pop(timeout: HEARTBEAT_INTERVAL)
138
+ if first.nil?
139
+ break if @queue.closed?
140
+ else
141
+ batch = [ first ]
142
+ batch << @queue.pop(true) while batch.size < BATCH_SIZE && !@queue.empty?
143
+ Tracker.perform_tracking_batch(batch)
144
+ end
145
+ heartbeat_if_due
118
146
  end
119
147
  rescue => e
120
148
  # perform_tracking_batch rescues its own errors, so this is a bug in the
@@ -122,6 +150,14 @@ module RailsPulse
122
150
  RailsPulse.logger.error("Rails Pulse writer thread stopped: #{e.class} - #{e.message}")
123
151
  end
124
152
 
153
+ def heartbeat_if_due
154
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
155
+ return if @last_heartbeat_at && now - @last_heartbeat_at < HEARTBEAT_INTERVAL
156
+
157
+ @last_heartbeat_at = now
158
+ Tracker.record_heartbeat(take_heartbeat_sample)
159
+ end
160
+
125
161
  def install_exit_hook
126
162
  return if @exit_hook_installed
127
163
 
@@ -148,7 +184,7 @@ module RailsPulse
148
184
 
149
185
  class << self
150
186
  def track_request(data)
151
- return if RequestStore.store[:skip_recording_rails_pulse_activity]
187
+ return if RailsPulse::Current.skip_recording_rails_pulse_activity
152
188
  return unless RailsPulse::SchemaCheck.tracking_allowed?
153
189
 
154
190
  if RailsPulse.configuration.async && !connection_shared_across_threads?
@@ -175,6 +211,27 @@ module RailsPulse
175
211
  # Discard the writer, queue contents included. For tests.
176
212
  def reset_writer!
177
213
  @writer_mutex.synchronize { @writer = nil }
214
+ @last_heartbeat_prune_at = nil
215
+ end
216
+
217
+ # table_exists? has to run inside with_writer_connection too, or the bare
218
+ # call pins a connection on this thread that nothing would ever release
219
+ def record_heartbeat(sample)
220
+ with_writer_connection do
221
+ next false unless RailsPulse::Event.table_available?
222
+
223
+ RailsPulse::WriterHeartbeat.record!(hostname: hostname, pid: Process.pid,
224
+ host_label: RailsPulse.configuration.cloud.host_label, **sample)
225
+ prune_heartbeats_if_due
226
+ true
227
+ end
228
+ rescue => e
229
+ RailsPulse.logger.debug("Rails Pulse writer heartbeat failed: #{e.class} - #{e.message}")
230
+ false
231
+ end
232
+
233
+ def hostname
234
+ @hostname ||= Socket.gethostname
178
235
  end
179
236
 
180
237
  def healthy?
@@ -230,6 +287,14 @@ module RailsPulse
230
287
  end
231
288
  end
232
289
 
290
+ def prune_heartbeats_if_due
291
+ now = Time.current
292
+ return if @last_heartbeat_prune_at && now - @last_heartbeat_prune_at < PRUNE_INTERVAL
293
+
294
+ @last_heartbeat_prune_at = now
295
+ RailsPulse::WriterHeartbeat.prune!
296
+ end
297
+
233
298
  def perform_tracking(data)
234
299
  with_writer_connection do
235
300
  request = persist_request(data)
@@ -245,11 +310,11 @@ module RailsPulse
245
310
  def with_writer_connection
246
311
  RailsPulse::ApplicationRecord.connection_pool.with_connection do |conn|
247
312
  clear_aborted_transaction(conn)
248
- RequestStore.store[:skip_recording_rails_pulse_activity] = true
313
+ RailsPulse::Current.skip_recording_rails_pulse_activity = true
249
314
  yield
250
315
  end
251
316
  ensure
252
- RequestStore.store[:skip_recording_rails_pulse_activity] = false
317
+ RailsPulse::Current.skip_recording_rails_pulse_activity = false
253
318
  end
254
319
 
255
320
  def persist_request(data)
@@ -280,14 +345,11 @@ module RailsPulse
280
345
  # connection to every thread that asks for one. A background writer would
281
346
  # then interleave its statements with the test's on a single socket, which
282
347
  # PostgreSQL reports as "message type 0x5a arrived from server while idle".
283
- # Rails 7.2+ records the pin in the pool's @pinned_connection; 7.1 sets a
284
- # @lock_thread on the pool instead. Neither is public API, so read both
285
- # defensively and treat any failure as "not shared".
348
+ # Rails records the pin in the pool's @pinned_connection. That is not
349
+ # public API, so read it defensively and treat any failure as "not shared".
286
350
  def connection_shared_across_threads?
287
351
  pool = RailsPulse::ApplicationRecord.connection_pool
288
- return true if pool.instance_variable_get(:@pinned_connection)
289
-
290
- pool.instance_variable_get(:@lock_thread) ? true : false
352
+ pool.instance_variable_get(:@pinned_connection) ? true : false
291
353
  rescue StandardError
292
354
  false
293
355
  end
@@ -1,3 +1,3 @@
1
1
  module RailsPulse
2
- VERSION = "0.4.2"
2
+ VERSION = "0.5.0.pre.2"
3
3
  end
data/lib/rails_pulse.rb CHANGED
@@ -22,10 +22,6 @@ module RailsPulse
22
22
  @nav_items || []
23
23
  end
24
24
 
25
- def pro?
26
- defined?(RailsPulse::Pro) == "constant"
27
- end
28
-
29
25
  def configure
30
26
  self.configuration ||= Configuration.new
31
27
  yield(configuration)
@@ -11,6 +11,12 @@ namespace :db do
11
11
  if schema_file.exist?
12
12
  load schema_file
13
13
  puts "Rails Pulse schema loaded successfully"
14
+ # Present only when the app ran rails_pulse:install_cloud.
15
+ cloud_schema_file = Rails.root.join("db/rails_pulse_cloud_schema.rb")
16
+ if cloud_schema_file.exist?
17
+ load cloud_schema_file
18
+ puts "Rails Pulse Cloud schema loaded successfully"
19
+ end
14
20
  # Record any copied migrations as already applied so they don't show as
15
21
  # pending after a fresh schema load. The schema file creates all tables and
16
22
  # columns directly, so the incremental migrations are logically already done.
@@ -28,6 +28,13 @@ namespace :rails_pulse do
28
28
  exit 1 unless RailsPulse::Tasks::StatusReporter.report
29
29
  end
30
30
 
31
+ namespace :cloud do
32
+ desc "Prints exactly what the next Rails Pulse Cloud sync would send, without sending anything."
33
+ task preview: :environment do
34
+ print RailsPulse::Cloud::Preview.new.render
35
+ end
36
+ end
37
+
31
38
  desc "Migrate existing routes: backfill controller actions, normalize paths, consolidate multi-verb routes."
32
39
  task migrate_routes: :environment do
33
40
  ca_results = RailsPulse::RouteControllerActionBackfiller.call