rails_pulse 0.3.3 → 0.4.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 (169) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +299 -0
  3. data/README.md +44 -3
  4. data/app/assets/stylesheets/rails_pulse/application.css +21 -32
  5. data/app/assets/stylesheets/rails_pulse/components/backtrace.css +238 -0
  6. data/app/assets/stylesheets/rails_pulse/components/badge.css +32 -0
  7. data/app/assets/stylesheets/rails_pulse/components/button.css +2 -36
  8. data/app/assets/stylesheets/rails_pulse/components/card.css +8 -11
  9. data/app/assets/stylesheets/rails_pulse/components/chart.css +16 -3
  10. data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +5 -39
  11. data/app/assets/stylesheets/rails_pulse/components/dashboard.css +0 -52
  12. data/app/assets/stylesheets/rails_pulse/components/deployments.css +39 -0
  13. data/app/assets/stylesheets/rails_pulse/components/dialog.css +0 -5
  14. data/app/assets/stylesheets/rails_pulse/components/input.css +0 -14
  15. data/app/assets/stylesheets/rails_pulse/components/layouts.css +0 -20
  16. data/app/assets/stylesheets/rails_pulse/components/menu.css +0 -16
  17. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +0 -16
  18. data/app/assets/stylesheets/rails_pulse/components/storage.css +187 -0
  19. data/app/assets/stylesheets/rails_pulse/components/tags.css +0 -14
  20. data/app/assets/stylesheets/rails_pulse/components/time_range_selector.css +5 -5
  21. data/app/assets/stylesheets/rails_pulse/components/utilities.css +0 -17
  22. data/app/controllers/concerns/chart_table_concern.rb +15 -3
  23. data/app/controllers/concerns/ransack_params_concern.rb +18 -0
  24. data/app/controllers/concerns/response_range_concern.rb +1 -1
  25. data/app/controllers/concerns/time_range_concern.rb +28 -14
  26. data/app/controllers/rails_pulse/application_controller.rb +104 -31
  27. data/app/controllers/rails_pulse/dashboard_controller.rb +9 -0
  28. data/app/controllers/rails_pulse/deployments_controller.rb +26 -5
  29. data/app/controllers/rails_pulse/exception_occurrences_controller.rb +19 -0
  30. data/app/controllers/rails_pulse/exceptions_controller.rb +119 -0
  31. data/app/controllers/rails_pulse/job_runs_controller.rb +1 -1
  32. data/app/controllers/rails_pulse/requests_controller.rb +1 -1
  33. data/app/controllers/rails_pulse/routes_controller.rb +7 -1
  34. data/app/controllers/rails_pulse/storage_controller.rb +7 -0
  35. data/app/controllers/rails_pulse/tags_controller.rb +2 -2
  36. data/app/helpers/rails_pulse/application_helper.rb +1 -0
  37. data/app/helpers/rails_pulse/backtrace_helper.rb +85 -0
  38. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +4 -1
  39. data/app/helpers/rails_pulse/chart_helper.rb +0 -3
  40. data/app/helpers/rails_pulse/route_helper.rb +8 -16
  41. data/app/javascript/rails_pulse/application.js +29 -8
  42. data/app/javascript/rails_pulse/controllers/clipboard_controller.js +58 -0
  43. data/app/javascript/rails_pulse/controllers/index_controller.js +16 -10
  44. data/app/javascript/rails_pulse/controllers/pagination_controller.js +6 -2
  45. data/app/javascript/rails_pulse/controllers/popover_controller.js +4 -0
  46. data/app/models/concerns/rails_pulse/taggable.rb +11 -6
  47. data/app/models/rails_pulse/cards/base.rb +53 -0
  48. data/app/models/rails_pulse/dashboard/health_summary.rb +52 -0
  49. data/app/models/rails_pulse/dashboard/needs_attention.rb +73 -1
  50. data/app/models/rails_pulse/dashboard/storage_status.rb +490 -0
  51. data/app/models/rails_pulse/deployment.rb +47 -1
  52. data/app/models/rails_pulse/exception_group.rb +37 -0
  53. data/app/models/rails_pulse/exception_occurrence.rb +27 -0
  54. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +116 -0
  55. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +49 -0
  56. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +62 -0
  57. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +56 -0
  58. data/app/models/rails_pulse/exceptions/raw_data.rb +216 -0
  59. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -2
  60. data/app/models/rails_pulse/operation.rb +13 -4
  61. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +8 -3
  62. data/app/models/rails_pulse/request.rb +5 -1
  63. data/app/models/rails_pulse/route.rb +71 -21
  64. data/app/models/rails_pulse/routes/cards/error_rates.rb +6 -3
  65. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +8 -3
  66. data/app/models/rails_pulse/routes/tables/index.rb +8 -6
  67. data/app/models/rails_pulse/summary.rb +21 -10
  68. data/app/models/rails_pulse/tables/base.rb +4 -2
  69. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +47 -12
  70. data/app/services/rails_pulse/exception_capture_service.rb +228 -0
  71. data/app/services/rails_pulse/exception_message_sanitizer.rb +152 -0
  72. data/app/services/rails_pulse/operations/change_point.rb +144 -0
  73. data/app/services/rails_pulse/operations/compare.rb +92 -0
  74. data/app/services/rails_pulse/operations/comparison.rb +126 -0
  75. data/app/services/rails_pulse/operations/metric.rb +63 -0
  76. data/app/services/rails_pulse/operations/series.rb +84 -0
  77. data/app/services/rails_pulse/operations/subject.rb +77 -0
  78. data/app/services/rails_pulse/route_controller_action_backfiller.rb +110 -0
  79. data/app/services/rails_pulse/route_merger.rb +99 -0
  80. data/app/services/rails_pulse/route_migrator.rb +139 -0
  81. data/app/services/rails_pulse/route_path_normalizer.rb +79 -0
  82. data/app/services/rails_pulse/route_recognizer.rb +47 -0
  83. data/app/services/rails_pulse/summary_service.rb +45 -0
  84. data/app/services/rails_pulse/tag_filter_service.rb +5 -3
  85. data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
  86. data/app/views/layouts/rails_pulse/_onboarding_banner.html.erb +8 -0
  87. data/app/views/layouts/rails_pulse/_setup_screen.html.erb +5 -5
  88. data/app/views/layouts/rails_pulse/application.html.erb +4 -4
  89. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  90. data/app/views/rails_pulse/components/_panel.html.erb +16 -6
  91. data/app/views/rails_pulse/components/_raw_data.html.erb +34 -0
  92. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -8
  93. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +64 -0
  94. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +21 -10
  95. data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +67 -0
  96. data/app/views/rails_pulse/dashboard/index.html.erb +22 -1
  97. data/app/views/rails_pulse/deployments/_status_badge.html.erb +5 -0
  98. data/app/views/rails_pulse/deployments/_table.html.erb +29 -0
  99. data/app/views/rails_pulse/deployments/index.html.erb +15 -0
  100. data/app/views/rails_pulse/deployments/show.html.erb +62 -0
  101. data/app/views/rails_pulse/exception_occurrences/show.html.erb +45 -0
  102. data/app/views/rails_pulse/exceptions/_backtrace.html.erb +33 -0
  103. data/app/views/rails_pulse/exceptions/_metric_cards.html.erb +6 -0
  104. data/app/views/rails_pulse/exceptions/_occurrences_table.html.erb +23 -0
  105. data/app/views/rails_pulse/exceptions/_status_badge.html.erb +6 -0
  106. data/app/views/rails_pulse/exceptions/_table.html.erb +33 -0
  107. data/app/views/rails_pulse/exceptions/index.html.erb +66 -0
  108. data/app/views/rails_pulse/exceptions/show.html.erb +52 -0
  109. data/app/views/rails_pulse/queries/_raw_data.html.erb +1 -23
  110. data/app/views/rails_pulse/requests/_table.html.erb +1 -1
  111. data/app/views/rails_pulse/routes/_requests_table.html.erb +5 -0
  112. data/app/views/rails_pulse/routes/_table.html.erb +22 -8
  113. data/app/views/rails_pulse/routes/index.html.erb +5 -1
  114. data/app/views/rails_pulse/routes/show.html.erb +5 -0
  115. data/app/views/rails_pulse/storage/show.html.erb +123 -0
  116. data/config/routes.rb +13 -4
  117. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +50 -0
  118. data/db/rails_pulse_migrate/20260507000001_add_actual_query_to_operations.rb +10 -5
  119. data/db/rails_pulse_migrate/20260610000001_add_controller_action_to_rails_pulse_routes.rb +8 -0
  120. data/db/rails_pulse_migrate/20260610000002_change_rails_pulse_routes_to_multi_verb_model.rb +185 -0
  121. data/db/rails_pulse_migrate/20260610000003_add_null_action_unique_index_to_routes.rb +32 -0
  122. data/db/rails_pulse_migrate/20260823000001_add_location_to_exception_groups.rb +12 -0
  123. data/db/rails_pulse_schema.rb +54 -6
  124. data/lib/generators/rails_pulse/base_methods.rb +2 -0
  125. data/lib/generators/rails_pulse/install_generator.rb +5 -0
  126. data/lib/generators/rails_pulse/schema_parser.rb +22 -2
  127. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
  128. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +1 -1
  129. data/lib/generators/rails_pulse/templates/rails_pulse.rb +157 -16
  130. data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
  131. data/lib/rails_pulse/cleanup_service.rb +110 -15
  132. data/lib/rails_pulse/configuration.rb +125 -6
  133. data/lib/rails_pulse/engine.rb +32 -17
  134. data/lib/rails_pulse/installers/config_updater.rb +202 -0
  135. data/lib/rails_pulse/job_run_collector.rb +43 -16
  136. data/lib/rails_pulse/middleware/asset_server.rb +11 -0
  137. data/lib/rails_pulse/middleware/request_collector.rb +9 -4
  138. data/lib/rails_pulse/packaged_assets.rb +147 -0
  139. data/lib/rails_pulse/route_indexes.rb +66 -0
  140. data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
  141. data/lib/rails_pulse/tracker.rb +29 -10
  142. data/lib/rails_pulse/version.rb +1 -1
  143. data/lib/rails_pulse.rb +1 -0
  144. data/lib/rails_pulse_server.ru +16 -47
  145. data/lib/tasks/rails_pulse.rake +83 -4
  146. data/lib/tasks/rails_pulse_assets.rake +15 -0
  147. data/lib/tasks/rails_pulse_tasks.rake +29 -0
  148. data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
  149. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  150. data/public/rails-pulse-assets/rails-pulse.js +18 -117
  151. metadata +78 -37
  152. data/Rakefile +0 -554
  153. data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
  154. data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
  155. data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
  156. data/app/javascript/rails_pulse/theme.js +0 -416
  157. data/config/brakeman.ignore +0 -190
  158. data/config/brakeman.yml +0 -68
  159. data/config/initializers/rails_pulse.rb +0 -287
  160. data/public/rails-pulse-assets/csp-test.js +0 -120
  161. data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
  162. data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
  163. data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
  164. data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
  165. data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
  166. data/vendor/assets/javascripts/rails-pulse.js +0 -157
  167. data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
  168. data/vendor/assets/stylesheets/rails-pulse.css +0 -1
  169. data/vendor/assets/stylesheets/rails-pulse.css.map +0 -1
