heapscope 0.6.0
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 +7 -0
- data/.rubocop.yml +119 -0
- data/CHANGELOG.md +86 -0
- data/CODE_OF_CONDUCT.md +38 -0
- data/CONTRIBUTING.md +72 -0
- data/Gemfile +11 -0
- data/LICENSE +21 -0
- data/README.md +342 -0
- data/Rakefile +49 -0
- data/SECURITY.md +39 -0
- data/assets/heapscope-logo.png +0 -0
- data/assets/logo.svg +24 -0
- data/assets/wordmark.svg +18 -0
- data/docs/README.md +20 -0
- data/docs/ROADMAP.md +29 -0
- data/docs/adr/0001-evidence-over-certainty.md +24 -0
- data/docs/adr/0002-local-only-privacy.md +19 -0
- data/docs/adr/0003-runtime-adapters.md +25 -0
- data/docs/adr/README.md +7 -0
- data/docs/allocators.md +13 -0
- data/docs/api/overview.md +70 -0
- data/docs/changelogs/0.1.0.md +22 -0
- data/docs/changelogs/0.2.0.md +49 -0
- data/docs/changelogs/0.3.0.md +38 -0
- data/docs/changelogs/0.4.0.md +32 -0
- data/docs/changelogs/0.5.0.md +18 -0
- data/docs/changelogs/0.6.0.md +23 -0
- data/docs/cli.md +98 -0
- data/docs/diagnostics/HS001_persistent_class_growth.md +39 -0
- data/docs/diagnostics/HS002_high_retention_ratio.md +24 -0
- data/docs/diagnostics/HS003_thread_local_retention.md +24 -0
- data/docs/diagnostics/HS004_unbounded_collection.md +19 -0
- data/docs/diagnostics/HS005_callback_accumulation.md +14 -0
- data/docs/diagnostics/HS006_closure_retention.md +15 -0
- data/docs/diagnostics/HS007_poor_gc_recovery.md +18 -0
- data/docs/diagnostics/HS008_baseline_regression.md +15 -0
- data/docs/diagnostics/HS009_high_allocation_pressure.md +13 -0
- data/docs/diagnostics/HS010_native_memory_mismatch.md +19 -0
- data/docs/guides/ci-budgets.md +43 -0
- data/docs/guides/first-retention-experiment.md +25 -0
- data/docs/guides/production-safe.md +17 -0
- data/docs/index.md +38 -0
- data/examples/cache_vs_leak.rb +30 -0
- data/examples/closure_capture.rb +20 -0
- data/examples/healthy_churn.rb +16 -0
- data/examples/heapscope.yml +12 -0
- data/examples/import_leak.rb +19 -0
- data/examples/probe_and_session.rb +22 -0
- data/examples/thread_local_leak.rb +31 -0
- data/exe/heapscope +7 -0
- data/heapscope.gemspec +73 -0
- data/lib/heapscope/aging.rb +114 -0
- data/lib/heapscope/analyzer.rb +333 -0
- data/lib/heapscope/baseline.rb +77 -0
- data/lib/heapscope/branding.rb +98 -0
- data/lib/heapscope/budget.rb +108 -0
- data/lib/heapscope/capabilities.rb +42 -0
- data/lib/heapscope/catalog.rb +71 -0
- data/lib/heapscope/cli/color.rb +67 -0
- data/lib/heapscope/cli/commands/capture.rb +289 -0
- data/lib/heapscope/cli/commands/diffing.rb +157 -0
- data/lib/heapscope/cli/commands/meta.rb +244 -0
- data/lib/heapscope/cli/commands/reporting.rb +228 -0
- data/lib/heapscope/cli/completion.rb +72 -0
- data/lib/heapscope/cli/help.rb +226 -0
- data/lib/heapscope/cli/support.rb +126 -0
- data/lib/heapscope/cli.rb +165 -0
- data/lib/heapscope/closures.rb +102 -0
- data/lib/heapscope/collector.rb +167 -0
- data/lib/heapscope/config.rb +196 -0
- data/lib/heapscope/detectors.rb +131 -0
- data/lib/heapscope/diff.rb +130 -0
- data/lib/heapscope/dominators.rb +87 -0
- data/lib/heapscope/errors.rb +13 -0
- data/lib/heapscope/extrapolation.rb +51 -0
- data/lib/heapscope/findings.rb +149 -0
- data/lib/heapscope/globals.rb +108 -0
- data/lib/heapscope/graph.rb +218 -0
- data/lib/heapscope/growth.rb +108 -0
- data/lib/heapscope/middleware.rb +35 -0
- data/lib/heapscope/minitest.rb +47 -0
- data/lib/heapscope/monitor.rb +160 -0
- data/lib/heapscope/noise.rb +48 -0
- data/lib/heapscope/notifications.rb +79 -0
- data/lib/heapscope/pack.rb +55 -0
- data/lib/heapscope/paths.rb +64 -0
- data/lib/heapscope/rails.rb +71 -0
- data/lib/heapscope/report/html.rb +283 -0
- data/lib/heapscope/report/markdown.rb +78 -0
- data/lib/heapscope/report/text.rb +204 -0
- data/lib/heapscope/report.rb +308 -0
- data/lib/heapscope/retention.rb +91 -0
- data/lib/heapscope/rspec.rb +118 -0
- data/lib/heapscope/runtime/base.rb +130 -0
- data/lib/heapscope/runtime/jruby.rb +28 -0
- data/lib/heapscope/runtime/mri.rb +86 -0
- data/lib/heapscope/runtime/truffleruby.rb +40 -0
- data/lib/heapscope/runtime/windows_rss.rb +75 -0
- data/lib/heapscope/runtime.rb +33 -0
- data/lib/heapscope/schema.rb +28 -0
- data/lib/heapscope/scorecard.rb +85 -0
- data/lib/heapscope/session.rb +90 -0
- data/lib/heapscope/sidekiq_middleware.rb +36 -0
- data/lib/heapscope/snapshot.rb +165 -0
- data/lib/heapscope/suggest.rb +99 -0
- data/lib/heapscope/tables.rb +58 -0
- data/lib/heapscope/trend_store.rb +41 -0
- data/lib/heapscope/version.rb +6 -0
- data/lib/heapscope.rb +307 -0
- metadata +211 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "objspace"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module HeapScope
|
|
7
|
+
module Runtime
|
|
8
|
+
class MRI < Base
|
|
9
|
+
def initialize
|
|
10
|
+
super(engine: "ruby")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def allocation_tracing?
|
|
14
|
+
ObjectSpace.respond_to?(:trace_object_allocations_start)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reachable_objects?
|
|
18
|
+
ObjectSpace.respond_to?(:reachable_objects_from)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def memsize?
|
|
22
|
+
ObjectSpace.respond_to?(:memsize_of)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def each_object?
|
|
26
|
+
ObjectSpace.respond_to?(:each_object)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def each_object(klass = nil, &)
|
|
30
|
+
raise CapabilityError, "ObjectSpace.each_object unavailable" unless each_object?
|
|
31
|
+
|
|
32
|
+
if klass
|
|
33
|
+
ObjectSpace.each_object(klass, &)
|
|
34
|
+
else
|
|
35
|
+
ObjectSpace.each_object(&)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def memsize_of(object)
|
|
40
|
+
return nil unless memsize?
|
|
41
|
+
|
|
42
|
+
ObjectSpace.memsize_of(object)
|
|
43
|
+
rescue StandardError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reachable_objects_from(object)
|
|
48
|
+
return [] unless reachable_objects?
|
|
49
|
+
|
|
50
|
+
ObjectSpace.reachable_objects_from(object) || []
|
|
51
|
+
rescue StandardError
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def start_allocation_tracing
|
|
56
|
+
raise CapabilityError, "Allocation tracing unavailable" unless allocation_tracing?
|
|
57
|
+
|
|
58
|
+
ObjectSpace.trace_object_allocations_start
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def stop_allocation_tracing
|
|
62
|
+
return unless allocation_tracing?
|
|
63
|
+
|
|
64
|
+
ObjectSpace.trace_object_allocations_stop
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def allocation_info(object)
|
|
68
|
+
return nil unless allocation_tracing?
|
|
69
|
+
|
|
70
|
+
file = ObjectSpace.allocation_sourcefile(object)
|
|
71
|
+
line = ObjectSpace.allocation_sourceline(object)
|
|
72
|
+
return nil unless file
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
file: file,
|
|
76
|
+
line: line,
|
|
77
|
+
generation: (ObjectSpace.allocation_generation(object) if ObjectSpace.respond_to?(:allocation_generation)),
|
|
78
|
+
class_path: (ObjectSpace.allocation_class_path(object) if ObjectSpace.respond_to?(:allocation_class_path)),
|
|
79
|
+
method_id: (ObjectSpace.allocation_method_id(object) if ObjectSpace.respond_to?(:allocation_method_id))
|
|
80
|
+
}.compact
|
|
81
|
+
rescue StandardError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module HeapScope
|
|
6
|
+
module Runtime
|
|
7
|
+
# Placeholder adapter — TruffleRuby support may expand later.
|
|
8
|
+
class TruffleRuby < Base
|
|
9
|
+
def initialize
|
|
10
|
+
super(engine: "truffleruby")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def each_object?
|
|
14
|
+
defined?(ObjectSpace) && ObjectSpace.respond_to?(:each_object)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def memsize?
|
|
18
|
+
defined?(ObjectSpace) && ObjectSpace.respond_to?(:memsize_of)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def each_object(klass = nil, &)
|
|
22
|
+
raise CapabilityError, "ObjectSpace.each_object unavailable on TruffleRuby" unless each_object?
|
|
23
|
+
|
|
24
|
+
if klass
|
|
25
|
+
ObjectSpace.each_object(klass, &)
|
|
26
|
+
else
|
|
27
|
+
ObjectSpace.each_object(&)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def memsize_of(object)
|
|
32
|
+
return nil unless memsize?
|
|
33
|
+
|
|
34
|
+
ObjectSpace.memsize_of(object)
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
module Runtime
|
|
5
|
+
# Windows working-set size via PowerShell (no native gem required).
|
|
6
|
+
# Optionally uses stdlib Fiddle when already loaded / available without warnings noise.
|
|
7
|
+
module WindowsRSS
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def working_set_bytes
|
|
11
|
+
powershell_working_set_bytes || fiddle_working_set_bytes
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def powershell_working_set_bytes
|
|
15
|
+
out = `powershell -NoProfile -Command "(Get-Process -Id #{Process.pid}).WorkingSet64"`.to_s.strip
|
|
16
|
+
return nil if out.empty?
|
|
17
|
+
|
|
18
|
+
Integer(out)
|
|
19
|
+
rescue StandardError
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fiddle_working_set_bytes
|
|
24
|
+
return nil unless Object.const_defined?(:Fiddle)
|
|
25
|
+
|
|
26
|
+
require "fiddle/import"
|
|
27
|
+
api.working_set_bytes
|
|
28
|
+
rescue StandardError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def api
|
|
33
|
+
@api ||= build_api
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_api
|
|
37
|
+
Module.new do
|
|
38
|
+
extend Fiddle::Importer
|
|
39
|
+
|
|
40
|
+
dlload "kernel32.dll", "psapi.dll"
|
|
41
|
+
typealias "DWORD", "unsigned long"
|
|
42
|
+
typealias "HANDLE", "void*"
|
|
43
|
+
typealias "SIZE_T", "size_t"
|
|
44
|
+
typealias "BOOL", "int"
|
|
45
|
+
|
|
46
|
+
const_set(
|
|
47
|
+
:Counters,
|
|
48
|
+
struct([
|
|
49
|
+
"DWORD cb",
|
|
50
|
+
"DWORD PageFaultCount",
|
|
51
|
+
"SIZE_T PeakWorkingSetSize",
|
|
52
|
+
"SIZE_T WorkingSetSize",
|
|
53
|
+
"SIZE_T QuotaPeakPagedPoolUsage",
|
|
54
|
+
"SIZE_T QuotaPagedPoolUsage",
|
|
55
|
+
"SIZE_T QuotaPeakNonPagedPoolUsage",
|
|
56
|
+
"SIZE_T QuotaNonPagedPoolUsage",
|
|
57
|
+
"SIZE_T PagefileUsage",
|
|
58
|
+
"SIZE_T PeakPagefileUsage"
|
|
59
|
+
])
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
extern "HANDLE GetCurrentProcess()"
|
|
63
|
+
extern "BOOL GetProcessMemoryInfo(HANDLE, void*, DWORD)"
|
|
64
|
+
|
|
65
|
+
define_singleton_method(:working_set_bytes) do
|
|
66
|
+
counters = const_get(:Counters).malloc
|
|
67
|
+
counters.cb = const_get(:Counters).size
|
|
68
|
+
ok = GetProcessMemoryInfo(GetCurrentProcess(), counters, counters.cb)
|
|
69
|
+
ok.zero? ? nil : counters.WorkingSetSize
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "runtime/base"
|
|
4
|
+
require_relative "runtime/mri"
|
|
5
|
+
require_relative "runtime/jruby"
|
|
6
|
+
require_relative "runtime/truffleruby"
|
|
7
|
+
|
|
8
|
+
module HeapScope
|
|
9
|
+
module Runtime
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def current
|
|
13
|
+
@current ||= detect
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def reset!
|
|
17
|
+
@current = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def detect
|
|
21
|
+
case RUBY_ENGINE
|
|
22
|
+
when "ruby"
|
|
23
|
+
MRI.new
|
|
24
|
+
when "jruby"
|
|
25
|
+
JRuby.new
|
|
26
|
+
when "truffleruby"
|
|
27
|
+
TruffleRuby.new
|
|
28
|
+
else
|
|
29
|
+
Base.new(engine: RUBY_ENGINE)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Validates versioned report JSON payloads.
|
|
5
|
+
module Schema
|
|
6
|
+
REQUIRED_ROOT = %i[schema_version heapscope_version].freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def validate!(data)
|
|
11
|
+
data = data.transform_keys(&:to_sym) if data.respond_to?(:transform_keys)
|
|
12
|
+
missing = REQUIRED_ROOT.reject { |k| data.key?(k) }
|
|
13
|
+
raise InvalidReportError, "Missing keys: #{missing.join(', ')}" unless missing.empty?
|
|
14
|
+
|
|
15
|
+
version = data[:schema_version].to_i
|
|
16
|
+
raise InvalidReportError, "Unsupported schema_version=#{version}" if version < 1 || version > SCHEMA_VERSION
|
|
17
|
+
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def valid?(data)
|
|
22
|
+
validate!(data)
|
|
23
|
+
true
|
|
24
|
+
rescue InvalidReportError
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# One-shot probe that prints an executive scorecard for a workload.
|
|
5
|
+
class Probe
|
|
6
|
+
Result = Struct.new(:report, :scorecard, :printed, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
def self.run(title: "probe", force_gc: true, mode: :lightweight, print: true, &block)
|
|
9
|
+
new(title: title, force_gc: force_gc, mode: mode, print: print).run(&block)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(title:, force_gc:, mode:, print:)
|
|
13
|
+
@title = title
|
|
14
|
+
@force_gc = force_gc
|
|
15
|
+
@mode = mode
|
|
16
|
+
@print = print
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run(&)
|
|
20
|
+
raise ArgumentError, "block required" unless block_given?
|
|
21
|
+
|
|
22
|
+
report = HeapScope.measure(force_gc: @force_gc, mode: @mode, metadata: { kind: "probe", title: @title }, &)
|
|
23
|
+
scorecard = Scorecard.from_report(report, title: @title)
|
|
24
|
+
puts scorecard.to_text if @print && !HeapScope.config.quiet
|
|
25
|
+
Result.new(report: report, scorecard: scorecard, printed: @print)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Compact executive summary for CI logs and consoles.
|
|
30
|
+
class Scorecard
|
|
31
|
+
attr_reader :title, :verdict, :rss_delta, :live_delta, :retention_ratio,
|
|
32
|
+
:top_suspects, :finding_codes, :healthy
|
|
33
|
+
|
|
34
|
+
def self.from_report(report, title: "HeapScope")
|
|
35
|
+
new(
|
|
36
|
+
title: title,
|
|
37
|
+
healthy: report.healthy?,
|
|
38
|
+
verdict: report.healthy? ? "HEALTHY" : "ATTENTION",
|
|
39
|
+
rss_delta: report.diff&.rss_delta,
|
|
40
|
+
live_delta: report.diff&.heap_live_delta,
|
|
41
|
+
retention_ratio: report.diff&.retention_ratio,
|
|
42
|
+
top_suspects: Array(report.suspects).first(3).map { |s| "#{s[:name]}(+#{s[:delta_count]}/#{s[:severity]})" },
|
|
43
|
+
finding_codes: report.findings.map { |f| "#{f.code}:#{f.severity}" }
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(**attrs)
|
|
48
|
+
attrs.each { |k, v| instance_variable_set("@#{k}", v) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_h
|
|
52
|
+
{
|
|
53
|
+
title: title,
|
|
54
|
+
verdict: verdict,
|
|
55
|
+
healthy: healthy,
|
|
56
|
+
rss_delta: rss_delta,
|
|
57
|
+
live_delta: live_delta,
|
|
58
|
+
retention_ratio: retention_ratio,
|
|
59
|
+
top_suspects: top_suspects,
|
|
60
|
+
finding_codes: finding_codes
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def to_text
|
|
65
|
+
lines = []
|
|
66
|
+
lines << "┌─ HeapScope scorecard ─────────────────────────"
|
|
67
|
+
lines << "│ #{title}"
|
|
68
|
+
lines << "│ Verdict: #{verdict}"
|
|
69
|
+
lines << "│ RSS Δ: #{fmt_bytes(rss_delta)} Live slots Δ: #{live_delta || 'n/a'}"
|
|
70
|
+
lines << "│ Retention: #{retention_ratio ? format('%.2f%%', retention_ratio * 100) : 'n/a'}"
|
|
71
|
+
lines << "│ Suspects: #{top_suspects.empty? ? 'none' : top_suspects.join(', ')}"
|
|
72
|
+
lines << "│ Findings: #{finding_codes.empty? ? 'none' : finding_codes.join(', ')}"
|
|
73
|
+
lines << "└───────────────────────────────────────────────"
|
|
74
|
+
lines.join("\n")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def fmt_bytes(n)
|
|
80
|
+
return "n/a" if n.nil?
|
|
81
|
+
|
|
82
|
+
format("%+.2f MB", n.to_f / (1024 * 1024))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module HeapScope
|
|
9
|
+
# Named diagnostic sessions that persist artifacts under `.heapscope/`.
|
|
10
|
+
class Session
|
|
11
|
+
attr_reader :name, :dir, :id, :created_at, :reports
|
|
12
|
+
|
|
13
|
+
def self.open(name, root: Dir.pwd)
|
|
14
|
+
new(name, root: root).tap(&:ensure!)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(name, root: Dir.pwd)
|
|
18
|
+
@name = name.to_s
|
|
19
|
+
@id = SecureRandom.hex(4)
|
|
20
|
+
@root = root
|
|
21
|
+
@dir = File.join(root, ".heapscope", "sessions", sanitize(name))
|
|
22
|
+
@created_at = Time.now.utc
|
|
23
|
+
@reports = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ensure!
|
|
27
|
+
FileUtils.mkdir_p(@dir)
|
|
28
|
+
write_meta!
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def record(report, label: nil)
|
|
33
|
+
label ||= "report-#{@reports.size + 1}"
|
|
34
|
+
path = File.join(@dir, "#{label}.json")
|
|
35
|
+
report.save(path)
|
|
36
|
+
html = File.join(@dir, "#{label}.html")
|
|
37
|
+
report.save_html(html)
|
|
38
|
+
entry = { label: label, path: path, html: html, at: Time.now.utc.iso8601, healthy: report.healthy? }
|
|
39
|
+
@reports << entry
|
|
40
|
+
write_meta!
|
|
41
|
+
entry
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def measure(label: "measure", **opts, &block)
|
|
45
|
+
report = HeapScope.measure(**opts, &block)
|
|
46
|
+
record(report, label: label)
|
|
47
|
+
report
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def retention_test(label: "retention", **opts, &block)
|
|
51
|
+
report = HeapScope.retention_test(**opts, &block)
|
|
52
|
+
record(report, label: label)
|
|
53
|
+
report
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def latest
|
|
57
|
+
@reports.last
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.list(root: Dir.pwd)
|
|
61
|
+
base = File.join(root, ".heapscope", "sessions")
|
|
62
|
+
return [] unless Dir.exist?(base)
|
|
63
|
+
|
|
64
|
+
Dir.children(base).sort.map do |name|
|
|
65
|
+
meta_path = File.join(base, name, "meta.json")
|
|
66
|
+
meta = File.exist?(meta_path) ? JSON.parse(File.read(meta_path), symbolize_names: true) : { name: name }
|
|
67
|
+
meta
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def sanitize(name)
|
|
74
|
+
name.gsub(/[^a-zA-Z0-9_-]+/, "_")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def write_meta!
|
|
78
|
+
File.write(
|
|
79
|
+
File.join(@dir, "meta.json"),
|
|
80
|
+
JSON.pretty_generate(
|
|
81
|
+
name: @name,
|
|
82
|
+
id: @id,
|
|
83
|
+
created_at: @created_at.iso8601,
|
|
84
|
+
reports: @reports,
|
|
85
|
+
heapscope_version: VERSION
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Optional Sidekiq server middleware. Does not require Sidekiq at load time.
|
|
5
|
+
class SidekiqMiddleware
|
|
6
|
+
def initialize(options = {})
|
|
7
|
+
@sample_rate = options.fetch(:sample_rate, 0.05)
|
|
8
|
+
@force_gc = options.fetch(:force_gc, false)
|
|
9
|
+
@mode = options.fetch(:mode, :lightweight)
|
|
10
|
+
@on_report = options[:on_report]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(worker, job, queue)
|
|
14
|
+
return yield if @sample_rate <= 0 || rand > @sample_rate
|
|
15
|
+
|
|
16
|
+
result = nil
|
|
17
|
+
report = HeapScope.measure(
|
|
18
|
+
force_gc: @force_gc,
|
|
19
|
+
mode: @mode,
|
|
20
|
+
metadata: {
|
|
21
|
+
kind: "sidekiq_job",
|
|
22
|
+
worker: worker.class.name,
|
|
23
|
+
queue: queue,
|
|
24
|
+
jid: job["jid"],
|
|
25
|
+
pid: Process.pid
|
|
26
|
+
}
|
|
27
|
+
) do
|
|
28
|
+
result = yield
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@on_report&.call(report)
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module HeapScope
|
|
8
|
+
# Stable snapshot abstraction. Configurable fidelity — not a full heap dump.
|
|
9
|
+
class Snapshot
|
|
10
|
+
SCHEMA_VERSION = HeapScope::SCHEMA_VERSION
|
|
11
|
+
SLIM_CLASS_LIMIT = 40
|
|
12
|
+
SLIM_ALLOC_LIMIT = 20
|
|
13
|
+
|
|
14
|
+
attr_reader :id, :timestamp, :mode, :gc_stat, :rss_bytes, :class_stats,
|
|
15
|
+
:allocation_sites, :metadata, :limitations, :sampled,
|
|
16
|
+
:process, :duration_ms, :object_sample
|
|
17
|
+
|
|
18
|
+
def initialize(id:, timestamp:, mode:, gc_stat:, rss_bytes:, class_stats:,
|
|
19
|
+
allocation_sites: {}, metadata: {}, limitations: [],
|
|
20
|
+
sampled: false, process: {}, duration_ms: 0, object_sample: [])
|
|
21
|
+
@id = id
|
|
22
|
+
@timestamp = timestamp
|
|
23
|
+
@mode = mode
|
|
24
|
+
@gc_stat = gc_stat || {}
|
|
25
|
+
@rss_bytes = rss_bytes
|
|
26
|
+
@class_stats = class_stats || {}
|
|
27
|
+
@allocation_sites = allocation_sites || {}
|
|
28
|
+
@metadata = metadata || {}
|
|
29
|
+
@limitations = limitations || []
|
|
30
|
+
@sampled = sampled
|
|
31
|
+
@process = process || {}
|
|
32
|
+
@duration_ms = duration_ms
|
|
33
|
+
@object_sample = object_sample || []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def gc_generation
|
|
37
|
+
gc_stat[:count]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def heap_live_slots
|
|
41
|
+
gc_stat[:heap_live_slots]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def heap_free_slots
|
|
45
|
+
gc_stat[:heap_free_slots]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def total_allocated_objects
|
|
49
|
+
gc_stat[:total_allocated_objects]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def total_freed_objects
|
|
53
|
+
gc_stat[:total_freed_objects]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def class_count(name)
|
|
57
|
+
(class_stats[name.to_s] || {})[:count].to_i
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def class_bytes(name)
|
|
61
|
+
(class_stats[name.to_s] || {})[:shallow_bytes].to_i
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def top_classes(limit = 20, by: :count)
|
|
65
|
+
class_stats.values.sort_by { |s| -(s[by] || 0) }.first(limit)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def same_process?(other)
|
|
69
|
+
process[:pid] == other.process[:pid]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_h(slim: false)
|
|
73
|
+
stats = class_stats
|
|
74
|
+
sites = allocation_sites
|
|
75
|
+
sample = object_sample
|
|
76
|
+
meta = metadata.dup
|
|
77
|
+
limits = limitations.dup
|
|
78
|
+
|
|
79
|
+
if slim
|
|
80
|
+
top = top_classes(SLIM_CLASS_LIMIT)
|
|
81
|
+
stats = top.to_h { |row| [row[:name], row] }
|
|
82
|
+
sites =
|
|
83
|
+
if allocation_sites.is_a?(Hash)
|
|
84
|
+
allocation_sites.to_a.first(SLIM_ALLOC_LIMIT).to_h
|
|
85
|
+
else
|
|
86
|
+
Array(allocation_sites).first(SLIM_ALLOC_LIMIT)
|
|
87
|
+
end
|
|
88
|
+
sample = []
|
|
89
|
+
meta[:slim] = true
|
|
90
|
+
limits << "slim_json_truncated_classes" unless limits.include?("slim_json_truncated_classes")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
schema_version: SCHEMA_VERSION,
|
|
95
|
+
heapscope_version: VERSION,
|
|
96
|
+
id: id,
|
|
97
|
+
timestamp: timestamp.iso8601(3),
|
|
98
|
+
mode: mode.to_s,
|
|
99
|
+
gc_stat: stringify_keys(gc_stat),
|
|
100
|
+
rss_bytes: rss_bytes,
|
|
101
|
+
class_stats: stats.transform_values { |v| stringify_keys(v) },
|
|
102
|
+
allocation_sites: sites,
|
|
103
|
+
metadata: meta,
|
|
104
|
+
limitations: limits,
|
|
105
|
+
sampled: sampled,
|
|
106
|
+
process: process,
|
|
107
|
+
duration_ms: duration_ms,
|
|
108
|
+
object_sample: sample
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def to_json(*_args)
|
|
113
|
+
JSON.pretty_generate(to_h)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def save(path, slim: false)
|
|
117
|
+
File.write(path, JSON.pretty_generate(to_h(slim: slim)))
|
|
118
|
+
path
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.load(path)
|
|
122
|
+
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
123
|
+
from_h(data)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.from_h(data)
|
|
127
|
+
class_stats = (data[:class_stats] || {}).each_with_object({}) do |(name, stats), hash|
|
|
128
|
+
hash[name.to_s] = {
|
|
129
|
+
name: name.to_s,
|
|
130
|
+
count: stats[:count].to_i,
|
|
131
|
+
shallow_bytes: stats[:shallow_bytes].to_i,
|
|
132
|
+
average_bytes: stats[:average_bytes].to_f
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
new(
|
|
137
|
+
id: data[:id] || SecureRandom.hex(8),
|
|
138
|
+
timestamp: Time.parse(data[:timestamp].to_s),
|
|
139
|
+
mode: (data[:mode] || "standard").to_sym,
|
|
140
|
+
gc_stat: symbolize_keys(data[:gc_stat] || {}),
|
|
141
|
+
rss_bytes: data[:rss_bytes],
|
|
142
|
+
class_stats: class_stats,
|
|
143
|
+
allocation_sites: data[:allocation_sites] || {},
|
|
144
|
+
metadata: data[:metadata] || {},
|
|
145
|
+
limitations: data[:limitations] || [],
|
|
146
|
+
sampled: data[:sampled] || false,
|
|
147
|
+
process: data[:process] || {},
|
|
148
|
+
duration_ms: data[:duration_ms].to_f,
|
|
149
|
+
object_sample: data[:object_sample] || []
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.stringify_keys(hash)
|
|
154
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def self.symbolize_keys(hash)
|
|
158
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def stringify_keys(hash)
|
|
162
|
+
self.class.stringify_keys(hash)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|