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
@@ -0,0 +1,77 @@
1
+ module RailsPulse
2
+ module Operations
3
+ # Normalizes the different things a caller can ask about — a Route, Query or
4
+ # Job record, or the symbol :requests for the overall application rollup —
5
+ # into the (summarizable_type, summarizable_id) pair the summaries table is
6
+ # keyed by.
7
+ #
8
+ # This exists so callers of the Operations API never have to know how
9
+ # summaries are stored. It is the one place that knows about the
10
+ # summarizable_id = 0 convention used for the overall request rollup.
11
+ class Subject
12
+ SUPPORTED_TYPES = %w[RailsPulse::Route RailsPulse::Query RailsPulse::Job RailsPulse::Request].freeze
13
+
14
+ # The overall-request rollup is stored with a sentinel id rather than a
15
+ # real record, so it needs its own constant.
16
+ OVERALL_REQUESTS_ID = 0
17
+
18
+ attr_reader :type, :id, :record
19
+
20
+ # @param subject [RailsPulse::Route, RailsPulse::Query, RailsPulse::Job, Symbol]
21
+ # a record, or :requests for the overall application rollup
22
+ # @return [Subject]
23
+ # @raise [ArgumentError] if the subject is not something summaries are kept for
24
+ def self.wrap(subject)
25
+ return subject if subject.is_a?(self)
26
+
27
+ if subject == :requests
28
+ return new(type: "RailsPulse::Request", id: OVERALL_REQUESTS_ID, record: nil)
29
+ end
30
+
31
+ type = subject.class.name
32
+
33
+ unless SUPPORTED_TYPES.include?(type)
34
+ raise ArgumentError,
35
+ "unsupported subject #{type.inspect} — expected a Route, Query, Job, or :requests"
36
+ end
37
+
38
+ new(type: type, id: subject.id, record: subject)
39
+ end
40
+
41
+ def initialize(type:, id:, record:)
42
+ @type = type
43
+ @id = id
44
+ @record = record
45
+ end
46
+
47
+ # Summaries for this subject, unscoped by period.
48
+ def summaries
49
+ RailsPulse::Summary.where(summarizable_type: type, summarizable_id: id)
50
+ end
51
+
52
+ def overall_requests?
53
+ type == "RailsPulse::Request" && id == OVERALL_REQUESTS_ID
54
+ end
55
+
56
+ # Short human label, used in finding messages and API output.
57
+ def label
58
+ return "All requests" if overall_requests?
59
+
60
+ case type
61
+ when "RailsPulse::Route" then record&.path
62
+ when "RailsPulse::Query" then record&.normalized_sql
63
+ when "RailsPulse::Job" then record&.name
64
+ end.presence || "#{type.demodulize} ##{id}"
65
+ end
66
+
67
+ def ==(other)
68
+ other.is_a?(Subject) && other.type == type && other.id == id
69
+ end
70
+ alias eql? ==
71
+
72
+ def hash
73
+ [ type, id ].hash
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,110 @@
1
+ module RailsPulse
2
+ class RouteControllerActionBackfiller
3
+ def self.call
4
+ new.call
5
+ end
6
+
7
+ def call
8
+ results = { updated: 0, skipped: 0, already_set: 0, merged: 0 }
9
+ total = RailsPulse::Route.count
10
+ processed = 0
11
+ RailsPulse::Route.find_each do |route|
12
+ process_route(route, results)
13
+ processed += 1
14
+ end
15
+ puts "" if total > 0
16
+ consolidate_unrecognized_duplicates(results)
17
+ results
18
+ end
19
+
20
+ private
21
+
22
+ def process_route(route, results)
23
+ # Skip rows already destroyed by an earlier merge in this run.
24
+ return unless RailsPulse::Route.exists?(id: route.id)
25
+
26
+ if route.controller_action.present?
27
+ results[:already_set] += 1
28
+ return
29
+ end
30
+
31
+ ca = recognized_controller_action(route) || controller_action_from_requests(route)
32
+
33
+ if ca
34
+ apply_controller_action(route, ca, results)
35
+ else
36
+ results[:skipped] += 1
37
+ end
38
+ end
39
+
40
+ def recognized_controller_action(route)
41
+ primary_method = route.http_methods_list.first
42
+ return nil unless primary_method
43
+
44
+ params = RailsPulse::RouteRecognizer.call(route.path, method: primary_method)
45
+ params && [ params[:controller], params[:action] ].compact.join("#").presence
46
+ end
47
+
48
+ # Historical requests stored PascalCase class names (HomeController#index).
49
+ # Normalize to the capture-time format (home#index).
50
+ def controller_action_from_requests(route)
51
+ raw = RailsPulse::Request.where(route_id: route.id)
52
+ .where.not(controller_action: [ nil, "" ])
53
+ .group(:controller_action)
54
+ .order(Arel.sql("COUNT(*) DESC"))
55
+ .limit(1)
56
+ .pluck(:controller_action)
57
+ .first
58
+
59
+ normalize_controller_action(raw)
60
+ end
61
+
62
+ def normalize_controller_action(value)
63
+ return nil if value.blank?
64
+
65
+ controller, action = value.to_s.split("#", 2)
66
+ return nil if controller.blank? || action.blank?
67
+
68
+ "#{controller.delete_suffix("Controller").underscore}##{action}"
69
+ end
70
+
71
+ def apply_controller_action(route, ca, results)
72
+ existing = RailsPulse::Route.find_by(controller_action: ca, path: route.path)
73
+ if existing && existing.id != route.id
74
+ RailsPulse::RouteMerger.call(target: existing, source: route)
75
+ results[:merged] += 1
76
+ return
77
+ end
78
+
79
+ route.update_column(:controller_action, ca)
80
+ results[:updated] += 1
81
+ rescue ActiveRecord::RecordNotUnique
82
+ # Concurrent insert/update raced us; merge into the surviving row.
83
+ existing = RailsPulse::Route.find_by!(controller_action: ca, path: route.path)
84
+ RailsPulse::RouteMerger.call(target: existing, source: route) unless existing.id == route.id
85
+ results[:merged] += 1
86
+ end
87
+
88
+ # Remaining null-action rows are unrecognized (404s). Group them by path so
89
+ # GET+POST /unknown become one route before the null-action unique index is added.
90
+ def consolidate_unrecognized_duplicates(results)
91
+ paths = RailsPulse::Route.where(controller_action: nil)
92
+ .group(:path)
93
+ .having("COUNT(*) > 1")
94
+ .pluck(:path)
95
+
96
+ paths.each do |path|
97
+ routes = RailsPulse::Route.where(controller_action: nil, path: path).order(:id).to_a
98
+ winner = routes.shift
99
+ next unless winner
100
+
101
+ routes.each do |loser|
102
+ next unless RailsPulse::Route.exists?(id: loser.id)
103
+
104
+ RailsPulse::RouteMerger.call(target: winner, source: loser)
105
+ results[:merged] += 1
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,99 @@
1
+ module RailsPulse
2
+ # Merges source into target: combine http_methods, reassign requests and summaries, destroy source.
3
+ # Used when two routes would collide on the unique [controller_action, path] index.
4
+ class RouteMerger
5
+ METRIC_SUM_COLUMNS = %w[
6
+ count error_count success_count total_duration
7
+ status_2xx status_3xx status_4xx status_5xx
8
+ ].freeze
9
+
10
+ WEIGHTED_AVG_COLUMNS = %w[
11
+ avg_duration p50_duration p95_duration p99_duration
12
+ ].freeze
13
+
14
+ def self.call(target:, source:)
15
+ new(target, source).call
16
+ end
17
+
18
+ def initialize(target, source)
19
+ @target = target
20
+ @source = source
21
+ end
22
+
23
+ def call
24
+ return if @target.id == @source.id
25
+
26
+ @source.http_methods_list.each { |m| @target.add_http_method(m) }
27
+ RailsPulse::Route.transaction do
28
+ @source.requests.update_all(route_id: @target.id)
29
+ reassign_or_merge_summaries!
30
+ @source.destroy!
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def reassign_or_merge_summaries!
37
+ source_summaries = RailsPulse::Summary.where(
38
+ summarizable_type: "RailsPulse::Route",
39
+ summarizable_id: @source.id
40
+ )
41
+
42
+ source_summaries.find_each do |source_summary|
43
+ target_summary = RailsPulse::Summary.find_by(
44
+ summarizable_type: "RailsPulse::Route",
45
+ summarizable_id: @target.id,
46
+ period_type: source_summary.period_type,
47
+ period_start: source_summary.period_start
48
+ )
49
+
50
+ if target_summary
51
+ merge_summary_metrics!(target_summary, source_summary)
52
+ source_summary.destroy!
53
+ else
54
+ source_summary.update_column(:summarizable_id, @target.id)
55
+ end
56
+ end
57
+ end
58
+
59
+ def merge_summary_metrics!(target_summary, source_summary)
60
+ target_count = target_summary.count.to_i
61
+ source_count = source_summary.count.to_i
62
+ combined_count = target_count + source_count
63
+
64
+ attrs = {}
65
+
66
+ METRIC_SUM_COLUMNS.each do |column|
67
+ next unless target_summary.has_attribute?(column)
68
+
69
+ attrs[column] = target_summary.public_send(column).to_f + source_summary.public_send(column).to_f
70
+ end
71
+
72
+ if combined_count.positive?
73
+ WEIGHTED_AVG_COLUMNS.each do |column|
74
+ next unless target_summary.has_attribute?(column)
75
+
76
+ target_value = target_summary.public_send(column)
77
+ source_value = source_summary.public_send(column)
78
+ next if target_value.nil? && source_value.nil?
79
+
80
+ attrs[column] = (
81
+ (target_value.to_f * target_count) + (source_value.to_f * source_count)
82
+ ) / combined_count
83
+ end
84
+ end
85
+
86
+ if target_summary.has_attribute?(:min_duration)
87
+ mins = [ target_summary.min_duration, source_summary.min_duration ].compact
88
+ attrs[:min_duration] = mins.min if mins.any?
89
+ end
90
+
91
+ if target_summary.has_attribute?(:max_duration)
92
+ maxes = [ target_summary.max_duration, source_summary.max_duration ].compact
93
+ attrs[:max_duration] = maxes.max if maxes.any?
94
+ end
95
+
96
+ target_summary.update_columns(attrs.merge(updated_at: Time.current))
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,139 @@
1
+ module RailsPulse
2
+ class RouteMigrator
3
+ def self.call
4
+ new.call
5
+ end
6
+
7
+ def call
8
+ sync_id_sequence!
9
+ results = { merged: 0, skipped: 0, unchanged: 0 }
10
+ RailsPulse::Route.find_each { |route| process_route(route, results) }
11
+ results
12
+ end
13
+
14
+ private
15
+
16
+ def process_route(route, results)
17
+ # Skip rows already destroyed by an earlier merge in this run.
18
+ return unless RailsPulse::Route.exists?(id: route.id)
19
+
20
+ primary_method = route.http_methods_list.first
21
+ return results[:skipped] += 1 unless primary_method
22
+
23
+ params = RailsPulse::RouteRecognizer.call(route.path, method: primary_method)
24
+ return results[:skipped] += 1 unless params
25
+
26
+ normalized = RailsPulse::RoutePathNormalizer.normalize(route.path, params)
27
+
28
+ # Derive controller_action from the recognized route params if not already stored.
29
+ recognized_ca = [ params[:controller], params[:action] ].compact.join("#").presence
30
+
31
+ if normalized == route.path
32
+ assign_or_merge_controller_action(route, recognized_ca, results)
33
+ return
34
+ end
35
+
36
+ ca_for_target = recognized_ca || route.controller_action
37
+ target = find_or_create_normalized_route(route, ca_for_target, normalized)
38
+ RailsPulse::RouteMerger.call(target: target, source: route) unless target.id == route.id
39
+
40
+ results[:merged] += 1
41
+ end
42
+
43
+ def assign_or_merge_controller_action(route, recognized_ca, results)
44
+ if route.controller_action.nil? && recognized_ca.present?
45
+ existing = RailsPulse::Route.find_by(controller_action: recognized_ca, path: route.path)
46
+ if existing && existing.id != route.id
47
+ RailsPulse::RouteMerger.call(target: existing, source: route)
48
+ results[:merged] += 1
49
+ return
50
+ end
51
+
52
+ begin
53
+ route.update_column(:controller_action, recognized_ca)
54
+ rescue ActiveRecord::RecordNotUnique
55
+ existing = RailsPulse::Route.find_by(controller_action: recognized_ca, path: route.path)
56
+ if existing && existing.id != route.id
57
+ RailsPulse::RouteMerger.call(target: existing, source: route)
58
+ results[:merged] += 1
59
+ return
60
+ end
61
+ raise
62
+ end
63
+ end
64
+
65
+ results[:unchanged] += 1
66
+ end
67
+
68
+ # Find an existing normalized route, or create one. Handles two RecordNotUnique cases:
69
+ # 1. Concurrent create on [controller_action, path] — find and return the winner
70
+ # 2. Stale PK sequence (id already taken) — resync sequence and retry create
71
+ def find_or_create_normalized_route(route, controller_action, path)
72
+ existing = RailsPulse::Route.find_by(controller_action: controller_action, path: path)
73
+ return existing if existing
74
+
75
+ attrs = {
76
+ http_methods: route.http_methods,
77
+ path: path,
78
+ controller_action: controller_action,
79
+ tags: route.tags || "[]"
80
+ }
81
+
82
+ RailsPulse::Route.create!(attrs)
83
+ rescue ActiveRecord::RecordNotUnique => e
84
+ existing = RailsPulse::Route.find_by(controller_action: controller_action, path: path)
85
+ return existing if existing
86
+
87
+ raise unless primary_key_conflict?(e)
88
+
89
+ sync_id_sequence!
90
+ RailsPulse::Route.create!(attrs)
91
+ end
92
+
93
+ def primary_key_conflict?(error)
94
+ message = error.message.to_s
95
+ message.include?("rails_pulse_routes_pkey") ||
96
+ message.include?("PRIMARY KEY") ||
97
+ message.match?(/unique constraint.*\bids?\b/i)
98
+ end
99
+
100
+ def sync_id_sequence!
101
+ connection = RailsPulse::Route.connection
102
+ table = RailsPulse::Route.table_name
103
+ max_id = RailsPulse::Route.maximum(:id).to_i
104
+ return if max_id.zero?
105
+
106
+ case connection.adapter_name.downcase
107
+ when /postgres/
108
+ sequence = connection.select_value(
109
+ "SELECT pg_get_serial_sequence(#{connection.quote(table)}, 'id')"
110
+ )
111
+ return unless sequence
112
+
113
+ connection.execute(
114
+ "SELECT setval(#{connection.quote(sequence)}, #{max_id}, true)"
115
+ )
116
+ when /mysql/
117
+ # ALTER TABLE implicitly commits on MySQL and destroys SAVEPOINTs used by
118
+ # transactional tests / nested transactions. Skip while nested.
119
+ return if connection.open_transactions.positive?
120
+
121
+ connection.execute(
122
+ "ALTER TABLE #{connection.quote_table_name(table)} AUTO_INCREMENT = #{max_id + 1}"
123
+ )
124
+ when /sqlite/
125
+ quoted_table = connection.quote(table)
126
+ exists = connection.select_value(
127
+ "SELECT 1 FROM sqlite_sequence WHERE name = #{quoted_table}"
128
+ )
129
+ if exists
130
+ connection.execute(
131
+ "UPDATE sqlite_sequence SET seq = #{max_id} WHERE name = #{quoted_table}"
132
+ )
133
+ end
134
+ end
135
+ rescue ActiveRecord::StatementInvalid
136
+ # Best-effort; create will raise a clearer error if the sequence is still wrong.
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,79 @@
1
+ module RailsPulse
2
+ class RoutePathNormalizer
3
+ SKIP_PARAMS = %i[controller action format].freeze
4
+
5
+ def self.normalize(path, path_params)
6
+ new(path, path_params).normalize
7
+ end
8
+
9
+ def initialize(path, path_params)
10
+ @path = path
11
+ @path_params = path_params || {}
12
+ end
13
+
14
+ def normalize
15
+ return @path if @path.nil? || @path.empty?
16
+
17
+ if route_params.empty?
18
+ # No path params means the request missed the Rails router (404,
19
+ # middleware short-circuit, mounted Rack app). Bucket dynamic segments
20
+ # to prevent unbounded route cardinality from scanners and bots.
21
+ return bucket_unrecognized_path(@path)
22
+ end
23
+
24
+ value_to_keys = build_value_map
25
+ segments = @path.split("/", -1)
26
+ segments.map { |seg| substitute_segment(seg, value_to_keys) }.join("/")
27
+ end
28
+
29
+ private
30
+
31
+ def route_params
32
+ @route_params ||= @path_params.reject { |k, _| SKIP_PARAMS.include?(k) }
33
+ end
34
+
35
+ def build_value_map
36
+ route_params.each_with_object({}) do |(key, value), map|
37
+ str = value.to_s
38
+ map[str] ||= []
39
+ map[str] << key
40
+ end
41
+ end
42
+
43
+ def substitute_segment(segment, value_to_keys)
44
+ # Exact match — only substitute if unambiguous (one key for this value)
45
+ if (keys = value_to_keys[segment]) && keys.length == 1
46
+ return ":#{keys.first}"
47
+ end
48
+
49
+ # Format extension match: e.g. "42.json" when id="42"
50
+ if segment.include?(".")
51
+ base, ext = segment.split(".", 2)
52
+ if (keys = value_to_keys[base]) && keys.length == 1
53
+ return ":#{keys.first}.#{ext}"
54
+ end
55
+ end
56
+
57
+ segment
58
+ end
59
+
60
+ # Replace dynamic-looking segments so scanner paths, bot probes, and
61
+ # arbitrary IDs don't create one route row per distinct URL.
62
+ UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
63
+ SHA_RE = /\A[0-9a-f]{40}\z/i
64
+ DIGIT_RE = /\A\d+\z/
65
+
66
+ def bucket_unrecognized_path(path)
67
+ segments = path.split("/", -1)
68
+ segments.map! do |seg|
69
+ case seg
70
+ when UUID_RE then ":uuid"
71
+ when SHA_RE then ":sha"
72
+ when DIGIT_RE then ":id"
73
+ else seg
74
+ end
75
+ end
76
+ segments.join("/")
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,47 @@
1
+ module RailsPulse
2
+ # Recognizes a path/method against the host app's router outside a real request.
3
+ # Host apps often constrain routes with Devise/Warden (`r.env["warden"].authenticated?`);
4
+ # those constraints raise when warden is missing, so we stub it and try both
5
+ # authenticated and unauthenticated.
6
+ class RouteRecognizer
7
+ WardenStub = Struct.new(:authenticated) do
8
+ def authenticated?(*)
9
+ authenticated
10
+ end
11
+
12
+ def authenticate?(*)
13
+ authenticated
14
+ end
15
+ end
16
+
17
+ def self.call(path, method:)
18
+ new(path, method).call
19
+ end
20
+
21
+ def initialize(path, method)
22
+ @path = path
23
+ @method = method.to_s.upcase
24
+ end
25
+
26
+ def call
27
+ return nil if @path.blank? || @method.blank?
28
+
29
+ recognize(authenticated: true) || recognize(authenticated: false)
30
+ end
31
+
32
+ private
33
+
34
+ def recognize(authenticated:)
35
+ env = Rack::MockRequest.env_for(@path, method: @method)
36
+ env["warden"] = WardenStub.new(authenticated)
37
+ request = ActionDispatch::Request.new(env)
38
+
39
+ Rails.application.routes.recognize_path_with_request(request, @path, {})
40
+ rescue ActionController::RoutingError, ActionController::UnknownHttpMethod
41
+ nil
42
+ rescue NoMethodError, ArgumentError
43
+ # Other request-env constraints that still can't run outside a real request.
44
+ nil
45
+ end
46
+ end
47
+ end
@@ -17,6 +17,7 @@ module RailsPulse
17
17
  aggregate_routes # Per-route metrics
