rails_pulse 0.4.0.pre.4 → 0.4.0

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -87
  3. data/README.md +83 -111
  4. data/app/controllers/concerns/session_filters_concern.rb +4 -3
  5. data/app/controllers/concerns/time_range_concern.rb +29 -19
  6. data/app/controllers/rails_pulse/application_controller.rb +9 -5
  7. data/app/controllers/rails_pulse/dashboard_controller.rb +20 -11
  8. data/app/controllers/rails_pulse/routes_controller.rb +46 -0
  9. data/app/helpers/rails_pulse/application_helper.rb +4 -0
  10. data/app/javascript/rails_pulse/controllers/chart_controller.js +27 -0
  11. data/app/models/rails_pulse/cards/base.rb +61 -25
  12. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +38 -15
  13. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +36 -13
  14. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +3 -0
  15. data/app/models/rails_pulse/dashboard/health_summary.rb +3 -1
  16. data/app/models/rails_pulse/dashboard/needs_attention.rb +3 -1
  17. data/app/models/rails_pulse/dashboard/storage_status.rb +38 -68
  18. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +1 -5
  19. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +4 -4
  20. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -4
  21. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +7 -20
  22. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +5 -8
  23. data/app/models/rails_pulse/jobs/cards/total_runs.rb +4 -4
  24. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -9
  25. data/app/models/rails_pulse/queries/cards/database_load.rb +7 -10
  26. data/app/models/rails_pulse/queries/cards/execution_rate.rb +4 -6
  27. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +5 -8
  28. data/app/models/rails_pulse/route.rb +7 -1
  29. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -11
  30. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +3 -1
  31. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +4 -6
  32. data/app/models/rails_pulse/summary.rb +13 -18
  33. data/app/models/rails_pulse/time_range_preference.rb +28 -0
  34. data/app/models/rails_pulse/time_window.rb +61 -0
  35. data/app/services/rails_pulse/summary_service.rb +112 -162
  36. data/app/services/rails_pulse/tag_filter_service.rb +10 -1
  37. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +2 -2
  38. data/app/views/rails_pulse/dashboard/index.html.erb +6 -3
  39. data/app/views/rails_pulse/routes/_archived_summary_table.html.erb +46 -0
  40. data/app/views/rails_pulse/routes/_archived_table_pagination.html.erb +28 -0
  41. data/app/views/rails_pulse/routes/show.html.erb +14 -2
  42. data/lib/generators/rails_pulse/install_generator.rb +5 -2
  43. data/lib/generators/rails_pulse/templates/rails_pulse.rb +7 -2
  44. data/lib/rails_pulse/cleanup_service.rb +7 -7
  45. data/lib/rails_pulse/configuration.rb +9 -1
  46. data/lib/rails_pulse/engine.rb +14 -42
  47. data/lib/rails_pulse/middleware/request_collector.rb +2 -17
  48. data/lib/rails_pulse/standalone.rb +12 -0
  49. data/lib/rails_pulse/subscribers/operation_subscriber.rb +45 -3
  50. data/lib/rails_pulse/tracker.rb +241 -25
  51. data/lib/rails_pulse/version.rb +1 -1
  52. data/lib/rails_pulse.rb +3 -16
  53. data/lib/rails_pulse_server.ru +37 -23
  54. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  55. metadata +7 -9
  56. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  57. data/app/assets/images/rails_pulse/query-show.png +0 -0
  58. data/app/assets/images/rails_pulse/request-show.png +0 -0
  59. data/app/assets/images/rails_pulse/request.png +0 -0
  60. data/app/assets/images/rails_pulse/route-show.png +0 -0
  61. data/lib/rails_pulse/extensions/active_record.rb +0 -124
@@ -7,50 +7,11 @@ require "rails_pulse/subscribers/operation_subscriber"
7
7
  require "rails_pulse/subscribers/exception_subscriber"
8
8
  require "rails_pulse/job_run_collector"
9
9
  require "rails_pulse/active_job_extensions"
10
- require "rails_pulse/extensions/active_record"
11
10
  require "request_store"
12
11
  require "rack/static"
13
12
  require "ransack"
14
13
 
15
14
  module RailsPulse
