stackprof 0.2.12 → 0.2.26

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.
@@ -0,0 +1,19 @@
1
+ require "stackprof"
2
+
3
+ options = {}
4
+ options[:mode] = ENV["STACKPROF_MODE"].to_sym if ENV.key?("STACKPROF_MODE")
5
+ options[:interval] = Integer(ENV["STACKPROF_INTERVAL"]) if ENV.key?("STACKPROF_INTERVAL")
6
+ options[:raw] = true if ENV["STACKPROF_RAW"]
7
+ options[:ignore_gc] = true if ENV["STACKPROF_IGNORE_GC"]
8
+
9
+ at_exit do
10
+ StackProf.stop
11
+ output_path = ENV.fetch("STACKPROF_OUT") do
12
+ require "tempfile"
13
+ Tempfile.create(["stackprof", ".dump"]).path
14
+ end
15
+ StackProf.results(output_path)
16
+ $stderr.puts("StackProf results dumped at: #{output_path}")
17
+ end
18
+
19
+ StackProf.start(**options)
@@ -13,12 +13,18 @@ module StackProf
13
13
  Middleware.enabled = options[:enabled]
14
14
  options[:path] = 'tmp/' if options[:path].to_s.empty?
15
15
  Middleware.path = options[:path]
16
+ Middleware.metadata = options[:metadata] || {}
16
17
  at_exit{ Middleware.save } if options[:save_at_exit]
17
18
  end
18
19
 
19
20
  def call(env)
20
21
  enabled = Middleware.enabled?(env)
21
- StackProf.start(mode: Middleware.mode, interval: Middleware.interval, raw: Middleware.raw) if enabled
22
+ StackProf.start(
23
+ mode: Middleware.mode,
24
+ interval: Middleware.interval,
25
+ raw: Middleware.raw,
26
+ metadata: Middleware.metadata,
27
+ ) if enabled
22
28
  @app.call(env)
23
29
  ensure
24
30
  if enabled
@@ -31,7 +37,7 @@ module StackProf
31
37
  end
32
38
 
33
39
  class << self
34
- attr_accessor :enabled, :mode, :interval, :raw, :path
40
+ attr_accessor :enabled, :mode, :interval, :raw, :path, :metadata
35
41
 
36
42
  def enabled?(env)
37
43
  if enabled.respond_to?(:call)
@@ -1,8 +1,44 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'pp'
2
- require 'digest/md5'
4
+ require 'digest/sha2'
5
+ require 'json'
3
6
 
4
7
  module StackProf
5
8
  class Report
9
+ MARSHAL_SIGNATURE = "\x04\x08"
10
+
11
+ class << self
12
+ def from_file(file)
13
+ if (content = IO.binread(file)).start_with?(MARSHAL_SIGNATURE)
14
+ new(Marshal.load(content))
15
+ else
16
+ from_json(JSON.parse(content))
17
+ end
18
+ end
19
+
20
+ def from_json(json)
21
+ new(parse_json(json))
22
+ end
23
+
24
+ def parse_json(json)
25
+ json.keys.each do |key|
26
+ value = json.delete(key)
27
+ from_json(value) if value.is_a?(Hash)
28
+
29
+ new_key = case key
30
+ when /\A[0-9]*\z/
31
+ key.to_i
32
+ else
33
+ key.to_sym
34
+ end
35
+
36
+ json[new_key] = value
37
+ end
38
+ json
39
+ end
40
+ end
41
+
6
42
  def initialize(data)
7
43
  @data = data
8
44
  end
@@ -16,7 +52,7 @@ module StackProf
16
52
  def normalized_frames
17
53
  id2hash = {}
18
54
  @data[:frames].each do |frame, info|
19
- id2hash[frame.to_s] = info[:hash] = Digest::MD5.hexdigest("#{info[:name]}#{info[:file]}#{info[:line]}")
55
+ id2hash[frame.to_s] = info[:hash] = Digest::SHA256.hexdigest("#{info[:name]}#{info[:file]}#{info[:line]}")
20
56
  end
21
57
  @data[:frames].inject(Hash.new) do |hash, (frame, info)|
