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
@@ -17,32 +17,59 @@ module RailsPulse
17
17
  job = nil
18
18
  job_run = nil
19
19
 
20
- with_recording_suppressed do
21
- job = find_or_create_job(active_job)
22
- job_run = create_job_run(job, active_job, adapter, occurred_at)
20
+ # Pulse DB writes before yield must not prevent the job from running.
21
+ begin
22
+ with_recording_suppressed do
23
+ job = find_or_create_job(active_job)
24
+ job_run = create_job_run(job, active_job, adapter, occurred_at)
25
+ end
26
+ rescue => e
27
+ RailsPulse.logger.error "Failed to initialize job tracking: #{e.class} - #{e.message}"
28
+ job_run = nil
23
29
  end
24
30
 
25
31
  RequestStore.store[:rails_pulse_request_id] = nil
26
- RequestStore.store[:rails_pulse_job_run_id] = job_run.id
32
+ RequestStore.store[:rails_pulse_job_run_id] = job_run&.id
27
33
  RequestStore.store[:rails_pulse_operations] = []
28
34
 
29
35
  yield
30
36
 
31
- duration = elapsed_time_ms(start_time)
32
- with_recording_suppressed do
33
- job_run.update!(status: "success", duration: duration)
37
+ # Record success — must not re-raise if the job already completed.
38
+ begin
39
+ if job_run
40
+ duration = elapsed_time_ms(start_time)
41
+ with_recording_suppressed do
42
+ job_run.update!(status: "success", duration: duration)
43
+ end
44
+ end
45
+ rescue => e
46
+ RailsPulse.logger.error "Failed to record job success: #{e.class} - #{e.message}"
34
47
  end
35
48
  rescue => error
36
- duration = elapsed_time_ms(start_time)
37
- with_recording_suppressed do
38
- job_run.update!(
39
- status: failure_status_for(error),
40
- duration: duration,
41
- error_class: error.class.name,
42
- error_message: error.message
43
- ) if job_run
49
+ # Record failure — must always re-raise the ORIGINAL error.
50
+ begin
51
+ if job_run
52
+ duration = elapsed_time_ms(start_time)
53
+ with_recording_suppressed do
54
+ job_run.update!(
55
+ status: failure_status_for(error),
56
+ duration: duration,
57
+ error_class: error.class.name,
58
+ # Same redaction as exception occurrences — a job failure
59
+ # message can carry the same literal PII a request one can.
60
+ error_message: RailsPulse::ExceptionMessageSanitizer.for_exception(error)
61
+ )
62
+ end
63
+ end
64
+ # Capture outside with_recording_suppressed — the capture service
65
+ # checks skip_recording_rails_pulse_activity and would bail out.
66
+ # The service's own SQL is already filtered by the rails_pulse_ prefix.
67
+ RailsPulse::ExceptionCaptureService.capture(error, environment: Rails.env.to_s)
68
+ RequestStore.store[:rails_pulse_captured_exception] = error
69
+ rescue => e
70
+ RailsPulse.logger.error "Failed to record job failure: #{e.class} - #{e.message}"
44
71
  end
45
- raise
72
+ raise error
46
73
  ensure
47
74
  begin
48
75
  save_operations(job_run)
@@ -11,6 +11,10 @@ module RailsPulse
11
11
  ".svg" => "image/svg+xml"
12
12
  }.freeze
13
13
 
14
+ # "/rails-pulse-assets/0.3.3/rails-pulse.css" → lookup at
15
+ # public/rails-pulse-assets/rails-pulse.css. Unversioned paths still work.
16
+ VERSIONED_ASSET_PATH = %r{\A(/rails-pulse-assets/)(?:\d+\.\d+[\w.\-]*/)}.freeze
17
+
14
18
  def initialize(app, root, options = {})
15
19
  # Rack::Static expects (app, options) where options[:root] is the root path
16
20
  options = options.merge(root: root) if root.is_a?(String) || root.is_a?(Pathname)
@@ -23,6 +27,9 @@ module RailsPulse
23
27
  return @app.call(env)
24
28
  end
25
29
 
30
+ env = env.dup
31
+ env["PATH_INFO"] = strip_asset_version(env["PATH_INFO"])
32
+
26
33
  # Log asset requests for debugging
