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,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
@@ -0,0 +1,3 @@
1
+ module RailsPulse
2
+ VERSION = "0.1.0"
3
+ end
@@ -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
+ })();