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
@@ -34,7 +34,7 @@ module RailsPulse
34
34
  name: :rails_pulse_summaries,
35
35
  model: "RailsPulse::Summary",
36
36
  label: "Summaries",
37
- description: "Hourly and daily aggregates used by charts. Hourly rows older than 2 days are cleaned up",
37
+ description: "Hourly and daily aggregates used by charts. Hourly rows are cleaned up after 7 days by default (hourly_summary_retention)",
38
38
  time_column: :period_start
39
39
  },
40
40
  {
@@ -71,42 +71,50 @@ module RailsPulse
71
71
  label: "Deployments",
72
72
  description: "Deploy markers shown on charts",
73
73
  time_column: :started_at
74
+ },
75
+ {
76
+ name: :rails_pulse_events,
77
+ model: "RailsPulse::Event",
78
+ label: "Events",
79
+ description: "What Pulse noticed: writer heartbeats every minute, pruned after a day; any other kind is kept for event_retention_period",
80
+ time_column: :occurred_at
81
+ },
82
+ {
83
+ name: :rails_pulse_cloud_batches,
84
+ model: "RailsPulse::Cloud::BufferedBatch",
85
+ label: "Cloud buffer",
86
+ description: "Batches waiting to be sent to Rails Pulse Cloud. Held for at most 7 days or 50 MB",
87
+ time_column: :created_at
74
88
  }
75
89
  ].freeze
76
90
 
77
- # Table sizes come from dbstat / pg_total_relation_size /
78
- # information_schema, one statement per table. The dashboard only shows
79
- # a headline total, so it reads them through a short-lived per-process
80
- # cache; the Storage page itself always measures afresh.
81
- SIZE_CACHE_TTL = 5.minutes
82
- @size_cache = {}
83
- @size_cache_mutex = Mutex.new
84
-
85
- class << self
86
- def cached_table_bytes(table_name)
87
- @size_cache_mutex.synchronize do
88
- entry = @size_cache[table_name]
89
- return entry[:bytes] if entry && entry[:measured_at] > SIZE_CACHE_TTL.ago
90
-
91
- bytes = yield
92
- @size_cache[table_name] = { bytes: bytes, measured_at: Time.current }
93
- bytes
94
- end
95
- end
91
+ # The Storage page counts every row exactly: one unindexed COUNT/MIN/MAX
92
+ # scan per table, which takes tens of seconds on large installs. The
93
+ # dashboard only shows headline counts and fill bars, so with
94
+ # estimated: true it reads the catalog's row estimates instead
95
+ # (pg_class.reltuples, information_schema TABLE_ROWS). SQLite keeps no
96
+ # such estimate, and dbstat walks the whole file, so there the dashboard
97
+ # measures no tables at all.
98
+ def initialize(estimated: false, storage_pressure: nil)
99
+ @config = RailsPulse.configuration
100
+ @pressure = storage_pressure || StoragePressure.new
101
+ @estimated = estimated
102
+ end
96
103
 
97
- def reset_size_cache!
98
- @size_cache_mutex.synchronize { @size_cache.clear }
99
- end
104
+ def estimated?
105
+ @estimated
100
106
  end
101
107
 
102
- def initialize(cached_sizes: false)
103
- @config = RailsPulse.configuration
104
- @pressure = StoragePressure.new
105
- @cached_sizes = cached_sizes
108
+ def counts_available?
109
+ !(@estimated && sqlite?)
106
110
  end
107
111
 
108
112
  def tables
109
- @tables ||= TABLE_CATALOG.filter_map { |definition| build_table(definition) }
113
+ @tables ||= if counts_available?
114
+ with_current_mysql_statistics { TABLE_CATALOG.filter_map { |definition| build_table(definition) } }
115
+ else
116
+ []
117
+ end
110
118
  end
111
119
 
112
120
  def pressure_items
@@ -137,6 +145,35 @@ module RailsPulse
137
145
  }
138
146
  end
139
147
 
