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
@@ -9,25 +9,77 @@ module RailsPulse
9
9
  has_many :summaries, as: :summarizable, class_name: "RailsPulse::Summary", dependent: :destroy
10
10
 
11
11
  # Validations
12
- validates :method, presence: true
13
12
  validates :path, presence: true
13
+ validates :http_methods, presence: true
14
+
15
+ # Find an existing route by controller_action + path, or create one.
16
+ # When a route already exists, the incoming http_method is appended to its
17
+ # http_methods array if not already present (self-healing for PATCH/PUT etc.).
18
+ # For requests without a controller_action (404s, middleware short-circuits),
19
+ # falls back to grouping by path alone.
20
+ def self.find_or_create_for_request(http_method, path, controller_action: nil)
21
+ route = if controller_action.present?
22
+ find_or_insert_by_action(http_method, path, controller_action)
23
+ else
24
+ find_or_insert_by_path(http_method, path)
25
+ end
26
+
27
+ # Always append — concurrent first inserts of different verbs can skip the
28
+ # INSERT via ON CONFLICT DO NOTHING and would otherwise drop the losing verb.
29
+ route.add_http_method(http_method)
30
+ route
31
+ end
14
32
 
15
- def self.by_method_and_path(method, path)
16
- # Fast path: route already exists (the common case for recurring requests).
17
- route = find_by(method: method, path: path)
18
- return route if route
19
-
20
- # Use INSERT ... ON CONFLICT DO NOTHING rather than create_or_find_by so that
21
- # concurrent inserts for the same route are silently skipped at the database level.
22
- # create_or_find_by rescues RecordNotUnique at the Ruby level, but PostgreSQL still
23
- # logs a constraint violation ERROR before raising — causing noisy logs.
24
- # unique_by is omitted for cross-database compatibility (MySQL rejects it); there is
25
- # only one unique constraint on this table so DO NOTHING resolves unambiguously.
26
- # Tags must be set explicitly here because insert bypasses before_save callbacks.
27
- insert(
28
- { method: method, path: path, tags: "[]", created_at: Time.current, updated_at: Time.current }
29
- )
30
- find_by!(method: method, path: path)
33
+ def self.find_or_insert_by_action(http_method, path, controller_action)
34
+ find_by(controller_action: controller_action, path: path) || begin
35
+ # INSERT ... ON CONFLICT DO NOTHING so concurrent inserts are skipped at the DB.
36
+ insert(
37
+ { http_methods: [ http_method ].to_json, path: path, controller_action: controller_action,
38
+ tags: "[]", created_at: Time.current, updated_at: Time.current }
39
+ )
40
+ find_by!(controller_action: controller_action, path: path)
41
+ end
42
+ end
43
+ private_class_method :find_or_insert_by_action
44
+
45
+ def self.find_or_insert_by_path(http_method, path)
46
+ find_by(controller_action: nil, path: path) || begin
47
+ insert(
48
+ { http_methods: [ http_method ].to_json, path: path, controller_action: nil,
49
+ tags: "[]", created_at: Time.current, updated_at: Time.current }
50
+ )
51
+ where(controller_action: nil, path: path).order(:id).first!
52
+ end
53
+ end
54
+ private_class_method :find_or_insert_by_path
55
+
56
+ def http_methods_list
57
+ return [] if http_methods.blank?
58
+ JSON.parse(http_methods)
59
+ rescue JSON::ParserError
60
+ []
61
+ end
62
+
63
+ def add_http_method(http_method)
64
+ return if http_method.blank?
65
+ current = http_methods_list
66
+ return if current.include?(http_method)
67
+ update_column(:http_methods, (current + [ http_method ]).sort.to_json)
68
+ end
69
+
70
+ # True when at least one stored route still has no action but the live
71
+ # host router would assign one — the usual sign that schema migrated
72
+ # without `rails rails_pulse:migrate_routes`.
73
+ def self.needs_action_backfill?
74
+ return false unless column_names.include?("controller_action")
75
+ return false unless column_names.include?("http_methods")
76
+
77
+ where(controller_action: [ nil, "" ]).limit(25).any? do |route|
78
+ method = route.http_methods_list.first
79
+ next false if method.blank?
80
+
81
+ RailsPulse::RouteRecognizer.call(route.path, method: method).present?
82
+ end
31
83
  end
