rails_pulse 0.2.4 → 0.2.5.pre.pre.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +269 -12
  3. data/Rakefile +142 -8
  4. data/app/assets/stylesheets/rails_pulse/components/table.css +16 -1
  5. data/app/assets/stylesheets/rails_pulse/components/tags.css +7 -2
  6. data/app/assets/stylesheets/rails_pulse/components/utilities.css +3 -0
  7. data/app/controllers/concerns/chart_table_concern.rb +2 -1
  8. data/app/controllers/rails_pulse/application_controller.rb +11 -1
  9. data/app/controllers/rails_pulse/assets_controller.rb +18 -2
  10. data/app/controllers/rails_pulse/job_runs_controller.rb +37 -0
  11. data/app/controllers/rails_pulse/jobs_controller.rb +80 -0
  12. data/app/controllers/rails_pulse/operations_controller.rb +43 -31
  13. data/app/controllers/rails_pulse/queries_controller.rb +1 -1
  14. data/app/controllers/rails_pulse/requests_controller.rb +3 -9
  15. data/app/controllers/rails_pulse/routes_controller.rb +1 -1
  16. data/app/controllers/rails_pulse/tags_controller.rb +31 -5
  17. data/app/helpers/rails_pulse/application_helper.rb +32 -1
  18. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +15 -1
  19. data/app/helpers/rails_pulse/status_helper.rb +16 -0
  20. data/app/helpers/rails_pulse/tags_helper.rb +39 -1
  21. data/app/javascript/rails_pulse/controllers/chart_controller.js +112 -8
  22. data/app/models/concerns/rails_pulse/taggable.rb +25 -2
  23. data/app/models/rails_pulse/charts/operations_chart.rb +33 -0
  24. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +1 -2
  25. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  26. data/app/models/rails_pulse/job.rb +85 -0
  27. data/app/models/rails_pulse/job_run.rb +76 -0
  28. data/app/models/rails_pulse/jobs/cards/average_duration.rb +85 -0
  29. data/app/models/rails_pulse/jobs/cards/base.rb +70 -0
  30. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +85 -0
  31. data/app/models/rails_pulse/jobs/cards/total_jobs.rb +74 -0
  32. data/app/models/rails_pulse/jobs/cards/total_runs.rb +48 -0
  33. data/app/models/rails_pulse/operation.rb +16 -3
  34. data/app/models/rails_pulse/queries/cards/average_query_times.rb +3 -3
  35. data/app/models/rails_pulse/queries/cards/execution_rate.rb +1 -1
  36. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
  37. data/app/models/rails_pulse/queries/tables/index.rb +2 -1
  38. data/app/models/rails_pulse/query.rb +10 -1
  39. data/app/models/rails_pulse/routes/cards/average_response_times.rb +3 -2
  40. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
  41. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
  42. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +1 -1
  43. data/app/models/rails_pulse/routes/tables/index.rb +2 -1
  44. data/app/models/rails_pulse/summary.rb +10 -3
  45. data/app/services/rails_pulse/summary_service.rb +46 -0
  46. data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
  47. data/app/views/layouts/rails_pulse/application.html.erb +23 -0
  48. data/app/views/rails_pulse/components/_active_filters.html.erb +7 -6
  49. data/app/views/rails_pulse/components/_page_header.html.erb +8 -7
  50. data/app/views/rails_pulse/components/_table.html.erb +7 -4
  51. data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
  52. data/app/views/rails_pulse/job_runs/_operations.html.erb +78 -0
  53. data/app/views/rails_pulse/job_runs/index.html.erb +3 -0
  54. data/app/views/rails_pulse/job_runs/show.html.erb +51 -0
  55. data/app/views/rails_pulse/jobs/_job_runs_table.html.erb +35 -0
  56. data/app/views/rails_pulse/jobs/_table.html.erb +43 -0
  57. data/app/views/rails_pulse/jobs/index.html.erb +34 -0
  58. data/app/views/rails_pulse/jobs/show.html.erb +49 -0
  59. data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +29 -27
  60. data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +11 -9
  61. data/app/views/rails_pulse/operations/show.html.erb +10 -8
  62. data/app/views/rails_pulse/queries/_table.html.erb +3 -3
  63. data/app/views/rails_pulse/requests/_table.html.erb +6 -6
  64. data/app/views/rails_pulse/routes/_table.html.erb +3 -3
  65. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  66. data/app/views/rails_pulse/tags/_tag_manager.html.erb +7 -14
  67. data/config/brakeman.ignore +213 -0
  68. data/config/brakeman.yml +68 -0
  69. data/config/initializers/rails_pulse.rb +52 -0
  70. data/config/routes.rb +6 -0
  71. data/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb +95 -0
  72. data/db/rails_pulse_migrate/20250122000000_add_query_fingerprinting.rb +150 -0
  73. data/db/rails_pulse_migrate/20250202000000_add_index_to_request_uuid.rb +14 -0
  74. data/db/rails_pulse_schema.rb +186 -103
  75. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +186 -103
  76. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +30 -1
  77. data/lib/generators/rails_pulse/templates/rails_pulse.rb +31 -0
  78. data/lib/rails_pulse/active_job_extensions.rb +13 -0
  79. data/lib/rails_pulse/adapters/delayed_job_plugin.rb +25 -0
  80. data/lib/rails_pulse/adapters/sidekiq_middleware.rb +41 -0
  81. data/lib/rails_pulse/cleanup_service.rb +65 -0
  82. data/lib/rails_pulse/configuration.rb +80 -7
  83. data/lib/rails_pulse/engine.rb +34 -3
  84. data/lib/rails_pulse/extensions/active_record.rb +82 -0
  85. data/lib/rails_pulse/job_run_collector.rb +172 -0
  86. data/lib/rails_pulse/middleware/request_collector.rb +20 -43
  87. data/lib/rails_pulse/subscribers/operation_subscriber.rb +11 -5
  88. data/lib/rails_pulse/tracker.rb +82 -0
  89. data/lib/rails_pulse/version.rb +1 -1
  90. data/lib/rails_pulse.rb +2 -0
  91. data/lib/rails_pulse_server.ru +107 -0
  92. data/lib/tasks/rails_pulse_benchmark.rake +382 -0
  93. data/public/rails-pulse-assets/rails-pulse-icons.js +3 -2
  94. data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
  95. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  96. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  97. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  98. data/public/rails-pulse-assets/rails-pulse.js.map +3 -3
  99. metadata +35 -7
  100. data/app/models/rails_pulse/requests/charts/operations_chart.rb +0 -35
  101. data/db/migrate/20250930105043_install_rails_pulse_tables.rb +0 -23