@@ -41,12 +41,16 @@ module RailsPulse
41
41
  RailsPulse.logger.info "Time-based cleanup: removing records older than #{cutoff_time}"
42
42
 
43
43
  # Clean up in order that respects foreign key constraints
44
- @stats[:time_based][:operations] = cleanup_operations_by_time(cutoff_time)
45
- @stats[:time_based][:job_runs] = cleanup_job_runs_by_time(cutoff_time)
46
- @stats[:time_based][:requests] = cleanup_requests_by_time(cutoff_time)
47
- @stats[:time_based][:queries] = cleanup_queries_by_time(cutoff_time)
48
- @stats[:time_based][:routes] = cleanup_routes_by_time(cutoff_time)
49
- @stats[:time_based][:jobs] = cleanup_jobs_by_time(cutoff_time)
44
+ @stats[:time_based][:operations] = cleanup_operations_by_time(cutoff_time)
45
+ @stats[:time_based][:job_runs] = cleanup_job_runs_by_time(cutoff_time)
46
+ @stats[:time_based][:requests] = cleanup_requests_by_time(cutoff_time)
47
+ @stats[:time_based][:queries] = cleanup_queries_by_time(cutoff_time)
48
+ @stats[:time_based][:routes] = cleanup_routes_by_time(cutoff_time)
49
+ @stats[:time_based][:jobs] = cleanup_jobs_by_time(cutoff_time)
50
+ if exception_tables_exist?
51
+ @stats[:time_based][:exception_occurrences] = cleanup_exception_occurrences_by_time(cutoff_time)
52
+ @stats[:time_based][:exception_groups] = cleanup_orphaned_exception_groups
53
+ end
50
54
  end
