diff --git a/Gemfile b/Gemfile index 3d1e1649..8bae70ed 100644 --- a/Gemfile +++ b/Gemfile @@ -36,6 +36,7 @@ gem 'puma' gem 'pundit', '>= 2.5.1' gem 'rails', '~> 8.0' gem 'rails_icons' +gem 'rails_pulse' gem 'redis' gem 'rexml' gem 'rgeo' diff --git a/Gemfile.lock b/Gemfile.lock index d7203d5e..e1a1840c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -141,6 +141,7 @@ GEM cronex (0.15.0) tzinfo unicode (>= 0.4.4.5) + css-zero (1.1.15) csv (3.3.4) data_migrate (11.3.1) activerecord (>= 6.1) @@ -351,6 +352,9 @@ GEM optimist (3.2.1) orm_adapter (0.5.0) ostruct (0.6.1) + pagy (43.2.2) + json + yaml parallel (1.27.0) parser (3.3.9.0) ast (~> 2.4.1) @@ -429,6 +433,14 @@ GEM rails_icons (1.4.0) nokogiri (~> 1.16, >= 1.16.4) rails (> 6.1) + rails_pulse (0.2.4) + css-zero (~> 1.1, >= 1.1.4) + groupdate (~> 6.0) + pagy (>= 8, < 44) + rails (>= 7.1.0, < 9.0.0) + ransack (~> 4.0) + request_store (~> 1.5) + turbo-rails (~> 2.0.11) railties (8.0.3) actionpack (= 8.0.3) activesupport (= 8.0.3) @@ -440,6 +452,10 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.1) + ransack (4.4.1) + activerecord (>= 7.2) + activesupport (>= 7.2) + i18n rdoc (6.16.1) erb psych (>= 4.0.0) @@ -625,6 +641,7 @@ GEM zeitwerk (>= 2.7) xpath (3.2.0) nokogiri (~> 1.8) + yaml (0.4.0) zeitwerk (2.7.3) PLATFORMS @@ -677,6 +694,7 @@ DEPENDENCIES pundit (>= 2.5.1) rails (~> 8.0) rails_icons + rails_pulse redis rexml rgeo diff --git a/config/initializers/rails_pulse.rb b/config/initializers/rails_pulse.rb new file mode 100644 index 00000000..feacbd06 --- /dev/null +++ b/config/initializers/rails_pulse.rb @@ -0,0 +1,205 @@ +RailsPulse.configure do |config| + # ==================================================================================================== + # GLOBAL CONFIGURATION + # ==================================================================================================== + + # Enable or disable Rails Pulse + config.enabled = true + + # ==================================================================================================== + # THRESHOLDS + # ==================================================================================================== + # These thresholds are used to determine if a route, request, or query is slow, very slow, or critical. + # Values are in milliseconds (ms). Adjust these based on your application's performance requirements. + + # Thresholds for an individual route + config.route_thresholds = { + slow: 500, + very_slow: 1500, + critical: 3000 + } + + # Thresholds for an individual request + config.request_thresholds = { + slow: 700, + very_slow: 2000, + critical: 4000 + } + + # Thresholds for an individual database query + config.query_thresholds = { + slow: 100, + very_slow: 500, + critical: 1000 + } + + # ==================================================================================================== + # FILTERING + # ==================================================================================================== + + # Asset Tracking Configuration + # By default, Rails Pulse ignores asset requests (images, CSS, JS files) to focus on application performance. + # Set track_assets to true if you want to monitor asset delivery performance. + config.track_assets = false + + # Custom asset patterns to ignore (in addition to the built-in defaults) + # Only applies when track_assets is false. Add patterns for app-specific asset paths. + config.custom_asset_patterns = [ + # Example: ignore specific asset directories + # %r{^/uploads/}, + # %r{^/media/}, + # "/special-assets/" + ] + + # Rails Pulse Mount Path (optional) + # If Rails Pulse is mounted at a custom path, specify it here to prevent + # Rails Pulse from tracking its own requests. Leave as nil for default '/rails_pulse'. + # Examples: + # config.mount_path = "/admin/monitoring" + config.mount_path = nil + + # Manual route filtering + # Specify additional routes, requests, or queries to ignore from performance tracking. + # Each array can include strings (exact matches) or regular expressions. + # + # Examples: + # config.ignored_routes = ["/health_check", %r{^/admin}] + # config.ignored_requests = ["GET /status", %r{POST /api/v1/.*}] + # config.ignored_queries = ["SELECT 1", %r{FROM \"schema_migrations\"}] + + config.ignored_routes = [] + config.ignored_requests = [] + config.ignored_queries = [] + + # ==================================================================================================== + # TAGGING + # ==================================================================================================== + # Define custom tags for categorizing routes, requests, and queries. + # You can add any custom tags you want for filtering and organization. + # + # Tag names should be in present tense and describe the current state or category. + # Examples of good tag names: + # - "critical" (for high-priority endpoints) + # - "experimental" (for routes under development) + # - "deprecated" (for routes being phased out) + # - "external" (for third-party API calls) + # - "background" (for async job-related operations) + # - "admin" (for administrative routes) + # - "public" (for public-facing routes) + # + # Example configuration: + # config.tags = ["ignored", "critical", "experimental", "deprecated", "external", "admin"] + + config.tags = %w[ignored critical experimental] + + # ==================================================================================================== + # DATABASE CONFIGURATION + # ==================================================================================================== + # Configure Rails Pulse to use a separate database for performance monitoring data. + # This is optional but recommended for production applications to isolate performance + # data from your main application database. + # + # Uncomment and configure one of the following patterns: + + # Option 1: Separate single database for Rails Pulse + # config.connects_to = { + # database: { writing: :rails_pulse, reading: :rails_pulse } + # } + + # Option 2: Primary/replica configuration for Rails Pulse + # config.connects_to = { + # database: { writing: :rails_pulse_primary, reading: :rails_pulse_replica } + # } + + # Don't forget to add the database configuration to config/database.yml: + # + # production: + # # ... your main database config ... + # rails_pulse: + # adapter: postgresql # or mysql2, sqlite3 + # database: myapp_rails_pulse_production + # username: rails_pulse_user + # password: <%= Rails.application.credentials.dig(:rails_pulse, :database_password) %> + # host: localhost + # pool: 5 + + # ==================================================================================================== + # AUTHENTICATION + # ==================================================================================================== + # Configure authentication to secure access to the Rails Pulse dashboard. + # Authentication is ENABLED BY DEFAULT in production environments for security. + # + # If no authentication method is configured, Rails Pulse will use HTTP Basic Auth + # with credentials from RAILS_PULSE_USERNAME (default: 'admin') and RAILS_PULSE_PASSWORD + # environment variables. Set RAILS_PULSE_PASSWORD to enable this fallback. + # + # Uncomment and configure one of the following patterns based on your authentication system: + + # Enable/disable authentication (enabled by default in production) + config.authentication_enabled = true + + # Where to redirect unauthorized users + # config.authentication_redirect_path = "/" + + # Custom authentication method - choose one of the examples below: + + # Example 1: Devise with admin role check + config.authentication_method = proc { + redirect_to main_app.root_path, alert: 'Access denied' unless user_signed_in? && current_user.admin? + } + + # Example 2: Custom session-based authentication + # config.authentication_method = proc { + # unless session[:user_id] && User.find_by(id: session[:user_id])&.admin? + # redirect_to main_app.login_path, alert: "Please log in as an admin" + # end + # } + + # Example 3: Warden authentication + # config.authentication_method = proc { + # warden.authenticate!(:scope => :admin) + # } + + # Example 4: Basic HTTP authentication + # config.authentication_method = proc { + # authenticate_or_request_with_http_basic do |username, password| + # username == ENV['RAILS_PULSE_USERNAME'] && password == ENV['RAILS_PULSE_PASSWORD'] + # end + # } + + # Example 5: Custom authorization check + # config.authentication_method = proc { + # current_user = User.find_by(id: session[:user_id]) + # unless current_user&.can_access_rails_pulse? + # render plain: "Forbidden", status: :forbidden + # end + # } + + # ==================================================================================================== + # DATA CLEANUP + # ==================================================================================================== + # Configure automatic cleanup of old performance data to manage database size. + # Rails Pulse provides two cleanup mechanisms that work together: + # + # 1. Time-based cleanup: Delete records older than the retention period + # 2. Count-based cleanup: Keep only the specified number of records per table + # + # Cleanup order respects foreign key constraints: + # operations → requests → queries/routes + + # Enable or disable automatic data cleanup + config.archiving_enabled = true + + # Time-based retention - delete records older than this period + config.full_retention_period = 2.weeks + + # Count-based retention - maximum records to keep per table + # After time-based cleanup, if tables still exceed these limits, + # the oldest remaining records will be deleted to stay under the limit + config.max_table_records = { + rails_pulse_requests: 10_000, # HTTP requests (moderate volume) + rails_pulse_operations: 50_000, # Operations within requests (high volume) + rails_pulse_routes: 1000, # Unique routes (low volume) + rails_pulse_queries: 500 # Normalized SQL queries (low volume) + } +end diff --git a/config/routes.rb b/config/routes.rb index 321dc63f..ff40c143 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,6 +26,7 @@ Rails.application.routes.draw do } do mount Sidekiq::Web => '/sidekiq' end + mount RailsPulse::Engine => '/rails_pulse' # We want to return a nice error message if the user is not authorized to access Sidekiq match '/sidekiq' => redirect { |_, request| diff --git a/config/schedule.yml b/config/schedule.yml index ae920927..84cf04b1 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -49,3 +49,13 @@ nightly_family_invitations_cleanup_job: cron: "30 2 * * *" # every day at 02:30 class: "Family::Invitations::CleanupJob" queue: family + +rails_pulse_summary_job: + cron: "5 * * * *" # every hour at 5 minutes past the hour + class: "RailsPulse::SummaryJob" + queue: default + +rails_pulse_clean_up_job: + cron: "0 1 * * *" # every day at 01:00 + class: "RailsPulse::CleanupJob" + queue: default diff --git a/db/migrate/20251228163703_install_rails_pulse_tables.rb b/db/migrate/20251228163703_install_rails_pulse_tables.rb new file mode 100644 index 00000000..02548e36 --- /dev/null +++ b/db/migrate/20251228163703_install_rails_pulse_tables.rb @@ -0,0 +1,23 @@ +# Generated from Rails Pulse schema - automatically loads current schema definition +class InstallRailsPulseTables < ActiveRecord::Migration[8.0] + def change + # Load and execute the Rails Pulse schema directly + # This ensures the migration is always in sync with the schema file + schema_file = File.join(::Rails.root.to_s, "db/rails_pulse_schema.rb") + + if File.exist?(schema_file) + say "Loading Rails Pulse schema from db/rails_pulse_schema.rb" + + # Load the schema file to define RailsPulse::Schema + load schema_file + + # Execute the schema in the context of this migration + RailsPulse::Schema.call(connection) + + say "Rails Pulse tables created successfully" + say "The schema file db/rails_pulse_schema.rb remains as your single source of truth" + else + raise "Rails Pulse schema file not found at db/rails_pulse_schema.rb" + end + end +end \ No newline at end of file diff --git a/db/rails_pulse_migrate/.keep b/db/rails_pulse_migrate/.keep new file mode 100644 index 00000000..e69de29b diff --git a/db/rails_pulse_schema.rb b/db/rails_pulse_schema.rb new file mode 100644 index 00000000..71f54e3e --- /dev/null +++ b/db/rails_pulse_schema.rb @@ -0,0 +1,133 @@ +# Rails Pulse Database Schema +# This file contains the complete schema for Rails Pulse tables +# Load with: rails db:schema:load:rails_pulse or db:prepare + +RailsPulse::Schema = lambda do |connection| + # Skip if all tables already exist to prevent conflicts + required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_summaries ] + + if ENV["CI"] == "true" + existing_tables = required_tables.select { |table| connection.table_exists?(table) } + missing_tables = required_tables - existing_tables + puts "[RailsPulse::Schema] Existing tables: #{existing_tables.join(', ')}" if existing_tables.any? + puts "[RailsPulse::Schema] Missing tables: #{missing_tables.join(', ')}" if missing_tables.any? + end + + return if required_tables.all? { |table| connection.table_exists?(table) } + + connection.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 + + connection.add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path" + + connection.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 + + connection.add_index :rails_pulse_queries, :normalized_sql, unique: true, name: "index_rails_pulse_queries_on_normalized_sql", length: 191 + + connection.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 + + connection.add_index :rails_pulse_requests, :occurred_at, name: "index_rails_pulse_requests_on_occurred_at" + connection.add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid" + connection.add_index :rails_pulse_requests, [ :route_id, :occurred_at ], name: "index_rails_pulse_requests_on_route_id_and_occurred_at" + + connection.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 + + connection.add_index :rails_pulse_operations, :operation_type, name: "index_rails_pulse_operations_on_operation_type" + connection.add_index :rails_pulse_operations, :occurred_at, name: "index_rails_pulse_operations_on_occurred_at" + connection.add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time" + connection.add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance" + connection.add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type" + + connection.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" + # This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query') + # and summarizable_id (route_id or query_id) + + # 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 + connection.add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ], + unique: true, + name: "idx_pulse_summaries_unique" + connection.add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period" + connection.add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at" + + # Add indexes to existing tables for efficient aggregation + connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation" + connection.add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at" + + connection.add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation" + connection.add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at" + + if ENV["CI"] == "true" + created_tables = required_tables.select { |table| connection.table_exists?(table) } + puts "[RailsPulse::Schema] Successfully created tables: #{created_tables.join(', ')}" + end +end + +if defined?(RailsPulse::ApplicationRecord) + RailsPulse::Schema.call(RailsPulse::ApplicationRecord.connection) +end diff --git a/db/schema.rb b/db/schema.rb index e7c2c4b1..a5ddaf50 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_12_28_100000) do +ActiveRecord::Schema[8.0].define(version: 2025_12_28_163703) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -285,6 +285,102 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_28_100000) do t.index ["user_id"], name: "index_points_raw_data_archives_on_user_id" end + create_table "rails_pulse_operations", force: :cascade do |t| + t.bigint "request_id", null: false, comment: "Link to the request" + t.bigint "query_id", 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", default: 0.0, null: false, comment: "Operation start time in milliseconds" + t.datetime "occurred_at", precision: nil, null: false, comment: "When the request started" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at", "query_id"], name: "idx_operations_for_aggregation" + t.index ["created_at"], name: "idx_operations_created_at" + t.index ["occurred_at", "duration", "operation_type"], name: "index_rails_pulse_operations_on_time_duration_type" + t.index ["occurred_at"], name: "index_rails_pulse_operations_on_occurred_at" + t.index ["operation_type"], name: "index_rails_pulse_operations_on_operation_type" + t.index ["query_id", "duration", "occurred_at"], name: "index_rails_pulse_operations_query_performance" + t.index ["query_id", "occurred_at"], name: "index_rails_pulse_operations_on_query_and_time" + t.index ["query_id"], name: "index_rails_pulse_operations_on_query_id" + t.index ["request_id"], name: "index_rails_pulse_operations_on_request_id" + end + + create_table "rails_pulse_queries", force: :cascade 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.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["normalized_sql"], name: "index_rails_pulse_queries_on_normalized_sql", unique: true + end + + create_table "rails_pulse_requests", force: :cascade do |t| + t.bigint "route_id", null: false, 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", default: false, null: 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.datetime "occurred_at", precision: nil, null: false, comment: "When the request started" + t.text "tags", comment: "JSON array of tags for filtering and categorization" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at", "route_id"], name: "idx_requests_for_aggregation" + t.index ["created_at"], name: "idx_requests_created_at" + t.index ["occurred_at"], name: "index_rails_pulse_requests_on_occurred_at" + t.index ["request_uuid"], name: "index_rails_pulse_requests_on_request_uuid", unique: true + t.index ["route_id", "occurred_at"], name: "index_rails_pulse_requests_on_route_id_and_occurred_at" + t.index ["route_id"], name: "index_rails_pulse_requests_on_route_id" + end + + create_table "rails_pulse_routes", force: :cascade 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.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["method", "path"], name: "index_rails_pulse_routes_on_method_and_path", unique: true + end + + create_table "rails_pulse_summaries", force: :cascade do |t| + 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" + t.string "summarizable_type", null: false + t.bigint "summarizable_id", null: false, comment: "Link to Route or Query" + 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" + 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.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at"], name: "index_rails_pulse_summaries_on_created_at" + t.index ["period_type", "period_start"], name: "index_rails_pulse_summaries_on_period" + t.index ["summarizable_type", "summarizable_id", "period_type", "period_start"], name: "idx_pulse_summaries_unique", unique: true + t.index ["summarizable_type", "summarizable_id"], name: "index_rails_pulse_summaries_on_summarizable" + end + create_table "stats", force: :cascade do |t| t.integer "year", null: false t.integer "month", null: false @@ -429,6 +525,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_28_100000) do add_foreign_key "points", "users" add_foreign_key "points", "visits" add_foreign_key "points_raw_data_archives", "users" + add_foreign_key "rails_pulse_operations", "rails_pulse_queries", column: "query_id" + add_foreign_key "rails_pulse_operations", "rails_pulse_requests", column: "request_id" + add_foreign_key "rails_pulse_requests", "rails_pulse_routes", column: "route_id" add_foreign_key "stats", "users" add_foreign_key "taggings", "tags" add_foreign_key "tags", "users"