@@ -3,128 +3,211 @@
3
3
  # Load with: rails db:schema:load:rails_pulse or db:prepare
4
4
 
5
5
  RailsPulse::Schema = lambda do |connection|
6
+ adapter = connection.adapter_name.downcase
6
7
  # 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
+ required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_jobs, :rails_pulse_job_runs, :rails_pulse_summaries ]
8
9
 
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?
10
+ # Check which tables already exist
11
+ existing_tables = required_tables.select { |table| connection.table_exists?(table) }
12
+ missing_tables = required_tables - existing_tables
13
+
14
+ # Always log for transparency (not just in CI)
15
+ if existing_tables.any?
16
+ puts "[RailsPulse::Schema] Existing tables detected: #{existing_tables.join(', ')}"
17
+ end
18
+
19
+ if missing_tables.any?
20
+ puts "[RailsPulse::Schema] Creating missing tables: #{missing_tables.join(', ')}"
14
21
  end
15
22
 
16
- return if required_tables.all? { |table| connection.table_exists?(table) }
23
+ # If all tables exist, skip creation entirely
24
+ if missing_tables.empty?
25
+ puts "[RailsPulse::Schema] All Rails Pulse tables already exist. Skipping schema load."
26
+ return
27
+ end
28
+
29
+ unless connection.table_exists?(:rails_pulse_routes)
30
+ connection.create_table :rails_pulse_routes do |t|
31
+ t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)"
32
+ t.string :path, null: false, comment: "Request path (e.g., /posts/index)"
33
+ t.text :tags, comment: "JSON array of tags for filtering and categorization"
34
+ t.timestamps
35
+ end
36
+
37
+ connection.add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path"
38
+ end
17
39
 