148
+ # Built from live_processes/dropped_by_process rather than
149
+ # WriterHeartbeat.summary, which would recompute both internally.
150
+ def tracking
151
+ @tracking ||= begin
152
+ live = RailsPulse::WriterHeartbeat.live_processes
153
+ dropped_by_process = RailsPulse::WriterHeartbeat.dropped_by_process(window: 1.hour)
154
+ processes = live.map do |process|
155
+ {
156
+ label: process.process_label,
157
+ queue_depth: process.queue_depth,
158
+ queue_size: process.queue_size,
159
+ dropped_last_hour: dropped_by_process.fetch(process.process_label, 0),
160
+ last_seen_at: process.sampled_at
161
+ }
162
+ end
163
+
164
+ {
165
+ processes: processes,
166
+ live_count: processes.size,
167
+ queue_depth: live.sum(&:queue_depth),
168
+ queue_size: live.map(&:queue_size).max || 0,
169
+ dropped: dropped_by_process.values.sum,
170
+ last_sampled_at: RailsPulse::WriterHeartbeat.events.maximum(:occurred_at)
171
+ }
172
+ end
173
+ rescue ActiveRecord::ActiveRecordError
174
+ { processes: [], live_count: 0, queue_depth: 0, queue_size: 0, dropped: 0, last_sampled_at: nil }
175
+ end
176
+
140
177
  def dashboard_tables
141
178
  capped = tables.select { |table| table[:limit] }
142
179
  return tables.first(4) if capped.empty?
@@ -162,12 +199,37 @@ module RailsPulse
162
199
  RailsPulse::ApplicationRecord.connection
163
200
  end
164
201
 
202
+ # MySQL 8 caches information_schema's TABLE_ROWS, DATA_LENGTH and
203
+ # INDEX_LENGTH for information_schema_stats_expiry seconds, a day by
204
+ # default, so a table that grew after its first read reports its old
205
+ # size. An expiry of 0 reads current statistics; a SET_VAR hint does
206
+ # not reach information_schema, so it is set on the session and put
207
+ # back afterwards because the connection returns to the pool. MariaDB
208
+ # has no such variable and does not cache, so the block runs as is.
209
+ def with_current_mysql_statistics
210
+ previous = session_statistics_expiry
211
+ return yield if previous.nil?
212
+
213
+ connection.execute("SET SESSION information_schema_stats_expiry = 0")
214
+ yield
215
+ ensure
216
+ connection.execute("SET SESSION information_schema_stats_expiry = #{previous.to_i}") unless previous.nil?
217
+ end
218
+
219
+ def session_statistics_expiry
220
+ return unless mysql?
221
+
222
+ connection.select_value("SELECT @@SESSION.information_schema_stats_expiry")
223
+ rescue ActiveRecord::StatementInvalid
224
+ nil
225
+ end
226
+
165
227
  def build_table(definition)
166
228
  model = definition[:model].safe_constantize
167
229
  return unless model
168
230
  return unless connection.table_exists?(definition[:name])
169
231
 
170
- stats = fetch_counts(model, definition)
232
+ stats = measured_counts(model, definition)
171
233
  limit = @config.max_table_records&.[](definition[:name])
172
234
  count = stats[:count]
173
235
  percent = limit.to_i.positive? ? ((count.to_f / limit) * 100).round(1) : nil
@@ -187,7 +249,7 @@ module RailsPulse
187
249
  newest_at: stats[:newest_at],
188
250
  recent_count: stats[:recent_count],
189
251
  runway_label: runway_label(count, limit, stats[:recent_count]),
190
- bytes: measured_table_bytes(definition[:name]),
252
+ bytes: table_bytes(definition[:name]),
191
253
  history_label: history_label(stats[:oldest_at], stats[:newest_at])
192
254
  }
193
255
  rescue => error
@@ -211,6 +273,33 @@ module RailsPulse
211
273
  }
212
274
  end
213
275
 
