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,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
|