32
84
 
33
85
  def self.ransackable_attributes(auth_object = nil)
@@ -69,13 +121,11 @@ module RailsPulse
69
121
  end
70
122
 
71
123
  ransacker :requests_per_minute do
72
- # Use a simpler database-agnostic approach - this is mainly used for sorting/filtering
73
- # so exact precision isn't as critical as avoiding database-specific functions
74
124
  Arel.sql("COUNT(rails_pulse_requests.id)")
75
125
  end
76
126
 
77
127
  def to_breadcrumb
78
- "#{method.upcase} #{path}".truncate(60)
128
+ "#{http_methods_list.sort.join('|')} #{path}".truncate(60)
79
129
  end
80
130
 
81
131
  def self.average_response_time
@@ -83,7 +133,7 @@ module RailsPulse
83
133
  end
84
134
 
85
135
  def path_and_method
86
- "#{path} #{method}"
136
+ "#{path} #{http_methods_list.join('|')}"
87
137
  end
88
138
  end
89
139
  end
@@ -33,8 +33,11 @@ module RailsPulse
33
33
  server_rate = has_data ? (total_errors.to_f / total_requests * 100).round(2) : 0
34
34
  client_rate = has_data ? (total_4xx.to_f / total_requests * 100).round(2) : 0
35
35
 
36
- # Use base class trend calculation
37
- if show_trend?
36
+ baseline = baseline_trend_for(@route, metric: :error_rate)
37
+
38
+ if baseline
39
+ trend_icon, trend_amount, trend_caption = baseline
40
+ elsif show_trend?
38
41
  trend_icon, trend_amount = has_data ? trend_for(current_total, previous_total) : [ "move-right", "—" ]
39
42
  end
40
43
 
@@ -58,7 +61,7 @@ module RailsPulse
58
61
  sparkline_options: { tooltip: { formatter: "sparkline_percentage_tooltip" } },
59
62
  trend_icon: trend_icon,
60
63
  trend_amount: trend_amount,
61
- trend_text: (show_trend? ? comparison_period_text : nil),
64
+ trend_text: trend_caption || (show_trend? ? comparison_period_text : nil),
62
65
  period_stat: has_data ? "#{format_number(total_errors + total_4xx)} errors" : period_date_range,
63
66
  help_heading: "Error Rates",
64
67
  help_text: "Combined 5xx and 4xx error rate over the last 2 weeks. 5xx server errors indicate bugs or infrastructure issues. 4xx client errors may indicate broken API consumers or deprecated endpoints."
@@ -28,8 +28,13 @@ module RailsPulse
28
28
 
29
29
  has_data = metrics.total_count.to_i > 0
30
30
 
31
- # Use base class trend calculation
32
- if show_trend?
31
+ # Prefer a comparison against this route's own history; fall back to
32
+ # period-over-period on the index, where there is no single subject.
33
+ baseline = baseline_trend_for(@route, metric: :p95)
34
+
35
+ if baseline
36
+ trend_icon, trend_amount, trend_caption = baseline
37
+ elsif show_trend?
33
38
  trend_icon, trend_amount = has_data ? trend_for(current_period_p95, previous_period_p95) : [ "move-right", "—" ]
34
39
  end
35
40
 
@@ -56,7 +61,7 @@ module RailsPulse
56
61
  chart_data: sparkline_data,
57
62
  trend_icon: trend_icon,
58
63
  trend_amount: trend_amount,
59
- trend_text: (show_trend? ? comparison_period_text : nil),
64
+ trend_text: trend_caption || (show_trend? ? comparison_period_text : nil),
60
65
  period_stat: has_data ? "Across #{format_number(metrics.total_count.to_i)} requests" : period_date_range,
61
66
  help_heading: "P95 Response Time",
62
67
  help_text: "The 95th percentile response time — 95% of requests are faster than this. Weighted by request volume across all routes. A rising P95 indicates increasing slowness affecting your users."
@@ -13,23 +13,21 @@ module RailsPulse
13
13
 
14
14
  def group_columns
15
15
  %w[
16
- rails_pulse_summaries.summarizable_id
17
- rails_pulse_summaries.summarizable_type
18
16
  rails_pulse_routes.id
19
17
  rails_pulse_routes.path
20
- rails_pulse_routes.method
18
+ rails_pulse_routes.controller_action
21
19
  rails_pulse_routes.tags
20
+ rails_pulse_routes.http_methods
22
21
  ]
