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,185 @@
1
+ class ChangeRailsPulseRoutesToMultiVerbModel < ActiveRecord::Migration[7.0]
2
+ def up
3
+ # Step 1: Add http_methods JSON array column to routes
4
+ unless column_exists?(:rails_pulse_routes, :http_methods)
5
+ add_column :rails_pulse_routes, :http_methods, :text,
6
+ comment: "JSON array of HTTP methods accepted by this route (e.g., [\"GET\",\"POST\"])"
7
+ end
8
+
9
+ # Step 2: Backfill http_methods from the existing single method column
10
+ if column_exists?(:rails_pulse_routes, :method) && column_exists?(:rails_pulse_routes, :http_methods)
11
+ adapter = connection.adapter_name.downcase
12
+ if adapter.include?("mysql")
13
+ execute("UPDATE rails_pulse_routes SET http_methods = CONCAT('[\"', method, '\"]') WHERE http_methods IS NULL AND method IS NOT NULL")
14
+ else
15
+ execute("UPDATE rails_pulse_routes SET http_methods = '[\"' || method || '\"]' WHERE http_methods IS NULL AND method IS NOT NULL")
16
+ end
17
+ execute("UPDATE rails_pulse_routes SET http_methods = '[]' WHERE http_methods IS NULL")
18
+
19
+ connection.schema_cache.clear! if connection.respond_to?(:schema_cache)
20
+ http_methods_col = connection.columns(:rails_pulse_routes).find { |c| c.name == "http_methods" }
21
+ change_column_null :rails_pulse_routes, :http_methods, false if http_methods_col&.null
22
+ end
23
+
24
+ # Step 3: Add method column to requests so each request records the specific verb used
25
+ unless column_exists?(:rails_pulse_requests, :method)
26
+ add_column :rails_pulse_requests, :method, :string,
27
+ comment: "HTTP method used for this specific request (e.g., GET, POST)"
28
+ end
29
+
30
+ # Step 4: Backfill requests.method from routes.method before dropping that column
31
+ if column_exists?(:rails_pulse_routes, :method) && column_exists?(:rails_pulse_requests, :method)
32
+ execute(<<~SQL)
33
+ UPDATE rails_pulse_requests
34
+ SET method = (
35
+ SELECT method FROM rails_pulse_routes
36
+ WHERE rails_pulse_routes.id = rails_pulse_requests.route_id
37
+ )
38
+ WHERE method IS NULL
39
+ SQL
40
+ end
41
+
42
+ # Step 5: Merge rows that already share [controller_action, path]. Do not merge
43
+ # null-action rows by path — GET /users (users#index) and POST /users (users#create)
44
+ # must stay distinct until `rails_pulse:migrate_routes` backfills controller_action.
45
+ consolidate_non_null_controller_action_groups
46
+
47
+ # Step 6: Swap the unique index from [method, path] to [controller_action, path]
48
+ if index_exists?(:rails_pulse_routes, [ :method, :path ], name: "index_rails_pulse_routes_on_method_and_path")
49
+ remove_index :rails_pulse_routes, name: "index_rails_pulse_routes_on_method_and_path"
50
+ end
51
+
52
+ unless index_exists?(:rails_pulse_routes, [ :controller_action, :path ], name: "index_rails_pulse_routes_on_controller_action_and_path")
53
+ add_index :rails_pulse_routes, [ :controller_action, :path ], unique: true,
54
+ name: "index_rails_pulse_routes_on_controller_action_and_path"
55
+ end
56
+
57
+ # Step 7: Drop the now-redundant single-method column from routes
58
+ if column_exists?(:rails_pulse_routes, :method)
59
+ remove_column :rails_pulse_routes, :method
60
+ end
61
+
62
+ say "Action will stay empty until you run: rails rails_pulse:migrate_routes"
63
+ end
64
+
65
+ def down
66
+ raise ActiveRecord::IrreversibleMigration
67
+ end
68
+
69
+ private
70
+
71
+ def consolidate_non_null_controller_action_groups
72
+ groups = connection.select_all(<<~SQL).to_a
73
+ SELECT controller_action, path
74
+ FROM rails_pulse_routes
75
+ WHERE controller_action IS NOT NULL
76
+ GROUP BY controller_action, path
77
+ HAVING COUNT(*) > 1
78
+ SQL
79
+
80
+ groups.each do |group|
81
+ routes = connection.select_all(
82
+ "SELECT id, http_methods FROM rails_pulse_routes " \
83
+ "WHERE controller_action = #{connection.quote(group["controller_action"])} " \
84
+ "AND path = #{connection.quote(group["path"])} " \
85
+ "ORDER BY id"
86
+ ).to_a
87
+ merge_route_rows!(routes)
88
+ end
89
+ end
90
+
91
+ def merge_route_rows!(routes)
92
+ all_methods = routes.flat_map { |r|
93
+ begin
94
+ JSON.parse(r["http_methods"] || "[]")
95
+ rescue JSON::ParserError
96
+ []
97
+ end
98
+ }.uniq.sort
99
+
100
+ winner_id = routes.first["id"].to_i
101
+ loser_ids = routes.drop(1).map { |r| r["id"].to_i }
102
+ return if loser_ids.empty?
103
+
104
+ loser_list = loser_ids.join(",")
105
+
106
+ connection.execute("UPDATE rails_pulse_requests SET route_id = #{winner_id} WHERE route_id IN (#{loser_list})")
107
+ reassign_or_merge_summaries!(winner_id, loser_ids)
108
+ connection.execute("UPDATE rails_pulse_routes SET http_methods = #{connection.quote(all_methods.to_json)} WHERE id = #{winner_id}")
109
+ connection.execute("DELETE FROM rails_pulse_routes WHERE id IN (#{loser_list})")
110
+ end
111
+
112
+ # Reassign loser summaries to the winner. When the same period already exists on the
113
+ # winner, combine additive/weighted metrics then delete the loser row (unique index
114
+ # on [summarizable_type, summarizable_id, period_type, period_start]).
115
+ def reassign_or_merge_summaries!(winner_id, loser_ids)
116
+ loser_ids.each do |loser_id|
117
+ source_summaries = connection.select_all(<<~SQL).to_a
118
+ SELECT *
119
+ FROM rails_pulse_summaries
120
+ WHERE summarizable_type = 'RailsPulse::Route'
121
+ AND summarizable_id = #{loser_id.to_i}
122
+ SQL
123
+
124
+ source_summaries.each do |source|
125
+ existing = connection.select_one(<<~SQL)
126
+ SELECT *
127
+ FROM rails_pulse_summaries
128
+ WHERE summarizable_type = 'RailsPulse::Route'
129
+ AND summarizable_id = #{winner_id.to_i}
130
+ AND period_type = #{connection.quote(source["period_type"])}
131
+ AND period_start = #{connection.quote(source["period_start"])}
132
+ SQL
133
+
134
+ if existing
135
+ merge_summary_row!(existing, source)
136
+ connection.execute("DELETE FROM rails_pulse_summaries WHERE id = #{source["id"].to_i}")
137
+ else
138
+ connection.execute(
139
+ "UPDATE rails_pulse_summaries SET summarizable_id = #{winner_id.to_i} WHERE id = #{source["id"].to_i}"
140
+ )
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+ def merge_summary_row!(target, source)
147
+ target_count = target["count"].to_i
148
+ source_count = source["count"].to_i
149
+ combined_count = target_count + source_count
150
+
151
+ sum_columns = %w[count error_count success_count total_duration status_2xx status_3xx status_4xx status_5xx]
152
+ weighted_columns = %w[avg_duration p50_duration p95_duration p99_duration]
153
+
154
+ sets = []
155
+
156
+ sum_columns.each do |column|
157
+ next unless target.key?(column)
158
+
159
+ sets << "#{column} = #{target[column].to_f + source[column].to_f}"
160
+ end
161
+
162
+ if combined_count.positive?
163
+ weighted_columns.each do |column|
164
+ next unless target.key?(column)
165
+ next if target[column].nil? && source[column].nil?
166
+
167
+ value = ((target[column].to_f * target_count) + (source[column].to_f * source_count)) / combined_count
168
+ sets << "#{column} = #{value}"
169
+ end
170
+ end
171
+
172
+ if target.key?("min_duration")
173
+ mins = [ target["min_duration"], source["min_duration"] ].compact
174
+ sets << "min_duration = #{mins.min}" if mins.any?
175
+ end
176
+
177
+ if target.key?("max_duration")
178
+ maxes = [ target["max_duration"], source["max_duration"] ].compact
179
+ sets << "max_duration = #{maxes.max}" if maxes.any?
180
+ end
181
+
182
+ sets << "updated_at = #{connection.quote(Time.current)}"
183
+ connection.execute("UPDATE rails_pulse_summaries SET #{sets.join(', ')} WHERE id = #{target["id"].to_i}")
184
+ end
185
+ end
@@ -0,0 +1,32 @@
1
+ require "rails_pulse/route_indexes"
2
+
3
+ class AddNullActionUniqueIndexToRoutes < ActiveRecord::Migration[7.0]
4
+ def up
5
+ if duplicate_null_action_paths?
6
+ say "Deferring null-action unique index — duplicate paths exist."
7
+ say "Run `rails rails_pulse:migrate_routes` to consolidate duplicates, then"
8
+ say "the index will be created automatically."
9
+ return
10
+ end
11
+
12
+ RailsPulse::RouteIndexes.ensure_null_action_uniqueness!(connection)
13
+ end
14
+
15
+ def down
16
+ RailsPulse::RouteIndexes.remove_null_action_uniqueness!(connection)
17
+ end
18
+
19
+ private
20
+
21
+ def duplicate_null_action_paths?
22
+ connection.select_value(<<~SQL).to_i.positive?
23
+ SELECT COUNT(*) FROM (
24
+ SELECT 1
25
+ FROM rails_pulse_routes
26
+ WHERE controller_action IS NULL
27
+ GROUP BY path
28
+ HAVING COUNT(*) > 1
29
+ ) duplicates
30
+ SQL
31
+ end
32
+ end
@@ -0,0 +1,12 @@
1
+ class AddLocationToExceptionGroups < ActiveRecord::Migration[7.0]
2
+ def change
3
+ # Guard on table existence — users who deleted the exceptions migration
4
+ # per the upgrade generator's advice won't have the table yet.
5
+ return unless table_exists?(:rails_pulse_exception_groups)
6
+
7
+ unless column_exists?(:rails_pulse_exception_groups, :location)
8
+ add_column :rails_pulse_exception_groups, :location, :string,
9
+ comment: "Relative first app-code frame, e.g. app/models/user.rb#save"
10
+ end
11
+ end
12
+ end
@@ -2,10 +2,12 @@
2
2
  # This file contains the complete schema for Rails Pulse tables