18
- connection.create_table :rails_pulse_routes do |t|
19
- t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)"
20
- t.string :path, null: false, comment: "Request path (e.g., /posts/index)"
21
- t.text :tags, comment: "JSON array of tags for filtering and categorization"
22
- t.timestamps
40
+ unless connection.table_exists?(:rails_pulse_queries)
41
+ connection.create_table :rails_pulse_queries do |t|
42
+ t.string :hashed_sql, limit: 32, null: false, comment: "MD5 hash of normalized SQL for indexing"
43
+ t.text :normalized_sql, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)"
44
+ t.datetime :analyzed_at, comment: "When query analysis was last performed"
45
+ t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution"
46
+ t.text :issues, comment: "JSON array of detected performance issues"
47
+ t.text :metadata, comment: "JSON object containing query complexity metrics"
48
+ t.text :query_stats, comment: "JSON object with query characteristics analysis"
49
+ t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection"
50
+ t.text :index_recommendations, comment: "JSON array of database index recommendations"
51
+ t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results"
52
+ t.text :suggestions, comment: "JSON array of optimization recommendations"
53
+ t.text :tags, comment: "JSON array of tags for filtering and categorization"
54
+ t.timestamps
55
+ end
56
+
57
+ connection.add_index :rails_pulse_queries, :hashed_sql, unique: true, name: "index_rails_pulse_queries_on_hashed_sql"
23
58
  end
24
59
 
25
- connection.add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path"
26
-
27
- connection.create_table :rails_pulse_queries do |t|
28
- t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)"
29
- t.datetime :analyzed_at, comment: "When query analysis was last performed"
30
- t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution"
31
- t.text :issues, comment: "JSON array of detected performance issues"
32
- t.text :metadata, comment: "JSON object containing query complexity metrics"
33
- t.text :query_stats, comment: "JSON object with query characteristics analysis"
34
- t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection"
35
- t.text :index_recommendations, comment: "JSON array of database index recommendations"
36
- t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results"
37
- t.text :suggestions, comment: "JSON array of optimization recommendations"
38
- t.text :tags, comment: "JSON array of tags for filtering and categorization"
39
- t.timestamps
60
+ unless connection.table_exists?(:rails_pulse_requests)
61
+ connection.create_table :rails_pulse_requests do |t|
62
+ t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route"
63
+ t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds"
64
+ t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)"
65
+ t.boolean :is_error, null: false, default: false, comment: "True if status >= 500"
66
+ t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)"
67
+ t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)"
68
+ t.timestamp :occurred_at, null: false, comment: "When the request started"
69
+ t.text :tags, comment: "JSON array of tags for filtering and categorization"
70
+ t.timestamps
71
+ end
72
+
73
+ connection.add_index :rails_pulse_requests, :occurred_at, name: "index_rails_pulse_requests_on_occurred_at"
74
+ connection.add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid"
75
+ connection.add_index :rails_pulse_requests, [ :route_id, :occurred_at ], name: "index_rails_pulse_requests_on_route_id_and_occurred_at"
40
76
  end
41
77
 
42
- connection.add_index :rails_pulse_queries, :normalized_sql, unique: true, name: "index_rails_pulse_queries_on_normalized_sql", length: 191
43
-
44
- connection.create_table :rails_pulse_requests do |t|
45
- t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route"
46
- t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds"
47
- t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)"
48
- t.boolean :is_error, null: false, default: false, comment: "True if status >= 500"
49
- t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)"
50
- t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)"
51
- t.timestamp :occurred_at, null: false, comment: "When the request started"
52
- t.text :tags, comment: "JSON array of tags for filtering and categorization"
53
- t.timestamps
78
+ unless connection.table_exists?(:rails_pulse_jobs)
79
+ connection.create_table :rails_pulse_jobs do |t|
80
+ t.string :name, null: false, comment: "Job class name"
81
+ t.string :queue_name, comment: "Default queue"
82
+ t.text :description, comment: "Optional description"
83
+ t.integer :runs_count, null: false, default: 0, comment: "Cache of total runs"
84
+ t.integer :failures_count, null: false, default: 0, comment: "Cache of failed runs"
85
+ t.integer :retries_count, null: false, default: 0, comment: "Cache of retried runs"
86
+ t.decimal :avg_duration, precision: 15, scale: 6, comment: "Average duration in milliseconds"
87
+ t.text :tags, comment: "JSON array of tags"
88
+ t.timestamps
89
+ end
90
+
91
+ connection.add_index :rails_pulse_jobs, :name, unique: true, name: "index_rails_pulse_jobs_on_name"
92
+ connection.add_index :rails_pulse_jobs, :queue_name, name: "index_rails_pulse_jobs_on_queue"
93
+ connection.add_index :rails_pulse_jobs, :runs_count, name: "index_rails_pulse_jobs_on_runs_count"
54
94
  end