51
55
 
52
56
  def perform_count_based_cleanup
@@ -63,12 +67,32 @@ module RailsPulse
63
67
  ops_scope = cutoff ? RailsPulse::Operation.where("occurred_at < ?", cutoff) : RailsPulse::Operation.none
64
68
 
65
69
  # Clean up in order that respects foreign key constraints
66
- @stats[:count_based][:operations] = cleanup_by_count(RailsPulse::Operation, :rails_pulse_operations, order_column: :occurred_at, scope: ops_scope)
67
- @stats[:count_based][:job_runs] = cleanup_job_runs_by_count
68
- @stats[:count_based][:requests] = cleanup_requests_by_count
69
- @stats[:count_based][:queries] = cleanup_queries_by_count
70
- @stats[:count_based][:routes] = cleanup_routes_by_count
71
- @stats[:count_based][:jobs] = cleanup_jobs_by_count
70
+ @stats[:count_based][:operations] = cleanup_by_count(RailsPulse::Operation, :rails_pulse_operations, order_column: :occurred_at, scope: ops_scope)
71
+ @stats[:count_based][:job_runs] = cleanup_job_runs_by_count
72
+ @stats[:count_based][:requests] = cleanup_requests_by_count
73
+ @stats[:count_based][:queries] = cleanup_queries_by_count
74
+ @stats[:count_based][:routes] = cleanup_routes_by_count
75
+ @stats[:count_based][:jobs] = cleanup_jobs_by_count
76
+ if exception_tables_exist?
77
+ @stats[:count_based][:exception_occurrences] = cleanup_exception_occurrences_by_count
78
+ @stats[:count_based][:exception_groups] = cleanup_exception_groups_by_count
79
+ @stats[:count_based][:orphaned_exception_groups] = cleanup_orphaned_exception_groups
80
+ end
81
+ if deployments_table_exists?
82
+ @stats[:count_based][:deployments] = cleanup_deployments_by_count
83
+ end
84
+ end
85
+
86
+ # Deployments are markers, not measurements, so they are never pruned by
87
+ # age — a marker from before the retention window still explains a
88
+ # summary row. They are capped by count so the token-authenticated
89
+ # create endpoint cannot grow the table without bound.
90
+ def cleanup_deployments_by_count
91
+ cleanup_by_count(RailsPulse::Deployment, :rails_pulse_deployments, order_column: :started_at)
92
+ end
93
+
94
+ def deployments_table_exists?
95
+ RailsPulse::ApplicationRecord.connection.table_exists?(:rails_pulse_deployments)
72
96
  end
73
97
 
74
98
  # Shared helper: delete oldest records beyond a configured max count
@@ -188,10 +212,81 @@ module RailsPulse
188
212
  cleanup_by_count(RailsPulse::Job, :rails_pulse_jobs, order_column: :created_at, scope: scope)
189
213
  end
190
214
 