3
3
  # Load with: rails db:schema:load_rails_pulse or db:prepare
4
4
 
5
- RailsPulse::Schema ||= lambda do |connection|
5
+ require "rails_pulse/route_indexes" unless defined?(RailsPulse::RouteIndexes)
6
+
7
+ RailsPulse::Schema = lambda do |connection|
6
8
  adapter = connection.adapter_name.downcase
7
9
  # Skip if all tables already exist to prevent conflicts
8
- required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries, :rails_pulse_deployments ]
10
+ required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries, :rails_pulse_deployments, :rails_pulse_exception_groups, :rails_pulse_exception_occurrences ]
9
11
 
10
12
  # Check which tables already exist
11
13
  existing_tables = required_tables.select { |table| connection.table_exists?(table) }
@@ -28,14 +30,17 @@ RailsPulse::Schema ||= lambda do |connection|
28
30
 
29
31
  unless connection.table_exists?(:rails_pulse_routes)
30
32
  connection.create_table :rails_pulse_routes do |t|
31
- t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)"
32
- t.string :path, null: false, comment: "Request path (e.g., /posts/index)"
33
+ t.text :http_methods, null: false, comment: "JSON array of HTTP methods accepted by this route (e.g., [\"GET\",\"POST\"])"
34
+ t.string :path, null: false, comment: "Normalized request path (e.g., /posts/:id)"
33
35
  t.text :tags, comment: "JSON array of tags for filtering and categorization"