55
95
 
56
- connection.add_index :rails_pulse_requests, :occurred_at, name: "index_rails_pulse_requests_on_occurred_at"
57
- connection.add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid"
58
- connection.add_index :rails_pulse_requests, [ :route_id, :occurred_at ], name: "index_rails_pulse_requests_on_route_id_and_occurred_at"
59
-
60
- connection.create_table :rails_pulse_operations do |t|
61
- t.references :request, null: false, foreign_key: { to_table: :rails_pulse_requests }, comment: "Link to the request"
62
- t.references :query, foreign_key: { to_table: :rails_pulse_queries }, index: true, comment: "Link to the normalized SQL query"
63
- t.string :operation_type, null: false, comment: "Type of operation (e.g., database, view, gem_call)"
64
- t.string :label, null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)"
65
- t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds"
66
- t.string :codebase_location, comment: "File and line number (e.g., app/models/user.rb:25)"
67
- t.float :start_time, null: false, default: 0.0, comment: "Operation start time in milliseconds"
68
- t.timestamp :occurred_at, null: false, comment: "When the request started"
69
- t.timestamps
96
+ unless connection.table_exists?(:rails_pulse_job_runs)
97
+ connection.create_table :rails_pulse_job_runs do |t|
98
+ t.references :job, null: false, foreign_key: { to_table: :rails_pulse_jobs }, comment: "Link to job definition"
99
+ t.string :run_id, null: false, comment: "Adapter specific run id"
100
+ t.decimal :duration, precision: 15, scale: 6, comment: "Execution duration in milliseconds"
101
+ t.string :status, null: false, comment: "Execution status"
102
+ t.string :error_class, comment: "Error class name"
103
+ t.text :error_message, comment: "Error message"
104
+ t.integer :attempts, null: false, default: 0, comment: "Retry attempts"
105
+ t.timestamp :occurred_at, null: false, comment: "When the job started"
106
+ t.timestamp :enqueued_at, comment: "When the job was enqueued"
107
+ t.text :arguments, comment: "Serialized arguments"
108
+ t.string :adapter, comment: "Queue adapter"
109
+ t.text :tags, comment: "Execution tags"
110
+ t.timestamps
111
+ end
112
+
113
+ connection.add_index :rails_pulse_job_runs, :run_id, unique: true, name: "index_rails_pulse_job_runs_on_run_id"
114
+ connection.add_index :rails_pulse_job_runs, [ :job_id, :occurred_at ], name: "index_rails_pulse_job_runs_on_job_and_occurred"
115
+ connection.add_index :rails_pulse_job_runs, :occurred_at, name: "index_rails_pulse_job_runs_on_occurred_at"
116
+ connection.add_index :rails_pulse_job_runs, :status, name: "index_rails_pulse_job_runs_on_status"
117
+ connection.add_index :rails_pulse_job_runs, [ :job_id, :status ], name: "index_rails_pulse_job_runs_on_job_and_status"
70
118
  end
71
119
 
