vernier 1.9.0 → 1.10.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: c3aa6d3665db78499efc979f38af3d3a4f633e105ccd3a6a17b9e11ad19556cf
4
- data.tar.gz: eb752d610602a89417c894a360ea46f7727ce9a9bd11a13a2f608d35d07d4680
3
+ metadata.gz: d1af998d7d2517b6c73e0ef7ec8a3e0762c9c0ca23dd859d3c268842f064612f
4
+ data.tar.gz: 558ad40b4ca261719e241f22806112e9ff18b2ba0e1a65d5c24067deee0803e0
5
5
  SHA512:
6
- metadata.gz: 3a96e0d3fa78b72e3a54c05eb871af65d405c32c44e0c0d8bc5efbeffaad59bb8490ab5c599e1a435d755093927c623e813965cda31a9bbeda43e3c2c41945cc
7
- data.tar.gz: 32355e62e58124b426899dc4c167139521081d8be2bcf454aa3b0fb4916fb92334464a5685cccf5b712ee2d3d23a328a804b96b12a79e85e590f30d31d28fc66
6
+ metadata.gz: 58a0c75db2f1ffb90017f887c4e6813653ef908efc4601cbe6fe105aa1493c5344028355d1ec68658e59c34d36b885fcd59340de553bfffc1a72fcadd7668398
7
+ data.tar.gz: a80cd4ab4c0da94d3799cbedfe93de02788dc48bb7626565eff62cfc7606aa75c93892f5dab17d6b076abf6ea361cbf9231940cfb40384c8116490aff5dee79e
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.4.7
1
+ 4.0
@@ -47,8 +47,6 @@ class HeapTracker {
47
47
  }
48
48
 
