rails_pulse 0.1.2 → 0.1.4

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -20
  3. data/Rakefile +169 -86
  4. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  5. data/app/assets/images/rails_pulse/request.png +0 -0
  6. data/app/assets/stylesheets/rails_pulse/application.css +28 -5
  7. data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
  8. data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
  9. data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
  10. data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
  11. data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
  12. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
  13. data/app/controllers/concerns/zoom_range_concern.rb +31 -0
  14. data/app/controllers/rails_pulse/application_controller.rb +5 -1
  15. data/app/controllers/rails_pulse/queries_controller.rb +49 -10
  16. data/app/controllers/rails_pulse/requests_controller.rb +46 -20
  17. data/app/controllers/rails_pulse/routes_controller.rb +40 -1
  18. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +1 -1
  19. data/app/helpers/rails_pulse/chart_helper.rb +16 -8
  20. data/app/helpers/rails_pulse/formatting_helper.rb +21 -2
  21. data/app/javascript/rails_pulse/application.js +34 -3
  22. data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
  23. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
  24. data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
  25. data/app/javascript/rails_pulse/controllers/index_controller.js +249 -11
  26. data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
  27. data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
  28. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +1 -1
  29. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  30. data/app/models/rails_pulse/queries/cards/average_query_times.rb +20 -20
  31. data/app/models/rails_pulse/queries/cards/execution_rate.rb +58 -14
  32. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +14 -9
  33. data/app/models/rails_pulse/queries/charts/average_query_times.rb +3 -7
  34. data/app/models/rails_pulse/query.rb +46 -0
  35. data/app/models/rails_pulse/request.rb +1 -1
  36. data/app/models/rails_pulse/requests/charts/average_response_times.rb +2 -2
  37. data/app/models/rails_pulse/requests/tables/index.rb +77 -0
  38. data/app/models/rails_pulse/routes/cards/average_response_times.rb +18 -20
  39. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +14 -9
  40. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +14 -9
  41. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +29 -13
  42. data/app/models/rails_pulse/routes/tables/index.rb +4 -2
  43. data/app/models/rails_pulse/summary.rb +7 -7
  44. data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
  45. data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
  46. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
  47. data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
  48. data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
  49. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +154 -0
  50. data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
  51. data/app/services/rails_pulse/query_analysis_service.rb +125 -0
  52. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
  53. data/app/views/layouts/rails_pulse/application.html.erb +0 -2
  54. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
  55. data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
  56. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  57. data/app/views/rails_pulse/components/_metric_card.html.erb +28 -5
  58. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  59. data/app/views/rails_pulse/components/_panel.html.erb +1 -1
  60. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
  61. data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
  62. data/app/views/rails_pulse/dashboard/index.html.erb +2 -2
  63. data/app/views/rails_pulse/operations/show.html.erb +17 -15
  64. data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
  65. data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
  66. data/app/views/rails_pulse/queries/_analysis_results.html.erb +117 -0
  67. data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
  68. data/app/views/rails_pulse/queries/_show_table.html.erb +34 -6
  69. data/app/views/rails_pulse/queries/_table.html.erb +4 -8
  70. data/app/views/rails_pulse/queries/index.html.erb +48 -51
  71. data/app/views/rails_pulse/queries/show.html.erb +56 -52
  72. data/app/views/rails_pulse/requests/_operations.html.erb +30 -43
  73. data/app/views/rails_pulse/requests/_table.html.erb +31 -18
  74. data/app/views/rails_pulse/requests/index.html.erb +55 -50
  75. data/app/views/rails_pulse/requests/show.html.erb +0 -2
  76. data/app/views/rails_pulse/routes/_requests_table.html.erb +39 -0
  77. data/app/views/rails_pulse/routes/_table.html.erb +4 -10
  78. data/app/views/rails_pulse/routes/index.html.erb +49 -52
  79. data/app/views/rails_pulse/routes/show.html.erb +6 -8
  80. data/config/initializers/rails_charts_csp_patch.rb +32 -40
  81. data/config/routes.rb +5 -1
  82. data/db/migrate/20250930105043_install_rails_pulse_tables.rb +23 -0
  83. data/db/rails_pulse_schema.rb +10 -1
  84. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +81 -0
  85. data/lib/generators/rails_pulse/install_generator.rb +75 -18
  86. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +72 -2
  87. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +23 -0
  88. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
  89. data/lib/generators/rails_pulse/upgrade_generator.rb +226 -0
  90. data/lib/rails_pulse/engine.rb +21 -0
  91. data/lib/rails_pulse/version.rb +1 -1
  92. data/lib/tasks/rails_pulse.rake +27 -8
  93. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  94. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  95. data/public/rails-pulse-assets/rails-pulse.js +53 -53
  96. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  97. metadata +25 -6
  98. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  99. data/app/assets/images/rails_pulse/routes.png +0 -0
  100. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
  101. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +0 -54
