rails_pulse 0.2.4 → 0.2.5.pre.pre.3

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 +37 -9
  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
@@ -4,10 +4,14 @@ module RailsPulse
4
4
  :route_thresholds,
5
5
  :request_thresholds,
6
6
  :query_thresholds,
7
+ :job_thresholds,
7
8
  :ignored_routes,
8
9
  :ignored_requests,
9
10
  :ignored_queries,
11
+ :ignored_jobs,
12
+ :ignored_queues,
10
13
  :track_assets,
14
+ :track_jobs,
11
15
  :custom_asset_patterns,
12
16
  :mount_path,
13
17
  :full_retention_period,
@@ -17,32 +21,60 @@ module RailsPulse
17
21
  :authentication_enabled,
18
22
  :authentication_method,
19
23
  :authentication_redirect_path,
20
- :tags
24
+ :tags,
25
+ :job_tracking_mode,
26
+ :job_adapters,
27
+ :capture_job_arguments,
28
+ :mount_dashboard,
29
+ :logger,
30
+ :async
21
31
 
22
32
  def initialize
23
33
  @enabled = true
24
34
  @route_thresholds = { slow: 500, very_slow: 1500, critical: 3000 }
25
35
  @request_thresholds = { slow: 700, very_slow: 2000, critical: 4000 }
26
36
  @query_thresholds = { slow: 100, very_slow: 500, critical: 1000 }
37
+ @job_thresholds = { slow: 5_000, very_slow: 30_000, critical: 60_000 }
27
38
  @ignored_routes = []
28
39
  @ignored_requests = []
29
40
  @ignored_queries = []
41
+ @ignored_jobs = []
42
+ @ignored_queues = []
30
43
  @track_assets = false
44
+ @track_jobs = false
31
45
  @custom_asset_patterns = []
32
46
  @mount_path = nil
33
- @full_retention_period = 2.weeks
47
+ @full_retention_period = 30.days
34
48
  @archiving_enabled = true
35
49
  @max_table_records = {
36
- rails_pulse_requests: 10000,
37
- rails_pulse_operations: 50000,
38
- rails_pulse_routes: 1000,
39
- rails_pulse_queries: 500
50
+ rails_pulse_operations: 100_000,
51
+ rails_pulse_requests: 50_000,
52
+ rails_pulse_job_runs: 50_000,
53
+ rails_pulse_queries: 10_000,
54
+ rails_pulse_routes: 1_000,
55
+ rails_pulse_jobs: 1_000
40
56
  }
41
57
  @connects_to = nil
42
58
  @authentication_enabled = Rails.env.production?
43
59
  @authentication_method = nil
44
60
  @authentication_redirect_path = "/"
45
61
  @tags = [ "ignored", "critical", "experimental" ]
62
+ @job_tracking_mode = :universal
63
+ @job_adapters = {
64
+ sidekiq: { enabled: true, track_queue_depth: false },
65
+ solid_queue: { enabled: true, track_recurring: false },
66
+ good_job: { enabled: true, track_cron: false },
67
+ delayed_job: { enabled: true },
68
+ resque: { enabled: true }
69
+ }
70
+ @capture_job_arguments = false
71
+
72
+ # Dashboard settings
73
+ @mount_dashboard = true
74
+ @logger = nil
75
+
76
+ # Tracking mode settings
77
+ @async = true
46
78
 
47
79
  validate_configuration!
48
80
  end
@@ -67,6 +99,9 @@ module RailsPulse
67
99
  validate_database_settings!
68
100
  validate_authentication_settings!
69
101
  validate_tags!
102
+ validate_job_settings!
103
+ validate_dashboard_settings!
104
+ validate_tracking_settings!
70
105
  end
71
106
 
72
107
  # Revalidate configuration after changes
@@ -77,7 +112,7 @@ module RailsPulse
77
112
  private
78
113
 
79
114
  def validate_thresholds!
80
- [ @route_thresholds, @request_thresholds, @query_thresholds ].each do |thresholds|
115
+ [ @route_thresholds, @request_thresholds, @query_thresholds, @job_thresholds ].each do |thresholds|
81
116
  thresholds.each do |key, value|
82
117
  unless value.is_a?(Numeric) && value > 0
83
118
  raise ArgumentError, "Threshold #{key} must be a positive number, got #{value}"