16
- # Manually load services to avoid Zeitwerk autoload issues
17
- autoload :SqlQueryNormalizer, File.expand_path("../../app/services/rails_pulse/sql_query_normalizer", __dir__)
18
- autoload :SummaryService, File.expand_path("../../app/services/rails_pulse/summary_service", __dir__)
19
- autoload :QueryAnalysisService, File.expand_path("../../app/services/rails_pulse/query_analysis_service", __dir__)
20
- autoload :TagFilterService, File.expand_path("../../app/services/rails_pulse/tag_filter_service", __dir__)
21
- autoload :ExceptionCaptureService, File.expand_path("../../app/services/rails_pulse/exception_capture_service", __dir__)
22
- autoload :ExceptionMessageSanitizer, File.expand_path("../../app/services/rails_pulse/exception_message_sanitizer", __dir__)
23
- autoload :OperationSuggestions, File.expand_path("../../app/services/rails_pulse/operation_suggestions", __dir__)
24
- autoload :RoutePathNormalizer, File.expand_path("../../app/services/rails_pulse/route_path_normalizer", __dir__)
25
- autoload :RouteRecognizer, File.expand_path("../../app/services/rails_pulse/route_recognizer", __dir__)
26
- autoload :RouteMerger, File.expand_path("../../app/services/rails_pulse/route_merger", __dir__)
27
- autoload :RouteMigrator, File.expand_path("../../app/services/rails_pulse/route_migrator", __dir__)
28
- autoload :RouteControllerActionBackfiller, File.expand_path("../../app/services/rails_pulse/route_controller_action_backfiller", __dir__)
29
-
30
- # Historical comparison operations. This namespace is the read-only interface
31
- # for "what changed?" — everything else in the codebase, and anything built on
32
- # top of Rails Pulse later, should ask these rather than querying summaries
33
- # directly.
34
- module Operations
35
- autoload :ChangePoint, File.expand_path("../../app/services/rails_pulse/operations/change_point", __dir__)
36
- autoload :Compare, File.expand_path("../../app/services/rails_pulse/operations/compare", __dir__)
37
- autoload :Comparison, File.expand_path("../../app/services/rails_pulse/operations/comparison", __dir__)
38
- autoload :Metric, File.expand_path("../../app/services/rails_pulse/operations/metric", __dir__)
39
- autoload :Series, File.expand_path("../../app/services/rails_pulse/operations/series", __dir__)
40
- autoload :Subject, File.expand_path("../../app/services/rails_pulse/operations/subject", __dir__)
41
- end
42
-
43
- # Analysis services
44
- module Analysis
45
- autoload :BacktraceAnalyzer, File.expand_path("../../app/services/rails_pulse/analysis/backtrace_analyzer", __dir__)
46
- autoload :BaseAnalyzer, File.expand_path("../../app/services/rails_pulse/analysis/base_analyzer", __dir__)
47
- autoload :ExplainPlanAnalyzer, File.expand_path("../../app/services/rails_pulse/analysis/explain_plan_analyzer", __dir__)
48
- autoload :IndexRecommendationEngine, File.expand_path("../../app/services/rails_pulse/analysis/index_recommendation_engine", __dir__)
49
- autoload :NPlusOneDetector, File.expand_path("../../app/services/rails_pulse/analysis/n_plus_one_detector", __dir__)
50
- autoload :QueryCharacteristicsAnalyzer, File.expand_path("../../app/services/rails_pulse/analysis/query_characteristics_analyzer", __dir__)
51
- autoload :SuggestionGenerator, File.expand_path("../../app/services/rails_pulse/analysis/suggestion_generator", __dir__)
52
- end
53
-
54
15
  # Installer services
55
16
  module Installers
56
17
  autoload :MigrationInstaller, File.expand_path("installers/migration_installer", __dir__)
@@ -72,9 +33,20 @@ module RailsPulse
72
33
  class Engine < ::Rails::Engine
73
34
  isolate_namespace RailsPulse
74
35
 