@@ -10,64 +10,61 @@
10
10
  <% end %>
11
11
 
12
12
  <div
13
- class="row"
14
13
  data-controller="rails-pulse--index"
15
14
  data-rails-pulse--index-chart-id-value="average_response_times_chart"
16
15
  >
17
- <div class="grid-item">
18
- <%= render 'rails_pulse/components/panel', { title: 'Average Response Time', } do %>
19
- <%= search_form_for @ransack_query, url: routes_path, class: "flex items-center justify-between gap mb-4" do |form| %>
20
- <div class="flex items-center grow gap">
21
- <%= form.search_field :route_path_cont, placeholder: "Filter by route", autocomplete: "off", class: "input", style: "max-inline-size: 250px" %>
22
- <%= form.select :period_start_range,
23
- RailsPulse::RoutesController::TIME_RANGE_OPTIONS,
24
- { selected: @selected_time_range },
25
- { class: "input" }
26
- %>
27
- <%= form.select :avg_duration,
28
- duration_options(:route),
29
- { selected: @selected_response_range },
30
- { class: "input" }
31
- %>
32
- <%= link_to "Reset", routes_path, class: "btn btn--borderless show@md" if params.has_key?(:q) %>
33
- <%= form.submit "Search", class: "btn show@sm" %>
16
+ <%= render 'rails_pulse/components/panel', { title: 'Average Response Time', card_classes: 'b-full' } do %>
17
+ <%= search_form_for @ransack_query, url: routes_path, class: "flex items-center justify-between gap mb-4" do |form| %>
18
+ <div class="flex items-center grow gap">
19
+ <%= form.search_field :route_path_cont, placeholder: "Filter by route", autocomplete: "off", class: "input", style: "max-inline-size: 250px" %>
20
+ <%= form.select :period_start_range,
21
+ RailsPulse::RoutesController::TIME_RANGE_OPTIONS,
22
+ { selected: @selected_time_range },
23
+ { class: "input" }
24
+ %>
25
+ <%= form.select :avg_duration,
26
+ duration_options(:route),
27
+ { selected: @selected_response_range },
28
+ { class: "input" }
29
+ %>
30
+ <%= link_to "Reset", routes_path, class: "btn btn--borderless show@md" if params.has_key?(:q) %>
31
+ <%= form.submit "Search", class: "btn show@sm" %>
32
+ </div>
33
+ <% end %>
34
+
35
+ <% if @has_data %>
36
+ <% if @chart_data && @chart_data.values.any? { |v| v > 0 } %>
37
+ <div
38
+ class="chart-container chart-container--slim"
39
+ data-rails-pulse--index-target="chart"
40
+ >
41
+ <%= bar_chart(
42
+ @chart_data,
43
+ code: false,
44
+ id: "average_response_times_chart",
45
+ height: "100%",
46
+ options: bar_chart_options(
47
+ units: "ms",
48
+ zoom: true,
49
+ chart_start: 0,
50
+ chart_end: @chart_data.length - 1,
51
+ xaxis_formatter: @xaxis_formatter,
52
+ tooltip_formatter: @tooltip_formatter,
53
+ zoom_start: @zoom_start,
54
+ zoom_end: @zoom_end,
55
+ chart_data: @chart_data
56
+ )
57
+ ) %>
34
58
  </div>
35
59
  <% end %>
36
60
 
