rails_pulse 0.3.2 → 0.3.3.pre.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4ae84f35fe7c694c6a41c2b7baf710bfbbbbcaa651c63fda713a7c2321ea6a2
4
- data.tar.gz: 1159b79cb75887806cbb655fe515198262810c882a87bcc3bec802ac0346eb7e
3
+ metadata.gz: b3163ab148ab6f7c2f3d94f39212eec0ddcd56d04701b5320a570725433c04ac
4
+ data.tar.gz: 3c1780d7779c03cf2176ae680c4ffcd8168fb2c91bf3da55f41b63a38d1044cd
5
5
  SHA512:
6
- metadata.gz: f8cb7c6c3e70a936552c10bb6b03067de2733287432bbb4a7464227e70ca33c4a597cb7d485bcaf5ff67c3127ce30f896e17207b765335aa92916d40239963fe
7
- data.tar.gz: 361a77be31e5e47294577d1629717f48b9a9fe55f80f023799c6caafd989eadeeabeb2f2cf4500a7d87403596ba5dcff54e461a02ba801a362e3dcd1697bc502
6
+ metadata.gz: 5ab05d4a32b9d9efc69b4a4f2683888815c6f213a1fee3e5f8a351492959d12318c345c589179455fab4c8db2ffbe496e2fcdf168badbf6557819b6479551171
7
+ data.tar.gz: 4697d128fdb52cc22a5a345652c48fbcd324e8482911325f3859a2dd46febbb8be309da2988c281a3adcf2efc22eaca06a03f1e02c4a3e393392e21e1b54b829
@@ -37,6 +37,60 @@ module RailsPulse
37
37
  before_validation :associate_query
38
38
  before_validation :truncate_label
39
39
 
40
+ # Bulk-insert a batch of operation hashes captured during a request or job run.
41
+ # `context` is merged into every row to bind it to its parent — either
42
+ # `{ request_id: id }` or `{ job_run_id: id, request_id: nil }`.
43
+ #
44
+ # SQL operations are handled in two phases to minimise DB round-trips:
45
+ # 1. Normalise each unique SQL source once and collect hashed→normalised pairs.
46
+ # 2. Resolve (or create) the corresponding Query records in bulk rather than
47
+ # one create_or_find_by per operation, which matters for N+1-heavy requests.
48
+ #
49
+ # insert_all! is used instead of individual creates to bypass ActiveRecord
50
+ # callbacks and validations — normalisation and truncation are done inline above.
51
+ # All rows must have the same key set, so keys are union-normalised before insert.
52
+ def self.persist_bulk(ops, context)
53
+ return if ops.empty?
54
+
55
+ # Phase 1: normalise each unique SQL source once.
56
+ # norm_cache avoids re-normalising repeated SQL (e.g. N+1 queries).
57
+ # norm_map feeds the bulk Query resolver: { hashed_sql => normalized_sql }.
58
+ norm_cache = {}
59
+ norm_map = {}
60
+ ops.each do |op|
61
+ next unless op[:operation_type] == "sql"
62
+ sql_source = op[:actual_sql].presence || op[:label].presence
63
+ next unless sql_source
64
+ unless norm_cache.key?(sql_source)
65
+ normalized = RailsPulse::SqlQueryNormalizer.normalize(sql_source)
66
+ hashed = Digest::MD5.hexdigest(normalized)
67
+ norm_cache[sql_source] = [ hashed, normalized ]
68
+ norm_map[hashed] ||= normalized
69
+ end
70
+ end
71
+
72
+ # Phase 2: resolve Query IDs in bulk (1 SELECT in steady state).
73
+ query_id_map = RailsPulse::Query.bulk_find_or_create(norm_map)
74
+
75
+ now = Time.current
76
+ rows = ops.map do |op|
77
+ op = op.merge(context).merge(created_at: now, updated_at: now)
78
+ if op[:operation_type] == "sql"
79
+ sql_source = op[:actual_sql].presence || op[:label].presence
80
+ if sql_source && (meta = norm_cache[sql_source])
81
+ hashed, normalized = meta
82
+ op = op.merge(label: normalized.truncate(255), query_id: query_id_map[hashed])
83
+ end
84
+ end
85
+ op[:label] = op[:label]&.truncate(255)
86
+ op
87
+ end
88
+ # insert_all! requires every row to have identical keys.
89
+ all_keys = rows.flat_map(&:keys).uniq
90
+ rows = rows.map { |r| all_keys.each_with_object({}) { |k, h| h[k] = r[k] } }
91
+ insert_all!(rows)
92
+ end
93
+
40
94
  def self.ransackable_attributes(auth_object = nil)
