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