75
- # Tell Zeitwerk to ignore services directory since we manually autoload them
76
- initializer "rails_pulse.ignore_services", before: :set_autoload_paths do
77
- Rails.autoloaders.main.ignore(root.join("app/services")) if Rails.respond_to?(:autoloaders)
36
+ # Zeitwerk derives constant names from file names through the host's
37
+ # inflections. A host that declares `inflect.acronym "SQL"` (or "CSP")
38
+ # would expect sql_query_normalizer.rb to define SQLQueryNormalizer and
39
+ # fail to eager load in production. Pin the names of the files whose
40
+ # basenames contain a common acronym so the gem's constants do not depend
41
+ # on the host's inflections.
42
+ ACRONYM_SAFE_INFLECTIONS = {
43
+ "sql_query_normalizer" => "SqlQueryNormalizer",
44
+ "csp_helper" => "CspHelper",
45
+ "csp_test_controller" => "CspTestController"
46
+ }.freeze
47
+
48
+ initializer "rails_pulse.inflections", before: :set_autoload_paths do
49
+ Rails.autoloaders.main.inflector.inflect(ACRONYM_SAFE_INFLECTIONS) if Rails.respond_to?(:autoloaders)
78
50
  end
79
51
 
80
52
  # Load Rake tasks
@@ -36,10 +36,9 @@ module RailsPulse
36
36
  status, headers, response = @app.call(env)
37
37
  duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
38
38
 
39
- # Collect all tracking data
40
- # Deep copy operations array to prevent race condition in async mode
39
+ # Collect all tracking data. Operations are copied because the writer
40
+ # thread annotates them (N+1 detection) after this request has moved on.
41
41
  operations = RequestStore.store[:rails_pulse_operations] || []
42
- detect_n_plus_one(operations)
43
42
  path_params = env["action_dispatch.request.path_parameters"] || {}
44
43
  controller_action = [ path_params[:controller], path_params[:action] ].compact.join("#").presence