72
- connection.add_index :rails_pulse_operations, :operation_type, name: "index_rails_pulse_operations_on_operation_type"
73
- connection.add_index :rails_pulse_operations, :occurred_at, name: "index_rails_pulse_operations_on_occurred_at"
74
- connection.add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time"
75
- connection.add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance"
76
- connection.add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type"
77
-
78
- connection.create_table :rails_pulse_summaries do |t|
79
- # Time fields
80
- t.datetime :period_start, null: false, comment: "Start of the aggregation period"
81
- t.datetime :period_end, null: false, comment: "End of the aggregation period"
82
- t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month"
83
-
84
- # Polymorphic association to handle both routes and queries
85
- t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query"
86
- # This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query')
87
- # and summarizable_id (route_id or query_id)
88
-
89
- # Universal metrics
90
- t.integer :count, default: 0, null: false, comment: "Total number of requests/operations"
91
- t.float :avg_duration, comment: "Average duration in milliseconds"
92
- t.float :min_duration, comment: "Minimum duration in milliseconds"
93
- t.float :max_duration, comment: "Maximum duration in milliseconds"
94
- t.float :p50_duration, comment: "50th percentile duration"
95
- t.float :p95_duration, comment: "95th percentile duration"
96
- t.float :p99_duration, comment: "99th percentile duration"
97
- t.float :total_duration, comment: "Total duration in milliseconds"
98
- t.float :stddev_duration, comment: "Standard deviation of duration"
99
-
100
- # Request/Route specific metrics
101
- t.integer :error_count, default: 0, comment: "Number of error responses (5xx)"
102
- t.integer :success_count, default: 0, comment: "Number of successful responses"
103
- t.integer :status_2xx, default: 0, comment: "Number of 2xx responses"
104
- t.integer :status_3xx, default: 0, comment: "Number of 3xx responses"
105
- t.integer :status_4xx, default: 0, comment: "Number of 4xx responses"
106
- t.integer :status_5xx, default: 0, comment: "Number of 5xx responses"
107
-
108
- t.timestamps
120
+ unless connection.table_exists?(:rails_pulse_operations)
121
+ connection.create_table :rails_pulse_operations do |t|
122
+ t.references :request, null: true, foreign_key: { to_table: :rails_pulse_requests }, comment: "Link to the request"
123
+ t.references :job_run, null: true, foreign_key: { to_table: :rails_pulse_job_runs }, comment: "Link to a background job execution"
124
+ t.references :query, foreign_key: { to_table: :rails_pulse_queries }, index: true, comment: "Link to the normalized SQL query"
125
+ t.string :operation_type, null: false, comment: "Type of operation (e.g., database, view, gem_call)"
126
+ t.string :label, null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)"
127
+ t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds"
128
+ t.string :codebase_location, comment: "File and line number (e.g., app/models/user.rb:25)"
129
+ t.float :start_time, null: false, default: 0.0, comment: "Operation start time in milliseconds"
130
+ t.timestamp :occurred_at, null: false, comment: "When the request started"
131
+ t.timestamps
132
+ end
133
+
134
+ connection.add_index :rails_pulse_operations, :operation_type, name: "index_rails_pulse_operations_on_operation_type"
135
+ connection.add_index :rails_pulse_operations, :occurred_at, name: "index_rails_pulse_operations_on_occurred_at"
136
+ connection.add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time"
137
+ connection.add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance"
138
+ connection.add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type"
139
+
140
+ if adapter.include?("postgres") || adapter.include?("mysql")
141
+ connection.add_check_constraint :rails_pulse_operations,
142
+ "(request_id IS NOT NULL OR job_run_id IS NOT NULL)",
143
+ name: "rails_pulse_operations_request_or_job_run"
144
+ end
109
145
  end
110
146
 
111
- # Unique constraint and indexes for summaries
112
- connection.add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ],
113
- unique: true,
114
- name: "idx_pulse_summaries_unique"
115
- connection.add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period"
116
- connection.add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at"
147
+ unless connection.table_exists?(:rails_pulse_summaries)
148
+ connection.create_table :rails_pulse_summaries do |t|
149
+ # Time fields
150
+ t.datetime :period_start, null: false, comment: "Start of the aggregation period"
151
+ t.datetime :period_end, null: false, comment: "End of the aggregation period"
152
+ t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month"
153
+
154
+ # Polymorphic association to handle both routes and queries
155
+ t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query"
156
+ # This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query')
157
+ # and summarizable_id (route_id or query_id)
158
+
159
+ # Universal metrics
160
+ t.integer :count, default: 0, null: false, comment: "Total number of requests/operations"
161
+ t.float :avg_duration, comment: "Average duration in milliseconds"
162
+ t.float :min_duration, comment: "Minimum duration in milliseconds"
163
+ t.float :max_duration, comment: "Maximum duration in milliseconds"
164
+ t.float :p50_duration, comment: "50th percentile duration"
165
+ t.float :p95_duration, comment: "95th percentile duration"
166
+ t.float :p99_duration, comment: "99th percentile duration"
167
+ t.float :total_duration, comment: "Total duration in milliseconds"
168
+ t.float :stddev_duration, comment: "Standard deviation of duration"
169
+
170
+ # Request/Route specific metrics
171
+ t.integer :error_count, default: 0, comment: "Number of error responses (5xx)"
172
+ t.integer :success_count, default: 0, comment: "Number of successful responses"
173
+ t.integer :status_2xx, default: 0, comment: "Number of 2xx responses"
174
+ t.integer :status_3xx, default: 0, comment: "Number of 3xx responses"
175
+ t.integer :status_4xx, default: 0, comment: "Number of 4xx responses"
176
+ t.integer :status_5xx, default: 0, comment: "Number of 5xx responses"
177
+
178
+ t.timestamps
179
+ end
180
+
181
+ # Unique constraint and indexes for summaries
182
+ connection.add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ],
183
+ unique: true,
184
+ name: "idx_pulse_summaries_unique"
185
+ connection.add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period"
186
+ connection.add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at"
187
+ end
117
188
 