27
34
  RailsPulse.logger.debug "Asset request: #{env['PATH_INFO']}"
28
35
 
@@ -51,6 +58,10 @@ module RailsPulse
51
58
  env["PATH_INFO"]&.start_with?("/rails-pulse-assets/")
52
59
  end
53
60
 
61
+ def strip_asset_version(path)
62
+ path.to_s.sub(VERSIONED_ASSET_PATH, '\1')
63
+ end
64
+
54
65
  def cache_headers
55
66
  if defined?(Rails) && Rails.env.development?
56
67
  { "Cache-Control" => "no-cache, no-store, must-revalidate", "Pragma" => "no-cache" }
@@ -40,10 +40,11 @@ module RailsPulse
40
40
  # Deep copy operations array to prevent race condition in async mode
41
41
  operations = RequestStore.store[:rails_pulse_operations] || []
42
42
  detect_n_plus_one(operations)
43
- controller_action = operations.find { |op| op[:operation_type] == "controller" }&.[](:label)
43
+ path_params = env["action_dispatch.request.path_parameters"] || {}
44
+ controller_action = [ path_params[:controller], path_params[:action] ].compact.join("#").presence
44
45
  tracking_data = {
45
46
  method: req.request_method,
46
- path: req.path,
47
+ path: RailsPulse::RoutePathNormalizer.normalize(req.path, path_params),
47
48
  duration: duration,
48
49
  status: status,
49
50
  is_error: status.to_i >= 500,
@@ -54,8 +55,12 @@ module RailsPulse
54
55
  operations: operations.map(&:dup)
55
56
  }
56
57
 
57
- # Send to tracker (non-blocking if async mode enabled)
58
- RailsPulse::Tracker.track_request(tracking_data)
58
+ # Send to tracker — rescue ensures Pulse never 500s a host request.
59
+ begin
60
+ RailsPulse::Tracker.track_request(tracking_data)
61
+ rescue => e
62
+ RailsPulse.logger.error("RailsPulse tracking failed: #{e.class} - #{e.message}")
63
+ end
59
64
 
60
65
  [ status, headers, response ]
