stackprof 0.2.12 → 0.2.26

Sign up to get free protection for your applications and to get access to all the features.
@@ -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