215
+ def exception_tables_exist?
216
+ connection = RailsPulse::ApplicationRecord.connection
217
+ connection.table_exists?(:rails_pulse_exception_groups) &&
218
+ connection.table_exists?(:rails_pulse_exception_occurrences)
219
+ end
220
+
221
+ def cleanup_exception_occurrences_by_time(cutoff_time)
222
+ exception_occurrences_cleanup_scope.where("occurred_at < ?", cutoff_time).delete_all
223
+ end
224
+
225
+ def cleanup_exception_occurrences_by_count
226
+ cleanup_by_count(
227
+ RailsPulse::ExceptionOccurrence,
228
+ :rails_pulse_exception_occurrences,
229
+ order_column: :occurred_at,
230
+ scope: exception_occurrences_cleanup_scope
231
+ )
232
+ end
233
+
234
+ # Preserve exempts a group from all automatic cleanup, including its
235
+ # occurrence rows — otherwise a preserved group can lose its backtraces.
236
+ def exception_occurrences_cleanup_scope
237
+ preserved_ids = RailsPulse::ExceptionGroup.where(preserve: true).select(:id)
238
+ RailsPulse::ExceptionOccurrence.where.not(exception_group_id: preserved_ids)
239
+ end
240
+
241
+ # Prune oldest non-preserved, non-ignored groups when over the configured cap.
242
+ # Child occurrences must be deleted first — there is no ON DELETE CASCADE on the FK.
243
+ def cleanup_exception_groups_by_count
244
+ max_records = @config.max_table_records[:rails_pulse_exception_groups]
245
+ return 0 unless max_records
246
+
247
+ current_count = RailsPulse::ExceptionGroup.count
248
+ return 0 if current_count <= max_records
249
+
250
+ overage = current_count - max_records
251
+ scope = RailsPulse::ExceptionGroup.where(preserve: false).where.not(status: "ignored")
252
+ # Only delete as many as the deletable population allows — if ignored/preserved
253
+ # groups dominate, we delete what we can and log when the cap cannot be met.
254
+ deletable_count = scope.count
255
+ records_to_delete = [ overage, deletable_count ].min
256
+ return 0 if records_to_delete <= 0
257
+
258
+ ids_to_delete = scope.order(last_seen_at: :asc).limit(records_to_delete).pluck(:id)
259
+ return 0 if ids_to_delete.empty?
260
+
261
+ RailsPulse::ExceptionOccurrence.where(exception_group_id: ids_to_delete).delete_all
262
+ RailsPulse::ExceptionGroup.where(id: ids_to_delete).delete_all
263
+
264
+ if overage > deletable_count
265
+ RailsPulse.logger.warn("[RailsPulse] Exception group cap #{max_records} cannot be met: " \
266
+ "#{current_count - ids_to_delete.size} remain (#{current_count - deletable_count} are preserved/ignored)")
267
+ end
268
+
269
+ ids_to_delete.size
270
+ end
271
+
272
+ # Delete groups whose last occurrence was removed — uses an atomic subquery
273
+ # so no orphan can be deleted while a concurrent occurrence is being inserted.
274
+ # preserve: true and ignored groups are never deleted automatically.
275
+ def cleanup_orphaned_exception_groups
276
+ RailsPulse::ExceptionGroup
277
+ .where(preserve: false)
278
+ .where.not(status: "ignored")
279
+ .where("id NOT IN (SELECT DISTINCT exception_group_id FROM rails_pulse_exception_occurrences)")
280
+ .delete_all
281
+ end
282
+
191
283
  def perform_summary_cleanup
192
- # Hourly summaries are only used for the 1-day time range view — anything
193
- # older than 2 days is unreachable in the UI and safe to delete.
194
- cutoff = 2.days.ago
284
+ # Hourly summaries back the 1-day time range view and set how precisely
285
+ # Operations::ChangePoint can place a change. Beyond this cutoff the finest
286
+ # answer available is a day, so raising `hourly_summary_retention` is what
287
+ # buys hour-accurate change points further back — at the cost of summary
288
+ # table growth. Day, week and month summaries are never pruned.
289
+ cutoff = @config.hourly_summary_retention.ago
195
290
  deleted = RailsPulse::Summary.where(period_type: "hour").where("period_start < ?", cutoff).delete_all
196
291
  @stats[:time_based][:hourly_summaries] = deleted
197
292
  end
@@ -9,14 +9,18 @@ module RailsPulse
9
9
  :ignored_queues,
10
10
  :track_assets,
11
11
  :track_jobs,
12
+ :track_exceptions,
13
+ :capture_exception_params,
14
+ :exception_message_filter,
15
+ :capture_actual_sql,
12
16
  :custom_asset_patterns,
13
17
  :mount_path,
14
18
  :full_retention_period,
15
19
  :archiving_enabled,
16
- :max_table_records,
17
20
  :connects_to,
18
21
  :authentication_enabled,
19
22
  :authentication_method,
23
+ :authorize,
20
24
  :authentication_redirect_path,
21
25
  :tags,
22
26
  :job_tracking_mode,
@@ -27,7 +31,10 @@ module RailsPulse
27
31
  :async,
28
32
  :service_level_objectives,
29
33
  :query_service_level_objectives,
30
- :warn_on_stale_summaries
34
+ :warn_on_stale_summaries,
35
+ :baseline_window,
36
+ :comparison_window,
37
+ :hourly_summary_retention
31
38
 
32
39
  # Override the attr_accessor setter to track explicit assignment.
33
40
  # The default (enabled in production) should not trigger a warning
@@ -42,7 +49,10 @@ module RailsPulse
42
49
  attr_reader :route_thresholds,
43
50
  :request_thresholds,
44
51
  :query_thresholds,
45
- :job_thresholds
52
+ :job_thresholds,
53
+ :exception_thresholds,
54
+ :max_table_records,
55
+ :regression_thresholds
46
56
 
47
57
  def initialize
48
58
  @enabled = true
@@ -50,6 +60,9 @@ module RailsPulse
50
60
  @request_thresholds = { slow: 700, very_slow: 2000, critical: 4000 }
51
61
  @query_thresholds = { slow: 100, very_slow: 500, critical: 1000 }
52
62
  @job_thresholds = { slow: 5_000, very_slow: 30_000, critical: 60_000 }
63
+ # Exception thresholds are occurrence counts over the dashboard period,
64
+ # not durations — an exception has no duration.
65
+ @exception_thresholds = { warning: 10, critical: 100 }
53
66
  @ignored_routes = []
54
67
  @ignored_requests = []
55
68
  @ignored_queries = []
