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
@@ -6,6 +6,11 @@ RailsPulse.configure do |config|
6
6
  # Enable or disable Rails Pulse
7
7
  config.enabled = true
8
8
 
9
+ # Tracking writes happen on a background thread by default (see `config.async`
10
+ # under ADVANCED). Transactional tests share one database connection across
11
+ # threads, so write inline there to keep the writer off the test's connection.
12
+ config.async = false if Rails.env.test?
13
+
9
14
  # ====================================================================================================
10
15
  # THRESHOLDS
11
16
  # ====================================================================================================
@@ -116,6 +121,58 @@ RailsPulse.configure do |config|
116
121
 
117
122
  config.tags = [ "ignored", "critical", "experimental" ]
118
123
 
124
+ # ====================================================================================================
125
+ # EXCEPTION TRACKING
126
+ # ====================================================================================================
127
+ # When enabled, Rails Pulse captures unhandled exceptions raised during web
128
+ # requests and failed background jobs, groups them by class and location, and
129
+ # displays them in the Exceptions tab. Rake tasks are not captured automatically —
130
+ # call ExceptionCaptureService.capture yourself if needed.
131
+ #
132
+ # Capture runs synchronously on the calling thread (upsert + insert). That keeps
133
+ # the implementation simple for v1; under an error storm it adds DB latency to
134
+ # failing requests and jobs. Set track_exceptions = false to disable.
135
+ #
136
+ # The gem default is false so existing installs do not start capturing on
137
+ # upgrade. New apps get true from this template; the upgrade generator
138
+ # inserts false into existing initializers.
139
+ #
140
+ # Backtraces store the first 50 frames. Request params are filtered via Rails'
141
+ # filter_parameters. Exception messages are filtered too: the SQL and echoed
142
+ # values in ActiveRecord::StatementInvalid messages are stripped, and any
143
+ # `key=value` / `key: value` fragment whose key matches filter_parameters
144
+ # (password=…, token: …) is masked. Messages are truncated to 500 characters.
145
+
146
+ # Enable or disable exception tracking
147
+ config.track_exceptions = true
148
+
149
+ # Capture request params with each exception occurrence.
150
+ # Params are filtered using Rails' filter_parameters config (passwords, tokens, etc. are redacted).
151
+ # Occurrences with params larger than 10KB after filtering are stored without params.
152
+ # Set to false to disable entirely, e.g. for strict data-minimization requirements.
153
+ config.capture_exception_params = true
154
+
155
+ # Extra redaction for exception messages, applied after the built-in
156
+ # filter_parameters pass. Receives the message and the exception; return the
157
+ # message to store. If the hook raises, the message is stored as "[FILTERED]".
158
+ # config.exception_message_filter = ->(message, exception) {
159
+ # message.gsub(/\b\d{13,16}\b/, "[FILTERED]") # card-number-shaped digits
160
+ # }
161
+
162
+ # How many times an exception group has to fire over the dashboard period
163
+ # before it is called out. These are occurrence counts, not durations.
164
+ # config.exception_thresholds = {
165
+ # warning: 10,
166
+ # critical: 100
167
+ # }
168
+
169
+ # Capture the raw (unparameterized) SQL for each operation.
170
+ # WARNING: mysql2 defaults to prepared_statements: false, so every literal
171
+ # value (emails, passwords, tokens) is inlined and stored in plaintext.
172
+ # Same applies to PostgreSQL behind PgBouncer in transaction-pool mode.
173
+ # Default: false (upgrade-safe). New installs may opt in after review.
174
+ # config.capture_actual_sql = true
175
+
119
176
  # ====================================================================================================
120
177
  # BACKGROUND JOBS
121
178
  # ====================================================================================================
@@ -144,8 +201,21 @@ RailsPulse.configure do |config|
144
201
  config.ignored_queues = []
145
202
 
146
203
  # Capture job arguments for debugging (may contain sensitive data)
147
- # Set to false in production to avoid storing potentially sensitive information
148
- config.capture_job_arguments = true
204
+ # WARNING: job arguments may contain user credentials, PII, or API keys.
205
+ # Default: false. Set to true only after reviewing your job argument contents.
206
+ config.capture_job_arguments = false
207
+
208
+ # Job tracking mode: :universal (all jobs) or :opt_in (only explicitly tracked jobs)
209
+ # config.job_tracking_mode = :universal
210
+
211
+ # Per-adapter settings. Disable adapters or opt into queue-depth tracking:
212
+ # config.job_adapters = {
213
+ # sidekiq: { enabled: true, track_queue_depth: false },
214
+ # solid_queue: { enabled: true, track_recurring: false },
215
+ # good_job: { enabled: true, track_cron: false },
216
+ # delayed_job: { enabled: true },
217
+ # resque: { enabled: true }
218
+ # }
149
219
 