18
18
  aggregate_queries # Per-query metrics
19
19
  aggregate_jobs # Per-job metrics
20
+ aggregate_exceptions # Per-exception-group frequency
20
21
  end
21
22
 
22
23
  RailsPulse.logger.info "Completed #{period_type} summary"
@@ -113,6 +114,50 @@ module RailsPulse
113
114
  end
114
115
  end
115
116
 
117
+ # Exception frequency, per group and overall.
118
+ #
119
+ # ExceptionGroup#occurrence_count is a lifetime counter and occurrence rows
120
+ # are pruned by retention, so without this there is no way to ask how often
121
+ # something happened last week — the history is gone as soon as cleanup
122
+ # runs. Only `count` is meaningful here; the duration columns stay null
123
+ # because an exception has no duration.
124
+ def aggregate_exceptions
125
+ return unless RailsPulse.configuration.track_exceptions
126
+ return unless ExceptionOccurrence.table_exists?
127
+
128
+ counts = ExceptionOccurrence
129
+ .where(occurred_at: start_time...end_time)
130
+ .group(:exception_group_id)
131
+ .count
132
+
133
+ return if counts.empty?
134
+
135
+ counts.each do |group_id, occurrences|
136
+ upsert_exception_summary(group_id, occurrences)
137
+ end
138
+
139
+ # A rollup across every group, so the dashboard can chart total exception
140
+ # volume without loading one series per group.
141
+ upsert_exception_summary(0, counts.values.sum)
142
+ rescue ActiveRecord::ActiveRecordError => e
143
+ # Exceptions are the newest summarizable and the only optional one. A
144
+ # failure here must not lose the route, query and job summaries that were
145
+ # written in the same transaction.
146
+ RailsPulse.logger.error "Exception summary skipped: #{e.message}"
147
+ end
148
+
149
+ def upsert_exception_summary(group_id, occurrences)
150
+ summary = Summary.find_or_initialize_by(
151
+ summarizable_type: "RailsPulse::ExceptionGroup",
152
+ summarizable_id: group_id,
153
+ period_type: period_type,
154
+ period_start: start_time
155
+ )
156
+
157
+ summary.assign_attributes(period_end: end_time, count: occurrences)
158
+ summary.save!
159
+ end
160
+
116
161
  def aggregate_queries