276
+ def measured_counts(model, definition)
277
+ return fetch_counts(model, definition) unless @estimated
278
+
279
+ { count: estimated_count(definition[:name]), oldest_at: nil, newest_at: nil, recent_count: 0 }
280
+ end
281
+
282
+ def estimated_count(table_name)
283
+ if connection.adapter_name.downcase.include?("postgres")
284
+ estimate = connection.select_value(
285
+ "SELECT reltuples::bigint FROM pg_class WHERE oid = to_regclass(#{connection.quote(table_name.to_s)})"
286
+ ).to_i
287
+ # -1 until the table's first ANALYZE; autovacuum runs one after
288
+ # about 50 inserts, so a table without an estimate is small enough
289
+ # to count.
290
+ estimate.negative? ? connection.select_value("SELECT COUNT(*) FROM #{connection.quote_table_name(table_name)}").to_i : estimate
291
+ else
292
+ connection.select_value(
293
+ RailsPulse::ApplicationRecord.sanitize_sql_array([ <<~SQL, table_name.to_s ])
294
+ SELECT TABLE_ROWS
295
+ FROM information_schema.TABLES
296
+ WHERE TABLE_SCHEMA = DATABASE()
297
+ AND TABLE_NAME = ?
298
+ SQL
299
+ ).to_i
300
+ end
301
+ end
302
+
214
303
  def fetch_counts(model, definition)
215
304
  quoted_table = connection.quote_table_name(definition[:name])
216
305
  time_column = definition[:time_column]
@@ -315,12 +404,6 @@ module RailsPulse
315
404
  row[key] || row[key.to_s] || row[key.to_s.upcase] || row[key.to_sym]
316
405
  end
317
406
 
318
- def measured_table_bytes(table_name)
319
- return table_bytes(table_name) unless @cached_sizes
320
-
321
- self.class.cached_table_bytes(table_name) { table_bytes(table_name) }
322
- end
323
-
324
407
  def table_bytes(table_name)
325
408
  adapter = connection.adapter_name.downcase
326
409
 
@@ -328,7 +411,7 @@ module RailsPulse
328
411
  connection.select_value(
329
412
  "SELECT pg_total_relation_size(#{connection.quote(table_name.to_s)})"
330
413
  ).to_i