36
+ t.string :controller_action, comment: "Rails controller and action handling this route (e.g., articles#show)"
34
37
  t.timestamps
35
38
  end
36
39
 
37
- connection.add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path"
40
+ connection.add_index :rails_pulse_routes, [ :controller_action, :path ], unique: true, name: "index_rails_pulse_routes_on_controller_action_and_path"
38
41
  connection.add_index :rails_pulse_routes, :path, name: "index_rails_pulse_routes_on_path"
42
+ # Groups 404s / unrecognized paths by path. Partial (PG/SQLite) or functional (MySQL).
43
+ RailsPulse::RouteIndexes.ensure_null_action_uniqueness!(connection)
39
44
  end
40
45
 
41
46
  unless connection.table_exists?(:rails_pulse_queries)
@@ -61,6 +66,7 @@ RailsPulse::Schema ||= lambda do |connection|
61
66
  unless connection.table_exists?(:rails_pulse_requests)
62
67
  connection.create_table :rails_pulse_requests do |t|
63
68
  t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route"
69
+ t.string :method, comment: "HTTP method used for this request (e.g., GET, POST)"
64
70
  t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds"
65
71
  t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)"
66
72
  t.boolean :is_error, null: false, default: false, comment: "True if status >= 500"
@@ -211,6 +217,48 @@ RailsPulse::Schema ||= lambda do |connection|
211
217
  name: "index_rails_pulse_deployments_on_revision"