23
22
  end
24
23
 
25
24
  def select_columns
26
25
  [
27
- "rails_pulse_summaries.summarizable_id",
28
- "rails_pulse_summaries.summarizable_type",
29
26
  "rails_pulse_routes.id as route_id",
30
27
  "rails_pulse_routes.path",
31
- "rails_pulse_routes.method as route_method",
28
+ "rails_pulse_routes.controller_action",
32
29
  "rails_pulse_routes.tags",
30
+ "rails_pulse_routes.http_methods as route_methods",
33
31
  "AVG(rails_pulse_summaries.avg_duration) as avg_duration",
34
32
  "MAX(rails_pulse_summaries.max_duration) as max_duration",
35
33
  "#{WEIGHTED_P95} as p95_duration",
@@ -58,6 +56,10 @@ module RailsPulse
58
56
  grouped_query.order(Arel.sql("(SUM(rails_pulse_summaries.error_count) * 100.0) / SUM(rails_pulse_summaries.count)").send(direction))
59
57
  when "route_path"
60
58
  grouped_query.order(Arel.sql("rails_pulse_routes.path").send(direction))
59
+ when "route_controller_action"
60
+ grouped_query.order(Arel.sql("rails_pulse_routes.controller_action").send(direction))
61
+ when "route_methods_sort"
62
+ grouped_query.order(Arel.sql("rails_pulse_routes.http_methods").send(direction))
61
63
  end
62
64
  end
63
65
 
@@ -30,6 +30,14 @@ module RailsPulse
30
30
  scope :for_routes, -> { where(summarizable_type: "RailsPulse::Route") }
31
31
  scope :for_queries, -> { where(summarizable_type: "RailsPulse::Query") }
32
32
  scope :for_jobs, -> { where(summarizable_type: "RailsPulse::Job") }
33
+ scope :for_exceptions, -> { where(summarizable_type: "RailsPulse::ExceptionGroup") }
34
+
35
+ # Frequency across every exception group, written alongside the per-group
36
+ # rows so a chart of total exception volume is one query rather than one
37
+ # per group.
38
+ scope :overall_exceptions, -> {
39
+ where(summarizable_type: "RailsPulse::ExceptionGroup", summarizable_id: 0)
40
+ }
33
41
  scope :by_job_name, ->(job_name) { for_jobs.joins(:job).where(rails_pulse_jobs: { name: job_name }) }
34
42
  scope :recent, -> { order(period_start: :desc) }
35
43
 
@@ -61,7 +69,8 @@ module RailsPulse
61
69
  " (summarizable_type = 'RailsPulse::Route' AND summarizable_id IN (?)) OR " \
62
70
  " (summarizable_type = 'RailsPulse::Query' AND summarizable_id IN (?)) OR " \
63
71
  " (summarizable_type = 'RailsPulse::Job' AND summarizable_id IN (?)) OR " \
64
- " (summarizable_type = 'RailsPulse::Request')" \
72
+ " (summarizable_type = 'RailsPulse::Request') OR " \
73
+ " (summarizable_type = 'RailsPulse::ExceptionGroup')" \
65
74
  ")",
66
75
  route_ids,
67
76
  query_ids,
@@ -73,7 +82,8 @@ module RailsPulse
73
82
  def self.ransackable_attributes(auth_object = nil)
74
83
  %w[
75
84
  period_start period_end avg_duration min_duration max_duration p95_duration p99_duration
76
- count error_count requests_per_minute error_rate_percentage route_path_cont
85
+ count error_count requests_per_minute error_rate_percentage
86
+ route_path route_controller_action route_methods_sort
77
87
  execution_count total_time_consumed normalized_sql
78
88
  summarizable_id summarizable_type
79
89
  ]
@@ -101,14 +111,15 @@ module RailsPulse
101
111
  Arel.sql("rails_pulse_routes.path")
102
112
  end
103
113
 
104
- # Ransacker for route path filtering using subquery (works without JOIN)
105
- ransacker :route_path_cont do |parent|
106
- Arel.sql(<<-SQL)
107
- rails_pulse_summaries.summarizable_id IN (
108
- SELECT id FROM rails_pulse_routes
109
- WHERE rails_pulse_routes.path LIKE '%' || ? || '%'
110
- )
111
- SQL
114
+ # Ransacker for controller_action sorting (when joined with routes table)
115
+ ransacker :route_controller_action do
116
+ Arel.sql("rails_pulse_routes.controller_action")
117
+ end
118
+
119
+ # Placeholder ransacker for method aggregation sorting; actual ORDER BY is
120
+ # applied by Routes::Tables::Index#named_sort using http_methods.
121
+ ransacker :route_methods_sort do
122
+ Arel.sql("rails_pulse_routes.http_methods")
112
123
  end
113
124
 
114
125
  # Sorting-specific ransackers for GROUP BY compatibility (used only in ORDER BY)
@@ -41,9 +41,11 @@ module RailsPulse
41
41
  def apply_tag_filters(query)
42
42
  actual_disabled_tags = @disabled_tags.reject { |tag| tag == "non_tagged" }
43
43
 
44
+ # Explicit ESCAPE: SQLite has no default escape character, so without
45
+ # it an underscore in a tag is never matched literally there.
44
46
  actual_disabled_tags.each do |tag|
45
- sanitized_tag = ActiveRecord::Base.sanitize_sql_like(tag.to_s, "\\")
46
- query = query.where.not("#{model_table}.tags LIKE ?", "%#{sanitized_tag}%")
47
+ sanitized_tag = ActiveRecord::Base.sanitize_sql_like(tag.to_s, RailsPulse::Taggable::LIKE_ESCAPE)
48
+ query = query.where.not("#{model_table}.tags LIKE ? ESCAPE '!'", "%#{sanitized_tag}%")
47
49
  end
48
50
 
49
51
  unless @show_non_tagged
@@ -3,7 +3,21 @@
3
3
  module RailsPulse
4
4
  module Analysis
5
5
  class ExplainPlanAnalyzer < BaseAnalyzer
6
- EXPLAIN_TIMEOUT = 5.seconds
6
+ # PostgreSQL statement_timeout for the EXPLAIN itself. Planner-only
7
+ # work is fast; this only guards against a pathological plan.
8
+ STATEMENT_TIMEOUT = "5s".freeze
9
+
10
+ # Only EXPLAIN SELECT/WITH statements — executing EXPLAIN on a
11
+ # DELETE/UPDATE/INSERT would re-run the statement on PostgreSQL
12
+ # when ANALYZE is used, and is never useful for the dashboard.
13
+ SAFE_VERB = /\A\s*(SELECT|WITH)\b/i
14
+
15
+ # The verb check only sees the first statement. PostgreSQL's execute
16
+ # (async_exec) runs every statement in the string, so a captured
17
+ # "SELECT …; UPDATE …" would re-run the UPDATE on every page view.
18
+ # Comments are refused too: nothing legitimate needs them explained,
19
+ # and they are the other place a second statement can hide.
20
+ UNSAFE_SQL = %r{;|--|/\*}
7
21
 
8
22
  def analyze
9
23
  return { explain_plan: nil, issues: [] } if recent_operations.empty?
@@ -21,25 +35,34 @@ module RailsPulse
21
35
 
22
36
  def generate_explain_plan(sql)
23
37
  return nil unless sql.present?
38
+ return nil unless sql.match?(SAFE_VERB)
24
39
 
25
- # Skip EXPLAIN queries in test environment to avoid transaction issues
26
- return nil if Rails.env.test?
40
+ sanitized_sql = sanitize_sql_for_explain(sql)
41
+ return nil unless sanitized_sql
27
42
 
28
43
  begin
29
- sanitized_sql = sanitize_sql_for_explain(sql)
30
-
31
- Timeout.timeout(EXPLAIN_TIMEOUT) do
32
- case database_adapter
44
+ conn = RailsPulse::ApplicationRecord.connection
45
+ plan = nil
46
+
47
+ # A savepoint keeps a failed EXPLAIN from aborting the host's
48
+ # surrounding transaction, and it is always rolled back: EXPLAIN
49
+ # writes nothing, so rolling back costs nothing, and it undoes the
50
+ # SET LOCALs below (and anything a statement that slipped past the
51
+ # guards might have done) instead of leaking them into the caller's
52
+ # transaction.
53
+ conn.transaction(requires_new: true) do
54
+ plan = case database_adapter
33
55
  when "postgresql"
34
56
  execute_postgres_explain(sanitized_sql)
35
57
  when "mysql", "mysql2"
36
58
  execute_mysql_explain(sanitized_sql)
37
59
  when "sqlite"
38
60
  execute_sqlite_explain(sanitized_sql)
39
- else
40
- nil
41
61
  end
62
+ raise ActiveRecord::Rollback
42
63
  end
64
+
65
+ plan
43
66
  rescue => e
44
67
  RailsPulse.logger.warn("[ExplainPlanAnalyzer] EXPLAIN failed for query #{query.id}: #{e.message}")
45
68
  nil
@@ -182,13 +205,25 @@ module RailsPulse
182
205
  issues
183
206
  end
184
207
 
208
+ # Strips a trailing terminator, then refuses anything that could still
209
+ # carry a second statement or a comment. Returns nil for refused SQL.
185
210
  def sanitize_sql_for_explain(sql)
186
- # Basic sanitization for EXPLAIN
187
- sql.strip.gsub(/;+\s*$/, "")
211
+ cleaned = sql.strip.gsub(/;+\s*$/, "")
212
+ return nil if cleaned.match?(UNSAFE_SQL)
213
+
214
+ cleaned
188
215
  end
189
216
 
217
+ # SET LOCAL is scoped to the enclosing transaction/savepoint and is
218
+ # undone by the rollback in generate_explain_plan. Read-only means
219
+ # even a statement that got past the guards cannot write, and the
220
+ # timeout replaces Timeout.timeout, which could interrupt the driver
221
+ # mid-protocol and leave the connection in an undefined state.
190
222
  def execute_postgres_explain(sql)
191
- result = RailsPulse::ApplicationRecord.connection.execute("EXPLAIN (ANALYZE, BUFFERS) #{sql}")
223
+ conn = RailsPulse::ApplicationRecord.connection
224
+ conn.execute("SET LOCAL transaction_read_only = on")
225
+ conn.execute("SET LOCAL statement_timeout = '#{STATEMENT_TIMEOUT}'")
226
+ result = conn.execute("EXPLAIN #{sql}")
192
227
  result.values.flatten.join("\n")
193
228
  end
194
229
 
@@ -0,0 +1,228 @@
1
+ module RailsPulse
2
+ class ExceptionCaptureService
3
+ APP_FRAME_PATTERN = %r{/app/|/lib/|/config/}
4
+ # /lib/ruby/ matches interpreter stdlib (…/lib/ruby/3.3.0/…) without
5
+ # treating project paths that happen to contain "ruby" as gem code.
6
+ GEM_FRAME_PATTERN = %r{/gems/|/rubygems/|/bundler/|/lib/ruby/}
7
+
8
+ PARAMS_SIZE_LIMIT = 10_240 # 10KB
9
+ DEPLOY_SHA_TTL = 60 # seconds
10
+ # Cap stored frames so a pathological backtrace cannot balloon the row.
11
+ BACKTRACE_FRAME_LIMIT = 50
12
+
13
+ # Cached on the class so every request in this process shares one value instead
14
+ # of each hitting the DB. Because multiple threads can call capture concurrently,
15
+ # the cache variables need a mutex to prevent torn reads/writes.
16
+ @_deploy_sha_mutex = Mutex.new # guards @_deploy_sha_value and @_deploy_sha_cached_at
17
+ @_deploy_sha_value = nil
18
+ @_deploy_sha_cached_at = nil
19
+
20
+ class << self
21
+ def capture(exception, request_url: nil, request_method: nil, request_params: nil, environment: nil, deploy_sha: nil)
22
+ new(exception, request_url: request_url, request_method: request_method, request_params: request_params, environment: environment, deploy_sha: deploy_sha).capture
23
+ end
24
+
25
+ def fetch_deploy_sha
26
+ @_deploy_sha_mutex.synchronize do
27
+ # Check expiry inside the lock so only one thread does the DB query when
28
+ # the cache is cold — other threads block here and then see the fresh value.
29
+ if @_deploy_sha_cached_at.nil? || (Time.current.to_f - @_deploy_sha_cached_at) >= DEPLOY_SHA_TTL
30
+ @_deploy_sha_value = RailsPulse::Deployment.order(started_at: :desc).first&.revision
31
+ @_deploy_sha_cached_at = Time.current.to_f
32
+ end
33
+ @_deploy_sha_value
34
+ end
35
+ end
36
+
37
+ def reset_deploy_sha_cache!
38
+ @_deploy_sha_mutex.synchronize do
39
+ @_deploy_sha_value = nil
40
+ @_deploy_sha_cached_at = nil
41
+ end
42
+ end
43
+ end
44
+
45
+ def initialize(exception, request_url: nil, request_method: nil, request_params: nil, environment: nil, deploy_sha: nil)
46
+ @exception = exception
47
+ @request_url = request_url
48
+ @request_method = request_method
49
+ @request_params = request_params
50
+ @environment = environment || Rails.env.to_s
51
+ @deploy_sha = deploy_sha
52
+ end
53
+
54
+ def capture
55
+ return unless RailsPulse.configuration.track_exceptions
56
+ return if RequestStore.store[:skip_recording_rails_pulse_activity]
57
+
58
+ frames = parse_backtrace(@exception.backtrace || [])
59
+ location = fingerprint_location(frames)
60
+ fingerprint = Digest::SHA256.hexdigest("#{@exception.class.name}:#{location}")
61
+ now = Time.current
62
+
63
+ group = upsert_group(fingerprint, location, now)
64
+ create_occurrence(group, frames, now) unless group.status == "ignored"
65
+ rescue => e
66
+ Rails.logger.error("[RailsPulse] ExceptionCaptureService error: #{e.message}")
67
+ nil
68
+ end
69
+
70
+
71
+ private
72
+
73
+ # Memoized: both upsert_group and create_occurrence need the sanitized
74
+ # message, and this runs on the host's error path.
75
+ def sanitize_exception_message(exception)
76
+ @sanitize_exception_message ||= ExceptionMessageSanitizer.for_exception(exception)
77
+ end
78
+
79
+ # Reusable aborted-transaction recovery shared with Tracker.
80
+ def clear_aborted_transaction(conn)
81
+ raw = conn.raw_connection
82
+ return unless raw.respond_to?(:transaction_status) && raw.transaction_status == 3 # PG_TRANSACTION_INERROR
83
+ conn.rollback_db_transaction
84
+ rescue ActiveRecord::StatementInvalid
85
+ nil
86
+ end
87
+
88
+ def parse_backtrace(raw_backtrace)
89
+ raw_backtrace.first(BACKTRACE_FRAME_LIMIT).filter_map do |line|
90
+ match = line.match(/\A(.+):(\d+):in ['`](.+?)'\z/)
91
+ next unless match
92
+ { file: match[1], line: match[2].to_i, method: match[3] }
93
+ end
94
+ end
95
+
96
+ def first_app_frame(frames)
97
+ frames.find { |f| f[:file].match?(APP_FRAME_PATTERN) && !f[:file].match?(GEM_FRAME_PATTERN) }
98
+ end
99
+
100
+ # Relative path used in the fingerprint and stored on the group so
101
+ # Capistrano-style release prefixes do not split groups across deploys.
102
+ def fingerprint_location(frames)
103
+ frame = first_app_frame(frames)
104
+ return "unknown" unless frame
105
+
106
+ path = normalize_app_path(frame[:file])
107
+ anonymous = frame[:method].include?("block") || frame[:method].start_with?("<")
108
+ anonymous ? "#{path}:#{frame[:line]}" : "#{path}##{frame[:method]}"
109
+ end
110
+
111
+ def normalize_app_path(file)
112
+ path = file.to_s
113
+ root = Rails.root.to_s.chomp("/")
114
+ if root.present? && path.start_with?("#{root}/")
115
+ return path.delete_prefix("#{root}/")
116
+ end
117
+
118
+ # Rightmost /app/, /lib/, or /config/ so Capistrano paths like
119
+ # /var/www/app/releases/TIMESTAMP/app/models/user.rb keep only app/models/user.rb.
120
+ if (idx = path.rindex(/\/(?:app|lib|config)\//))
121
+ path[(idx + 1)..]
122
+ else
123
+ path
124
+ end
125
+ end
126
+
127
+ # Single SQL upsert: inserts a new group or updates the existing one.
128
+ # occurrence_count is incremented atomically in the same statement, so no
129
+ # separate update_counters call is needed and no race window exists.
130
+ def upsert_group(fingerprint, location, now)
131
+ message = sanitize_exception_message(@exception)
132
+ conn = ExceptionGroup.connection
133
+ clear_aborted_transaction(conn)
134
+
135
+ # Explicitly set status/preserve: SQLite schema dumps omit boolean DEFAULT false,
136
+ # so omitting those columns makes INSERT fail with NOT NULL on preserve.
137
+ if conn.adapter_name.downcase.include?("mysql")
138
+ conn.execute(<<~SQL)
139
+ INSERT INTO rails_pulse_exception_groups
140
+ (fingerprint, exception_class, location, message, first_seen_at, last_seen_at, occurrence_count, status, preserve, created_at, updated_at)
141
+ VALUES (
142
+ #{conn.quote(fingerprint)},
143
+ #{conn.quote(@exception.class.name)},
144
+ #{conn.quote(location)},
145
+ #{conn.quote(message)},
146
+ #{conn.quote(now)},
147
+ #{conn.quote(now)},
148
+ 1,
149
+ #{conn.quote("open")},
150
+ #{conn.quote(false)},
151
+ #{conn.quote(now)},
152
+ #{conn.quote(now)}
153
+ )
154
+ ON DUPLICATE KEY UPDATE
155
+ exception_class = VALUES(exception_class),
156
+ location = VALUES(location),
157
+ message = VALUES(message),
158
+ last_seen_at = VALUES(last_seen_at),
159
+ occurrence_count = occurrence_count + 1,
160
+ updated_at = VALUES(updated_at)
161
+ SQL
162
+ else
163
+ conn.execute(<<~SQL)
164
+ INSERT INTO rails_pulse_exception_groups
165
+ (fingerprint, exception_class, location, message, first_seen_at, last_seen_at, occurrence_count, status, preserve, created_at, updated_at)
166
+ VALUES (
167
+ #{conn.quote(fingerprint)},
168
+ #{conn.quote(@exception.class.name)},
169
+ #{conn.quote(location)},
170
+ #{conn.quote(message)},
171
+ #{conn.quote(now)},
172
+ #{conn.quote(now)},
173
+ 1,
174
+ #{conn.quote("open")},
175
+ #{conn.quote(false)},
176
+ #{conn.quote(now)},
177
+ #{conn.quote(now)}
178
+ )
179
+ ON CONFLICT (fingerprint) DO UPDATE SET
180
+ exception_class = EXCLUDED.exception_class,
181
+ location = EXCLUDED.location,
182
+ message = EXCLUDED.message,
183
+ last_seen_at = EXCLUDED.last_seen_at,
184
+ occurrence_count = rails_pulse_exception_groups.occurrence_count + 1,
185
+ updated_at = EXCLUDED.updated_at
186
+ SQL
187
+ end
188
+
189
+ group = ExceptionGroup.find_by!(fingerprint: fingerprint)
190
+
191
+ ExceptionGroup.where(id: group.id, status: "resolved")
192
+ .update_all(status: "open", resolved_at: nil, updated_at: Time.current)
193
+
194
+ group
195
+ end
196
+
197
+ def current_deploy_sha
198
+ self.class.fetch_deploy_sha
199
+ end
200
+
201
+ def filtered_params
202
+ return nil unless RailsPulse.configuration.capture_exception_params
203
+ return nil if @request_params.blank?
204
+
205
+ params_hash = @request_params.respond_to?(:to_unsafe_h) ? @request_params.to_unsafe_h : @request_params.to_h
206
+ filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
207
+ filtered = filter.filter(params_hash)
208
+
209
+ serialized = filtered.to_json
210
+ serialized.bytesize <= PARAMS_SIZE_LIMIT ? filtered : nil
211
+ end
212
+
213
+ def create_occurrence(group, frames, now)
214
+ ExceptionOccurrence.create!(
215
+ exception_group: group,
216
+ exception_class: @exception.class.name,
217
+ message: sanitize_exception_message(@exception),
218
+ backtrace: frames,
219
+ request_url: @request_url,
220
+ request_method: @request_method,
221
+ request_params: filtered_params,
222
+ environment: @environment,
223
+ deploy_sha: @deploy_sha || current_deploy_sha,
224
+ occurred_at: now
225
+ )
226
+ end
227
+ end
228
+ end