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,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Approximate object aging across forced GC cycles.
|
|
5
|
+
# Does not invent ages the runtime cannot support — labels are coarse.
|
|
6
|
+
class Aging
|
|
7
|
+
BUCKETS = %i[young middle_aged long_lived unknown].freeze
|
|
8
|
+
|
|
9
|
+
Sample = Struct.new(:cycle_index, :class_counts, :generation_hint, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
def initialize(runtime: Runtime.current)
|
|
12
|
+
@runtime = runtime
|
|
13
|
+
@samples = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def sample!(cycle:)
|
|
17
|
+
counts = Hash.new(0)
|
|
18
|
+
if @runtime.each_object?
|
|
19
|
+
@runtime.each_object do |obj|
|
|
20
|
+
name = begin
|
|
21
|
+
obj.class.name
|
|
22
|
+
rescue StandardError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
next unless name
|
|
26
|
+
|
|
27
|
+
counts[name] += 1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
push_counts(cycle: cycle, counts: counts, generation_hint: (GC.stat[:count] if GC.respond_to?(:stat)))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def push_counts(cycle:, counts:, generation_hint: nil)
|
|
34
|
+
@samples << Sample.new(cycle_index: cycle, class_counts: counts, generation_hint: generation_hint)
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def classify(class_name)
|
|
39
|
+
series = @samples.map { |s| s.class_counts[class_name].to_i }
|
|
40
|
+
return { bucket: :unknown, series: series } if series.size < 2
|
|
41
|
+
|
|
42
|
+
first = series.first
|
|
43
|
+
last = series.last
|
|
44
|
+
survived_ratio = first.positive? ? last.to_f / first : 0.0
|
|
45
|
+
bucket =
|
|
46
|
+
if survived_ratio < 0.2
|
|
47
|
+
:young
|
|
48
|
+
elsif survived_ratio < 0.7
|
|
49
|
+
:middle_aged
|
|
50
|
+
else
|
|
51
|
+
:long_lived
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
class: class_name,
|
|
56
|
+
bucket: bucket,
|
|
57
|
+
series: series,
|
|
58
|
+
survived_ratio: survived_ratio.round(4),
|
|
59
|
+
note: "Coarse age bucket from multi-cycle survival — not exact object age."
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def report(top: 20)
|
|
64
|
+
names = @samples.flat_map { |s| s.class_counts.keys }.uniq
|
|
65
|
+
names.map { |n| classify(n) }
|
|
66
|
+
.select { |r| r[:series].last.to_i > 10 }
|
|
67
|
+
.sort_by { |r| -r[:survived_ratio] }
|
|
68
|
+
.first(top)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Tracks GC stats around workloads for recovery correlation.
|
|
73
|
+
class GCTracker
|
|
74
|
+
def initialize
|
|
75
|
+
@points = []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def mark(label)
|
|
79
|
+
@points << {
|
|
80
|
+
label: label,
|
|
81
|
+
at: Time.now.utc,
|
|
82
|
+
gc_stat: Runtime.current.gc_stat.dup,
|
|
83
|
+
rss_bytes: begin
|
|
84
|
+
Runtime.current.rss_bytes
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
}
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def correlation
|
|
93
|
+
return {} if @points.size < 2
|
|
94
|
+
|
|
95
|
+
first = @points.first
|
|
96
|
+
last = @points.last
|
|
97
|
+
{
|
|
98
|
+
points: @points,
|
|
99
|
+
major_gc_delta: delta(last[:gc_stat][:major_gc_count], first[:gc_stat][:major_gc_count]),
|
|
100
|
+
minor_gc_delta: delta(last[:gc_stat][:minor_gc_count], first[:gc_stat][:minor_gc_count]),
|
|
101
|
+
live_slots_delta: delta(last[:gc_stat][:heap_live_slots], first[:gc_stat][:heap_live_slots]),
|
|
102
|
+
rss_delta: delta(last[:rss_bytes], first[:rss_bytes])
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def delta(a, b)
|
|
109
|
+
return nil if a.nil? || b.nil?
|
|
110
|
+
|
|
111
|
+
a - b
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Evidence-based analyzer producing findings and suspect rankings.
|
|
5
|
+
class Analyzer
|
|
6
|
+
CHURN_THRESHOLD = 0.05
|
|
7
|
+
STICKY_RETENTION = 0.30
|
|
8
|
+
|
|
9
|
+
def initialize(config: HeapScope.config)
|
|
10
|
+
@config = config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def analyze_diff(diff, context: {})
|
|
14
|
+
findings = []
|
|
15
|
+
suspects = []
|
|
16
|
+
|
|
17
|
+
diff.growing_classes(30).each do |delta|
|
|
18
|
+
next if @config.ignore_class?(delta[:name])
|
|
19
|
+
next if delta[:delta_count] < 50 && delta[:delta_bytes] < 100_000
|
|
20
|
+
|
|
21
|
+
classification = classify_population(delta, diff)
|
|
22
|
+
severity = suspicion_for(delta, classification, context)
|
|
23
|
+
|
|
24
|
+
suspects << {
|
|
25
|
+
name: delta[:name],
|
|
26
|
+
severity: severity,
|
|
27
|
+
delta_count: delta[:delta_count],
|
|
28
|
+
delta_bytes: delta[:delta_bytes],
|
|
29
|
+
classification: classification,
|
|
30
|
+
retention_ratio: context.dig(:retention_by_class, delta[:name])
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
next unless delta[:delta_count] >= 100
|
|
34
|
+
next if classification == :high_churn_low_retention
|
|
35
|
+
next if classification == :normal && severity == :low
|
|
36
|
+
|
|
37
|
+
findings << Finding.new(
|
|
38
|
+
code: "HS001",
|
|
39
|
+
severity: severity,
|
|
40
|
+
subject: delta[:name],
|
|
41
|
+
facts: [
|
|
42
|
+
"Observed fact: #{delta[:name]} count #{delta[:before_count]} → #{delta[:after_count]} (Δ #{signed(delta[:delta_count])})."
|
|
43
|
+
],
|
|
44
|
+
derived: [
|
|
45
|
+
"Derived: shallow bytes Δ #{format_bytes(delta[:delta_bytes])}."
|
|
46
|
+
],
|
|
47
|
+
hypothesis: "This population appears to be growing across the measured window.",
|
|
48
|
+
suspected_cause: nil,
|
|
49
|
+
evidence: [delta],
|
|
50
|
+
suggestions: [
|
|
51
|
+
"Compare against a post-GC snapshot",
|
|
52
|
+
"Run HeapScope.retention_test to check multi-cycle survival",
|
|
53
|
+
"Inspect allocation sites for #{delta[:name]}"
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if diff.retention_ratio && diff.retention_ratio > 0.2 && diff.allocated_delta.to_i > 1000
|
|
59
|
+
findings << Finding.new(
|
|
60
|
+
code: "HS002",
|
|
61
|
+
severity: :high,
|
|
62
|
+
facts: [
|
|
63
|
+
"Observed fact: allocated Δ #{diff.allocated_delta}, freed Δ #{diff.freed_delta}."
|
|
64
|
+
],
|
|
65
|
+
derived: [
|
|
66
|
+
"Derived: retention ratio #{pct(diff.retention_ratio)} (surviving estimate #{diff.surviving_estimate})."
|
|
67
|
+
],
|
|
68
|
+
hypothesis: "A meaningful fraction of allocations remained reachable after the workload.",
|
|
69
|
+
suggestions: [
|
|
70
|
+
"Force GC between snapshots if not already doing so",
|
|
71
|
+
"Identify top retained classes and their owners"
|
|
72
|
+
]
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if diff.native_memory_mismatch?
|
|
77
|
+
findings << Finding.new(
|
|
78
|
+
code: "HS010",
|
|
79
|
+
severity: :medium,
|
|
80
|
+
facts: [
|
|
81
|
+
"Observed fact: RSS Δ #{format_bytes(diff.rss_delta)}; heap live slots Δ #{diff.heap_live_delta}."
|
|
82
|
+
],
|
|
83
|
+
derived: [
|
|
84
|
+
"Derived: Ruby heap growth does not explain RSS growth."
|
|
85
|
+
],
|
|
86
|
+
hypothesis: "Native allocations, allocator fragmentation, mmap, or extension buffers may be involved.",
|
|
87
|
+
suggestions: [
|
|
88
|
+
"Do not assume an object leak from RSS alone",
|
|
89
|
+
"Inspect native extensions and allocator (jemalloc/glibc/macOS)"
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if high_churn?(diff)
|
|
95
|
+
findings << Finding.new(
|
|
96
|
+
code: "HS009",
|
|
97
|
+
severity: :low,
|
|
98
|
+
facts: [
|
|
99
|
+
"Observed fact: allocated Δ #{diff.allocated_delta}, freed Δ #{diff.freed_delta}."
|
|
100
|
+
],
|
|
101
|
+
derived: ["Derived: high allocation pressure with substantial reclamation."],
|
|
102
|
+
hypothesis: "Workload exhibits object churn rather than persistent retention.",
|
|
103
|
+
suggestions: ["Optimize hot allocation sites if CPU/GC time is a concern"]
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
ranked = Findings.rank_and_dedupe(findings)
|
|
108
|
+
{
|
|
109
|
+
findings: ranked,
|
|
110
|
+
suspects: suspects.sort_by { |s| [-severity_rank(s[:severity]), -s[:delta_count]] },
|
|
111
|
+
summary: build_summary(diff, ranked, suspects)
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def analyze_retention(session)
|
|
116
|
+
findings = []
|
|
117
|
+
persistent = session.persistent_classes
|
|
118
|
+
persistent.first(10).each do |entry|
|
|
119
|
+
next if @config.ignore_class?(entry[:name])
|
|
120
|
+
|
|
121
|
+
findings << Finding.new(
|
|
122
|
+
code: "HS001",
|
|
123
|
+
severity: entry[:delta] > 500 ? :high : :medium,
|
|
124
|
+
subject: entry[:name],
|
|
125
|
+
facts: [
|
|
126
|
+
"Observed fact: #{entry[:name]} series #{entry[:series].inspect}."
|
|
127
|
+
],
|
|
128
|
+
derived: [
|
|
129
|
+
"Derived: pattern=#{entry[:trend][:pattern]}, slope=#{entry[:trend][:slope]} objects/sample."
|
|
130
|
+
],
|
|
131
|
+
hypothesis: "Persistent growth across GC-sampled cycles is consistent with retention.",
|
|
132
|
+
suggestions: [
|
|
133
|
+
"Locate allocation site for #{entry[:name]}",
|
|
134
|
+
"Inspect thread-locals, globals, and registries for owners"
|
|
135
|
+
]
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if session.force_gc && persistent.any?
|
|
140
|
+
findings << Finding.new(
|
|
141
|
+
code: "HS007",
|
|
142
|
+
severity: :medium,
|
|
143
|
+
facts: ["Observed fact: forced GC between samples; persistent classes still grew."],
|
|
144
|
+
derived: ["Derived: recovery after GC appears poor for top populations."],
|
|
145
|
+
hypothesis: "Growth is not explained by delayed GC alone."
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
series = session.class_series
|
|
150
|
+
findings.concat(Detectors.collection_findings(series))
|
|
151
|
+
|
|
152
|
+
thread_local = Detectors.analyze_thread_locals
|
|
153
|
+
findings.concat(thread_local[:findings])
|
|
154
|
+
|
|
155
|
+
findings = Findings.rank_and_dedupe(findings)
|
|
156
|
+
{
|
|
157
|
+
findings: findings,
|
|
158
|
+
persistent: persistent,
|
|
159
|
+
class_series: series,
|
|
160
|
+
thread_locals: thread_local[:inventory],
|
|
161
|
+
collections: series.values.map { |e|
|
|
162
|
+
Detectors.classify_collection_growth(name: e[:name], sizes: e[:series])
|
|
163
|
+
}.select { |c| %i[likely_cache unbounded_candidate].include?(c[:kind]) },
|
|
164
|
+
summary: {
|
|
165
|
+
samples: session.samples.size,
|
|
166
|
+
persistent_count: persistent.size,
|
|
167
|
+
top: persistent.first(5).map { |e| { name: e[:name], delta: e[:delta], pattern: e[:trend][:pattern] } }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Extra deep enrichment for retention sessions (globals, fibers, aging, etc.).
|
|
173
|
+
def enrich_session(session, last_snapshot)
|
|
174
|
+
findings = []
|
|
175
|
+
limitations = []
|
|
176
|
+
cfg = @config
|
|
177
|
+
|
|
178
|
+
Noise.apply_defaults! if cfg.apply_noise_defaults
|
|
179
|
+
|
|
180
|
+
fibers = cfg.detect_fibers ? Fibers.inventory : []
|
|
181
|
+
globals = cfg.detect_globals ? Globals.inventory(max_entries: 100) : []
|
|
182
|
+
closures =
|
|
183
|
+
if cfg.detect_closures
|
|
184
|
+
Closures.inventory(limit: 50)
|
|
185
|
+
else
|
|
186
|
+
limitations << "closure_detection_disabled"
|
|
187
|
+
[]
|
|
188
|
+
end
|
|
189
|
+
findings.concat(Closures.findings(closures)) if cfg.detect_closures
|
|
190
|
+
|
|
191
|
+
dominators =
|
|
192
|
+
begin
|
|
193
|
+
Dominators.new.from_thread_locals(limit: 5)
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
limitations << "dominators_unavailable:#{e.class}"
|
|
196
|
+
[]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
fragmentation = last_snapshot ? Fragmentation.assess(last_snapshot) : nil
|
|
200
|
+
if fragmentation && fragmentation[:status] == :potential_fragmentation
|
|
201
|
+
findings << Finding.new(
|
|
202
|
+
code: "HS010",
|
|
203
|
+
severity: :low,
|
|
204
|
+
facts: ["Observed fact: RSS/heap ratio indicator=#{fragmentation[:rss_to_heap_ratio]}."],
|
|
205
|
+
derived: ["Derived: status=#{fragmentation[:status]}."],
|
|
206
|
+
hypothesis: fragmentation[:note],
|
|
207
|
+
suggestions: ["Investigate allocator / native extensions before blaming Ruby objects"]
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
aging = Aging.new
|
|
212
|
+
session.samples.each_with_index do |sample, idx|
|
|
213
|
+
counts = sample.snapshot.class_stats.transform_values { |s| s[:count].to_i }
|
|
214
|
+
aging.push_counts(cycle: idx, counts: counts, generation_hint: sample.snapshot.gc_generation)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
gc_correlation = {
|
|
218
|
+
samples: session.samples.size,
|
|
219
|
+
first_live: session.samples.first&.snapshot&.heap_live_slots,
|
|
220
|
+
last_live: session.samples.last&.snapshot&.heap_live_slots,
|
|
221
|
+
first_rss: session.samples.first&.snapshot&.rss_bytes,
|
|
222
|
+
last_rss: session.samples.last&.snapshot&.rss_bytes
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
retention_paths = []
|
|
226
|
+
if dominators.any?
|
|
227
|
+
retention_paths << {
|
|
228
|
+
summary: "Top approximate retainers from thread-locals",
|
|
229
|
+
retainers: dominators.map { |d| { class: d.class_name, approx_retained: d.approx_retained, note: d.note } }
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
{
|
|
234
|
+
findings: findings,
|
|
235
|
+
fibers: fibers,
|
|
236
|
+
globals: globals,
|
|
237
|
+
closures: closures.first(20),
|
|
238
|
+
dominators: dominators,
|
|
239
|
+
fragmentation: fragmentation,
|
|
240
|
+
aging: aging.report(top: 15),
|
|
241
|
+
gc_correlation: gc_correlation,
|
|
242
|
+
retention_paths: retention_paths,
|
|
243
|
+
limitations: limitations
|
|
244
|
+
}
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def classify_population(delta, diff)
|
|
248
|
+
allocated = diff.allocated_delta.to_i
|
|
249
|
+
retained = delta[:delta_count]
|
|
250
|
+
ratio = allocated.positive? ? retained.to_f / allocated : nil
|
|
251
|
+
|
|
252
|
+
if ratio && ratio < CHURN_THRESHOLD && allocated > 10_000
|
|
253
|
+
:high_churn_low_retention
|
|
254
|
+
elsif ratio && ratio > STICKY_RETENTION
|
|
255
|
+
:sticky
|
|
256
|
+
elsif retained > 1000 && allocated > 10_000 && ratio && ratio > 0.1
|
|
257
|
+
:severe
|
|
258
|
+
else
|
|
259
|
+
:normal
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def suspicion_for(delta, classification, context)
|
|
264
|
+
return :low if classification == :high_churn_low_retention
|
|
265
|
+
|
|
266
|
+
score = 0
|
|
267
|
+
score += 2 if delta[:delta_count] > 500
|
|
268
|
+
score += 2 if %i[sticky severe].include?(classification)
|
|
269
|
+
score += 1 if context[:force_gc]
|
|
270
|
+
score += 1 if context.dig(:survival_cycles, delta[:name]).to_i >= 3
|
|
271
|
+
score -= 2 if context.dig(:likely_cache, delta[:name])
|
|
272
|
+
score -= 1 if @config.ignore_class?(delta[:name])
|
|
273
|
+
|
|
274
|
+
case score
|
|
275
|
+
when ...1 then :low
|
|
276
|
+
when 1..3 then :medium
|
|
277
|
+
else :high
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
private
|
|
282
|
+
|
|
283
|
+
def high_churn?(diff)
|
|
284
|
+
allocated = diff.allocated_delta.to_i
|
|
285
|
+
freed = diff.freed_delta.to_i
|
|
286
|
+
allocated > 50_000 && freed > allocated * 0.9
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def build_summary(diff, findings, suspects)
|
|
290
|
+
actionable = findings.reject { |f| f.code == "HS009" }
|
|
291
|
+
healthy = actionable.none? { |f| %i[high medium].include?(f.severity) } &&
|
|
292
|
+
suspects.none? { |s| s[:severity] == :high }
|
|
293
|
+
{
|
|
294
|
+
healthy: healthy,
|
|
295
|
+
rss_delta: diff.rss_delta,
|
|
296
|
+
heap_live_delta: diff.heap_live_delta,
|
|
297
|
+
retention_ratio: diff.retention_ratio,
|
|
298
|
+
top_suspects: suspects.first(5),
|
|
299
|
+
finding_codes: findings.map(&:code)
|
|
300
|
+
}
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def severity_rank(sev)
|
|
304
|
+
{ high: 3, medium: 2, low: 1 }[sev] || 0
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def signed(n)
|
|
308
|
+
n.positive? ? "+#{n}" : n.to_s
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def pct(ratio)
|
|
312
|
+
format("%.2f%%", ratio * 100.0)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def format_bytes(bytes)
|
|
316
|
+
return "n/a" if bytes.nil?
|
|
317
|
+
|
|
318
|
+
abs = bytes.abs.to_f
|
|
319
|
+
sign = if bytes.negative?
|
|
320
|
+
"-"
|
|
321
|
+
else
|
|
322
|
+
(bytes.positive? ? "+" : "")
|
|
323
|
+
end
|
|
324
|
+
if abs >= 1024 * 1024
|
|
325
|
+
format("%s%.2f MB", sign, abs / (1024 * 1024))
|
|
326
|
+
elsif abs >= 1024
|
|
327
|
+
format("%s%.1f KB", sign, abs / 1024)
|
|
328
|
+
else
|
|
329
|
+
format("%s%d B", sign, bytes)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module HeapScope
|
|
6
|
+
module Baseline
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def create(report_or_path, output_path)
|
|
10
|
+
report = report_or_path.is_a?(Report) ? report_or_path : Report.load(report_or_path)
|
|
11
|
+
payload = {
|
|
12
|
+
schema_version: SCHEMA_VERSION,
|
|
13
|
+
kind: "baseline",
|
|
14
|
+
created_at: Time.now.utc.iso8601,
|
|
15
|
+
heapscope_version: VERSION,
|
|
16
|
+
summary: report.summary,
|
|
17
|
+
classes: top_classes(report),
|
|
18
|
+
rss_bytes: report.after&.rss_bytes || report.before&.rss_bytes,
|
|
19
|
+
heap_live_slots: report.after&.heap_live_slots || report.before&.heap_live_slots,
|
|
20
|
+
retained_objects_estimate: report.diff&.surviving_estimate,
|
|
21
|
+
retained_bytes_estimate: report.diff&.heap_bytes_estimate_delta
|
|
22
|
+
}
|
|
23
|
+
File.write(output_path, JSON.pretty_generate(payload))
|
|
24
|
+
output_path
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def compare(baseline_path, current_path_or_report, threshold: 0.5)
|
|
28
|
+
baseline = JSON.parse(File.read(baseline_path), symbolize_names: true)
|
|
29
|
+
current = current_path_or_report.is_a?(Report) ? current_path_or_report : Report.load(current_path_or_report)
|
|
30
|
+
|
|
31
|
+
base_retained = baseline[:retained_objects_estimate].to_i
|
|
32
|
+
curr_retained = current.diff&.surviving_estimate.to_i
|
|
33
|
+
change = if base_retained.zero?
|
|
34
|
+
curr_retained.positive? ? 1.0 : 0.0
|
|
35
|
+
else
|
|
36
|
+
(curr_retained - base_retained).to_f / base_retained
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
base_bytes = baseline[:retained_bytes_estimate].to_i
|
|
40
|
+
curr_bytes = current.diff&.heap_bytes_estimate_delta.to_i
|
|
41
|
+
bytes_change = base_bytes.zero? ? 0.0 : (curr_bytes - base_bytes).to_f / base_bytes
|
|
42
|
+
|
|
43
|
+
regression = change > threshold || bytes_change > threshold
|
|
44
|
+
findings = []
|
|
45
|
+
if regression
|
|
46
|
+
findings << Finding.new(
|
|
47
|
+
code: "HS008",
|
|
48
|
+
severity: :high,
|
|
49
|
+
facts: [
|
|
50
|
+
"Observed fact: retained objects baseline=#{base_retained} current=#{curr_retained}.",
|
|
51
|
+
"Observed fact: retained bytes baseline=#{base_bytes} current=#{curr_bytes}."
|
|
52
|
+
],
|
|
53
|
+
derived: [
|
|
54
|
+
"Derived: object change #{(change * 100).round(1)}%, bytes change #{(bytes_change * 100).round(1)}%."
|
|
55
|
+
],
|
|
56
|
+
hypothesis: "Current run exceeds baseline retention beyond threshold #{threshold}."
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
regression: regression,
|
|
62
|
+
baseline_retained_objects: base_retained,
|
|
63
|
+
current_retained_objects: curr_retained,
|
|
64
|
+
object_change_ratio: change,
|
|
65
|
+
baseline_retained_bytes: base_bytes,
|
|
66
|
+
current_retained_bytes: curr_bytes,
|
|
67
|
+
bytes_change_ratio: bytes_change,
|
|
68
|
+
findings: findings,
|
|
69
|
+
result: regression ? "REGRESSION" : "OK"
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def top_classes(report)
|
|
74
|
+
(report.classes || []).first(50)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Central brand + funding metadata for CLI, reports, and docs generation.
|
|
5
|
+
module Branding
|
|
6
|
+
NAME = "HeapScope"
|
|
7
|
+
TAGLINE = "Ruby retention & heap growth diagnostics"
|
|
8
|
+
GITHUB_USER = "theworker02"
|
|
9
|
+
GITHUB_REPO = "https://github.com/theworker02/heapscope"
|
|
10
|
+
PAGES_URL = "https://theworker02.github.io/heapscope/"
|
|
11
|
+
SPONSORS_URL = "https://github.com/sponsors/theworker02"
|
|
12
|
+
THANKS_DEV_URL = "https://thanks.dev/u/gh/theworker02"
|
|
13
|
+
THANKS_DEV_PATH = "u/gh/theworker02"
|
|
14
|
+
LOGO_SVG = "assets/logo.svg"
|
|
15
|
+
WORDMARK_SVG = "assets/wordmark.svg"
|
|
16
|
+
LOGO_PNG = "assets/heapscope-logo.png"
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def banner
|
|
21
|
+
<<~BANNER
|
|
22
|
+
██╗ ██╗███████╗ █████╗ ██████╗ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗
|
|
23
|
+
██║ ██║██╔════╝██╔══██╗██╔══██╗██╔════╝██╔════╝██╔═══██╗██╔══██╗██╔════╝
|
|
24
|
+
███████║█████╗ ███████║██████╔╝███████╗██║ ██║ ██║██████╔╝█████╗
|
|
25
|
+
██╔══██║██╔══╝ ██╔══██║██╔═══╝ ╚════██║██║ ██║ ██║██╔═══╝ ██╔══╝
|
|
26
|
+
██║ ██║███████╗██║ ██║██║ ███████║╚██████╗╚██████╔╝██║ ███████╗
|
|
27
|
+
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝
|
|
28
|
+
#{TAGLINE} v#{VERSION}
|
|
29
|
+
BANNER
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def compact_banner
|
|
33
|
+
"#{NAME} v#{VERSION} — #{TAGLINE}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def funding_lines
|
|
37
|
+
[
|
|
38
|
+
"Sponsor: #{SPONSORS_URL}",
|
|
39
|
+
"thanks.dev: #{THANKS_DEV_URL}",
|
|
40
|
+
"Docs site: #{PAGES_URL}"
|
|
41
|
+
]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def about_text
|
|
45
|
+
lines = []
|
|
46
|
+
lines << compact_banner
|
|
47
|
+
lines << ""
|
|
48
|
+
lines << "Repository: #{GITHUB_REPO}"
|
|
49
|
+
lines << "Website: #{PAGES_URL}"
|
|
50
|
+
funding_lines.each { |l| lines << l }
|
|
51
|
+
lines << ""
|
|
52
|
+
lines << "Privacy: local-only diagnostics — no telemetry, no object-value dumps by default."
|
|
53
|
+
lines.join("\n")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def report_footer
|
|
57
|
+
"Privacy: object values are not serialized by default. · #{THANKS_DEV_URL} · #{PAGES_URL}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def html_footer
|
|
61
|
+
"Generated locally by #{NAME} · no network · no telemetry · " \
|
|
62
|
+
"<a href=\"#{THANKS_DEV_URL}\">thanks.dev</a> · " \
|
|
63
|
+
"<a href=\"#{PAGES_URL}\">docs</a>"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def gem_root
|
|
67
|
+
File.expand_path("../..", __dir__)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def asset_path(relative)
|
|
71
|
+
File.join(gem_root, relative)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def logo_svg_inline
|
|
75
|
+
path = asset_path(LOGO_SVG)
|
|
76
|
+
return nil unless File.file?(path)
|
|
77
|
+
|
|
78
|
+
File.read(path)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_h
|
|
82
|
+
{
|
|
83
|
+
name: NAME,
|
|
84
|
+
tagline: TAGLINE,
|
|
85
|
+
version: VERSION,
|
|
86
|
+
github_user: GITHUB_USER,
|
|
87
|
+
github_repo: GITHUB_REPO,
|
|
88
|
+
pages_url: PAGES_URL,
|
|
89
|
+
sponsors_url: SPONSORS_URL,
|
|
90
|
+
thanks_dev_url: THANKS_DEV_URL,
|
|
91
|
+
thanks_dev_path: THANKS_DEV_PATH,
|
|
92
|
+
logo_svg: LOGO_SVG,
|
|
93
|
+
wordmark_svg: WORDMARK_SVG,
|
|
94
|
+
logo_png: LOGO_PNG
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|