diff --git a/README.md b/README.md index 81c03b2..7c45af7 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ - [Installation](#installation) - [Quick Setup](#quick-setup) - [Basic Configuration](#basic-configuration) +- [Upgrading](#upgrading) + - [Upgrading from Schema-based Installations](#upgrading-from-schema-based-installations) + - [Migration-based Upgrades](#migration-based-upgrades) - [Authentication](#authentication) - [Authentication Setup](#authentication-setup) - [Authentication Examples](#authentication-examples) @@ -206,6 +209,93 @@ RailsPulse.configure do |config| end ``` +## Upgrading + +Rails Pulse uses a **migration-based upgrade system** starting from version 0.2.5+ to ensure smooth schema updates without manual intervention. + +### Migration-based Upgrades (Recommended) + +**For new installations (v0.2.5+)**, the installation process uses standard Rails migrations by default: + +```bash +# Update your Gemfile +bundle update rails_pulse + +# Generate and run any new migrations +rails generate rails_pulse:upgrade +rails db:migrate + +# Restart your server +``` + +The `rails_pulse:upgrade` generator intelligently detects missing schema changes and generates only the necessary migrations. + +### Upgrading from Schema-based Installations + +**If you installed Rails Pulse before v0.2.5** (using the schema file approach), you may encounter issues when upgrading because the schema file approach couldn't add new columns to existing tables. + +#### Common Upgrade Issue + +When upgrading from v0.2.3 or earlier to v0.2.4+, you may see this error: + +``` +NameError (undefined local variable or method 'tags' for an instance of RailsPulse::Request) +``` + +This occurs because v0.2.4 added the tagging feature but couldn't automatically add the `tags` columns to existing installations. + +#### Solution + +Run the upgrade generator to add missing columns: + +```bash +# This will detect and generate migrations for any missing columns/tables +rails generate rails_pulse:upgrade +rails db:migrate + +# Restart your server +``` + +The upgrade generator checks your current schema and creates migrations only for what's missing: +- **Tags columns** (added in v0.2.4) - for routes, queries, and requests tables +- **Query analysis columns** (added in v0.2.x) - for enhanced query performance insights +- **Summaries table** (added in v0.2.x) - for aggregated performance metrics + +#### Manual Upgrade (Alternative) + +If you prefer to see exactly what's being added, you can manually create a migration: + +```ruby +# db/migrate/TIMESTAMP_add_missing_rails_pulse_columns.rb +class AddMissingRailsPulseColumns < ActiveRecord::Migration[7.1] + def change + # Add tags columns if missing + add_column :rails_pulse_routes, :tags, :text unless column_exists?(:rails_pulse_routes, :tags) + add_column :rails_pulse_queries, :tags, :text unless column_exists?(:rails_pulse_queries, :tags) + add_column :rails_pulse_requests, :tags, :text unless column_exists?(:rails_pulse_requests, :tags) + end +end +``` + +Then run: `rails db:migrate` + +### Best Practices + +1. **Always run the upgrade generator** after updating Rails Pulse +2. **Review generated migrations** before running `db:migrate` in production +3. **Test in development/staging** before deploying to production +4. **Check the CHANGELOG** for breaking changes or new features + +### Future Upgrades + +Starting from v0.2.5+, all schema changes will include proper migrations, making upgrades seamless: + +```bash +bundle update rails_pulse +rails generate rails_pulse:upgrade # Only if new schema changes exist +rails db:migrate +``` + ## Authentication Rails Pulse supports flexible authentication to secure access to your monitoring dashboard. diff --git a/lib/generators/rails_pulse/install_generator.rb b/lib/generators/rails_pulse/install_generator.rb index c862756..b8bb28c 100644 --- a/lib/generators/rails_pulse/install_generator.rb +++ b/lib/generators/rails_pulse/install_generator.rb @@ -4,40 +4,21 @@ class InstallGenerator < Rails::Generators::Base include Rails::Generators::Migration source_root File.expand_path("templates", __dir__) - desc "Install Rails Pulse with flexible database setup options" + desc "Install Rails Pulse (recommended: use migrations for easier upgrades)" - class_option :database, type: :string, default: "single", - desc: "Database setup: 'single' (default) or 'separate'" + class_option :use_schema, type: :boolean, default: false, + desc: "Use schema file instead of migrations (legacy)" - def self.next_migration_number(path) - next_migration_number = current_migration_number(path) + 1 + def self.next_migration_number(dirname) + next_migration_number = current_migration_number(dirname) + 1 ActiveRecord::Migration.next_migration_number(next_migration_number) end - def copy_schema - copy_file "db/rails_pulse_schema.rb", "db/rails_pulse_schema.rb" - end - - def create_migration_directory - create_file "db/rails_pulse_migrate/.keep" - end - - def copy_gem_migrations - gem_migrations_path = File.expand_path("../../../db/rails_pulse_migrate", __dir__) - destination_dir = separate_database? ? "db/rails_pulse_migrate" : "db/migrate" - - if File.directory?(gem_migrations_path) - Dir.glob("#{gem_migrations_path}/*.rb").each do |migration_file| - migration_name = File.basename(migration_file) - destination_path = File.join(destination_dir, migration_name) - - # Only copy if it doesn't already exist in the destination - # Use File.join with destination_root to check the actual location - full_destination_path = File.join(destination_root, destination_path) - unless File.exist?(full_destination_path) - copy_file migration_file, destination_path - end - end + def install_database_tables + if use_schema? + install_via_schema + else + install_via_migrations end end @@ -45,83 +26,58 @@ def copy_initializer copy_file "rails_pulse.rb", "config/initializers/rails_pulse.rb" end - def setup_database_configuration - if separate_database? - create_separate_database_setup - else - create_single_database_setup - end - end - def display_post_install_message - if separate_database? - display_separate_database_message + if use_schema? + display_schema_message else - display_single_database_message + display_migrations_message end end private - def separate_database? - options[:database] == "separate" + def use_schema? + options[:use_schema] end - def create_separate_database_setup - say "Setting up separate database configuration...", :green - - # Migration directory already created by create_migration_directory - # Could add database.yml configuration here if needed - # For now, users will configure manually + def install_via_schema + say "Installing Rails Pulse using schema file (legacy)...", :yellow + copy_file "db/rails_pulse_schema.rb", "db/rails_pulse_schema.rb" end - def create_single_database_setup - say "Setting up single database configuration...", :green - - # Create a migration that loads the schema - migration_template( - "migrations/install_rails_pulse_tables.rb", - "db/migrate/install_rails_pulse_tables.rb" - ) + def install_via_migrations + say "Installing Rails Pulse using migrations (recommended)...", :green + migration_template "migrations/create_rails_pulse_tables.rb", + "db/migrate/create_rails_pulse_tables.rb" end - def display_separate_database_message + def display_schema_message say <<~MESSAGE - Rails Pulse installation complete! (Separate Database Setup) + Rails Pulse installation complete! (Schema-based setup - Legacy) Next steps: - 1. Add Rails Pulse database configuration to config/database.yml: - - #{Rails.env}: - rails_pulse: - <<: *default - database: storage/#{Rails.env}_rails_pulse.sqlite3 - migrations_paths: db/rails_pulse_migrate - - 2. Run: rails db:prepare (creates database and loads schema) - 3. Restart your Rails server + 1. Run: rails db:prepare (creates database and loads schema) + 2. Restart your Rails server - The schema file db/rails_pulse_schema.rb is your single source of truth. - Future upgrades will automatically copy new migrations to db/rails_pulse_migrate/ + Note: Future upgrades may require manual schema synchronization. + Consider using migrations-based installation for easier upgrades: + rails generate rails_pulse:install MESSAGE end - def display_single_database_message + def display_migrations_message say <<~MESSAGE - Rails Pulse installation complete! (Single Database Setup) + Rails Pulse installation complete! Next steps: - 1. Run: rails db:migrate (creates Rails Pulse tables in your main database) + 1. Run: rails db:migrate 2. Restart your Rails server - The schema file db/rails_pulse_schema.rb is your single source of truth. - Future upgrades will automatically copy new migrations to db/migrate/ - - Note: The installation migration loads from db/rails_pulse_schema.rb - and includes all current Rails Pulse tables and columns. + For upgrades, run: rails generate rails_pulse:upgrade + This will detect and generate any necessary schema updates. MESSAGE end diff --git a/lib/generators/rails_pulse/migrations/create_rails_pulse_tables.rb b/lib/generators/rails_pulse/migrations/create_rails_pulse_tables.rb new file mode 100644 index 0000000..06229c7 --- /dev/null +++ b/lib/generators/rails_pulse/migrations/create_rails_pulse_tables.rb @@ -0,0 +1,112 @@ +class CreateRailsPulseTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] + def change + # Routes table + create_table :rails_pulse_routes do |t| + t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)" + t.string :path, null: false, comment: "Request path (e.g., /posts/index)" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path" + + # Queries table + create_table :rails_pulse_queries do |t| + t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)" + t.datetime :analyzed_at, comment: "When query analysis was last performed" + t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution" + t.text :issues, comment: "JSON array of detected performance issues" + t.text :metadata, comment: "JSON object containing query complexity metrics" + t.text :query_stats, comment: "JSON object with query characteristics analysis" + t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection" + t.text :index_recommendations, comment: "JSON array of database index recommendations" + t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results" + t.text :suggestions, comment: "JSON array of optimization recommendations" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + add_index :rails_pulse_queries, :normalized_sql, unique: true, name: "index_rails_pulse_queries_on_normalized_sql", length: 191 + + # Requests table + create_table :rails_pulse_requests do |t| + t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route" + t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds" + t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)" + t.boolean :is_error, null: false, default: false, comment: "True if status >= 500" + t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)" + t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)" + t.timestamp :occurred_at, null: false, comment: "When the request started" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + add_index :rails_pulse_requests, :occurred_at, name: "index_rails_pulse_requests_on_occurred_at" + add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid" + add_index :rails_pulse_requests, [ :route_id, :occurred_at ], name: "index_rails_pulse_requests_on_route_id_and_occurred_at" + + # Operations table + create_table :rails_pulse_operations do |t| + t.references :request, null: false, foreign_key: { to_table: :rails_pulse_requests }, comment: "Link to the request" + t.references :query, foreign_key: { to_table: :rails_pulse_queries }, index: true, comment: "Link to the normalized SQL query" + t.string :operation_type, null: false, comment: "Type of operation (e.g., database, view, gem_call)" + t.string :label, null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)" + t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds" + t.string :codebase_location, comment: "File and line number (e.g., app/models/user.rb:25)" + t.float :start_time, null: false, default: 0.0, comment: "Operation start time in milliseconds" + t.timestamp :occurred_at, null: false, comment: "When the request started" + t.timestamps + end + + add_index :rails_pulse_operations, :operation_type, name: "index_rails_pulse_operations_on_operation_type" + add_index :rails_pulse_operations, :occurred_at, name: "index_rails_pulse_operations_on_occurred_at" + add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time" + add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance" + add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type" + + # Summaries table + create_table :rails_pulse_summaries do |t| + # Time fields + t.datetime :period_start, null: false, comment: "Start of the aggregation period" + t.datetime :period_end, null: false, comment: "End of the aggregation period" + t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month" + + # Polymorphic association to handle both routes and queries + t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query" + + # Universal metrics + t.integer :count, default: 0, null: false, comment: "Total number of requests/operations" + t.float :avg_duration, comment: "Average duration in milliseconds" + t.float :min_duration, comment: "Minimum duration in milliseconds" + t.float :max_duration, comment: "Maximum duration in milliseconds" + t.float :p50_duration, comment: "50th percentile duration" + t.float :p95_duration, comment: "95th percentile duration" + t.float :p99_duration, comment: "99th percentile duration" + t.float :total_duration, comment: "Total duration in milliseconds" + t.float :stddev_duration, comment: "Standard deviation of duration" + + # Request/Route specific metrics + t.integer :error_count, default: 0, comment: "Number of error responses (5xx)" + t.integer :success_count, default: 0, comment: "Number of successful responses" + t.integer :status_2xx, default: 0, comment: "Number of 2xx responses" + t.integer :status_3xx, default: 0, comment: "Number of 3xx responses" + t.integer :status_4xx, default: 0, comment: "Number of 4xx responses" + t.integer :status_5xx, default: 0, comment: "Number of 5xx responses" + + t.timestamps + end + + # Unique constraint and indexes for summaries + add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ], + unique: true, + name: "idx_pulse_summaries_unique" + add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period" + add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at" + + # Additional aggregation indexes + add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation" + add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at" + add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation" + add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at" + end +end diff --git a/lib/generators/rails_pulse/migrations_generator.rb b/lib/generators/rails_pulse/migrations_generator.rb new file mode 100644 index 0000000..891cdfd --- /dev/null +++ b/lib/generators/rails_pulse/migrations_generator.rb @@ -0,0 +1,45 @@ +module RailsPulse + module Generators + class MigrationsGenerator < Rails::Generators::Base + include Rails::Generators::Migration + source_root File.expand_path("migrations", __dir__) + + desc "Install Rails Pulse migrations" + + def self.next_migration_number(dirname) + next_migration_number = current_migration_number(dirname) + 1 + ActiveRecord::Migration.next_migration_number(next_migration_number) + end + + def copy_migrations + migration_template "create_rails_pulse_tables.rb", + "db/migrate/create_rails_pulse_tables.rb" + end + + def display_post_install_message + say <<~MESSAGE + + Rails Pulse migrations have been installed! + + Next steps: + 1. Run: rails db:migrate + 2. Restart your Rails server + + For separate database setup, add to config/database.yml: + #{environment}: + rails_pulse: + <<: *default + database: storage/#{environment}_rails_pulse.sqlite3 + migrations_paths: db/migrate + + MESSAGE + end + + private + + def environment + Rails.env.production? ? "production" : "development" + end + end + end +end diff --git a/lib/generators/rails_pulse/templates/migrations/create_rails_pulse_tables.rb b/lib/generators/rails_pulse/templates/migrations/create_rails_pulse_tables.rb new file mode 100644 index 0000000..06229c7 --- /dev/null +++ b/lib/generators/rails_pulse/templates/migrations/create_rails_pulse_tables.rb @@ -0,0 +1,112 @@ +class CreateRailsPulseTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] + def change + # Routes table + create_table :rails_pulse_routes do |t| + t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)" + t.string :path, null: false, comment: "Request path (e.g., /posts/index)" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path" + + # Queries table + create_table :rails_pulse_queries do |t| + t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)" + t.datetime :analyzed_at, comment: "When query analysis was last performed" + t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution" + t.text :issues, comment: "JSON array of detected performance issues" + t.text :metadata, comment: "JSON object containing query complexity metrics" + t.text :query_stats, comment: "JSON object with query characteristics analysis" + t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection" + t.text :index_recommendations, comment: "JSON array of database index recommendations" + t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results" + t.text :suggestions, comment: "JSON array of optimization recommendations" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + add_index :rails_pulse_queries, :normalized_sql, unique: true, name: "index_rails_pulse_queries_on_normalized_sql", length: 191 + + # Requests table + create_table :rails_pulse_requests do |t| + t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route" + t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds" + t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)" + t.boolean :is_error, null: false, default: false, comment: "True if status >= 500" + t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)" + t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)" + t.timestamp :occurred_at, null: false, comment: "When the request started" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + add_index :rails_pulse_requests, :occurred_at, name: "index_rails_pulse_requests_on_occurred_at" + add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid" + add_index :rails_pulse_requests, [ :route_id, :occurred_at ], name: "index_rails_pulse_requests_on_route_id_and_occurred_at" + + # Operations table + create_table :rails_pulse_operations do |t| + t.references :request, null: false, foreign_key: { to_table: :rails_pulse_requests }, comment: "Link to the request" + t.references :query, foreign_key: { to_table: :rails_pulse_queries }, index: true, comment: "Link to the normalized SQL query" + t.string :operation_type, null: false, comment: "Type of operation (e.g., database, view, gem_call)" + t.string :label, null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)" + t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds" + t.string :codebase_location, comment: "File and line number (e.g., app/models/user.rb:25)" + t.float :start_time, null: false, default: 0.0, comment: "Operation start time in milliseconds" + t.timestamp :occurred_at, null: false, comment: "When the request started" + t.timestamps + end + + add_index :rails_pulse_operations, :operation_type, name: "index_rails_pulse_operations_on_operation_type" + add_index :rails_pulse_operations, :occurred_at, name: "index_rails_pulse_operations_on_occurred_at" + add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time" + add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance" + add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type" + + # Summaries table + create_table :rails_pulse_summaries do |t| + # Time fields + t.datetime :period_start, null: false, comment: "Start of the aggregation period" + t.datetime :period_end, null: false, comment: "End of the aggregation period" + t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month" + + # Polymorphic association to handle both routes and queries + t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query" + + # Universal metrics + t.integer :count, default: 0, null: false, comment: "Total number of requests/operations" + t.float :avg_duration, comment: "Average duration in milliseconds" + t.float :min_duration, comment: "Minimum duration in milliseconds" + t.float :max_duration, comment: "Maximum duration in milliseconds" + t.float :p50_duration, comment: "50th percentile duration" + t.float :p95_duration, comment: "95th percentile duration" + t.float :p99_duration, comment: "99th percentile duration" + t.float :total_duration, comment: "Total duration in milliseconds" + t.float :stddev_duration, comment: "Standard deviation of duration" + + # Request/Route specific metrics + t.integer :error_count, default: 0, comment: "Number of error responses (5xx)" + t.integer :success_count, default: 0, comment: "Number of successful responses" + t.integer :status_2xx, default: 0, comment: "Number of 2xx responses" + t.integer :status_3xx, default: 0, comment: "Number of 3xx responses" + t.integer :status_4xx, default: 0, comment: "Number of 4xx responses" + t.integer :status_5xx, default: 0, comment: "Number of 5xx responses" + + t.timestamps + end + + # Unique constraint and indexes for summaries + add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ], + unique: true, + name: "idx_pulse_summaries_unique" + add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period" + add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at" + + # Additional aggregation indexes + add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation" + add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at" + add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation" + add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at" + end +end diff --git a/lib/generators/rails_pulse/upgrade_generator.rb b/lib/generators/rails_pulse/upgrade_generator.rb index 78ceb2f..d9002bb 100644 --- a/lib/generators/rails_pulse/upgrade_generator.rb +++ b/lib/generators/rails_pulse/upgrade_generator.rb @@ -2,340 +2,84 @@ module RailsPulse module Generators class UpgradeGenerator < Rails::Generators::Base include Rails::Generators::Migration - source_root File.expand_path("templates", __dir__) + source_root File.expand_path("upgrade_migrations", __dir__) - desc "Upgrade Rails Pulse database schema to the latest version" + desc "Upgrade existing Rails Pulse installation to latest schema" - class_option :database, type: :string, default: "detect", - desc: "Database setup: 'single', 'separate', or 'detect' (default)" - - def self.next_migration_number(path) - next_migration_number = current_migration_number(path) + 1 + def self.next_migration_number(dirname) + next_migration_number = current_migration_number(dirname) + 1 ActiveRecord::Migration.next_migration_number(next_migration_number) end - def check_current_installation - @database_type = detect_database_setup - - say "Detected database setup: #{@database_type}", :green - - case @database_type - when :single - upgrade_single_database - when :separate - upgrade_separate_database - when :schema_only - offer_conversion_to_migrations - when :not_installed - say "Rails Pulse not detected. Run 'rails generate rails_pulse:install' first.", :red + def check_tables_exist + unless tables_exist? + say "Rails Pulse tables not found. Please run 'rails generate rails_pulse:migrations' for a fresh installation.", :red exit 1 end end - private - - def detect_database_setup - # Override with command line option if provided - return options[:database].to_sym if options[:database] != "detect" - - # Check for existing Rails Pulse tables - tables_exist = rails_pulse_tables_exist? - - root_path = respond_to?(:destination_root) ? destination_root : Rails.root - schema_path = File.join(root_path, "db/rails_pulse_schema.rb") - - if !tables_exist && File.exist?(schema_path) - :schema_only - elsif !tables_exist - :not_installed - elsif has_separate_database_config? - :separate - else - :single + def copy_upgrade_migrations + # Check which columns are missing and generate appropriate migrations + if needs_tags_migration? + migration_template "add_tags_to_rails_pulse_tables.rb", + "db/migrate/add_tags_to_rails_pulse_tables.rb" + say "Generated migration to add tags columns", :green end - end - - def has_separate_database_config? - root_path = respond_to?(:destination_root) ? destination_root : Rails.root - config_path = File.join(root_path, "config/database.yml") - - return false unless File.exist?(config_path) - - require "yaml" - db_config = YAML.load_file(config_path) - - # Check if any environment has a rails_pulse database configuration - db_config.values.any? { |env| env.is_a?(Hash) && env.key?("rails_pulse") } - rescue => e - # If we can't read the file, assume single database - false - end - - def rails_pulse_tables_exist? - return false unless defined?(ActiveRecord::Base) - - connection = ActiveRecord::Base.connection - required_tables = get_rails_pulse_table_names - - required_tables.all? { |table| connection.table_exists?(table) } - rescue - false - end - - def get_rails_pulse_table_names - # Load the schema file to get the table names dynamically - root_path = respond_to?(:destination_root) ? destination_root : Rails.root - schema_file = File.join(root_path, "db/rails_pulse_schema.rb") - - if File.exist?(schema_file) - # Read the schema file and extract the required_tables array - schema_content = File.read(schema_file) - # Extract the required_tables line using regex - if match = schema_content.match(/required_tables\s*=\s*\[(.*?)\]/m) - # Parse the array content, handling symbols and strings - table_names = match[1].scan(/:(\w+)/).flatten - return table_names.map(&:to_s) - end + if needs_query_analysis_columns? + migration_template "add_query_analysis_columns.rb", + "db/migrate/add_query_analysis_columns.rb" + say "Generated migration to add query analysis columns", :green end - # Fallback to default table names if schema file parsing fails - %w[rails_pulse_routes rails_pulse_queries rails_pulse_requests rails_pulse_operations rails_pulse_summaries] - end - - def upgrade_single_database - # Check for new migrations in gem - gem_migrations = get_gem_migrations - existing_migrations = get_user_migrations("db/migrate") - new_migrations = gem_migrations - existing_migrations - - if new_migrations.any? - say "Found #{new_migrations.size} new migration(s) to copy:", :blue - new_migrations.each do |migration| - say " - #{migration}", :blue - copy_gem_migration_to(migration, "db/migrate") - end - - say "\nMigrations copied successfully!", :green - say "\nNext steps:", :green - say "1. Run: rails db:migrate" - say "2. Restart your Rails server" - else - # Fall back to detecting missing columns - missing_columns = detect_missing_columns - - if missing_columns.empty? - say "Rails Pulse is up to date! No migration needed.", :green - return - end - - # Format missing columns by table for the template - missing_by_table = format_missing_columns_by_table(missing_columns) - - say "Creating upgrade migration for missing columns: #{missing_columns.keys.join(', ')}", :blue - - # Set instance variables for template - @migration_version = ActiveRecord::Migration.current_version - @missing_columns = missing_by_table - - migration_template( - "migrations/upgrade_rails_pulse_tables.rb", - "db/migrate/upgrade_rails_pulse_tables.rb" - ) - - say <<~MESSAGE - - Upgrade migration created successfully! - - Next steps: - 1. Run: rails db:migrate - 2. Restart your Rails server - - This migration will add: #{missing_columns.keys.join(', ')} - - MESSAGE + if needs_summaries_table? + migration_template "create_rails_pulse_summaries.rb", + "db/migrate/create_rails_pulse_summaries.rb" + say "Generated migration to create summaries table", :green end end - def upgrade_separate_database - # Check for new migrations in gem - gem_migrations = get_gem_migrations - existing_migrations = get_user_migrations("db/rails_pulse_migrate") - new_migrations = gem_migrations - existing_migrations - - if new_migrations.any? - say "Found #{new_migrations.size} new migration(s) to copy:", :blue - new_migrations.each do |migration| - say " - #{migration}", :blue - copy_gem_migration_to(migration, "db/rails_pulse_migrate") - end - - say "\nMigrations copied successfully!", :green - say "\nNext steps:", :green - say "1. Run migrations for the rails_pulse database:" - say " rails db:migrate (will run migrations for all databases)" - say " OR manually run the migration files in db/rails_pulse_migrate/" - say "2. Restart your Rails server" - else - # Fall back to detecting missing columns - missing_columns = detect_missing_columns - - if missing_columns.empty? - say "Rails Pulse is up to date! No migrations needed.", :green - else - # Format missing columns by table for the template - missing_by_table = format_missing_columns_by_table(missing_columns) - - say "Creating upgrade migration for missing columns: #{missing_columns.keys.join(', ')}", :blue - - # Set instance variables for template - @migration_version = ActiveRecord::Migration.current_version - @missing_columns = missing_by_table - - migration_template( - "migrations/upgrade_rails_pulse_tables.rb", - "db/rails_pulse_migrate/upgrade_rails_pulse_tables.rb" - ) - - say <<~MESSAGE - - Upgrade migration created successfully! - - Next steps: - 1. Run migrations for the rails_pulse database: - rails db:migrate (will run migrations for all databases) - OR manually run the migration files in db/rails_pulse_migrate/ - 2. Restart your Rails server - - This migration will add: #{missing_columns.keys.join(', ')} - - MESSAGE - end - end - end - - def offer_conversion_to_migrations + def display_post_install_message say <<~MESSAGE - Rails Pulse schema detected but no tables found. + Rails Pulse upgrade migrations have been generated! - To convert to single database setup: - 1. Run: rails generate rails_pulse:convert_to_migrations + Next steps: + 1. Review the generated migrations in db/migrate/ 2. Run: rails db:migrate - - The schema file db/rails_pulse_schema.rb will remain as your single source of truth. + 3. Restart your Rails server MESSAGE end - def detect_missing_columns - return {} unless rails_pulse_tables_exist? - - connection = ActiveRecord::Base.connection - missing = {} - - # Get expected schema from the schema file - expected_schema = get_expected_schema_from_file - - expected_schema.each do |table_name, columns| - table_symbol = table_name.to_sym - - if connection.table_exists?(table_symbol) - existing_columns = connection.columns(table_symbol).map(&:name) - - columns.each do |column_name, definition| - unless existing_columns.include?(column_name) - missing[column_name] = definition - end - end - end - end - - missing - end - - def get_expected_schema_from_file - root_path = respond_to?(:destination_root) ? destination_root : Rails.root - schema_file = File.join(root_path, "db/rails_pulse_schema.rb") - return {} unless File.exist?(schema_file) - - schema_content = File.read(schema_file) - expected_columns = {} - - # Find each create_table block and parse its contents - table_blocks = schema_content.scan(/connection\.create_table\s+:(\w+).*?do\s*\|t\|(.*?)(?:connection\.add_index|connection\.create_table|\z)/m) - - table_blocks.each do |table_name, table_block| - columns = {} - - # Split the table block into lines and process each line - table_block.split("\n").each do |line| - # Match column definitions like: t.text :index_recommendations, comment: "..." - if match = line.match(/t\.(\w+)\s+:([a-zA-Z_][a-zA-Z0-9_]*)(?:.*?comment:\s*"([^"]*)")?/) - column_type, column_name, comment = match.captures - - # Skip timestamps and references as they're handled by Rails - next if %w[timestamps references].include?(column_type) - - columns[column_name] = { - type: column_type.to_sym, - comment: comment - }.compact - end - end - - expected_columns[table_name] = columns if columns.any? - end + private - expected_columns + def tables_exist? + connection.table_exists?(:rails_pulse_routes) && + connection.table_exists?(:rails_pulse_requests) end - def format_missing_columns_by_table(missing_columns) - # The missing_columns are already organized by table from detect_missing_columns - # but we need to restructure them for the template - missing_by_table = {} - - # Get expected schema to find which table each missing column belongs to - expected_schema = get_expected_schema_from_file - - expected_schema.each do |table_name, expected_columns| - table_missing = {} - - expected_columns.each do |column_name, definition| - if missing_columns.key?(column_name) - table_missing[column_name] = definition - end - end - - missing_by_table[table_name] = table_missing if table_missing.any? - end - - missing_by_table + def needs_tags_migration? + !connection.column_exists?(:rails_pulse_routes, :tags) || + !connection.column_exists?(:rails_pulse_queries, :tags) || + !connection.column_exists?(:rails_pulse_requests, :tags) end - def get_gem_migrations - gem_migrations_path = File.expand_path("../../../db/rails_pulse_migrate", __dir__) - return [] unless File.directory?(gem_migrations_path) - - Dir.glob("#{gem_migrations_path}/*.rb").map { |f| File.basename(f) } + def needs_query_analysis_columns? + !connection.column_exists?(:rails_pulse_queries, :analyzed_at) || + !connection.column_exists?(:rails_pulse_queries, :explain_plan) end - def get_user_migrations(directory) - # Use destination_root in tests, Rails.root in production - root_path = respond_to?(:destination_root) ? destination_root : Rails.root - full_directory = File.join(root_path, directory) - - return [] unless File.directory?(full_directory) - - Dir.glob("#{full_directory}/*.rb").map { |f| File.basename(f) } + def needs_summaries_table? + !connection.table_exists?(:rails_pulse_summaries) end - def copy_gem_migration_to(migration_name, destination) - gem_migrations_path = File.expand_path("../../../db/rails_pulse_migrate", __dir__) - source_file = File.join(gem_migrations_path, migration_name) - destination_file = File.join(destination, migration_name) - - copy_file source_file, destination_file + def connection + @connection ||= if defined?(RailsPulse::ApplicationRecord) + RailsPulse::ApplicationRecord.connection + else + ActiveRecord::Base.connection + end end end end diff --git a/lib/generators/rails_pulse/upgrade_migrations/add_query_analysis_columns.rb b/lib/generators/rails_pulse/upgrade_migrations/add_query_analysis_columns.rb new file mode 100644 index 0000000..fa090ae --- /dev/null +++ b/lib/generators/rails_pulse/upgrade_migrations/add_query_analysis_columns.rb @@ -0,0 +1,39 @@ +class AddQueryAnalysisColumns < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] + def change + unless column_exists?(:rails_pulse_queries, :analyzed_at) + add_column :rails_pulse_queries, :analyzed_at, :datetime, comment: "When query analysis was last performed" + end + + unless column_exists?(:rails_pulse_queries, :explain_plan) + add_column :rails_pulse_queries, :explain_plan, :text, comment: "EXPLAIN output from actual SQL execution" + end + + unless column_exists?(:rails_pulse_queries, :issues) + add_column :rails_pulse_queries, :issues, :text, comment: "JSON array of detected performance issues" + end + + unless column_exists?(:rails_pulse_queries, :metadata) + add_column :rails_pulse_queries, :metadata, :text, comment: "JSON object containing query complexity metrics" + end + + unless column_exists?(:rails_pulse_queries, :query_stats) + add_column :rails_pulse_queries, :query_stats, :text, comment: "JSON object with query characteristics analysis" + end + + unless column_exists?(:rails_pulse_queries, :backtrace_analysis) + add_column :rails_pulse_queries, :backtrace_analysis, :text, comment: "JSON object with call chain and N+1 detection" + end + + unless column_exists?(:rails_pulse_queries, :index_recommendations) + add_column :rails_pulse_queries, :index_recommendations, :text, comment: "JSON array of database index recommendations" + end + + unless column_exists?(:rails_pulse_queries, :n_plus_one_analysis) + add_column :rails_pulse_queries, :n_plus_one_analysis, :text, comment: "JSON object with enhanced N+1 query detection results" + end + + unless column_exists?(:rails_pulse_queries, :suggestions) + add_column :rails_pulse_queries, :suggestions, :text, comment: "JSON array of optimization recommendations" + end + end +end diff --git a/lib/generators/rails_pulse/upgrade_migrations/add_tags_to_rails_pulse_tables.rb b/lib/generators/rails_pulse/upgrade_migrations/add_tags_to_rails_pulse_tables.rb new file mode 100644 index 0000000..ffdc9c4 --- /dev/null +++ b/lib/generators/rails_pulse/upgrade_migrations/add_tags_to_rails_pulse_tables.rb @@ -0,0 +1,7 @@ +class AddTagsToRailsPulseTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] + def change + add_column :rails_pulse_routes, :tags, :text, comment: "JSON array of tags for filtering and categorization" unless column_exists?(:rails_pulse_routes, :tags) + add_column :rails_pulse_queries, :tags, :text, comment: "JSON array of tags for filtering and categorization" unless column_exists?(:rails_pulse_queries, :tags) + add_column :rails_pulse_requests, :tags, :text, comment: "JSON array of tags for filtering and categorization" unless column_exists?(:rails_pulse_requests, :tags) + end +end diff --git a/lib/generators/rails_pulse/upgrade_migrations/create_rails_pulse_summaries.rb b/lib/generators/rails_pulse/upgrade_migrations/create_rails_pulse_summaries.rb new file mode 100644 index 0000000..2be6f3f --- /dev/null +++ b/lib/generators/rails_pulse/upgrade_migrations/create_rails_pulse_summaries.rb @@ -0,0 +1,60 @@ +class CreateRailsPulseSummaries < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] + def change + return if table_exists?(:rails_pulse_summaries) + + create_table :rails_pulse_summaries do |t| + # Time fields + t.datetime :period_start, null: false, comment: "Start of the aggregation period" + t.datetime :period_end, null: false, comment: "End of the aggregation period" + t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month" + + # Polymorphic association to handle both routes and queries + t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query" + + # Universal metrics + t.integer :count, default: 0, null: false, comment: "Total number of requests/operations" + t.float :avg_duration, comment: "Average duration in milliseconds" + t.float :min_duration, comment: "Minimum duration in milliseconds" + t.float :max_duration, comment: "Maximum duration in milliseconds" + t.float :p50_duration, comment: "50th percentile duration" + t.float :p95_duration, comment: "95th percentile duration" + t.float :p99_duration, comment: "99th percentile duration" + t.float :total_duration, comment: "Total duration in milliseconds" + t.float :stddev_duration, comment: "Standard deviation of duration" + + # Request/Route specific metrics + t.integer :error_count, default: 0, comment: "Number of error responses (5xx)" + t.integer :success_count, default: 0, comment: "Number of successful responses" + t.integer :status_2xx, default: 0, comment: "Number of 2xx responses" + t.integer :status_3xx, default: 0, comment: "Number of 3xx responses" + t.integer :status_4xx, default: 0, comment: "Number of 4xx responses" + t.integer :status_5xx, default: 0, comment: "Number of 5xx responses" + + t.timestamps + end + + # Unique constraint and indexes for summaries + add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ], + unique: true, + name: "idx_pulse_summaries_unique" + add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period" + add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at" + + # Additional aggregation indexes + unless index_exists?(:rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation") + add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation" + end + + unless index_exists?(:rails_pulse_requests, :created_at, name: "idx_requests_created_at") + add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at" + end + + unless index_exists?(:rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation") + add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation" + end + + unless index_exists?(:rails_pulse_operations, :created_at, name: "idx_operations_created_at") + add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at" + end + end +end