41
95
  %w[id occurred_at label duration start_time average_query_time_ms query_count operation_type query_id]
42
96
  end
@@ -24,6 +24,21 @@ module RailsPulse
24
24
  serialize :index_recommendations, type: Array, coder: JSON
25
25
  serialize :suggestions, type: Array, coder: JSON
26
26
 
27
+ def self.bulk_find_or_create(norm_map)
28
+ return {} if norm_map.empty?
29
+
30
+ id_map = where(hashed_sql: norm_map.keys).pluck(:hashed_sql, :id).to_h
31
+
32
+ missing = norm_map.reject { |h, _| id_map.key?(h) }
33
+ unless missing.empty?
34
+ now = Time.current
35
+ insert_all(missing.map { |h, n| { hashed_sql: h, normalized_sql: n, created_at: now, updated_at: now } })
36
+ id_map.merge!(where(hashed_sql: missing.keys).pluck(:hashed_sql, :id).to_h)
37
+ end
38
+
39
+ id_map
40
+ end
41
+
27
42
  def self.ransackable_attributes(auth_object = nil)
28
43
  %w[id normalized_sql average_query_time_ms execution_count total_time_consumed performance_status occurred_at]
29
44
  end
@@ -16,7 +16,7 @@ module RailsPulse
16
16
  validates :duration, presence: true, numericality: { greater_than_or_equal_to: 0 }
17
17
  validates :status, presence: true
18
18
  validates :is_error, inclusion: { in: [ true, false ] }
19
- validates :request_uuid, presence: true, uniqueness: true
19
+ validates :request_uuid, presence: true
20
20
 
21
21
  before_validation :set_request_uuid, on: :create
22
22
 
@@ -92,21 +92,49 @@ module RailsPulse
92
92
  end
93
93
 
94
94
  def handle_special_constructs(query)
95
- normalized = query.dup
95
+ normalized = normalize_in_clauses(query)
96
+ normalized.gsub(/\bBETWEEN\s+\?\s+AND\s+\?/i, "BETWEEN ? AND ?")
97
+ end
96
98
 