212
218
  end
213
219
 
220
+ unless connection.table_exists?(:rails_pulse_exception_groups)
221
+ connection.create_table :rails_pulse_exception_groups do |t|
222
+ t.string :fingerprint, null: false, comment: "SHA256 of exception_class + relative first app-code location"
223
+ t.string :exception_class, null: false, comment: "e.g. ActiveRecord::RecordNotFound"
224
+ t.string :location, comment: "Relative first app-code frame, e.g. app/models/user.rb#save"
225
+ t.text :message, comment: "Message from the most recent occurrence"
226
+ t.datetime :first_seen_at, null: false
227
+ t.datetime :last_seen_at, null: false
228
+ t.integer :occurrence_count, null: false, default: 0
229
+ t.string :status, null: false, default: "open", comment: "open, resolved, ignored"
230
+ t.datetime :resolved_at, comment: "When the group was last resolved"
231
+ t.boolean :preserve, null: false, default: false, comment: "Exempt from all automatic cleanup including occurrence rows"
232
+ t.timestamps
233
+ end
234
+
235
+ connection.add_index :rails_pulse_exception_groups, :fingerprint, unique: true, name: "index_rp_exception_groups_on_fingerprint"
236
+ connection.add_index :rails_pulse_exception_groups, :last_seen_at, name: "index_rp_exception_groups_on_last_seen_at"
237
+ connection.add_index :rails_pulse_exception_groups, :exception_class, name: "index_rp_exception_groups_on_class"
238
+ connection.add_index :rails_pulse_exception_groups, :status, name: "index_rp_exception_groups_on_status"
239
+ end
240
+
241
+ unless connection.table_exists?(:rails_pulse_exception_occurrences)
242
+ connection.create_table :rails_pulse_exception_occurrences do |t|
243
+ t.references :exception_group, null: false, index: false,
244
+ foreign_key: { to_table: :rails_pulse_exception_groups },
245
+ comment: "FK to the group this occurrence belongs to"
246
+ t.string :exception_class, null: false
247
+ t.text :message
248
+ t.text :backtrace, comment: "JSON array of {file, line, method} frames (first 50)"
249
+ t.string :request_url, comment: "Nullable — web requests only"
250
+ t.string :request_method, comment: "GET, POST, etc."
251
+ t.string :environment, comment: "production, staging, etc."
252
+ t.string :deploy_sha, comment: "Captured now even though Pro uses it — cannot backfill later"
253
+ t.text :request_params, comment: "JSON hash of filtered request params — web requests only"
254
+ t.datetime :occurred_at, null: false
255
+ t.timestamps
256
+ end
257
+
258
+ connection.add_index :rails_pulse_exception_occurrences, :occurred_at, name: "index_rp_exception_occurrences_on_occurred_at"
259
+ connection.add_index :rails_pulse_exception_occurrences, :exception_group_id, name: "index_rp_exception_occurrences_on_group_id"
260
+ end
261
+
214
262
  # Add indexes to existing tables for efficient aggregation