331
- elsif adapter.include?("mysql")
414
+ elsif mysql?
332
415
  connection.select_value(
333
416
  RailsPulse::ApplicationRecord.sanitize_sql_array([ <<~SQL, table_name.to_s ])
334
417
  SELECT DATA_LENGTH + INDEX_LENGTH
@@ -374,11 +457,19 @@ module RailsPulse
374
457
  nil
375
458
  end
376
459
 
460
+ def mysql?
461
+ connection.adapter_name.downcase.match?(/mysql|trilogy/)
462
+ end
463
+
464
+ def sqlite?
465
+ connection.adapter_name.downcase.include?("sqlite")
466
+ end
467
+
377
468
  def adapter_label
378
469
  name = connection.adapter_name
379
470
  case name.downcase
380
471
  when /postgres/ then "PostgreSQL"
381
- when /mysql/ then "MySQL"
472
+ when /mysql|trilogy/ then "MySQL"
382
473
  when /sqlite/ then "SQLite"
383
474
  else name
384
475
  end
@@ -407,7 +498,7 @@ module RailsPulse
407
498
  "File size of the dedicated Rails Pulse SQLite database."
408
499
  elsif adapter.include?("postgres")
409
500
  "PostgreSQL relation size including indexes (pg_total_relation_size)."
410
- elsif adapter.include?("mysql")
501
+ elsif mysql?
411
502
  "MySQL data + index length from information_schema."
412
503
  end
413
504
  end
@@ -1,5 +1,7 @@
1
1
  module RailsPulse
2
2
  class Deployment < RailsPulse::ApplicationRecord
3
+ include HasMetadata
4
+
3
5
  self.table_name = "rails_pulse_deployments"
4
6
 
5
7
  # The create endpoint is reachable with a CI token, so every field a
@@ -52,13 +54,6 @@ module RailsPulse
52
54
  finished_at - started_at
53
55
  end
54
56
 
55
- def metadata_hash
56
- return {} if metadata.blank?
57
- JSON.parse(metadata)
58
- rescue JSON::ParserError
59
- {}
60
- end
61
-
62
57
  def to_chart_marker
63
58
  { timestamp: started_at.to_i * 1000, revision: revision, started_at: started_at.iso8601 }
64
59
  end
@@ -0,0 +1,41 @@
1
+ module RailsPulse
2
+ # What Rails Pulse noticed, as opposed to what it measured. One row per
3
+ # outcome or sample, tagged by `kind`; `subject` names what it is about,
4
+ # `value` carries its number and `metadata` the kind-specific detail as JSON.
5
+ #
6
+ # Two kinds are written today: writer_heartbeat (see WriterHeartbeat) and
7
+ # cleanup_run (see CleanupRun). The table is generic so a new kind needs no
8
+ # migration. Decision 0019.
9
+ #
10
+ # CleanupService deletes rows older than config.event_retention_period,
11
+ # except kinds listed in config.event_retention_exempt_kinds (rows a writer
12
+ # updates in place). Writer heartbeats are pruned after a day by the writer
13
+ # itself.
14
+ class Event < RailsPulse::ApplicationRecord
15
+ include HasMetadata
16
+
17
+ self.table_name = "rails_pulse_events"
18
+
19
+ validates :kind, :outcome, :occurred_at, presence: true
20
+
21
+ scope :of_kind, ->(*kinds) { where(kind: kinds.flatten.map(&:to_s)) }
22
+ scope :for_subject, ->(subject) { where(subject: subject) }
23
+ scope :since, ->(time) { where(occurred_at: time..) }
24
+ scope :recent, -> { order(occurred_at: :desc) }
25
+
26
+ def self.ransackable_attributes(_auth_object = nil)
27
+ %w[kind subject outcome value occurred_at message created_at updated_at]
28
+ end
29
+
30
+ def self.ransackable_associations(_auth_object = nil)
31
+ []
32
+ end
33
+
34
+ # table_exists?, rescued — for an upgrader who hasn't migrated yet
35
+ def self.table_available?
36
+ table_exists?
37
+ rescue ActiveRecord::ActiveRecordError
38
+ false
39
+ end
40
+ end
41
+ end
@@ -21,7 +21,7 @@ module RailsPulse
21
21
  end
22
22
 
23
23
  def to_breadcrumb
24
- occurred_at.getlocal.strftime("%b %d, %Y %l:%M %p")
24
+ occurred_at.strftime("%b %d, %Y %l:%M %p")
25
25
  end
26
26
  end
27
27
  end
@@ -16,10 +16,7 @@ module RailsPulse
16
16
  PER = 1_000
17
17
 
18
18
  def initialize(disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
19
- @disabled_tags = disabled_tags
20
- @show_non_tagged = show_non_tagged
21
- @period = period
22
- @period_type = period_type
19
+ super
23
20
  end
24
21
 
25
22
  def to_metric_card
@@ -63,8 +60,8 @@ module RailsPulse
63
60
  .where(period_type: @period_type, period_start: range_start..now)
64
61
  .select(
65
62
  "SUM(rails_pulse_summaries.count) AS total_count",
66
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS current_count",
67
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(range_start)} AND rails_pulse_summaries.period_start < #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count"
63
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_count"),
64
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count")
68
65
  )
69
66
  .take
70
67
 
@@ -9,10 +9,7 @@ module RailsPulse
9
9
  # you about the other.
10
10
  class OpenGroups < RailsPulse::Cards::Base
11
11
  def initialize(disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
12
- @disabled_tags = disabled_tags
13
- @show_non_tagged = show_non_tagged
14
- @period = period
15
- @period_type = period_type
12
+ super
16
13
  end
17
14
 
18
15
  def to_metric_card
@@ -11,10 +11,7 @@ module RailsPulse
11
11
  SUMMARIZABLE_TYPE = "RailsPulse::ExceptionGroup".freeze
12
12
 
13
13
  def initialize(disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day")
14
- @disabled_tags = disabled_tags
15
- @show_non_tagged = show_non_tagged
16
- @period = period
17
- @period_type = period_type
14
+ super
18
15
  end
19
16
 
20
17
  def to_metric_card
@@ -24,8 +21,8 @@ module RailsPulse
24
21
 
25
22
  metrics = base_query.select(
26
23
  "SUM(rails_pulse_summaries.count) AS total_count",
27
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS current_count",
28
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(range_start)} AND rails_pulse_summaries.period_start < #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count"
24
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_count"),
25
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count")
29
26
  ).take
30
27
 
31
28
  total = metrics&.total_count.to_i
@@ -8,16 +8,15 @@ module RailsPulse
8
8
  # silently flattens toward zero the further back you look. Summaries are
9
9
  # retained at day granularity indefinitely.
10
10
  class OccurrenceVolume
11
- def initialize(start_time:, end_time:, period_type: "day")
12
- @start_time = start_time
13
- @end_time = end_time
11
+ def initialize(window:, period_type: "day")
12
+ @window = window
14
13
  @period_type = period_type
15
14
  end
16
15
 
17
16
  def to_chart_data
18
17
  totals = RailsPulse::Summary
19
18
  .overall_exceptions
20
- .where(period_type: @period_type, period_start: @start_time..@end_time)
19
+ .where(period_type: @period_type, period_start: @window.start_time..@window.end_time)
21
20
  .group(:period_start)
22
21
  .sum(:count)
23
22
 
@@ -42,10 +41,8 @@ module RailsPulse
42
41
  # answer "zero" rather than "no data" — leaving the gap unfilled would
43
42
  # draw a chart that skips quiet days and misrepresents the trend.
44
43
  def pad_with_zeros(raw)
45
- step = @period_type.to_s == "hour" ? 3600 : 86_400
46
-
47
44
  {}.tap do |padded|
48
- (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
45
+ @window.bucket_timestamps(@period_type).each do |timestamp|
49
46
  padded[timestamp] = raw[timestamp] || 0
50
47
  end
51
48
  end
@@ -40,7 +40,7 @@ module RailsPulse
40
40
  after_commit :apply_to_job_caches, on: %i[create update], if: :finalized?
41
41
 
42
42
  def to_breadcrumb
43
- occurred_at.getlocal.strftime("%b %d, %Y %l:%M %p")
43
+ occurred_at.strftime("%b %d, %Y %l:%M %p")
44
44
  end
45
45
 
46
46
  def all_tags
@@ -2,14 +2,8 @@ module RailsPulse
2
2
  module Jobs
3
3
  module Cards
4
4
  class FailureRate < RailsPulse::Cards::Base
5
- def initialize(job: nil, disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day", start_time: nil, end_time: nil)
6
- @job = job
7
- @disabled_tags = disabled_tags
8
- @show_non_tagged = show_non_tagged
9
- @period = period
10
- @period_type = period_type
11
- @start_time = start_time
12
- @end_time = end_time
5
+ def initialize(job: nil, period: 14, **kwargs)
6
+ super(subject: job, period: period, **kwargs)
13
7
  end
14
8
 
15
9
  def to_metric_card
@@ -20,15 +14,15 @@ module RailsPulse
20
14
  period_type: @period_type,
21
15
  period_start: range_start..now
22
16
  )
23
- base_query = base_query.where(summarizable_id: @job.id) if @job
17
+ base_query = base_query.where(summarizable_id: @subject.id) if @subject
24
18
 
25
19
  metrics = base_query.select(
26
20
  "SUM(rails_pulse_summaries.count) AS total_count",
27
21
  "SUM(rails_pulse_summaries.error_count) AS total_errors",
28
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS current_count",
29
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.error_count ELSE 0 END) AS current_errors",
30
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(range_start)} AND rails_pulse_summaries.period_start < #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count",
31
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(range_start)} AND rails_pulse_summaries.period_start < #{quote(current_window_start)} THEN rails_pulse_summaries.error_count ELSE 0 END) AS previous_errors"
22
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_count"),
23
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.error_count ELSE 0 END) AS current_errors"),
24
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count"),
25
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.error_count ELSE 0 END) AS previous_errors")
32
26
  ).take