97
- # Handle IN clauses with multiple values - replace content but preserve structure
98
- normalized = normalized.gsub(/\bIN\s*\(\s*([^)]+)\)/i) do |match|
99
- content = $1
100
- # Count commas to determine number of values
101
- value_count = content.split(",").length
102
- placeholders = Array.new(value_count, "?").join(", ")
103
- "IN (#{placeholders})"
99
+ # Replaces IN clause contents with placeholders using paren-depth tracking
100
+ # so that subqueries containing nested parens (e.g. HAVING (COUNT(*) > N))
101
+ # are handled correctly. A flat [^)]+ regex stops at the first ) it finds,
102
+ # which breaks when the subquery contains function calls or nested clauses.
103
+ def normalize_in_clauses(query)
104
+ result = +""
105
+ i = 0
106
+ while i < query.length
107
+ at_word_boundary = i == 0 || !query[i - 1].match?(/[a-zA-Z_0-9]/i)
108
+ m = at_word_boundary && query[i..].match(/\AIN\s*\(\s*/i)
109
+
110
+ if m
111
+ content_start = i + m[0].length
112
+ depth = 1
113
+ j = content_start
114
+
115
+ while j < query.length && depth > 0
116
+ case query[j]
117
+ when "(" then depth += 1
118
+ when ")" then depth -= 1
119
+ end
120
+ j += 1 if depth > 0
121
+ end
122
+
123
+ content = query[content_start...j]
124
+
125
+ if content.strip.match?(/\ASELECT\s/i)
126
+ result << "IN (?)"
127
+ else
128
+ value_count = [ content.split(",").length, 1 ].max
129
+ result << "IN (#{Array.new(value_count, "?").join(", ")})"
130
+ end
131
+ i = j + 1
132
+ else
133
+ result << query[i]
134
+ i += 1
135
+ end
104
136
  end
105
-
106
- # Handle BETWEEN clauses
107
- normalized = normalized.gsub(/\bBETWEEN\s+\?\s+AND\s+\?/i, "BETWEEN ? AND ?")
108
-
109
- normalized
137
+ result
110
138
  end
111
139
 
112
140
  def restore_identifiers(query, identifier_mapping)
@@ -71,34 +71,17 @@ module RailsPulse
71
71
  private
72
72
 
73
73
  def aggregate_routes
74
- # Use ActiveRecord for cross-database compatibility
75
- route_groups = Request
74
+ all_rows = Request
76
75
  .where(occurred_at: start_time...end_time)
77
76
  .where.not(route_id: nil)
78
- .joins(:route)
79
- .group(:route_id)
80
-
81
- # Calculate basic aggregates
82
- basic_stats = route_groups.pluck(
83
- :route_id,
84
- Arel.sql("COUNT(*) as request_count"),
85
- Arel.sql("AVG(duration) as avg_duration"),
86
- Arel.sql("MIN(duration) as min_duration"),
87
- Arel.sql("MAX(duration) as max_duration"),
88
- Arel.sql("SUM(duration) as total_duration")
89
- )
90
-
91
- basic_stats.each do |stats|
92
- route_id = stats[0]
77
+ .pluck(:route_id, :duration, :status)
93
78
 
94
- # Calculate percentiles and status counts separately for cross-DB compatibility
95
- durations = Request
96
- .where(occurred_at: start_time...end_time)
97
- .where(route_id: route_id)
98
- .pluck(:duration, :status)
79
+ return if all_rows.empty?
99
80
 
100
- sorted_durations = durations.map(&:first).compact.sort
101
- statuses = durations.map(&:last)
81
+ all_rows.group_by(&:first).each do |route_id, rows|
82
+ durations = rows.map { |_, d, _| d }.compact.sort
83
+ statuses = rows.map { |_, _, s| s }
84
+ avg = durations.sum.to_f / durations.size
102
85
 
103
86
  summary = Summary.find_or_initialize_by(
104
87
  summarizable_type: "RailsPulse::Route",
@@ -109,15 +92,15 @@ module RailsPulse
109
92
 
110
93
  summary.assign_attributes(
111
94
  period_end: end_time,
112
- count: stats[1],
113
- avg_duration: stats[2],
114
- min_duration: stats[3],
115
- max_duration: stats[4],
116
- total_duration: stats[5],
117
- p50_duration: RailsPulse::Statistics.calculate_percentile(sorted_durations, 0.5),
118
- p95_duration: RailsPulse::Statistics.calculate_percentile(sorted_durations, 0.95),
119
- p99_duration: RailsPulse::Statistics.calculate_percentile(sorted_durations, 0.99),
120
- stddev_duration: RailsPulse::Statistics.calculate_stddev(sorted_durations, stats[2]),
95
+ count: rows.size,
96
+ avg_duration: avg,
97
+ min_duration: durations.first,
98
+ max_duration: durations.last,
99
+ total_duration: durations.sum,
100
+ p50_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.5),
101
+ p95_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.95),
102
+ p99_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.99),
103
+ stddev_duration: RailsPulse::Statistics.calculate_stddev(durations, avg),
121
104
  error_count: statuses.count { |s| s >= 500 },
122
105
  success_count: statuses.count { |s| s < 500 },