61
66
  ensure
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module RailsPulse
8
+ # Copies gem-built dashboard files into the host's public/assets with a
9
+ # content digest, then records them in a small manifest. This is hooked to
10
+ # assets:precompile so CDNs (asset_sync, CloudFront on /assets) and
11
+ # CDN-only CSP policies see the same URLs as the rest of the host app —
12
+ # without running the host js_compressor over the already-minified bundle.
13
+ class PackagedAssets
14
+ LOGICAL_NAMES = %w[
15
+ rails-pulse.css
16
+ rails-pulse.js
17
+ rails-pulse-icons.js
18
+ rails-pulse-logo.png
19
+ search.svg
20
+ ].freeze
21
+
22
+ MANIFEST_FILENAME = ".rails-pulse-manifest.json"
23
+
24
+ class << self
25
+ def install!(destination: default_destination)
26
+ FileUtils.mkdir_p(destination)
27
+ entries = {}
28
+
29
+ LOGICAL_NAMES.each do |logical_name|
30
+ source = source_path(logical_name)
31
+ next unless source.exist?
32
+
33
+ digested_name = digested_filename(logical_name, source)
34
+ FileUtils.cp(source, destination.join(digested_name))
35
+ copy_adjacent_source_map(source, destination)
36
+ entries[logical_name] = digested_name
37
+ end
38
+
39
+ File.write(destination.join(MANIFEST_FILENAME), JSON.pretty_generate(entries))
40
+ patch_host_manifests(destination, entries)
41
+ reset!
42
+ entries
43
+ end
44
+
45
+ def url_path(logical_name)
46
+ digested = manifest[logical_name.to_s]
47
+ return if digested.blank?
48
+
49
+ "/assets/#{digested}"
50
+ end
51
+
52
+ def manifest
53
+ @manifest ||= load_manifest
54
+ end
55
+
56
+ def reset!
57
+ @manifest = nil
58
+ end
59
+
60
+ def uninstall!(destination: default_destination)
61
+ path = destination.join(MANIFEST_FILENAME)
62
+ if path.exist?
63
+ JSON.parse(path.read).each_value do |filename|
64
+ FileUtils.rm_f(destination.join(filename))
65
+ end
66
+ FileUtils.rm_f(path)
67
+ end
68
+ %w[rails-pulse.js.map rails-pulse.css.map rails-pulse-icons.js.map].each do |map|
69
+ FileUtils.rm_f(destination.join(map))
70
+ end
71
+ reset!
72
+ end
73
+
74
+ private
75
+
76
+ def default_destination
77
+ Rails.public_path.join("assets")
78
+ end
79
+
80
+ def source_path(logical_name)
81
+ Engine.root.join("public", "rails-pulse-assets", logical_name)
82
+ end
83
+
84
+ def digested_filename(logical_name, source)
85
+ ext = File.extname(logical_name)
86
+ base = File.basename(logical_name, ext)
87
+ digest = Digest::SHA256.file(source).hexdigest
88
+ "#{base}-#{digest}#{ext}"
89
+ end
90
+
91
+ def copy_adjacent_source_map(source, destination)
92
+ map = Pathname.new("#{source}.map")
93
+ FileUtils.cp(map, destination.join(map.basename)) if map.exist?
94
+ end
95
+
96
+ def load_manifest
97
+ path = Rails.public_path.join("assets", MANIFEST_FILENAME)
98
+ return {} unless path.exist?
99
+
100
+ JSON.parse(path.read)
101
+ rescue JSON::ParserError
102
+ {}
103
+ end
104
+
105
+ def patch_host_manifests(destination, entries)
106
+ patch_propshaft_manifest(destination, entries)
107
+ patch_sprockets_manifests(destination, entries)
108
+ end
109
+
110
+ def patch_propshaft_manifest(destination, entries)
111
+ path = destination.join(".manifest.json")
112
+ return unless path.exist?
113
+
114
+ data = JSON.parse(path.read)
115
+ File.write(path, JSON.pretty_generate(data.merge(entries)))
116
+ rescue JSON::ParserError
117
+ nil
118
+ end
119
+
120
+ def patch_sprockets_manifests(destination, entries)
121
+ Dir.glob(destination.join(".sprockets-manifest-*.json")).each do |path|
122
+ data = JSON.parse(File.read(path))
123
+ data["assets"] ||= {}
124
+ data["files"] ||= {}
125
+ entries.each do |logical_name, digested_name|
126
+ data["assets"][logical_name] = digested_name
127
+ file = destination.join(digested_name)
128
+ data["files"][digested_name] = {
129
+ "logical_path" => logical_name,
130
+ "mtime" => file.mtime.iso8601,
131
+ "size" => file.size,
132
+ "digest" => digested_name[/-([a-f0-9]{32,})\./, 1],
133
+ "integrity" => integrity_for(file)
134
+ }
135
+ end
136
+ File.write(path, JSON.pretty_generate(data))
137
+ rescue JSON::ParserError
138
+ next
139
+ end
140
+ end
141
+
142
+ def integrity_for(file)
143
+ "sha256-#{Digest::SHA256.base64digest(file.binread)}"
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPulse
4
+ # Adapter-specific unique index that groups unrecognized routes (404s, middleware
5
+ # short-circuits) by path. PostgreSQL and SQLite use a partial unique index;
6
+ # MySQL uses a functional unique index because it has no partial indexes.
7
+ #
8
+ # Must not be added while upgrade rows still have a null controller_action on
9
+ # different-verb REST siblings (GET /users vs POST /users). Call this after
10
+ # RouteControllerActionBackfiller has assigned actions.
11
+ #
12
+ # PostgreSQL and SQLite go through `add_index(..., where:)` so `schema.rb`
13
+ # round-trips the predicate. Raw multiline `CREATE UNIQUE INDEX ... WHERE`
14
+ # is stored with a trailing newline that SQLite's schema dumper does not
15
+ # parse, so `db:schema:load` (and parallel test workers) recreate a unique
16
+ # index on every path. MySQL still uses SQL: `add_index(..., where:)` is
17
+ # silently ignored and would unique-index every path.
18
+ module RouteIndexes
19
+ NULL_ACTION_INDEX = "index_rails_pulse_routes_on_path_without_action"
20
+
21
+ def self.ensure_null_action_uniqueness!(connection)
22
+ return if exists?(connection)
23
+
24
+ adapter = connection.adapter_name.downcase
25
+
26
+ if adapter.include?("mysql")
27
+ version_string = connection.select_value("SELECT VERSION()")
28
+ if version_string.to_s.include?("MariaDB")
29
+ raise "Rails Pulse requires MySQL >= 8.0.13 for functional indexes. " \
30
+ "MariaDB is not supported — it does not implement the required " \
31
+ "CREATE INDEX ... ((expression)) syntax."
32
+ end
33
+
34
+ mysql_version = version_string.to_s.scan(/\A(\d+\.\d+\.\d+)/).flatten.first
35
+ if mysql_version && Gem::Version.new(mysql_version) < Gem::Version.new("8.0.13")
36
+ raise "Rails Pulse requires MySQL >= 8.0.13 for functional indexes " \
37
+ "(found #{mysql_version}). Upgrade MySQL or use PostgreSQL/SQLite."
38
+ end
39
+
40
+ table = connection.quote_table_name(:rails_pulse_routes)
41
+ connection.execute(<<~SQL.strip)
42
+ CREATE UNIQUE INDEX #{NULL_ACTION_INDEX}
43
+ ON #{table} ((CASE WHEN controller_action IS NULL THEN path ELSE NULL END))
44
+ SQL
45
+ else
46
+ connection.add_index(
47
+ :rails_pulse_routes,
48
+ :path,
49
+ unique: true,
50
+ where: "controller_action IS NULL",
51
+ name: NULL_ACTION_INDEX
52
+ )
53
+ end
54
+ end
55
+
56
+ def self.remove_null_action_uniqueness!(connection)
57
+ return unless exists?(connection)
58
+
59
+ connection.remove_index(:rails_pulse_routes, name: NULL_ACTION_INDEX)
60
+ end
61
+
62
+ def self.exists?(connection)
63
+ connection.indexes(:rails_pulse_routes).any? { |idx| idx.name == NULL_ACTION_INDEX }
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,36 @@
1
+ module RailsPulse
2
+ module Subscribers
3
+ class ExceptionSubscriber
4
+ def self.subscribe!
5
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
6
+ event = ActiveSupport::Notifications::Event.new(*args)
7
+ new(event).process
8
+ end
9
+ end
10
+
11
+ def initialize(event)
12
+ @event = event
13
+ end
14
+
15
+ def process
16
+ return unless RailsPulse.configuration.enabled
17
+ return unless RailsPulse.configuration.track_exceptions
18
+ return if RequestStore.store[:skip_recording_rails_pulse_activity]
19
+
20
+ exception = @event.payload[:exception_object]
21
+ return unless exception
22
+ return if RequestStore.store[:rails_pulse_captured_exception].equal?(exception)
23
+
24
+ RailsPulse::ExceptionCaptureService.capture(
25
+ exception,
26
+ request_url: @event.payload[:path],
27
+ request_method: @event.payload[:method],
28
+ request_params: @event.payload[:params],
29
+ environment: Rails.env.to_s
30
+ )
31
+ rescue => e
32
+ Rails.logger.error("[RailsPulse] ExceptionSubscriber error: #{e.message}")
33
+ end
34
+ end
35
+ end
36
+ end
@@ -1,5 +1,3 @@
1
- require "async"
2
-
3
1
  module RailsPulse
