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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +299 -0
- data/README.md +44 -3
- data/app/assets/stylesheets/rails_pulse/application.css +21 -32
- data/app/assets/stylesheets/rails_pulse/components/backtrace.css +238 -0
- data/app/assets/stylesheets/rails_pulse/components/badge.css +32 -0
- data/app/assets/stylesheets/rails_pulse/components/button.css +2 -36
- data/app/assets/stylesheets/rails_pulse/components/card.css +8 -11
- data/app/assets/stylesheets/rails_pulse/components/chart.css +16 -3
- data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +5 -39
- data/app/assets/stylesheets/rails_pulse/components/dashboard.css +0 -52
- data/app/assets/stylesheets/rails_pulse/components/deployments.css +39 -0
- data/app/assets/stylesheets/rails_pulse/components/dialog.css +0 -5
- data/app/assets/stylesheets/rails_pulse/components/input.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/layouts.css +0 -20
- data/app/assets/stylesheets/rails_pulse/components/menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/storage.css +187 -0
- data/app/assets/stylesheets/rails_pulse/components/tags.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/time_range_selector.css +5 -5
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +0 -17
- data/app/controllers/concerns/chart_table_concern.rb +15 -3
- data/app/controllers/concerns/ransack_params_concern.rb +18 -0
- data/app/controllers/concerns/response_range_concern.rb +1 -1
- data/app/controllers/concerns/time_range_concern.rb +28 -14
- data/app/controllers/rails_pulse/application_controller.rb +104 -31
- data/app/controllers/rails_pulse/dashboard_controller.rb +9 -0
- data/app/controllers/rails_pulse/deployments_controller.rb +26 -5
- data/app/controllers/rails_pulse/exception_occurrences_controller.rb +19 -0
- data/app/controllers/rails_pulse/exceptions_controller.rb +119 -0
- data/app/controllers/rails_pulse/job_runs_controller.rb +1 -1
- data/app/controllers/rails_pulse/requests_controller.rb +1 -1
- data/app/controllers/rails_pulse/routes_controller.rb +7 -1
- data/app/controllers/rails_pulse/storage_controller.rb +7 -0
- data/app/controllers/rails_pulse/tags_controller.rb +2 -2
- data/app/helpers/rails_pulse/application_helper.rb +1 -0
- data/app/helpers/rails_pulse/backtrace_helper.rb +85 -0
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +4 -1
- data/app/helpers/rails_pulse/chart_helper.rb +0 -3
- data/app/helpers/rails_pulse/route_helper.rb +8 -16
- data/app/javascript/rails_pulse/application.js +29 -8
- data/app/javascript/rails_pulse/controllers/clipboard_controller.js +58 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +16 -10
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +6 -2
- data/app/javascript/rails_pulse/controllers/popover_controller.js +4 -0
- data/app/models/concerns/rails_pulse/taggable.rb +11 -6
- data/app/models/rails_pulse/cards/base.rb +53 -0
- data/app/models/rails_pulse/dashboard/health_summary.rb +52 -0
- data/app/models/rails_pulse/dashboard/needs_attention.rb +73 -1
- data/app/models/rails_pulse/dashboard/storage_status.rb +490 -0
- data/app/models/rails_pulse/deployment.rb +47 -1
- data/app/models/rails_pulse/exception_group.rb +37 -0
- data/app/models/rails_pulse/exception_occurrence.rb +27 -0
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +116 -0
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +49 -0
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +62 -0
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +56 -0
- data/app/models/rails_pulse/exceptions/raw_data.rb +216 -0
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -2
- data/app/models/rails_pulse/operation.rb +13 -4
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +8 -3
- data/app/models/rails_pulse/request.rb +5 -1
- data/app/models/rails_pulse/route.rb +71 -21
- data/app/models/rails_pulse/routes/cards/error_rates.rb +6 -3
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +8 -3
- data/app/models/rails_pulse/routes/tables/index.rb +8 -6
- data/app/models/rails_pulse/summary.rb +21 -10
- data/app/models/rails_pulse/tables/base.rb +4 -2
- data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +47 -12
- data/app/services/rails_pulse/exception_capture_service.rb +228 -0
- data/app/services/rails_pulse/exception_message_sanitizer.rb +152 -0
- data/app/services/rails_pulse/operations/change_point.rb +144 -0
- data/app/services/rails_pulse/operations/compare.rb +92 -0
- data/app/services/rails_pulse/operations/comparison.rb +126 -0
- data/app/services/rails_pulse/operations/metric.rb +63 -0
- data/app/services/rails_pulse/operations/series.rb +84 -0
- data/app/services/rails_pulse/operations/subject.rb +77 -0
- data/app/services/rails_pulse/route_controller_action_backfiller.rb +110 -0
- data/app/services/rails_pulse/route_merger.rb +99 -0
- data/app/services/rails_pulse/route_migrator.rb +139 -0
- data/app/services/rails_pulse/route_path_normalizer.rb +79 -0
- data/app/services/rails_pulse/route_recognizer.rb +47 -0
- data/app/services/rails_pulse/summary_service.rb +45 -0
- data/app/services/rails_pulse/tag_filter_service.rb +5 -3
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
- data/app/views/layouts/rails_pulse/_onboarding_banner.html.erb +8 -0
- data/app/views/layouts/rails_pulse/_setup_screen.html.erb +5 -5
- data/app/views/layouts/rails_pulse/application.html.erb +4 -4
- data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
- data/app/views/rails_pulse/components/_panel.html.erb +16 -6
- data/app/views/rails_pulse/components/_raw_data.html.erb +34 -0
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -8
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +64 -0
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +21 -10
- data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +67 -0
- data/app/views/rails_pulse/dashboard/index.html.erb +22 -1
- data/app/views/rails_pulse/deployments/_status_badge.html.erb +5 -0
- data/app/views/rails_pulse/deployments/_table.html.erb +29 -0
- data/app/views/rails_pulse/deployments/index.html.erb +15 -0
- data/app/views/rails_pulse/deployments/show.html.erb +62 -0
- data/app/views/rails_pulse/exception_occurrences/show.html.erb +45 -0
- data/app/views/rails_pulse/exceptions/_backtrace.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/_metric_cards.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_occurrences_table.html.erb +23 -0
- data/app/views/rails_pulse/exceptions/_status_badge.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_table.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/index.html.erb +66 -0
- data/app/views/rails_pulse/exceptions/show.html.erb +52 -0
- data/app/views/rails_pulse/queries/_raw_data.html.erb +1 -23
- data/app/views/rails_pulse/requests/_table.html.erb +1 -1
- data/app/views/rails_pulse/routes/_requests_table.html.erb +5 -0
- data/app/views/rails_pulse/routes/_table.html.erb +22 -8
- data/app/views/rails_pulse/routes/index.html.erb +5 -1
- data/app/views/rails_pulse/routes/show.html.erb +5 -0
- data/app/views/rails_pulse/storage/show.html.erb +123 -0
- data/config/routes.rb +13 -4
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +50 -0
- data/db/rails_pulse_migrate/20260507000001_add_actual_query_to_operations.rb +10 -5
- data/db/rails_pulse_migrate/20260610000001_add_controller_action_to_rails_pulse_routes.rb +8 -0
- data/db/rails_pulse_migrate/20260610000002_change_rails_pulse_routes_to_multi_verb_model.rb +185 -0
- data/db/rails_pulse_migrate/20260610000003_add_null_action_unique_index_to_routes.rb +32 -0
- data/db/rails_pulse_migrate/20260823000001_add_location_to_exception_groups.rb +12 -0
- data/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/base_methods.rb +2 -0
- data/lib/generators/rails_pulse/install_generator.rb +5 -0
- data/lib/generators/rails_pulse/schema_parser.rb +22 -2
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +1 -1
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +157 -16
- data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
- data/lib/rails_pulse/cleanup_service.rb +110 -15
- data/lib/rails_pulse/configuration.rb +125 -6
- data/lib/rails_pulse/engine.rb +32 -17
- data/lib/rails_pulse/installers/config_updater.rb +202 -0
- data/lib/rails_pulse/job_run_collector.rb +43 -16
- data/lib/rails_pulse/middleware/asset_server.rb +11 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -4
- data/lib/rails_pulse/packaged_assets.rb +147 -0
- data/lib/rails_pulse/route_indexes.rb +66 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
- data/lib/rails_pulse/tracker.rb +29 -10
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +1 -0
- data/lib/rails_pulse_server.ru +16 -47
- data/lib/tasks/rails_pulse.rake +83 -4
- data/lib/tasks/rails_pulse_assets.rake +15 -0
- data/lib/tasks/rails_pulse_tasks.rake +29 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +18 -117
- metadata +78 -37
- data/Rakefile +0 -554
- data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
- data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
- data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
- data/app/javascript/rails_pulse/theme.js +0 -416
- data/config/brakeman.ignore +0 -190
- data/config/brakeman.yml +0 -68
- data/config/initializers/rails_pulse.rb +0 -287
- data/public/rails-pulse-assets/csp-test.js +0 -120
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
- data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
- data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
- data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
- data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
- data/vendor/assets/javascripts/rails-pulse.js +0 -157
- data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
- data/vendor/assets/stylesheets/rails-pulse.css +0 -1
- 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
|
data/db/rails_pulse_schema.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
32
|
-
t.string :path, null: false, comment: "
|
|
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, [ :
|
|
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)
|
|
@@ -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(
|
|
77
|
+
next unless match = line.match(COLUMN_DEFINITION)
|
|
59
78
|
|
|
60
|
-
column_type, column_name,
|
|
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
|
-
|
|
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.
|
|
32
|
-
t.string :path, null: false, comment: "
|
|
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, [ :
|
|
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:
|
|
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
|