215
263
  unless connection.index_exists?(:rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation")
216
264
  connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation"
@@ -226,7 +274,7 @@ RailsPulse::Schema ||= lambda do |connection|
226
274
  if newly_created.any?
227
275
  puts "[RailsPulse::Schema] Successfully created tables: #{newly_created.join(', ')}"
228
276
  end
229
- end
277
+ end unless defined?(RailsPulse::Schema)
230
278
 
231
279
  if defined?(RailsPulse::ApplicationRecord)
232
280
  RailsPulse::Schema.call(RailsPulse::ApplicationRecord.connection)
@@ -13,6 +13,8 @@ module RailsPulse
13
13
  rails_pulse_job_runs
14
14
  rails_pulse_summaries
15
15
  rails_pulse_deployments
16
+ rails_pulse_exception_groups
17
+ rails_pulse_exception_occurrences
16
18
  ].freeze
17
19
 
18
20
  # Generate next migration number for timestamped migrations
@@ -78,6 +78,11 @@ module RailsPulse
78
78
  <<: *default
79
79
  database: storage/#{Rails.env}_rails_pulse.sqlite3
80
80
  migrations_paths: db/rails_pulse_migrate
81
+ schema_dump: false
82
+
83
+ schema_dump: false is required so Rails does not dump or load
84
+ db/rails_pulse_structure.sql. Keep database_tasks enabled so
85
+ rails db:migrate:rails_pulse still runs upgrades.
81
86
 
82
87
  2. Run: rails db:prepare (creates database and loads schema)
83
88
  3. Restart your Rails server
@@ -48,6 +48,25 @@ module RailsPulse
48
48
 
49
49
  private
50
50
 
51
+ # Column definitions look like: t.text :column_name, comment: "..."
52
+ # The comment capture must tolerate escaped quotes — the http_methods
53
+ # comment embeds a JSON example, [\"GET\",\"POST\"] — so it matches a
54
+ # full Ruby string literal (quotes included) rather than stopping at the
55
+ # first quote character.
56
+ COLUMN_DEFINITION = /t\.(\w+)\s+:([a-zA-Z_][a-zA-Z0-9_]*)(?:.*?comment:\s*("(?:[^"\\]|\\.)*"))?/
57
+
58
+ # Turn the matched Ruby string literal back into the string it denotes, so
59
+ # callers hold the real comment text and re-escape it themselves (the
60
+ # upgrade migration template uses String#inspect). Falls back to stripping
61
+ # the quotes if the literal uses an escape String#undump rejects.
62
+ def decode_comment(literal)
63
+ return nil if literal.nil?
64
+
65
+ literal.undump
66
+ rescue RuntimeError
67
+ literal.delete_prefix('"').delete_suffix('"')
68
+ end
69
+
51
70
  # Parse column definitions from a table block
52
71
  # Returns hash of column_name => { type:, comment: }
53
72
  def parse_table_columns(table_block)
@@ -55,9 +74,10 @@ module RailsPulse
55
74
 
56
75
  table_block.split("\n").each do |line|
