rails_pulse 0.2.3 → 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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +270 -13
  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 +3 -3
  8. data/app/controllers/rails_pulse/application_controller.rb +20 -3
  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 -8
  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 +79 -3
  18. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +15 -1
  19. data/app/helpers/rails_pulse/chart_helper.rb +32 -2
  20. data/app/helpers/rails_pulse/status_helper.rb +16 -0
  21. data/app/helpers/rails_pulse/tags_helper.rb +39 -1
  22. data/app/javascript/rails_pulse/application.js +3 -54
  23. data/app/javascript/rails_pulse/controllers/chart_controller.js +333 -0
  24. data/app/javascript/rails_pulse/controllers/index_controller.js +9 -14
  25. data/app/javascript/rails_pulse/controllers/pagination_controller.js +27 -33
  26. data/app/jobs/rails_pulse/backfill_summaries_job.rb +0 -2
  27. data/app/jobs/rails_pulse/cleanup_job.rb +0 -2
  28. data/app/jobs/rails_pulse/summary_job.rb +0 -2
  29. data/app/models/concerns/rails_pulse/taggable.rb +25 -2
  30. data/app/models/rails_pulse/charts/operations_chart.rb +33 -0
  31. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +1 -2
  32. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  33. data/app/models/rails_pulse/job.rb +85 -0
  34. data/app/models/rails_pulse/job_run.rb +76 -0
  35. data/app/models/rails_pulse/jobs/cards/average_duration.rb +85 -0
  36. data/app/models/rails_pulse/jobs/cards/base.rb +70 -0
  37. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +85 -0
  38. data/app/models/rails_pulse/jobs/cards/total_jobs.rb +74 -0
  39. data/app/models/rails_pulse/jobs/cards/total_runs.rb +48 -0
  40. data/app/models/rails_pulse/operation.rb +16 -3
  41. data/app/models/rails_pulse/queries/cards/average_query_times.rb +3 -3
  42. data/app/models/rails_pulse/queries/cards/execution_rate.rb +1 -1
  43. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
  44. data/app/models/rails_pulse/queries/charts/average_query_times.rb +1 -1
  45. data/app/models/rails_pulse/queries/tables/index.rb +2 -1
  46. data/app/models/rails_pulse/query.rb +10 -1
  47. data/app/models/rails_pulse/requests/charts/average_response_times.rb +1 -1
  48. data/app/models/rails_pulse/routes/cards/average_response_times.rb +3 -2
  49. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
  50. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
  51. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +1 -1
  52. data/app/models/rails_pulse/routes/charts/average_response_times.rb +1 -1
  53. data/app/models/rails_pulse/routes/tables/index.rb +2 -1
  54. data/app/models/rails_pulse/summary.rb +10 -3
  55. data/app/services/rails_pulse/summary_service.rb +46 -0
  56. data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
  57. data/app/views/layouts/rails_pulse/application.html.erb +23 -0
  58. data/app/views/rails_pulse/components/_active_filters.html.erb +7 -6
  59. data/app/views/rails_pulse/components/_metric_card.html.erb +2 -2
  60. data/app/views/rails_pulse/components/_page_header.html.erb +8 -7
  61. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +1 -1
  62. data/app/views/rails_pulse/components/_table.html.erb +7 -4
  63. data/app/views/rails_pulse/components/_table_pagination.html.erb +8 -6
  64. data/app/views/rails_pulse/csp_test/show.html.erb +1 -1
  65. data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -1
  66. data/app/views/rails_pulse/dashboard/index.html.erb +5 -4
  67. data/app/views/rails_pulse/job_runs/_operations.html.erb +78 -0
  68. data/app/views/rails_pulse/job_runs/index.html.erb +3 -0
  69. data/app/views/rails_pulse/job_runs/show.html.erb +51 -0
  70. data/app/views/rails_pulse/jobs/_job_runs_table.html.erb +35 -0
  71. data/app/views/rails_pulse/jobs/_table.html.erb +43 -0
  72. data/app/views/rails_pulse/jobs/index.html.erb +34 -0
  73. data/app/views/rails_pulse/jobs/show.html.erb +49 -0
  74. data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +29 -27
  75. data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +11 -9
  76. data/app/views/rails_pulse/operations/show.html.erb +10 -8
  77. data/app/views/rails_pulse/queries/_table.html.erb +3 -3
  78. data/app/views/rails_pulse/queries/index.html.erb +2 -1
  79. data/app/views/rails_pulse/queries/show.html.erb +2 -1
  80. data/app/views/rails_pulse/requests/_table.html.erb +6 -6
  81. data/app/views/rails_pulse/routes/_table.html.erb +3 -3
  82. data/app/views/rails_pulse/routes/index.html.erb +2 -1
  83. data/app/views/rails_pulse/routes/show.html.erb +3 -2
  84. data/app/views/rails_pulse/tags/_tag_manager.html.erb +7 -14
  85. data/config/brakeman.ignore +213 -0
  86. data/config/brakeman.yml +68 -0
  87. data/config/importmap.rb +1 -1
  88. data/config/initializers/rails_pulse.rb +52 -0
  89. data/config/routes.rb +6 -0
  90. data/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb +95 -0
  91. data/db/rails_pulse_migrate/20250122000000_add_query_fingerprinting.rb +150 -0
  92. data/db/rails_pulse_migrate/20250202000000_add_index_to_request_uuid.rb +14 -0
  93. data/db/rails_pulse_schema.rb +186 -103
  94. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +186 -103
  95. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +30 -1
  96. data/lib/generators/rails_pulse/templates/rails_pulse.rb +31 -0
  97. data/lib/rails_pulse/active_job_extensions.rb +13 -0
  98. data/lib/rails_pulse/adapters/delayed_job_plugin.rb +25 -0
  99. data/lib/rails_pulse/adapters/sidekiq_middleware.rb +41 -0
  100. data/lib/rails_pulse/cleanup_service.rb +65 -0
  101. data/lib/rails_pulse/configuration.rb +80 -7
  102. data/lib/rails_pulse/engine.rb +29 -28
  103. data/lib/rails_pulse/extensions/active_record.rb +82 -0
  104. data/lib/rails_pulse/job_run_collector.rb +172 -0
  105. data/lib/rails_pulse/middleware/request_collector.rb +20 -43
  106. data/lib/rails_pulse/subscribers/operation_subscriber.rb +11 -5
  107. data/lib/rails_pulse/tracker.rb +82 -0
  108. data/lib/rails_pulse/version.rb +1 -1
  109. data/lib/rails_pulse.rb +2 -0
  110. data/lib/rails_pulse_server.ru +107 -0
  111. data/lib/tasks/rails_pulse_benchmark.rake +382 -0
  112. data/public/rails-pulse-assets/csp-test.js +10 -10
  113. data/public/rails-pulse-assets/rails-pulse-icons.js +3 -2
  114. data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
  115. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  116. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  117. data/public/rails-pulse-assets/rails-pulse.js +48 -48
  118. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  119. metadata +38 -30
  120. data/app/models/rails_pulse/requests/charts/operations_chart.rb +0 -35
  121. data/config/initializers/rails_charts_csp_patch.rb +0 -75
  122. data/db/migrate/20250930105043_install_rails_pulse_tables.rb +0 -23
