rails_console_pro 0.1.1 → 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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec_status +261 -240
  3. data/CHANGELOG.md +4 -0
  4. data/QUICK_START.md +8 -0
  5. data/README.md +16 -0
  6. data/docs/FORMATTING.md +5 -0
  7. data/docs/MODEL_STATISTICS.md +4 -0
  8. data/docs/OBJECT_DIFFING.md +6 -0
  9. data/docs/PROFILING.md +91 -0
  10. data/docs/QUEUE_INSIGHTS.md +82 -0
  11. data/docs/SCHEMA_INSPECTION.md +5 -0
  12. data/docs/SNIPPETS.md +71 -0
  13. data/lib/rails_console_pro/commands/base_command.rb +1 -1
  14. data/lib/rails_console_pro/commands/jobs_command.rb +212 -0
  15. data/lib/rails_console_pro/commands/profile_command.rb +84 -0
  16. data/lib/rails_console_pro/commands/snippets_command.rb +141 -0
  17. data/lib/rails_console_pro/commands.rb +15 -0
  18. data/lib/rails_console_pro/configuration.rb +39 -0
  19. data/lib/rails_console_pro/format_exporter.rb +8 -0
  20. data/lib/rails_console_pro/global_methods.rb +12 -0
  21. data/lib/rails_console_pro/initializer.rb +29 -4
  22. data/lib/rails_console_pro/model_validator.rb +1 -1
  23. data/lib/rails_console_pro/printers/profile_printer.rb +180 -0
  24. data/lib/rails_console_pro/printers/queue_insights_printer.rb +150 -0
  25. data/lib/rails_console_pro/printers/snippet_collection_printer.rb +68 -0
  26. data/lib/rails_console_pro/printers/snippet_printer.rb +64 -0
  27. data/lib/rails_console_pro/profile_result.rb +109 -0
  28. data/lib/rails_console_pro/pry_commands.rb +106 -0
  29. data/lib/rails_console_pro/queue_insights_result.rb +110 -0
  30. data/lib/rails_console_pro/serializers/profile_serializer.rb +73 -0
  31. data/lib/rails_console_pro/services/profile_collector.rb +245 -0
  32. data/lib/rails_console_pro/services/queue_action_service.rb +176 -0
  33. data/lib/rails_console_pro/services/queue_insight_fetcher.rb +600 -0
  34. data/lib/rails_console_pro/services/snippet_repository.rb +191 -0
  35. data/lib/rails_console_pro/snippets/collection_result.rb +44 -0
  36. data/lib/rails_console_pro/snippets/single_result.rb +30 -0
  37. data/lib/rails_console_pro/snippets/snippet.rb +112 -0
  38. data/lib/rails_console_pro/snippets.rb +12 -0
  39. data/lib/rails_console_pro/version.rb +1 -1
  40. data/rails_console_pro.gemspec +1 -1
  41. 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
+