57
76
  # Match column definitions: t.text :column_name, comment: "..."
58
- next unless match = line.match(/t\.(\w+)\s+:([a-zA-Z_][a-zA-Z0-9_]*)(?:.*?comment:\s*"([^"]*)")?/)
77
+ next unless match = line.match(COLUMN_DEFINITION)
59
78
 
60
- column_type, column_name, comment = match.captures
79
+ column_type, column_name, comment_literal = match.captures
80
+ comment = decode_comment(comment_literal)
61
81
 
62
82
  # Skip timestamps and references (Rails handles these)
63
83
  next if %w[timestamps references].include?(column_type)
@@ -2,10 +2,12 @@
2
2
  # This file contains the complete schema for Rails Pulse tables
3
3
  # Load with: rails db:schema:load_rails_pulse or db:prepare
4
4
 
5
- RailsPulse::Schema ||= lambda do |connection|
5
+ require "rails_pulse/route_indexes" unless defined?(RailsPulse::RouteIndexes)
6
+
7
+ RailsPulse::Schema = lambda do |connection|
6
8
  adapter = connection.adapter_name.downcase
7
9
  # Skip if all tables already exist to prevent conflicts
8
- required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries, :rails_pulse_deployments ]
10
+ required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries, :rails_pulse_deployments, :rails_pulse_exception_groups, :rails_pulse_exception_occurrences ]
9
11
 
10
12
  # Check which tables already exist
11
13
  existing_tables = required_tables.select { |table| connection.table_exists?(table) }
@@ -28,14 +30,17 @@ RailsPulse::Schema ||= lambda do |connection|
28
30
 
29
31
  unless connection.table_exists?(:rails_pulse_routes)
30
32
  connection.create_table :rails_pulse_routes do |t|
31
- t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)"
32
- t.string :path, null: false, comment: "Request path (e.g., /posts/index)"
33
+ t.text :http_methods, null: false, comment: "JSON array of HTTP methods accepted by this route (e.g., [\"GET\",\"POST\"])"
34
+ t.string :path, null: false, comment: "Normalized request path (e.g., /posts/:id)"
33
35
  t.text :tags, comment: "JSON array of tags for filtering and categorization"
36
+ t.string :controller_action, comment: "Rails controller and action handling this route (e.g., articles#show)"
34
37
  t.timestamps
35
38
  end
36
39
 
37
- connection.add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path"
40
+ connection.add_index :rails_pulse_routes, [ :controller_action, :path ], unique: true, name: "index_rails_pulse_routes_on_controller_action_and_path"
38
41
  connection.add_index :rails_pulse_routes, :path, name: "index_rails_pulse_routes_on_path"
42
+ # Groups 404s / unrecognized paths by path. Partial (PG/SQLite) or functional (MySQL).
43
+ RailsPulse::RouteIndexes.ensure_null_action_uniqueness!(connection)
39
44
  end
40
45
 
41
46
  unless connection.table_exists?(:rails_pulse_queries)
@@ -61,6 +66,7 @@ RailsPulse::Schema ||= lambda do |connection|
61
66
  unless connection.table_exists?(:rails_pulse_requests)
62
67
  connection.create_table :rails_pulse_requests do |t|
63
68
  t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route"
69
+ t.string :method, comment: "HTTP method used for this request (e.g., GET, POST)"
64
70
  t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds"
65
71
  t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)"
66
72
  t.boolean :is_error, null: false, default: false, comment: "True if status >= 500"
@@ -211,6 +217,48 @@ RailsPulse::Schema ||= lambda do |connection|
211
217
  name: "index_rails_pulse_deployments_on_revision"
212
218
  end
213
219
 