150
220
  # ====================================================================================================
151
221
  # DATABASE CONFIGURATION
@@ -177,26 +247,37 @@ RailsPulse.configure do |config|
177
247
  # password: <%= Rails.application.credentials.dig(:rails_pulse, :database_password) %>
178
248
  # host: localhost
179
249
  # pool: 5
250
+ # migrations_paths: db/rails_pulse_migrate
251
+ # schema_dump: false
180
252
 
181
253
  # ====================================================================================================
182
254
  # AUTHENTICATION
183
255
  # ====================================================================================================
184
256
  # Configure authentication to secure access to the Rails Pulse dashboard.
185
- # Authentication is ENABLED BY DEFAULT in production environments for security.
257
+ # Authentication is ENABLED BY DEFAULT outside development and test.
186
258
  #
187
- # If no authentication method is configured, Rails Pulse will use HTTP Basic Auth
259
+ # If nothing below is configured, Rails Pulse will use HTTP Basic Auth
188
260
  # with credentials from RAILS_PULSE_USERNAME (default: 'admin') and RAILS_PULSE_PASSWORD
189
261
  # environment variables. Set RAILS_PULSE_PASSWORD to enable this fallback.
190
- #
191
- # Uncomment and configure one of the following patterns based on your authentication system:
192
262
 
193
- # Enable/disable authentication (enabled by default in production)
194
- # config.authentication_enabled = Rails.env.production?
263
+ # Enable/disable authentication (enabled by default outside development/test)
264
+ # config.authentication_enabled = !Rails.env.local?
195
265
 
196
- # Where to redirect unauthorized users
266
+ # Where to redirect users when an authentication hook raises
197
267
  # config.authentication_redirect_path = "/"
198
268
 
199
- # Custom authentication method - choose one of the examples below:
269
+ # RECOMMENDED: a fail-closed predicate. Receives the controller; return
270
+ # true to allow. Anything else (false, nil, no user) is a 403 Forbidden.
271
+ # config.authorize = ->(controller) { controller.current_user&.admin? }
272
+ #
273
+ # A zero-argument proc runs in the controller's context instead:
274
+ # config.authorize = proc { user_signed_in? && current_user.admin? }
275
+
276
+ # Alternatively, a hook that runs in the controller and DENIES BY RENDERING
277
+ # OR REDIRECTING. Returning false without responding is also a denial;
278
+ # returning nil (what `unless … end` returns on success) allows the request.
279
+ # Do not write predicate-style checks here — use config.authorize for those.
280
+ # If both are set, authentication_method runs first, then authorize.
200
281
 
201
282
  # Example 1: Devise with admin role check
202
283
  # config.authentication_method = proc {
@@ -220,7 +301,8 @@ RailsPulse.configure do |config|
220
301
  # Example 4: Basic HTTP authentication
221
302
  # config.authentication_method = proc {
222
303
  # authenticate_or_request_with_http_basic do |username, password|
223
- # username == ENV['RAILS_PULSE_USERNAME'] && password == ENV['RAILS_PULSE_PASSWORD']
304
+ # ActiveSupport::SecurityUtils.secure_compare(username, ENV['RAILS_PULSE_USERNAME']) &&
305
+ # ActiveSupport::SecurityUtils.secure_compare(password, ENV['RAILS_PULSE_PASSWORD'])
224
306
  # end
225
307
  # }
226
308
 
@@ -246,6 +328,10 @@ RailsPulse.configure do |config|
246
328
  # config.deployment_api_token = Rails.application.credentials.dig(:rails_pulse, :deployment_api_token)
247
329
  # config.deployment_api_token = ENV["RAILS_PULSE_DEPLOYMENT_TOKEN"]
248
330
  #
331
+ # Limits: revision ≤ 255 characters, metadata ≤ 4 KB serialized, started_at at most
332
+ # one hour in the future. Rows beyond max_table_records[:rails_pulse_deployments]
333
+ # are pruned oldest-first by the cleanup task.
334
+ #
249
335
  # Record a deployment from your CI/CD pipeline:
250
336
  # curl -X POST https://yourapp.com/rails_pulse/deployments \