123
106
  status_2xx: statuses.count { |s| s.between?(200, 299) },
@@ -131,31 +114,18 @@ module RailsPulse
131
114
  end
132
115
 
133
116
  def aggregate_queries
134
- query_groups = Operation
117
+ all_rows = Operation
135
118
  .where(occurred_at: start_time...end_time)
136
119
  .where.not(query_id: nil)
137
- .joins(:query)
138
- .group(:query_id)
139
-
140
- basic_stats = query_groups.pluck(
141
- :query_id,
142
- Arel.sql("COUNT(*) as execution_count"),
143
- Arel.sql("AVG(duration) as avg_duration"),
144
- Arel.sql("MIN(duration) as min_duration"),
145
- Arel.sql("MAX(duration) as max_duration"),
146
- Arel.sql("SUM(duration) as total_duration")
147
- )
120
+ .pluck(:query_id, :duration)
121
+
122
+ return if all_rows.empty?
148
123
 
149
- basic_stats.each do |stats|
150
- query_id = stats[0]
124
+ all_rows.group_by(&:first).each do |query_id, rows|
125
+ durations = rows.map(&:last).compact.sort
126
+ next if durations.empty?
151
127
 
152
- # Calculate percentiles separately
153
- durations = Operation
154
- .where(occurred_at: start_time...end_time)
155
- .where(query_id: query_id)
156
- .pluck(:duration)
157
- .compact
158
- .sort
128
+ avg = durations.sum.to_f / durations.size
159
129
 
160
130
  summary = Summary.find_or_initialize_by(
161
131
  summarizable_type: "RailsPulse::Query",
@@ -166,15 +136,15 @@ module RailsPulse
166
136
 
167
137
  summary.assign_attributes(
168
138
  period_end: end_time,
169
- count: stats[1],
170
- avg_duration: stats[2],
171
- min_duration: stats[3],
172
- max_duration: stats[4],
173
- total_duration: stats[5],
139
+ count: durations.size,
140
+ avg_duration: avg,
141
+ min_duration: durations.first,
142
+ max_duration: durations.last,
143
+ total_duration: durations.sum,
174
144
  p50_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.5),
175
145
  p95_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.95),
176
146
  p99_duration: RailsPulse::Statistics.calculate_percentile(durations, 0.99),
177
- stddev_duration: RailsPulse::Statistics.calculate_stddev(durations, stats[2])
147
+ stddev_duration: RailsPulse::Statistics.calculate_stddev(durations, avg)
178
148
  )
179
149
 
180
150
  summary.save!
@@ -57,7 +57,7 @@ module RailsPulse
57
57
  # Only delete records from periods that have already been summarized.
58
58
  # This prevents count-based cleanup from removing data before the summary
59
59
  # job has had a chance to aggregate it.
60
- cutoff = latest_summarized_cutoff
60
+ cutoff = summarized_cutoff
61
61
 
62
62
  # Operations: only delete those before the summarization cutoff
63
63
  ops_scope = cutoff ? RailsPulse::Operation.where("occurred_at < ?", cutoff) : RailsPulse::Operation.none
@@ -93,39 +93,35 @@ module RailsPulse
93
93
  end
94
94
 
95
95
  def cleanup_requests_by_time(cutoff_time)
96
- request_ids = RailsPulse::Request.where("occurred_at < ?", cutoff_time).pluck(:id)
97
- RailsPulse::Operation.where(request_id: request_ids).delete_all
98
- RailsPulse::Request.where(id: request_ids).delete_all
99
- request_ids.size
96
+ request_subquery = RailsPulse::Request.where("occurred_at < ?", cutoff_time).select(:id)
97
+ RailsPulse::Operation.where(request_id: request_subquery).delete_all
98
+ RailsPulse::Request.where("occurred_at < ?", cutoff_time).delete_all
100
99
  end
101
100
 
102
101
  def cleanup_queries_by_time(cutoff_time)
103
102
  RailsPulse::Query
104
103
  .where("created_at < ?", cutoff_time)