22
58
  info = hash[id2hash[frame.to_s]] = info.dup
@@ -38,7 +74,7 @@ module StackProf
38
74
  end
39
75
 
40
76
  def max_samples
41
- @data[:max_samples] ||= frames.max_by{ |addr, frame| frame[:samples] }.last[:samples]
77
+ @data[:max_samples] ||= @data[:frames].values.max_by{ |frame| frame[:samples] }[:samples]
42
78
  end
43
79
 
44
80
  def files
@@ -68,6 +104,11 @@ module StackProf
68
104
  f.puts Marshal.dump(@data.reject{|k,v| k == :files })
69
105
  end
70
106
 
107
+ def print_json(f=STDOUT)
108
+ require "json"
109
+ f.puts JSON.generate(@data, max_nesting: false)
110
+ end
111
+
71
112
  def print_stackcollapse
72
113
  raise "profile does not include raw samples (add `raw: true` to collecting StackProf.run)" unless raw = data[:raw]
73
114
 
@@ -80,18 +121,20 @@ module StackProf
80
121
  end
81
122
  end
82
123
 
83
- def print_flamegraph(f=STDOUT, skip_common=true)
124
+ def print_timeline_flamegraph(f=STDOUT, skip_common=true)
125
+ print_flamegraph(f, skip_common, false)
126
+ end
127
+
128
+ def print_alphabetical_flamegraph(f=STDOUT, skip_common=true)
129
+ print_flamegraph(f, skip_common, true)
130
+ end
131
+
132
+ def print_flamegraph(f, skip_common, alphabetical=false)
84
133
  raise "profile does not include raw samples (add `raw: true` to collecting StackProf.run)" unless raw = data[:raw]
85
134
 
86
- stacks = []
87
- max_x = 0
88
- max_y = 0
89
- while len = raw.shift
90
- max_y = len if len > max_y
91
- stack = raw.slice!(0, len+1)
92
- stacks << stack
93
- max_x += stack.last
94
- end
135
+ stacks, max_x, max_y = flamegraph_stacks(raw)
136
+
137
+ stacks.sort! if alphabetical
95
138
 
96
139
  f.puts 'flamegraph(['
97
140
  max_y.times do |y|
@@ -141,13 +184,233 @@ module StackProf
141
184
  f.puts '])'
142
185
  end
143
186
 
187
+ def flamegraph_stacks(raw)
188
+ stacks = []
189
+ max_x = 0
190
+ max_y = 0
191
+ idx = 0
192
+
193
+ while len = raw[idx]
194
+ idx += 1
195
+ max_y = len if len > max_y
196
+ stack = raw.slice(idx, len+1)
197
+ idx += len+1
198
+ stacks << stack
199
+ max_x += stack.last
200
+ end
201
+
202
+ return stacks, max_x, max_y
203
+ end
204
+
144
205
  def flamegraph_row(f, x, y, weight, addr)
145
- frame = frames[addr]
206
+ frame = @data[:frames][addr]
146
207
  f.print ',' if @rows_started
147
208
  @rows_started = true