37
- <% if @has_data %>
38
- <% if @chart_data && @chart_data.values.any? { |v| v > 0 } %>
39
- <div
40
- class="chart-container chart-container--slim"
41
- data-rails-pulse--index-target="chart"
42
- >
43
- <%= bar_chart(
44
- @chart_data,
45
- code: false,
46
- id: "average_response_times_chart",
47
- height: "100%",
48
- options: bar_chart_options(
49
- units: "ms",
50
- zoom: true,
51
- chart_start: 0,
52
- chart_end: @chart_data.length - 1,
53
- xaxis_formatter: @xaxis_formatter,
54
- tooltip_formatter: @tooltip_formatter,
55
- zoom_start: @zoom_start,
56
- zoom_end: @zoom_end,
57
- chart_data: @chart_data
58
- )
59
- ) %>
60
- </div>
61
- <% end %>
62
-
63
- <%= turbo_frame_tag :routes_index_table, data: { rails_pulse__index_target: "indexTable" } do %>
64
- <%= render 'rails_pulse/routes/table' %>
65
- <% end %>
66
- <% else %>
67
- <%= render 'rails_pulse/components/empty_state',
68
- title: 'No route data found for the selected filters.',
69
- description: 'Try adjusting your time range or filters to see results.' %>
61
+ <%= turbo_frame_tag :index_table, data: { rails_pulse__index_target: "indexTable" } do %>
62
+ <%= render 'rails_pulse/routes/table' %>
70
63
  <% end %>
64
+ <% else %>
65
+ <%= render 'rails_pulse/components/empty_state',
66
+ title: 'No route data found for the selected filters.',
67
+ description: 'Try adjusting your time range or filters to see results.' %>
71
68
  <% end %>
72
- </div>
69
+ <% end %>
73
70
  </div>
@@ -1,7 +1,5 @@
1
1
  <%= render 'rails_pulse/components/breadcrumbs' %>
2
2
 
3
- <h1 class="text-2xl mbe-2"><%= @route.path_and_method %></h1>
4
-
5
3
  <% unless turbo_frame_request? %>
6
4
  <div class="row">
7
5
  <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @average_query_times_metric_card } %>
@@ -14,7 +12,7 @@
14
12
  <div
15
13
  class="row"
16
14
  data-controller="rails-pulse--index"
17
- data-rails-pulse--index-chart-id-value="route_repsonses_chart"
15
+ data-rails-pulse--index-chart-id-value="route_responses_chart"
18
16
  >
19
17
  <div class="grid-item">
20
18
  <%= render 'rails_pulse/components/panel', { title: 'Route Reqeusts', } do %>