105
- .where("id NOT IN (SELECT DISTINCT query_id FROM rails_pulse_operations WHERE query_id IS NOT NULL)")
104
+ .where("id NOT IN (SELECT query_id FROM rails_pulse_operations WHERE query_id IS NOT NULL)")
106
105
  .delete_all
107
106
  end
108
107
 
109
108
  def cleanup_routes_by_time(cutoff_time)
110
109
  RailsPulse::Route
111
110
  .where("created_at < ?", cutoff_time)
112
- .where("id NOT IN (SELECT DISTINCT route_id FROM rails_pulse_requests WHERE route_id IS NOT NULL)")
111
+ .where("id NOT IN (SELECT route_id FROM rails_pulse_requests WHERE route_id IS NOT NULL)")
113
112
  .delete_all
114
113
  end
115
114
 
116
115
  def cleanup_job_runs_by_time(cutoff_time)
117
- job_run_ids = RailsPulse::JobRun.where("occurred_at < ?", cutoff_time).pluck(:id)
118
- return 0 if job_run_ids.empty?
119
-
120
- RailsPulse::Operation.where(job_run_id: job_run_ids).delete_all
121
- RailsPulse::JobRun.where(id: job_run_ids).delete_all
122
- job_run_ids.size
116
+ job_run_subquery = RailsPulse::JobRun.where("occurred_at < ?", cutoff_time).select(:id)
117
+ RailsPulse::Operation.where(job_run_id: job_run_subquery).delete_all
118
+ RailsPulse::JobRun.where("occurred_at < ?", cutoff_time).delete_all
123
119
  end
124
120
 
125
121
  def cleanup_jobs_by_time(cutoff_time)
126
122
  RailsPulse::Job
127
123
  .where("created_at < ?", cutoff_time)
128
- .where("id NOT IN (SELECT DISTINCT job_id FROM rails_pulse_job_runs WHERE job_id IS NOT NULL)")
124
+ .where("id NOT IN (SELECT job_id FROM rails_pulse_job_runs WHERE job_id IS NOT NULL)")
129
125
  .delete_all
130
126
  end
131
127
 
@@ -136,7 +132,7 @@ module RailsPulse
136
132
  return 0 unless max_records
137
133
 
138
134
  # Only delete requests from periods that have already been summarized
139
- cutoff = latest_summarized_cutoff
135
+ cutoff = summarized_cutoff
140
136
  return 0 unless cutoff
141
137
 
142
138
  current_count = RailsPulse::Request.count
@@ -173,21 +169,21 @@ module RailsPulse
173
169
 
174
170
  def cleanup_queries_by_count
175
171
  scope = RailsPulse::Query.where(
176
- "id NOT IN (SELECT DISTINCT query_id FROM rails_pulse_operations WHERE query_id IS NOT NULL)"
172
+ "id NOT IN (SELECT query_id FROM rails_pulse_operations WHERE query_id IS NOT NULL)"
177
173
  )
178
174
  cleanup_by_count(RailsPulse::Query, :rails_pulse_queries, order_column: :created_at, scope: scope)
179
175
  end
180
176
 
181
177
  def cleanup_routes_by_count
182
178
  scope = RailsPulse::Route.where(
183
- "id NOT IN (SELECT DISTINCT route_id FROM rails_pulse_requests WHERE route_id IS NOT NULL)"
179
+ "id NOT IN (SELECT route_id FROM rails_pulse_requests WHERE route_id IS NOT NULL)"
184
180
  )
185
181
  cleanup_by_count(RailsPulse::Route, :rails_pulse_routes, order_column: :created_at, scope: scope)
186
182
  end
187
183
 
188
184
  def cleanup_jobs_by_count
189
185
  scope = RailsPulse::Job.where(
190
- "id NOT IN (SELECT DISTINCT job_id FROM rails_pulse_job_runs WHERE job_id IS NOT NULL)"
186
+ "id NOT IN (SELECT job_id FROM rails_pulse_job_runs WHERE job_id IS NOT NULL)"
191
187
  )