220
+ unless connection.table_exists?(:rails_pulse_exception_groups)
221
+ connection.create_table :rails_pulse_exception_groups do |t|
222
+ t.string :fingerprint, null: false, comment: "SHA256 of exception_class + relative first app-code location"
223
+ t.string :exception_class, null: false, comment: "e.g. ActiveRecord::RecordNotFound"
224
+ t.string :location, comment: "Relative first app-code frame, e.g. app/models/user.rb#save"
225
+ t.text :message, comment: "Message from the most recent occurrence"
226
+ t.datetime :first_seen_at, null: false
227
+ t.datetime :last_seen_at, null: false
228
+ t.integer :occurrence_count, null: false, default: 0
229
+ t.string :status, null: false, default: "open", comment: "open, resolved, ignored"
230
+ t.datetime :resolved_at, comment: "When the group was last resolved"
231
+ t.boolean :preserve, null: false, default: false, comment: "Exempt from all automatic cleanup including occurrence rows"
232
+ t.timestamps
233
+ end
234
+
235
+ connection.add_index :rails_pulse_exception_groups, :fingerprint, unique: true, name: "index_rp_exception_groups_on_fingerprint"
236
+ connection.add_index :rails_pulse_exception_groups, :last_seen_at, name: "index_rp_exception_groups_on_last_seen_at"
237
+ connection.add_index :rails_pulse_exception_groups, :exception_class, name: "index_rp_exception_groups_on_class"
238
+ connection.add_index :rails_pulse_exception_groups, :status, name: "index_rp_exception_groups_on_status"
239
+ end
240
+
241
+ unless connection.table_exists?(:rails_pulse_exception_occurrences)
242
+ connection.create_table :rails_pulse_exception_occurrences do |t|
243
+ t.references :exception_group, null: false, index: false,
244
+ foreign_key: { to_table: :rails_pulse_exception_groups },
245
+ comment: "FK to the group this occurrence belongs to"
246
+ t.string :exception_class, null: false
247
+ t.text :message
248
+ t.text :backtrace, comment: "JSON array of {file, line, method} frames (first 50)"
249
+ t.string :request_url, comment: "Nullable — web requests only"
250
+ t.string :request_method, comment: "GET, POST, etc."
251
+ t.string :environment, comment: "production, staging, etc."
252
+ t.string :deploy_sha, comment: "Captured now even though Pro uses it — cannot backfill later"
253
+ t.text :request_params, comment: "JSON hash of filtered request params — web requests only"
254
+ t.datetime :occurred_at, null: false
255
+ t.timestamps
256
+ end
257
+
258
+ connection.add_index :rails_pulse_exception_occurrences, :occurred_at, name: "index_rp_exception_occurrences_on_occurred_at"
259
+ connection.add_index :rails_pulse_exception_occurrences, :exception_group_id, name: "index_rp_exception_occurrences_on_group_id"
260
+ end
261
+
214
262
  # Add indexes to existing tables for efficient aggregation
215
263
  unless connection.index_exists?(:rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation")
216
264
  connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation"
@@ -226,7 +274,7 @@ RailsPulse::Schema ||= lambda do |connection|
226
274
  if newly_created.any?
227
275
  puts "[RailsPulse::Schema] Successfully created tables: #{newly_created.join(', ')}"
228
276
  end
229
- end
277
+ end unless defined?(RailsPulse::Schema)
230
278
 
231
279
  if defined?(RailsPulse::ApplicationRecord)
232
280
  RailsPulse::Schema.call(RailsPulse::ApplicationRecord.connection)
@@ -4,7 +4,7 @@ class UpgradeRailsPulseTables < ActiveRecord::Migration[<%= @migration_version %
4
4
  <% @missing_columns.each do |table_name, columns| %>
5
5
  <% columns.each do |column_name, definition| %>
6
6
  # Add <%= column_name %> column to <%= table_name %>
7
- add_column :<%= table_name %>, :<%= column_name %>, :<%= definition[:type] %><% if definition[:comment] %>, comment: "<%= definition[:comment] %>"<% end %>
7
+ add_column :<%= table_name %>, :<%= column_name %>, :<%= definition[:type] %><% if definition[:comment] %>, comment: <%= definition[:comment].inspect %><% end %>
8
8
  <% end %>
9
9
  <% end %>
10
10
  end