rails_console_pro 0.1.2 → 0.1.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.
- checksums.yaml +4 -4
- data/.rspec_status +261 -240
- data/CHANGELOG.md +4 -0
- data/QUICK_START.md +8 -0
- data/README.md +16 -0
- data/docs/FORMATTING.md +5 -0
- data/docs/MODEL_STATISTICS.md +4 -0
- data/docs/OBJECT_DIFFING.md +6 -0
- data/docs/PROFILING.md +91 -0
- data/docs/QUEUE_INSIGHTS.md +82 -0
- data/docs/SCHEMA_INSPECTION.md +5 -0
- data/docs/SNIPPETS.md +71 -0
- data/lib/rails_console_pro/commands/jobs_command.rb +212 -0
- data/lib/rails_console_pro/commands/profile_command.rb +84 -0
- data/lib/rails_console_pro/commands/snippets_command.rb +141 -0
- data/lib/rails_console_pro/commands.rb +15 -0
- data/lib/rails_console_pro/configuration.rb +39 -0
- data/lib/rails_console_pro/format_exporter.rb +8 -0
- data/lib/rails_console_pro/global_methods.rb +12 -0
- data/lib/rails_console_pro/initializer.rb +23 -0
- data/lib/rails_console_pro/model_validator.rb +1 -1
- data/lib/rails_console_pro/printers/profile_printer.rb +180 -0
- data/lib/rails_console_pro/printers/queue_insights_printer.rb +150 -0
- data/lib/rails_console_pro/printers/snippet_collection_printer.rb +68 -0
- data/lib/rails_console_pro/printers/snippet_printer.rb +64 -0
- data/lib/rails_console_pro/profile_result.rb +109 -0
- data/lib/rails_console_pro/pry_commands.rb +106 -0
- data/lib/rails_console_pro/queue_insights_result.rb +110 -0
- data/lib/rails_console_pro/serializers/profile_serializer.rb +73 -0
- data/lib/rails_console_pro/services/profile_collector.rb +245 -0
- data/lib/rails_console_pro/services/queue_action_service.rb +176 -0
- data/lib/rails_console_pro/services/queue_insight_fetcher.rb +600 -0
- data/lib/rails_console_pro/services/snippet_repository.rb +191 -0
- data/lib/rails_console_pro/snippets/collection_result.rb +44 -0
- data/lib/rails_console_pro/snippets/single_result.rb +30 -0
- data/lib/rails_console_pro/snippets/snippet.rb +112 -0
- data/lib/rails_console_pro/snippets.rb +12 -0
- data/lib/rails_console_pro/version.rb +1 -1
- data/rails_console_pro.gemspec +1 -1
- metadata +26 -8
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsConsolePro
|
|
4
|
+
module Services
|
|
5
|
+
# Collects metrics for profiling a block or relation execution
|
|
6
|
+
class ProfileCollector
|
|
7
|
+
SQL_EVENT = 'sql.active_record'
|
|
8
|
+
INSTANTIATION_EVENT = 'instantiation.active_record'
|
|
9
|
+
CACHE_EVENTS = %w[
|
|
10
|
+
cache_read.active_support
|
|
11
|
+
cache_generate.active_support
|
|
12
|
+
cache_fetch_hit.active_support
|
|
13
|
+
cache_write.active_support
|
|
14
|
+
].freeze
|
|
15
|
+
IGNORED_SQL_NAMES = %w[SCHEMA CACHE EXPLAIN TRANSACTION].freeze
|
|
16
|
+
WRITE_SQL_REGEX = /\A\s*(INSERT|UPDATE|DELETE|MERGE|REPLACE)\b/i.freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :config
|
|
19
|
+
|
|
20
|
+
def initialize(config = RailsConsolePro.config)
|
|
21
|
+
@config = config
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def profile(label: nil)
|
|
25
|
+
raise ArgumentError, 'ProfileCollector#profile requires a block' unless block_given?
|
|
26
|
+
|
|
27
|
+
reset_state(label)
|
|
28
|
+
subscribe!
|
|
29
|
+
|
|
30
|
+
wall_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
31
|
+
@started_at = Time.current
|
|
32
|
+
|
|
33
|
+
begin
|
|
34
|
+
@result = yield
|
|
35
|
+
rescue => e
|
|
36
|
+
@error = e
|
|
37
|
+
ensure
|
|
38
|
+
@finished_at = Time.current
|
|
39
|
+
@duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_start) * 1000.0).round(2)
|
|
40
|
+
unsubscribe!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
build_result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def reset_state(label)
|
|
49
|
+
@label = label
|
|
50
|
+
@result = nil
|
|
51
|
+
@error = nil
|
|
52
|
+
@query_count = 0
|
|
53
|
+
@cached_query_count = 0
|
|
54
|
+
@write_query_count = 0
|
|
55
|
+
@total_sql_duration_ms = 0.0
|
|
56
|
+
@slow_queries = []
|
|
57
|
+
@query_samples = []
|
|
58
|
+
@fingerprints = {}
|
|
59
|
+
@instantiation_count = 0
|
|
60
|
+
@cache_hits = 0
|
|
61
|
+
@cache_misses = 0
|
|
62
|
+
@cache_writes = 0
|
|
63
|
+
@subscriptions = []
|
|
64
|
+
@started_at = nil
|
|
65
|
+
@finished_at = nil
|
|
66
|
+
@duration_ms = 0.0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def subscribe!
|
|
70
|
+
@subscriptions << ActiveSupport::Notifications.subscribe(SQL_EVENT) do |*args|
|
|
71
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
72
|
+
handle_sql_event(event)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@subscriptions << ActiveSupport::Notifications.subscribe(INSTANTIATION_EVENT) do |_name, _start, _finish, _id, payload|
|
|
76
|
+
@instantiation_count += payload[:record_count].to_i
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
CACHE_EVENTS.each do |event_name|
|
|
80
|
+
@subscriptions << ActiveSupport::Notifications.subscribe(event_name) do |_name, _start, _finish, _id, payload|
|
|
81
|
+
handle_cache_event(event_name, payload)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def unsubscribe!
|
|
87
|
+
@subscriptions.each do |sub|
|
|
88
|
+
ActiveSupport::Notifications.unsubscribe(sub)
|
|
89
|
+
end
|
|
90
|
+
ensure
|
|
91
|
+
@subscriptions.clear
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def handle_sql_event(event)
|
|
95
|
+
payload = event.payload
|
|
96
|
+
sql = payload[:sql].to_s
|
|
97
|
+
name = payload[:name].to_s
|
|
98
|
+
|
|
99
|
+
return if sql.empty?
|
|
100
|
+
return if IGNORED_SQL_NAMES.any? { |ignored| name.start_with?(ignored) }
|
|
101
|
+
return if sql =~ /\A\s*(BEGIN|COMMIT|ROLLBACK)/i
|
|
102
|
+
|
|
103
|
+
duration_ms = event.duration.round(2)
|
|
104
|
+
cached = payload[:cached] ? true : false
|
|
105
|
+
|
|
106
|
+
@query_count += 1
|
|
107
|
+
@cached_query_count += 1 if cached
|
|
108
|
+
@write_query_count += 1 if sql.match?(WRITE_SQL_REGEX)
|
|
109
|
+
@total_sql_duration_ms += duration_ms
|
|
110
|
+
|
|
111
|
+
sample = build_query_sample(sql, duration_ms, cached, name, payload[:binds])
|
|
112
|
+
store_sample(sample)
|
|
113
|
+
store_slow_query(sample) if duration_ms >= config.profile_slow_query_threshold
|
|
114
|
+
register_fingerprint(sample, payload[:binds])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_query_sample(sql, duration_ms, cached, name, binds)
|
|
118
|
+
bind_values = Array(binds).map do |bind|
|
|
119
|
+
if bind.respond_to?(:value_for_database)
|
|
120
|
+
bind.value_for_database
|
|
121
|
+
elsif bind.respond_to?(:value)
|
|
122
|
+
bind.value
|
|
123
|
+
else
|
|
124
|
+
bind
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
ProfileResult::QuerySample.new(
|
|
129
|
+
sql: sql,
|
|
130
|
+
duration_ms: duration_ms,
|
|
131
|
+
cached: cached,
|
|
132
|
+
name: name,
|
|
133
|
+
binds: bind_values
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def store_sample(sample)
|
|
138
|
+
return if config.profile_max_saved_queries <= 0
|
|
139
|
+
|
|
140
|
+
@query_samples << sample
|
|
141
|
+
if @query_samples.length > config.profile_max_saved_queries
|
|
142
|
+
@query_samples.shift
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def store_slow_query(sample)
|
|
147
|
+
@slow_queries << sample
|
|
148
|
+
if @slow_queries.length > config.profile_max_saved_queries
|
|
149
|
+
@slow_queries = @slow_queries.sort_by { |q| -q.duration_ms }.first(config.profile_max_saved_queries)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def register_fingerprint(sample, binds)
|
|
154
|
+
fingerprint = fingerprint_for(sample.sql, binds)
|
|
155
|
+
entry = (@fingerprints[fingerprint] ||= {
|
|
156
|
+
sql: sample.sql,
|
|
157
|
+
count: 0,
|
|
158
|
+
total_duration_ms: 0.0
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
entry[:count] += 1
|
|
162
|
+
entry[:total_duration_ms] += sample.duration_ms
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def fingerprint_for(sql, binds)
|
|
166
|
+
normalized = sql.gsub(/\s+/, ' ').strip
|
|
167
|
+
bind_array = Array(binds)
|
|
168
|
+
if bind_array.any?
|
|
169
|
+
bind_signature = bind_array.map { |b| bind_signature_for(b) }.join(',')
|
|
170
|
+
"#{normalized}|#{bind_signature}"
|
|
171
|
+
else
|
|
172
|
+
normalized
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def bind_signature_for(bind)
|
|
177
|
+
value =
|
|
178
|
+
if bind.respond_to?(:value_for_database)
|
|
179
|
+
bind.value_for_database
|
|
180
|
+
elsif bind.respond_to?(:value)
|
|
181
|
+
bind.value
|
|
182
|
+
else
|
|
183
|
+
bind
|
|
184
|
+
end
|
|
185
|
+
value.nil? ? 'NULL' : value.class.name
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def handle_cache_event(event_name, payload)
|
|
189
|
+
case event_name
|
|
190
|
+
when 'cache_read.active_support'
|
|
191
|
+
payload[:hit] ? @cache_hits += 1 : @cache_misses += 1
|
|
192
|
+
when 'cache_fetch_hit.active_support'
|
|
193
|
+
@cache_hits += 1
|
|
194
|
+
when 'cache_generate.active_support'
|
|
195
|
+
@cache_misses += 1
|
|
196
|
+
when 'cache_write.active_support'
|
|
197
|
+
@cache_writes += 1
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def build_result
|
|
202
|
+
ProfileResult.new(
|
|
203
|
+
label: @label,
|
|
204
|
+
duration_ms: @duration_ms,
|
|
205
|
+
result: @result,
|
|
206
|
+
error: @error,
|
|
207
|
+
query_count: @query_count,
|
|
208
|
+
cached_query_count: @cached_query_count,
|
|
209
|
+
write_query_count: @write_query_count,
|
|
210
|
+
total_sql_duration_ms: @total_sql_duration_ms.round(2),
|
|
211
|
+
slow_queries: top_slow_queries,
|
|
212
|
+
duplicate_queries: duplicate_queries,
|
|
213
|
+
query_samples: @query_samples.dup,
|
|
214
|
+
instantiation_count: @instantiation_count,
|
|
215
|
+
cache_hits: @cache_hits,
|
|
216
|
+
cache_misses: @cache_misses,
|
|
217
|
+
cache_writes: @cache_writes,
|
|
218
|
+
started_at: @started_at,
|
|
219
|
+
finished_at: @finished_at
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def top_slow_queries
|
|
224
|
+
@slow_queries.sort_by { |q| -q.duration_ms }.first(config.profile_max_saved_queries)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def duplicate_queries
|
|
228
|
+
threshold = [config.profile_duplicate_query_threshold.to_i, 2].max
|
|
229
|
+
|
|
230
|
+
@fingerprints.each_with_object([]) do |(fingerprint, info), acc|
|
|
231
|
+
next if info[:count] < threshold
|
|
232
|
+
|
|
233
|
+
acc << ProfileResult::DuplicateQuery.new(
|
|
234
|
+
fingerprint: fingerprint,
|
|
235
|
+
sql: info[:sql],
|
|
236
|
+
count: info[:count],
|
|
237
|
+
total_duration_ms: info[:total_duration_ms].round(2)
|
|
238
|
+
)
|
|
239
|
+
end.sort_by { |dup| [-dup.count, -dup.total_duration_ms] }
|
|
240
|
+
.first(config.profile_max_saved_queries)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module RailsConsolePro
|
|
6
|
+
module Services
|
|
7
|
+
class QueueActionService
|
|
8
|
+
ActionResult = Struct.new(:success, :message, :warning, :details, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def perform(action:, jid:, queue: nil)
|
|
11
|
+
jid = jid.to_s.strip
|
|
12
|
+
return ActionResult.new(success: false, warning: 'Please provide a job id (jid)') if jid.empty?
|
|
13
|
+
|
|
14
|
+
unless sidekiq_available?
|
|
15
|
+
return ActionResult.new(success: false, warning: 'Queue actions are only supported for Sidekiq adapters right now.')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
unless ensure_sidekiq_api_loaded
|
|
19
|
+
return ActionResult.new(success: false, warning: "Unable to load Sidekiq API. Require 'sidekiq/api' and try again.")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
case action.to_sym
|
|
23
|
+
when :retry
|
|
24
|
+
retry_job(jid)
|
|
25
|
+
when :delete
|
|
26
|
+
delete_job(jid, queue)
|
|
27
|
+
when :details
|
|
28
|
+
job_details(jid, queue)
|
|
29
|
+
else
|
|
30
|
+
ActionResult.new(success: false, warning: "Unknown jobs action: #{action}")
|
|
31
|
+
end
|
|
32
|
+
rescue => e
|
|
33
|
+
ActionResult.new(success: false, warning: e.message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def sidekiq_available?
|
|
39
|
+
defined?(::Sidekiq)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ensure_sidekiq_api_loaded
|
|
43
|
+
return true if defined?(::Sidekiq::Queue)
|
|
44
|
+
|
|
45
|
+
require 'sidekiq/api'
|
|
46
|
+
defined?(::Sidekiq::Queue)
|
|
47
|
+
rescue LoadError
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def retry_job(jid)
|
|
52
|
+
job = find_retry_job(jid)
|
|
53
|
+
unless job
|
|
54
|
+
return ActionResult.new(success: false, warning: "Retry job #{jid} not found in Sidekiq retry set.")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
job.retry
|
|
58
|
+
ActionResult.new(
|
|
59
|
+
success: true,
|
|
60
|
+
message: "Retried job #{jid} from retry set."
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def delete_job(jid, queue)
|
|
65
|
+
job = find_retry_job(jid)
|
|
66
|
+
source = 'retry set'
|
|
67
|
+
unless job
|
|
68
|
+
job = find_queue_job(jid, queue)
|
|
69
|
+
source = queue ? "queue '#{queue}'" : 'queues'
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
unless job
|
|
73
|
+
return ActionResult.new(success: false, warning: "Job #{jid} not found in retry set or queues.")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
job.delete
|
|
77
|
+
ActionResult.new(
|
|
78
|
+
success: true,
|
|
79
|
+
message: "Deleted job #{jid} from #{source}."
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def job_details(jid, queue)
|
|
84
|
+
job = find_retry_job(jid) || find_queue_job(jid, queue)
|
|
85
|
+
unless job
|
|
86
|
+
return ActionResult.new(success: false, warning: "Job #{jid} not found in retry set or queues.")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
ActionResult.new(
|
|
90
|
+
success: true,
|
|
91
|
+
message: "Details for job #{jid}:",
|
|
92
|
+
details: extract_job_details(job)
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def find_retry_job(jid)
|
|
97
|
+
return nil unless defined?(::Sidekiq::RetrySet)
|
|
98
|
+
|
|
99
|
+
::Sidekiq::RetrySet.new.find_job(jid)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def find_queue_job(jid, queue)
|
|
103
|
+
queues =
|
|
104
|
+
if queue
|
|
105
|
+
[safe_queue(queue)]
|
|
106
|
+
elsif ::Sidekiq::Queue.respond_to?(:all)
|
|
107
|
+
::Sidekiq::Queue.all
|
|
108
|
+
else
|
|
109
|
+
[]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
queues.each do |q|
|
|
113
|
+
next unless q
|
|
114
|
+
|
|
115
|
+
job = if q.respond_to?(:find_job)
|
|
116
|
+
q.find_job(jid)
|
|
117
|
+
else
|
|
118
|
+
q.detect { |entry| entry.jid == jid }
|
|
119
|
+
end
|
|
120
|
+
return job if job
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def safe_queue(name)
|
|
127
|
+
::Sidekiq::Queue.new(name)
|
|
128
|
+
rescue
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def extract_job_details(job)
|
|
133
|
+
item = job.respond_to?(:item) ? job.item : {}
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
jid: safe_attr(job, :jid),
|
|
137
|
+
queue: safe_attr(job, :queue) || item['queue'],
|
|
138
|
+
job_class: safe_attr(job, :klass) || safe_attr(job, :display_class) || item['class'] || item['wrapped'],
|
|
139
|
+
wrapped: item['wrapped'],
|
|
140
|
+
args: safe_attr(job, :args) || item['args'],
|
|
141
|
+
enqueued_at: format_time(safe_attr(job, :enqueued_at) || item['enqueued_at']),
|
|
142
|
+
scheduled_at: format_time(safe_attr(job, :at) || item['at']),
|
|
143
|
+
retry_count: safe_attr(job, :retry_count) || item['retry_count'],
|
|
144
|
+
error_class: safe_attr(job, :error_class) || item['error_class'],
|
|
145
|
+
error_message: safe_attr(job, :error_message) || item['error_message'],
|
|
146
|
+
raw: item
|
|
147
|
+
}.delete_if { |_k, v| v.nil? }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def safe_attr(object, method_name)
|
|
151
|
+
return unless object.respond_to?(method_name)
|
|
152
|
+
|
|
153
|
+
object.public_send(method_name)
|
|
154
|
+
rescue
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def format_time(value)
|
|
159
|
+
return if value.nil?
|
|
160
|
+
|
|
161
|
+
case value
|
|
162
|
+
when Time
|
|
163
|
+
value.iso8601
|
|
164
|
+
when Integer, Float
|
|
165
|
+
Time.at(value).utc.iso8601
|
|
166
|
+
else
|
|
167
|
+
value.to_s
|
|
168
|
+
end
|
|
169
|
+
rescue
|
|
170
|
+
value.to_s
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
|