117
162
  all_rows = Operation
118
163
  .where(occurred_at: start_time...end_time)
@@ -8,10 +8,12 @@ module RailsPulse
8
8
  def self.filter_ids(model_class, disabled_tags, show_non_tagged)
9
9
  relation = model_class.all
10
10
 
11
- # Exclude items with disabled tags
11
+ # Exclude items with disabled tags. Explicit ESCAPE: SQLite has no
12
+ # default escape character, so without it an underscore in a tag is
13
+ # never matched literally there and the exclusion matches nothing.
12
14
  disabled_tags.each do |tag|
13
- sanitized_tag = ActiveRecord::Base.sanitize_sql_like(tag.to_s, "\\")
14
- relation = relation.where.not("tags LIKE ?", "%#{sanitized_tag}%")
15
+ sanitized_tag = ActiveRecord::Base.sanitize_sql_like(tag.to_s, RailsPulse::Taggable::LIKE_ESCAPE)
16
+ relation = relation.where.not("tags LIKE ? ESCAPE '!'", "%#{sanitized_tag}%")
15
17
  end
16
18
 
17
19
  # Exclude non-tagged items if show_non_tagged is false
@@ -13,6 +13,13 @@
13
13
  <span class="overflow-ellipsis">Queries</span>
14
14
  <% end %>