4
2
  module Tracker
5
3
  # PG::Connection::PQTRANS_INERROR (libpq enum value 3) — the connection's transaction
@@ -13,8 +11,12 @@ module RailsPulse
13
11
  def track_request(data)
14
12
  return if RequestStore.store[:skip_recording_rails_pulse_activity]
15
13
 
16
- if RailsPulse.configuration.async
17
- Async { perform_tracking(data) }
14
+ if RailsPulse.configuration.async && !connection_shared_across_threads?
15
+ Thread.new do
16
+ perform_tracking(data)
17
+ rescue => e
18
+ log_error(e)
19
+ end
18
20
  else
19
21
  perform_tracking(data)
20
22
  end
@@ -34,10 +36,11 @@ module RailsPulse
34
36
  clear_aborted_transaction(conn)
35
37
  RequestStore.store[:skip_recording_rails_pulse_activity] = true
36
38
 
37
- route = RailsPulse::Route.by_method_and_path(data[:method], data[:path])
39
+ route = RailsPulse::Route.find_or_create_for_request(data[:method], data[:path], controller_action: data[:controller_action])
38
40
 
39
41
  request = RailsPulse::Request.create!(
40
42
  route: route,
43
+ method: data[:method],
41
44
  duration: data[:duration],
42
45
  status: data[:status],
43
46
  is_error: data[:is_error],
@@ -51,12 +54,28 @@ module RailsPulse
51
54
  RailsPulse::Operation.persist_bulk(ops, request_id: request.id)
52
55
 
53
56
  request
54
- rescue => e
55
- log_error(e)
56
- nil
57
- ensure
58
- RequestStore.store[:skip_recording_rails_pulse_activity] = false
59
57
  end
58
+ rescue => e
59
+ log_error(e)
60
+ nil
61
+ ensure
62
+ RequestStore.store[:skip_recording_rails_pulse_activity] = false
63
+ end
64
+
65
+ # Transactional tests pin one connection per pool and hand that same
66
+ # connection to every thread that asks for one. A background writer would
67
+ # then interleave its statements with the test's on a single socket, which
68
+ # PostgreSQL reports as "message type 0x5a arrived from server while idle".
69
+ # Rails 7.2+ records the pin in the pool's @pinned_connection; 7.1 sets a
70
+ # @lock_thread on the pool instead. Neither is public API, so read both
71
+ # defensively and treat any failure as "not shared".
72
+ def connection_shared_across_threads?
73
+ pool = RailsPulse::ApplicationRecord.connection_pool
74
+ return true if pool.instance_variable_get(:@pinned_connection)
75
+
76
+ pool.instance_variable_get(:@lock_thread) ? true : false
77
+ rescue StandardError
78
+ false
60
79
  end
61
80
 
62
81
  def clear_aborted_transaction(conn)
@@ -1,3 +1,3 @@
1
1
  module RailsPulse
2
- VERSION = "0.3.3"
2
+ VERSION = "0.4.0.pre.2"
3
3
  end
data/lib/rails_pulse.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "rails_pulse/version"
2
2
  require "rails_pulse/engine"
3
+ require "rails_pulse/packaged_assets"
3
4
  require "rails_pulse/configuration"
4
5
  require "rails_pulse/paginator"
5
6
  require "rails_pulse/cleanup_service"
@@ -1,58 +1,22 @@
1
- # Load Rails environment - try multiple locations
1
+ # Load the host Rails environment. The dashboard is a mounted engine and needs
2
+ # the host's models, routes and initializer (authentication, database), so run
3
+ # this from the Rails application's root — or from the gem root, which loads
4
+ # the dummy app for development.
2
5
  if File.exist?("test/dummy/config/environment.rb")
3
- # Running from Rails Pulse gem root (for development/testing)
4
6
  require_relative "../test/dummy/config/environment"
5
7
  elsif File.exist?("config/environment.rb")
6
- # Running from a Rails app that has Rails Pulse installed
7
8
  require File.expand_path("config/environment", Dir.pwd)
8
9
  else
9
- # Standalone mode - load minimal dependencies
10
- puts "=" * 80
11
- puts "RailsPulse Dashboard (Standalone Mode)"
12
- puts "=" * 80
10
+ abort <<~MESSAGE
11
+ RailsPulse standalone dashboard: config/environment.rb not found in #{Dir.pwd}.
13
12
 
14
- require "bundler/setup"
15
- require "active_support/all"
16
- require "active_record"
13
+ Run from your Rails application's root directory:
17
14
 
18
- $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
19
- require "rails_pulse"
15
+ cd /path/to/your/app
16
+ RAILS_ENV=production bundle exec rackup $(bundle show rails_pulse)/lib/rails_pulse_server.ru -p 3001
20
17
 
21
- # Load database configuration from environment
22
- db_config = if ENV["DATABASE_URL"]
23
- { url: ENV["DATABASE_URL"] }
24
- elsif File.exist?("config/database.yml")
25
- require "yaml"
26
- db_yml = YAML.load_file("config/database.yml", aliases: true)
27
- rails_env = ENV.fetch("RAILS_ENV", "production")
28
-
29
- rails_pulse_config = db_yml.dig(rails_env, "rails_pulse")
30
- if rails_pulse_config
31
- rails_pulse_config
32
- else
33
- puts "WARNING: No 'rails_pulse' database found in config/database.yml, using primary connection"
34
- db_yml[rails_env]
35
- end
36
- else
37
- raise "Database configuration not found. Set DATABASE_URL or provide config/database.yml"
38
- end
39
-
40
- puts "Connecting to database: #{db_config[:database] || db_config[:url]&.split('@')&.last}"
41
-
42
- # Configure RailsPulse for dashboard-only mode
43
- RailsPulse.configure do |config|
44
- # CRITICAL: Disable tracking in dashboard process
45
- config.enabled = false
46
-
47
- # Configure database connection
48
- config.connects_to = { database: db_config }
49
- end
50
-
51
- # Establish database connection
52
- RailsPulse::ApplicationRecord.establish_connection(db_config)
53
-
54
- puts "Dashboard ready on port #{ENV.fetch('PORT', 3001)}"
55
- puts "=" * 80
18
+ RAILS_ENV selects the database and enables dashboard authentication.
19
+ MESSAGE
56
20
  end
57
21
 
58
22
  # Disable output buffering so logs appear immediately
@@ -99,10 +63,15 @@ secret_key = ENV.fetch("SECRET_KEY_BASE") do
99
63
  raise "SECRET_KEY_BASE environment variable must be set for standalone dashboard"
100
64
  end
101
65
 
66
+ # `secure` keeps the session cookie off plain HTTP in production. Set
67
+ # RAILS_PULSE_INSECURE_SESSION=1 only for a deliberately non-TLS deployment
68
+ # (a private network with no reverse proxy in front).
102
69
  use Rack::Session::Cookie,
103
70
  key: "rails_pulse_session",
104
71
  secret: secret_key,
105
72
  same_site: :lax,
73
+ httponly: true,
74
+ secure: Rails.env.production? && ENV["RAILS_PULSE_INSECURE_SESSION"].blank?,
106
75
  max_age: 86400 # 1 day
107
76
 
108
77
  run DashboardApp.new
@@ -9,6 +9,10 @@ namespace :db do
9
9
  if schema_file.exist?
10
10
  load schema_file
11
11
  puts "Rails Pulse schema loaded successfully"
12
+ # Record any copied migrations as already applied so they don't show as
13
+ # pending after a fresh schema load. The schema file creates all tables and
14
+ # columns directly, so the incremental migrations are logically already done.
15
+ record_rails_pulse_migrations_applied
12
16
  else
13
17
  puts "Rails Pulse schema file not found. Run: rails generate rails_pulse:install --database=separate"
14
18
  end
@@ -28,12 +32,87 @@ namespace :db do
28
32
  end
29
33
  end
30
34
 
35
+ # Hook into test database preparation so the separate Rails Pulse DB is populated
36
+ # for test runs, not just for development/production db:prepare.
37
+ # The task may be namespaced as "app:db:schema:load_rails_pulse" in engine context
38
+ # vs "db:schema:load_rails_pulse" in a host app — try both.
39
+ Rake::Task["db:test:prepare"].enhance do
40
+ if separate_database_setup?
41
+ task_name = %w[db:schema:load_rails_pulse app:db:schema:load_rails_pulse]
42
+ .find { |name| Rake::Task.task_defined?(name) }
43
+ if task_name
44
+ Rake::Task[task_name].reenable
45
+ Rake::Task[task_name].invoke
46
+ end
47
+ end
48
+ end if Rake::Task.task_defined?("db:test:prepare")
49
+
31
50
  # Helper function to detect database setup type
32
51
  def separate_database_setup?
33
- # Check if there's a rails_pulse_migrate directory (indicates separate database)
34
- Rails.root.join("db/rails_pulse_migrate").exist? ||
35
- # Check if database.yml has rails_pulse configuration
36
- (Rails.application.config.database_configuration.dig(Rails.env, "rails_pulse").present?)
52
+ # A .keep file alone (created by install_generator for all modes) does not
53
+ # indicate separate-DB. Require actual migration files or a database.yml entry.
54
+ migrate_dir = Rails.root.join("db/rails_pulse_migrate")
55
+ has_migration_files = migrate_dir.exist? && Dir.glob("#{migrate_dir}/*.rb").any?
56
+ has_db_config = Rails.application.config.database_configuration.dig(Rails.env, "rails_pulse").present?
57
+ has_migration_files || has_db_config
58
+ end
59
+
60
+ # After a schema load, insert version numbers for any migration files already in
61
+ # db/rails_pulse_migrate/ into schema_migrations so Rails does not report them as
62
+ # pending. The schema already includes every column those migrations would add, so
63
+ # running them would be a no-op — but Rails still needs the version recorded.
64
+ def record_rails_pulse_migrations_applied
65
+ return unless defined?(RailsPulse::ApplicationRecord)
66
+
67
+ migrations_path = Rails.root.join("db/rails_pulse_migrate")
68
+ return unless migrations_path.exist?
69
+
70
+ connection = RailsPulse::ApplicationRecord.connection
71
+
72
+ # Only record versions if the schema load actually created/updated tables.
73
+ # On an existing database, the schema file is a no-op, so marking migrations
74
+ # as applied would hide genuinely pending migrations.
75
+ unless connection.table_exists?(:rails_pulse_routes)
76
+ puts "[RailsPulse] Schema load did not create tables — skipping migration recording"
77
+ return
78
+ end
79
+
80
+ # Create schema_migrations if this is a brand-new database that has not yet
81
+ # had any migrations run against it (e.g. just after db:create).
82
+ unless connection.table_exists?(:schema_migrations)
83
+ connection.create_table :schema_migrations, id: false do |t|
84
+ t.string :version, null: false
85
+ end
86
+ connection.add_index :schema_migrations, :version,
87
+ unique: true, name: "unique_schema_migrations"
88
+ end
89
+
90
+ Dir.glob("#{migrations_path}/*.rb").sort.each do |file|
91
+ version = File.basename(file, ".rb").split("_").first
92
+ migration_name = File.basename(file, ".rb").sub(/\A\d+_/, "")
93
+
94
+ # For the 0.4.0 route-identity migrations, only record them as applied if
95
+ # the schema actually has the columns they would add. This prevents marking
96
+ # them applied on an existing DB where the schema load was a no-op.
97
+ if migration_name.include?("change_rails_pulse_routes") || migration_name.include?("null_action_unique_index")
98
+ unless connection.column_exists?(:rails_pulse_routes, :controller_action) &&
99
+ connection.column_exists?(:rails_pulse_routes, :http_methods)
100
+ puts "[RailsPulse] Skipping #{version} — route columns not present yet"
101
+ next
102
+ end
103
+ end
104
+
105
+ next if connection.select_values(
106
+ "SELECT version FROM schema_migrations WHERE version = #{connection.quote(version)}"
107
+ ).any?
108
+
109
+ connection.execute(
110
+ "INSERT INTO schema_migrations (version) VALUES (#{connection.quote(version)})"
111
+ )
112
+ puts "[RailsPulse] Marked migration #{version} as applied (schema already up to date)"
113
+ end
114
+ rescue => e
115
+ warn "[RailsPulse] Could not mark migrations as applied: #{e.message}"
37
116
  end
38
117
 
39
118
  namespace :rails_pulse do
@@ -0,0 +1,15 @@
1
+ namespace :rails_pulse do
2
+ desc "Copy pre-built dashboard assets into public/assets without running the host JS compressor"
3
+ task install_assets: :environment do
4
+ entries = RailsPulse::PackagedAssets.install!
5
+ puts "[RailsPulse] Installed #{entries.size} dashboard assets into public/assets"
6
+ end
7
+ end
8
+
9
+ # Copy after the host pipeline finishes so asset_sync / CDN uploads include
10
+ # the files, but never register them for Sprockets compression (#190).
11
+ if Rake::Task.task_defined?("assets:precompile")
12
+ Rake::Task["assets:precompile"].enhance do
13
+ Rake::Task["rails_pulse:install_assets"].invoke
14
+ end
15
+ end