192
188
  cleanup_by_count(RailsPulse::Job, :rails_pulse_jobs, order_column: :created_at, scope: scope)
193
189
  end
@@ -203,8 +199,8 @@ module RailsPulse
203
199
  # Returns the period_end of the most recent completed hourly overall-request
204
200
  # summary, or nil if the summary job has never run. Records with occurred_at
205
201
  # before this timestamp have been fully aggregated and are safe to delete.
206
- def latest_summarized_cutoff
207
- RailsPulse::Summary
202
+ def summarized_cutoff
203
+ @summarized_cutoff ||= RailsPulse::Summary
208
204
  .where(summarizable_type: "RailsPulse::Request", summarizable_id: 0, period_type: "hour")
209
205
  .maximum(:period_end)
210
206
  end
@@ -128,17 +128,12 @@ module RailsPulse
128
128
  def save_operations(job_run)
129
129
  return unless job_run
130
130
 
131
- operations_data = RequestStore.store[:rails_pulse_operations] || []
132
- operations_data.each do |operation_data|
133
- operation_data[:job_run_id] = job_run.id
134
- operation_data[:request_id] = nil
135
-
136
- with_recording_suppressed do
137
- RailsPulse::Operation.create!(operation_data)
138
- end
139
- rescue => e
140
- RailsPulse.logger.error "Failed to save job operation: #{e.class} - #{e.message}"
131
+ ops = RequestStore.store[:rails_pulse_operations] || []
132
+ with_recording_suppressed do
133
+ RailsPulse::Operation.persist_bulk(ops, job_run_id: job_run.id, request_id: nil)
141
134
  end
135
+ rescue => e
136
+ RailsPulse.logger.error "Failed to save job operations: #{e.class} - #{e.message}"
142
137
  ensure
143
138
  RequestStore.store[:rails_pulse_operations] = nil
144
139
  end
@@ -30,7 +30,6 @@ module RailsPulse
30
30
  RequestStore.store[:rails_pulse_operations] = []
31
31
 
32
32
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
- controller_action = "#{env['action_dispatch.request.parameters']&.[]('controller')&.classify}##{env['action_dispatch.request.parameters']&.[]('action')}"
34
33
  occurred_at = Time.current
35
34
 
36
35
  # Process request
@@ -41,6 +40,7 @@ module RailsPulse
41
40
  # Deep copy operations array to prevent race condition in async mode
42
41
  operations = RequestStore.store[:rails_pulse_operations] || []
43
42
  detect_n_plus_one(operations)
