rails_pulse 0.1.0
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +638 -0
- data/Rakefile +207 -0
- data/app/assets/images/rails_pulse/dashboard.png +0 -0
- data/app/assets/images/rails_pulse/menu.svg +1 -0
- data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
- data/app/assets/images/rails_pulse/request.png +0 -0
- data/app/assets/images/rails_pulse/routes.png +0 -0
- data/app/assets/stylesheets/rails_pulse/application.css +102 -0
- data/app/assets/stylesheets/rails_pulse/components/alert.css +24 -0
- data/app/assets/stylesheets/rails_pulse/components/badge.css +58 -0
- data/app/assets/stylesheets/rails_pulse/components/base.css +79 -0
- data/app/assets/stylesheets/rails_pulse/components/breadcrumb.css +31 -0
- data/app/assets/stylesheets/rails_pulse/components/button.css +99 -0
- data/app/assets/stylesheets/rails_pulse/components/card.css +19 -0
- data/app/assets/stylesheets/rails_pulse/components/chart.css +18 -0
- data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +86 -0
- data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +9 -0
- data/app/assets/stylesheets/rails_pulse/components/dialog.css +56 -0
- data/app/assets/stylesheets/rails_pulse/components/flash.css +47 -0
- data/app/assets/stylesheets/rails_pulse/components/input.css +80 -0
- data/app/assets/stylesheets/rails_pulse/components/layouts.css +63 -0
- data/app/assets/stylesheets/rails_pulse/components/menu.css +43 -0
- data/app/assets/stylesheets/rails_pulse/components/popover.css +36 -0
- data/app/assets/stylesheets/rails_pulse/components/prose.css +144 -0
- data/app/assets/stylesheets/rails_pulse/components/row.css +24 -0
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +79 -0
- data/app/assets/stylesheets/rails_pulse/components/skeleton.css +5 -0
- data/app/assets/stylesheets/rails_pulse/components/table.css +37 -0
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +36 -0
- data/app/controllers/concerns/chart_table_concern.rb +82 -0
- data/app/controllers/concerns/response_range_concern.rb +24 -0
- data/app/controllers/concerns/time_range_concern.rb +67 -0
- data/app/controllers/concerns/zoom_range_concern.rb +40 -0
- data/app/controllers/rails_pulse/application_controller.rb +67 -0
- data/app/controllers/rails_pulse/assets_controller.rb +33 -0
- data/app/controllers/rails_pulse/caches_controller.rb +115 -0
- data/app/controllers/rails_pulse/csp_test_controller.rb +57 -0
- data/app/controllers/rails_pulse/dashboard_controller.rb +6 -0
- data/app/controllers/rails_pulse/operations_controller.rb +219 -0
- data/app/controllers/rails_pulse/queries_controller.rb +121 -0
- data/app/controllers/rails_pulse/requests_controller.rb +69 -0
- data/app/controllers/rails_pulse/routes_controller.rb +99 -0
- data/app/helpers/rails_pulse/application_helper.rb +111 -0
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +62 -0
- data/app/helpers/rails_pulse/cached_component_helper.rb +73 -0
- data/app/helpers/rails_pulse/chart_formatters.rb +43 -0
- data/app/helpers/rails_pulse/chart_helper.rb +140 -0
- data/app/helpers/rails_pulse/formatting_helper.rb +29 -0
- data/app/helpers/rails_pulse/status_helper.rb +279 -0
- data/app/helpers/rails_pulse/table_helper.rb +54 -0
- data/app/javascript/rails_pulse/application.js +119 -0
- data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +20 -0
- data/app/javascript/rails_pulse/controllers/context_menu_controller.js +16 -0
- data/app/javascript/rails_pulse/controllers/dialog_controller.js +21 -0
- data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +67 -0
- data/app/javascript/rails_pulse/controllers/form_controller.js +39 -0
- data/app/javascript/rails_pulse/controllers/icon_controller.js +170 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +230 -0
- data/app/javascript/rails_pulse/controllers/menu_controller.js +60 -0
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +69 -0
- data/app/javascript/rails_pulse/controllers/popover_controller.js +91 -0
- data/app/javascript/rails_pulse/controllers/timezone_controller.js +106 -0
- data/app/javascript/rails_pulse/theme.js +416 -0
- data/app/jobs/rails_pulse/application_job.rb +4 -0
- data/app/jobs/rails_pulse/cleanup_job.rb +21 -0
- data/app/mailers/rails_pulse/application_mailer.rb +6 -0
- data/app/models/rails_pulse/application_record.rb +7 -0
- data/app/models/rails_pulse/component_cache_key.rb +33 -0
- data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +27 -0
- data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +37 -0
- data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +59 -0
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +45 -0
- data/app/models/rails_pulse/operation.rb +87 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +52 -0
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +57 -0
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +71 -0
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +112 -0
- data/app/models/rails_pulse/query.rb +58 -0
- data/app/models/rails_pulse/request.rb +64 -0
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +99 -0
- data/app/models/rails_pulse/requests/charts/operations_chart.rb +35 -0
- data/app/models/rails_pulse/route.rb +77 -0
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +54 -0
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +73 -0
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +73 -0
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +59 -0
- data/app/models/rails_pulse/routes/charts/average_response_times.rb +115 -0
- data/app/models/rails_pulse/routes/tables/index.rb +63 -0
- data/app/services/rails_pulse/sql_query_normalizer.rb +124 -0
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +19 -0
- data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +44 -0
- data/app/views/layouts/rails_pulse/application.html.erb +72 -0
- data/app/views/rails_pulse/caches/show.html.erb +9 -0
- data/app/views/rails_pulse/components/_breadcrumbs.html.erb +12 -0
- data/app/views/rails_pulse/components/_code_panel.html.erb +12 -0
- data/app/views/rails_pulse/components/_metric_card.html.erb +55 -0
- data/app/views/rails_pulse/components/_metric_row.html.erb +9 -0
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +241 -0
- data/app/views/rails_pulse/components/_panel.html.erb +56 -0
- data/app/views/rails_pulse/components/_sparkline_stats.html.erb +15 -0
- data/app/views/rails_pulse/components/_table.html.erb +50 -0
- data/app/views/rails_pulse/components/_table_head.html.erb +20 -0
- data/app/views/rails_pulse/components/_table_pagination.html.erb +45 -0
- data/app/views/rails_pulse/components/_time_period.html.erb +16 -0
- data/app/views/rails_pulse/csp_test/show.html.erb +207 -0
- data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -0
- data/app/views/rails_pulse/dashboard/index.html.erb +64 -0
- data/app/views/rails_pulse/dashboard/tables/_routes_table.html.erb +32 -0
- data/app/views/rails_pulse/dashboard/tables/_standard_table.html.erb +1 -0
- data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +43 -0
- data/app/views/rails_pulse/operations/_operation_analysis_database.html.erb +12 -0
- data/app/views/rails_pulse/operations/_operation_analysis_generic.html.erb +15 -0
- data/app/views/rails_pulse/operations/_operation_analysis_other.html.erb +69 -0
- data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +39 -0
- data/app/views/rails_pulse/operations/show.html.erb +79 -0
- data/app/views/rails_pulse/queries/_show_table.html.erb +19 -0
- data/app/views/rails_pulse/queries/_table.html.erb +31 -0
- data/app/views/rails_pulse/queries/index.html.erb +64 -0
- data/app/views/rails_pulse/queries/show.html.erb +86 -0
- data/app/views/rails_pulse/requests/_operations.html.erb +85 -0
- data/app/views/rails_pulse/requests/_table.html.erb +31 -0
- data/app/views/rails_pulse/requests/index.html.erb +64 -0
- data/app/views/rails_pulse/requests/show.html.erb +44 -0
- data/app/views/rails_pulse/routes/_table.html.erb +29 -0
- data/app/views/rails_pulse/routes/index.html.erb +65 -0
- data/app/views/rails_pulse/routes/show.html.erb +67 -0
- data/app/views/rails_pulse/skeletons/_chart.html.erb +3 -0
- data/app/views/rails_pulse/skeletons/_metric_card.html.erb +20 -0
- data/app/views/rails_pulse/skeletons/_panel.html.erb +19 -0
- data/app/views/rails_pulse/skeletons/_table.html.erb +8 -0
- data/config/importmap.rb +12 -0
- data/config/initializers/rails_charts_csp_patch.rb +83 -0
- data/config/initializers/rails_pulse.rb +198 -0
- data/config/routes.rb +16 -0
- data/db/migrate/20250227235904_create_routes.rb +12 -0
- data/db/migrate/20250227235915_create_requests.rb +19 -0
- data/db/migrate/20250228000000_create_queries.rb +14 -0
- data/db/migrate/20250228000056_create_operations.rb +24 -0
- data/lib/generators/rails_pulse/install_generator.rb +17 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +198 -0
- data/lib/rails_pulse/cleanup_service.rb +212 -0
- data/lib/rails_pulse/configuration.rb +176 -0
- data/lib/rails_pulse/engine.rb +88 -0
- data/lib/rails_pulse/middleware/asset_server.rb +84 -0
- data/lib/rails_pulse/middleware/request_collector.rb +120 -0
- data/lib/rails_pulse/migration.rb +29 -0
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +280 -0
- data/lib/rails_pulse/version.rb +3 -0
- data/lib/rails_pulse.rb +38 -0
- data/lib/tasks/rails_pulse_tasks.rake +138 -0
- data/public/rails-pulse-assets/csp-test.js +110 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js +89 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +13 -0
- data/public/rails-pulse-assets/rails-pulse.css +1 -0
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -0
- data/public/rails-pulse-assets/rails-pulse.js +183 -0
- data/public/rails-pulse-assets/rails-pulse.js.map +7 -0
- metadata +339 -0
@@ -0,0 +1,29 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
# Determine the appropriate migration version based on Rails version
|
3
|
+
migration_version = case Rails.version
|
4
|
+
when /^8\./
|
5
|
+
8.0
|
6
|
+
when /^7\.2/
|
7
|
+
7.2
|
8
|
+
when /^7\.1/
|
9
|
+
7.1
|
10
|
+
when /^7\.0/
|
11
|
+
7.0
|
12
|
+
else
|
13
|
+
7.1 # Default fallback
|
14
|
+
end
|
15
|
+
|
16
|
+
class Migration < ActiveRecord::Migration[migration_version]
|
17
|
+
# This base migration class ensures that Rails Pulse migrations
|
18
|
+
# target the correct database when using multiple database configuration.
|
19
|
+
# The connection is determined by the connects_to configuration.
|
20
|
+
|
21
|
+
def connection
|
22
|
+
if RailsPulse.connects_to
|
23
|
+
RailsPulse::ApplicationRecord.connection
|
24
|
+
else
|
25
|
+
super
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,280 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Subscribers
|
3
|
+
class OperationSubscriber
|
4
|
+
def self.subscribe!
|
5
|
+
# Helper method to clean SQL labels by removing Rails comments
|
6
|
+
def self.clean_sql_label(sql)
|
7
|
+
return sql unless sql
|
8
|
+
# Remove Rails SQL comments like /*action='search',application='Dummy',controller='home'*/
|
9
|
+
sql.gsub(/\/\*[^*]*\*\//, "").strip
|
10
|
+
end
|
11
|
+
|
12
|
+
# Helper method to convert absolute paths to relative paths
|
13
|
+
def self.relative_path(absolute_path)
|
14
|
+
return absolute_path unless absolute_path&.start_with?("/")
|
15
|
+
|
16
|
+
rails_root = Rails.root.to_s
|
17
|
+
if absolute_path.start_with?(rails_root)
|
18
|
+
absolute_path.sub(rails_root + "/", "")
|
19
|
+
else
|
20
|
+
absolute_path
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Helper method to find the first app frame in the call stack
|
25
|
+
def self.find_app_frame
|
26
|
+
app_path = Rails.root.join("app").to_s
|
27
|
+
caller_locations.each do |loc|
|
28
|
+
path = loc.absolute_path || loc.path
|
29
|
+
return path if path && path.start_with?(app_path)
|
30
|
+
end
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
|
34
|
+
# Helper method to resolve controller action source location
|
35
|
+
def self.controller_action_source_location(payload)
|
36
|
+
return nil unless payload[:controller] && payload[:action]
|
37
|
+
begin
|
38
|
+
controller_klass = payload[:controller].constantize
|
39
|
+
if controller_klass.instance_methods(false).include?(payload[:action].to_sym)
|
40
|
+
file, line = controller_klass.instance_method(payload[:action]).source_location
|
41
|
+
return "#{relative_path(file)}:#{line}" if file && line
|
42
|
+
end
|
43
|
+
# fallback: try superclass (for ApplicationController actions)
|
44
|
+
if controller_klass.superclass.respond_to?(:instance_method)
|
45
|
+
if controller_klass.superclass.instance_methods(false).include?(payload[:action].to_sym)
|
46
|
+
file, line = controller_klass.superclass.instance_method(payload[:action]).source_location
|
47
|
+
return "#{relative_path(file)}:#{line}" if file && line
|
48
|
+
end
|
49
|
+
end
|
50
|
+
rescue => e
|
51
|
+
Rails.logger.debug "[RailsPulse] Could not resolve controller source location: #{e.class} - #{e.message}"
|
52
|
+
end
|
53
|
+
nil
|
54
|
+
end
|
55
|
+
|
56
|
+
# Helper method to capture operation data
|
57
|
+
def self.capture_operation(event_name, start, finish, payload, operation_type, label_key = nil)
|
58
|
+
return unless RailsPulse.configuration.enabled
|
59
|
+
return if RequestStore.store[:skip_recording_rails_pulse_activity]
|
60
|
+
|
61
|
+
request_id = RequestStore.store[:rails_pulse_request_id]
|
62
|
+
return unless request_id
|
63
|
+
|
64
|
+
# Skip RailsPulse-related operations to prevent recursion
|
65
|
+
if operation_type == "sql"
|
66
|
+
sql = payload[:sql]
|
67
|
+
return if sql&.include?("rails_pulse_")
|
68
|
+
end
|
69
|
+
|
70
|
+
label = case label_key
|
71
|
+
when :sql then clean_sql_label(payload[:sql])
|
72
|
+
when :template then relative_path(payload[:identifier] || payload[:template])
|
73
|
+
when :partial then relative_path(payload[:identifier] || payload[:partial])
|
74
|
+
when :controller then "#{payload[:controller]}##{payload[:action]}"
|
75
|
+
when :cache then payload[:key]
|
76
|
+
else payload[label_key] || event_name
|
77
|
+
end
|
78
|
+
|
79
|
+
codebase_location =
|
80
|
+
if payload[:identifier]
|
81
|
+
relative_path(payload[:identifier])
|
82
|
+
elsif payload[:template]
|
83
|
+
relative_path(payload[:template])
|
84
|
+
elsif operation_type == "controller"
|
85
|
+
controller_action_source_location(payload) || find_app_frame || caller_locations(3, 1).first&.path
|
86
|
+
elsif operation_type == "sql"
|
87
|
+
relative_path(find_app_frame || caller_locations(3, 1).first&.path)
|
88
|
+
else
|
89
|
+
find_app_frame || caller_locations(3, 1).first&.path
|
90
|
+
end
|
91
|
+
|
92
|
+
operation_data = {
|
93
|
+
request_id: request_id,
|
94
|
+
operation_type: operation_type,
|
95
|
+
label: label,
|
96
|
+
duration: (finish - start) * 1000,
|
97
|
+
codebase_location: codebase_location,
|
98
|
+
start_time: start.to_f,
|
99
|
+
occurred_at: Time.zone.at(start)
|
100
|
+
}
|
101
|
+
|
102
|
+
RequestStore.store[:rails_pulse_operations] ||= []
|
103
|
+
RequestStore.store[:rails_pulse_operations] << operation_data
|
104
|
+
end
|
105
|
+
|
106
|
+
# SQL queries
|
107
|
+
ActiveSupport::Notifications.subscribe "sql.active_record" do |name, start, finish, id, payload|
|
108
|
+
begin
|
109
|
+
next if payload[:name] == "SCHEMA"
|
110
|
+
capture_operation(name, start, finish, payload, "sql", :sql)
|
111
|
+
rescue => e
|
112
|
+
Rails.logger.error "[RailsPulse] Exception in SQL subscriber: #{e.class} - #{e.message}"
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Controller action processing
|
117
|
+
ActiveSupport::Notifications.subscribe "process_action.action_controller" do |name, start, finish, id, payload|
|
118
|
+
begin
|
119
|
+
capture_operation(name, start, finish, payload, "controller", :controller)
|
120
|
+
rescue => e
|
121
|
+
Rails.logger.error "[RailsPulse] Exception in controller subscriber: #{e.class} - #{e.message}"
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
# Template rendering
|
126
|
+
ActiveSupport::Notifications.subscribe "render_template.action_view" do |name, start, finish, id, payload|
|
127
|
+
begin
|
128
|
+
capture_operation(name, start, finish, payload, "template", :template)
|
129
|
+
rescue => e
|
130
|
+
Rails.logger.error "[RailsPulse] Exception in template subscriber: #{e.class} - #{e.message}"
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Partial rendering
|
135
|
+
ActiveSupport::Notifications.subscribe "render_partial.action_view" do |name, start, finish, id, payload|
|
136
|
+
begin
|
137
|
+
capture_operation(name, start, finish, payload, "partial", :partial)
|
138
|
+
rescue => e
|
139
|
+
Rails.logger.error "[RailsPulse] Exception in partial subscriber: #{e.class} - #{e.message}"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# Layout rendering
|
144
|
+
ActiveSupport::Notifications.subscribe "render_layout.action_view" do |name, start, finish, id, payload|
|
145
|
+
begin
|
146
|
+
capture_operation(name, start, finish, payload, "layout", :template)
|
147
|
+
rescue => e
|
148
|
+
Rails.logger.error "[RailsPulse] Exception in layout subscriber: #{e.class} - #{e.message}"
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
# Cache operations
|
153
|
+
ActiveSupport::Notifications.subscribe "cache_read.active_support" do |name, start, finish, id, payload|
|
154
|
+
begin
|
155
|
+
capture_operation(name, start, finish, payload, "cache_read", :cache)
|
156
|
+
rescue => e
|
157
|
+
Rails.logger.error "[RailsPulse] Exception in cache_read subscriber: #{e.class} - #{e.message}"
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
ActiveSupport::Notifications.subscribe "cache_write.active_support" do |name, start, finish, id, payload|
|
162
|
+
begin
|
163
|
+
capture_operation(name, start, finish, payload, "cache_write", :cache)
|
164
|
+
rescue => e
|
165
|
+
Rails.logger.error "[RailsPulse] Exception in cache_write subscriber: #{e.class} - #{e.message}"
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
# HTTP client requests (if using Net::HTTP)
|
170
|
+
ActiveSupport::Notifications.subscribe "request.net_http" do |name, start, finish, id, payload|
|
171
|
+
begin
|
172
|
+
next unless RailsPulse.configuration.enabled
|
173
|
+
label = "#{payload[:method]} #{payload[:uri]}"
|
174
|
+
codebase_location = find_app_frame || caller_locations(2, 1).first&.path
|
175
|
+
operation_data = {
|
176
|
+
request_id: RequestStore.store[:rails_pulse_request_id],
|
177
|
+
operation_type: "http",
|
178
|
+
label: label,
|
179
|
+
duration: (finish - start) * 1000,
|
180
|
+
codebase_location: codebase_location,
|
181
|
+
start_time: start.to_f,
|
182
|
+
occurred_at: Time.zone.at(start)
|
183
|
+
}
|
184
|
+
|
185
|
+
if operation_data[:request_id]
|
186
|
+
RequestStore.store[:rails_pulse_operations] ||= []
|
187
|
+
RequestStore.store[:rails_pulse_operations] << operation_data
|
188
|
+
end
|
189
|
+
rescue => e
|
190
|
+
Rails.logger.error "[RailsPulse] Exception in HTTP subscriber: #{e.class} - #{e.message}"
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
# Active Job processing
|
195
|
+
ActiveSupport::Notifications.subscribe "perform.active_job" do |name, start, finish, id, payload|
|
196
|
+
begin
|
197
|
+
next unless RailsPulse.configuration.enabled
|
198
|
+
label = "#{payload[:job].class.name}"
|
199
|
+
codebase_location = find_app_frame || caller_locations(2, 1).first&.path
|
200
|
+
operation_data = {
|
201
|
+
request_id: RequestStore.store[:rails_pulse_request_id],
|
202
|
+
operation_type: "job",
|
203
|
+
label: label,
|
204
|
+
duration: (finish - start) * 1000,
|
205
|
+
codebase_location: codebase_location,
|
206
|
+
start_time: start.to_f,
|
207
|
+
occurred_at: Time.zone.at(start)
|
208
|
+
}
|
209
|
+
|
210
|
+
if operation_data[:request_id]
|
211
|
+
RequestStore.store[:rails_pulse_operations] ||= []
|
212
|
+
RequestStore.store[:rails_pulse_operations] << operation_data
|
213
|
+
end
|
214
|
+
rescue => e
|
215
|
+
Rails.logger.error "[RailsPulse] Exception in job subscriber: #{e.class} - #{e.message}"
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# Collection rendering (for rendering collections)
|
220
|
+
ActiveSupport::Notifications.subscribe "render_collection.action_view" do |name, start, finish, id, payload|
|
221
|
+
begin
|
222
|
+
capture_operation(name, start, finish, payload, "collection", :template)
|
223
|
+
rescue => e
|
224
|
+
Rails.logger.error "[RailsPulse] Exception in collection subscriber: #{e.class} - #{e.message}"
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
# Action Mailer
|
229
|
+
ActiveSupport::Notifications.subscribe "deliver.action_mailer" do |name, start, finish, id, payload|
|
230
|
+
begin
|
231
|
+
next unless RailsPulse.configuration.enabled
|
232
|
+
label = "#{payload[:mailer]}##{payload[:action]}"
|
233
|
+
codebase_location = find_app_frame || caller_locations(2, 1).first&.path
|
234
|
+
operation_data = {
|
235
|
+
request_id: RequestStore.store[:rails_pulse_request_id],
|
236
|
+
operation_type: "mailer",
|
237
|
+
label: label,
|
238
|
+
duration: (finish - start) * 1000,
|
239
|
+
codebase_location: codebase_location,
|
240
|
+
start_time: start.to_f,
|
241
|
+
occurred_at: Time.zone.at(start)
|
242
|
+
}
|
243
|
+
|
244
|
+
if operation_data[:request_id]
|
245
|
+
RequestStore.store[:rails_pulse_operations] ||= []
|
246
|
+
RequestStore.store[:rails_pulse_operations] << operation_data
|
247
|
+
end
|
248
|
+
rescue => e
|
249
|
+
Rails.logger.error "[RailsPulse] Exception in mailer subscriber: #{e.class} - #{e.message}"
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
# Active Storage
|
254
|
+
ActiveSupport::Notifications.subscribe "service_upload.active_storage" do |name, start, finish, id, payload|
|
255
|
+
begin
|
256
|
+
next unless RailsPulse.configuration.enabled
|
257
|
+
label = "Upload: #{payload[:key]}"
|
258
|
+
codebase_location = find_app_frame || caller_locations(2, 1).first&.path
|
259
|
+
operation_data = {
|
260
|
+
request_id: RequestStore.store[:rails_pulse_request_id],
|
261
|
+
operation_type: "storage",
|
262
|
+
label: label,
|
263
|
+
duration: (finish - start) * 1000,
|
264
|
+
codebase_location: codebase_location,
|
265
|
+
start_time: start.to_f,
|
266
|
+
occurred_at: Time.zone.at(start)
|
267
|
+
}
|
268
|
+
|
269
|
+
if operation_data[:request_id]
|
270
|
+
RequestStore.store[:rails_pulse_operations] ||= []
|
271
|
+
RequestStore.store[:rails_pulse_operations] << operation_data
|
272
|
+
end
|
273
|
+
rescue => e
|
274
|
+
Rails.logger.error "[RailsPulse] Exception in storage subscriber: #{e.class} - #{e.message}"
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|
280
|
+
end
|
data/lib/rails_pulse.rb
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
require "rails_pulse/version"
|
2
|
+
require "rails_pulse/engine"
|
3
|
+
require "rails_pulse/configuration"
|
4
|
+
require "rails_pulse/cleanup_service"
|
5
|
+
|
6
|
+
module RailsPulse
|
7
|
+
class << self
|
8
|
+
attr_accessor :configuration
|
9
|
+
|
10
|
+
def configure
|
11
|
+
self.configuration ||= Configuration.new
|
12
|
+
yield(configuration)
|
13
|
+
end
|
14
|
+
|
15
|
+
def clear_metric_cache!
|
16
|
+
Rails.cache.delete_matched("rails_pulse_metric*")
|
17
|
+
end
|
18
|
+
|
19
|
+
def warm_metric_cache!
|
20
|
+
# Pre-warm cache for common metrics
|
21
|
+
[ :average_response_times, :percentile_response_times, :request_count_totals, :error_rate_per_route ].each do |metric|
|
22
|
+
begin
|
23
|
+
Rails.logger.info "Warming cache for metric: #{metric}"
|
24
|
+
# This would trigger cache generation by making the request
|
25
|
+
rescue => e
|
26
|
+
Rails.logger.error "Failed to warm cache for #{metric}: #{e.message}"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def connects_to
|
32
|
+
configuration&.connects_to
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Ensure configuration is initialized
|
37
|
+
self.configuration ||= Configuration.new
|
38
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
namespace :rails_pulse do
|
2
|
+
desc "Copies Rails Pulse migrations to the application."
|
3
|
+
task :install_migrations do
|
4
|
+
source_dir = File.expand_path("../../../db/migrate", __FILE__)
|
5
|
+
destination_dir = File.join(Rails.root, "db/migrate")
|
6
|
+
|
7
|
+
# Define the correct migration order
|
8
|
+
migration_order = [
|
9
|
+
"create_routes.rb",
|
10
|
+
"create_requests.rb",
|
11
|
+
"create_queries.rb",
|
12
|
+
"create_operations.rb"
|
13
|
+
]
|
14
|
+
|
15
|
+
puts "Copying migrations..."
|
16
|
+
base_time = Time.now.utc
|
17
|
+
|
18
|
+
migration_order.each_with_index do |migration_name, index|
|
19
|
+
# Find the source migration file
|
20
|
+
source_file = Dir.glob(File.join(source_dir, "*#{migration_name}")).first
|
21
|
+
next unless source_file
|
22
|
+
|
23
|
+
# Generate new timestamp
|
24
|
+
timestamp = (base_time + index.seconds).strftime("%Y%m%d%H%M%S")
|
25
|
+
new_filename = "#{timestamp}_#{migration_name.gsub('.rb', '')}.rails_pulse.rb"
|
26
|
+
destination_file = File.join(destination_dir, new_filename)
|
27
|
+
|
28
|
+
# Check if any version of this migration already exists
|
29
|
+
existing_migration = Dir.glob(File.join(destination_dir, "*#{migration_name.gsub('.rb', '')}*")).first
|
30
|
+
|
31
|
+
if existing_migration
|
32
|
+
puts "Skipping existing migration: #{File.basename(existing_migration)}"
|
33
|
+
else
|
34
|
+
FileUtils.cp(source_file, destination_file)
|
35
|
+
puts "Copied migration: #{new_filename}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
desc "Copies Rails Pulse example configuration to the application."
|
41
|
+
task :install_config do
|
42
|
+
source_file = File.expand_path("../../../lib/generators/rails_pulse/templates/rails_pulse.rb", __FILE__)
|
43
|
+
destination_file = File.join(Rails.root, "config/initializers/rails_pulse.rb")
|
44
|
+
|
45
|
+
if File.exist?(destination_file)
|
46
|
+
puts "Config already exists at #{destination_file}, skipping."
|
47
|
+
else
|
48
|
+
FileUtils.cp(source_file, destination_file)
|
49
|
+
puts "Copied example config to #{destination_file}"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
desc "Runs all install tasks for Rails Pulse (migrations and config)."
|
54
|
+
task install: [ :install_migrations, :install_config ]
|
55
|
+
|
56
|
+
desc "Performs data cleanup based on configured retention policies."
|
57
|
+
task cleanup: :environment do
|
58
|
+
puts "Starting Rails Pulse data cleanup..."
|
59
|
+
|
60
|
+
config = RailsPulse.configuration
|
61
|
+
|
62
|
+
unless config.archiving_enabled
|
63
|
+
puts "Cleanup is disabled (archiving_enabled = false). Exiting."
|
64
|
+
exit
|
65
|
+
end
|
66
|
+
|
67
|
+
stats = RailsPulse::CleanupService.perform
|
68
|
+
|
69
|
+
puts "Cleanup completed!"
|
70
|
+
puts "Records deleted:"
|
71
|
+
puts " Time-based cleanup: #{stats[:time_based].values.sum}"
|
72
|
+
puts " Count-based cleanup: #{stats[:count_based].values.sum}"
|
73
|
+
puts " Total: #{stats[:total_deleted]}"
|
74
|
+
|
75
|
+
if stats[:total_deleted] > 0
|
76
|
+
puts "\nBreakdown by table:"
|
77
|
+
stats[:time_based].each do |table, count|
|
78
|
+
puts " #{table} (time-based): #{count}" if count > 0
|
79
|
+
end
|
80
|
+
stats[:count_based].each do |table, count|
|
81
|
+
puts " #{table} (count-based): #{count}" if count > 0
|
82
|
+
end
|
83
|
+
end
|
84
|
+
rescue => e
|
85
|
+
puts "Cleanup failed: #{e.message}"
|
86
|
+
puts e.backtrace.join("\n") if ENV["VERBOSE"]
|
87
|
+
exit 1
|
88
|
+
end
|
89
|
+
|
90
|
+
desc "Shows current table sizes and cleanup configuration."
|
91
|
+
task cleanup_stats: :environment do
|
92
|
+
config = RailsPulse.configuration
|
93
|
+
|
94
|
+
puts "Rails Pulse Cleanup Configuration:"
|
95
|
+
puts " Cleanup enabled: #{config.archiving_enabled}"
|
96
|
+
puts " Retention period: #{config.full_retention_period}"
|
97
|
+
puts " Table limits: #{config.max_table_records}"
|
98
|
+
puts
|
99
|
+
|
100
|
+
puts "Current table sizes:"
|
101
|
+
|
102
|
+
tables = {
|
103
|
+
"rails_pulse_requests" => "RailsPulse::Request",
|
104
|
+
"rails_pulse_operations" => "RailsPulse::Operation",
|
105
|
+
"rails_pulse_routes" => "RailsPulse::Route",
|
106
|
+
"rails_pulse_queries" => "RailsPulse::Query"
|
107
|
+
}
|
108
|
+
|
109
|
+
tables.each do |table_name, model_name|
|
110
|
+
begin
|
111
|
+
model_class = model_name.constantize
|
112
|
+
count = model_class.count
|
113
|
+
limit = config.max_table_records[table_name.to_sym]
|
114
|
+
status = limit && count > limit ? " (OVER LIMIT)" : ""
|
115
|
+
puts " #{table_name}: #{count} records#{status}"
|
116
|
+
rescue NameError
|
117
|
+
puts " #{table_name}: Model not found"
|
118
|
+
rescue => e
|
119
|
+
puts " #{table_name}: Error - #{e.message}"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
if config.full_retention_period
|
124
|
+
cutoff_time = config.full_retention_period.ago
|
125
|
+
puts
|
126
|
+
puts "Records older than #{cutoff_time}:"
|
127
|
+
|
128
|
+
begin
|
129
|
+
old_requests = RailsPulse::Request.where("occurred_at < ?", cutoff_time).count
|
130
|
+
old_operations = RailsPulse::Operation.where("occurred_at < ?", cutoff_time).count
|
131
|
+
puts " rails_pulse_requests: #{old_requests} old records"
|
132
|
+
puts " rails_pulse_operations: #{old_operations} old records"
|
133
|
+
rescue => e
|
134
|
+
puts " Error calculating old records: #{e.message}"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
// CSP Test JavaScript
|
2
|
+
// This file tests that external JS files load correctly under strict CSP
|
3
|
+
|
4
|
+
(function() {
|
5
|
+
'use strict';
|
6
|
+
|
7
|
+
function updateStatus(elementId, status, success) {
|
8
|
+
const element = document.getElementById(elementId);
|
9
|
+
if (element) {
|
10
|
+
element.textContent = status;
|
11
|
+
element.className = success ? 'badge badge--success' : 'badge badge--error';
|
12
|
+
}
|
13
|
+
}
|
14
|
+
|
15
|
+
function checkAssetLoading() {
|
16
|
+
// Check CSS loading
|
17
|
+
const cssLoaded = document.querySelector('link[href*="rails-pulse.css"]');
|
18
|
+
updateStatus('css-status', cssLoaded ? 'Loaded' : 'Failed', !!cssLoaded);
|
19
|
+
|
20
|
+
// Check if main JS bundle exists (has Stimulus controllers)
|
21
|
+
const hasStimulus = window.Stimulus !== undefined;
|
22
|
+
updateStatus('js-status', hasStimulus ? 'Loaded' : 'Failed', hasStimulus);
|
23
|
+
|
24
|
+
// Check icons bundle (should have icon definitions)
|
25
|
+
const hasIcons = document.querySelector('script[src*="rails-pulse-icons.js"]');
|
26
|
+
updateStatus('icons-status', hasIcons ? 'Loaded' : 'Failed', !!hasIcons);
|
27
|
+
|
28
|
+
// Check Stimulus controllers are registered
|
29
|
+
const stimulusControllers = hasStimulus && window.Stimulus.router.modulesByIdentifier.size > 0;
|
30
|
+
updateStatus('stimulus-status', stimulusControllers ? 'Active' : 'Failed', stimulusControllers);
|
31
|
+
}
|
32
|
+
|
33
|
+
function setupAjaxTest() {
|
34
|
+
const button = document.getElementById('ajax-test-btn');
|
35
|
+
const result = document.getElementById('ajax-result');
|
36
|
+
|
37
|
+
if (button && result) {
|
38
|
+
button.addEventListener('click', async () => {
|
39
|
+
try {
|
40
|
+
button.disabled = true;
|
41
|
+
button.textContent = 'Loading...';
|
42
|
+
|
43
|
+
// Test a simple fetch request
|
44
|
+
const response = await fetch('/rails_pulse/csp_test', {
|
45
|
+
headers: { 'Accept': 'application/json' }
|
46
|
+
});
|
47
|
+
|
48
|
+
const data = await response.json();
|
49
|
+
|
50
|
+
result.innerHTML = `
|
51
|
+
<div class="text-success">✓ AJAX request completed successfully</div>
|
52
|
+
<div class="text-subtle mt-1">Status: ${response.status} ${response.statusText}</div>
|
53
|
+
<div class="text-subtle mt-1">Response: ${data.message}</div>
|
54
|
+
`;
|
55
|
+
} catch (error) {
|
56
|
+
result.innerHTML = `
|
57
|
+
<div class="text-error">✗ AJAX request failed</div>
|
58
|
+
<div class="text-subtle mt-1">Error: ${error.message}</div>
|
59
|
+
`;
|
60
|
+
} finally {
|
61
|
+
button.disabled = false;
|
62
|
+
button.textContent = 'Test AJAX Loading';
|
63
|
+
}
|
64
|
+
});
|
65
|
+
}
|
66
|
+
}
|
67
|
+
|
68
|
+
function trackCSPViolations() {
|
69
|
+
let violationCount = 0;
|
70
|
+
const countElement = document.getElementById('violation-count');
|
71
|
+
|
72
|
+
// Listen for CSP violations
|
73
|
+
document.addEventListener('securitypolicyviolation', (event) => {
|
74
|
+
violationCount++;
|
75
|
+
if (countElement) {
|
76
|
+
countElement.textContent = violationCount;
|
77
|
+
countElement.className = violationCount > 0 ? 'badge badge--error' : 'badge badge--success';
|
78
|
+
}
|
79
|
+
console.warn('CSP Violation:', event.violatedDirective, event.blockedURI);
|
80
|
+
});
|
81
|
+
|
82
|
+
// Initialize count display
|
83
|
+
if (countElement) {
|
84
|
+
countElement.textContent = '0';
|
85
|
+
countElement.className = 'badge badge--success';
|
86
|
+
}
|
87
|
+
}
|
88
|
+
|
89
|
+
function initializeTests() {
|
90
|
+
checkAssetLoading();
|
91
|
+
setupAjaxTest();
|
92
|
+
trackCSPViolations();
|
93
|
+
}
|
94
|
+
|
95
|
+
// Run tests when DOM is ready
|
96
|
+
if (document.readyState === 'loading') {
|
97
|
+
document.addEventListener('DOMContentLoaded', initializeTests);
|
98
|
+
} else {
|
99
|
+
initializeTests();
|
100
|
+
}
|
101
|
+
|
102
|
+
console.log('CSP Test JS loaded successfully - monitoring for violations');
|
103
|
+
|
104
|
+
// Add a visible indicator for system tests
|
105
|
+
const indicator = document.createElement('div');
|
106
|
+
indicator.id = 'js-loaded-indicator';
|
107
|
+
indicator.textContent = 'CSP Test JS loaded successfully';
|
108
|
+
indicator.style.display = 'none'; // Hidden but accessible to tests
|
109
|
+
document.body.appendChild(indicator);
|
110
|
+
})();
|