251
337
  # -H "X-Rails-Pulse-Token: $RAILS_PULSE_DEPLOYMENT_TOKEN" \
@@ -273,15 +359,70 @@ RailsPulse.configure do |config|
273
359
  config.archiving_enabled = true
274
360
 
275
361
  # Time-based retention - delete records older than this period
276
- config.full_retention_period = 2.weeks
362
+ config.full_retention_period = 30.days
277
363
 
278
364
  # Count-based retention - maximum records to keep per table
279
365
  # After time-based cleanup, if tables still exceed these limits,
280
366
  # the oldest remaining records will be deleted to stay under the limit
281
367
  config.max_table_records = {
282
- rails_pulse_requests: 10000, # HTTP requests (moderate volume)
283
- rails_pulse_operations: 50000, # Operations within requests (high volume)
284
- rails_pulse_routes: 1000, # Unique routes (low volume)
285
- rails_pulse_queries: 500 # Normalized SQL queries (low volume)
368
+ rails_pulse_requests: 50_000, # HTTP requests (moderate volume)
369
+ rails_pulse_operations: 100_000, # Operations within requests (high volume)
370
+ rails_pulse_routes: 1_000, # Unique routes (low volume)
371
+ rails_pulse_queries: 10_000, # Normalized SQL queries (low volume)
372
+ rails_pulse_job_runs: 50_000, # Individual job executions (high volume)
373
+ rails_pulse_jobs: 1_000, # Unique job classes (low volume)
374
+ rails_pulse_exception_occurrences: 50_000, # Individual exception raises (high volume)
375
+ rails_pulse_exception_groups: 10_000, # Distinct exception sites (moderate volume)
376
+ rails_pulse_deployments: 1_000 # Deploy markers (low volume; oldest pruned first)
286
377
  }
378
+
379
+ # ====================================================================================================
380
+ # HISTORICAL COMPARISON
381
+ # ====================================================================================================
382
+
383
+ # Rails Pulse compares recent behaviour against a route, query or job's own
384
+ # history to answer "what changed?" rather than only "what is slow?".
385
+ #
386
+ # The baseline is the traffic-weighted metric across `baseline_window` of day
387
+ # summaries; `comparison_window` is the recent slice measured against it. The
388
+ # two never overlap.
389
+ # config.baseline_window = 28.days
390
+ # config.comparison_window = 1.day
391
+
392
+ # Hourly summaries decide how precisely a change point can be placed. They are
393
+ # pruned at this age because the 1-day view is otherwise the only thing that
394
+ # reads them. Inside this window Rails Pulse can say a route slowed down at
395
+ # 14:00; beyond it, the finest answer is the day. Raising this buys precision
396
+ # at the cost of summary table growth.
397
+ # config.hourly_summary_retention = 2.days
398
+
399
+ # A change is reported as a regression only when it clears both the ratio and
400
+ # the absolute floor for its unit. The ratio alone flags trivial millisecond
401
+ # noise on fast endpoints; the floor alone flags slow endpoints that never
402
+ # actually changed. min_samples keeps low-traffic subjects quiet.
403
+ # config.regression_thresholds = {
404
+ # ratio: 1.5, # 50% worse than baseline
405
+ # min_delta_ms: 50.0, # ...and at least 50ms worse
406
+ # min_delta_rate: 1.0, # ...or 1 percentage point, for error rates
407
+ # min_samples: 100, # minimum observations on each side
408
+ # min_baseline_periods: 3 # minimum days of history before comparing
409
+ # }
410
+
411
+ # ====================================================================================================
412
+ # ADVANCED
413
+ # ====================================================================================================
414
+
415
+ # Use a custom logger (default: Rails.logger)
416
+ # config.logger = Logger.new("log/rails_pulse.log")
417
+
418
+ # Perform tracking writes in a background thread (default: true).
419
+ # When false, writes happen inline before the response is sent.
420
+ # config.async = true
421
+
422
+ # Show a dashboard banner when summary data is stale (default: true)
423
+ # config.warn_on_stale_summaries = true
424
+
425
+ # Set to false to skip dashboard middleware and asset serving entirely.
426
+ # Useful for standalone/API-only deployments that use a separate dashboard app.
427
+ # config.mount_dashboard = true
287
428
  end
@@ -1,5 +1,6 @@
1
1
  require_relative "base_methods"
2
2
  require_relative "schema_parser"
3
+ require_relative "../../rails_pulse/installers/config_updater"
3
4
 
4
5
  module RailsPulse