@@ -57,6 +70,18 @@ module RailsPulse
57
70
  @ignored_queues = []
58
71
  @track_assets = false
59
72
  @track_jobs = false
73
+ # Off unless the host initializer opts in. The install template sets true
74
+ # for new apps; existing 0.3.3 initializers omit this key, so a true
75
+ # default would start capturing exception messages (unfiltered) on upgrade.
76
+ @track_exceptions = false
77
+ @capture_exception_params = true
78
+ # Optional host hook applied to every captured exception message after
79
+ # the built-in filter_parameters redaction. nil means no extra filtering.
80
+ @exception_message_filter = nil
81
+ # Off by default — actual_sql contains unparameterized SQL on mysql2
82
+ # (prepared_statements: false) and PG behind PgBouncer transaction mode.
83
+ # Inline literals (emails, passwords) would be stored in plaintext.
84
+ @capture_actual_sql = false
60
85
  @custom_asset_patterns = []
61
86
  @mount_path = nil
62
87
  @full_retention_period = 30.days
@@ -67,12 +92,18 @@ module RailsPulse
67
92
  rails_pulse_job_runs: 50_000,
68
93
  rails_pulse_queries: 10_000,
69
94
  rails_pulse_routes: 1_000,
70
- rails_pulse_jobs: 1_000
95
+ rails_pulse_jobs: 1_000,
96
+ rails_pulse_exception_occurrences: 50_000,
97
+ rails_pulse_exception_groups: 10_000,
98
+ rails_pulse_deployments: 1_000
71
99
  }
72
100
  @connects_to = nil
73
- @authentication_enabled = Rails.env.production?
101
+ @authentication_enabled = !(Rails.env.development? || Rails.env.test?)
74
102
  @authentication_enabled_explicitly_set = false
75
103
  @authentication_method = nil
104
+ # Fail-closed predicate: ->(controller) { true to allow }. Anything
105
+ # falsy is a 403. Runs after authentication_method, if both are set.
106
+ @authorize = nil
76
107
  @authentication_redirect_path = "/"
77
108
  @tags = [ "ignored", "critical", "experimental" ]
78
109
  @job_tracking_mode = :universal
@@ -99,6 +130,29 @@ module RailsPulse
99
130
  # Show a warning banner when summaries haven't been generated recently
100
131
  @warn_on_stale_summaries = true
101
132
 
133
+ # Historical comparison. The baseline is the traffic-weighted metric across
134
+ # `baseline_window` of day summaries; `comparison_window` is the recent
135
+ # slice measured against it.
136
+ @baseline_window = 28.days
137
+ @comparison_window = 1.day
138
+
139
+ # Hourly summaries are what give a change point its precision. They are
140
+ # pruned aggressively because the 1-day UI view is all that reads them, so
141
+ # raising this is the knob that buys hour-accurate change points further
142
+ # back, at the cost of summary-table growth.
143
+ @hourly_summary_retention = 2.days
144
+
145
+ # Deterministic regression rules. A change must clear both the ratio and
146
+ # the absolute floor: without the floor, 2ms to 4ms reports as a 100%
147
+ # regression. min_samples keeps low-traffic routes from generating noise.
148
+ @regression_thresholds = {
149
+ ratio: 1.5,
150
+ min_delta_ms: 50.0,
151
+ min_delta_rate: 1.0,
152
+ min_samples: 100,
153
+ min_baseline_periods: 3
154
+ }
155
+
102
156
  # Validate defaults eagerly so that a misconfigured initializer raises at
103
157
  # boot time rather than at the first request. Auth settings are not
104
158
  # validated against defaults — only when the user explicitly enables
@@ -128,6 +182,15 @@ module RailsPulse
128
182
  @job_thresholds = value
129
183
  end
130
184
 
185
+ # Merge user-supplied caps into the defaults so tables added in newer
186
+ # versions still have a cap when the user carries an older explicit hash.
187
+ # A nil value explicitly disables count-based cleanup.
188
+ def max_table_records=(value)
189
+ return @max_table_records = nil if value.nil?
190
+
191
+ @max_table_records = (@max_table_records || {}).merge(value)
192
+ end
193
+
131
194
  # Get all routes to ignore, including asset patterns if track_assets is false
132
195
  def ignored_routes
133
196
  routes = @ignored_routes.dup
@@ -151,8 +214,10 @@ module RailsPulse
151
214
  validate_job_settings!
152
215
  validate_dashboard_settings!
153
216
  validate_tracking_settings!
217
+ validate_exception_settings!
154
218
  validate_service_level_objectives_settings!
155
219
  validate_query_service_level_objectives_settings!
220
+ validate_comparison_settings!
156
221
  end
157
222
 
158
223
  private
@@ -174,6 +239,7 @@ module RailsPulse
174
239
  validate_threshold_hash!(@request_thresholds, "request_thresholds")
175
240
  validate_threshold_hash!(@query_thresholds, "query_thresholds")
176
241
  validate_threshold_hash!(@job_thresholds, "job_thresholds")
242
+ validate_threshold_hash!(@exception_thresholds, "exception_thresholds")
177
243
  end
178
244
 
179
245
  def validate_retention_settings!
@@ -188,6 +254,41 @@ module RailsPulse
188
254
  end
189
255
  end
190
256
 
