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.
Files changed (160) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +638 -0
  4. data/Rakefile +207 -0
  5. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  6. data/app/assets/images/rails_pulse/menu.svg +1 -0
  7. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  8. data/app/assets/images/rails_pulse/request.png +0 -0
  9. data/app/assets/images/rails_pulse/routes.png +0 -0
  10. data/app/assets/stylesheets/rails_pulse/application.css +102 -0
  11. data/app/assets/stylesheets/rails_pulse/components/alert.css +24 -0
  12. data/app/assets/stylesheets/rails_pulse/components/badge.css +58 -0
  13. data/app/assets/stylesheets/rails_pulse/components/base.css +79 -0
  14. data/app/assets/stylesheets/rails_pulse/components/breadcrumb.css +31 -0
  15. data/app/assets/stylesheets/rails_pulse/components/button.css +99 -0
  16. data/app/assets/stylesheets/rails_pulse/components/card.css +19 -0
  17. data/app/assets/stylesheets/rails_pulse/components/chart.css +18 -0
  18. data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +86 -0
  19. data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +9 -0
  20. data/app/assets/stylesheets/rails_pulse/components/dialog.css +56 -0
  21. data/app/assets/stylesheets/rails_pulse/components/flash.css +47 -0
  22. data/app/assets/stylesheets/rails_pulse/components/input.css +80 -0
  23. data/app/assets/stylesheets/rails_pulse/components/layouts.css +63 -0
  24. data/app/assets/stylesheets/rails_pulse/components/menu.css +43 -0
  25. data/app/assets/stylesheets/rails_pulse/components/popover.css +36 -0
  26. data/app/assets/stylesheets/rails_pulse/components/prose.css +144 -0
  27. data/app/assets/stylesheets/rails_pulse/components/row.css +24 -0
  28. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +79 -0
  29. data/app/assets/stylesheets/rails_pulse/components/skeleton.css +5 -0
  30. data/app/assets/stylesheets/rails_pulse/components/table.css +37 -0
  31. data/app/assets/stylesheets/rails_pulse/components/utilities.css +36 -0
  32. data/app/controllers/concerns/chart_table_concern.rb +82 -0
  33. data/app/controllers/concerns/response_range_concern.rb +24 -0
  34. data/app/controllers/concerns/time_range_concern.rb +67 -0
  35. data/app/controllers/concerns/zoom_range_concern.rb +40 -0
  36. data/app/controllers/rails_pulse/application_controller.rb +67 -0
  37. data/app/controllers/rails_pulse/assets_controller.rb +33 -0
  38. data/app/controllers/rails_pulse/caches_controller.rb +115 -0
  39. data/app/controllers/rails_pulse/csp_test_controller.rb +57 -0
  40. data/app/controllers/rails_pulse/dashboard_controller.rb +6 -0
  41. data/app/controllers/rails_pulse/operations_controller.rb +219 -0
  42. data/app/controllers/rails_pulse/queries_controller.rb +121 -0
  43. data/app/controllers/rails_pulse/requests_controller.rb +69 -0
  44. data/app/controllers/rails_pulse/routes_controller.rb +99 -0
  45. data/app/helpers/rails_pulse/application_helper.rb +111 -0
  46. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +62 -0
  47. data/app/helpers/rails_pulse/cached_component_helper.rb +73 -0
  48. data/app/helpers/rails_pulse/chart_formatters.rb +43 -0
  49. data/app/helpers/rails_pulse/chart_helper.rb +140 -0
  50. data/app/helpers/rails_pulse/formatting_helper.rb +29 -0
  51. data/app/helpers/rails_pulse/status_helper.rb +279 -0
  52. data/app/helpers/rails_pulse/table_helper.rb +54 -0
  53. data/app/javascript/rails_pulse/application.js +119 -0
  54. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +20 -0
  55. data/app/javascript/rails_pulse/controllers/context_menu_controller.js +16 -0
  56. data/app/javascript/rails_pulse/controllers/dialog_controller.js +21 -0
  57. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +67 -0
  58. data/app/javascript/rails_pulse/controllers/form_controller.js +39 -0
  59. data/app/javascript/rails_pulse/controllers/icon_controller.js +170 -0
  60. data/app/javascript/rails_pulse/controllers/index_controller.js +230 -0
  61. data/app/javascript/rails_pulse/controllers/menu_controller.js +60 -0
  62. data/app/javascript/rails_pulse/controllers/pagination_controller.js +69 -0
  63. data/app/javascript/rails_pulse/controllers/popover_controller.js +91 -0
  64. data/app/javascript/rails_pulse/controllers/timezone_controller.js +106 -0
  65. data/app/javascript/rails_pulse/theme.js +416 -0
  66. data/app/jobs/rails_pulse/application_job.rb +4 -0
  67. data/app/jobs/rails_pulse/cleanup_job.rb +21 -0
  68. data/app/mailers/rails_pulse/application_mailer.rb +6 -0
  69. data/app/models/rails_pulse/application_record.rb +7 -0
  70. data/app/models/rails_pulse/component_cache_key.rb +33 -0
  71. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +27 -0
  72. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +37 -0
  73. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +59 -0
  74. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +45 -0
  75. data/app/models/rails_pulse/operation.rb +87 -0
  76. data/app/models/rails_pulse/queries/cards/average_query_times.rb +52 -0
  77. data/app/models/rails_pulse/queries/cards/execution_rate.rb +57 -0
  78. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +71 -0
  79. data/app/models/rails_pulse/queries/charts/average_query_times.rb +112 -0
  80. data/app/models/rails_pulse/query.rb +58 -0
  81. data/app/models/rails_pulse/request.rb +64 -0
  82. data/app/models/rails_pulse/requests/charts/average_response_times.rb +99 -0
  83. data/app/models/rails_pulse/requests/charts/operations_chart.rb +35 -0
  84. data/app/models/rails_pulse/route.rb +77 -0
  85. data/app/models/rails_pulse/routes/cards/average_response_times.rb +54 -0
  86. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +73 -0
  87. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +73 -0
  88. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +59 -0
  89. data/app/models/rails_pulse/routes/charts/average_response_times.rb +115 -0
  90. data/app/models/rails_pulse/routes/tables/index.rb +63 -0
  91. data/app/services/rails_pulse/sql_query_normalizer.rb +124 -0
  92. data/app/views/layouts/rails_pulse/_menu_items.html.erb +19 -0
  93. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +44 -0
  94. data/app/views/layouts/rails_pulse/application.html.erb +72 -0
  95. data/app/views/rails_pulse/caches/show.html.erb +9 -0
  96. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +12 -0
  97. data/app/views/rails_pulse/components/_code_panel.html.erb +12 -0
  98. data/app/views/rails_pulse/components/_metric_card.html.erb +55 -0
  99. data/app/views/rails_pulse/components/_metric_row.html.erb +9 -0
  100. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +241 -0
  101. data/app/views/rails_pulse/components/_panel.html.erb +56 -0
  102. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +15 -0
  103. data/app/views/rails_pulse/components/_table.html.erb +50 -0
  104. data/app/views/rails_pulse/components/_table_head.html.erb +20 -0
  105. data/app/views/rails_pulse/components/_table_pagination.html.erb +45 -0
  106. data/app/views/rails_pulse/components/_time_period.html.erb +16 -0
  107. data/app/views/rails_pulse/csp_test/show.html.erb +207 -0
  108. data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -0
  109. data/app/views/rails_pulse/dashboard/index.html.erb +64 -0
  110. data/app/views/rails_pulse/dashboard/tables/_routes_table.html.erb +32 -0
  111. data/app/views/rails_pulse/dashboard/tables/_standard_table.html.erb +1 -0
  112. data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +43 -0
  113. data/app/views/rails_pulse/operations/_operation_analysis_database.html.erb +12 -0
  114. data/app/views/rails_pulse/operations/_operation_analysis_generic.html.erb +15 -0
  115. data/app/views/rails_pulse/operations/_operation_analysis_other.html.erb +69 -0
  116. data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +39 -0
  117. data/app/views/rails_pulse/operations/show.html.erb +79 -0
  118. data/app/views/rails_pulse/queries/_show_table.html.erb +19 -0
  119. data/app/views/rails_pulse/queries/_table.html.erb +31 -0
  120. data/app/views/rails_pulse/queries/index.html.erb +64 -0
  121. data/app/views/rails_pulse/queries/show.html.erb +86 -0
  122. data/app/views/rails_pulse/requests/_operations.html.erb +85 -0
  123. data/app/views/rails_pulse/requests/_table.html.erb +31 -0
  124. data/app/views/rails_pulse/requests/index.html.erb +64 -0
  125. data/app/views/rails_pulse/requests/show.html.erb +44 -0
  126. data/app/views/rails_pulse/routes/_table.html.erb +29 -0
  127. data/app/views/rails_pulse/routes/index.html.erb +65 -0
  128. data/app/views/rails_pulse/routes/show.html.erb +67 -0
  129. data/app/views/rails_pulse/skeletons/_chart.html.erb +3 -0
  130. data/app/views/rails_pulse/skeletons/_metric_card.html.erb +20 -0
  131. data/app/views/rails_pulse/skeletons/_panel.html.erb +19 -0
  132. data/app/views/rails_pulse/skeletons/_table.html.erb +8 -0
  133. data/config/importmap.rb +12 -0
  134. data/config/initializers/rails_charts_csp_patch.rb +83 -0
  135. data/config/initializers/rails_pulse.rb +198 -0
  136. data/config/routes.rb +16 -0
  137. data/db/migrate/20250227235904_create_routes.rb +12 -0
  138. data/db/migrate/20250227235915_create_requests.rb +19 -0
  139. data/db/migrate/20250228000000_create_queries.rb +14 -0
  140. data/db/migrate/20250228000056_create_operations.rb +24 -0
  141. data/lib/generators/rails_pulse/install_generator.rb +17 -0
  142. data/lib/generators/rails_pulse/templates/rails_pulse.rb +198 -0
  143. data/lib/rails_pulse/cleanup_service.rb +212 -0
  144. data/lib/rails_pulse/configuration.rb +176 -0
  145. data/lib/rails_pulse/engine.rb +88 -0
  146. data/lib/rails_pulse/middleware/asset_server.rb +84 -0
  147. data/lib/rails_pulse/middleware/request_collector.rb +120 -0
  148. data/lib/rails_pulse/migration.rb +29 -0
  149. data/lib/rails_pulse/subscribers/operation_subscriber.rb +280 -0
  150. data/lib/rails_pulse/version.rb +3 -0
  151. data/lib/rails_pulse.rb +38 -0
  152. data/lib/tasks/rails_pulse_tasks.rake +138 -0
  153. data/public/rails-pulse-assets/csp-test.js +110 -0
  154. data/public/rails-pulse-assets/rails-pulse-icons.js +89 -0
  155. data/public/rails-pulse-assets/rails-pulse-icons.js.map +13 -0
  156. data/public/rails-pulse-assets/rails-pulse.css +1 -0
  157. data/public/rails-pulse-assets/rails-pulse.css.map +1 -0
  158. data/public/rails-pulse-assets/rails-pulse.js +183 -0
  159. data/public/rails-pulse-assets/rails-pulse.js.map +7 -0
  160. 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