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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +119 -0
  3. data/CHANGELOG.md +86 -0
  4. data/CODE_OF_CONDUCT.md +38 -0
  5. data/CONTRIBUTING.md +72 -0
  6. data/Gemfile +11 -0
  7. data/LICENSE +21 -0
  8. data/README.md +342 -0
  9. data/Rakefile +49 -0
  10. data/SECURITY.md +39 -0
  11. data/assets/heapscope-logo.png +0 -0
  12. data/assets/logo.svg +24 -0
  13. data/assets/wordmark.svg +18 -0
  14. data/docs/README.md +20 -0
  15. data/docs/ROADMAP.md +29 -0
  16. data/docs/adr/0001-evidence-over-certainty.md +24 -0
  17. data/docs/adr/0002-local-only-privacy.md +19 -0
  18. data/docs/adr/0003-runtime-adapters.md +25 -0
  19. data/docs/adr/README.md +7 -0
  20. data/docs/allocators.md +13 -0
  21. data/docs/api/overview.md +70 -0
  22. data/docs/changelogs/0.1.0.md +22 -0
  23. data/docs/changelogs/0.2.0.md +49 -0
  24. data/docs/changelogs/0.3.0.md +38 -0
  25. data/docs/changelogs/0.4.0.md +32 -0
  26. data/docs/changelogs/0.5.0.md +18 -0
  27. data/docs/changelogs/0.6.0.md +23 -0
  28. data/docs/cli.md +98 -0
  29. data/docs/diagnostics/HS001_persistent_class_growth.md +39 -0
  30. data/docs/diagnostics/HS002_high_retention_ratio.md +24 -0
  31. data/docs/diagnostics/HS003_thread_local_retention.md +24 -0
  32. data/docs/diagnostics/HS004_unbounded_collection.md +19 -0
  33. data/docs/diagnostics/HS005_callback_accumulation.md +14 -0
  34. data/docs/diagnostics/HS006_closure_retention.md +15 -0
  35. data/docs/diagnostics/HS007_poor_gc_recovery.md +18 -0
  36. data/docs/diagnostics/HS008_baseline_regression.md +15 -0
  37. data/docs/diagnostics/HS009_high_allocation_pressure.md +13 -0
  38. data/docs/diagnostics/HS010_native_memory_mismatch.md +19 -0
  39. data/docs/guides/ci-budgets.md +43 -0
  40. data/docs/guides/first-retention-experiment.md +25 -0
  41. data/docs/guides/production-safe.md +17 -0
  42. data/docs/index.md +38 -0
  43. data/examples/cache_vs_leak.rb +30 -0
  44. data/examples/closure_capture.rb +20 -0
  45. data/examples/healthy_churn.rb +16 -0
  46. data/examples/heapscope.yml +12 -0
  47. data/examples/import_leak.rb +19 -0
  48. data/examples/probe_and_session.rb +22 -0
  49. data/examples/thread_local_leak.rb +31 -0
  50. data/exe/heapscope +7 -0
  51. data/heapscope.gemspec +73 -0
  52. data/lib/heapscope/aging.rb +114 -0
  53. data/lib/heapscope/analyzer.rb +333 -0
  54. data/lib/heapscope/baseline.rb +77 -0
  55. data/lib/heapscope/branding.rb +98 -0
  56. data/lib/heapscope/budget.rb +108 -0
  57. data/lib/heapscope/capabilities.rb +42 -0
  58. data/lib/heapscope/catalog.rb +71 -0
  59. data/lib/heapscope/cli/color.rb +67 -0
  60. data/lib/heapscope/cli/commands/capture.rb +289 -0
  61. data/lib/heapscope/cli/commands/diffing.rb +157 -0
  62. data/lib/heapscope/cli/commands/meta.rb +244 -0
  63. data/lib/heapscope/cli/commands/reporting.rb +228 -0
  64. data/lib/heapscope/cli/completion.rb +72 -0
  65. data/lib/heapscope/cli/help.rb +226 -0
  66. data/lib/heapscope/cli/support.rb +126 -0
  67. data/lib/heapscope/cli.rb +165 -0
  68. data/lib/heapscope/closures.rb +102 -0
  69. data/lib/heapscope/collector.rb +167 -0
  70. data/lib/heapscope/config.rb +196 -0
  71. data/lib/heapscope/detectors.rb +131 -0
  72. data/lib/heapscope/diff.rb +130 -0
  73. data/lib/heapscope/dominators.rb +87 -0
  74. data/lib/heapscope/errors.rb +13 -0
  75. data/lib/heapscope/extrapolation.rb +51 -0
  76. data/lib/heapscope/findings.rb +149 -0
  77. data/lib/heapscope/globals.rb +108 -0
  78. data/lib/heapscope/graph.rb +218 -0
  79. data/lib/heapscope/growth.rb +108 -0
  80. data/lib/heapscope/middleware.rb +35 -0
  81. data/lib/heapscope/minitest.rb +47 -0
  82. data/lib/heapscope/monitor.rb +160 -0
  83. data/lib/heapscope/noise.rb +48 -0
  84. data/lib/heapscope/notifications.rb +79 -0
  85. data/lib/heapscope/pack.rb +55 -0
  86. data/lib/heapscope/paths.rb +64 -0
  87. data/lib/heapscope/rails.rb +71 -0
  88. data/lib/heapscope/report/html.rb +283 -0
  89. data/lib/heapscope/report/markdown.rb +78 -0
  90. data/lib/heapscope/report/text.rb +204 -0
  91. data/lib/heapscope/report.rb +308 -0
  92. data/lib/heapscope/retention.rb +91 -0
  93. data/lib/heapscope/rspec.rb +118 -0
  94. data/lib/heapscope/runtime/base.rb +130 -0
  95. data/lib/heapscope/runtime/jruby.rb +28 -0
  96. data/lib/heapscope/runtime/mri.rb +86 -0
  97. data/lib/heapscope/runtime/truffleruby.rb +40 -0
  98. data/lib/heapscope/runtime/windows_rss.rb +75 -0
  99. data/lib/heapscope/runtime.rb +33 -0
  100. data/lib/heapscope/schema.rb +28 -0
  101. data/lib/heapscope/scorecard.rb +85 -0
  102. data/lib/heapscope/session.rb +90 -0
  103. data/lib/heapscope/sidekiq_middleware.rb +36 -0
  104. data/lib/heapscope/snapshot.rb +165 -0
  105. data/lib/heapscope/suggest.rb +99 -0
  106. data/lib/heapscope/tables.rb +58 -0
  107. data/lib/heapscope/trend_store.rb +41 -0
  108. data/lib/heapscope/version.rb +6 -0
  109. data/lib/heapscope.rb +307 -0
  110. 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