257
+ def validate_comparison_settings!
258
+ {
259
+ baseline_window: @baseline_window,
260
+ comparison_window: @comparison_window,
261
+ hourly_summary_retention: @hourly_summary_retention
262
+ }.each do |name, value|
263
+ unless value.respond_to?(:seconds) && value.to_i > 0
264
+ raise ArgumentError, "#{name} must be a positive time duration (e.g., 7.days), got #{value.inspect}"
265
+ end
266
+ end
267
+
268
+ if @baseline_window.to_i <= @comparison_window.to_i
269
+ raise ArgumentError,
270
+ "baseline_window (#{@baseline_window.inspect}) must be longer than " \
271
+ "comparison_window (#{@comparison_window.inspect})"
272
+ end
273
+
274
+ unless @regression_thresholds.is_a?(Hash)
275
+ raise ArgumentError, "regression_thresholds must be a hash, got #{@regression_thresholds.class}"
276
+ end
277
+
278
+ %i[ratio min_delta_ms min_delta_rate min_samples min_baseline_periods].each do |key|
279
+ value = @regression_thresholds[key]
280
+ unless value.is_a?(Numeric) && value > 0
281
+ raise ArgumentError, "regression_thresholds[:#{key}] must be a positive number, got #{value.inspect}"
282
+ end
283
+ end
284
+
285
+ if @regression_thresholds[:ratio] <= 1
286
+ raise ArgumentError,
287
+ "regression_thresholds[:ratio] must be greater than 1 — it is a multiple of the baseline, " \
288
+ "got #{@regression_thresholds[:ratio].inspect}"
289
+ end
290
+ end
291
+
191
292
  def validate_patterns!
192
293
  [ @ignored_routes, @ignored_requests, @ignored_queries, @custom_asset_patterns ].each do |patterns|
193
294
  patterns.each do |pattern|
@@ -214,13 +315,17 @@ module RailsPulse
214
315
  end
215
316
 
216
317
  def validate_authentication_settings!
217
- if @authentication_enabled && @authentication_enabled_explicitly_set && @authentication_method.nil?
318
+ if @authentication_enabled && @authentication_enabled_explicitly_set && @authentication_method.nil? && @authorize.nil?
218
319
  RailsPulse.logger.warn "Authentication is enabled but no authentication method is configured. This will deny all access."
219
320
  end
220
321
 
221
322
  if @authentication_method && ![ Proc, Symbol, String ].include?(@authentication_method.class)
222
323
  raise ArgumentError, "authentication_method must be a Proc, Symbol, String, or nil, got #{@authentication_method.class}"
223
324
  end
325
+
326
+ if @authorize && !@authorize.respond_to?(:call)
327
+ raise ArgumentError, "authorize must respond to #call (a proc or lambda receiving the controller and returning true to allow), got #{@authorize.class}"
328
+ end
224
329
  end
225
330
 
226
331
  def validate_tags!
@@ -273,6 +378,20 @@ module RailsPulse
273
378
  end
274
379
  end
275
380
 
381
+ def validate_exception_settings!
382
+ unless [ true, false ].include?(@track_exceptions)
383
+ raise ArgumentError, "track_exceptions must be a boolean"
384
+ end
385
+
386
+ unless [ true, false ].include?(@capture_exception_params)
387
+ raise ArgumentError, "capture_exception_params must be a boolean"
388
+ end
389
+
390
+ if @exception_message_filter && !@exception_message_filter.respond_to?(:call)
391
+ raise ArgumentError, "exception_message_filter must respond to #call (a proc or lambda taking the message and the exception), got #{@exception_message_filter.class}"
392
+ end
393
+ end
394
+
276
395
  def validate_service_level_objectives_settings!
277
396
  unless @service_level_objectives.is_a?(Array)
278
397
  raise ArgumentError, "service_level_objectives must be an array, got #{@service_level_objectives.class}"
@@ -1,8 +1,10 @@
1
1
  require "rails_pulse/version"
2
2
  require "rails_pulse/statistics"
3
+ require "rails_pulse/route_indexes"
3
4
  require "rails_pulse/middleware/request_collector"
4
5
  require "rails_pulse/middleware/asset_server"
5
6
  require "rails_pulse/subscribers/operation_subscriber"
7
+ require "rails_pulse/subscribers/exception_subscriber"
6
8
  require "rails_pulse/job_run_collector"
7
9
  require "rails_pulse/active_job_extensions"
8
10
  require "rails_pulse/extensions/active_record"
@@ -16,7 +18,27 @@ module RailsPulse
16
18
  autoload :SummaryService, File.expand_path("../../app/services/rails_pulse/summary_service", __dir__)
17
19
  autoload :QueryAnalysisService, File.expand_path("../../app/services/rails_pulse/query_analysis_service", __dir__)
18
20
  autoload :TagFilterService, File.expand_path("../../app/services/rails_pulse/tag_filter_service", __dir__)
21
+ autoload :ExceptionCaptureService, File.expand_path("../../app/services/rails_pulse/exception_capture_service", __dir__)
22
+ autoload :ExceptionMessageSanitizer, File.expand_path("../../app/services/rails_pulse/exception_message_sanitizer", __dir__)
19
23
  autoload :OperationSuggestions, File.expand_path("../../app/services/rails_pulse/operation_suggestions", __dir__)
