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