49
49
  void record_newobj(VALUE obj) {
50
- objects_allocated++;
51
-
52
50
  RawSample sample;
53
51
  sample.sample();
54
52
  if (sample.empty()) {
@@ -59,13 +57,13 @@ class HeapTracker {
59
57
  }
60
58
  int stack_index = stack_table->stack_index(sample);
61
59
 
60
+ objects_allocated++;
62
61
  int idx = object_list.size();
63
62
  object_list.push_back(obj);
64
63
  frame_list.push_back(stack_index);
65
64
  object_index.emplace(obj, idx);
66
65
 
67
- assert(objects_allocated == frame_list.size());
68
- assert(objects_allocated == object_list.size());
66
+ assert(frame_list.size() == object_list.size());
69
67
  }
70
68
 
71
69
  void rebuild() {
@@ -140,6 +138,9 @@ class HeapTracker {
140
138
  if (RTEST(tp_newobj)) {
141
139
  rb_tracepoint_disable(tp_newobj);
142
140
  tp_newobj = Qnil;
141
+ if (tombstones * 10 > object_list.size()) {
142
+ rebuild();
143
+ }
143
144
  }
144
145
  }
145
146
 
@@ -29,7 +29,9 @@
29
29
  #undef assert
30
30
  #define assert RUBY_ASSERT_ALWAYS
31
31
 
32
+ #ifndef PTR2NUM
32
33
  # define PTR2NUM(x) (rb_int2inum((intptr_t)(void *)(x)))
34
+ #endif
33
35
 
34
36
  // Internal TracePoint events we'll monitor during profiling
35
37
  #define RUBY_INTERNAL_EVENTS \
@@ -48,8 +48,11 @@ module Vernier
48
48
  end
49
49
  prefix = "profile-"
50
50
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
51
- suffix = if options[:format] == "cpuprofile"
51
+ suffix = case options[:format]
52
+ when "cpuprofile"
52
53
  ".vernier.cpuprofile"
54
+ when "markdown", "md"
55
+ ".vernier.md"
53
56
  else
54
57
  ".vernier.json.gz"
55
58
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "vernier" # Make sure constants are loaded
3
+ require "vernier/vernier"
4
4
 
5
5
  module Vernier
6
6
  module Marker
@@ -1,5 +1,10 @@
1
1
  module Vernier
2
2
  class Middleware
3
+ HOOKS = [
4
+ (:rails if defined?(Rails))
5
+ ].compact.freeze
6
+ private_constant :HOOKS
7
+
3
8
  def initialize(app, permit: ->(_env) { true })
4
9
  @app = app
5
10
  @permit = permit
@@ -15,7 +20,7 @@ module Vernier
15
20
  interval = request.GET.fetch("vernier_interval", 200).to_i
16
21
  allocation_interval = request.GET.fetch("vernier_allocation_interval", 200).to_i
17
22
 
18
- result = Vernier.trace(interval:, allocation_interval:, hooks: [:rails]) do
23
+ result = Vernier.trace(interval:, allocation_interval:, hooks: HOOKS) do
19
24
  @app.call(env)
20
25
  end
21
26
  body = result.to_firefox(gzip: true)
@@ -267,6 +267,11 @@ module Vernier
267
267
  end
268
268
 
269
269
  class Thread
270
+ SAMPLE_CATEGORY_NAMES = {
271
+ 1 => "Idle",
272
+ 2 => "Stalled"
273
+ }.freeze
274
+
270
275
  attr_reader :profile, :is_start
271
276
 
272
277
  def initialize(ruby_thread_id, profile, categorizer, name:, tid:, samples:, weights:, timestamps: nil, sample_categories: nil, markers:, started_at:, stopped_at: nil, allocations: nil, is_main: nil, is_start: nil)
@@ -355,6 +360,31 @@ module Vernier
355
360
  @frame_subcategories = @stack_table_hash[:frame_table].fetch(:func).map do |func_idx|
356
361
  func_subcategories[func_idx]
357
362
  end
363
+
364
+ @sample_category_idx = SAMPLE_CATEGORY_NAMES.transform_values do |name|
365
+ @categorizer.get_category(name).idx
366
+ end
367
+
368
+ @samples.zip(@sample_categories).each do |sample, raw_category|
369
+ next if raw_category == 0
370
+
371
+ @categorized_stacks[[sample, raw_category]]
372
+ end
373
+
374
+ base_stack_frames = @stack_table_hash[:stack_table].fetch(:frame)
375
+ base_frame_count = @stack_table_hash[:frame_table].fetch(:func).size
376
+ @extra_frames = []
377
+ @categorized_frame_map = {}
378
+
379
+ @categorized_stacks.each_key do |(stack, raw_category)|
380
+ original_frame_idx = base_stack_frames[stack]
381
+ key = [original_frame_idx, raw_category]
382
+ next if @categorized_frame_map.key?(key)
383
+
384
+ new_frame_idx = base_frame_count + @extra_frames.size
385
+ @categorized_frame_map[key] = new_frame_idx
386
+ @extra_frames << [original_frame_idx, @sample_category_idx[raw_category]]
387
+ end
358
388
  end
359
389
 
360
390
  def categorize_filename(filename)
@@ -377,7 +407,7 @@ module Vernier
377
407
  def find_category_and_subcategory(filename, categories)
378
408
  categories.each do |category_name|
379
409
  category = @categorizer.get_category(category_name)
380
- subcategory = category.subcategories.detect {|c| c.matches?(filename) }&.idx
410
+ subcategory = category.subcategories.detect { |c| c.matches?(filename) }&.idx
381
411
  return category, subcategory if subcategory
382
412
  end
383
413
  [nil, nil]
@@ -430,7 +460,7 @@ module Vernier
430
460
  categories = []
431
461
  data = []
432
462
 
433
- @markers.each_with_index do |(_, name, start, finish, phase, datum), i|
463
+ @markers.each do |(_, name, start, finish, phase, datum)|
434
464
  string_indexes << @strings[name]
435
465
  start_times << (start / 1_000_000.0)
436
466
 
@@ -463,12 +493,14 @@ module Vernier
463
493
  end
464
494
 
465
495
  def allocations_table
466
- return nil if !@allocations
496
+ return nil unless @allocations
497
+
467
498
  samples, weights, timestamps = @allocations.values_at(:samples, :weights, :timestamps)
468
- return nil if samples.size == 0
499
+ return nil if samples.empty?
500
+
469
501
  size = samples.size
470
502
  timestamps = timestamps.map { _1 / 1_000_000.0 }
471
- ret = {
503
+ {
472
504
  "time": timestamps,
473
505
  "className": ["Object"]*size,
474
506
  "typeName": ["JSObject"]*size,
@@ -478,7 +510,6 @@ module Vernier
478
510
  "stack": samples,
479
511
  "length": size
480
512
  }
481
- ret
482
513
  end
483
514
 
484
515
  def samples_table
@@ -518,21 +549,22 @@ module Vernier
518
549
  end
519
550
 
520
551
  def stack_table
521
- frames = @stack_table_hash[:stack_table].fetch(:frame).dup
552
+ base_frames = @stack_table_hash[:stack_table].fetch(:frame)
553
+ frames = base_frames.dup
522
554
  prefixes = @stack_table_hash[:stack_table].fetch(:parent).dup
523
- categories = frames.map{|idx| @frame_categories[idx].idx }
524
- subcategories = frames.map{|idx| @frame_subcategories[idx] }
555
+ categories = frames.map { |idx| @frame_categories[idx].idx }
556
+ subcategories = frames.map { |idx| @frame_subcategories[idx] }
525
557
 
526
- @categorized_stacks.each_key do |(stack, category)|
527
- frames << frames[stack]
558
+ @categorized_stacks.each_key do |(stack, raw_category)|
559
+ original_frame_idx = base_frames[stack]
560
+ frames << @categorized_frame_map[[original_frame_idx, raw_category]]
528
561
  prefixes << prefixes[stack]
529
- categories << category
562
+ categories << @sample_category_idx[raw_category]
530
563
  subcategories << 0
531
564
  end
532
565
 
533
- size = frames.length
534
- raise unless frames.size == size
535
- raise unless prefixes.size == size
566
+ raise unless prefixes.size == frames.size
567
+
536
568
  {
537
569
  frame: frames,
538
570
  category: categories,
@@ -543,18 +575,27 @@ module Vernier
543
575
  end
544
576
 
545
577
  def frame_table
546
- funcs = @stack_table_hash[:frame_table].fetch(:func)
547
- lines = @stack_table_hash[:frame_table].fetch(:line)
578
+ funcs = @stack_table_hash[:frame_table].fetch(:func).dup
579
+ lines = @stack_table_hash[:frame_table].fetch(:line).dup
548
580
  raise unless lines.size == funcs.size
549
581
 
582
+ categories = @frame_categories.map(&:idx)
583
+ subcategories = @frame_subcategories.dup
584
+ implementations = @frame_implementations.dup
585
+
586
+ @extra_frames.each do |(frame_idx, category_idx)|
587
+ funcs << funcs[frame_idx]
588
+ lines << lines[frame_idx]
589
+ categories << category_idx
590
+ subcategories << 0
591
+ implementations << implementations[frame_idx]
592
+ end
593
+
550
594
  size = funcs.size
551
595
  none = [nil] * size
552
596
  default = [0] * size
553
597
  unidentified = [-1] * size
554
598
 
555
- categories = @frame_categories.map(&:idx)
556
- subcategories = @frame_subcategories
557
-
558
599
  {
559
600
  address: unidentified,
560
601
  inlineDepth: default,
@@ -563,7 +604,7 @@ module Vernier
563
604
  func: funcs,
564
605
  nativeSymbol: none,
565
606
  innerWindowID: none,
566
- implementation: @frame_implementations,
607
+ implementation: implementations,
567
608
  line: lines,
568
609
  column: none,
569
610
  length: size
@@ -605,11 +646,8 @@ module Vernier
605
646
  else
606
647
  string.scrub
607
648
  end
608
- elsif string.encoding == Encoding::BINARY
609
- # TODO: We might want to guess UTF-8 and escape the binary more explicitly
610
- string.dup.force_encoding("UTF-8").scrub
611
649
  else
612
- # TODO: ideally we should attempt to properly re-encode here, but right now I think this is dead code
650
+ # TODO: We might want to guess UTF-8 and escape the binary more explicitly
613
651
  string.dup.force_encoding("UTF-8").scrub
614
652
  end
615
653
  end
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "filename_filter"
4
+
5
+ module Vernier
6
+ module Output
7
+ class Markdown
8
+ DEFAULT_TOP_N = 20
9
+ DEFAULT_LINES_PER_FILE = 5
10
+
11
+ def initialize(profile, top_n: DEFAULT_TOP_N, lines_per_file: DEFAULT_LINES_PER_FILE)
12
+ @profile = profile
13
+ @top_n = top_n
14
+ @lines_per_file = lines_per_file
15
+ end
16
+
17
+ def output
18
+ out = +""
19
+ out << build_title
20
+ out << build_summary
21
+ out << build_hotspots
22
+ out << build_threads
23
+ out << build_files
24
+ out
25
+ end
26
+
27
+ private
28
+
29
+ def build_title
30
+ "# Vernier Profile\n\n"
31
+ end
32
+
33
+ def build_summary
34
+ out = +"## Summary\n\n"
35
+
36
+ mode = @profile.meta[:mode] rescue nil
37
+ weight_unit = mode == :retained ? "bytes" : "samples"
38
+
39
+ out << "| Metric | Value |\n"
40
+ out << "|--------|-------|\n"
41
+ out << "| Mode | #{mode || 'unknown'} |\n"
42
+
43
+ if @profile.respond_to?(:elapsed_seconds)
44
+ begin
45
+ out << "| Duration | #{format("%.2f", @profile.elapsed_seconds)} seconds |\n"
46
+ rescue
47
+ # elapsed_seconds may fail if end_time not set
48
+ end
49
+ end
50
+
51
+ out << "| Total Samples | #{total_samples} |\n"
52
+ out << "| Total Unique Samples | #{@profile.total_unique_samples rescue 'N/A'} |\n"
53
+ out << "| Threads | #{thread_count} |\n"
54
+ out << "| Weight Unit | #{weight_unit} |\n"
55
+
56
+ if @profile.respond_to?(:pid) && @profile.pid
57
+ out << "| PID | #{@profile.pid} |\n"
58
+ end
59
+
60
+ out << "\n"
61
+
62
+ # User metadata if available
63
+ if @profile.respond_to?(:meta) && @profile.meta[:user_metadata]
64
+ metadata = @profile.meta[:user_metadata]
65
+ unless metadata.empty?
66
+ out << "### User Metadata\n\n"
67
+ out << "| Key | Value |\n"
68
+ out << "|-----|-------|\n"
69
+ metadata.each do |k, v|
70
+ out << "| #{escape_markdown(k.to_s)} | #{escape_markdown(v.to_s)} |\n"
71
+ end
72
+ out << "\n"
73
+ end
74
+ end
75
+
76
+ out
77
+ end
78
+
79
+ def build_hotspots
80
+ out = +"## Top Hotspots\n\n"
81
+
82
+ thread = main_thread
83
+ return out << "_No samples collected._\n\n" unless thread
84
+
85
+ stack_table = get_stack_table(thread)
86
+ samples = thread[:samples] || []
87
+ weights = thread[:weights] || []
88
+
89
+ return out << "_No samples collected._\n\n" if samples.empty?
90
+
91
+ # Compute self weights per function
92
+ total = weights.sum
93
+ return out << "_No samples collected._\n\n" if total == 0
94
+
95
+ top_by_func = Hash.new { |h, k| h[k] = { self: 0, total: 0, file: nil, line: nil } }
96
+
97
+ samples.zip(weights).each do |stack_idx, weight|
98
+ # Self time: top frame only
99
+ frame_idx = stack_table.stack_frame_idx(stack_idx)
100
+ func_idx = stack_table.frame_func_idx(frame_idx)
101
+ name = stack_table.func_name(func_idx)
102
+ filename = stack_table.func_filename(func_idx)
103
+ first_lineno = stack_table.func_first_lineno(func_idx)
104
+
105
+ top_by_func[name][:self] += weight
106
+ top_by_func[name][:file] ||= filename
107
+ top_by_func[name][:line] ||= first_lineno
108
+
109
+ # Total time: walk up the stack
110
+ seen = {}
111
+ current_stack_idx = stack_idx
112
+ while current_stack_idx
113
+ frame_idx = stack_table.stack_frame_idx(current_stack_idx)
114
+ func_idx = stack_table.frame_func_idx(frame_idx)
115
+ func_name = stack_table.func_name(func_idx)
116
+
117
+ unless seen[func_name]
118
+ seen[func_name] = true
119
+ top_by_func[func_name][:total] += weight
120
+ top_by_func[func_name][:file] ||= stack_table.func_filename(func_idx)
121
+ top_by_func[func_name][:line] ||= stack_table.func_first_lineno(func_idx)
122
+ end
123
+
124
+ current_stack_idx = stack_table.stack_parent_idx(current_stack_idx)
125
+ end
126
+ end
127
+
128
+ out << top_functions_table("By Self Time", top_by_func, total, :self)
129
+ out << top_functions_table("By Total Time", top_by_func, total, :total)
130
+
131
+ out
132
+ end
133
+
134
+ def top_functions_table(title, funcs, total, sort_key)
135
+ out = +"### #{title}\n\n"
136
+ sorted = funcs.sort_by { |_, v| -v[sort_key] }.first(@top_n)
137
+
138
+ primary = sort_key == :self ? "Self" : "Total"
139
+ secondary = sort_key == :self ? "Total" : "Self"
140
+ out << "| Rank | #{primary} % | #{secondary} % | Function | Location |\n"
141
+ out << "|------|--------|---------|----------|----------|\n"
142
+
143
+ sorted.each_with_index do |(name, data), idx|
144
+ self_pct = 100.0 * data[:self] / total
145
+ total_pct = 100.0 * data[:total] / total
146
+ primary_pct = sort_key == :self ? self_pct : total_pct
147
+ secondary_pct = sort_key == :self ? total_pct : self_pct
148
+ location = format_location(data[:file], data[:line])
149
+ out << "| #{idx + 1} | #{format("%.1f", primary_pct)}% | #{format("%.1f", secondary_pct)}% | #{format_code_span(name)} | #{escape_markdown(location)} |\n"
150
+ end
151
+
152
+ out << "\n"
153
+ out
154
+ end
155
+
156
+ def build_threads
157
+ out = +"## Threads\n\n"
158
+
159
+ threads_data = get_threads
160
+ return out << "_No thread information available._\n\n" if threads_data.empty?
161
+
162
+ threads_data.each do |thread_id, thread|
163
+ name = get_thread_name(thread) || "Thread #{thread_id}"
164
+ is_main = get_thread_main(thread) ? "yes" : "no"
165
+ tid = get_thread_tid(thread) || thread_id
166
+
167
+ samples = thread[:samples] || []
168
+ weights = thread[:weights] || []
169
+ sample_count = samples.size
170
+ weight_sum = weights.sum
171
+
172
+ out << "### #{escape_markdown(name)}\n\n"
173
+ out << "| Property | Value |\n"
174
+ out << "|----------|-------|\n"
175
+ out << "| TID | #{tid} |\n"
176
+ out << "| Main Thread | #{is_main} |\n"
177
+ out << "| Samples | #{sample_count} |\n"
178
+ out << "| Total Weight | #{weight_sum} |\n"
179
+ out << "\n"
180
+ end
181
+
182
+ out
183
+ end
184
+
185
+ def build_files
186
+ out = +"## Hot Files\n\n"
187
+
188
+ thread = main_thread
189
+ return out << "_No file information available._\n\n" unless thread
190
+
191
+ stack_table = get_stack_table(thread)
192
+ samples = thread[:samples] || []
193
+ weights = thread[:weights] || []
194
+
195
+ return out << "_No samples collected._\n\n" if samples.empty?
196
+
197
+ total = weights.sum
198
+ return out << "_No samples collected._\n\n" if total == 0
199
+
200
+ # Build samples by file and line (similar to FileListing)
201
+ samples_by_frame = Hash.new { |h, k| h[k] = { self: 0, total: 0 } }
202
+
203
+ samples.zip(weights).each do |stack_idx, weight|
204
+ # Self time: top frame only
205
+ top_frame_idx = stack_table.stack_frame_idx(stack_idx)
206
+ samples_by_frame[top_frame_idx][:self] += weight
207
+
208
+ # Total time: walk up the stack
209
+ current_stack_idx = stack_idx
210
+ while current_stack_idx
211
+ frame_idx = stack_table.stack_frame_idx(current_stack_idx)
212
+ samples_by_frame[frame_idx][:total] += weight
213
+ current_stack_idx = stack_table.stack_parent_idx(current_stack_idx)
214
+ end
215
+ end
216
+
217
+ # Group by file
218
+ samples_by_file = Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = { self: 0, total: 0 } } }
219
+
220
+ samples_by_frame.each do |frame_idx, data|
221
+ func_idx = stack_table.frame_func_idx(frame_idx)
222
+ filename = stack_table.func_filename(func_idx)
223
+ line = stack_table.frame_line_no(frame_idx)
224
+
225
+ filename = filter_filename(filename)
226
+ samples_by_file[filename][line][:self] += data[:self]
227
+ samples_by_file[filename][line][:total] += data[:total]
228
+ end
229
+
230
+ # Filter to relevant files (>1% self time, exclude gem/rubylib/<)
231
+ relevant_files = samples_by_file.select do |filename, lines|
232
+ next false if filename.start_with?("gem:")
233
+ next false if filename.start_with?("rubylib:")
234
+ next false if filename.start_with?("<")
235
+
236
+ lines.values.map { |d| d[:self] }.max > total * 0.01
237
+ end
238
+
239
+ if relevant_files.empty?
240
+ return out << "_No significant file hotspots found._\n\n"
241
+ end
242
+
243
+ # Sort files by total weight
244
+ sorted_files = relevant_files.sort_by { |_, lines| -lines.values.map { |d| d[:self] }.sum }
245
+
246
+ sorted_files.each do |filename, lines|
247
+ out << "### #{escape_markdown(filename)}\n\n"
248
+
249
+ # Get top lines by self weight
250
+ sorted_lines = lines.sort_by { |_, d| -d[:self] }.first(@lines_per_file)
251
+
252
+ out << "| Line | Self % | Total % | Code |\n"
253
+ out << "|------|--------|---------|------|\n"
254
+
255
+ sorted_lines.each do |line_no, data|
256
+ self_pct = 100.0 * data[:self] / total
257
+ total_pct = 100.0 * data[:total] / total
258
+
259
+ source_line = read_source_line(filename, line_no)
260
+ code = source_line ? truncate_code(source_line) : "_source unavailable_"
261
+
262
+ out << "| #{line_no} | #{format("%.1f", self_pct)}% | #{format("%.1f", total_pct)}% | #{format_code_span(code)} |\n"
263
+ end
264
+
265
+ out << "\n"
266
+ end
267
+
268
+ out
269
+ end
270
+
271
+ # Helper methods
272
+
273
+ def main_thread
274
+ @profile.main_thread
275
+ end
276
+
277
+ def get_threads
278
+ if @profile.respond_to?(:threads)
279
+ threads = @profile.threads
280
+ if threads.is_a?(Hash)
281
+ threads
282
+ elsif threads.is_a?(Array)
283
+ # ParsedProfile returns array
284
+ threads.each_with_index.to_h { |t, i| [i, t] }
285
+ else
286
+ {}
287
+ end
288
+ else
289
+ {}
290
+ end
291
+ end
292
+
293
+ def get_stack_table(thread)
294
+ if thread.respond_to?(:stack_table)
295
+ thread.stack_table
296
+ else
297
+ @profile._stack_table
298
+ end
299
+ end
300
+
301
+ def get_thread_name(thread)
302
+ if thread.is_a?(Hash)
303
+ thread[:name]
304
+ elsif thread.respond_to?(:data)
305
+ thread.data["name"]
306
+ end
307
+ end
308
+
309
+ def get_thread_main(thread)
310
+ if thread.is_a?(Hash)
311
+ thread[:is_main]
312
+ elsif thread.respond_to?(:main_thread?)
313
+ thread.main_thread?
314
+ end
315
+ end
316
+
317
+ def get_thread_tid(thread)
318
+ if thread.is_a?(Hash)
319
+ thread[:tid]
320
+ elsif thread.respond_to?(:data)
321
+ thread.data["tid"]
322
+ end
323
+ end
324
+
325
+ def total_samples
326
+ if @profile.respond_to?(:total_samples)
327
+ @profile.total_samples
328
+ else
329
+ main = main_thread
330
+ main ? (main[:samples]&.size || 0) : 0
331
+ end
332
+ end
333
+
334
+ def thread_count
335
+ threads = get_threads
336
+ threads.is_a?(Hash) ? threads.size : threads.length
337
+ end
338
+
339
+ def filter_filename(filename)
340
+ @filename_filter ||= if live_profile?
341
+ FilenameFilter.new
342
+ else
343
+ ->(x) { x }
344
+ end
345
+ @filename_filter.call(filename)
346
+ end
347
+
348
+ def live_profile?
349
+ thread = main_thread
350
+ thread.is_a?(Hash)
351
+ end
352
+
353
+ def format_location(file, line)
354
+ return "unknown" unless file
355
+ filtered = filter_filename(file)
356
+ line ? "#{filtered}:#{line}" : filtered
357
+ end
358
+
359
+ def read_source_line(filename, line_no)
360
+ return nil unless line_no && line_no > 0
361
+
362
+ # For filtered filenames, try to resolve back to real path
363
+ real_path = resolve_filename(filename)
364
+ return nil unless real_path && File.exist?(real_path)
365
+
366
+ lines = File.readlines(real_path)
367
+ lines[line_no - 1]&.chomp
368
+ rescue
369
+ nil
370
+ end
371
+
372
+ def resolve_filename(filename)
373
+ return nil if filename.start_with?("gem:", "rubylib:", "<")
374
+ return filename if File.exist?(filename)
375
+
376
+ # Try expanding relative to cwd
377
+ expanded = File.expand_path(filename)
378
+ return expanded if File.exist?(expanded)
379
+
380
+ nil
381
+ end
382
+
383
+ def truncate_code(code, max_length: 60)
384
+ code = code.strip
385
+ if code.length > max_length
386
+ code[0, max_length - 3] + "..."
387
+ else
388
+ code
389
+ end
390
+ end
391
+
392
+ def format_code_span(text)
393
+ content = text.to_s.gsub("|", "\\|")
394
+
395
+ # Find longest run of consecutive backticks in content
396
+ max_run = content.scan(/`+/).map(&:length).max || 0
397
+ delimiter = "`" * (max_run + 1)
398
+
399
+ if delimiter.length > 1
400
+ "#{delimiter} #{content} #{delimiter}"
401
+ else
402
+ "#{delimiter}#{content}#{delimiter}"
403
+ end
404
+ end
405
+
406
+ def escape_markdown(text)
407
+ return "" unless text
408
+ text.to_s
409
+ .gsub("&", "&amp;")
410
+ .gsub("<", "&lt;")
411
+ .gsub(">", "&gt;")
412
+ .gsub(/([|`*_\[\]])/, '\\\\\1')
413
+ end
414
+ end
415
+ end
416
+ end
@@ -29,7 +29,7 @@ module Vernier
29
29
  @frame_lines = thread_data["frameTable"]["line"]
30
30
  @func_names = thread_data["funcTable"]["name"]
31
31
  @func_filenames = thread_data["funcTable"]["fileName"]
32
- #@func_first_linenos = thread_data["funcTable"]["first"]
32
+ @func_first_linenos = thread_data["funcTable"]["lineNumber"]
33
33
  @strings = thread_data["stringArray"]
34
34
  end
35
35
 
@@ -49,7 +49,7 @@ module Vernier
49
49
  def func_filename_idx(idx) = @func_filenames[idx]
50
50
  def func_name(idx) = @strings[func_name_idx(idx)]
51
51
  def func_filename(idx) = @strings[func_filename_idx(idx)]
52
- def func_first_lineno(idx) = @func_first_lineno[idx]
52
+ def func_first_lineno(idx) = @func_first_linenos[idx]
53
53
 
54
54
  include StackTableHelpers
55
55
  end
@@ -30,6 +30,10 @@ module Vernier
30
30
  Output::Cpuprofile.new(self).output
31
31
  end
32
32
 
33
+ def to_markdown(top_n: 20, lines_per_file: 5)
34
+ Output::Markdown.new(self, top_n: top_n, lines_per_file: lines_per_file).output
35
+ end
36
+
33
37
  def write(out:, format: "firefox")
34
38
  case format
35
39
  when "cpuprofile"
@@ -38,6 +42,12 @@ module Vernier
38
42
  else
39
43
  File.binwrite(out, to_cpuprofile)
40
44
  end
45
+ when "markdown", "md"
46
+ if out.respond_to?(:write)
47
+ out.write(to_markdown)
48
+ else
49
+ File.binwrite(out, to_markdown)
50
+ end
41
51
  when "firefox", nil
42
52
  if out.respond_to?(:write)
43
53
  out.write(to_firefox)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vernier
4
- VERSION = "1.9.0"
4
+ VERSION = "1.10.1"
5
5
  end
data/lib/vernier.rb CHANGED
@@ -1,19 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "vernier/version"
4
- require_relative "vernier/collector"
5
- require_relative "vernier/stack_table"
6
- require_relative "vernier/heap_tracker"
7
- require_relative "vernier/memory_leak_detector"
8
- require_relative "vernier/parsed_profile"
9
- require_relative "vernier/result"
10
- require_relative "vernier/hooks"
11
- require_relative "vernier/vernier"
12
- require_relative "vernier/output/firefox"
13
- require_relative "vernier/output/cpuprofile"
14
- require_relative "vernier/output/top"
15
- require_relative "vernier/output/file_listing"
16
- require_relative "vernier/output/filename_filter"
3
+ require "vernier/version"
4
+ require "vernier/collector"
5
+ require "vernier/stack_table"
6
+ require "vernier/heap_tracker"
7
+ require "vernier/memory_leak_detector"
8
+ require "vernier/parsed_profile"
9
+ require "vernier/result"
10
+ require "vernier/hooks"
11
+ require "vernier/output/firefox"
12
+ require "vernier/output/cpuprofile"
13
+ require "vernier/output/top"
14
+ require "vernier/output/file_listing"
15
+ require "vernier/output/filename_filter"
16
+ require "vernier/output/markdown"
17
+ require "vernier/vernier"
17
18
 
18
19
  module Vernier
19
20
  class Error < StandardError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vernier
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.0
4
+ version: 1.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Hawthorn
@@ -104,6 +104,7 @@ files:
104
104
  - lib/vernier/output/file_listing.rb
105
105
  - lib/vernier/output/filename_filter.rb
106
106
  - lib/vernier/output/firefox.rb
107
+ - lib/vernier/output/markdown.rb
107
108
  - lib/vernier/output/top.rb
108
109
  - lib/vernier/parsed_profile.rb
109
110
  - lib/vernier/result.rb
@@ -133,7 +134,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
133
134
  - !ruby/object:Gem::Version
134
135
  version: '0'
135
136
  requirements: []
136
- rubygems_version: 3.6.9
137
+ rubygems_version: 4.0.6
137
138
  specification_version: 4
138
139
  summary: A next generation CRuby profiler
139
140
  test_files: []