5
6
  module Generators
@@ -21,9 +22,10 @@ module RailsPulse
21
22
 
22
23
  case @database_type
23
24
  when :single
24
- upgrade_installation(migration_dir: "db/migrate", next_steps: single_db_next_steps)
25
+ upgrade_installation(migration_dir: "db/migrate", migrate_command: "rails db:migrate")
25
26
  when :separate
26
- upgrade_installation(migration_dir: "db/rails_pulse_migrate", next_steps: separate_db_next_steps)
27
+ warn_if_missing_schema_dump_false
28
+ upgrade_installation(migration_dir: "db/rails_pulse_migrate", migrate_command: "rails db:migrate:rails_pulse")
27
29
  when :schema_only
28
30
  offer_conversion_to_migrations
29
31
  when :not_installed
@@ -38,16 +40,20 @@ module RailsPulse
38
40
  # Override with command line option if provided
39
41
  return options[:database].to_sym if options[:database] != "detect"
40
42
 
41
- # Check for existing Rails Pulse tables
43
+ # Determine database type before checking tables so the table lookup
44
+ # uses the correct connection pool for separate-database setups.
45
+ @is_separate_db = has_separate_database_config?
42
46
  tables_exist = rails_pulse_tables_exist?
43
-
44
47
  schema_path = File.join(root_path, "db/rails_pulse_schema.rb")
45
48
 
46
- if !tables_exist && File.exist?(schema_path)
49
+ # :schema_only only applies to single-database users who have the schema file
50
+ # but haven't run the install migration yet. Separate-database users legitimately
51
+ # have no Rails Pulse tables on the primary connection — that is not an error.
52
+ if !tables_exist && !@is_separate_db && File.exist?(schema_path)
47
53
  :schema_only
48
54
  elsif !tables_exist
49
55
  :not_installed
50
- elsif has_separate_database_config?
56
+ elsif @is_separate_db
51
57
  :separate
52
58
  else
53
59
  :single
@@ -56,25 +62,30 @@ module RailsPulse
56
62
 
57
63
  def has_separate_database_config?
58
64
  config_path = File.join(root_path, "config/database.yml")
59
-
60
65
  return false unless File.exist?(config_path)
61
66
 
62
67
  require "yaml"
63
- db_config = YAML.safe_load(File.read(config_path), aliases: true)
64
-
65
- # Check if any environment has a rails_pulse database configuration
68
+ require "erb"
69
+ # Process ERB before YAML parsing — database.yml files commonly use ERB
70
+ # for environment-specific values. YAML.safe_load alone raises SyntaxError
71
+ # on ERB tags.
72
+ yaml_content = ERB.new(File.read(config_path)).result
73
+ db_config = YAML.safe_load(yaml_content, aliases: true)
66
74
  db_config.values.any? { |env| env.is_a?(Hash) && env.key?("rails_pulse") }
67
- rescue Psych::SyntaxError, Psych::AliasesNotEnabled, Errno::ENOENT
68
- # If we can't read or parse the file, assume single database
75
+ rescue
69
76
  false
70
77
  end
71
78
 
72
79
  def rails_pulse_tables_exist?
73
80
  return false unless defined?(ActiveRecord::Base)
74
81
 
75
- connection = ActiveRecord::Base.connection
76
- required_tables = get_rails_pulse_table_names
82
+ connection = if @is_separate_db && defined?(RailsPulse::ApplicationRecord)
83
+ RailsPulse::ApplicationRecord.connection
84
+ else
85
+ ActiveRecord::Base.connection
86
+ end
77
87
 
88
+ required_tables = get_rails_pulse_table_names
78
89
  required_tables.all? { |table| connection.table_exists?(table) }
79
90
  rescue
80
91
  false
@@ -85,8 +96,41 @@ module RailsPulse
85
96
  SchemaParser.new(schema_file).extract_table_names
86
97
  end
87
98
 