15
15
 
16
+ <% if RailsPulse.configuration.track_exceptions %>
17
+ <%= link_to exceptions_path, class: 'btn sidebar-menu__button' do %>
18
+ <%= rails_pulse_icon "alert-circle", width: "16" %>
19
+ <span class="overflow-ellipsis">Exceptions</span>
20
+ <% end %>
21
+ <% end %>
22
+
16
23
  <% if RailsPulse.configuration.track_jobs %>
17
24
  <%= link_to jobs_path, class: 'btn sidebar-menu__button' do %>
18
25
  <%= rails_pulse_icon "zap", width: "16" %>
@@ -1,3 +1,11 @@
1
+ <% if @route_backfill_pending %>
2
+ <div class="setup-banner setup-banner--negative">
3
+ <p class="setup-banner__title">Route actions have not been backfilled</p>
4
+ <p class="setup-banner__body">Schema migrate adds the Action column but leaves it empty. Run this once to fill controller actions, normalize historical paths, and merge routes that share the same action:</p>
5
+ <code class="setup-banner__command"><span class="setup-banner__command-prefix">$</span> bin/rails rails_pulse:migrate_routes</code>
6
+ </div>
7
+ <% end %>
8
+
1
9
  <% if @summaries_stale %>
2
10
  <div class="setup-banner setup-banner--negative">