@@ -0,0 +1,213 @@
1
+ {
2
+ "ignored_warnings": [
3
+ {
4
+ "warning_type": "SQL Injection",
5
+ "warning_code": 0,
6
+ "fingerprint": "3d4cff0f317df7445891a1fc467912f3ad09e869da9b3e8433d5897354f5d849",
7
+ "check_name": "SQL",
8
+ "message": "Possible SQL injection",
9
+ "file": "app/models/rails_pulse/request.rb",
10
+ "line": 40,
11
+ "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
12
+ "code": "Arel.sql(\"FLOOR(#{parent.table[:status].name} / 100)\")",
13
+ "render_path": null,
14
+ "location": {
15
+ "type": "method",
16
+ "class": "Request",
17
+ "method": null
18
+ },
19
+ "user_input": "parent.table[:status].name",
20
+ "confidence": "Medium",
21
+ "cwe_id": [
22
+ 89
23
+ ],
24
+ "note": "Reviewed: Using Arel.sql with column name from ActiveRecord table definition - safe, not user input"
25
+ },
26
+ {
27
+ "warning_type": "SQL Injection",
28
+ "warning_code": 0,
29
+ "fingerprint": "dc13170579f824326ffcaf309858f90fc807c7bdc4bfe41df3bb5d2b8f719a12",
30
+ "check_name": "SQL",
31
+ "message": "Possible SQL injection",
32
+ "file": "app/models/rails_pulse/request.rb",
33
+ "line": 54,
34
+ "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
35
+ "code": "Arel.sql with configuration thresholds",
36
+ "render_path": null,
37
+ "location": {
38
+ "type": "method",
39
+ "class": "Request",
40
+ "method": null
41
+ },
42
+ "user_input": "((RailsPulse.configuration rescue nil).request_thresholds or { :slow => 500, :very_slow => 1000, :critical => 2000 })[:slow]",
43
+ "confidence": "Medium",
44
+ "cwe_id": [
45
+ 89
46
+ ],
47
+ "note": "Reviewed: Using Arel.sql with configuration values from RailsPulse.configuration - safe, not user input"
48
+ },
49
+ {
50
+ "warning_type": "SQL Injection",
51
+ "warning_code": 0,
52
+ "fingerprint": "f3a63d26866552b7e487f0de0f73af1c6575a62a0bbea859a52fd24912b06f3d",
53
+ "check_name": "SQL",
54
+ "message": "Possible SQL injection",
55
+ "file": "app/models/rails_pulse/jobs/cards/average_duration.rb",
56
+ "line": 21,
57
+ "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
58
+ "code": "quote(current_window_start)",
59
+ "render_path": null,
60
+ "location": {
61
+ "type": "method",
62
+ "class": "RailsPulse::Jobs::Cards::AverageDuration",
63
+ "method": "to_metric_card"
64
+ },
65
+ "user_input": "quote(current_window_start)",
66
+ "confidence": "Medium",
67
+ "cwe_id": [
68
+ 89
69
+ ],
70
+ "note": "Reviewed: Uses connection.quote() method with calculated timestamp - safe, properly escaped by ActiveRecord"
71
+ },
72
+ {
73
+ "warning_type": "SQL Injection",
74
+ "warning_code": 0,
75
+ "fingerprint": "e9c51f9fff0ace6daba404a62decb15efdbc72129cdf0818ee802dcfb3419c11",
76
+ "check_name": "SQL",
77
+ "message": "Possible SQL injection",
78
+ "file": "app/models/rails_pulse/jobs/cards/failure_rate.rb",
79
+ "line": 21,
80
+ "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
81
+ "code": "quote(current_window_start)",
82
+ "render_path": null,
83
+ "location": {
84
+ "type": "method",
85
+ "class": "RailsPulse::Jobs::Cards::FailureRate",
86
+ "method": "to_metric_card"
87
+ },
88
+ "user_input": "quote(current_window_start)",
89
+ "confidence": "Medium",
90
+ "cwe_id": [
91
+ 89
92
+ ],
93
+ "note": "Reviewed: Uses connection.quote() method with calculated timestamp - safe, properly escaped by ActiveRecord"
94
+ },
95
+ {
96
+ "warning_type": "SQL Injection",
97
+ "warning_code": 0,
98
+ "fingerprint": "808fdc102a0d41199ed96c40058b819ea5a28dc4a8eeabca5c444d528006cb40",
99
+ "check_name": "SQL",
100
+ "message": "Possible SQL injection",
101
+ "file": "app/models/rails_pulse/jobs/cards/total_jobs.rb",
102
+ "line": 22,
103
+ "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
104
+ "code": "quote(current_window_start)",
105
+ "render_path": null,
106
+ "location": {
107
+ "type": "method",
108
+ "class": "RailsPulse::Jobs::Cards::TotalJobs",
109
+ "method": "to_metric_card"
110
+ },
111
+ "user_input": "quote(current_window_start)",
112
+ "confidence": "Medium",
113
+ "cwe_id": [
114
+ 89
115
+ ],
116
+ "note": "Reviewed: Uses connection.quote() method with calculated timestamp - safe, properly escaped by ActiveRecord"
117
+ },
118
+ {
119
+ "warning_type": "SQL Injection",
120
+ "warning_code": 0,
121
+ "fingerprint": "f92a184f3bc0b0374b67cf49510f2c7dd5fb010fe86a9ae210daf920b9523214",
122
+ "check_name": "SQL",
123
+ "message": "Possible SQL injection",
124
+ "file": "app/models/rails_pulse/jobs/cards/total_runs.rb",
125
+ "line": 20,
126
+ "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
127
+ "code": "quote(current_window_start)",
128
+ "render_path": null,
129
+ "location": {
130
+ "type": "method",
131
+ "class": "RailsPulse::Jobs::Cards::TotalRuns",
132
+ "method": "to_metric_card"
133
+ },
134
+ "user_input": "quote(current_window_start)",
135
+ "confidence": "Medium",
136
+ "cwe_id": [
137
+ 89
138
+ ],
139
+ "note": "Reviewed: Uses connection.quote() method with calculated timestamp - safe, properly escaped by ActiveRecord"
140
+ },
141
+ {
142
+ "warning_type": "SQL Injection",
143
+ "warning_code": 0,
144
+ "fingerprint": "3c816cd9f565d6592595f5566cbd9d9a19ecb51ad704477f01a9bf2b4c1f29fb",
145
+ "check_name": "SQL",
146
+ "message": "Possible SQL injection",
147
+ "file": "app/services/rails_pulse/analysis/explain_plan_analyzer.rb",
148
+ "line": 191,
149
+ "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
150
+ "code": "RailsPulse::ApplicationRecord.connection.execute(\"EXPLAIN (ANALYZE, BUFFERS) #{sql}\")",
151
+ "render_path": null,
152
+ "location": {
153
+ "type": "method",
154
+ "class": "RailsPulse::Analysis::ExplainPlanAnalyzer",
155
+ "method": "execute_postgres_explain"
156
+ },
157
+ "user_input": "sql",
158
+ "confidence": "Medium",
159
+ "cwe_id": [
160
+ 89
161
+ ],
162
+ "note": "Reviewed: SQL comes from Rails' ActiveRecord instrumentation (payload[:sql]), not user input. This is internally-generated SQL that Rails has already executed safely. EXPLAIN is read-only."
163
+ },
164
+ {
165
+ "warning_type": "SQL Injection",
166
+ "warning_code": 0,
167
+ "fingerprint": "a57a1c949d21de15d6b48e52bfc3077e3177a13ba1d703ddf48e241fb4e81c6e",
168
+ "check_name": "SQL",
169
+ "message": "Possible SQL injection",
170
+ "file": "app/services/rails_pulse/analysis/explain_plan_analyzer.rb",
171
+ "line": 196,
172
+ "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
173
+ "code": "RailsPulse::ApplicationRecord.connection.execute(\"EXPLAIN #{sql}\")",
174
+ "render_path": null,
175
+ "location": {
176
+ "type": "method",
177
+ "class": "RailsPulse::Analysis::ExplainPlanAnalyzer",
178
+ "method": "execute_mysql_explain"
179
+ },
180
+ "user_input": "sql",
181
+ "confidence": "Medium",
182
+ "cwe_id": [
183
+ 89
184
+ ],
185
+ "note": "Reviewed: SQL comes from Rails' ActiveRecord instrumentation (payload[:sql]), not user input. This is internally-generated SQL that Rails has already executed safely. EXPLAIN is read-only."
186
+ },
187
+ {
188
+ "warning_type": "SQL Injection",
189
+ "warning_code": 0,
190
+ "fingerprint": "85c09ca9672d132fb206d0371e20087deaf381d89733f73d647129878b89f374",
191
+ "check_name": "SQL",
192
+ "message": "Possible SQL injection",
193
+ "file": "app/services/rails_pulse/analysis/explain_plan_analyzer.rb",
194
+ "line": 201,
195
+ "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
196
+ "code": "RailsPulse::ApplicationRecord.connection.execute(\"EXPLAIN QUERY PLAN #{sql}\")",
197
+ "render_path": null,
198
+ "location": {
199
+ "type": "method",
200
+ "class": "RailsPulse::Analysis::ExplainPlanAnalyzer",
201
+ "method": "execute_sqlite_explain"
202
+ },
203
+ "user_input": "sql",
204
+ "confidence": "Medium",
205
+ "cwe_id": [
206
+ 89
207
+ ],
208
+ "note": "Reviewed: SQL comes from Rails' ActiveRecord instrumentation (payload[:sql]), not user input. This is internally-generated SQL that Rails has already executed safely. EXPLAIN is read-only."
209
+ }
210
+ ],
211
+ "updated": "2025-11-30 15:10:00 +0700",
212
+ "brakeman_version": "7.1.1"
213
+ }
@@ -0,0 +1,68 @@
1
+ ---
2
+ # Brakeman configuration file for Rails Pulse Engine
3
+ # https://brakemanscanner.org/docs/options/
4
+
5
+ # Application path - scan the engine files
6
+ :app_path: "."
7
+
8
+ # This is a Rails engine, not a standalone app
9
+ :engine_paths:
10
+ - app
11
+ - lib
12
+
13
+ # Rails version - let Brakeman detect automatically from gemspec
14
+ :rails7: true
15
+
16
+ # Output format for reports (can be: text, json, html, csv, tabs, markdown)
17
+ :output_format: :text
18
+
19
+ # Paths to skip - don't scan dummy app or test files
20
+ :skip_files:
21
+ - test/dummy/**/*
22
+ - node_modules/**/*
23
+ - vendor/**/*
24
+ - gemfiles/**/*
25
+
26
+ # Paths to ignore - additional paths to skip
27
+ :ignore_paths:
28
+ - test/**/*
29
+ - tmp/**/*
30
+ - log/**/*
31
+ - vendor/**/*
32
+ - node_modules/**/*
33
+ - public/**/*
34
+ - gemfiles/**/*
35
+
36
+ # Confidence levels: 0 = high only, 1 = high and medium, 2 = all
37
+ :min_confidence: 1 # Report high and medium confidence warnings only
38
+
39
+ # Exit code configuration
40
+ :exit_on_warn: true # Exit with error code if warnings found
41
+ :exit_on_error: true # Exit with error code if errors found
42
+
43
+ # Interactive mode
44
+ :interactive: false
45
+
46
+ # Parallelization
47
+ :parallel_checks: true
48
+
49
+ # Report additional information
50
+ :report_progress: true
51
+ :highlight_user_input: true
52
+ :message_limit: 100 # Maximum number of warnings to report per type
53
+
54
+ # Ignore file for specific false positives
55
+ :ignore_file: config/brakeman.ignore
56
+
57
+ # Additional options
58
+ :absolute_paths: false # Use relative paths in reports
59
+ :summary_only: false # Show full details, not just summary
60
+
61
+ # Engine-specific: Skip checks that don't apply to mountable engines
62
+ # These checks are more relevant for standalone apps
63
+ :skip_checks:
64
+ # Skip checks that are handled by the host application
65
+ - CheckForgerySetting # CSRF protection is handled by host app
66
+
67
+ # Note: We're NOT skipping SQL injection checks - those should be reviewed
68
+ # Note: We're NOT skipping file access checks - those should be reviewed
data/config/importmap.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  pin "application", to: "rails_pulse/application.js"
2
2
 