@@ -150,6 +185,44 @@ module RailsPulse
150
185
  end
151
186
  end
152
187
 
188
+ def validate_job_settings!
189
+ unless @ignored_jobs.is_a?(Array) && @ignored_queues.is_a?(Array)
190
+ raise ArgumentError, "ignored_jobs and ignored_queues must be arrays"
191
+ end
192
+
193
+ unless [ true, false ].include?(@track_jobs)
194
+ raise ArgumentError, "track_jobs must be a boolean"
195
+ end
196
+
197
+ unless @job_adapters.is_a?(Hash)
198
+ raise ArgumentError, "job_adapters must be a hash"
199
+ end
200
+
201
+ unless @job_thresholds.is_a?(Hash)
202
+ raise ArgumentError, "job_thresholds must be a hash"
203
+ end
204
+
205
+ unless @job_tracking_mode.is_a?(Symbol)
206
+ raise ArgumentError, "job_tracking_mode must be a symbol"
207
+ end
208
+
209
+ unless [ true, false ].include?(@capture_job_arguments)
210
+ raise ArgumentError, "capture_job_arguments must be a boolean"
211
+ end
212
+ end
213
+
214
+ def validate_dashboard_settings!
215
+ unless [ true, false ].include?(@mount_dashboard)
216
+ raise ArgumentError, "mount_dashboard must be true or false, got #{@mount_dashboard}"
217
+ end
218
+ end
219
+
220
+ def validate_tracking_settings!
221
+ unless [ true, false ].include?(@async)
222
+ raise ArgumentError, "async must be true or false, got #{@async}"
223
+ end
224
+ end
225
+
153
226
  # Default patterns for common asset types and paths
154
227
  def default_asset_patterns