3
11
  <p class="setup-banner__title">Summary jobs don't appear to be running</p>
@@ -1,10 +1,10 @@
1
1
  <div class="setup-screen">
2
2
  <div class="setup-screen__brand">
3
- <%= image_tag rails_pulse.asset_path("rails-pulse-logo.png"),
4
- width: 72,
5
- height: 72,
6
- alt: "Rails Pulse",
7
- class: "setup-screen__logo" %>
3
+ <%= tag.img src: rails_pulse.asset_path("rails-pulse-logo.png"),
4
+ width: 72,
5
+ height: 72,
6
+ alt: "Rails Pulse",
7
+ class: "setup-screen__logo" %>
8
8
  <p class="setup-screen__brand-name">Rails Pulse</p>
9
9
  </div>
10
10
 
@@ -33,10 +33,10 @@
33
33
  })();
34
34
  </script>
35
35
 
36
- <!-- Rails Pulse Pre-compiled Assets (CSP-safe) -->
37
- <%= stylesheet_link_tag rails_pulse.asset_path('rails-pulse.css') %>
38
- <%= javascript_include_tag rails_pulse.asset_path('rails-pulse-icons.js'), defer: true, nonce: rails_pulse_csp_nonce %>
39
- <%= javascript_include_tag rails_pulse.asset_path('rails-pulse.js'), defer: true, nonce: rails_pulse_csp_nonce %>
36
+ <%# tag.link/script (not stylesheet_link_tag): middleware fallback paths must stay off asset_host. %>
37
+ <%= tag.link rel: "stylesheet", href: rails_pulse.asset_path("rails-pulse.css") %>
38
+ <%= tag.script "", src: rails_pulse.asset_path("rails-pulse-icons.js"), defer: true, nonce: rails_pulse_csp_nonce %>
39
+ <%= tag.script "", src: rails_pulse.asset_path("rails-pulse.js"), defer: true, nonce: rails_pulse_csp_nonce %>
40
40
  </head>
41
41
 
42
42
  <body class="header-layout">