3
- # echarts is a dependency of the rails_charts gem
3
+ # echarts is used for chart rendering
4
4
  pin "echarts", to: "echarts.min.js"
5
5
  # pin "echarts/theme/inspired", to: "echarts/theme/inspired.js"
6
6
  pin "rails_pulse/theme", to: "rails_pulse/theme.js"
@@ -71,6 +71,58 @@ RailsPulse.configure do |config|
71
71
  config.ignored_requests = []
72
72
  config.ignored_queries = []
73
73
 
74
+ # ====================================================================================================
75
+ # TAGGING
76
+ # ====================================================================================================
77
+ # Define custom tags for categorizing routes, requests, and queries.
78
+ # You can add any custom tags you want for filtering and organization.
79
+ #
80
+ # Tag names should be in present tense and describe the current state or category.
81
+ # Examples of good tag names:
82
+ # - "critical" (for high-priority endpoints)
83
+ # - "experimental" (for routes under development)
84
+ # - "deprecated" (for routes being phased out)
85
+ # - "external" (for third-party API calls)
86
+ # - "background" (for async job-related operations)
87
+ # - "admin" (for administrative routes)
88
+ # - "public" (for public-facing routes)
89
+ #
90
+ # Example configuration:
91
+ # config.tags = ["ignored", "critical", "experimental", "deprecated", "external", "admin"]
92
+
93
+ config.tags = [ "ignored", "critical", "experimental" ]
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
+
74
126
  # ====================================================================================================