99
+ # Notices printed when a feature's migration is newly copied into the host app.
100
+ # Key is a substring of the migration filename (without timestamp).
101
+ FEATURE_NOTICES = {
102
+ "create_rails_pulse_exceptions" => <<~NOTICE.rstrip
103
+ Exception tracking (opt-in)
104
+
105
+ This upgrade adds exception tables. Capture stays off for existing
106
+ installs until you opt in. After migrating, set the following in
107
+ config/initializers/rails_pulse.rb (the upgrade generator inserts
108
+ it as false — review with git diff) and restart:
109
+
110
+ config.track_exceptions = true
111
+
112
+ New installs enable this in the generated initializer. Messages and
113
+ request params are both redacted using Rails filter_parameters; set
114
+ config.exception_message_filter for anything app-specific.
115
+
116
+ Two new tables will be created:
117
+ - rails_pulse_exception_groups
118
+ - rails_pulse_exception_occurrences
119
+
120
+ To skip the tables entirely, delete the copied
121
+ *_create_rails_pulse_exceptions.rb migration before db:migrate.
122
+ (A later upgrade will copy it again unless that file remains in
123
+ your migrate folder.)
124
+ NOTICE
125
+ }.freeze
126
+
88
127
  # Shared upgrade logic for both single and separate database setups
89
- def upgrade_installation(migration_dir:, next_steps:)
128
+ def upgrade_installation(migration_dir:, migrate_command:)
129
+ # Refresh the schema file so fresh databases (test, CI) built from
130
+ # db/rails_pulse_schema.rb include all current columns and tables.
131
+ copy_file "db/rails_pulse_schema.rb", "db/rails_pulse_schema.rb", force: true
132
+ sync_initializer
133
+
90
134
  gem_migrations = get_gem_migrations
91
135
  existing_migrations = get_user_migrations(migration_dir)
92
136
  new_migrations = gem_migrations - existing_migrations
@@ -99,18 +143,38 @@ module RailsPulse
99
143
  end
100
144
 
101
145
  say "\nMigrations copied successfully!", :green
102
- say "\nNext steps:", :green
103
- next_steps.each { |step| say step }
146
+ announce_new_features(new_migrations)
147
+ include_route_backfill = requires_route_backfill?(new_migrations)
148
+ say_route_backfill_warning if include_route_backfill
149
+ say_next_steps(migrate_command, include_route_backfill: include_route_backfill)
104
150
  else
105
- upgrade_with_missing_columns(migration_dir: migration_dir, next_steps: next_steps)
151
+ upgrade_with_missing_columns(migration_dir: migration_dir, migrate_command: migrate_command)
106
152
  end
107
153
  end
108
154
 
109
- def upgrade_with_missing_columns(migration_dir:, next_steps:)
155
+ def announce_new_features(new_migrations)
156
+ notices = FEATURE_NOTICES.filter_map do |migration_key, notice|
157
+ notice if new_migrations.any? { |filename| filename.include?(migration_key) }
158
+ end
159
+ return if notices.empty?
160
+
161
+ say "\n" + ("=" * 72), :yellow
162
+ notices.each_with_index do |notice, index|
163
+ say "" if index.positive?
164
+ notice.each_line { |line| say line.chomp, :yellow }
165
+ end
166
+ say ("=" * 72) + "\n", :yellow
167
+ end
168
+
169
+ def upgrade_with_missing_columns(migration_dir:, migrate_command:)
110
170
  missing_columns = detect_missing_columns
111
171
 
112
172
  if missing_columns.empty?
113
- say "Rails Pulse is up to date! No migration needed.", :green
173
+ if @initializer_updated
174
+ say "Schema is up to date. Review initializer changes with git diff.", :green
175
+ else
176
+ say "Rails Pulse is up to date! No migration needed.", :green
177
+ end
114
178
  return
115
179
  end
116
180
 
@@ -126,31 +190,91 @@ module RailsPulse
126
190
  "#{migration_dir}/upgrade_rails_pulse_tables.rb"
127
191
  )
128
192
 
129
- say <<~MESSAGE
193
+ say "\nUpgrade migration created successfully!", :green
194
+ missing_names = missing_columns.keys.map(&:to_s)
195
+ include_route_backfill = missing_names.intersect?(%w[controller_action http_methods])
196
+ say_route_backfill_warning if include_route_backfill
197
+ say_next_steps(migrate_command, include_route_backfill: include_route_backfill)
198
+ say "\nThis migration will add: #{missing_columns.keys.join(', ')}\n"
199
+ end
130
200
 
131
- Upgrade migration created successfully!
201
+ ROUTE_BACKFILL_MIGRATIONS = %w[
202
+ change_rails_pulse_routes_to_multi_verb_model
203
+ add_null_action_unique_index_to_routes
204
+ ].freeze
132
205
 
133
- Next steps:
134
- #{next_steps.map { |s| " #{s}" }.join("\n")}
206
+ def requires_route_backfill?(migrations)
207
+ migrations.any? do |name|
208
+ ROUTE_BACKFILL_MIGRATIONS.any? { |fragment| name.include?(fragment) }
209
+ end
210
+ end
135
211
 