118
189
  # Add indexes to existing tables for efficient aggregation
119
- connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation"
120
- connection.add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at"
190
+ unless connection.index_exists?(:rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation")
191
+ connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation"
192
+ end
193
+
194
+ unless connection.index_exists?(:rails_pulse_requests, :created_at, name: "idx_requests_created_at")
195
+ connection.add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at"
196
+ end
121
197
 
122
- connection.add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation"
123
- connection.add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at"
198
+ unless connection.index_exists?(:rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation")
199
+ connection.add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation"
200
+ end
201
+
202
+ unless connection.index_exists?(:rails_pulse_operations, :created_at, name: "idx_operations_created_at")
203
+ connection.add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at"
204
+ end
124
205
 
125
- if ENV["CI"] == "true"
126
- created_tables = required_tables.select { |table| connection.table_exists?(table) }
127
- puts "[RailsPulse::Schema] Successfully created tables: #{created_tables.join(', ')}"
206
+ # Log successful creation
207
+ created_tables = required_tables.select { |table| connection.table_exists?(table) }
208
+ newly_created = created_tables - existing_tables
209
+ if newly_created.any?
210
+ puts "[RailsPulse::Schema] Successfully created tables: #{newly_created.join(', ')}"
128
211
  end
129
212
  end
130
213
 
@@ -1,6 +1,12 @@
1
1
  # Generated from Rails Pulse schema - automatically loads current schema definition
2
2
  class InstallRailsPulseTables < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
3
- def change
3
+ def up
4
+ # Check if Rails Pulse is already installed
5
+ if rails_pulse_installed?
6
+ say "Rails Pulse tables already exist. Skipping installation.", :yellow
7
+ return
8
+ end
9
+
4
10
  # Load and execute the Rails Pulse schema directly
5
11
  # This ensures the migration is always in sync with the schema file
6
12
  schema_file = File.join(::Rails.root.to_s, "db/rails_pulse_schema.rb")
@@ -20,4 +26,27 @@ class InstallRailsPulseTables < ActiveRecord::Migration[<%= Rails::VERSION::MAJO
20
26
  raise "Rails Pulse schema file not found at db/rails_pulse_schema.rb"
21
27
  end
22
28
  end
29
+
30
+ def down
31
+ # Rollback: drop all Rails Pulse tables in reverse dependency order
32
+ say "Dropping Rails Pulse tables..."
33
+
34
+ drop_table :rails_pulse_operations if table_exists?(:rails_pulse_operations)
35
+ drop_table :rails_pulse_job_runs if table_exists?(:rails_pulse_job_runs)
36
+ drop_table :rails_pulse_jobs if table_exists?(:rails_pulse_jobs)
37
+ drop_table :rails_pulse_summaries if table_exists?(:rails_pulse_summaries)
38
+ drop_table :rails_pulse_requests if table_exists?(:rails_pulse_requests)
39
+ drop_table :rails_pulse_routes if table_exists?(:rails_pulse_routes)
40
+ drop_table :rails_pulse_queries if table_exists?(:rails_pulse_queries)
41
+
42
+ say "Rails Pulse tables dropped successfully"
43
+ end
44
+
45
+ private
46
+
47
+ def rails_pulse_installed?
48
+ # Check if core Rails Pulse tables exist
49
+ # We check for routes and requests as they are foundational tables
50
+ table_exists?(:rails_pulse_routes) && table_exists?(:rails_pulse_requests)
51
+ end
23
52
  end
@@ -92,6 +92,37 @@ RailsPulse.configure do |config|
92
92
 
93
93
  config.tags = [ "ignored", "critical", "experimental" ]
94
94
 
95
+ # ====================================================================================================
96
+ # BACKGROUND JOBS
97
+ # ====================================================================================================
98
+ # Configure background job monitoring and tracking.
99
+ # When enabled, Rails Pulse will track job executions, durations, failures, and retries.
100
+ # Supports ActiveJob, Sidekiq, and Delayed Job.
101
+
102
+ # Enable or disable background job tracking
103
+ config.track_jobs = false
104
+
105
+ # Thresholds for job execution times (in milliseconds)
106
+ config.job_thresholds = {
107
+ slow: 5_000, # 5 seconds
108
+ very_slow: 30_000, # 30 seconds
109
+ critical: 60_000 # 1 minute
110
+ }
111
+
112
+ # Job classes to ignore from tracking (by class name)
113
+ # Examples:
114
+ # config.ignored_jobs = ["ActionMailer::MailDeliveryJob", "MyApp::HealthCheckJob"]
115
+ config.ignored_jobs = []
116
+
117
+ # Queue names to ignore from tracking
118
+ # Examples:
119
+ # config.ignored_queues = ["low_priority", "mailers"]
120
+ config.ignored_queues = []
121
+
122
+ # Capture job arguments for debugging (may contain sensitive data)
123
+ # Set to false in production to avoid storing potentially sensitive information
124
+ config.capture_job_arguments = true
125
+
95
126
  # ====================================================================================================
96
127
  # DATABASE CONFIGURATION
97
128
  # ====================================================================================================
@@ -0,0 +1,13 @@
1
+ module RailsPulse
2
+ module ActiveJobExtensions
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ around_perform do |job, block|
7
+ RailsPulse::JobRunCollector.track(job) do
8
+ block.call
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ module RailsPulse
2
+ module Adapters
3
+ class DelayedJobPlugin < Delayed::Plugin
4
+ callbacks do |lifecycle|
5
+ lifecycle.around(:perform) do |worker, job_data, &block|
6
+ next block.call(worker, job_data) unless RailsPulse.configuration.enabled
7
+ next block.call(worker, job_data) unless RailsPulse.configuration.track_jobs
8
+
9
+ job_wrapper = JobWrapper.new(
10
+ job_id: job_data.id.to_s,
11
+ class_name: job_data.payload_object.class.name,
12
+ queue_name: job_data.queue,
13
+ arguments: job_data.payload_object.args,
14
+ enqueued_at: job_data.created_at,
15
+ executions: job_data.attempts
16
+ )
17
+
18
+ RailsPulse::JobRunCollector.track(job_wrapper, adapter: "delayed_job") do
19
+ block.call(worker, job_data)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,41 @@
1
+ module RailsPulse
2
+ module Adapters
3
+ class SidekiqMiddleware
4
+ def call(worker, job_data, queue)
5
+ return yield unless RailsPulse.configuration.enabled
6
+ return yield unless RailsPulse.configuration.track_jobs
7
+
8
+ # Create ActiveJob-like wrapper for tracking
9
+ job_wrapper = JobWrapper.new(
10
+ job_id: job_data["jid"],
11
+ class_name: worker.class.name,
12
+ queue_name: queue,
13
+ arguments: job_data["args"],
14
+ enqueued_at: Time.at(job_data["enqueued_at"] || Time.current.to_f),
15
+ executions: job_data["retry_count"] || 0
16
+ )
17
+
18
+ RailsPulse::JobRunCollector.track(job_wrapper, adapter: "sidekiq") do
19
+ yield
20
+ end
21
+ end
22
+ end
23
+
24
+ class JobWrapper
25
+ attr_reader :job_id, :queue_name, :arguments, :enqueued_at, :executions
26
+
27
+ def initialize(job_id:, class_name:, queue_name:, arguments:, enqueued_at:, executions:)
28
+ @job_id = job_id
29
+ @class_name = class_name
30
+ @queue_name = queue_name
31
+ @arguments = arguments
32
+ @enqueued_at = enqueued_at
33
+ @executions = executions
34
+ end
35
+
36
+ def class
37
+ OpenStruct.new(name: @class_name)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -39,9 +39,11 @@ module RailsPulse
39
39
 
40
40
  # Clean up in order that respects foreign key constraints
41
41
  @stats[:time_based][:operations] = cleanup_operations_by_time(cutoff_time)
42
+ @stats[:time_based][:job_runs] = cleanup_job_runs_by_time(cutoff_time)
42
43
  @stats[:time_based][:requests] = cleanup_requests_by_time(cutoff_time)
43
44
  @stats[:time_based][:queries] = cleanup_queries_by_time(cutoff_time)
44
45
  @stats[:time_based][:routes] = cleanup_routes_by_time(cutoff_time)
46
+ @stats[:time_based][:jobs] = cleanup_jobs_by_time(cutoff_time)
45
47
  end
46
48
 
47
49
  def perform_count_based_cleanup
@@ -51,9 +53,11 @@ module RailsPulse
51
53
 
52
54
  # Clean up in order that respects foreign key constraints
53
55
  @stats[:count_based][:operations] = cleanup_operations_by_count
56
+ @stats[:count_based][:job_runs] = cleanup_job_runs_by_count
54
57
  @stats[:count_based][:requests] = cleanup_requests_by_count
55
58
  @stats[:count_based][:queries] = cleanup_queries_by_count
56
59
  @stats[:count_based][:routes] = cleanup_routes_by_count
60
+ @stats[:count_based][:jobs] = cleanup_jobs_by_count
57
61
  end
58
62
 
59
63
  # Time-based cleanup methods
@@ -110,6 +114,27 @@ module RailsPulse
110
114
  count
111
115
  end
112
116
 
117
+ def cleanup_job_runs_by_time(cutoff_time)
118
+ return 0 unless defined?(RailsPulse::JobRun)
119
+
120
+ job_runs = RailsPulse::JobRun.where("occurred_at < ?", cutoff_time)
121
+ count = job_runs.count
122
+ job_runs.delete_all
123
+ count
124
+ end
125
+
126
+ def cleanup_jobs_by_time(cutoff_time)
127
+ return 0 unless defined?(RailsPulse::Job)
128
+
129
+ job_ids_with_runs = RailsPulse::JobRun.distinct.pluck(:job_id).compact
130
+ jobs = RailsPulse::Job
131
+ .where("created_at < ?", cutoff_time)
132
+ .where.not(id: job_ids_with_runs)
133
+ count = jobs.count
134
+ jobs.delete_all
135
+ count
136
+ end
137
+
113
138
  # Count-based cleanup methods
114
139
  def cleanup_operations_by_count
115
140
  return 0 unless defined?(RailsPulse::Operation)
@@ -196,6 +221,46 @@ module RailsPulse
196
221
  records_to_delete
197
222
  end
198
223
 
224
+ def cleanup_job_runs_by_count
225
+ return 0 unless defined?(RailsPulse::JobRun)
226
+
227
+ max_records = @config.max_table_records[:rails_pulse_job_runs]
228
+ return 0 unless max_records
229
+
230
+ current_count = RailsPulse::JobRun.count
231
+ return 0 if current_count <= max_records
232
+
233
+ records_to_delete = current_count - max_records
234
+ ids_to_delete = RailsPulse::JobRun
235
+ .order(:occurred_at)
236
+ .limit(records_to_delete)
237
+ .pluck(:id)
238
+
239
+ RailsPulse::JobRun.where(id: ids_to_delete).delete_all
240
+ records_to_delete
241
+ end
242
+
243
+ def cleanup_jobs_by_count
244
+ return 0 unless defined?(RailsPulse::Job)
245
+
246
+ max_records = @config.max_table_records[:rails_pulse_jobs]
247
+ return 0 unless max_records
248
+
249
+ job_ids_with_runs = RailsPulse::JobRun.distinct.pluck(:job_id).compact
250
+ available_jobs = RailsPulse::Job.where.not(id: job_ids_with_runs)
251
+ current_count = available_jobs.count
252
+ return 0 if current_count <= max_records
253
+
254
+ records_to_delete = current_count - max_records
255
+ ids_to_delete = available_jobs
256
+ .order(:created_at)
257
+ .limit(records_to_delete)
258
+ .pluck(:id)
259
+
260
+ RailsPulse::Job.where(id: ids_to_delete).delete_all
261
+ records_to_delete
262
+ end
263
+
199
264
  def log_cleanup_summary
200
265
  total_time_based = @stats[:time_based].values.sum
201
266
  total_count_based = @stats[:count_based].values.sum