33
27
 
34
28
  total_runs = metrics&.total_count.to_i
@@ -52,7 +46,7 @@ module RailsPulse
52
46
  period_type: @period_type,
53
47
  period_start: current_window_start..now
54
48
  )
55
- sparkline_query = sparkline_query.where(summarizable_id: @job.id) if @job
49
+ sparkline_query = sparkline_query.where(summarizable_id: @subject.id) if @subject
56
50
 
57
51
  grouped_errors = bucket_by_period(sparkline_query) { |relation| relation.sum("rails_pulse_summaries.error_count") }
58
52
  grouped_counts = bucket_by_period(sparkline_query) { |relation| relation.sum("rails_pulse_summaries.count") }
@@ -2,14 +2,8 @@ module RailsPulse
2
2
  module Jobs
3
3
  module Cards
4
4
  class P95Duration < RailsPulse::Cards::Base
5
- def initialize(job: nil, disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day", start_time: nil, end_time: nil)
6
- @job = job
7
- @disabled_tags = disabled_tags
8
- @show_non_tagged = show_non_tagged
9
- @period = period
10
- @period_type = period_type
11
- @start_time = start_time
12
- @end_time = end_time
5
+ def initialize(job: nil, period: 14, **kwargs)
6
+ super(subject: job, period: period, **kwargs)
13
7
  end
14
8
 
15
9
  def to_metric_card
@@ -20,15 +14,15 @@ module RailsPulse
20
14
  period_type: @period_type,
21
15
  period_start: range_start..now
22
16
  )