136
- This migration will add: #{missing_columns.keys.join(', ')}
212
+ def say_route_backfill_warning
213
+ say "\nIMPORTANT: This upgrade changes how routes are identified.", :yellow
214
+ say "Schema migrate alone leaves the Action column empty. After migrating, run:", :yellow
215
+ say " rails rails_pulse:migrate_routes", :yellow
216
+ say "Skipping this leaves GET/POST on the same path unmerged in the dashboard.", :yellow
217
+ say "\nWARNING: Migration 20260610000002 is irreversible (drops routes.method).", :red
218
+ say "db:rollback past this point requires a database restore.", :red
219
+ end
137
220
 
138
- MESSAGE
221
+ def say_next_steps(migrate_command, include_route_backfill:)
222
+ n = 1
223
+ say "\nNext steps:", :green
224
+ say "#{n}. Run: #{migrate_command}"
225
+ n += 1
226
+ if include_route_backfill
227
+ say "#{n}. Run: rails rails_pulse:migrate_routes"
228
+ n += 1
229
+ end
230
+ if @initializer_updated
231
+ say "#{n}. Review config/initializers/rails_pulse.rb with git diff"
232
+ n += 1
233
+ end
234
+ say "#{n}. Restart ALL processes (web + workers) together — a rolling restart"
235
+ say " that leaves old processes against the new schema breaks tracking."
236
+ end
237
+
238
+ def sync_initializer
239
+ path = File.join(root_path, "config/initializers/rails_pulse.rb")
240
+ result = RailsPulse::Installers::ConfigUpdater.update(
241
+ destination: path,
242
+ source: File.join(self.class.source_root, "rails_pulse.rb")
243
+ )
244
+ @initializer_updated = result[:status] == :updated
245
+ return unless @initializer_updated
246
+
247
+ say "\nUpdated config/initializers/rails_pulse.rb with new settings:", :blue
248
+ result[:keys].each { |key| say " - config.#{key}", :blue }
249
+ result[:hash_keys].each { |key| say " - #{key}", :blue }
250
+ say "Review with git diff and keep or discard hunks.", :green
139
251
  end
140
252
 
141
- def single_db_next_steps
142
- [
143
- "1. Run: rails db:migrate",
144
- "2. Restart your Rails server"
145
- ]
253
+ def warn_if_missing_schema_dump_false
254
+ return unless separate_database_missing_schema_dump_false?
255
+
256
+ say "\nIMPORTANT: Add schema_dump: false to the rails_pulse entry in", :yellow
257
+ say "config/database.yml. Without it, Rails may dump or load", :yellow
258
+ say "db/rails_pulse_structure.sql and db:migrate can fail with", :yellow
259
+ say "\"relation already exists\". Delete that structure file if present.", :yellow
146
260
  end
147
261
 
148
- def separate_db_next_steps
149
- [
150
- "1. Run migrations for the rails_pulse database:",
151
- " rails db:migrate:rails_pulse",
152
- "2. Restart your Rails server"
153
- ]
262
+ def separate_database_missing_schema_dump_false?
263
+ config_path = File.join(root_path, "config/database.yml")
264
+ return false unless File.exist?(config_path)
265
+
266
+ require "yaml"
267
+ require "erb"
268
+ yaml_content = ERB.new(File.read(config_path)).result
269
+ db_config = YAML.safe_load(yaml_content, aliases: true)
270
+ pulse_entries = db_config.values.filter_map do |env|
271
+ env["rails_pulse"] if env.is_a?(Hash)
272
+ end
273
+ return false if pulse_entries.empty?
274
+
275
+ pulse_entries.any? { |entry| !entry.is_a?(Hash) || entry["schema_dump"] != false }
276
+ rescue
277
+ false
154
278
  end
155
279
 
156
280
  def offer_conversion_to_migrations
@@ -170,7 +294,11 @@ module RailsPulse
170
294
  def detect_missing_columns
171
295
  return {} unless rails_pulse_tables_exist?
172
296
 
173
- connection = ActiveRecord::Base.connection
297
+ connection = if @is_separate_db && defined?(RailsPulse::ApplicationRecord)
298
+ RailsPulse::ApplicationRecord.connection
299
+ else
300
+ ActiveRecord::Base.connection
301
+ end
174
302
  missing = {}
175
303
 
176
304
  get_expected_schema_from_file.each do |table_name, columns|