155
228
  [
@@ -2,12 +2,14 @@ require "rails_pulse/version"
2
2
  require "rails_pulse/middleware/request_collector"
3
3
  require "rails_pulse/middleware/asset_server"
4
4
  require "rails_pulse/subscribers/operation_subscriber"
5
+ require "rails_pulse/job_run_collector"
6
+ require "rails_pulse/active_job_extensions"
7
+ require "rails_pulse/extensions/active_record"
5
8
  require "request_store"
6
9
  require "rack/static"
7
10
  require "ransack"
8
11
  require "pagy"
9
12
  require "turbo-rails"
10
- require "groupdate"
11
13
 
12
14
  module RailsPulse
13
15
  class Engine < ::Rails::Engine
@@ -49,6 +51,30 @@ module RailsPulse
49
51
  # Ensure Ransack is loaded before our models
50
52
  end
51
53
 
54
+ initializer "rails_pulse.active_job" do
55
+ ActiveSupport.on_load(:active_job) do
56
+ include RailsPulse::ActiveJobExtensions
57
+ end
58
+ end
59
+
60
+ initializer "rails_pulse.configure_sidekiq", after: "rails_pulse.active_job" do
61
+ if defined?(Sidekiq) && RailsPulse.configuration.job_adapters.dig(:sidekiq, :enabled)
62
+ require "rails_pulse/adapters/sidekiq_middleware"
63
+ Sidekiq.configure_server do |config|
64
+ config.server_middleware do |chain|
65
+ chain.add RailsPulse::Adapters::SidekiqMiddleware
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ initializer "rails_pulse.configure_delayed_job", after: "rails_pulse.active_job" do
72
+ if defined?(Delayed::Job) && RailsPulse.configuration.job_adapters.dig(:delayed_job, :enabled)
73
+ require "rails_pulse/adapters/delayed_job_plugin"
74
+ Delayed::Worker.plugins << RailsPulse::Adapters::DelayedJobPlugin
75
+ end
76
+ end
77
+
52
78
  initializer "rails_pulse.database_configuration", before: "active_record.initialize_timezone" do
53
79
  # Ensure database configuration is applied early in the initialization process
54
80
  # This allows models to properly connect to configured databases
@@ -56,9 +82,14 @@ module RailsPulse
56
82
 
57
83
  initializer "rails_pulse.timezone" do
58
84
  # Configure Rails Pulse to always use UTC for consistent time operations
59
- # This prevents Groupdate timezone mismatch errors across different host applications
60
85
  # Note: We don't set Time.zone_default as it would affect the entire application
61
- # Instead, we explicitly use time_zone: "UTC" in all groupdate calls
86
+ # Our custom group_by_date extension works regardless of ActiveRecord.default_timezone
87
+ end
88
+
89
+ initializer "rails_pulse.configure_logger", before: :initialize_logger do
90
+ RailsPulse.configure do |config|
91
+ config.logger ||= Rails.logger
92
+ end
62
93
  end
63
94
 
64
95
  initializer "rails_pulse.disable_turbo" do
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPulse
4
+ module Extensions
5
+ module ActiveRecord
6
+ # Extends ActiveRecord::Relation with database-agnostic date grouping
7
+ # This is a replacement for Groupdate that works regardless of ActiveRecord.default_timezone
8
+ module QueryMethods
9
+ # Groups records by date extracted from a timestamp column
10
+ # Works across PostgreSQL, MySQL, and SQLite
11
+ #
12
+ # @param column [Symbol, String] the timestamp column to group by (default: :period_start)
13
+ # @return [ActiveRecord::Relation] relation with DATE grouping applied
14
+ #
15
+ # @example Group summaries by date
16
+ # RailsPulse::Summary.where(...).group_by_date(:period_start).sum(:count)
17
+ # # => { Date(2024-01-01) => 100, Date(2024-01-02) => 150, ... }
18
+ #
19
+ # @example Group by different column
20
+ # Model.group_by_date(:created_at).count
21
+ #
22
+ def group_by_date(column = :period_start)
23
+ group(Arel.sql(date_sql(column.to_s))).extending(DateResultTransformer)
24
+ end
25
+
26
+ private
27
+
28
+ # Returns database-specific SQL for extracting date from timestamp
29
+ def date_sql(column)
30
+ adapter = connection.adapter_name.downcase
31
+
32
+ case adapter
33
+ when "postgresql"
34
+ "DATE(#{column})"
35
+ when "mysql", "mysql2"
36
+ "DATE(#{column})"
37
+ when "sqlite"
38
+ "DATE(#{column})"
39
+ else
40
+ # Fallback for unknown adapters
41
+ "DATE(#{column})"
42
+ end
43
+ end
44
+ end
45
+
46
+ # Module to transform aggregation result keys from strings to Date objects
47
+ # This makes the API match Groupdate's behavior
48
+ module DateResultTransformer
49
+ def sum(*args)
50
+ super.transform_keys { |date_str| Date.parse(date_str.to_s) }
51
+ end
52
+
53
+ def count(*args)
54
+ result = super
55
+ # count can return an integer or a hash depending on whether group is used
56
+ result.is_a?(Hash) ? result.transform_keys { |date_str| Date.parse(date_str.to_s) } : result
57
+ end
58
+
59
+ def average(*args)
60
+ super.transform_keys { |date_str| Date.parse(date_str.to_s) }
61
+ end
62
+
63
+ def maximum(*args)
64
+ super.transform_keys { |date_str| Date.parse(date_str.to_s) }
65
+ end
66
+
67
+ def minimum(*args)
68
+ super.transform_keys { |date_str| Date.parse(date_str.to_s) }
69
+ end
70
+
71
+ def pluck(*args)
72
+ result = super
73
+ # If grouping, transform the keys
74
+ result.is_a?(Hash) ? result.transform_keys { |date_str| Date.parse(date_str.to_s) } : result
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ # Extend ActiveRecord::Relation with our date grouping methods
82
+ ActiveRecord::Relation.include(RailsPulse::Extensions::ActiveRecord::QueryMethods)
@@ -0,0 +1,172 @@
1
+ require "securerandom"
2
+
3
+ module RailsPulse
4
+ class JobRunCollector
5
+ class << self
6
+ def track(active_job, adapter: detect_adapter)
7
+ return yield unless tracking_enabled?
8
+ return yield if ignore_job?(active_job)
9
+
10
+ previous_request_id = RequestStore.store[:rails_pulse_request_id]
11
+ previous_operations = RequestStore.store[:rails_pulse_operations]
12
+ previous_job_run_id = RequestStore.store[:rails_pulse_job_run_id]
13
+
14
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
+ occurred_at = Time.current
16
+
17
+ job = nil
18
+ job_run = nil
19
+
20
+ with_recording_suppressed do
21
+ job = find_or_create_job(active_job)
22
+ job_run = create_job_run(job, active_job, adapter, occurred_at)
23
+ end
24
+
25
+ RequestStore.store[:rails_pulse_request_id] = nil
26
+ RequestStore.store[:rails_pulse_job_run_id] = job_run.id
27
+ RequestStore.store[:rails_pulse_operations] = []
28
+
29
+ yield
30
+
31
+ duration = elapsed_time_ms(start_time)
32
+ with_recording_suppressed do
33
+ job_run.update!(status: "success", duration: duration)
34
+ end
35
+ rescue => error
36
+ duration = elapsed_time_ms(start_time)
37
+ with_recording_suppressed do
38
+ job_run.update!(
39
+ status: failure_status_for(error),
40
+ duration: duration,
41
+ error_class: error.class.name,
42
+ error_message: error.message
43
+ ) if job_run
44
+ end
45
+ raise
46
+ ensure
47
+ begin
48
+ save_operations(job_run)
49
+ rescue => e
50
+ Rails.logger.error "[RailsPulse] Failed to persist job operations: #{e.class} - #{e.message}"
51
+ ensure
52
+ RequestStore.store[:rails_pulse_job_run_id] = previous_job_run_id
53
+ RequestStore.store[:rails_pulse_operations] = previous_operations
54
+ RequestStore.store[:rails_pulse_request_id] = previous_request_id
55
+ end
56
+ end
57
+
58
+ def should_ignore_job?(job)
59
+ ignore_job?(job)
60
+ end
61
+
62
+ private
63
+
64
+ def tracking_enabled?
65
+ config = RailsPulse.configuration
66
+ config.enabled && config.track_jobs
67
+ end
68
+
69
+ def ignore_job?(job)
70
+ config = RailsPulse.configuration
71
+ job_class = job.class.name
72
+ queue_name = job.queue_name
73
+
74
+ return true if config.ignored_jobs&.include?(job_class)
75
+ return true if config.ignored_queues&.include?(queue_name)
76
+ return true if job_class.start_with?("RailsPulse::")
77
+
78
+ false
79
+ end
80
+
81
+ def find_or_create_job(active_job)
82
+ RailsPulse::Job.find_or_create_by!(name: active_job.class.name) do |job|
83
+ job.queue_name = active_job.queue_name
84
+ end
85
+ end
86
+
87
+ def create_job_run(job, active_job, adapter, occurred_at)
88
+ RailsPulse::JobRun.create!(
89
+ job: job,
90
+ run_id: active_job.job_id || SecureRandom.uuid,
91
+ status: initial_status_for(active_job),
92
+ enqueued_at: safe_timestamp(active_job.try(:enqueued_at)),
93
+ occurred_at: occurred_at,
94
+ attempts: (active_job.respond_to?(:executions) ? active_job.executions : 0),
95
+ adapter: adapter,
96
+ arguments: serialized_arguments(active_job)
97
+ )
98
+ end
99
+
100
+ def serialized_arguments(active_job)
101
+ return unless RailsPulse.configuration.capture_job_arguments
102
+
103
+ Array(active_job.arguments).to_json
104
+ rescue StandardError => e
105
+ Rails.logger.debug "[RailsPulse] Unable to serialize job arguments: #{e.class} - #{e.message}"
106
+ nil
107
+ end
108
+
109
+ def initial_status_for(active_job)
110
+ active_job.respond_to?(:scheduled_at) ? "enqueued" : "running"
111
+ end
112
+
113
+ def failure_status_for(error)
114
+ error.is_a?(StandardError) ? "failed" : "discarded"
115
+ end
116
+
117
+ def save_operations(job_run)
118
+ return unless job_run
119
+
120
+ operations_data = RequestStore.store[:rails_pulse_operations] || []
121
+ operations_data.each do |operation_data|
122
+ operation_data[:job_run_id] = job_run.id
123
+ operation_data[:request_id] = nil
124
+
125
+ with_recording_suppressed do
126
+ RailsPulse::Operation.create!(operation_data)
127
+ end
128
+ rescue => e
129
+ Rails.logger.error "[RailsPulse] Failed to save job operation: #{e.class} - #{e.message}"
130
+ end
131
+ ensure
132
+ RequestStore.store[:rails_pulse_operations] = nil
133
+ end
134
+
135
+ def detect_adapter
136
+ return "sidekiq" if defined?(::Sidekiq)
137
+ return "solid_queue" if defined?(::SolidQueue)
138
+ return "good_job" if defined?(::GoodJob)
139
+ return "delayed_job" if defined?(::Delayed::Job)
140
+ return "resque" if defined?(::Resque)
141
+ return "que" if defined?(::Que)
142
+
143
+ "active_job"
144
+ end
145
+
146
+ def elapsed_time_ms(start_time)
147
+ return 0.0 unless start_time
148
+
149
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
150
+ end
151
+
152
+ def safe_timestamp(value)
153
+ case value
154
+ when Time, ActiveSupport::TimeWithZone
155
+ value
156
+ when Integer
157
+ Time.at(value)
158
+ else
159
+ nil
160
+ end
161
+ end
162
+
163
+ def with_recording_suppressed
164
+ previous = RequestStore.store[:skip_recording_rails_pulse_activity]
165
+ RequestStore.store[:skip_recording_rails_pulse_activity] = true
166
+ yield
167
+ ensure
168
+ RequestStore.store[:skip_recording_rails_pulse_activity] = previous
169
+ end
170
+ end
171
+ end
172
+ end
@@ -31,52 +31,35 @@ module RailsPulse
31
31
  return result
32
32
  end
33
33
 
34
- # Clear any previous request ID to avoid conflicts
34
+ # Clear any previous request data
35
35
  RequestStore.store[:rails_pulse_request_id] = nil
36
+ RequestStore.store[:rails_pulse_operations] = []
36
37
 
37
38
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
38
-
39
- # Temporarily skip recording while we create the route and request
40
- RequestStore.store[:skip_recording_rails_pulse_activity] = true
41
- route = find_or_create_route(req)
42
39
  controller_action = "#{env['action_dispatch.request.parameters']&.[]('controller')&.classify}##{env['action_dispatch.request.parameters']&.[]('action')}"
43
40
  occurred_at = Time.current
44
41
 
45
- request = nil
46
- if route
47
- request = RailsPulse::Request.create!(
48
- route: route,
49
- duration: 0, # will update after response
50
- status: 0, # will update after response
51
- is_error: false,
52
- request_uuid: req.uuid,
53
- controller_action: controller_action,
54
- occurred_at: occurred_at
55
- )
56
- RequestStore.store[:rails_pulse_request_id] = request.id
57
- end
58
-
59
- # Re-enable recording for the actual request processing
60
- RequestStore.store[:skip_recording_rails_pulse_activity] = false
61
-
42
+ # Process request
62
43
  status, headers, response = @app.call(env)
63
44
  duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
64
45
 
65
- # Temporarily skip recording while we update the request and save operations
66
- RequestStore.store[:skip_recording_rails_pulse_activity] = true
67
- if request
68
- request.update(duration: duration, status: status, is_error: status.to_i >= 500)
69
-
70
- # Save collected operations
71
- operations_data = RequestStore.store[:rails_pulse_operations] || []
72
- operations_data.each do |operation_data|
73
- begin
74
- RailsPulse::Operation.create!(operation_data)
75
- rescue => e
76
- Rails.logger.error "[RailsPulse] Failed to save operation: #{e.message}"
77
- end
78
- end
79
- end
46
+ # Collect all tracking data
47
+ # Deep copy operations array to prevent race condition in async mode
48
+ operations = RequestStore.store[:rails_pulse_operations] || []
49
+ tracking_data = {
50
+ method: req.request_method,
51
+ path: req.path,
52
+ duration: duration,
53
+ status: status,
54
+ is_error: status.to_i >= 500,
55
+ request_uuid: req.uuid,
56
+ controller_action: controller_action,
57
+ occurred_at: occurred_at,
58
+ operations: operations.map(&:dup)
59
+ }
60
+
61
+ # Send to tracker (non-blocking if async mode enabled)
62
+ RailsPulse::Tracker.track_request(tracking_data)
80
63
 
81
64
  [ status, headers, response ]
82
65
  ensure
@@ -87,12 +70,6 @@ module RailsPulse
87
70
 
88
71
  private
89
72
 
90
- def find_or_create_route(req)
91
- method = req.request_method
92
- path = req.path
93
- RailsPulse::Route.find_or_create_by(method: method, path: path)
94
- end
95
-
96
73
  def should_ignore_route?(req)
97
74
  # Get ignored routes from configuration
98
75
  ignored_routes = RailsPulse.configuration.ignored_routes || []
@@ -59,7 +59,8 @@ module RailsPulse
59
59
  return if RequestStore.store[:skip_recording_rails_pulse_activity]
60
60
 
61
61
  request_id = RequestStore.store[:rails_pulse_request_id]
62
- return unless request_id
62
+ job_run_id = RequestStore.store[:rails_pulse_job_run_id]
63
+ return unless request_id || job_run_id
63
64
 
64
65
  # Skip RailsPulse-related operations to prevent recursion
65
66
  if operation_type == "sql"
@@ -91,6 +92,7 @@ module RailsPulse
91
92
 
92
93
  operation_data = {
93
94
  request_id: request_id,
95
+ job_run_id: job_run_id,
94
96
  operation_type: operation_type,
95
97
  label: label,
96
98
  duration: (finish - start) * 1000,
@@ -174,6 +176,7 @@ module RailsPulse
174
176
  codebase_location = find_app_frame || caller_locations(2, 1).first&.path
175
177
  operation_data = {
176
178
  request_id: RequestStore.store[:rails_pulse_request_id],
179
+ job_run_id: RequestStore.store[:rails_pulse_job_run_id],
177
180
  operation_type: "http",
178
181
  label: label,
179
182
  duration: (finish - start) * 1000,
@@ -182,7 +185,7 @@ module RailsPulse
182
185
  occurred_at: Time.zone.at(start)
183
186
  }
184
187
 
185
- if operation_data[:request_id]
188
+ if operation_data[:request_id] || operation_data[:job_run_id]
186
189
  RequestStore.store[:rails_pulse_operations] ||= []
187
190
  RequestStore.store[:rails_pulse_operations] << operation_data
188
191
  end
@@ -199,6 +202,7 @@ module RailsPulse
199
202
  codebase_location = find_app_frame || caller_locations(2, 1).first&.path
200
203
  operation_data = {
201
204
  request_id: RequestStore.store[:rails_pulse_request_id],
205
+ job_run_id: RequestStore.store[:rails_pulse_job_run_id],
202
206
  operation_type: "job",
203
207
  label: label,
204
208
  duration: (finish - start) * 1000,
@@ -207,7 +211,7 @@ module RailsPulse
207
211
  occurred_at: Time.zone.at(start)
208
212
  }
209
213
 
210
- if operation_data[:request_id]
214
+ if operation_data[:request_id] || operation_data[:job_run_id]
211
215
  RequestStore.store[:rails_pulse_operations] ||= []
212
216
  RequestStore.store[:rails_pulse_operations] << operation_data
213
217
  end
@@ -233,6 +237,7 @@ module RailsPulse
233
237
  codebase_location = find_app_frame || caller_locations(2, 1).first&.path
234
238
  operation_data = {
235
239
  request_id: RequestStore.store[:rails_pulse_request_id],
240
+ job_run_id: RequestStore.store[:rails_pulse_job_run_id],
236
241
  operation_type: "mailer",
237
242
  label: label,
238
243
  duration: (finish - start) * 1000,
@@ -241,7 +246,7 @@ module RailsPulse
241
246
  occurred_at: Time.zone.at(start)
242
247
  }
243
248
 
244
- if operation_data[:request_id]
249
+ if operation_data[:request_id] || operation_data[:job_run_id]
245
250
  RequestStore.store[:rails_pulse_operations] ||= []
246
251
  RequestStore.store[:rails_pulse_operations] << operation_data
247
252
  end
@@ -258,6 +263,7 @@ module RailsPulse
258
263
  codebase_location = find_app_frame || caller_locations(2, 1).first&.path
259
264
  operation_data = {
260
265
  request_id: RequestStore.store[:rails_pulse_request_id],
266
+ job_run_id: RequestStore.store[:rails_pulse_job_run_id],
261
267
  operation_type: "storage",
262
268
  label: label,
263
269
  duration: (finish - start) * 1000,
@@ -266,7 +272,7 @@ module RailsPulse
266
272
  occurred_at: Time.zone.at(start)
267
273
  }
268
274
 
269
- if operation_data[:request_id]
275
+ if operation_data[:request_id] || operation_data[:job_run_id]
270
276
  RequestStore.store[:rails_pulse_operations] ||= []
271
277
  RequestStore.store[:rails_pulse_operations] << operation_data
272
278
  end