75
127
  # DATABASE CONFIGURATION
76
128
  # ====================================================================================================
data/config/routes.rb CHANGED
@@ -10,6 +10,12 @@ RailsPulse::Engine.routes.draw do
10
10
  end
11
11
  resources :operations, only: %i[show]
12
12
  resources :caches, only: %i[show], as: :cache
13
+
14
+ if RailsPulse.configuration.track_jobs
15
+ resources :jobs, only: %i[index show], param: :id do
16
+ resources :runs, only: %i[index show], controller: "job_runs"
17
+ end
18
+ end
13
19
  patch "pagination/limit", to: "application#set_pagination_limit"
14
20
  patch "settings/global_filters", to: "application#set_global_filters"
15
21
 
@@ -0,0 +1,95 @@
1
+ # Add background job tracking to Rails Pulse
2
+ class AddJobsToRailsPulse < ActiveRecord::Migration[7.0]
3
+ def up
4
+ # Create jobs table for storing job definitions
5
+ unless table_exists?(:rails_pulse_jobs)
6
+ create_table :rails_pulse_jobs do |t|
7
+ t.string :name, null: false, comment: "Job class name"
8
+ t.string :queue_name, comment: "Default queue"
9
+ t.text :description, comment: "Optional description"
10
+ t.integer :runs_count, null: false, default: 0, comment: "Cache of total runs"
11
+ t.integer :failures_count, null: false, default: 0, comment: "Cache of failed runs"
12
+ t.integer :retries_count, null: false, default: 0, comment: "Cache of retried runs"
13
+ t.decimal :avg_duration, precision: 15, scale: 6, comment: "Average duration in milliseconds"
14
+ t.text :tags, comment: "JSON array of tags"
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :rails_pulse_jobs, :name, unique: true, name: "index_rails_pulse_jobs_on_name"
19
+ add_index :rails_pulse_jobs, :queue_name, name: "index_rails_pulse_jobs_on_queue"
20
+ add_index :rails_pulse_jobs, :runs_count, name: "index_rails_pulse_jobs_on_runs_count"
21
+ end
22
+
23
+ # Create job_runs table for individual job executions
24
+ unless table_exists?(:rails_pulse_job_runs)
25
+ create_table :rails_pulse_job_runs do |t|
26
+ t.references :job, null: false, foreign_key: { to_table: :rails_pulse_jobs }, comment: "Link to job definition"
27
+ t.string :run_id, null: false, comment: "Adapter specific run id"
28
+ t.decimal :duration, precision: 15, scale: 6, comment: "Execution duration in milliseconds"
29
+ t.string :status, null: false, comment: "Execution status"
30
+ t.string :error_class, comment: "Error class name"
31
+ t.text :error_message, comment: "Error message"
32
+ t.integer :attempts, null: false, default: 0, comment: "Retry attempts"
33
+ t.timestamp :occurred_at, null: false, comment: "When the job started"
34
+ t.timestamp :enqueued_at, comment: "When the job was enqueued"
35
+ t.text :arguments, comment: "Serialized arguments"
36
+ t.string :adapter, comment: "Queue adapter"
37
+ t.text :tags, comment: "Execution tags"
38
+ t.timestamps
39
+ end
40
+
41
+ add_index :rails_pulse_job_runs, :run_id, unique: true, name: "index_rails_pulse_job_runs_on_run_id"
42
+ add_index :rails_pulse_job_runs, [ :job_id, :occurred_at ], name: "index_rails_pulse_job_runs_on_job_and_occurred"
43
+ add_index :rails_pulse_job_runs, :occurred_at, name: "index_rails_pulse_job_runs_on_occurred_at"
44
+ add_index :rails_pulse_job_runs, :status, name: "index_rails_pulse_job_runs_on_status"
45
+ add_index :rails_pulse_job_runs, [ :job_id, :status ], name: "index_rails_pulse_job_runs_on_job_and_status"
46
+ end
47
+
48
+ # Add job_run_id to operations table if it doesn't exist
49
+ if table_exists?(:rails_pulse_operations) && !column_exists?(:rails_pulse_operations, :job_run_id)
50
+ # Make request_id nullable to allow job operations
51
+ change_column_null :rails_pulse_operations, :request_id, true
52
+
53
+ # Add job_run_id reference
54
+ add_reference :rails_pulse_operations, :job_run,
55
+ null: true,
56
+ foreign_key: { to_table: :rails_pulse_job_runs },
57
+ comment: "Link to a background job execution"
58
+
59
+ # Add check constraint for PostgreSQL and MySQL to ensure either request_id or job_run_id is present
60
+ adapter = connection.adapter_name.downcase
61
+ if adapter.include?("postgres") || adapter.include?("mysql")
62
+ execute <<-SQL
63
+ ALTER TABLE rails_pulse_operations
64
+ ADD CONSTRAINT rails_pulse_operations_request_or_job_run
65
+ CHECK (request_id IS NOT NULL OR job_run_id IS NOT NULL)
66
+ SQL
67
+ end
68
+ end
69
+ end
70
+
71
+ def down
72
+ # Remove check constraint first
73
+ adapter = connection.adapter_name.downcase
74
+ if adapter.include?("postgres") || adapter.include?("mysql")
75
+ execute <<-SQL
76
+ ALTER TABLE rails_pulse_operations
77
+ DROP CONSTRAINT IF EXISTS rails_pulse_operations_request_or_job_run
78
+ SQL
79
+ end
80
+
81
+ # Remove job_run_id from operations
82
+ if column_exists?(:rails_pulse_operations, :job_run_id)
83
+ remove_reference :rails_pulse_operations, :job_run, foreign_key: { to_table: :rails_pulse_job_runs }
84
+ end
85
+
86
+ # Make request_id non-nullable again
87
+ if column_exists?(:rails_pulse_operations, :request_id)
88
+ change_column_null :rails_pulse_operations, :request_id, false
89
+ end
90
+
91
+ # Drop job tables
92
+ drop_table :rails_pulse_job_runs if table_exists?(:rails_pulse_job_runs)
93
+ drop_table :rails_pulse_jobs if table_exists?(:rails_pulse_jobs)
94
+ end
95
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add query fingerprinting to handle long SQL queries
4
+ # Uses MD5 hash of normalized SQL as unique identifier
5
+ class AddQueryFingerprinting < ActiveRecord::Migration[7.0]
6
+ def up
7
+ return unless table_exists?(:rails_pulse_queries)
8
+
9
+ # Add hashed_sql column if it doesn't exist
10
+ unless column_exists?(:rails_pulse_queries, :hashed_sql)
11
+ say "Adding hashed_sql column to rails_pulse_queries..."
12
+ add_column :rails_pulse_queries, :hashed_sql, :string, limit: 32
13
+
14
+ # Backfill existing records with MD5 hash
15
+ say "Backfilling query hashes for existing records..."
16
+ backfill_query_hashes
17
+
18
+ # Make it required and unique
19
+ say "Adding constraints and indexes..."
20
+ change_column_null :rails_pulse_queries, :hashed_sql, false
21
+ add_index :rails_pulse_queries, :hashed_sql, unique: true,
22
+ name: "index_rails_pulse_queries_on_hashed_sql"
23
+
24
+ # Remove old index
25
+ say "Removing old normalized_sql index..."
26
+ if index_exists?(:rails_pulse_queries, :normalized_sql, name: "index_rails_pulse_queries_on_normalized_sql")
27
+ remove_index :rails_pulse_queries, :normalized_sql, name: "index_rails_pulse_queries_on_normalized_sql"
28
+ end
29
+
30
+ # Change normalized_sql to text (remove 1000 char limit)
31
+ say "Changing normalized_sql to text type (removing length limit)..."
32
+ change_column :rails_pulse_queries, :normalized_sql, :text
33
+
34
+ say "Query fingerprinting migration completed successfully!", :green
35
+ else
36
+ say "Query fingerprinting already applied. Skipping.", :yellow
37
+ end
38
+ end
39
+
40
+ def down
41
+ # Prevent rollback if there are queries longer than 1000 characters
42
+ if has_long_queries?
43
+ raise ActiveRecord::IrreversibleMigration,
44
+ "Cannot rollback: normalized_sql contains queries longer than 1000 characters. " \
45
+ "Rolling back would truncate data."
46
+ end
47
+
48
+ return unless column_exists?(:rails_pulse_queries, :hashed_sql)
49
+
50
+ say "Rolling back query fingerprinting changes..."
51
+
52
+ # Restore varchar limit (safe because we checked for long queries)
53
+ change_column :rails_pulse_queries, :normalized_sql, :string, limit: 1000
54
+
55
+ # Restore old index
56
+ add_index :rails_pulse_queries, :normalized_sql, unique: true,
57
+ name: "index_rails_pulse_queries_on_normalized_sql", length: 191
58
+
59
+ # Remove new index
60
+ if index_exists?(:rails_pulse_queries, :hashed_sql, name: "index_rails_pulse_queries_on_hashed_sql")
61
+ remove_index :rails_pulse_queries, :hashed_sql, name: "index_rails_pulse_queries_on_hashed_sql"
62
+ end
63
+
64
+ # Remove hashed_sql column
65
+ remove_column :rails_pulse_queries, :hashed_sql
66
+
67
+ say "Rollback completed.", :green
68
+ end
69
+
70
+ private
71
+
72
+ def backfill_query_hashes
73
+ adapter = connection.adapter_name.downcase
74
+
75
+ if adapter.include?("postgres") || adapter.include?("mysql")
76
+ # Use database MD5 function for better performance
77
+ execute <<-SQL
78
+ UPDATE rails_pulse_queries
79
+ SET hashed_sql = MD5(normalized_sql)
80
+ WHERE hashed_sql IS NULL
81
+ SQL
82
+ else
83
+ # SQLite - use Ruby MD5 (slower but works)
84
+ require "digest"
85
+ RailsPulse::Query.where(hashed_sql: nil).find_each do |query|
86
+ query.update_column(:hashed_sql, Digest::MD5.hexdigest(query.normalized_sql))
87
+ end
88
+ end
89
+
90
+ # Handle potential duplicates (queries with same normalized SQL)
91
+ handle_duplicate_hashes
92
+ end
93
+
94
+ def handle_duplicate_hashes
95
+ # Group queries by hash and find duplicates
96
+ query_groups = RailsPulse::Query
97
+ .select(:hashed_sql)
98
+ .group(:hashed_sql)
99
+ .having("COUNT(*) > 1")
100
+ .pluck(:hashed_sql)
101
+
102
+ return if query_groups.empty?
103
+
104
+ say "Found #{query_groups.size} duplicate query groups. Merging...", :yellow
105
+
106
+ query_groups.each do |hash|
107
+ # Get all queries with this hash, ordered by creation time
108
+ queries = RailsPulse::Query.where(hashed_sql: hash).order(:created_at).to_a
109
+ keep_query = queries.first
110
+ duplicate_queries = queries[1..]
111
+
112
+ duplicate_queries.each do |dup_query|
113
+ # Count operations before merge
114
+ operations_count = RailsPulse::Operation.where(query_id: dup_query.id).count
115
+
116
+ if operations_count > 0
117
+ say " Merging #{operations_count} operations from query ##{dup_query.id} into ##{keep_query.id}"
118
+
119
+ # Reassign operations to the kept query
120
+ RailsPulse::Operation.where(query_id: dup_query.id).update_all(query_id: keep_query.id)
121
+ end
122
+
123
+ # Delete the duplicate query
124
+ dup_query.delete
125
+ end
126
+ end
127
+
128
+ say "Merged #{query_groups.size} duplicate query groups successfully.", :green
129
+ end
130
+
131
+ def has_long_queries?
132
+ # Check if any queries exceed 1000 characters
133
+ adapter = connection.adapter_name.downcase
134
+
135
+ if adapter.include?("postgres")
136
+ result = execute("SELECT EXISTS(SELECT 1 FROM rails_pulse_queries WHERE LENGTH(normalized_sql) > 1000)")
137
+ # Handle both Rails 7.2 and 8.0 result formats
138
+ result.first.is_a?(Hash) ? result.first["exists"] == "t" : result.first[0] == "t"
139
+ elsif adapter.include?("mysql")
140
+ result = execute("SELECT EXISTS(SELECT 1 FROM rails_pulse_queries WHERE LENGTH(normalized_sql) > 1000) as result")
141
+ # Handle both result formats
142
+ result.first.is_a?(Hash) ? result.first["result"] == 1 : result.first[0] == 1
143
+ else
144
+ # SQLite
145
+ result = execute("SELECT COUNT(*) as count FROM rails_pulse_queries WHERE LENGTH(normalized_sql) > 1000")
146
+ count = result.first.is_a?(Hash) ? result.first["count"] : result.first[0]
147
+ count.to_i > 0
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,14 @@
1
+ # Add index to rails_pulse_requests.request_uuid for efficient lookups
2
+ class AddIndexToRequestUuid < ActiveRecord::Migration[7.0]
3
+ def up
4
+ unless index_exists?(:rails_pulse_requests, :request_uuid)
5
+ add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid"
6
+ end
7
+ end
8
+
9
+ def down
10
+ if index_exists?(:rails_pulse_requests, :request_uuid)
11
+ remove_index :rails_pulse_requests, name: "index_rails_pulse_requests_on_request_uuid"
12
+ end
13
+ end
14
+ end