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.
- checksums.yaml +4 -4
- data/README.md +66 -20
- data/Rakefile +169 -86
- data/app/assets/images/rails_pulse/dashboard.png +0 -0
- data/app/assets/images/rails_pulse/request.png +0 -0
- data/app/assets/stylesheets/rails_pulse/application.css +28 -5
- data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
- data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
- data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
- data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
- data/app/controllers/concerns/zoom_range_concern.rb +31 -0
- data/app/controllers/rails_pulse/application_controller.rb +5 -1
- data/app/controllers/rails_pulse/queries_controller.rb +49 -10
- data/app/controllers/rails_pulse/requests_controller.rb +46 -20
- data/app/controllers/rails_pulse/routes_controller.rb +40 -1
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +1 -1
- data/app/helpers/rails_pulse/chart_helper.rb +16 -8
- data/app/helpers/rails_pulse/formatting_helper.rb +21 -2
- data/app/javascript/rails_pulse/application.js +34 -3
- data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
- data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
- data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +249 -11
- data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
- data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
- data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +1 -1
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +20 -20
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +58 -14
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +14 -9
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +3 -7
- data/app/models/rails_pulse/query.rb +46 -0
- data/app/models/rails_pulse/request.rb +1 -1
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +2 -2
- data/app/models/rails_pulse/requests/tables/index.rb +77 -0
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +18 -20
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +14 -9
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +14 -9
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +29 -13
- data/app/models/rails_pulse/routes/tables/index.rb +4 -2
- data/app/models/rails_pulse/summary.rb +7 -7
- data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
- data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
- data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
- data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
- data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
- data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +154 -0
- data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
- data/app/services/rails_pulse/query_analysis_service.rb +125 -0
- data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
- data/app/views/layouts/rails_pulse/application.html.erb +0 -2
- data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
- data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
- data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
- data/app/views/rails_pulse/components/_metric_card.html.erb +28 -5
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
- data/app/views/rails_pulse/components/_panel.html.erb +1 -1
- data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
- data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/index.html.erb +2 -2
- data/app/views/rails_pulse/operations/show.html.erb +17 -15
- data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
- data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
- data/app/views/rails_pulse/queries/_analysis_results.html.erb +117 -0
- data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
- data/app/views/rails_pulse/queries/_show_table.html.erb +34 -6
- data/app/views/rails_pulse/queries/_table.html.erb +4 -8
- data/app/views/rails_pulse/queries/index.html.erb +48 -51
- data/app/views/rails_pulse/queries/show.html.erb +56 -52
- data/app/views/rails_pulse/requests/_operations.html.erb +30 -43
- data/app/views/rails_pulse/requests/_table.html.erb +31 -18
- data/app/views/rails_pulse/requests/index.html.erb +55 -50
- data/app/views/rails_pulse/requests/show.html.erb +0 -2
- data/app/views/rails_pulse/routes/_requests_table.html.erb +39 -0
- data/app/views/rails_pulse/routes/_table.html.erb +4 -10
- data/app/views/rails_pulse/routes/index.html.erb +49 -52
- data/app/views/rails_pulse/routes/show.html.erb +6 -8
- data/config/initializers/rails_charts_csp_patch.rb +32 -40
- data/config/routes.rb +5 -1
- data/db/migrate/20250930105043_install_rails_pulse_tables.rb +23 -0
- data/db/rails_pulse_schema.rb +10 -1
- data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +81 -0
- data/lib/generators/rails_pulse/install_generator.rb +75 -18
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +72 -2
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +23 -0
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
- data/lib/generators/rails_pulse/upgrade_generator.rb +226 -0
- data/lib/rails_pulse/engine.rb +21 -0
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/tasks/rails_pulse.rake +27 -8
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +53 -53
- data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
- metadata +25 -6
- data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
- data/app/assets/images/rails_pulse/routes.png +0 -0
- data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
- 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
|
-
|
18
|
-
<%=
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
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
|
-
|
38
|
-
|
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
|
-
|
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="
|
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: "
|
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/
|
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
|
-
#
|
2
|
-
#
|
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
|
-
|
12
|
-
|
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
|
27
|
-
|
28
|
-
nonce = nil
|
19
|
+
def add_csp_nonce_to_chart(chart_html)
|
20
|
+
return chart_html unless chart_html.present?
|
29
21
|
|
30
|
-
|
31
|
-
|
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
|
-
#
|
41
|
-
|
42
|
-
|
43
|
-
|
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
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
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
|
data/db/rails_pulse_schema.rb
CHANGED
@@ -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
|
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
|
29
|
+
def setup_database_configuration
|
17
30
|
if separate_database?
|
18
|
-
|
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.
|
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
|
-
|
33
|
-
|
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
|
-
|
92
|
+
def display_single_database_message
|
93
|
+
say <<~MESSAGE
|
43
94
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
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
|
-
|
50
|
-
|
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
|
-
|
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
|