@@ -44,7 +42,7 @@
44
42
  <%= bar_chart(
45
43
  @chart_data,
46
44
  code: false,
47
- id: "route_repsonses_chart",
45
+ id: "route_responses_chart",
48
46
  height: "100%",
49
47
  options: bar_chart_options(
50
48
  units: "ms",
@@ -61,12 +59,12 @@
61
59
  </div>
62
60
  <% end %>
63
61
 
64
- <%= turbo_frame_tag :index_table do %>
65
- <%= render 'rails_pulse/requests/table' %>
62
+ <%= turbo_frame_tag :index_table, data: { rails_pulse__index_target: "indexTable" } do %>
63
+ <%= render 'rails_pulse/routes/requests_table' %>
66
64
  <% end %>
67
65
  <% else %>
68
- <%= render 'rails_pulse/components/empty_state',
69
- title: 'No route requests found for the selected filters.',
66
+ <%= render 'rails_pulse/components/empty_state',
67
+ title: 'No route requests found for the selected filters.',
70
68
  description: 'Try adjusting your time range or filters to see results.' %>
71
69
  <% end %>
72
70
  <% end %>
@@ -1,62 +1,54 @@
1
- # Monkey patch for RailsCharts CSP compliance
2
- # This adds nonce attributes to script tags generated by the RailsCharts gem
1
+ # CSP patch for RailsCharts gem
2
+ # Adds nonce attributes to script tags generated by RailsCharts for CSP compliance
3
3
 
4
4
  if defined?(RailsCharts)
5
5
  module RailsCharts
6
6
  module CspPatch
7
7
  def line_chart(data_source, options = {})
8
- # Get the original chart HTML
9
8
  chart_html = super(data_source, options)
9
+ add_csp_nonce_to_chart(chart_html)
10
+ end
10
11
 
11
- # Try to get CSP nonce from various sources
12
- nonce = get_csp_nonce
13
-
14
- if nonce.present? && chart_html.present?
15
- # Add nonce to all script tags in the chart HTML
16
- chart_html = add_nonce_to_scripts(chart_html.to_s, nonce)
17
- # Ensure the HTML is marked as safe for Rails to render
18
- chart_html = chart_html.html_safe if chart_html.respond_to?(:html_safe)
19
- end
20
-
21
- chart_html
12
+ def bar_chart(data_source, options = {})
13
+ chart_html = super(data_source, options)
14
+ add_csp_nonce_to_chart(chart_html)
22
15
  end
23
16
 
24
17
  private
25
18
 
26
- def get_csp_nonce
27
- # Try various methods to get the CSP nonce
28
- nonce = nil
19
+ def add_csp_nonce_to_chart(chart_html)
20
+ return chart_html unless chart_html.present?
29
21
 
30
- # Method 1: Check for Rails 6+ CSP nonce helper
31
- if respond_to?(:content_security_policy_nonce)
32
- nonce = content_security_policy_nonce
33
- end
34
-
35
- # Method 2: Check for custom csp_nonce helper
36
- if nonce.blank? && respond_to?(:csp_nonce)
37
- nonce = csp_nonce
38
- end
22
+ nonce = get_csp_nonce
23
+ return chart_html unless nonce.present?
39
24
 
40
- # Method 3: Check request environment
41
- if nonce.blank? && defined?(request) && request
42
- nonce = request.env["action_dispatch.content_security_policy_nonce"] ||
43
- request.env["secure_headers.content_security_policy_nonce"] ||
44
- request.env["csp_nonce"]
45
- end
25
+ # Add nonce to script tags and mark as safe
26
+ modified_html = add_nonce_to_scripts(chart_html.to_s, nonce)
27
+ modified_html.html_safe if modified_html.respond_to?(:html_safe)
28
+ end
46
29
 
47
- # Method 4: Check thread/request store
48
- if nonce.blank?
49
- nonce = Thread.current[:rails_pulse_csp_nonce] ||
50
- (defined?(RequestStore) && RequestStore.store[:rails_pulse_csp_nonce])
30
+ def get_csp_nonce
31
+ # Try common CSP nonce sources in order of preference
32
+ if respond_to?(:content_security_policy_nonce)
33
+ content_security_policy_nonce
34
+ elsif respond_to?(:csp_nonce)
35
+ csp_nonce
36
+ elsif defined?(request) && request
37
+ request.env["action_dispatch.content_security_policy_nonce"] ||
38
+ request.env["secure_headers.content_security_policy_nonce"] ||
39
+ request.env["csp_nonce"]
40
+ elsif respond_to?(:controller) && controller.respond_to?(:content_security_policy_nonce)
41
+ controller.content_security_policy_nonce
42
+ elsif defined?(@view_context) && @view_context.respond_to?(:content_security_policy_nonce)
43
+ @view_context.content_security_policy_nonce
44
+ else
45
+ Thread.current[:rails_pulse_csp_nonce] ||
46
+ (defined?(RequestStore) && RequestStore.store[:rails_pulse_csp_nonce])
51
47
  end
52
-
53
- nonce.presence
54
48
  end
55
49
 
56
50
  def add_nonce_to_scripts(html, nonce)
57
- # Use regex to add nonce to script tags that don't already have one
58
51
  html.gsub(/<script(?![^>]*\snonce=)([^>]*)>/i) do |match|
59
- # Insert nonce attribute before the closing >
60
52
  attributes = $1
61
53
  if attributes.strip.empty?
62
54
  "<script nonce=\"#{nonce}\">"
data/config/routes.rb CHANGED
@@ -3,7 +3,11 @@ RailsPulse::Engine.routes.draw do
3
3
 
4
4
  resources :routes, only: %i[index show]
5
5
  resources :requests, only: %i[index show]
6
- resources :queries, only: %i[index show]
6
+ resources :queries, only: %i[index show] do
7
+ member do
8
+ post :analyze
9
+ end
10
+ end
7
11
  resources :operations, only: %i[show]
8
12
  resources :caches, only: %i[show], as: :cache
9
13
  patch "pagination/limit", to: "application#set_pagination_limit"
@@ -0,0 +1,23 @@
1
+ # Generated from Rails Pulse schema - automatically loads current schema definition
2
+ class InstallRailsPulseTables < ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}".to_f]
3
+ def change
4
+ # Load and execute the Rails Pulse schema directly
5
+ # This ensures the migration is always in sync with the schema file
6
+ schema_file = File.join(File.dirname(__FILE__), "..", "rails_pulse_schema.rb")
7
+
8
+ if File.exist?(schema_file)
9
+ say "Loading Rails Pulse schema from db/rails_pulse_schema.rb"
10
+
11
+ # Load the schema file to define RailsPulse::Schema
12
+ load schema_file
13
+
14
+ # Execute the schema in the context of this migration
15
+ RailsPulse::Schema.call(connection)
16
+
17
+ say "Rails Pulse tables created successfully"
18
+ say "The schema file db/rails_pulse_schema.rb remains as your single source of truth"
19
+ else
20
+ raise "Rails Pulse schema file not found at db/rails_pulse_schema.rb"
21
+ end
22
+ end
23
+ end
@@ -4,7 +4,7 @@
4
4
 
5
5
  RailsPulse::Schema = lambda do |connection|
6
6
  # Skip if all tables already exist to prevent conflicts
7
- required_tables = [:rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_summaries]
7
+ required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_summaries ]
8
8
 