23
- base_query = base_query.where(summarizable_id: @job.id) if @job
17
+ base_query = base_query.where(summarizable_id: @subject.id) if @subject
24
18
 
25
19
  metrics = base_query.select(
26
20
  "SUM(rails_pulse_summaries.p95_duration * rails_pulse_summaries.count) AS total_weighted_p95",
27
21
  "SUM(rails_pulse_summaries.count) AS total_runs",
28
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.p95_duration * rails_pulse_summaries.count ELSE 0 END) AS current_weighted_p95",
29
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS current_runs",
30
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(range_start)} AND rails_pulse_summaries.period_start < #{quote(current_window_start)} THEN rails_pulse_summaries.p95_duration * rails_pulse_summaries.count ELSE 0 END) AS previous_weighted_p95",
31
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(range_start)} AND rails_pulse_summaries.period_start < #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS previous_runs"
22
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.p95_duration * rails_pulse_summaries.count ELSE 0 END) AS current_weighted_p95"),
23
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_runs"),
24
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.p95_duration * rails_pulse_summaries.count ELSE 0 END) AS previous_weighted_p95"),
25
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_runs")
32
26
  ).take
33
27
 
34
28
  total_runs = metrics&.total_runs.to_i
@@ -42,7 +36,7 @@ module RailsPulse
42
36
  current_p95 = weighted_average(current_weighted_p95, current_runs)
43
37
  previous_p95 = weighted_average(previous_weighted_p95, previous_runs)
44
38
 
45
- baseline = baseline_trend_for(@job, metric: :p95)
39
+ baseline = baseline_trend_for(@subject, metric: :p95)
46
40
 
47
41
  if baseline
48
42
  trend_icon, trend_amount, trend_caption = baseline
@@ -2,14 +2,8 @@ module RailsPulse
2
2
  module Jobs
3
3
  module Cards
4
4
  class TotalRuns < RailsPulse::Cards::Base
5
- def initialize(job: nil, disabled_tags: [], show_non_tagged: true, period: 14, period_type: "day", start_time: nil, end_time: nil)
6
- @job = job
7
- @disabled_tags = disabled_tags
8
- @show_non_tagged = show_non_tagged
9
- @period = period
10
- @period_type = period_type
11
- @start_time = start_time
12
- @end_time = end_time
5
+ def initialize(job: nil, period: 14, **kwargs)
6
+ super(subject: job, period: period, **kwargs)
13
7
  end