45
44
  tracking_data = {
@@ -86,20 +85,6 @@ module RailsPulse
86
85
  nil
87
86
  end
88
87
 
89
- def detect_n_plus_one(operations)
90
- sql_ops = operations.select { |op| op[:operation_type] == "sql" }
91
- return if sql_ops.size < 2
92
-
93
- groups = sql_ops.group_by { |op| RailsPulse::SqlQueryNormalizer.normalize(op[:actual_sql].to_s) }
94
- groups.each do |normalized_sql, ops|
95
- next if ops.size < 2
96
- ops.each do |op|
97
- op[:repeated_query_group] = normalized_sql
98
- op[:repetition_count] = ops.size
99
- end
100
- end
101
- end
102
-
103
88
  def should_ignore_route?(req)
104
89
  # Get ignored routes from configuration
105
90
  ignored_routes = RailsPulse.configuration.ignored_routes || []
@@ -43,6 +43,18 @@ module RailsPulse
43
43
  # prepended override stays but defers to the mount prefix again.
44
44
  def exit_standalone!
45
45
  @standalone = false
46
+ @host_authentication_ignored_noted = false
47
+ end
48
+
49
+ # ApplicationController logs a one-line notice when the standalone
50
+ # dashboard ignores the host's authentication_method / authorize hooks.
51
+ # Returns true the first time it is called in this process (log now),
52
+ # false afterwards. Process-wide on purpose: a per-controller flag
53
+ # repeats the notice for every engine controller a visitor reaches.
54
+ def note_host_authentication_ignored!
55
+ return false if @host_authentication_ignored_noted
56
+
57
+ @host_authentication_ignored_noted = true
46
58
  end
47
59
  end
48
60
 
@@ -1,6 +1,8 @@
1
1
  module RailsPulse
2
2
  module Subscribers
3
3
  class OperationSubscriber
4
+ FRAME_SLICE = 40
5
+
4
6
  class << self
5
7
  def subscribe!
6
8
  subscribe_sql_queries!
@@ -40,10 +42,30 @@ module RailsPulse
40
42
  @app_path ||= Rails.root.join("app").to_s
41
43
  end
42
44
 
45
+ # The first frame under the host's app/ directory, or nil.
46
+ #
47
+ # This runs for every SQL, template, cache and HTTP event, so it must
48
+ # not materialise the whole stack: a bare caller_locations on a
49
+ # typical 150-frame controller action costs ~30 µs per call, which is
50
+ # ~15 ms on a 500-query request before the response goes out.
51
+ # Thread.each_caller_location (Ruby 3.2+) yields frames lazily and
52
+ # stops at the first match; older Rubies walk the stack in slices.
43
53
  def find_app_frame
44
- caller_locations.each do |loc|
45
- path = loc.absolute_path || loc.path
46
- return path if path && path.start_with?(app_path)
54
+ if Thread.respond_to?(:each_caller_location)
55
+ Thread.each_caller_location do |loc|
56
+ path = loc.path
57
+ return path if path&.start_with?(app_path)
58
+ end
59
+ return nil
60
+ end
61
+
62
+ offset = 2
63
+ while (frames = caller_locations(offset, FRAME_SLICE)) && !frames.empty?
64
+ frames.each do |loc|
65
+ path = loc.path
66
+ return path if path&.start_with?(app_path)
67
+ end
68
+ offset += FRAME_SLICE
47
69
  end
48
70
  nil
49
71
  end
@@ -133,6 +155,7 @@ module RailsPulse
133
155
 
134
156
  def store_operation(label:, operation_type:, start:, finish:, codebase_location:)
135
157
  return unless RailsPulse.configuration.enabled
158
+ return if RequestStore.store[:skip_recording_rails_pulse_activity]
136
159
 
137
160
  request_id = RequestStore.store[:rails_pulse_request_id]
138
161
  job_run_id = RequestStore.store[:rails_pulse_job_run_id]
@@ -157,6 +180,10 @@ module RailsPulse
157
180
  ActiveSupport::Notifications.subscribe "sql.active_record" do |name, start, finish, id, payload|
158
181
  begin
159
182
  next if payload[:name] == "SCHEMA"
183
+ # Cached reads never hit the database, so there's nothing to time or
184
+ # analyze for N+1s — skip before the stack walk in capture_operation.
185
+ next if payload[:cached]
186
+ next if query_ignored?(payload[:sql])
160
187
  capture_operation(name, start, finish, payload, "sql", :sql, extra: { row_count: payload[:row_count] })
161
188
  rescue => e
162
189
  RailsPulse.logger.error "Exception in SQL subscriber: #{e.class} - #{e.message}"
@@ -164,6 +191,21 @@ module RailsPulse
164
191
  end
165
192
  end
166
193
 
194
+ def query_ignored?(sql)
195
+ return false if sql.nil?
196
+
197
+ RailsPulse.configuration.ignored_queries.any? do |pattern|
198
+ case pattern
199
+ when String
200
+ pattern == sql
201
+ when Regexp
202
+ pattern.match?(sql)
203
+ else
204
+ false
205
+ end
206
+ end
207
+ end
208
+
167
209
  def subscribe_controller_actions!
168
210
  ActiveSupport::Notifications.subscribe "process_action.action_controller" do |name, start, finish, id, payload|
169
211
  begin
@@ -1,4 +1,17 @@
1
1
  module RailsPulse
2
+ # Persists the data RequestCollector gathers for one request: the route,
3
+ # the request row and its operations.
4
+ #
5
+ # With `config.async` (the default) the middleware hands the payload to a
6
+ # single per-process Writer thread through a bounded queue and returns
7
+ # immediately. The writer drains the queue in batches on one connection
8
+ # checked out of the Rails Pulse pool, so tracking never holds more than one
9
+ # of the host's connections and a traffic burst never fans out into a thread
10
+ # per request. When the queue is full the payload is dropped and counted;
11
+ # losing a sample is the intended trade for never slowing the host down.
12
+ #
13
+ # With `config.async = false`, or whenever the pool's connection is pinned
14
+ # to the current thread (transactional tests), writes happen inline.
2
15
  module Tracker
3
16
  # PG::Connection::PQTRANS_INERROR (libpq enum value 3) — the connection's transaction
4
17
  # was aborted by a DB-level error and no ROLLBACK has been issued yet. Defined as a
@@ -7,22 +20,163 @@ module RailsPulse
7
20
  PG_TRANSACTION_INERROR = 3
8
21
  private_constant :PG_TRANSACTION_INERROR
9
22
 
23
+ # The background writer: one thread, one bounded queue, one connection.
24
+ class Writer
25
+ BATCH_SIZE = 50
26
+ DROP_LOG_INTERVAL = 60 # seconds between "queue full" log lines
27
+ SHUTDOWN_TIMEOUT = 5 # seconds to wait for the queue to drain at exit
28
+
29
+ attr_reader :queue_size, :dropped
30
+
31
+ def initialize(queue_size:, auto_start: true)
32
+ @queue_size = queue_size
33
+ @auto_start = auto_start
34
+ @mutex = Mutex.new
35
+ @queue = SizedQueue.new(queue_size)
36
+ @thread = nil
37
+ @pid = Process.pid
38
+ @dropped = 0
39
+ @drop_window_count = 0
40
+ @drop_window_started = nil
41
+ @exit_hook_installed = false
42
+ end
43
+
44
+ # Never blocks. Returns false (and counts a drop) when the queue is full.
45
+ def enqueue(data)
46
+ ensure_thread!
47
+ @queue.push(data, true)
48
+ true
49
+ rescue ThreadError, ClosedQueueError
50
+ record_drop
51
+ false
52
+ end
53
+
54
+ def size
55
+ @queue.size
56
+ end
57
+
58
+ def running?
59
+ @thread&.alive? || false
60
+ end
61
+
62
+ # Persist everything queued so far on the calling thread. Used by
63
+ # shutdown when the writer thread is gone, and by tests.
64
+ def drain
65
+ batch = []
66
+ batch << @queue.pop(true) while !@queue.empty?
67
+ Tracker.perform_tracking_batch(batch) unless batch.empty?
68
+ batch.size
69
+ rescue ThreadError
70
+ batch.size
71
+ end
72
+
73
+ # Stop accepting work, let the thread finish what is queued (up to
74
+ # `timeout` seconds), then persist any remainder inline. Safe to call
75
+ # more than once; the next enqueue starts a fresh queue and thread.
76
+ def shutdown(timeout: SHUTDOWN_TIMEOUT)
77
+ thread = nil
78
+ @mutex.synchronize do
79
+ @queue.close
80
+ thread = @thread
81
+ @thread = nil
82
+ end
83
+ thread.join(timeout) if thread&.alive?
84
+ thread.kill if thread&.alive?
85
+ drain
86
+ end
87
+
88
+ private
89
+
90
+ def ensure_thread!
91
+ return unless @auto_start
92
+ return if @thread&.alive? && @pid == Process.pid
93
+
94
+ @mutex.synchronize do
95
+ # A forked child inherits the parent's queue contents and a thread
96
+ # that no longer exists. Start clean; the parent still owns its data.
97
+ if @pid != Process.pid
98
+ @pid = Process.pid
99
+ @queue = SizedQueue.new(@queue_size)
100
+ elsif @queue.closed?
101
+ @queue = SizedQueue.new(@queue_size)
102
+ end
103
+
104
+ next if @thread&.alive?
105
+
106
+ @thread = Thread.new { run }
107
+ @thread&.name = "rails_pulse_writer"
108
+ install_exit_hook
109
+ end
110
+ end
111
+
112
+ def run
113
+ # pop returns nil once the queue is closed and empty.
114
+ while (first = @queue.pop)
115
+ batch = [ first ]
116
+ batch << @queue.pop(true) while batch.size < BATCH_SIZE && !@queue.empty?
117
+ Tracker.perform_tracking_batch(batch)
118
+ end
119
+ rescue => e
120
+ # perform_tracking_batch rescues its own errors, so this is a bug in the
121
+ # loop itself. Report it; the next enqueue starts a fresh thread.
122
+ RailsPulse.logger.error("Rails Pulse writer thread stopped: #{e.class} - #{e.message}")
123
+ end
124
+
125
+ def install_exit_hook
126
+ return if @exit_hook_installed
127
+
128
+ @exit_hook_installed = true
129
+ at_exit { shutdown }
130
+ end
131
+
132
+ def record_drop
133
+ @mutex.synchronize do
134
+ @dropped += 1
135
+ @drop_window_count += 1
136
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
+ next if @drop_window_started && now - @drop_window_started < DROP_LOG_INTERVAL
138
+
139
+ RailsPulse.logger.warn(
140
+ "Rails Pulse writer queue is full (#{@queue_size}); dropped #{@drop_window_count} " \
141
+ "request(s) since the last notice. Raise config.async_queue_size or check database latency."
142
+ )
143
+ @drop_window_started = now
144
+ @drop_window_count = 0
145
+ end
146
+ end
147
+ end
148
+
10
149
  class << self
11
150
  def track_request(data)
12
151
  return if RequestStore.store[:skip_recording_rails_pulse_activity]
13
152
  return unless RailsPulse::SchemaCheck.tracking_allowed?
14
153
 
15
154
  if RailsPulse.configuration.async && !connection_shared_across_threads?
16
- Thread.new do
17
- perform_tracking(data)
18
- rescue => e
19
- log_error(e)
20
- end
155
+ writer.enqueue(data)
156
+ nil
21
157
  else
22
158
  perform_tracking(data)
23
159
  end
24
160
  end
25
161
 
162
+ # Queue depth and lifetime drop count of the background writer, for
163
+ # health output. Zeros before the writer has been used.
164
+ def stats
165
+ current = @writer
166
+ { queue_size: current&.size || 0, dropped: current&.dropped || 0, running: current&.running? || false }
167
+ end
168
+
169
+ # Persist everything queued so far and stop the writer thread. The next
170
+ # tracked request starts it again.
171
+ def flush!
172
+ @writer&.shutdown
173
+ end
174
+
175
+ # Discard the writer, queue contents included. For tests.
176
+ def reset_writer!
177
+ @writer_mutex.synchronize { @writer = nil }
178
+ end
179
+
26
180
  def healthy?
27
181
  RailsPulse::ApplicationRecord.connection.execute("SELECT 1")
28
182
  true
@@ -30,39 +184,98 @@ module RailsPulse
30
184
  false
31
185
  end
32
186
 
33
- private
187
+ # Marks SQL operations that repeat within one request with the
188
+ # normalized statement they share and how many times it ran. Runs on the
189
+ # writer so the normalisation cost stays off the request thread.
190
+ def detect_n_plus_one(operations)
191
+ sql_ops = operations.select { |op| op[:operation_type] == "sql" }
192
+ return if sql_ops.size < 2
34
193
 
35
- def perform_tracking(data)
36
- RailsPulse::ApplicationRecord.connection_pool.with_connection do |conn|
37
- clear_aborted_transaction(conn)
38
- RequestStore.store[:skip_recording_rails_pulse_activity] = true
194
+ groups = sql_ops.group_by { |op| RailsPulse::SqlQueryNormalizer.normalize(op[:actual_sql].to_s) }
195
+ groups.each do |normalized_sql, ops|
196
+ next if ops.size < 2
197
+ ops.each do |op|
198
+ op[:repeated_query_group] = normalized_sql
199
+ op[:repetition_count] = ops.size
200
+ end
201
+ end
202
+ end
39
203
 
40
- route = RailsPulse::Route.find_or_create_for_request(data[:method], data[:path], controller_action: data[:controller_action])
41
-
42
- request = RailsPulse::Request.create!(
43
- route: route,
44
- method: data[:method],
45
- duration: data[:duration],
46
- status: data[:status],
47
- is_error: data[:is_error],
48
- request_uuid: data[:request_uuid],
49
- controller_action: data[:controller_action],
50
- occurred_at: data[:occurred_at],
51
- response_size_bytes: data[:response_size_bytes]
52
- )
204
+ # One connection checkout for the whole batch. A request that fails to
205
+ # persist is logged and skipped; the others in the batch still land, and
206
+ # their operations go in with a single insert.
207
+ def perform_tracking_batch(batch)
208
+ with_writer_connection do
209
+ persisted = batch.filter_map do |data|
210
+ request = persist_request(data)
211
+ [ request, data[:operations] ]
212
+ rescue => e
213
+ log_error(e)
214
+ nil
215
+ end
216
+
217
+ rows = persisted.flat_map { |request, operations| operation_rows(request, operations) }
218
+ RailsPulse::Operation.persist_bulk(rows, {}) unless rows.empty?
219
+ end
220
+ rescue => e
221
+ log_error(e)
222
+ nil
223
+ end
53
224
 
54
- ops = data[:operations] || []
55
- RailsPulse::Operation.persist_bulk(ops, request_id: request.id)
225
+ private
56
226
 
227
+ def writer
228
+ @writer_mutex.synchronize do
229
+ @writer ||= Writer.new(queue_size: RailsPulse.configuration.async_queue_size)
230
+ end
231
+ end
232
+
233
+ def perform_tracking(data)
234
+ with_writer_connection do
235
+ request = persist_request(data)
236
+ rows = operation_rows(request, data[:operations])
237
+ RailsPulse::Operation.persist_bulk(rows, {}) unless rows.empty?
57
238
  request
58
239
  end
59
240
  rescue => e
60
241
  log_error(e)
61
242
  nil
243
+ end
244
+
245
+ def with_writer_connection
246
+ RailsPulse::ApplicationRecord.connection_pool.with_connection do |conn|
247
+ clear_aborted_transaction(conn)
248
+ RequestStore.store[:skip_recording_rails_pulse_activity] = true
249
+ yield
250
+ end
62
251
  ensure
63
252
  RequestStore.store[:skip_recording_rails_pulse_activity] = false
64
253
  end
65
254
 
255
+ def persist_request(data)
256
+ route = RailsPulse::Route.find_or_create_for_request(data[:method], data[:path], controller_action: data[:controller_action])
257
+
258
+ RailsPulse::Request.create!(
259
+ route: route,
260
+ method: data[:method],
261
+ duration: data[:duration],
262
+ status: data[:status],
263
+ is_error: data[:is_error],
264
+ request_uuid: data[:request_uuid],
265
+ controller_action: data[:controller_action],
266
+ occurred_at: data[:occurred_at],
267
+ response_size_bytes: data[:response_size_bytes]
268
+ )
269
+ end
270
+
271
+ def operation_rows(request, operations)
272
+ operations = Array(operations)
273
+ return [] if operations.empty?
274
+
275
+ detect_n_plus_one(operations)
276
+ operations.map { |op| op.merge(request_id: request.id, job_run_id: nil) }
277
+ end
278
+
66
279
  # Transactional tests pin one connection per pool and hand that same
67
280
  # connection to every thread that asks for one. A background writer would
68
281
  # then interleave its statements with the test's on a single socket, which
@@ -93,5 +306,8 @@ module RailsPulse
93
306
  RailsPulse.logger.error(error.backtrace.join("\n")) if RailsPulse.logger.debug?
94
307
  end
95
308
  end
309
+
310
+ @writer = nil
311
+ @writer_mutex = Mutex.new
96
312
  end
97
313
  end
@@ -1,3 +1,3 @@
1
1
  module RailsPulse
2
- VERSION = "0.4.0.pre.4"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/rails_pulse.rb CHANGED
@@ -33,6 +33,9 @@ module RailsPulse
33
33
  end
34
34
 
35
35
  def logger
36
+ configured = configuration&.logger
37
+ return configured if configured
38
+
36
39
  if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
37
40
  @logger ||= ActiveSupport::TaggedLogging.new(Rails.logger).tagged("RailsPulse")
38
41
  else
@@ -40,22 +43,6 @@ module RailsPulse
40
43
  end
41
44
  end
42
45
 
43
- def clear_metric_cache!
44
- Rails.cache.delete_matched("rails_pulse_metric*")
45
- end
46
-
47
- def warm_metric_cache!
48
- # Pre-warm cache for common metrics
49
- [ :average_response_times, :percentile_response_times, :request_count_totals, :error_rate_per_route ].each do |metric|
50
- begin
51
- logger.info "Warming cache for metric: #{metric}"
52
- # This would trigger cache generation by making the request
53
- rescue => e
54
- logger.error "Failed to warm cache for #{metric}: #{e.message}"
55
- end
56
- end
57
- end
58
-
59
46
  def connects_to
60
47
  configuration&.connects_to
61
48
  end
@@ -26,10 +26,8 @@ RailsPulse.standalone!
26
26
  $stdout.sync = true
27
27
  $stderr.sync = true
28
28
 
29
- # Build the Rack app with session support
30
- require "rack/session/cookie"
31
29
  require "rack/static"
32
- require "securerandom"
30
+ require "rack/method_override"
33
31
  require_relative "rails_pulse/rack_compat"
34
32
  require_relative "rails_pulse/middleware/asset_server"
35
33
 
@@ -62,6 +60,22 @@ class DashboardApp
62
60
  end
63
61
  end
64
62
 
63
+ # Rails::Engine#call is entered directly here, skipping the action_dispatch.*
64
+ # env defaults Rails::Application#call normally seeds before its middleware
65
+ # stack runs. ActionDispatch::Cookies and Session::CookieStore below need
66
+ # those (the encryption key generator, in particular) — without them,
67
+ # ActionController::RequestForgeryProtection#commit_csrf_token never fires,
68
+ # and every CSRF token silently fails to persist to the session.
69
+ class SeedRailsEnv
70
+ def initialize(app)
71
+ @app = app
72
+ end
73
+
74
+ def call(env)
75
+ @app.call(Rails.application.env_config.merge(env))
76
+ end
77
+ end
78
+
65
79
  # Dashboard assets. RouteHelper#asset_path emits one of two URL shapes: the
66
80
  # gem-served fallback (/rails-pulse-assets/<version>/...) or, once
67
81
  # assets:precompile has run, the digested copies it installs under the host's
@@ -79,32 +93,32 @@ use Rack::Static,
79
93
  root: Rails.public_path.to_s,
80
94
  header_rules: [ [ :all, RailsPulse::Engine.asset_headers ] ]
81
95
 
82
- # Add session middleware for the dashboard. The cookie is signed with
83
- # SECRET_KEY_BASE when set, otherwise with the host app's own secret_key_base
84
- # (config/environment.rb is already loaded above) — hosts on encrypted
85
- # credentials have no SECRET_KEY_BASE environment variable.
86
- secret_key = ENV["SECRET_KEY_BASE"].to_s
87
- if secret_key.empty?
88
- secret_key = begin
89
- Rails.application.secret_key_base.to_s
90
- rescue StandardError
91
- ""
92
- end
93
- end
94
- if secret_key.empty?
95
- raise "SECRET_KEY_BASE environment variable must be set for standalone dashboard " \
96
- "(no Rails secret_key_base was available to fall back to)"
97
- end
96
+ # The mounted engine gets this from the host app's default stack; here it
97
+ # must be added explicitly or the settings forms' PATCH routes 404.
98
+ use Rack::MethodOverride
99
+
100
+ # Fail fast on boot rather than on the first request. secret_key_base checks
101
+ # SECRET_KEY_BASE, then the host's encrypted credentials.
102
+ Rails.application.secret_key_base.presence || raise(
103
+ "No secret_key_base available for the standalone dashboard " \
104
+ "(set SECRET_KEY_BASE, or config/credentials)."
105
+ )
106
+
107
+ use SeedRailsEnv
108
+
109
+ # ActionDispatch::Cookies and Session::CookieStore, not the plain rack-session
110
+ # gem's Rack::Session::Cookie: only these know to call commit_csrf_token, so a
111
+ # CSRF token generated for a form actually gets persisted to the session
112
+ # instead of discarded at the end of the request.
113
+ use ActionDispatch::Cookies
98
114
 
99
115
  # `secure` keeps the session cookie off plain HTTP in production. Set
100
116
  # RAILS_PULSE_INSECURE_SESSION=1 only for a deliberately non-TLS deployment
101
117
  # (a private network with no reverse proxy in front).
102
- use Rack::Session::Cookie,
118
+ use ActionDispatch::Session::CookieStore,
103
119
  key: "rails_pulse_session",
104
- secret: secret_key,
105
120
  same_site: :lax,
106
- httponly: true,
107
121
  secure: Rails.env.production? && ENV["RAILS_PULSE_INSECURE_SESSION"].blank?,
108
- max_age: 86400 # 1 day
122
+ expire_after: 86400 # 1 day
109
123
 
110
124
  run DashboardApp.new