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,212 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class CleanupService
|
3
|
+
def self.perform
|
4
|
+
new.perform
|
5
|
+
end
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@config = RailsPulse.configuration
|
9
|
+
@stats = {
|
10
|
+
time_based: {},
|
11
|
+
count_based: {},
|
12
|
+
total_deleted: 0
|
13
|
+
}
|
14
|
+
end
|
15
|
+
|
16
|
+
def perform
|
17
|
+
return unless cleanup_enabled?
|
18
|
+
|
19
|
+
Rails.logger.info "[RailsPulse] Starting data cleanup..."
|
20
|
+
|
21
|
+
perform_time_based_cleanup
|
22
|
+
perform_count_based_cleanup
|
23
|
+
|
24
|
+
log_cleanup_summary
|
25
|
+
@stats
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def cleanup_enabled?
|
31
|
+
@config.archiving_enabled
|
32
|
+
end
|
33
|
+
|
34
|
+
def perform_time_based_cleanup
|
35
|
+
return unless @config.full_retention_period
|
36
|
+
|
37
|
+
cutoff_time = @config.full_retention_period.ago
|
38
|
+
Rails.logger.info "[RailsPulse] Time-based cleanup: removing records older than #{cutoff_time}"
|
39
|
+
|
40
|
+
# Clean up in order that respects foreign key constraints
|
41
|
+
@stats[:time_based][:operations] = cleanup_operations_by_time(cutoff_time)
|
42
|
+
@stats[:time_based][:requests] = cleanup_requests_by_time(cutoff_time)
|
43
|
+
@stats[:time_based][:queries] = cleanup_queries_by_time(cutoff_time)
|
44
|
+
@stats[:time_based][:routes] = cleanup_routes_by_time(cutoff_time)
|
45
|
+
end
|
46
|
+
|
47
|
+
def perform_count_based_cleanup
|
48
|
+
return unless @config.max_table_records&.any?
|
49
|
+
|
50
|
+
Rails.logger.info "[RailsPulse] Count-based cleanup: enforcing table record limits"
|
51
|
+
|
52
|
+
# Clean up in order that respects foreign key constraints
|
53
|
+
@stats[:count_based][:operations] = cleanup_operations_by_count
|
54
|
+
@stats[:count_based][:requests] = cleanup_requests_by_count
|
55
|
+
@stats[:count_based][:queries] = cleanup_queries_by_count
|
56
|
+
@stats[:count_based][:routes] = cleanup_routes_by_count
|
57
|
+
end
|
58
|
+
|
59
|
+
# Time-based cleanup methods
|
60
|
+
def cleanup_operations_by_time(cutoff_time)
|
61
|
+
return 0 unless defined?(RailsPulse::Operation)
|
62
|
+
|
63
|
+
count = RailsPulse::Operation.where("occurred_at < ?", cutoff_time).count
|
64
|
+
RailsPulse::Operation.where("occurred_at < ?", cutoff_time).delete_all
|
65
|
+
count
|
66
|
+
end
|
67
|
+
|
68
|
+
def cleanup_requests_by_time(cutoff_time)
|
69
|
+
return 0 unless defined?(RailsPulse::Request)
|
70
|
+
|
71
|
+
count = RailsPulse::Request.where("occurred_at < ?", cutoff_time).count
|
72
|
+
RailsPulse::Request.where("occurred_at < ?", cutoff_time).delete_all
|
73
|
+
count
|
74
|
+
end
|
75
|
+
|
76
|
+
def cleanup_queries_by_time(cutoff_time)
|
77
|
+
return 0 unless defined?(RailsPulse::Query)
|
78
|
+
|
79
|
+
# Only delete queries that have no associated operations
|
80
|
+
query_ids_with_operations = RailsPulse::Operation.distinct.pluck(:query_id).compact
|
81
|
+
count = RailsPulse::Query
|
82
|
+
.where("created_at < ?", cutoff_time)
|
83
|
+
.where.not(id: query_ids_with_operations)
|
84
|
+
.count
|
85
|
+
RailsPulse::Query
|
86
|
+
.where("created_at < ?", cutoff_time)
|
87
|
+
.where.not(id: query_ids_with_operations)
|
88
|
+
.delete_all
|
89
|
+
count
|
90
|
+
end
|
91
|
+
|
92
|
+
def cleanup_routes_by_time(cutoff_time)
|
93
|
+
return 0 unless defined?(RailsPulse::Route)
|
94
|
+
|
95
|
+
# Only delete routes that have no associated requests
|
96
|
+
route_ids_with_requests = RailsPulse::Request.distinct.pluck(:route_id).compact
|
97
|
+
count = RailsPulse::Route
|
98
|
+
.where("created_at < ?", cutoff_time)
|
99
|
+
.where.not(id: route_ids_with_requests)
|
100
|
+
.count
|
101
|
+
RailsPulse::Route
|
102
|
+
.where("created_at < ?", cutoff_time)
|
103
|
+
.where.not(id: route_ids_with_requests)
|
104
|
+
.delete_all
|
105
|
+
count
|
106
|
+
end
|
107
|
+
|
108
|
+
# Count-based cleanup methods
|
109
|
+
def cleanup_operations_by_count
|
110
|
+
return 0 unless defined?(RailsPulse::Operation)
|
111
|
+
|
112
|
+
max_records = @config.max_table_records[:rails_pulse_operations]
|
113
|
+
return 0 unless max_records
|
114
|
+
|
115
|
+
current_count = RailsPulse::Operation.count
|
116
|
+
return 0 if current_count <= max_records
|
117
|
+
|
118
|
+
records_to_delete = current_count - max_records
|
119
|
+
ids_to_delete = RailsPulse::Operation
|
120
|
+
.order(:occurred_at)
|
121
|
+
.limit(records_to_delete)
|
122
|
+
.pluck(:id)
|
123
|
+
|
124
|
+
RailsPulse::Operation.where(id: ids_to_delete).delete_all
|
125
|
+
records_to_delete
|
126
|
+
end
|
127
|
+
|
128
|
+
def cleanup_requests_by_count
|
129
|
+
return 0 unless defined?(RailsPulse::Request)
|
130
|
+
|
131
|
+
max_records = @config.max_table_records[:rails_pulse_requests]
|
132
|
+
return 0 unless max_records
|
133
|
+
|
134
|
+
current_count = RailsPulse::Request.count
|
135
|
+
return 0 if current_count <= max_records
|
136
|
+
|
137
|
+
records_to_delete = current_count - max_records
|
138
|
+
ids_to_delete = RailsPulse::Request
|
139
|
+
.order(:occurred_at)
|
140
|
+
.limit(records_to_delete)
|
141
|
+
.pluck(:id)
|
142
|
+
|
143
|
+
RailsPulse::Request.where(id: ids_to_delete).delete_all
|
144
|
+
records_to_delete
|
145
|
+
end
|
146
|
+
|
147
|
+
def cleanup_queries_by_count
|
148
|
+
return 0 unless defined?(RailsPulse::Query)
|
149
|
+
|
150
|
+
max_records = @config.max_table_records[:rails_pulse_queries]
|
151
|
+
return 0 unless max_records
|
152
|
+
|
153
|
+
# Only consider queries that have no associated operations
|
154
|
+
query_ids_with_operations = RailsPulse::Operation.distinct.pluck(:query_id).compact
|
155
|
+
available_queries = RailsPulse::Query.where.not(id: query_ids_with_operations)
|
156
|
+
current_count = available_queries.count
|
157
|
+
return 0 if current_count <= max_records
|
158
|
+
|
159
|
+
records_to_delete = current_count - max_records
|
160
|
+
ids_to_delete = available_queries
|
161
|
+
.order(:created_at)
|
162
|
+
.limit(records_to_delete)
|
163
|
+
.pluck(:id)
|
164
|
+
|
165
|
+
RailsPulse::Query.where(id: ids_to_delete).delete_all
|
166
|
+
records_to_delete
|
167
|
+
end
|
168
|
+
|
169
|
+
def cleanup_routes_by_count
|
170
|
+
return 0 unless defined?(RailsPulse::Route)
|
171
|
+
|
172
|
+
max_records = @config.max_table_records[:rails_pulse_routes]
|
173
|
+
return 0 unless max_records
|
174
|
+
|
175
|
+
# Only consider routes that have no associated requests
|
176
|
+
route_ids_with_requests = RailsPulse::Request.distinct.pluck(:route_id).compact
|
177
|
+
available_routes = RailsPulse::Route.where.not(id: route_ids_with_requests)
|
178
|
+
current_count = available_routes.count
|
179
|
+
return 0 if current_count <= max_records
|
180
|
+
|
181
|
+
records_to_delete = current_count - max_records
|
182
|
+
ids_to_delete = available_routes
|
183
|
+
.order(:created_at)
|
184
|
+
.limit(records_to_delete)
|
185
|
+
.pluck(:id)
|
186
|
+
|
187
|
+
RailsPulse::Route.where(id: ids_to_delete).delete_all
|
188
|
+
records_to_delete
|
189
|
+
end
|
190
|
+
|
191
|
+
def log_cleanup_summary
|
192
|
+
total_time_based = @stats[:time_based].values.sum
|
193
|
+
total_count_based = @stats[:count_based].values.sum
|
194
|
+
@stats[:total_deleted] = total_time_based + total_count_based
|
195
|
+
|
196
|
+
Rails.logger.info "[RailsPulse] Cleanup completed:"
|
197
|
+
Rails.logger.info " Time-based: #{total_time_based} records deleted"
|
198
|
+
Rails.logger.info " Count-based: #{total_count_based} records deleted"
|
199
|
+
Rails.logger.info " Total: #{@stats[:total_deleted]} records deleted"
|
200
|
+
|
201
|
+
if @stats[:total_deleted] > 0
|
202
|
+
Rails.logger.info " Breakdown:"
|
203
|
+
@stats[:time_based].each do |table, count|
|
204
|
+
Rails.logger.info " #{table} (time): #{count}" if count > 0
|
205
|
+
end
|
206
|
+
@stats[:count_based].each do |table, count|
|
207
|
+
Rails.logger.info " #{table} (count): #{count}" if count > 0
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
@@ -0,0 +1,176 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class Configuration
|
3
|
+
attr_accessor :enabled,
|
4
|
+
:route_thresholds,
|
5
|
+
:request_thresholds,
|
6
|
+
:query_thresholds,
|
7
|
+
:ignored_routes,
|
8
|
+
:ignored_requests,
|
9
|
+
:ignored_queries,
|
10
|
+
:track_assets,
|
11
|
+
:custom_asset_patterns,
|
12
|
+
:mount_path,
|
13
|
+
:full_retention_period,
|
14
|
+
:archiving_enabled,
|
15
|
+
:max_table_records,
|
16
|
+
:component_cache_enabled,
|
17
|
+
:component_cache_duration,
|
18
|
+
:connects_to,
|
19
|
+
:authentication_enabled,
|
20
|
+
:authentication_method,
|
21
|
+
:authentication_redirect_path
|
22
|
+
|
23
|
+
def initialize
|
24
|
+
@enabled = true
|
25
|
+
@route_thresholds = { slow: 500, very_slow: 1500, critical: 3000 }
|
26
|
+
@request_thresholds = { slow: 700, very_slow: 2000, critical: 4000 }
|
27
|
+
@query_thresholds = { slow: 100, very_slow: 500, critical: 1000 }
|
28
|
+
@ignored_routes = []
|
29
|
+
@ignored_requests = []
|
30
|
+
@ignored_queries = []
|
31
|
+
@track_assets = false
|
32
|
+
@custom_asset_patterns = []
|
33
|
+
@mount_path = nil
|
34
|
+
@full_retention_period = 2.weeks
|
35
|
+
@archiving_enabled = true
|
36
|
+
@max_table_records = {
|
37
|
+
rails_pulse_requests: 10000,
|
38
|
+
rails_pulse_operations: 50000,
|
39
|
+
rails_pulse_routes: 1000,
|
40
|
+
rails_pulse_queries: 500
|
41
|
+
}
|
42
|
+
@component_cache_enabled = true
|
43
|
+
@component_cache_duration = 1.hour
|
44
|
+
@connects_to = nil
|
45
|
+
@authentication_enabled = Rails.env.production?
|
46
|
+
@authentication_method = nil
|
47
|
+
@authentication_redirect_path = "/"
|
48
|
+
|
49
|
+
validate_configuration!
|
50
|
+
end
|
51
|
+
|
52
|
+
# Get all routes to ignore, including asset patterns if track_assets is false
|
53
|
+
def ignored_routes
|
54
|
+
routes = @ignored_routes.dup
|
55
|
+
|
56
|
+
unless @track_assets
|
57
|
+
routes.concat(default_asset_patterns)
|
58
|
+
routes.concat(@custom_asset_patterns)
|
59
|
+
end
|
60
|
+
|
61
|
+
routes
|
62
|
+
end
|
63
|
+
|
64
|
+
# Validate configuration settings
|
65
|
+
def validate_configuration!
|
66
|
+
validate_thresholds!
|
67
|
+
validate_retention_settings!
|
68
|
+
validate_patterns!
|
69
|
+
validate_cache_settings!
|
70
|
+
validate_database_settings!
|
71
|
+
validate_authentication_settings!
|
72
|
+
end
|
73
|
+
|
74
|
+
# Revalidate configuration after changes
|
75
|
+
def revalidate!
|
76
|
+
validate_configuration!
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
def validate_thresholds!
|
82
|
+
[ @route_thresholds, @request_thresholds, @query_thresholds ].each do |thresholds|
|
83
|
+
thresholds.each do |key, value|
|
84
|
+
unless value.is_a?(Numeric) && value > 0
|
85
|
+
raise ArgumentError, "Threshold #{key} must be a positive number, got #{value}"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def validate_retention_settings!
|
92
|
+
unless @full_retention_period.respond_to?(:seconds)
|
93
|
+
raise ArgumentError, "full_retention_period must be a time duration (e.g., 2.weeks), got #{@full_retention_period}"
|
94
|
+
end
|
95
|
+
|
96
|
+
@max_table_records.each do |table, count|
|
97
|
+
unless count.is_a?(Integer) && count > 0
|
98
|
+
raise ArgumentError, "max_table_records[#{table}] must be a positive integer, got #{count}"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def validate_patterns!
|
104
|
+
[ @ignored_routes, @ignored_requests, @ignored_queries, @custom_asset_patterns ].each do |patterns|
|
105
|
+
patterns.each do |pattern|
|
106
|
+
unless pattern.is_a?(String) || pattern.is_a?(Regexp)
|
107
|
+
raise ArgumentError, "Ignored patterns must be strings or regular expressions, got #{pattern.class}"
|
108
|
+
end
|
109
|
+
|
110
|
+
# Test regex patterns to ensure they're valid
|
111
|
+
if pattern.is_a?(Regexp)
|
112
|
+
begin
|
113
|
+
"test" =~ pattern
|
114
|
+
rescue RegexpError => e
|
115
|
+
raise ArgumentError, "Invalid regular expression pattern: #{e.message}"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def validate_cache_settings!
|
123
|
+
unless @component_cache_duration.respond_to?(:seconds)
|
124
|
+
raise ArgumentError, "component_cache_duration must be a time duration (e.g., 1.hour), got #{@component_cache_duration}"
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def validate_database_settings!
|
129
|
+
if @connects_to && !@connects_to.is_a?(Hash)
|
130
|
+
raise ArgumentError, "connects_to must be a hash with database connection configuration"
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def validate_authentication_settings!
|
135
|
+
if @authentication_enabled && @authentication_method.nil?
|
136
|
+
Rails.logger.warn "RailsPulse: Authentication is enabled but no authentication method is configured. This will deny all access."
|
137
|
+
end
|
138
|
+
|
139
|
+
if @authentication_method && ![ Proc, Symbol, String, NilClass ].include?(@authentication_method.class)
|
140
|
+
raise ArgumentError, "authentication_method must be a Proc, Symbol, String, or nil, got #{@authentication_method.class}"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Default patterns for common asset types and paths
|
145
|
+
def default_asset_patterns
|
146
|
+
[
|
147
|
+
# Asset file extensions
|
148
|
+
%r{\.(png|jpg|jpeg|gif|svg|css|js|ico|woff|woff2|ttf|eot|map)$}i,
|
149
|
+
|
150
|
+
# Common Rails asset paths
|
151
|
+
%r{^/assets/},
|
152
|
+
%r{^/packs/},
|
153
|
+
%r{^/.*?/assets/}, # Catches /connect/assets/, /admin/assets/, etc.
|
154
|
+
|
155
|
+
# Webpack dev server
|
156
|
+
%r{^/__webpack_hmr},
|
157
|
+
%r{^/sockjs-node/},
|
158
|
+
|
159
|
+
# Common health check endpoints
|
160
|
+
"/health",
|
161
|
+
"/health_check",
|
162
|
+
"/status",
|
163
|
+
"/ping",
|
164
|
+
|
165
|
+
# Favicon requests
|
166
|
+
"/favicon.ico",
|
167
|
+
"/apple-touch-icon.png",
|
168
|
+
"/apple-touch-icon-precomposed.png",
|
169
|
+
|
170
|
+
# Robots and sitemaps
|
171
|
+
"/robots.txt",
|
172
|
+
"/sitemap.xml"
|
173
|
+
]
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require "rails_pulse/version"
|
2
|
+
require "rails_pulse/migration"
|
3
|
+
require "rails_pulse/middleware/request_collector"
|
4
|
+
require "rails_pulse/middleware/asset_server"
|
5
|
+
require "rails_pulse/subscribers/operation_subscriber"
|
6
|
+
require "request_store"
|
7
|
+
require "rack/static"
|
8
|
+
require "rails_charts"
|
9
|
+
require "ransack"
|
10
|
+
require "pagy"
|
11
|
+
require "turbo-rails"
|
12
|
+
require "groupdate"
|
13
|
+
|
14
|
+
module RailsPulse
|
15
|
+
class Engine < ::Rails::Engine
|
16
|
+
isolate_namespace RailsPulse
|
17
|
+
|
18
|
+
# Load Rake tasks
|
19
|
+
rake_tasks do
|
20
|
+
Dir.glob(File.expand_path("../tasks/**/*.rake", __FILE__)).each { |file| load file }
|
21
|
+
end
|
22
|
+
|
23
|
+
# Register the install generator
|
24
|
+
generators do
|
25
|
+
require "generators/rails_pulse/install_generator"
|
26
|
+
end
|
27
|
+
|
28
|
+
initializer "rails_pulse.static_assets", before: "sprockets.environment" do |app|
|
29
|
+
# Configure Rack::Static middleware to serve pre-compiled assets
|
30
|
+
assets_path = Engine.root.join("public")
|
31
|
+
|
32
|
+
# Add custom middleware for serving Rails Pulse assets with proper headers
|
33
|
+
# Insert after Rack::Runtime but before ActionDispatch::Static for better compatibility
|
34
|
+
app.middleware.insert_after Rack::Runtime, RailsPulse::Middleware::AssetServer,
|
35
|
+
assets_path.to_s,
|
36
|
+
{
|
37
|
+
urls: [ "/rails-pulse-assets" ],
|
38
|
+
headers: Engine.asset_headers
|
39
|
+
}
|
40
|
+
end
|
41
|
+
|
42
|
+
initializer "rails_pulse.middleware" do |app|
|
43
|
+
app.middleware.use RailsPulse::Middleware::RequestCollector
|
44
|
+
end
|
45
|
+
|
46
|
+
initializer "rails_pulse.operation_notifications" do
|
47
|
+
RailsPulse::Subscribers::OperationSubscriber.subscribe!
|
48
|
+
end
|
49
|
+
|
50
|
+
initializer "rails_pulse.rails_charts_theme" do
|
51
|
+
RailsCharts.options[:theme] = "railspulse"
|
52
|
+
end
|
53
|
+
|
54
|
+
initializer "rails_pulse.ransack", after: "ransack.initialize" do
|
55
|
+
# Ensure Ransack is loaded before our models
|
56
|
+
end
|
57
|
+
|
58
|
+
initializer "rails_pulse.database_configuration", before: "active_record.initialize_timezone" do
|
59
|
+
# Ensure database configuration is applied early in the initialization process
|
60
|
+
# This allows models to properly connect to configured databases
|
61
|
+
end
|
62
|
+
|
63
|
+
initializer "rails_pulse.timezone" do
|
64
|
+
# Configure Rails Pulse to always use UTC for consistent time operations
|
65
|
+
# This prevents Groupdate timezone mismatch errors across different host applications
|
66
|
+
# Note: We don't set Time.zone_default as it would affect the entire application
|
67
|
+
# Instead, we explicitly use time_zone: "UTC" in all groupdate calls
|
68
|
+
end
|
69
|
+
|
70
|
+
# CSP helper methods
|
71
|
+
def self.csp_sources
|
72
|
+
{
|
73
|
+
script_src: [ "'self'", "'nonce-'" ],
|
74
|
+
style_src: [ "'self'", "'nonce-'" ],
|
75
|
+
img_src: [ "'self'", "data:" ]
|
76
|
+
}
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
def self.asset_headers
|
82
|
+
{
|
83
|
+
"Cache-Control" => "public, max-age=31536000, immutable",
|
84
|
+
"Vary" => "Accept-Encoding"
|
85
|
+
}
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require "rack/static"
|
2
|
+
|
3
|
+
module RailsPulse
|
4
|
+
module Middleware
|
5
|
+
class AssetServer < Rack::Static
|
6
|
+
MIME_TYPES = {
|
7
|
+
".css" => "text/css",
|
8
|
+
".js" => "application/javascript",
|
9
|
+
".map" => "application/json",
|
10
|
+
".svg" => "image/svg+xml"
|
11
|
+
}.freeze
|
12
|
+
|
13
|
+
def initialize(app, root, options = {})
|
14
|
+
@logger = Rails.logger if defined?(Rails)
|
15
|
+
# Rack::Static expects (app, options) where options[:root] is the root path
|
16
|
+
options = options.merge(root: root) if root.is_a?(String) || root.is_a?(Pathname)
|
17
|
+
super(app, options)
|
18
|
+
end
|
19
|
+
|
20
|
+
def call(env)
|
21
|
+
# Only handle requests for Rails Pulse assets
|
22
|
+
unless rails_pulse_asset_request?(env)
|
23
|
+
return @app.call(env)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Log asset requests for debugging
|
27
|
+
@logger&.debug "[Rails Pulse] Asset request: #{env['PATH_INFO']}"
|
28
|
+
|
29
|
+
# Set proper MIME type based on file extension
|
30
|
+
set_content_type(env)
|
31
|
+
|
32
|
+
# Call parent Rack::Static with error handling
|
33
|
+
begin
|
34
|
+
status, headers, body = super(env)
|
35
|
+
|
36
|
+
# Add immutable cache headers for successful responses
|
37
|
+
if status == 200
|
38
|
+
headers.merge!(cache_headers)
|
39
|
+
@logger&.debug "[Rails Pulse] Asset served successfully: #{env['PATH_INFO']}"
|
40
|
+
elsif status == 404
|
41
|
+
log_missing_asset(env["PATH_INFO"]) if @logger
|
42
|
+
end
|
43
|
+
|
44
|
+
[ status, headers, body ]
|
45
|
+
rescue => e
|
46
|
+
log_asset_error(env["PATH_INFO"], e) if @logger
|
47
|
+
@app.call(env)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def rails_pulse_asset_request?(env)
|
54
|
+
env["PATH_INFO"]&.start_with?("/rails-pulse-assets/")
|
55
|
+
end
|
56
|
+
|
57
|
+
def set_content_type(env)
|
58
|
+
path = env["PATH_INFO"]
|
59
|
+
extension = File.extname(path)
|
60
|
+
|
61
|
+
if MIME_TYPES.key?(extension)
|
62
|
+
env["rails_pulse.content_type"] = MIME_TYPES[extension]
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def cache_headers
|
67
|
+
{
|
68
|
+
"Cache-Control" => "public, max-age=31536000, immutable",
|
69
|
+
"Vary" => "Accept-Encoding",
|
70
|
+
"Expires" => (Time.now + 1.year).httpdate
|
71
|
+
}
|
72
|
+
end
|
73
|
+
|
74
|
+
def log_missing_asset(path)
|
75
|
+
@logger.warn "[Rails Pulse] Asset not found: #{path}"
|
76
|
+
end
|
77
|
+
|
78
|
+
def log_asset_error(path, error)
|
79
|
+
@logger.error "[Rails Pulse] Error serving asset #{path}: #{error.message}"
|
80
|
+
@logger.error error.backtrace.join("\n") if @logger.debug?
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Middleware
|
3
|
+
class RequestCollector
|
4
|
+
def initialize(app)
|
5
|
+
@app = app
|
6
|
+
end
|
7
|
+
|
8
|
+
def call(env)
|
9
|
+
# Skip if Rails Pulse is disabled
|
10
|
+
return @app.call(env) unless RailsPulse.configuration.enabled
|
11
|
+
|
12
|
+
# Skip logging if we are already recording RailsPulse activity. This is to avoid recursion issues
|
13
|
+
return @app.call(env) if RequestStore.store[:skip_recording_rails_pulse_activity]
|
14
|
+
|
15
|
+
req = ActionDispatch::Request.new(env)
|
16
|
+
|
17
|
+
# Skip RailsPulse engine requests
|
18
|
+
mount_path = RailsPulse.configuration.mount_path || "/rails_pulse"
|
19
|
+
if req.path.start_with?(mount_path)
|
20
|
+
RequestStore.store[:skip_recording_rails_pulse_activity] = true
|
21
|
+
result = @app.call(env)
|
22
|
+
RequestStore.store[:skip_recording_rails_pulse_activity] = false
|
23
|
+
return result
|
24
|
+
end
|
25
|
+
|
26
|
+
# Check if route should be ignored based on configuration
|
27
|
+
if should_ignore_route?(req)
|
28
|
+
RequestStore.store[:skip_recording_rails_pulse_activity] = true
|
29
|
+
result = @app.call(env)
|
30
|
+
RequestStore.store[:skip_recording_rails_pulse_activity] = false
|
31
|
+
return result
|
32
|
+
end
|
33
|
+
|
34
|
+
# Clear any previous request ID to avoid conflicts
|
35
|
+
RequestStore.store[:rails_pulse_request_id] = nil
|
36
|
+
|
37
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
38
|
+
|
39
|
+
# Temporarily skip recording while we create the route and request
|
40
|
+
RequestStore.store[:skip_recording_rails_pulse_activity] = true
|
41
|
+
route = find_or_create_route(req)
|
42
|
+
controller_action = "#{env['action_dispatch.request.parameters']&.[]('controller')&.classify}##{env['action_dispatch.request.parameters']&.[]('action')}"
|
43
|
+
occurred_at = Time.current
|
44
|
+
|
45
|
+
request = nil
|
46
|
+
if route
|
47
|
+
request = RailsPulse::Request.create!(
|
48
|
+
route: route,
|
49
|
+
duration: 0, # will update after response
|
50
|
+
status: 0, # will update after response
|
51
|
+
is_error: false,
|
52
|
+
request_uuid: req.uuid,
|
53
|
+
controller_action: controller_action,
|
54
|
+
occurred_at: occurred_at
|
55
|
+
)
|
56
|
+
RequestStore.store[:rails_pulse_request_id] = request.id
|
57
|
+
end
|
58
|
+
|
59
|
+
# Re-enable recording for the actual request processing
|
60
|
+
RequestStore.store[:skip_recording_rails_pulse_activity] = false
|
61
|
+
|
62
|
+
status, headers, response = @app.call(env)
|
63
|
+
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
64
|
+
|
65
|
+
# Temporarily skip recording while we update the request and save operations
|
66
|
+
RequestStore.store[:skip_recording_rails_pulse_activity] = true
|
67
|
+
if request
|
68
|
+
request.update(duration: duration, status: status, is_error: status.to_i >= 500)
|
69
|
+
|
70
|
+
# Save collected operations
|
71
|
+
operations_data = RequestStore.store[:rails_pulse_operations] || []
|
72
|
+
operations_data.each do |operation_data|
|
73
|
+
begin
|
74
|
+
RailsPulse::Operation.create!(operation_data)
|
75
|
+
rescue => e
|
76
|
+
Rails.logger.error "[RailsPulse] Failed to save operation: #{e.message}"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
[ status, headers, response ]
|
82
|
+
ensure
|
83
|
+
RequestStore.store[:skip_recording_rails_pulse_activity] = false
|
84
|
+
RequestStore.store[:rails_pulse_request_id] = nil
|
85
|
+
RequestStore.store[:rails_pulse_operations] = nil
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
def find_or_create_route(req)
|
91
|
+
method = req.request_method
|
92
|
+
path = req.path
|
93
|
+
RailsPulse::Route.find_or_create_by(method: method, path: path)
|
94
|
+
end
|
95
|
+
|
96
|
+
def should_ignore_route?(req)
|
97
|
+
# Get ignored routes from configuration
|
98
|
+
ignored_routes = RailsPulse.configuration.ignored_routes || []
|
99
|
+
|
100
|
+
# Create route identifier for matching
|
101
|
+
route_method_path = "#{req.request_method} #{req.path}"
|
102
|
+
route_path = req.path
|
103
|
+
|
104
|
+
# Check each ignored route pattern
|
105
|
+
ignored_routes.any? do |pattern|
|
106
|
+
case pattern
|
107
|
+
when String
|
108
|
+
# Exact string match against path or method+path
|
109
|
+
pattern == route_path || pattern == route_method_path
|
110
|
+
when Regexp
|
111
|
+
# Regex match against path or method+path
|
112
|
+
pattern.match?(route_path) || pattern.match?(route_method_path)
|
113
|
+
else
|
114
|
+
false
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|