9
9
  if ENV["CI"] == "true"
10
10
  existing_tables = required_tables.select { |table| connection.table_exists?(table) }
@@ -25,6 +25,15 @@ RailsPulse::Schema = lambda do |connection|
25
25
 
26
26
  connection.create_table :rails_pulse_queries do |t|
27
27
  t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)"
28
+ t.datetime :analyzed_at, comment: "When query analysis was last performed"
29
+ t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution"
30
+ t.text :issues, comment: "JSON array of detected performance issues"
31
+ t.text :metadata, comment: "JSON object containing query complexity metrics"
32
+ t.text :query_stats, comment: "JSON object with query characteristics analysis"
33
+ t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection"
34
+ t.text :index_recommendations, comment: "JSON array of database index recommendations"
35
+ t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results"
36
+ t.text :suggestions, comment: "JSON array of optimization recommendations"
28
37
  t.timestamps
29
38
  end
30
39
 
@@ -0,0 +1,81 @@
1
+ module RailsPulse
2
+ module Generators
3
+ class ConvertToMigrationsGenerator < Rails::Generators::Base
4
+ include Rails::Generators::Migration
5
+ source_root File.expand_path("templates", __dir__)
6
+
7
+ desc "Convert Rails Pulse schema file to migrations for single database setup"
8
+
9
+ def self.next_migration_number(path)
10
+ next_migration_number = current_migration_number(path) + 1
11
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
12
+ end
13
+
14
+ def check_schema_file
15
+ unless File.exist?("db/rails_pulse_schema.rb")
16
+ # Only show message in non-test environments to reduce test noise
17
+ unless Rails.env.test?
18
+ say "No db/rails_pulse_schema.rb file found. Run 'rails generate rails_pulse:install' first.", :red
19
+ exit 1
20
+ else
21
+ return false
22
+ end
23
+ end
24
+
25
+ if rails_pulse_tables_exist?
26
+ unless Rails.env.test?
27
+ say "Rails Pulse tables already exist. No conversion needed.", :yellow
28
+ say "Use 'rails generate rails_pulse:upgrade' to update existing installation.", :blue
29
+ exit 0
30
+ else
31
+ return false
32
+ end
33
+ end
34
+
35
+ true
36
+ end
37
+
38
+ def create_conversion_migration
39
+ # Only create migration if schema file check passes
40
+ return unless check_schema_file
41
+
42
+ say "Converting db/rails_pulse_schema.rb to migration...", :green
43
+
44
+ migration_template(
45
+ "migrations/install_rails_pulse_tables.rb",
46
+ "db/migrate/install_rails_pulse_tables.rb"
47
+ )
48
+ end
49
+
50
+ def display_completion_message
51
+ # Only display completion message if migration was created
52
+ return unless File.exist?("db/rails_pulse_schema.rb")
53
+
54
+ say <<~MESSAGE
55
+
56
+ Conversion complete!
57
+
58
+ Next steps:
59
+ 1. Run: rails db:migrate
60
+ 2. Restart your Rails server
61
+
62
+ The schema file db/rails_pulse_schema.rb remains as your single source of truth.
63
+ Future Rails Pulse updates will come as regular migrations in db/migrate/
64
+
65
+ MESSAGE
66
+ end
67
+
68
+ private
69
+
70
+ def rails_pulse_tables_exist?
71
+ return false unless defined?(ActiveRecord::Base)
72
+
73
+ connection = ActiveRecord::Base.connection
74
+ %w[rails_pulse_routes rails_pulse_queries rails_pulse_requests rails_pulse_operations rails_pulse_summaries]
75
+ .all? { |table| connection.table_exists?(table) }
76
+ rescue
77
+ false
78
+ end
79
+ end
80
+ end
81
+ end
@@ -1,53 +1,110 @@
1
1
  module RailsPulse