24
+ autoload :RoutePathNormalizer, File.expand_path("../../app/services/rails_pulse/route_path_normalizer", __dir__)
25
+ autoload :RouteRecognizer, File.expand_path("../../app/services/rails_pulse/route_recognizer", __dir__)
26
+ autoload :RouteMerger, File.expand_path("../../app/services/rails_pulse/route_merger", __dir__)
27
+ autoload :RouteMigrator, File.expand_path("../../app/services/rails_pulse/route_migrator", __dir__)
28
+ autoload :RouteControllerActionBackfiller, File.expand_path("../../app/services/rails_pulse/route_controller_action_backfiller", __dir__)
29
+
30
+ # Historical comparison operations. This namespace is the read-only interface
31
+ # for "what changed?" — everything else in the codebase, and anything built on
32
+ # top of Rails Pulse later, should ask these rather than querying summaries
33
+ # directly.
34
+ module Operations
35
+ autoload :ChangePoint, File.expand_path("../../app/services/rails_pulse/operations/change_point", __dir__)
36
+ autoload :Compare, File.expand_path("../../app/services/rails_pulse/operations/compare", __dir__)
37
+ autoload :Comparison, File.expand_path("../../app/services/rails_pulse/operations/comparison", __dir__)
38
+ autoload :Metric, File.expand_path("../../app/services/rails_pulse/operations/metric", __dir__)
39
+ autoload :Series, File.expand_path("../../app/services/rails_pulse/operations/series", __dir__)
40
+ autoload :Subject, File.expand_path("../../app/services/rails_pulse/operations/subject", __dir__)
41
+ end
20
42
 
21
43
  # Analysis services
22
44
  module Analysis
@@ -33,6 +55,7 @@ module RailsPulse
33
55
  module Installers
34
56
  autoload :MigrationInstaller, File.expand_path("installers/migration_installer", __dir__)
35
57
  autoload :ConfigInstaller, File.expand_path("installers/config_installer", __dir__)
58
+ autoload :ConfigUpdater, File.expand_path("installers/config_updater", __dir__)
36
59
  end
37
60
 
38
61
  # Stats reporting services
@@ -64,24 +87,12 @@ module RailsPulse
64
87
  end
65
88
 
66
89
  initializer "rails_pulse.assets" do |app|
67
- # Register Rails Pulse assets with Sprockets for production/CDN deployment
68
- if app.config.respond_to?(:assets)
69
- # Add vendor assets to the asset pipeline
70
- app.config.assets.paths << Engine.root.join("vendor", "assets", "stylesheets").to_s
71
- app.config.assets.paths << Engine.root.join("vendor", "assets", "javascripts").to_s
72
-
73
- # Register bundled assets for precompilation
74
- if defined?(::Sprockets)
75
- app.config.assets.precompile += %w[
76
- rails-pulse.css
77
- rails-pulse.js
78
- rails-pulse-icons.js
79
- ]
80
- end
81
- end
90
+ next unless RailsPulse.configuration.mount_dashboard
82
91
 
83
- # Fallback: Add middleware for development/non-CDN setups
84
- # This serves assets directly when not using precompiled manifest
92
+ # Dashboard assets are bundled at gem build time. They are not registered
93
+ # with Sprockets (re-minifying the 2 MB bundle OOMs small hosts — #190).
94
+ # assets:precompile copies them into public/assets for CDN/CSP; the
95
+ # middleware is the development / no-pipeline fallback.
85
96
  assets_path = Engine.root.join("public")
86
97
  app.middleware.insert_after Rack::Runtime, RailsPulse::Middleware::AssetServer,
87
98
  assets_path.to_s,
@@ -99,6 +110,10 @@ module RailsPulse
99
110
  RailsPulse::Subscribers::OperationSubscriber.subscribe!
100
111
  end
101
112
 
113
+ initializer "rails_pulse.exception_notifications" do
114
+ RailsPulse::Subscribers::ExceptionSubscriber.subscribe!
115
+ end
116
+
102
117
  initializer "rails_pulse.active_job" do
103
118
  ActiveSupport.on_load(:active_job) do
104
119
  include RailsPulse::ActiveJobExtensions