43
+ controller_action = operations.find { |op| op[:operation_type] == "controller" }&.[](:label)
44
44
  tracking_data = {
45
45
  method: req.request_method,
46
46
  path: req.path,
@@ -85,7 +85,7 @@ module RailsPulse
85
85
  sql_ops = operations.select { |op| op[:operation_type] == "sql" }
86
86
  return if sql_ops.size < 2
87
87
 
88
- groups = sql_ops.group_by { |op| RailsPulse::SqlQueryNormalizer.normalize(op[:label].to_s) }
88
+ groups = sql_ops.group_by { |op| RailsPulse::SqlQueryNormalizer.normalize(op[:actual_sql].to_s) }
89
89
  groups.each do |normalized_sql, ops|
90
90
  next if ops.size < 2
91
91
  ops.each do |op|
@@ -21,7 +21,7 @@ module RailsPulse
21
21
 
22
22
  def clean_sql_label(sql)
23
23
  return sql unless sql
24
- # Remove Rails SQL comments like /*action='search',application='Dummy',controller='home'*/
24
+ return sql unless sql.include?("/*")
25
25
  sql.gsub(/\/\*[^*]*\*\//, "").strip
26
26
  end
27
27
 
@@ -36,8 +36,11 @@ module RailsPulse
36
36
  end
37
37
  end
38
38
 
39
+ def app_path
40
+ @app_path ||= Rails.root.join("app").to_s
41
+ end
42
+
39
43
  def find_app_frame
40
- app_path = Rails.root.join("app").to_s
41
44
  caller_locations.each do |loc|
42
45
  path = loc.absolute_path || loc.path
43
46
  return path if path && path.start_with?(app_path)
@@ -47,23 +50,30 @@ module RailsPulse
47
50
 
48
51
  def controller_action_source_location(payload)
49
52
  return nil unless payload[:controller] && payload[:action]
53
+
54
+ cache_key = "#{payload[:controller]}##{payload[:action]}"
55
+ @source_location_cache ||= {}
56
+ return @source_location_cache[cache_key] if @source_location_cache.key?(cache_key)
57
+
58
+ result = nil
50
59
  begin
51
60
  controller_klass = payload[:controller].constantize
52
61
  if controller_klass.instance_methods(false).include?(payload[:action].to_sym)
53
62
  file, line = controller_klass.instance_method(payload[:action]).source_location
54
- return "#{relative_path(file)}:#{line}" if file && line
63
+ result = "#{relative_path(file)}:#{line}" if file && line
55
64
  end
56
65
  # fallback: try superclass (for ApplicationController actions)
57
- if controller_klass.superclass.respond_to?(:instance_method)
66
+ if result.nil? && controller_klass.superclass.respond_to?(:instance_method)
58
67
  if controller_klass.superclass.instance_methods(false).include?(payload[:action].to_sym)
59
68
  file, line = controller_klass.superclass.instance_method(payload[:action]).source_location
60
- return "#{relative_path(file)}:#{line}" if file && line
69
+ result = "#{relative_path(file)}:#{line}" if file && line
61
70
  end
62
71
  end
63
72
  rescue => e
64
73
  RailsPulse.logger.debug "Could not resolve controller source location: #{e.class} - #{e.message}"
65
74
  end
66
- nil
75
+
76
+ @source_location_cache[cache_key] = result
67
77
  end
68
78
 
69
79
  def capture_operation(event_name, start, finish, payload, operation_type, label_key = nil, extra: {})
@@ -29,11 +29,8 @@ module RailsPulse
29
29
 
30
30
  begin
31
31
  # Find or create route
32
- route = RailsPulse::Route.create_or_find_by(
33
- method: data[:method],
34
- path: data[:path]
35
- )
36
- route = RailsPulse::Route.find_by!(method: data[:method], path: data[:path]) unless route
32
+ route = RailsPulse::Route.find_by(method: data[:method], path: data[:path]) ||
33
+ RailsPulse::Route.create_or_find_by(method: data[:method], path: data[:path])
37
34
 
38
35
 
39
36
  # Create request record
@@ -49,9 +46,8 @@ module RailsPulse
49
46
  )
50
47
 
51
48
  # Create operation records
52
- (data[:operations] || []).each do |op_data|
53
- RailsPulse::Operation.create!(op_data.merge(request_id: request.id))
54
- end
49
+ ops = data[:operations] || []
50
+ RailsPulse::Operation.persist_bulk(ops, request_id: request.id)
55
51
 
56
52
  request
57
53
  rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::StatementInvalid => e
@@ -1,3 +1,3 @@
1
1
  module RailsPulse
2
- VERSION = "0.3.2"
2
+ VERSION = "0.3.3.pre.1"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_pulse
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.3.pre.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Scott Harvey
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-12 00:00:00.000000000 Z
11
+ date: 2026-05-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -515,7 +515,9 @@ metadata:
515
515
  source_code_uri: https://github.com/railspulse/rails_pulse
516
516
  changelog_uri: https://github.com/railspulse/rails_pulse/blob/main/CHANGELOG.md
517
517
  documentation_uri: https://railspulse.com/documentation/installation
518
- post_install_message:
518
+ post_install_message: |
519
+ Rails Pulse 0.3.3.pre.1 installed. If upgrading, run:
520
+ rails generate rails_pulse:upgrade && rails db:migrate
519
521
  rdoc_options: []
520
522
  require_paths:
521
523
  - lib