2
2
  module Generators
3
3
  class InstallGenerator < Rails::Generators::Base
4
+ include Rails::Generators::Migration
4
5
  source_root File.expand_path("templates", __dir__)
5
6
 
6
- desc "Install Rails Pulse with schema-based setup"
7
+ desc "Install Rails Pulse with flexible database setup options"
8
+
9
+ class_option :database, type: :string, default: "single",
10
+ desc: "Database setup: 'single' (default) or 'separate'"
11
+
12
+ def self.next_migration_number(path)
13
+ next_migration_number = current_migration_number(path) + 1
14
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
15
+ end
7
16
 
8
17
  def copy_schema
9
18
  copy_file "db/rails_pulse_schema.rb", "db/rails_pulse_schema.rb"
10
19
  end
11
20
 
21
+ def create_migration_directory
22
+ create_file "db/rails_pulse_migrate/.keep"
23
+ end
24
+
12
25
  def copy_initializer
13
26
  copy_file "rails_pulse.rb", "config/initializers/rails_pulse.rb"
14
27
  end
15
28
 
16
- def create_database_migration_paths
29
+ def setup_database_configuration
17
30
  if separate_database?
18
- create_file "db/rails_pulse_migrate/.keep"
31
+ create_separate_database_setup
32
+ else
33
+ create_single_database_setup
19
34
  end
20
35
  end
21
36
 
22
37
  def display_post_install_message
38
+ if separate_database?
39
+ display_separate_database_message
40
+ else
41
+ display_single_database_message
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def separate_database?
48
+ options[:database] == "separate"
49
+ end
50
+
51
+ def create_separate_database_setup
52
+ say "Setting up separate database configuration...", :green
53
+
54
+ # Migration directory already created by create_migration_directory
55
+ # Could add database.yml configuration here if needed
56
+ # For now, users will configure manually
57
+ end
58
+
59
+ def create_single_database_setup
60
+ say "Setting up single database configuration...", :green
61
+
62
+ # Create a migration that loads the schema
63
+ migration_template(
64
+ "migrations/install_rails_pulse_tables.rb",
65
+ "db/migrate/install_rails_pulse_tables.rb"
66
+ )
67
+ end
68
+
69
+ def display_separate_database_message
23
70
  say <<~MESSAGE
24
71
 
25
- Rails Pulse installation complete!
72
+ Rails Pulse installation complete! (Separate Database Setup)
26
73
 
27
74
  Next steps:
