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 +4 -4
- data/app/models/rails_pulse/operation.rb +54 -0
- data/app/models/rails_pulse/query.rb +15 -0
- data/app/models/rails_pulse/request.rb +1 -1
- data/app/services/rails_pulse/sql_query_normalizer.rb +41 -13
- data/app/services/rails_pulse/summary_service.rb +30 -60
- data/lib/rails_pulse/cleanup_service.rb +16 -20
- data/lib/rails_pulse/job_run_collector.rb +5 -10
- data/lib/rails_pulse/middleware/request_collector.rb +2 -2
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +16 -6
- data/lib/rails_pulse/tracker.rb +4 -8
- data/lib/rails_pulse/version.rb +1 -1
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3163ab148ab6f7c2f3d94f39212eec0ddcd56d04701b5320a570725433c04ac
|
|
4
|
+
data.tar.gz: 3c1780d7779c03cf2176ae680c4ffcd8168fb2c91bf3da55f41b63a38d1044cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
95
|
+
normalized = normalize_in_clauses(query)
|
|
96
|
+
normalized.gsub(/\bBETWEEN\s+\?\s+AND\s+\?/i, "BETWEEN ? AND ?")
|
|
97
|
+
end
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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:
|
|
113
|
-
avg_duration:
|
|
114
|
-
min_duration:
|
|
115
|
-
max_duration:
|
|
116
|
-
total_duration:
|
|
117
|
-
p50_duration: RailsPulse::Statistics.calculate_percentile(
|
|
118
|
-
p95_duration: RailsPulse::Statistics.calculate_percentile(
|
|
119
|
-
p99_duration: RailsPulse::Statistics.calculate_percentile(
|
|
120
|
-
stddev_duration: RailsPulse::Statistics.calculate_stddev(
|
|
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
|
-
|
|
117
|
+
all_rows = Operation
|
|
135
118
|
.where(occurred_at: start_time...end_time)
|
|
136
119
|
.where.not(query_id: nil)
|
|
137
|
-
.
|
|
138
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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:
|
|
170
|
-
avg_duration:
|
|
171
|
-
min_duration:
|
|
172
|
-
max_duration:
|
|
173
|
-
total_duration:
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
97
|
-
RailsPulse::Operation.where(request_id:
|
|
98
|
-
RailsPulse::Request.where(
|
|
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
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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[:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {})
|
data/lib/rails_pulse/tracker.rb
CHANGED
|
@@ -29,11 +29,8 @@ module RailsPulse
|
|
|
29
29
|
|
|
30
30
|
begin
|
|
31
31
|
# Find or create route
|
|
32
|
-
route = RailsPulse::Route.
|
|
33
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
data/lib/rails_pulse/version.rb
CHANGED
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.
|
|
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-
|
|
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
|