stackprof 0.2.12 → 0.2.17

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.
data/lib/stackprof.rb CHANGED
@@ -1,4 +1,8 @@
1
1
  require "stackprof/stackprof"
2
2
 
3
+ module StackProf
4
+ VERSION = '0.2.17'
5
+ end
6
+
3
7
  StackProf.autoload :Report, "stackprof/report.rb"
4
8
  StackProf.autoload :Middleware, "stackprof/middleware.rb"
@@ -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,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'pp'
2
4
  require 'digest/md5'
3
5
 
@@ -38,7 +40,7 @@ module StackProf
38
40
  end
39
41
 
40
42
  def max_samples
41
- @data[:max_samples] ||= frames.max_by{ |addr, frame| frame[:samples] }.last[:samples]
43
+ @data[:max_samples] ||= @data[:frames].values.max_by{ |frame| frame[:samples] }[:samples]
42
44
  end
43
45
 
44
46
  def files
@@ -68,6 +70,11 @@ module StackProf
68
70
  f.puts Marshal.dump(@data.reject{|k,v| k == :files })
69
71
  end
70
72
 
73
+ def print_json(f=STDOUT)
74
+ require "json"
75
+ f.puts JSON.generate(@data, max_nesting: false)
76
+ end
77
+
71
78
  def print_stackcollapse
72
79
  raise "profile does not include raw samples (add `raw: true` to collecting StackProf.run)" unless raw = data[:raw]
73
80
 
@@ -80,19 +87,62 @@ module StackProf
80
87
  end
81
88
  end
82
89
 
83
- def print_flamegraph(f=STDOUT, skip_common=true)
90
+ def print_timeline_flamegraph(f=STDOUT, skip_common=true)
91
+ print_flamegraph(f, skip_common, false)
92
+ end
93
+
94
+ def print_alphabetical_flamegraph(f=STDOUT, skip_common=true)
95
+ print_flamegraph(f, skip_common, true)
96
+ end
97
+
98
+ StackCursor = Struct.new(:raw, :idx, :length) do
99
+ def weight
100
+ @weight ||= raw[1 + idx + length]
101
+ end
102
+
103
+ def [](i)
104
+ if i >= length
105
+ nil
106
+ else
107
+ raw[1 + idx + i]
108
+ end
109
+ end
110
+
111
+ def <=>(other)
112
+ i = 0
113
+ while i < length && i < other.length
114
+ if self[i] != other[i]
115
+ return self[i] <=> other[i]
116
+ end
117
+ i += 1
118
+ end
119
+
120
+ return length <=> other.length
121
+ end
122
+ end
123
+
124
+ def print_flamegraph(f, skip_common, alphabetical=false)
84
125
  raise "profile does not include raw samples (add `raw: true` to collecting StackProf.run)" unless raw = data[:raw]
85
126
 
86
127
  stacks = []
87
128
  max_x = 0
88
129
  max_y = 0
89
- while len = raw.shift
130
+
131
+ idx = 0
132
+ loop do
133
+ len = raw[idx]
134
+ break unless len
90
135
  max_y = len if len > max_y
91
- stack = raw.slice!(0, len+1)
136
+
137
+ stack = StackCursor.new(raw, idx, len)
92
138
  stacks << stack
93
- max_x += stack.last
139
+ max_x += stack.weight
140
+
141
+ idx += len + 2
94
142
  end
95
143
 
144
+ stacks.sort! if alphabetical
145
+
96
146
  f.puts 'flamegraph(['
97
147
  max_y.times do |y|
98
148
  row_prev = nil
@@ -100,7 +150,7 @@ module StackProf
100
150
  x = 0
101
151
 
102
152
  stacks.each do |stack|
103
- weight = stack.last
153
+ weight = stack.weight
104
154
  cell = stack[y] unless y == stack.length-1
105
155
 
106
156
  if cell.nil?
@@ -142,12 +192,222 @@ module StackProf
142
192
  end
143
193
 
144
194
  def flamegraph_row(f, x, y, weight, addr)
145
- frame = frames[addr]
195
+ frame = @data[:frames][addr]
146
196
  f.print ',' if @rows_started
147
197
  @rows_started = true