28
- 1. Configure your database in config/database.yml (see README for examples)
75
+ 1. Add Rails Pulse database configuration to config/database.yml:
76
+
77
+ #{Rails.env}:
78
+ rails_pulse:
79
+ <<: *default
80
+ database: storage/#{Rails.env}_rails_pulse.sqlite3
81
+ migrations_paths: db/rails_pulse_migrate
82
+
29
83
  2. Run: rails db:prepare (creates database and loads schema)
30
84
  3. Restart your Rails server
31
85
 
32
- For separate database setup, add to config/database.yml:
33
- #{environment}:
34
- rails_pulse:
35
- <<: *default
36
- database: storage/#{environment}_rails_pulse.sqlite3
37
- migrations_paths: db/rails_pulse_migrate
86
+ The schema file db/rails_pulse_schema.rb is your single source of truth.
87
+ Future schema changes will come as regular migrations in db/rails_pulse_migrate/
38
88
 
39
89
  MESSAGE
40
90
  end
41
91
 
42
- private
92
+ def display_single_database_message
93
+ say <<~MESSAGE
43
94
 
44
- def separate_database?
45
- # Could make this configurable via options
46
- false
47
- end
95
+ Rails Pulse installation complete! (Single Database Setup)
96
+
97
+ Next steps:
98
+ 1. Run: rails db:migrate (creates Rails Pulse tables in your main database)
99
+ 2. Restart your Rails server
48
100
 
49
- def environment
50
- Rails.env.production? ? "production" : "development"
101
+ The schema file db/rails_pulse_schema.rb is your single source of truth.
102
+ Future schema changes will come as regular migrations in db/migrate/
103
+
104
+ Note: The installation migration loads from db/rails_pulse_schema.rb
105
+ and includes all current Rails Pulse tables and columns.
106
+
107
+ MESSAGE
51
108
  end
52
109
  end
53
110
  end
@@ -3,8 +3,17 @@
3
3
  # Load with: rails db:schema:load:rails_pulse or db:prepare
4
4
 
5
5
  RailsPulse::Schema = lambda do |connection|
6
- # Skip if tables already exist to prevent conflicts
7
- return if connection.table_exists?(:rails_pulse_routes)
6
+ # Skip if all tables already exist to prevent conflicts
7
+ required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_summaries ]
8
+
9
+ if ENV["CI"] == "true"
10
+ existing_tables = required_tables.select { |table| connection.table_exists?(table) }
11
+ missing_tables = required_tables - existing_tables
12
+ puts "[RailsPulse::Schema] Existing tables: #{existing_tables.join(', ')}" if existing_tables.any?
13
+ puts "[RailsPulse::Schema] Missing tables: #{missing_tables.join(', ')}" if missing_tables.any?
14
+ end
15
+
16
+ return if required_tables.all? { |table| connection.table_exists?(table) }
8
17
 
9
18
  connection.create_table :rails_pulse_routes do |t|
10
19
  t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)"
@@ -16,6 +25,15 @@ RailsPulse::Schema = lambda do |connection|
16
25
 
17
26
  connection.create_table :rails_pulse_queries do |t|
18
27
  t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)"
28
+ t.datetime :analyzed_at, comment: "When query analysis was last performed"
29
+ t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution"
30
+ t.text :issues, comment: "JSON array of detected performance issues"
31
+ t.text :metadata, comment: "JSON object containing query complexity metrics"
32
+ t.text :query_stats, comment: "JSON object with query characteristics analysis"
33
+ t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection"
34
+ t.text :index_recommendations, comment: "JSON array of database index recommendations"
35
+ t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results"
36
+ t.text :suggestions, comment: "JSON array of optimization recommendations"
19
37
  t.timestamps
20
38
  end
21
39
 
@@ -53,6 +71,58 @@ RailsPulse::Schema = lambda do |connection|
53
71
  connection.add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time"
54
72
  connection.add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance"
55
73
  connection.add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type"