148
209
  f.puts %{{"x":#{x},"y":#{y},"width":#{weight},"frame_id":#{addr},"frame":#{frame[:name].dump},"file":#{frame[:file].dump}}}
149
210
  end
150
211
 
212
+ def convert_to_d3_flame_graph_format(name, stacks, depth)
213
+ weight = 0
214
+ children = []
215
+ stacks.chunk do |stack|
216
+ if depth == stack.length - 1
217
+ :leaf
218
+ else
219
+ stack[depth]
220
+ end
221
+ end.each do |val, child_stacks|
222
+ if val == :leaf
223
+ child_stacks.each do |stack|
224
+ weight += stack.last
225
+ end
226
+ else
227
+ frame = @data[:frames][val]
228
+ child_name = "#{ frame[:name] } : #{ frame[:file] } : #{ frame[:line] }"
229
+ child_data = convert_to_d3_flame_graph_format(child_name, child_stacks, depth + 1)
230
+ weight += child_data["value"]
231
+ children << child_data
232
+ end
233
+ end
234
+
235
+ {
236
+ "name" => name,
237
+ "value" => weight,
238
+ "children" => children,
239
+ }
240
+ end
241
+
242
+ def print_d3_flamegraph(f=STDOUT, skip_common=true)
243
+ raise "profile does not include raw samples (add `raw: true` to collecting StackProf.run)" unless raw = data[:raw]
244
+
245
+ stacks, * = flamegraph_stacks(raw)
246
+
247
+ # d3-flame-grpah supports only alphabetical flamegraph
248
+ stacks.sort!
249
+
250
+ require "json"
251
+ json = JSON.generate(convert_to_d3_flame_graph_format("<root>", stacks, 0), max_nesting: false)
252
+
253
+ # This html code is almost copied from d3-flame-graph sample code.
254
+ # (Apache License 2.0)
255
+ # https://github.com/spiermar/d3-flame-graph/blob/gh-pages/index.html
256
+
257
+ f.print <<-END
258
+ <!DOCTYPE html>
259
+ <html lang="en">
260
+ <head>
261
+ <meta charset="utf-8">
262
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
263
+ <meta name="viewport" content="width=device-width, initial-scale=1">
264
+
265
+ <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
266
+ <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/gh/spiermar/d3-flame-graph@2.0.3/dist/d3-flamegraph.css">
267
+
268
+ <style>
269
+
270
+ /* Space out content a bit */
271
+ body {
272
+ padding-top: 20px;
273
+ padding-bottom: 20px;
274
+ }
275
+
276
+ /* Custom page header */
277
+ .header {
278
+ padding-bottom: 20px;
279
+ padding-right: 15px;
280
+ padding-left: 15px;
281
+ border-bottom: 1px solid #e5e5e5;
282
+ }
283
+
284
+ /* Make the masthead heading the same height as the navigation */
285
+ .header h3 {
286
+ margin-top: 0;
287
+ margin-bottom: 0;
288
+ line-height: 40px;
289
+ }
290
+
291
+ /* Customize container */
292
+ .container {
293
+ max-width: 990px;
294
+ }
295
+
296
+ address {
297
+ text-align: right;
298
+ }
299
+ </style>
300
+
301
+ <title>stackprof (mode: #{ data[:mode] })</title>
302
+
303
+ <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
304
+ <!--[if lt IE 9]>
305
+ <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
306
+ <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
307
+ <![endif]-->
308
+ </head>
309
+ <body>
310
+ <div class="container">
311
+ <div class="header clearfix">
312
+ <nav>
313
+ <div class="pull-right">
314
+ <form class="form-inline" id="form">
315
+ <a class="btn" href="javascript: resetZoom();">Reset zoom</a>
316
+ <a class="btn" href="javascript: clear();">Clear</a>
317
+ <div class="form-group">
318
+ <input type="text" class="form-control" id="term">
319
+ </div>
320
+ <a class="btn btn-primary" href="javascript: search();">Search</a>
321
+ </form>
322
+ </div>
323
+ </nav>
324
+ <h3 class="text-muted">stackprof (mode: #{ data[:mode] })</h3>
325
+ </div>
326
+ <div id="chart">
327
+ </div>
328
+ <address>
329
+ powered by <a href="https://github.com/spiermar/d3-flame-graph">d3-flame-graph</a>
330
+ </address>
331
+ <hr>
332
+ <div id="details">
333
+ </div>
334
+ </div>
335
+
336
+ <!-- D3.js -->
337
+ <script src="https://d3js.org/d3.v4.min.js" charset="utf-8"></script>
338
+
339
+ <!-- d3-tip -->
340
+ <script type="text/javascript" src=https://cdnjs.cloudflare.com/ajax/libs/d3-tip/0.9.1/d3-tip.min.js></script>
341
+
342
+ <!-- d3-flamegraph -->
343
+ <script type="text/javascript" src="https://cdn.jsdelivr.net/gh/spiermar/d3-flame-graph@2.0.3/dist/d3-flamegraph.min.js"></script>
344
+
345
+ <script type="text/javascript">
346
+ var flameGraph = d3.flamegraph()
347
+ .width(960)
348
+ .cellHeight(18)
349
+ .transitionDuration(750)
350
+ .minFrameSize(5)
351
+ .transitionEase(d3.easeCubic)
352
+ .sort(true)
353
+ //Example to sort in reverse order
354
+ //.sort(function(a,b){ return d3.descending(a.name, b.name);})
355
+ .title("")
356
+ .onClick(onClick)
357
+ .differential(false)
358
+ .selfValue(false);
359
+
360
+
361
+ // Example on how to use custom tooltips using d3-tip.
362
+ // var tip = d3.tip()
363
+ // .direction("s")
364
+ // .offset([8, 0])
365
+ // .attr('class', 'd3-flame-graph-tip')
366
+ // .html(function(d) { return "name: " + d.data.name + ", value: " + d.data.value; });
367
+
368
+ // flameGraph.tooltip(tip);
369
+
370
+ var details = document.getElementById("details");
371
+ flameGraph.setDetailsElement(details);
372
+
373
+ // Example on how to use custom labels
374
+ // var label = function(d) {
375
+ // return "name: " + d.name + ", value: " + d.value;
376
+ // }
377
+ // flameGraph.label(label);
378
+
379
+ // Example of how to set fixed chart height
380
+ // flameGraph.height(540);
381
+
382
+ d3.select("#chart")
383
+ .datum(#{ json })
384
+ .call(flameGraph);
385
+
386
+ document.getElementById("form").addEventListener("submit", function(event){
387
+ event.preventDefault();
388
+ search();
389
+ });
390
+
391
+ function search() {
392
+ var term = document.getElementById("term").value;
393
+ flameGraph.search(term);
394
+ }
395
+
396
+ function clear() {
397
+ document.getElementById('term').value = '';
398
+ flameGraph.clear();
399
+ }
400
+
401
+ function resetZoom() {
402
+ flameGraph.resetZoom();
403
+ }
404
+
405
+ function onClick(d) {
406
+ console.info("Clicked on " + d.data.name);
407
+ }
408
+ </script>
409
+ </body>
410
+ </html>
411
+ END
412
+ end
413
+
151
414
  def print_graphviz(options = {}, f = STDOUT)
152
415
  if filter = options[:filter]
153
416
  mark_stack = []
@@ -185,7 +448,7 @@ module StackProf
185
448
  call, total = info.values_at(:samples, :total_samples)
186
449
  break if total < node_minimum || (limit && index >= limit)
187
450
 
188
- sample = ''
451
+ sample = ''.dup
189
452
  sample << "#{call} (%2.1f%%)\\rof " % (call*100.0/overall_samples) if call < total
190
453
  sample << "#{total} (%2.1f%%)\\r" % (total*100.0/overall_samples)
191
454
  fontsize = (1.0 * call / max_samples) * 28 + 10
@@ -429,7 +692,8 @@ module StackProf
429
692
  end
430
693
  end
431
694
  end
695
+ rescue SystemCallError
696
+ f.puts " SOURCE UNAVAILABLE"
432
697
  end
433
-
434
698
  end
435
699
  end
@@ -0,0 +1,37 @@
1
+ module StackProf
2
+ # Define the same methods as stackprof.c
3
+ class << self
4
+ def running?
5
+ false
6
+ end
7
+
8
+ def run(*args)
9
+ unimplemented
10
+ end
11
+
12
+ def start(*args)
13
+ unimplemented
14
+ end
15
+
16
+ def stop
17
+ unimplemented
18
+ end
19
+
20
+ def results(*args)
21
+ unimplemented
22
+ end
23
+
24
+ def sample
25
+ unimplemented
26
+ end
27
+
28
+ def use_postponed_job!
29
+ # noop
30
+ end
31
+
32
+ private def unimplemented
33
+ raise "Use --cpusampler=flamegraph or --cpusampler instead of StackProf on TruffleRuby.\n" \
34
+ "See https://www.graalvm.org/tools/profiling/ and `ruby --help:cpusampler` for more details."
35
+ end
36
+ end
37
+ end
data/lib/stackprof.rb CHANGED
@@ -1,4 +1,25 @@
1
- require "stackprof/stackprof"
1
+ if RUBY_ENGINE == 'truffleruby'
2
+ require "stackprof/truffleruby"
3
+ else
4
+ require "stackprof/stackprof"
5
+ end
6
+
7
+ if defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?
8
+ if RUBY_VERSION < "3.3"
9
+ # On 3.3 we don't need postponed jobs:
10
+ # https://github.com/ruby/ruby/commit/a1dc1a3de9683daf5a543d6f618e17aabfcb8708
11
+ StackProf.use_postponed_job!
12
+ end
13
+ elsif RUBY_VERSION == "3.2.0"
14
+ # 3.2.0 crash is the signal is received at the wrong time.
15
+ # Fixed in https://github.com/ruby/ruby/pull/7116
16
+ # The fix is backported in 3.2.1: https://bugs.ruby-lang.org/issues/19336
17
+ StackProf.use_postponed_job!
18
+ end
19
+
20
+ module StackProf
21
+ VERSION = '0.2.26'
22
+ end
2
23
 
3
24
  StackProf.autoload :Report, "stackprof/report.rb"
4
25
  StackProf.autoload :Middleware, "stackprof/middleware.rb"
data/stackprof.gemspec CHANGED
@@ -1,11 +1,18 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = 'stackprof'
3
- s.version = '0.2.12'
3
+ s.version = '0.2.26'
4
4
  s.homepage = 'http://github.com/tmm1/stackprof'
5
5
 
6
6
  s.authors = 'Aman Gupta'
7
7
  s.email = 'aman@tmm1.net'
8
8
 
9
+ s.metadata = {
10
+ 'bug_tracker_uri' => 'https://github.com/tmm1/stackprof/issues',
11
+ 'changelog_uri' => "https://github.com/tmm1/stackprof/blob/v#{s.version}/CHANGELOG.md",
12
+ 'documentation_uri' => "https://www.rubydoc.info/gems/stackprof/#{s.version}",
13
+ 'source_code_uri' => "https://github.com/tmm1/stackprof/tree/v#{s.version}"
14
+ }
15
+
9
16
  s.files = `git ls-files`.split("\n")
10
17
  s.extensions = 'ext/stackprof/extconf.rb'
11
18
 
@@ -14,12 +21,13 @@ Gem::Specification.new do |s|
14
21
  s.executables << 'stackprof-flamegraph.pl'
15
22
  s.executables << 'stackprof-gprof2dot.py'
16
23
 
17
- s.summary = 'sampling callstack-profiler for ruby 2.1+'
24
+ s.summary = 'sampling callstack-profiler for ruby 2.2+'
18
25
  s.description = 'stackprof is a fast sampling profiler for ruby code, with cpu, wallclock and object allocation samplers.'
19
26
 
27
+ s.required_ruby_version = '>= 2.2'
28
+
20
29
  s.license = 'MIT'
21
30
 
22
31
  s.add_development_dependency 'rake-compiler', '~> 0.9'
23
- s.add_development_dependency 'mocha', '~> 0.14'
24
32
  s.add_development_dependency 'minitest', '~> 5.0'
25
33
  end
@@ -0,0 +1 @@
1
+ {: modeI"cpu:ET
@@ -0,0 +1 @@
1
+ { "mode": "cpu" }
@@ -2,9 +2,9 @@ $:.unshift File.expand_path('../../lib', __FILE__)
2
2
  require 'stackprof'
3
3
  require 'stackprof/middleware'
4
4
  require 'minitest/autorun'
5
- require 'mocha/setup'
5
+ require 'tmpdir'
6
6
 
7
- class StackProf::MiddlewareTest < MiniTest::Test
7
+ class StackProf::MiddlewareTest < Minitest::Test
8
8
 
9
9
  def test_path_default
10
10
  StackProf::Middleware.new(Object.new)
@@ -19,23 +19,36 @@ class StackProf::MiddlewareTest < MiniTest::Test
19
19
  end
20
20
 
21
21
  def test_save_default
22
- StackProf::Middleware.new(Object.new)
23
-
24
- StackProf.stubs(:results).returns({ mode: 'foo' })
25
- FileUtils.expects(:mkdir_p).with('tmp/')
26
- File.expects(:open).with(regexp_matches(/^tmp\/stackprof-foo/), 'wb')
27
-
28
- StackProf::Middleware.save
22
+ middleware = StackProf::Middleware.new(->(env) { 100.times { Object.new } },
23
+ save_every: 1,
24
+ enabled: true)
25
+ Dir.mktmpdir do |dir|
26
+ Dir.chdir(dir) { middleware.call({}) }
27
+ dir = File.join(dir, "tmp")
28
+ assert File.directory? dir
29
+ profile = Dir.entries(dir).reject { |x| File.directory?(x) }.first
30
+ assert profile
31
+ assert_equal "stackprof", profile.split("-")[0]
32
+ assert_equal "cpu", profile.split("-")[1]
33
+ assert_equal Process.pid.to_s, profile.split("-")[2]
34
+ end
29
35
  end
30
36
 
31
37
  def test_save_custom
32
- StackProf::Middleware.new(Object.new, { path: 'foo/' })
33
-
34
- StackProf.stubs(:results).returns({ mode: 'foo' })
35
- FileUtils.expects(:mkdir_p).with('foo/')
36
- File.expects(:open).with(regexp_matches(/^foo\/stackprof-foo/), 'wb')
37
-
38
- StackProf::Middleware.save
38
+ middleware = StackProf::Middleware.new(->(env) { 100.times { Object.new } },
39
+ path: "foo/",
40
+ save_every: 1,
41
+ enabled: true)
42
+ Dir.mktmpdir do |dir|
43
+ Dir.chdir(dir) { middleware.call({}) }
44
+ dir = File.join(dir, "foo")
45
+ assert File.directory? dir
46
+ profile = Dir.entries(dir).reject { |x| File.directory?(x) }.first
47
+ assert profile
48
+ assert_equal "stackprof", profile.split("-")[0]
49
+ assert_equal "cpu", profile.split("-")[1]
50
+ assert_equal Process.pid.to_s, profile.split("-")[2]
51
+ end
39
52
  end
40
53
 
41
54
  def test_enabled_should_use_a_proc_if_passed
@@ -64,4 +77,10 @@ class StackProf::MiddlewareTest < MiniTest::Test
64
77
  StackProf::Middleware.new(Object.new, raw: true)
65
78
  assert StackProf::Middleware.raw
66
79
  end
67
- end
80
+
81
+ def test_metadata
82
+ metadata = { key: 'value' }
83
+ StackProf::Middleware.new(Object.new, metadata: metadata)
84
+ assert_equal metadata, StackProf::Middleware.metadata
85
+ end
86
+ end unless RUBY_ENGINE == 'truffleruby'
data/test/test_report.rb CHANGED
@@ -2,7 +2,7 @@ $:.unshift File.expand_path('../../lib', __FILE__)
2
2
  require 'stackprof'
3
3
  require 'minitest/autorun'
4
4
 
5
- class ReportDumpTest < MiniTest::Test
5
+ class ReportDumpTest < Minitest::Test
6
6
  require 'stringio'
7
7
 
8
8
  def test_dump_to_stdout
@@ -32,3 +32,27 @@ class ReportDumpTest < MiniTest::Test
32
32
  assert_equal expected, Marshal.load(marshal_data)
33
33
  end
34
34
  end
35
+
36
+ class ReportReadTest < Minitest::Test
37
+ require 'pathname'
38
+
39
+ def test_from_file_read_json
40
+ file = fixture("profile.json")
41
+ report = StackProf::Report.from_file(file)
42
+
43
+ assert_equal({ mode: "cpu" }, report.data)
44
+ end
45
+
46
+ def test_from_file_read_marshal
47
+ file = fixture("profile.dump")
48
+ report = StackProf::Report.from_file(file)
49
+
50
+ assert_equal({ mode: "cpu" }, report.data)
51
+ end
52
+
53
+ private
54
+
55
+ def fixture(name)
56
+ Pathname.new(__dir__).join("fixtures", name)
57
+ end
58
+ end