@@ -0,0 +1,202 @@
1
+ module RailsPulse
2
+ module Installers
3
+ # Inserts settings from the install template that the host initializer does
4
+ # not already mention. Existing lines are never rewritten, so `git diff` is
5
+ # the review: keep or discard hunks. Values that differ between a new
6
+ # install and an upgrade (today: track_exceptions) use the gem default.
7
+ class ConfigUpdater
8
+ ASSIGNMENT = /\A\s*(#\s*)?config\.(\w+)\s*=/.freeze
9
+ HASH_ENTRY = /\A\s*([a-z_][a-z0-9_]*):/.freeze
10
+
11
+ UPGRADE_VALUE_OVERRIDES = {
12
+ "track_exceptions" => "false"
13
+ }.freeze
14
+
15
+ BANNER = <<~RUBY.gsub(/^/, " ")
16
+ # ====================================================================================================
17
+ # ADDED BY rails generate rails_pulse:upgrade
18
+ # ====================================================================================================
19
+ # New settings from this gem version. Existing values above were not changed.
20
+ # Review with git diff and keep or discard hunks as you like.
21
+
22
+ RUBY
23
+
24
+ attr_reader :output, :source, :destination
25
+
26
+ def self.update(destination:, source: nil, output: $stdout)
27
+ new(destination: destination, source: source, output: output).update
28
+ end
29
+
30
+ def self.template_path
31
+ File.expand_path("../../../../lib/generators/rails_pulse/templates/rails_pulse.rb", __FILE__)
32
+ end
33
+
34
+ def initialize(destination:, source: nil, output: $stdout)
35
+ @destination = destination
36
+ @source = source || self.class.template_path
37
+ @output = output
38
+ end
39
+
40
+ def update
41
+ return { status: :missing, keys: [], hash_keys: [] } unless File.exist?(destination)
42
+
43
+ host = File.read(destination)
44
+ missing_keys = template_settings.map { |setting| setting[:key] } - mentioned_keys(host)
45
+ hash_keys = missing_hash_entries(host, missing_keys)
46
+
47
+ return { status: :unchanged, keys: [], hash_keys: [] } if missing_keys.empty? && hash_keys.empty?
48
+
49
+ updated = host
50
+ updated = inject_hash_entries(updated, hash_keys) if hash_keys.any?
51
+ updated = append_settings(updated, missing_keys) if missing_keys.any?
52
+ File.write(destination, updated)
53
+
54
+ { status: :updated, keys: missing_keys, hash_keys: hash_keys.map { |entry| entry[:name] } }
55
+ end
56
+
57
+ private
58
+
59
+ def template_settings
60
+ @template_settings ||= parse_settings(File.read(source))
61
+ end
62
+
63
+ def parse_settings(content)
64
+ settings = []
65
+ comments = []
66
+ lines = content.lines
67
+ index = 0
68
+
69
+ while index < lines.size
70
+ line = lines[index]
71
+
72
+ if line.match?(ASSIGNMENT)
73
+ key = line[ASSIGNMENT, 2]
74
+ assignment, index = read_multiline(lines, index)
75
+ unless settings.any? { |setting| setting[:key] == key }
76
+ settings << { key: key, lines: comments + assignment }
77
+ end
78
+ comments = []
79
+ elsif preamble?(line)
80
+ comments = []
81
+ index += 1
82
+ else
83
+ comments << line
84
+ index += 1
85
+ end
86
+ end
87
+
88
+ settings
89
+ end
90
+
91
+ def preamble?(line)
92
+ line.match?(/\A\s*(RailsPulse\.configure|end)\b/) || line.match?(/\A\s*do\s*\|config\|/)
93
+ end
94
+
95
+ def read_multiline(lines, index)
96
+ collected = [ lines[index] ]
97
+ depth = nest_delta(lines[index])
98
+ index += 1
99
+
100
+ while index < lines.size && depth.positive?
101
+ collected << lines[index]
102
+ depth += nest_delta(lines[index])
103
+ index += 1
104
+ end
105
+
106
+ [ collected, index ]
107
+ end
108
+
109
+ def nest_delta(line)
110
+ code = line.sub(/#.*\z/, "")
111
+ code.count("{") - code.count("}") + code.count("[") - code.count("]")
112
+ end
113
+
114
+ def mentioned_keys(content)
115
+ content.scan(/config\.(\w+)/).flatten.uniq
116
+ end
117
+
118
+ def missing_hash_entries(host, missing_keys)
119
+ template_settings.filter_map do |setting|
120
+ next if missing_keys.include?(setting[:key])
121
+ next unless host.match?(/^\s*config\.#{Regexp.escape(setting[:key])}\s*=\s*\{/)
122
+
123
+ setting[:lines].filter_map do |line|
124
+ name = line[HASH_ENTRY, 1]
125
+ next unless name
126
+ next if host.match?(/^\s*#{Regexp.escape(name)}:/)
127
+
128
+ { setting: setting[:key], name: name, line: line }
129
+ end
130
+ end.flatten
131
+ end
132
+
133
+ def inject_hash_entries(content, entries)
134
+ entries.group_by { |entry| entry[:setting] }.reduce(content) do |text, (setting, grouped)|
135
+ pattern = /(^\s*config\.#{Regexp.escape(setting)}\s*=\s*\{)(.*?)(\n\s*\})/m
136
+ text.sub(pattern) do
137
+ opening, body, closing = Regexp.last_match[1], Regexp.last_match[2], Regexp.last_match[3]
138
+ "#{opening}#{body_with_comma(body)}\n#{grouped.map { |entry| entry[:line].rstrip }.join("\n")}#{closing}"
139
+ end
140
+ end
141
+ end
142
+
143
+ def body_with_comma(body)
144
+ lines = body.lines
145
+ index = lines.rindex { |line| line.strip != "" }
146
+ return body unless index
147
+
148
+ working = lines[index].chomp
149
+ return body if working.match?(/,\s*(#.*)?\z/)
150
+
151
+ working = if working.include?("#")
152
+ working.sub(/\s+#/, ", #")
153
+ else
154
+ "#{working},"
155
+ end
156
+ lines[index] = "#{working}\n"
157
+ lines.join
158
+ end
159
+
160
+ def append_settings(content, keys)
161
+ blocks = keys.filter_map do |key|
162
+ setting = template_settings.find { |candidate| candidate[:key] == key }
163
+ next unless setting
164
+
165
+ apply_overrides(setting[:lines], key).join
166
+ end
167
+ return content if blocks.empty?
168
+
169
+ insertion = BANNER + blocks.join("\n").gsub(/\n+\z/, "\n")
170
+ insert_before_configure_end(content, insertion)
171
+ end
172
+
173
+ def insert_before_configure_end(content, insertion)
174
+ lines = content.lines
175
+ start_index = lines.index { |line| line.match?(/^\s*RailsPulse\.configure\b/) }
176
+
177
+ unless start_index
178
+ return content + "\nRailsPulse.configure do |config|\n#{insertion}end\n"
179
+ end
180
+
181
+ indent = lines[start_index][/\A\s*/]
182
+ end_index = ((start_index + 1)...lines.size).find do |index|
183
+ lines[index].match?(/\A#{Regexp.escape(indent)}end\b/)
184
+ end
185
+
186
+ unless end_index
187
+ return content.sub(/\nend\s*\z/, "\n\n#{insertion}end\n")
188
+ end
189
+
190
+ (lines[0...end_index] + [ "\n#{insertion}" ] + lines[end_index..]).join
191
+ end
192
+
193
+ def apply_overrides(lines, key)
194
+ return lines unless UPGRADE_VALUE_OVERRIDES.key?(key)
195
+
196
+ lines.map do |line|
197
+ line.sub(/config\.#{Regexp.escape(key)}\s*=\s*.*/, "config.#{key} = #{UPGRADE_VALUE_OVERRIDES[key]}")
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end