148
198
  f.puts %{{"x":#{x},"y":#{y},"width":#{weight},"frame_id":#{addr},"frame":#{frame[:name].dump},"file":#{frame[:file].dump}}}
149
199
  end
150
200
 
201
+ def convert_to_d3_flame_graph_format(name, stacks, depth)
202
+ weight = 0
203
+ children = []
204
+ stacks.chunk do |stack|
205
+ if depth == stack.length - 1
206
+ :leaf
207
+ else
208
+ stack[depth]
209
+ end
210
+ end.each do |val, child_stacks|
211
+ if val == :leaf
212
+ child_stacks.each do |stack|
213
+ weight += stack.last
214
+ end
215
+ else
216
+ frame = @data[:frames][val]
217
+ child_name = "#{ frame[:name] } : #{ frame[:file] }"
218
+ child_data = convert_to_d3_flame_graph_format(child_name, child_stacks, depth + 1)
219
+ weight += child_data["value"]
220
+ children << child_data
221
+ end
222
+ end
223
+
224
+ {
225
+ "name" => name,
226
+ "value" => weight,
227
+ "children" => children,
228
+ }
229
+ end
230
+
231
+ def print_d3_flamegraph(f=STDOUT, skip_common=true)
232
+ raise "profile does not include raw samples (add `raw: true` to collecting StackProf.run)" unless raw = data[:raw]
233
+
234
+ stacks = []
235
+ max_x = 0
236
+ max_y = 0
237
+ while len = raw.shift
238
+ max_y = len if len > max_y
239
+ stack = raw.slice!(0, len+1)
240
+ stacks << stack
241
+ max_x += stack.last
242
+ end
243
+
244
+ # d3-flame-grpah supports only alphabetical flamegraph
245
+ stacks.sort!
246
+
247
+ require "json"
248
+ json = JSON.generate(convert_to_d3_flame_graph_format("<root>", stacks, 0), max_nesting: false)
249
+
250
+ # This html code is almost copied from d3-flame-graph sample code.
251
+ # (Apache License 2.0)
252
+ # https://github.com/spiermar/d3-flame-graph/blob/gh-pages/index.html
253
+
254
+ f.print <<-END
255
+ <!DOCTYPE html>
256
+ <html lang="en">
257
+ <head>
258
+ <meta charset="utf-8">
259
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
260
+ <meta name="viewport" content="width=device-width, initial-scale=1">
261
+
262
+ <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
263
+ <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/gh/spiermar/d3-flame-graph@2.0.3/dist/d3-flamegraph.css">
264
+
265
+ <style>
266
+
267
+ /* Space out content a bit */
268
+ body {
269
+ padding-top: 20px;
270
+ padding-bottom: 20px;
271
+ }
272
+
273
+ /* Custom page header */
274
+ .header {
275
+ padding-bottom: 20px;
276
+ padding-right: 15px;
277
+ padding-left: 15px;
278
+ border-bottom: 1px solid #e5e5e5;
279
+ }
280
+
281
+ /* Make the masthead heading the same height as the navigation */
282
+ .header h3 {
283
+ margin-top: 0;
284
+ margin-bottom: 0;
285
+ line-height: 40px;
286
+ }
287
+
288
+ /* Customize container */
289
+ .container {
290
+ max-width: 990px;
291
+ }
292
+
293
+ address {
294
+ text-align: right;
295
+ }
296
+ </style>
297
+
298
+ <title>stackprof (mode: #{ data[:mode] })</title>
299
+
300
+ <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
301
+ <!--[if lt IE 9]>
302
+ <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
303
+ <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
304
+ <![endif]-->
305
+ </head>
306
+ <body>
307
+ <div class="container">
308
+ <div class="header clearfix">
309
+ <nav>
310
+ <div class="pull-right">
311
+ <form class="form-inline" id="form">
312
+ <a class="btn" href="javascript: resetZoom();">Reset zoom</a>
313
+ <a class="btn" href="javascript: clear();">Clear</a>
314
+ <div class="form-group">
315
+ <input type="text" class="form-control" id="term">
316
+ </div>
317
+ <a class="btn btn-primary" href="javascript: search();">Search</a>
318
+ </form>
319
+ </div>
320
+ </nav>
321
+ <h3 class="text-muted">stackprof (mode: #{ data[:mode] })</h3>
322
+ </div>
323
+ <div id="chart">
324
+ </div>
325
+ <address>
326
+ powered by <a href="https://github.com/spiermar/d3-flame-graph">d3-flame-graph</a>
327
+ </address>
328
+ <hr>
329
+ <div id="details">
330
+ </div>
331
+ </div>
332
+
333
+ <!-- D3.js -->
334
+ <script src="https://d3js.org/d3.v4.min.js" charset="utf-8"></script>
335
+
336
+ <!-- d3-tip -->
337
+ <script type="text/javascript" src=https://cdnjs.cloudflare.com/ajax/libs/d3-tip/0.9.1/d3-tip.min.js></script>
338
+
339
+ <!-- d3-flamegraph -->
340
+ <script type="text/javascript" src="https://cdn.jsdelivr.net/gh/spiermar/d3-flame-graph@2.0.3/dist/d3-flamegraph.min.js"></script>
341
+
342
+ <script type="text/javascript">
343
+ var flameGraph = d3.flamegraph()
344
+ .width(960)
345
+ .cellHeight(18)
346
+ .transitionDuration(750)
347
+ .minFrameSize(5)
348
+ .transitionEase(d3.easeCubic)
349
+ .sort(true)
350
+ //Example to sort in reverse order
351
+ //.sort(function(a,b){ return d3.descending(a.name, b.name);})
352
+ .title("")
353
+ .onClick(onClick)
354
+ .differential(false)
355
+ .selfValue(false);
356
+
357
+
358
+ // Example on how to use custom tooltips using d3-tip.
359
+ // var tip = d3.tip()
360
+ // .direction("s")
361
+ // .offset([8, 0])
362
+ // .attr('class', 'd3-flame-graph-tip')
363
+ // .html(function(d) { return "name: " + d.data.name + ", value: " + d.data.value; });
364
+
365
+ // flameGraph.tooltip(tip);
366
+
367
+ var details = document.getElementById("details");
368
+ flameGraph.setDetailsElement(details);
369
+
370
+ // Example on how to use custom labels
371
+ // var label = function(d) {
372
+ // return "name: " + d.name + ", value: " + d.value;
373
+ // }
374
+ // flameGraph.label(label);
375
+
376
+ // Example of how to set fixed chart height
377
+ // flameGraph.height(540);
378
+
379
+ d3.select("#chart")
380
+ .datum(#{ json })
381
+ .call(flameGraph);
382
+
383
+ document.getElementById("form").addEventListener("submit", function(event){
384
+ event.preventDefault();
385
+ search();
386
+ });
387
+
388
+ function search() {
389
+ var term = document.getElementById("term").value;
390
+ flameGraph.search(term);
391
+ }
392
+
393
+ function clear() {
394
+ document.getElementById('term').value = '';
395
+ flameGraph.clear();
396
+ }
397
+
398
+ function resetZoom() {
399
+ flameGraph.resetZoom();
400
+ }
401
+
402
+ function onClick(d) {
403
+ console.info("Clicked on " + d.data.name);
404
+ }
405
+ </script>
406
+ </body>
407
+ </html>
408
+ END
409
+ end
410
+
151
411
  def print_graphviz(options = {}, f = STDOUT)
152
412
  if filter = options[:filter]
153
413
  mark_stack = []
@@ -185,7 +445,7 @@ module StackProf
185
445
  call, total = info.values_at(:samples, :total_samples)
186
446
  break if total < node_minimum || (limit && index >= limit)
187
447
 
188
- sample = ''
448
+ sample = ''.dup
189
449
  sample << "#{call} (%2.1f%%)\\rof " % (call*100.0/overall_samples) if call < total
190
450
  sample << "#{total} (%2.1f%%)\\r" % (total*100.0/overall_samples)
191
451
  fontsize = (1.0 * call / max_samples) * 28 + 10
@@ -429,7 +689,8 @@ module StackProf
429
689
  end
430
690
  end
431
691
  end
692
+ rescue SystemCallError
693
+ f.puts " SOURCE UNAVAILABLE"
432
694
  end
433
-
434
695
  end
435
696
  end
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.17'
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,9 +21,11 @@ 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'
@@ -64,4 +64,10 @@ class StackProf::MiddlewareTest < MiniTest::Test
64
64
  StackProf::Middleware.new(Object.new, raw: true)
65
65
  assert StackProf::Middleware.raw
66
66
  end
67
+
68
+ def test_metadata
69
+ metadata = { key: 'value' }
70
+ StackProf::Middleware.new(Object.new, metadata: metadata)
71
+ assert_equal metadata, StackProf::Middleware.metadata
72
+ end
67
73
  end
@@ -2,6 +2,7 @@ $:.unshift File.expand_path('../../lib', __FILE__)
2
2
  require 'stackprof'
3
3
  require 'minitest/autorun'
4
4
  require 'tempfile'
5
+ require 'pathname'
5
6
 
6
7
  class StackProfTest < MiniTest::Test
7
8
  def test_info
@@ -38,16 +39,30 @@ class StackProfTest < MiniTest::Test
38
39
  end
39
40
  assert_equal :object, profile[:mode]
40
41
  assert_equal 1, profile[:interval]
41
- assert_equal 2, profile[:samples]
42
+ if RUBY_VERSION >= '3'
43
+ assert_equal 4, profile[:samples]
44
+ else
45
+ assert_equal 2, profile[:samples]
46
+ end
42
47
 
43
48
  frame = profile[:frames].values.first
44
49
  assert_includes frame[:name], "StackProfTest#test_object_allocation"
45
50
  assert_equal 2, frame[:samples]
46
51
  assert_includes [profile_base_line - 2, profile_base_line], frame[:line]
47
- assert_equal [1, 1], frame[:lines][profile_base_line+1]
48
- assert_equal [1, 1], frame[:lines][profile_base_line+2]
52
+ if RUBY_VERSION >= '3'
53
+ assert_equal [2, 1], frame[:lines][profile_base_line+1]
54
+ assert_equal [2, 1], frame[:lines][profile_base_line+2]
55
+ else
56
+ assert_equal [1, 1], frame[:lines][profile_base_line+1]
57
+ assert_equal [1, 1], frame[:lines][profile_base_line+2]
58
+ end
49
59
  frame = profile[:frames].values[1] if RUBY_VERSION < '2.3'
50
- assert_equal [2, 0], frame[:lines][profile_base_line]
60
+
61
+ if RUBY_VERSION >= '3'
62
+ assert_equal [4, 0], frame[:lines][profile_base_line]
63
+ else
64
+ assert_equal [2, 0], frame[:lines][profile_base_line]
65
+ end
51
66
  end
52
67
 
53
68
  def test_object_allocation_interval
@@ -63,7 +78,8 @@ class StackProfTest < MiniTest::Test
63
78
  end
64
79
 
65
80
  assert_operator profile[:samples], :>=, 1
66
- frame = profile[:frames].values.first
81
+ offset = RUBY_VERSION >= '3' ? 1 : 0
82
+ frame = profile[:frames].values[offset]
67
83
  assert_includes frame[:name], "StackProfTest#math"
68
84
  end
69
85
 
@@ -73,7 +89,11 @@ class StackProfTest < MiniTest::Test
73
89
  end
74
90
 
75
91
  frame = profile[:frames].values.first
76
- assert_equal "StackProfTest#idle", frame[:name]
92
+ if RUBY_VERSION >= '3'
93
+ assert_equal "IO.select", frame[:name]
94
+ else
95
+ assert_equal "StackProfTest#idle", frame[:name]
96
+ end
77
97
  assert_in_delta 200, frame[:samples], 25
78
98
  end
79
99
 
@@ -88,10 +108,16 @@ class StackProfTest < MiniTest::Test
88
108
  assert_equal :custom, profile[:mode]
89
109
  assert_equal 10, profile[:samples]
90
110
 
91
- frame = profile[:frames].values.first
111
+ offset = RUBY_VERSION >= '3' ? 1 : 0
112
+ frame = profile[:frames].values[offset]
92
113
  assert_includes frame[:name], "StackProfTest#test_custom"
93
114
  assert_includes [profile_base_line-2, profile_base_line+1], frame[:line]
94
- assert_equal [10, 10], frame[:lines][profile_base_line+2]
115
+
116
+ if RUBY_VERSION >= '3'
117
+ assert_equal [10, 0], frame[:lines][profile_base_line+2]
118
+ else
119
+ assert_equal [10, 10], frame[:lines][profile_base_line+2]
120
+ end
95
121
  end
96
122
 
97
123
  def test_raw
@@ -104,10 +130,42 @@ class StackProfTest < MiniTest::Test
104
130
  raw = profile[:raw]
105
131
  assert_equal 10, raw[-1]
106
132
  assert_equal raw[0] + 2, raw.size
107
- assert_includes profile[:frames][raw[-2]][:name], 'StackProfTest#test_raw'
133
+
134
+ offset = RUBY_VERSION >= '3' ? -3 : -2
135
+ assert_includes profile[:frames][raw[offset]][:name], 'StackProfTest#test_raw'
108
136
  assert_equal 10, profile[:raw_timestamp_deltas].size
109
137
  end
110
138
 
139
+ def test_metadata
140
+ metadata = {
141
+ path: '/foo/bar',
142
+ revision: '5c0b01f1522ae8c194510977ae29377296dd236b',
143
+ }
144
+ profile = StackProf.run(mode: :cpu, metadata: metadata) do
145
+ math
146
+ end
147
+
148
+ assert_equal metadata, profile[:metadata]
149
+ end
150
+
151
+ def test_empty_metadata
152
+ profile = StackProf.run(mode: :cpu) do
153
+ math
154
+ end
155
+
156
+ assert_equal({}, profile[:metadata])
157
+ end
158
+
159
+ def test_raises_if_metadata_is_not_a_hash
160
+ exception = assert_raises ArgumentError do
161
+ StackProf.run(mode: :cpu, metadata: 'foobar') do
162
+ math
163
+ end
164
+ end
165
+
166
+ assert_equal 'metadata should be a hash', exception.message
167
+ end
168
+
111
169
  def test_fork
112
170
  StackProf.run do
113
171
  pid = fork do
@@ -149,10 +207,18 @@ class StackProfTest < MiniTest::Test
149
207
 
150
208
  raw = profile[:raw]
151
209
  gc_frame = profile[:frames].values.find{ |f| f[:name] == "(garbage collection)" }
210
+ marking_frame = profile[:frames].values.find{ |f| f[:name] == "(marking)" }
211
+ sweeping_frame = profile[:frames].values.find{ |f| f[:name] == "(sweeping)" }
212
+
152
213
  assert gc_frame
153
- assert_equal gc_frame[:samples], profile[:gc_samples]
214
+ assert marking_frame
215
+ assert sweeping_frame
216
+
217
+ assert_equal gc_frame[:total_samples], profile[:gc_samples]
218
+ assert_equal profile[:gc_samples], [gc_frame, marking_frame, sweeping_frame].map{|x| x[:samples] }.inject(:+)
219
+
154
220
  assert_operator profile[:gc_samples], :>, 0
155
- assert_operator profile[:missed_samples], :<=, 10
221
+ assert_operator profile[:missed_samples], :<=, 25
156
222
  end
157
223
 
158
224
  def test_out
@@ -167,6 +233,41 @@ class StackProfTest < MiniTest::Test
167
233
  refute_empty profile[:frames]
168
234
  end
169
235
 
236
+ def test_out_to_path_string
237
+ tmpfile = Tempfile.new('stackprof-out')
238
+ ret = StackProf.run(mode: :custom, out: tmpfile.path) do
239
+ StackProf.sample
240
+ end
241
+
242
+ refute_equal tmpfile, ret
243
+ assert_equal tmpfile.path, ret.path
244
+ tmpfile.rewind
245
+ profile = Marshal.load(tmpfile.read)
246
+ refute_empty profile[:frames]
247
+ end
248
+
249
+ def test_pathname_out
250
+ tmpfile = Tempfile.new('stackprof-out')
251
+ pathname = Pathname.new(tmpfile.path)
252
+ ret = StackProf.run(mode: :custom, out: pathname) do
253
+ StackProf.sample
254
+ end
255
+
256
+ assert_equal tmpfile.path, ret.path
257
+ tmpfile.rewind
258
+ profile = Marshal.load(tmpfile.read)
259
+ refute_empty profile[:frames]
260
+ end
261
+
262
+ def test_min_max_interval
263
+ [-1, 0, 1_000_000, 1_000_001].each do |invalid_interval|
264
+ err = assert_raises(ArgumentError, "invalid interval #{invalid_interval}") do
265
+ StackProf.run(interval: invalid_interval, debug: true) {}
266
+ end
267
+ assert_match(/microseconds/, err.message)
268
+ end
269
+ end
270
+
170
271
  def math
171
272
  250_000.times do
172
273
  2 ** 10