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,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module HeapScope
6
+ # Collects heap snapshots at configurable fidelity with pause/object budgets.
7
+ class Collector
8
+ RUNTIME_NOISE = %w[
9
+ Class Module Object BasicObject String Array Hash Integer Float Symbol
10
+ Proc Method UnboundMethod Binding Encoding Range Regexp MatchData
11
+ Thread Fiber Mutex Rational Complex NilClass TrueClass FalseClass
12
+ RubyVM RubyVM::InstructionSequence IO File Dir Time Random
13
+ ].freeze
14
+
15
+ def initialize(runtime: Runtime.current, config: HeapScope.config)
16
+ @runtime = runtime
17
+ @config = config
18
+ end
19
+
20
+ def capture(mode: nil, metadata: {}, max_objects: nil, max_pause: nil, sample_rate: nil)
21
+ mode = @config.effective_snapshot_mode(mode)
22
+ max_objects ||= @config.max_objects
23
+ sample_rate ||= @config.object_sample_rate
24
+ max_pause_ms = normalize_pause(max_pause || @config.max_pause_ms)
25
+
26
+ started = monotonic_ms
27
+ limitations = []
28
+
29
+ gc_stat = @runtime.gc_stat
30
+ rss = safe_rss
31
+ limitations << "rss_tracking_unavailable" if rss.nil? && !@runtime.rss_tracking?
32
+
33
+ class_stats, allocation_sites, object_sample, truncated, more_limits =
34
+ collect_objects(mode, max_objects, sample_rate, max_pause_ms, started)
35
+ limitations.concat(more_limits)
36
+ limitations << "analysis_truncated_pause_budget" if truncated && max_pause_ms
37
+
38
+ duration = monotonic_ms - started
39
+
40
+ Snapshot.new(
41
+ id: SecureRandom.hex(8),
42
+ timestamp: Time.now.utc,
43
+ mode: mode,
44
+ gc_stat: gc_stat,
45
+ rss_bytes: rss,
46
+ class_stats: class_stats,
47
+ allocation_sites: allocation_sites,
48
+ metadata: metadata,
49
+ limitations: limitations.uniq,
50
+ sampled: sample_rate < 1.0 || mode != :lightweight,
51
+ process: @runtime.process_metadata,
52
+ duration_ms: duration.round(2),
53
+ object_sample: object_sample
54
+ )
55
+ rescue CapabilityError
56
+ raise
57
+ rescue StandardError => e
58
+ raise SnapshotError, "Failed to capture snapshot: #{e.class}: #{e.message}"
59
+ end
60
+
61
+ private
62
+
63
+ def collect_objects(mode, max_objects, sample_rate, max_pause_ms, started)
64
+ return [{}, {}, [], false, ["each_object_unavailable"]] unless @runtime.each_object?
65
+
66
+ counts = Hash.new { |h, k| h[k] = { name: k, count: 0, shallow_bytes: 0 } }
67
+ allocation_sites = Hash.new { |h, k| h[k] = { site: k, count: 0, shallow_bytes: 0, classes: Hash.new(0) } }
68
+ object_sample = []
69
+ seen = 0
70
+ truncated = false
71
+ limitations = []
72
+
73
+ estimate_memsize = mode != :lightweight && @runtime.memsize?
74
+ track_alloc = mode != :lightweight && (@config.track_allocations || mode == :deep) && @runtime.allocation_tracing?
75
+ sample_refs = mode == :deep
76
+
77
+ limitations << "memsize_unavailable" unless @runtime.memsize?
78
+ limitations << "allocation_tracing_not_enabled" if mode != :lightweight && !track_alloc
79
+
80
+ @runtime.each_object do |object|
81
+ break truncated = true if seen >= max_objects
82
+ break truncated = true if max_pause_ms && (monotonic_ms - started) > max_pause_ms
83
+
84
+ seen += 1
85
+ next if sample_rate < 1.0 && rand > sample_rate
86
+
87
+ name = safe_class_name(object)
88
+ next if name.nil?
89
+
90
+ counts[name][:count] += 1
91
+ bytes = estimate_memsize ? (@runtime.memsize_of(object) || 0) : 0
92
+ counts[name][:shallow_bytes] += bytes
93
+
94
+ if track_alloc
95
+ info = @runtime.allocation_info(object)
96
+ if info && info[:file]
97
+ site = "#{info[:file]}:#{info[:line]}"
98
+ allocation_sites[site][:count] += 1
99
+ allocation_sites[site][:shallow_bytes] += bytes
100
+ allocation_sites[site][:classes][name] += 1
101
+ allocation_sites[site][:method_id] ||= info[:method_id]&.to_s
102
+ end
103
+ end
104
+
105
+ next unless sample_refs && object_sample.size < 50 && !noise?(name)
106
+
107
+ object_sample << {
108
+ local_id: object_sample.size,
109
+ class: name,
110
+ shallow_bytes: bytes,
111
+ allocation: track_alloc ? @runtime.allocation_info(object) : nil
112
+ }.compact
113
+ end
114
+
115
+ limitations << "object_budget_reached" if truncated && seen >= max_objects
116
+
117
+ class_stats = counts.transform_values do |stats|
118
+ avg = stats[:count].positive? ? (stats[:shallow_bytes].to_f / stats[:count]) : 0.0
119
+ stats.merge(average_bytes: avg.round(2))
120
+ end
121
+
122
+ # Scale sampled counts when sample_rate < 1
123
+ if sample_rate < 1.0 && sample_rate.positive?
124
+ factor = 1.0 / sample_rate
125
+ class_stats = class_stats.transform_values do |stats|
126
+ stats.merge(
127
+ count: (stats[:count] * factor).round,
128
+ shallow_bytes: (stats[:shallow_bytes] * factor).round,
129
+ sampled: true,
130
+ sample_rate: sample_rate
131
+ )
132
+ end
133
+ limitations << "object_counts_extrapolated_from_sample"
134
+ end
135
+
136
+ [class_stats, allocation_sites, object_sample, truncated, limitations]
137
+ end
138
+
139
+ def safe_class_name(object)
140
+ object.class.name || "#<Anonymous:#{object.class.object_id}>"
141
+ rescue StandardError
142
+ nil
143
+ end
144
+
145
+ def noise?(name)
146
+ RUNTIME_NOISE.include?(name) || name.start_with?("RubyVM")
147
+ end
148
+
149
+ def safe_rss
150
+ @runtime.rss_bytes
151
+ rescue StandardError
152
+ nil
153
+ end
154
+
155
+ def normalize_pause(value)
156
+ return nil if value.nil?
157
+ return value if value.is_a?(Numeric)
158
+ return value.to_f if value.respond_to?(:to_f)
159
+
160
+ nil
161
+ end
162
+
163
+ def monotonic_ms
164
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ class Config
5
+ MODES = %i[lightweight standard deep production_safe development].freeze
6
+ SNAPSHOT_MODES = %i[lightweight standard deep].freeze
7
+
8
+ attr_accessor :mode,
9
+ :track_allocations,
10
+ :max_graph_depth,
11
+ :max_objects,
12
+ :max_edges,
13
+ :max_pause_ms,
14
+ :object_sample_rate,
15
+ :force_gc_default,
16
+ :include_runtime_objects,
17
+ :ignore_patterns,
18
+ :ignored_classes,
19
+ :inspect_values,
20
+ :verbose,
21
+ :debug,
22
+ :rails,
23
+ :redactor,
24
+ :logger,
25
+ :warmup_done,
26
+ :min_retention_cycles,
27
+ :detect_globals,
28
+ :detect_closures,
29
+ :detect_fibers,
30
+ :detect_thread_locals,
31
+ :apply_noise_defaults,
32
+ :quiet
33
+
34
+ def initialize
35
+ @mode = :standard
36
+ @track_allocations = false
37
+ @max_graph_depth = 6
38
+ @max_objects = 100_000
39
+ @max_edges = 500_000
40
+ @max_pause_ms = nil
41
+ @object_sample_rate = 1.0
42
+ @force_gc_default = false
43
+ @include_runtime_objects = false
44
+ @ignore_patterns = [/^HeapScope::/]
45
+ @ignored_classes = []
46
+ @inspect_values = false
47
+ @verbose = false
48
+ @debug = false
49
+ @rails = false
50
+ @redactor = nil
51
+ @logger = nil
52
+ @warmup_done = false
53
+ @min_retention_cycles = 3
54
+ @detect_globals = true
55
+ @detect_closures = false
56
+ @detect_fibers = true
57
+ @detect_thread_locals = true
58
+ @apply_noise_defaults = true
59
+ @quiet = false
60
+ end
61
+
62
+ def production_safe?
63
+ mode == :production_safe
64
+ end
65
+
66
+ def development?
67
+ mode == :development
68
+ end
69
+
70
+ def effective_snapshot_mode(requested = nil)
71
+ requested ||= snapshot_mode_for_config
72
+ return :lightweight if production_safe? && requested == :deep
73
+
74
+ requested
75
+ end
76
+
77
+ def snapshot_mode_for_config
78
+ case mode
79
+ when :lightweight, :production_safe then :lightweight
80
+ when :deep, :development then :deep
81
+ else :standard
82
+ end
83
+ end
84
+
85
+ def ignore_class?(name)
86
+ return true if ignored_classes.map(&:to_s).include?(name.to_s)
87
+
88
+ ignore_patterns.any? { |pattern| name.to_s.match?(pattern) }
89
+ end
90
+
91
+ def redact(object)
92
+ return nil unless inspect_values
93
+ return redactor.call(object) if redactor
94
+
95
+ :"[redacted]"
96
+ end
97
+
98
+ def log(level, message)
99
+ return unless logger || verbose || debug
100
+ return if level == :debug && !debug
101
+
102
+ (logger || $stderr).puts("[HeapScope] #{message}")
103
+ end
104
+ end
105
+
106
+ # Load Ruby or YAML configuration files.
107
+ module ConfigLoader
108
+ STARTER_YAML = <<~YAML
109
+ # HeapScope local config — no telemetry, no uploads
110
+ heapscope:
111
+ mode: standard
112
+ track_allocations: false
113
+ apply_noise_defaults: true
114
+ detect_globals: true
115
+ detect_fibers: true
116
+ detect_closures: false
117
+ detect_thread_locals: true
118
+ retention:
119
+ min_cycles: 3
120
+ ignore_patterns:
121
+ - "^Zeitwerk"
122
+ - "^Bootsnap"
123
+ - "^HeapScope::"
124
+ YAML
125
+
126
+ module_function
127
+
128
+ def load!(path)
129
+ raise ConfigurationError, "Config not found: #{path}" unless File.exist?(path)
130
+
131
+ case File.extname(path)
132
+ when ".yml", ".yaml"
133
+ load_yaml!(path)
134
+ when ".rb"
135
+ load(path)
136
+ else
137
+ raise ConfigurationError, "Unsupported config type: #{path}"
138
+ end
139
+ HeapScope.config
140
+ end
141
+
142
+ def load_yaml!(path)
143
+ require "yaml"
144
+ data = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true) || {}
145
+ data = data["heapscope"] || data[:heapscope] || data
146
+ HeapScope.configure do |c|
147
+ c.mode = data["mode"]&.to_sym if data["mode"]
148
+ c.track_allocations = data["track_allocations"] unless data["track_allocations"].nil?
149
+ c.max_graph_depth = data["max_graph_depth"] if data["max_graph_depth"]
150
+ c.max_objects = data["max_objects"] if data["max_objects"]
151
+ c.object_sample_rate = data["object_sample_rate"] if data["object_sample_rate"]
152
+ Array(data["ignore_patterns"]).each { |p| c.ignore_patterns << Regexp.new(p) }
153
+ c.min_retention_cycles = data.dig("retention", "min_cycles") if data.dig("retention", "min_cycles")
154
+ c.detect_globals = data["detect_globals"] unless data["detect_globals"].nil?
155
+ c.detect_closures = data["detect_closures"] unless data["detect_closures"].nil?
156
+ c.detect_fibers = data["detect_fibers"] unless data["detect_fibers"].nil?
157
+ c.apply_noise_defaults = data["apply_noise_defaults"] unless data["apply_noise_defaults"].nil?
158
+ end
159
+ end
160
+
161
+ def write_starter!(path = "heapscope.yml", force: false)
162
+ raise ConfigurationError, "Refusing to overwrite existing #{path}" if File.exist?(path) && !force
163
+
164
+ File.write(path, STARTER_YAML)
165
+ path
166
+ end
167
+ end
168
+
169
+ class << self
170
+ def config
171
+ @config ||= Config.new
172
+ end
173
+
174
+ def configure
175
+ yield config
176
+ self
177
+ end
178
+
179
+ def reset_config!
180
+ @config = Config.new
181
+ end
182
+
183
+ def ignore_class(klass)
184
+ config.ignored_classes << klass
185
+ end
186
+
187
+ def after_warmup
188
+ yield if block_given?
189
+ config.warmup_done = true
190
+ end
191
+
192
+ def load_config!(path)
193
+ ConfigLoader.load!(path)
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ # Pattern detectors for cache-vs-leak, unbounded collections, and thread-locals.
5
+ # Findings remain evidence-based: facts, derived behavior, hypothesis, suspected cause.
6
+ module Detectors
7
+ module_function
8
+
9
+ def analyze_thread_locals(runtime: Runtime.current)
10
+ findings = []
11
+ inventory = Graph.new(runtime: runtime).thread_local_inventory
12
+ inventory.each do |thread|
13
+ thread[:keys].each do |key, meta|
14
+ bytes = meta[:shallow_bytes].to_i
15
+ next if bytes < 64_000 && !suspicious_key?(key)
16
+
17
+ findings << Finding.new(
18
+ code: "HS003",
19
+ severity: bytes >= 1_000_000 ? :high : :medium,
20
+ subject: "#{thread[:name]}#{key}",
21
+ facts: [
22
+ "Observed fact: thread #{thread[:name]} holds key #{key} of class #{meta[:class]}."
23
+ ],
24
+ derived: [
25
+ "Derived: shallow size estimate #{format_bytes(bytes)}."
26
+ ],
27
+ hypothesis: "Thread-local state may retain request/job context across reuse of this thread.",
28
+ suspected_cause: "Thread-local #{key}",
29
+ suggestions: [
30
+ "Clear the key in an ensure block after each request/job",
31
+ "Avoid storing full request graphs on long-lived Puma/Sidekiq threads",
32
+ "Confirm whether this cache is intentional and bounded"
33
+ ],
34
+ evidence: [{ thread: thread[:name], key: key, meta: meta }]
35
+ )
36
+ end
37
+ end
38
+ { inventory: inventory, findings: findings }
39
+ end
40
+
41
+ def classify_collection_growth(name:, sizes:, owner: nil)
42
+ return { kind: :insufficient_data } if sizes.size < 3
43
+
44
+ trend = Growth.analyze(sizes)
45
+ max_size = sizes.max
46
+ min_size = sizes.min
47
+ last = sizes.last(3)
48
+ plateau = last.max - last.min <= [last.max * 0.05, 2].max
49
+ shrinking = sizes.each_cons(2).any? { |a, b| b < a }
50
+
51
+ kind =
52
+ if plateau && max_size > 0 && sizes.first < max_size
53
+ :likely_cache
54
+ elsif %i[monotonic_growth linear_growth exponential_like].include?(trend[:pattern]) && !shrinking
55
+ :unbounded_candidate
56
+ elsif trend[:pattern] == :sawtooth
57
+ :recovering_collection
58
+ else
59
+ :stable_or_noisy
60
+ end
61
+
62
+ {
63
+ name: name,
64
+ owner: owner,
65
+ kind: kind,
66
+ trend: trend,
67
+ sizes: sizes,
68
+ max: max_size,
69
+ min: min_size
70
+ }
71
+ end
72
+
73
+ def collection_findings(class_series)
74
+ findings = []
75
+ class_series.each_value do |entry|
76
+ next unless %w[Array Hash Set].include?(entry[:name]) || entry[:name].end_with?("Registry", "Cache", "Store")
77
+
78
+ classification = classify_collection_growth(name: entry[:name], sizes: entry[:series])
79
+ case classification[:kind]
80
+ when :unbounded_candidate
81
+ findings << Finding.new(
82
+ code: "HS004",
83
+ severity: entry[:delta] > 200 ? :high : :medium,
84
+ subject: entry[:name],
85
+ facts: ["Observed fact: #{entry[:name]} sizes #{entry[:series].inspect}."],
86
+ derived: [
87
+ "Derived: pattern=#{classification[:trend][:pattern]}, no shrinkage observed across samples."
88
+ ],
89
+ hypothesis: "This structure is an unbounded collection candidate.",
90
+ suggestions: [
91
+ "Identify the owner/root retaining the collection",
92
+ "Cap growth or add eviction",
93
+ "Verify appends are not accidental per-request registrations"
94
+ ],
95
+ evidence: [classification]
96
+ )
97
+ when :likely_cache
98
+ findings << Finding.new(
99
+ code: "HS001",
100
+ severity: :low,
101
+ subject: entry[:name],
102
+ title: "Likely cache plateau",
103
+ facts: ["Observed fact: #{entry[:name]} reached a plateau near #{classification[:max]}."],
104
+ derived: ["Derived: growth appears bounded (cache-like)."],
105
+ hypothesis: "Intentional cache behavior is more likely than a leak.",
106
+ suggestions: ["Confirm max size / LRU / TTL expectations"],
107
+ evidence: [classification]
108
+ )
109
+ end
110
+ end
111
+ findings
112
+ end
113
+
114
+ def suspicious_key?(key)
115
+ key.to_s.match?(/context|request|session|payload|current|job|controller|env/i)
116
+ end
117
+
118
+ def format_bytes(bytes)
119
+ return "n/a" if bytes.nil?
120
+
121
+ abs = bytes.abs.to_f
122
+ if abs >= 1024 * 1024
123
+ format("%.2f MB", abs / (1024 * 1024))
124
+ elsif abs >= 1024
125
+ format("%.1f KB", abs / 1024)
126
+ else
127
+ "#{bytes} B"
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ # Rich comparison between two snapshots.
5
+ class Diff
6
+ attr_reader :before, :after, :class_deltas, :rss_delta, :heap_live_delta,
7
+ :allocated_delta, :freed_delta, :warnings, :allocation_deltas
8
+
9
+ def initialize(before, after)
10
+ @before = before
11
+ @after = after
12
+ @warnings = []
13
+ @warnings << "snapshots_from_different_processes" unless before.same_process?(after)
14
+ @class_deltas = compute_class_deltas
15
+ @rss_delta = delta(after.rss_bytes, before.rss_bytes)
16
+ @heap_live_delta = delta(after.heap_live_slots, before.heap_live_slots)
17
+ @allocated_delta = delta(after.total_allocated_objects, before.total_allocated_objects)
18
+ @freed_delta = delta(after.total_freed_objects, before.total_freed_objects)
19
+ @allocation_deltas = compute_allocation_deltas
20
+ end
21
+
22
+ def surviving_estimate
23
+ return nil if allocated_delta.nil? || freed_delta.nil?
24
+
25
+ allocated_delta - freed_delta
26
+ end
27
+
28
+ def retention_ratio
29
+ return nil if allocated_delta.nil? || allocated_delta <= 0
30
+
31
+ surviving = surviving_estimate
32
+ return nil if surviving.nil?
33
+
34
+ surviving.to_f / allocated_delta
35
+ end
36
+
37
+ def top_growth(limit = 20)
38
+ class_deltas.sort_by { |d| -d[:delta_count].abs }.first(limit)
39
+ end
40
+
41
+ def growing_classes(limit = 20)
42
+ class_deltas.select { |d| d[:delta_count].positive? }
43
+ .sort_by { |d| -d[:delta_count] }
44
+ .first(limit)
45
+ end
46
+
47
+ def freed_classes(limit = 20)
48
+ class_deltas.select { |d| d[:delta_count].negative? }
49
+ .sort_by { |d| d[:delta_count] }
50
+ .first(limit)
51
+ end
52
+
53
+ def new_classes
54
+ class_deltas.select { |d| d[:before_count].zero? && d[:after_count].positive? }
55
+ end
56
+
57
+ def native_memory_mismatch?
58
+ return false if rss_delta.nil? || heap_live_delta.nil?
59
+
60
+ rss_delta > 10 * 1024 * 1024 && heap_live_delta < 50_000 &&
61
+ (rss_delta > heap_bytes_estimate_delta * 3)
62
+ end
63
+
64
+ def heap_bytes_estimate_delta
65
+ class_deltas.sum { |d| d[:delta_bytes] }
66
+ end
67
+
68
+ def to_h
69
+ {
70
+ before_id: before.id,
71
+ after_id: after.id,
72
+ rss_delta: rss_delta,
73
+ heap_live_delta: heap_live_delta,
74
+ allocated_delta: allocated_delta,
75
+ freed_delta: freed_delta,
76
+ surviving_estimate: surviving_estimate,
77
+ retention_ratio: retention_ratio,
78
+ class_deltas: class_deltas,
79
+ allocation_deltas: allocation_deltas,
80
+ warnings: warnings,
81
+ native_memory_mismatch: native_memory_mismatch?
82
+ }
83
+ end
84
+
85
+ def to_table(format: :ascii, limit: 15)
86
+ Tables.class_growth(self, limit: limit, format: format)
87
+ end
88
+
89
+ private
90
+
91
+ def compute_class_deltas
92
+ names = (before.class_stats.keys + after.class_stats.keys).uniq
93
+ names.map do |name|
94
+ b = before.class_stats[name] || { count: 0, shallow_bytes: 0 }
95
+ a = after.class_stats[name] || { count: 0, shallow_bytes: 0 }
96
+ {
97
+ name: name,
98
+ before_count: b[:count].to_i,
99
+ after_count: a[:count].to_i,
100
+ delta_count: a[:count].to_i - b[:count].to_i,
101
+ before_bytes: b[:shallow_bytes].to_i,
102
+ after_bytes: a[:shallow_bytes].to_i,
103
+ delta_bytes: a[:shallow_bytes].to_i - b[:shallow_bytes].to_i
104
+ }
105
+ end
106
+ end
107
+
108
+ def compute_allocation_deltas
109
+ sites = (before.allocation_sites.keys + after.allocation_sites.keys).uniq
110
+ sites.map do |site|
111
+ b = before.allocation_sites[site] || { count: 0, shallow_bytes: 0 }
112
+ a = after.allocation_sites[site] || { count: 0, shallow_bytes: 0 }
113
+ {
114
+ site: site,
115
+ before_count: b[:count].to_i,
116
+ after_count: a[:count].to_i,
117
+ delta_count: a[:count].to_i - b[:count].to_i,
118
+ delta_bytes: a[:shallow_bytes].to_i - b[:shallow_bytes].to_i,
119
+ classes: a[:classes] || b[:classes] || {}
120
+ }
121
+ end.sort_by { |d| -d[:delta_count] }
122
+ end
123
+
124
+ def delta(after_v, before_v)
125
+ return nil if after_v.nil? || before_v.nil?
126
+
127
+ after_v - before_v
128
+ end
129
+ end
130
+ end