74
+
75
+ connection.create_table :rails_pulse_summaries do |t|
76
+ # Time fields
77
+ t.datetime :period_start, null: false, comment: "Start of the aggregation period"
78
+ t.datetime :period_end, null: false, comment: "End of the aggregation period"
79
+ t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month"
80
+
81
+ # Polymorphic association to handle both routes and queries
82
+ t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query"
83
+ # This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query')
84
+ # and summarizable_id (route_id or query_id)
85
+
86
+ # Universal metrics
87
+ t.integer :count, default: 0, null: false, comment: "Total number of requests/operations"
88
+ t.float :avg_duration, comment: "Average duration in milliseconds"
89
+ t.float :min_duration, comment: "Minimum duration in milliseconds"
90
+ t.float :max_duration, comment: "Maximum duration in milliseconds"
91
+ t.float :p50_duration, comment: "50th percentile duration"
92
+ t.float :p95_duration, comment: "95th percentile duration"
93
+ t.float :p99_duration, comment: "99th percentile duration"
94
+ t.float :total_duration, comment: "Total duration in milliseconds"
95
+ t.float :stddev_duration, comment: "Standard deviation of duration"
96
+
97
+ # Request/Route specific metrics
98
+ t.integer :error_count, default: 0, comment: "Number of error responses (5xx)"
99
+ t.integer :success_count, default: 0, comment: "Number of successful responses"
100
+ t.integer :status_2xx, default: 0, comment: "Number of 2xx responses"
101
+ t.integer :status_3xx, default: 0, comment: "Number of 3xx responses"
102
+ t.integer :status_4xx, default: 0, comment: "Number of 4xx responses"
103
+ t.integer :status_5xx, default: 0, comment: "Number of 5xx responses"
104
+
105
+ t.timestamps
106
+ end
107
+
108
+ # Unique constraint and indexes for summaries
109
+ connection.add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ],
110
+ unique: true,
111
+ name: "idx_pulse_summaries_unique"
112
+ connection.add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period"
113
+ connection.add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at"
114
+
115
+ # Add indexes to existing tables for efficient aggregation
116
+ connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation"
117
+ connection.add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at"
118
+
119
+ connection.add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation"
120
+ connection.add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at"
121
+
122
+ if ENV["CI"] == "true"
123
+ created_tables = required_tables.select { |table| connection.table_exists?(table) }
124
+ puts "[RailsPulse::Schema] Successfully created tables: #{created_tables.join(', ')}"
125
+ end
56
126
  end
57
127
 
58
128
  if defined?(RailsPulse::ApplicationRecord)
@@ -0,0 +1,23 @@
1
+ # Generated from Rails Pulse schema - automatically loads current schema definition
2
+ class InstallRailsPulseTables < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
3
+ def change
4
+ # Load and execute the Rails Pulse schema directly
5
+ # This ensures the migration is always in sync with the schema file
6
+ schema_file = File.join(::Rails.root.to_s, "db/rails_pulse_schema.rb")
7
+
8
+ if File.exist?(schema_file)
9
+ say "Loading Rails Pulse schema from db/rails_pulse_schema.rb"
10
+
11
+ # Load the schema file to define RailsPulse::Schema
12
+ load schema_file
13
+
14
+ # Execute the schema in the context of this migration
15
+ RailsPulse::Schema.call(connection)
16
+
17
+ say "Rails Pulse tables created successfully"
18
+ say "The schema file db/rails_pulse_schema.rb remains as your single source of truth"
19
+ else
20
+ raise "Rails Pulse schema file not found at db/rails_pulse_schema.rb"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ # Upgrade Rails Pulse tables with new features
2
+ class UpgradeRailsPulseTables < ActiveRecord::Migration[<%= @migration_version %>]
3
+ def change
4
+ <% @missing_columns.each do |table_name, columns| %>
5
+ <% columns.each do |column_name, definition| %>
6
+ # Add <%= column_name %> column to <%= table_name %>
7
+ add_column :<%= table_name %>, :<%= column_name %>, :<%= definition[:type] %><% if definition[:comment] %>, comment: "<%= definition[:comment] %>"<% end %>
8
+ <% end %>
9
+ <% end %>
10
+ end
11
+
12
+ def down
13
+ <% @missing_columns.each do |table_name, columns| %>
14
+ <% columns.each do |column_name, _definition| %>
15
+ remove_column :<%= table_name %>, :<%= column_name %>
16
+ <% end %>
17
+ <% end %>
18
+ end
19
+ end