14
8
 
15
9
  def to_metric_card
@@ -20,12 +14,12 @@ module RailsPulse
20
14
  period_type: @period_type,
21
15
  period_start: range_start..now
22
16
  )
23
- base_query = base_query.where(summarizable_id: @job.id) if @job
17
+ base_query = base_query.where(summarizable_id: @subject.id) if @subject
24
18
 
25
19
  metrics = base_query.select(
26
20
  "SUM(rails_pulse_summaries.count) AS total_count",
27
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS current_count",
28
- "SUM(CASE WHEN rails_pulse_summaries.period_start >= #{quote(range_start)} AND rails_pulse_summaries.period_start < #{quote(current_window_start)} THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count"
21
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS current_count"),
22
+ window_sql("SUM(CASE WHEN rails_pulse_summaries.period_start >= :range_start AND rails_pulse_summaries.period_start < :current_start THEN rails_pulse_summaries.count ELSE 0 END) AS previous_count")
29
23
  ).take
30
24
 
31
25
  total_runs = metrics&.total_count.to_i
@@ -31,9 +31,8 @@ module RailsPulse
31
31
  end
32
32
 
33
33
  # Convert to final values (weighted averages) and pad missing data
34
- step = time_step
35
34
  daily_data = {}
36
- (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
35
+ @window.bucket_timestamps(@period_type).each do |timestamp|
37
36
  if raw_data[timestamp]
38
37
  count = raw_data[timestamp][:total_count]
39
38
  daily_data[timestamp] = {
@@ -21,7 +21,7 @@ module RailsPulse
21
21
  end
22
22
 
23
23
  # Pad missing data with zeros using base class helper
24
- daily_data = pad_data_with_zeros(raw_data, @start_time, @end_time, time_step)
24
+ daily_data = pad_data_with_zeros(raw_data)
25
25
 
26
26
  # Build series data as [timestamp_ms, value] pairs for ECharts time axis
27
27
  series = [ {
@@ -23,9 +23,8 @@ module RailsPulse
23
23
  end
24
24
 
25
25
  # Pad missing data with zeros
26
- step = time_step
27
26
  daily_data = {}
28
- (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
27
+ @window.bucket_timestamps(@period_type).each do |timestamp|
29
28
  daily_data[timestamp] = raw_data[timestamp] || nil
30
29
  end
31
30
 
@@ -0,0 +1,35 @@
1
+ module RailsPulse
2
+ # Builds LIKE patterns whose `_`, `%` and escape characters match literally.
3
+ #
4
+ # Every pattern built here must be used with CLAUSE appended to the LIKE
5
+ # expression. SQLite has no default escape character, so `foo!_bar` without an
6
+ # explicit ESCAPE matches a literal `!` while the `_` still wildcards, and the
7
+ # comparison silently matches nothing. `!` is the escape character rather than
8
+ # `\` because MySQL and PostgreSQL disagree on how a backslash is quoted.
9
+ module LikePattern
10
+ ESCAPE_CHARACTER = "!".freeze
11
+
12
+ # Interpolated directly into SQL strings, so it must stay a literal.
13
+ CLAUSE = "ESCAPE '#{ESCAPE_CHARACTER}'".freeze
14
+
15
+ module_function
16
+
17
+ # @param value [#to_s] user-supplied text to match anywhere in a column
18
+ # @return [String] escaped pattern wrapped in leading and trailing wildcards
19
+ def containing(value)
20
+ "%#{escape(value)}%"
21
+ end
22
+
23
+ # @param value [#to_s] user-supplied text to match at the start of a column
24
+ # @return [String] escaped pattern followed by a trailing wildcard
25
+ def starting_with(value)
26
+ "#{escape(value)}%"
27
+ end
28
+
29
+ # @param value [#to_s] user-supplied text
30
+ # @return [String] the text with LIKE metacharacters escaped
31
+ def escape(value)
32
+ ActiveRecord::Base.sanitize_sql_like(value.to_s, ESCAPE_CHARACTER)
33
+ end
34
+ end
35
+ end