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,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Detects growth patterns across samples. Evidence-based, not magical.
|
|
5
|
+
module Growth
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
PATTERNS = %i[
|
|
9
|
+
stable bursty linear_growth monotonic_growth sawtooth
|
|
10
|
+
exponential_like bounded_plateau insufficient_data
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def analyze(samples)
|
|
14
|
+
values = samples.map(&:to_f)
|
|
15
|
+
return result(:insufficient_data, values) if values.size < 3
|
|
16
|
+
|
|
17
|
+
slope = linear_slope(values)
|
|
18
|
+
variance = sample_variance(values)
|
|
19
|
+
mean = values.sum / values.size
|
|
20
|
+
recovering = sawtooth?(values)
|
|
21
|
+
plateau = plateau?(values)
|
|
22
|
+
monotonic = monotonic?(values)
|
|
23
|
+
exponential = exponential_like?(values)
|
|
24
|
+
|
|
25
|
+
relative_span = mean.positive? ? (values.max - values.min) / mean : 0
|
|
26
|
+
|
|
27
|
+
pattern =
|
|
28
|
+
if recovering
|
|
29
|
+
:sawtooth
|
|
30
|
+
elsif plateau && values.max > values.first * 1.5
|
|
31
|
+
:bounded_plateau
|
|
32
|
+
elsif exponential
|
|
33
|
+
:exponential_like
|
|
34
|
+
elsif monotonic && slope > 0 && !exponential
|
|
35
|
+
:monotonic_growth
|
|
36
|
+
elsif relative_span < 0.05 || (slope.abs < [mean * 0.02, 1.0].max && variance < (mean * 0.05)**2)
|
|
37
|
+
:stable
|
|
38
|
+
elsif slope > 0
|
|
39
|
+
:linear_growth
|
|
40
|
+
elsif variance > (mean * 0.3)**2
|
|
41
|
+
:bursty
|
|
42
|
+
else
|
|
43
|
+
:stable
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
result(pattern, values, slope: slope)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def result(pattern, values, slope: 0.0)
|
|
50
|
+
{
|
|
51
|
+
pattern: pattern,
|
|
52
|
+
slope: slope.round(3),
|
|
53
|
+
samples: values,
|
|
54
|
+
min: values.min,
|
|
55
|
+
max: values.max,
|
|
56
|
+
mean: values.empty? ? 0.0 : (values.sum / values.size).round(3),
|
|
57
|
+
recovery_after_gc: pattern == :sawtooth ? :observed : :minimal
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def linear_slope(values)
|
|
62
|
+
n = values.size
|
|
63
|
+
xs = (0...n).map(&:to_f)
|
|
64
|
+
x_mean = xs.sum / n
|
|
65
|
+
y_mean = values.sum / n
|
|
66
|
+
num = xs.zip(values).sum { |x, y| (x - x_mean) * (y - y_mean) }
|
|
67
|
+
den = xs.sum { |x| (x - x_mean)**2 }
|
|
68
|
+
return 0.0 if den.zero?
|
|
69
|
+
|
|
70
|
+
num / den
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def sample_variance(values)
|
|
74
|
+
return 0.0 if values.size < 2
|
|
75
|
+
|
|
76
|
+
mean = values.sum / values.size
|
|
77
|
+
values.sum { |v| (v - mean)**2 } / (values.size - 1)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def monotonic?(values)
|
|
81
|
+
values.each_cons(2).all? { |a, b| b >= a }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def sawtooth?(values)
|
|
85
|
+
drops = values.each_cons(2).count { |a, b| b < a * 0.7 }
|
|
86
|
+
rises = values.each_cons(2).count { |a, b| b > a }
|
|
87
|
+
drops >= 1 && rises >= 2
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def plateau?(values)
|
|
91
|
+
return false if values.size < 5
|
|
92
|
+
|
|
93
|
+
last = values.last(3)
|
|
94
|
+
span = last.max - last.min
|
|
95
|
+
early_growth = values[2] >= values.first * 1.5
|
|
96
|
+
span <= [last.max * 0.05, 2].max && early_growth
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def exponential_like?(values)
|
|
100
|
+
return false unless monotonic?(values) && values.first.positive?
|
|
101
|
+
return false if values.size < 4
|
|
102
|
+
|
|
103
|
+
ratios = values.each_cons(2).map { |a, b| a.positive? ? b / a : 0 }
|
|
104
|
+
# Require accelerating growth, not merely a steady linear climb.
|
|
105
|
+
ratios.size >= 3 && ratios.all? { |r| r >= 1.4 } && ratios.last >= ratios.first
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Opt-in Rack middleware. Lightweight sampling only — never full heap on every request.
|
|
5
|
+
class Middleware
|
|
6
|
+
def initialize(app, sample_rate: 0.01, force_gc: false, mode: :lightweight, on_report: nil)
|
|
7
|
+
@app = app
|
|
8
|
+
@sample_rate = sample_rate
|
|
9
|
+
@force_gc = force_gc
|
|
10
|
+
@mode = mode
|
|
11
|
+
@on_report = on_report
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(env)
|
|
15
|
+
return @app.call(env) if @sample_rate <= 0 || rand > @sample_rate
|
|
16
|
+
|
|
17
|
+
status = headers = body = nil
|
|
18
|
+
report = HeapScope.measure(
|
|
19
|
+
force_gc: @force_gc,
|
|
20
|
+
mode: @mode,
|
|
21
|
+
metadata: {
|
|
22
|
+
kind: "rack_request",
|
|
23
|
+
path: env["PATH_INFO"],
|
|
24
|
+
method: env["REQUEST_METHOD"],
|
|
25
|
+
pid: Process.pid
|
|
26
|
+
}
|
|
27
|
+
) do
|
|
28
|
+
status, headers, body = @app.call(env)
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
@on_report&.call(report)
|
|
32
|
+
[status, headers, body]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "heapscope"
|
|
4
|
+
|
|
5
|
+
# Optional Minitest helpers: require "heapscope/minitest"
|
|
6
|
+
module HeapScope
|
|
7
|
+
module Minitest
|
|
8
|
+
def assert_heapscope_retained_under(limit, force_gc: true, klass: nil, &block)
|
|
9
|
+
report = HeapScope.measure(force_gc: force_gc, &block)
|
|
10
|
+
actual = klass ? report.retained_count_for(klass) : report.diff&.surviving_estimate.to_i
|
|
11
|
+
assert actual < limit,
|
|
12
|
+
"expected retained objects < #{limit}, got #{actual}. " \
|
|
13
|
+
"Suspects: #{report.suspects.first(5).map { |s| s[:name] }.join(', ')}"
|
|
14
|
+
report
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def assert_heapscope_bytes_under(limit, force_gc: true, &block)
|
|
18
|
+
report = HeapScope.measure(force_gc: force_gc, &block)
|
|
19
|
+
actual = report.diff&.heap_bytes_estimate_delta.to_i
|
|
20
|
+
assert actual < limit,
|
|
21
|
+
"expected retained bytes < #{limit}, got #{actual}"
|
|
22
|
+
report
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def assert_heapscope_budget(budget, **opts, &)
|
|
26
|
+
report = HeapScope.check_budget(budget: budget, **opts, &)
|
|
27
|
+
assert report.passed_budget?, report.budget_result[:violations].join("\n")
|
|
28
|
+
report
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def assert_heapscope_healthy(force_gc: true, &block)
|
|
32
|
+
report = HeapScope.measure(force_gc: force_gc, &block)
|
|
33
|
+
assert report.healthy? || report.findings.none? { |f| f.severity == :high },
|
|
34
|
+
"expected healthy profile, got #{report.findings.map(&:code)}"
|
|
35
|
+
report
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def assert_heapscope_no_code(code, force_gc: true, &block)
|
|
39
|
+
report = HeapScope.measure(force_gc: force_gc, &block)
|
|
40
|
+
refute report.findings.any? { |f| f.code == code },
|
|
41
|
+
"expected no #{code}, got #{report.findings.map(&:code)}"
|
|
42
|
+
report
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Minitest::Test.include HeapScope::Minitest if defined?(Minitest::Test)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# In-process sampling monitor with optional anomaly alerts.
|
|
5
|
+
# Uses a low-priority background thread.
|
|
6
|
+
class Monitor
|
|
7
|
+
Sample = Struct.new(:at, :snapshot, keyword_init: true)
|
|
8
|
+
Alert = Struct.new(:at, :kind, :message, :rss_delta, :live_delta, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
attr_reader :samples, :interval, :mode, :alerts
|
|
11
|
+
|
|
12
|
+
def self.start(interval: 10, mode: :lightweight, max_samples: 1_000,
|
|
13
|
+
alert: false, rss_alert_bytes: 25 * 1024 * 1024, live_alert_slots: 80_000)
|
|
14
|
+
new(
|
|
15
|
+
interval: interval,
|
|
16
|
+
mode: mode,
|
|
17
|
+
max_samples: max_samples,
|
|
18
|
+
alert: alert,
|
|
19
|
+
rss_alert_bytes: rss_alert_bytes,
|
|
20
|
+
live_alert_slots: live_alert_slots
|
|
21
|
+
).tap(&:start)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(interval: 10, mode: :lightweight, max_samples: 1_000,
|
|
25
|
+
alert: false, rss_alert_bytes: 25 * 1024 * 1024, live_alert_slots: 80_000)
|
|
26
|
+
@interval = interval
|
|
27
|
+
@mode = mode
|
|
28
|
+
@max_samples = max_samples
|
|
29
|
+
@alert = alert
|
|
30
|
+
@rss_alert_bytes = rss_alert_bytes
|
|
31
|
+
@live_alert_slots = live_alert_slots
|
|
32
|
+
@samples = []
|
|
33
|
+
@alerts = []
|
|
34
|
+
@thread = nil
|
|
35
|
+
@stop = false
|
|
36
|
+
@collector = Collector.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def alert?
|
|
40
|
+
@alert
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def start
|
|
44
|
+
return self if @thread&.alive?
|
|
45
|
+
|
|
46
|
+
@stop = false
|
|
47
|
+
@thread = Thread.new do
|
|
48
|
+
Thread.current.name = "heapscope-monitor" if Thread.current.respond_to?(:name=)
|
|
49
|
+
Thread.current.abort_on_exception = false
|
|
50
|
+
until @stop
|
|
51
|
+
begin
|
|
52
|
+
snap = @collector.capture(mode: @mode, metadata: { monitor: true })
|
|
53
|
+
sample = Sample.new(at: Time.now.utc, snapshot: snap)
|
|
54
|
+
maybe_alert!(sample)
|
|
55
|
+
@samples << sample
|
|
56
|
+
@samples.shift if @samples.size > @max_samples
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
HeapScope.config.log(:debug, "monitor sample failed: #{e.message}")
|
|
59
|
+
end
|
|
60
|
+
sleep @interval
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def stop
|
|
67
|
+
@stop = true
|
|
68
|
+
@thread&.join(2)
|
|
69
|
+
@thread = nil
|
|
70
|
+
finish_report
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def finish_report
|
|
74
|
+
if samples.empty?
|
|
75
|
+
return Report.new(
|
|
76
|
+
summary: { healthy: true, samples: 0 },
|
|
77
|
+
metadata: { kind: "monitor", alerts: [] }
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
session_like = samples.map(&:snapshot)
|
|
82
|
+
first = session_like.first
|
|
83
|
+
last = session_like.last
|
|
84
|
+
diff = Diff.new(first, last)
|
|
85
|
+
analysis = Analyzer.new.analyze_diff(diff)
|
|
86
|
+
series = build_timeline
|
|
87
|
+
alert_payload = alerts.map(&:to_h)
|
|
88
|
+
Report.from_diff(
|
|
89
|
+
diff,
|
|
90
|
+
analysis: analysis,
|
|
91
|
+
metadata: {
|
|
92
|
+
kind: "monitor",
|
|
93
|
+
timeline: series,
|
|
94
|
+
alerts: alert_payload,
|
|
95
|
+
alert_count: alert_payload.size
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def maybe_alert!(sample)
|
|
103
|
+
return unless @alert
|
|
104
|
+
return if samples.empty?
|
|
105
|
+
|
|
106
|
+
prev = samples.last.snapshot
|
|
107
|
+
rss_delta = safe_delta(sample.snapshot.rss_bytes, prev.rss_bytes)
|
|
108
|
+
live_delta = safe_delta(sample.snapshot.heap_live_slots, prev.heap_live_slots)
|
|
109
|
+
|
|
110
|
+
if rss_delta && rss_delta >= @rss_alert_bytes
|
|
111
|
+
record_alert!(
|
|
112
|
+
:rss_spike,
|
|
113
|
+
"RSS rose by #{rss_delta} bytes between samples (threshold #{@rss_alert_bytes}).",
|
|
114
|
+
rss_delta: rss_delta,
|
|
115
|
+
live_delta: live_delta
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
return unless live_delta && live_delta >= @live_alert_slots
|
|
120
|
+
|
|
121
|
+
record_alert!(
|
|
122
|
+
:live_slots_spike,
|
|
123
|
+
"Heap live slots rose by #{live_delta} between samples (threshold #{@live_alert_slots}).",
|
|
124
|
+
rss_delta: rss_delta,
|
|
125
|
+
live_delta: live_delta
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def record_alert!(kind, message, rss_delta:, live_delta:)
|
|
130
|
+
alert = Alert.new(
|
|
131
|
+
at: Time.now.utc.iso8601,
|
|
132
|
+
kind: kind,
|
|
133
|
+
message: message,
|
|
134
|
+
rss_delta: rss_delta,
|
|
135
|
+
live_delta: live_delta
|
|
136
|
+
)
|
|
137
|
+
@alerts << alert
|
|
138
|
+
HeapScope.config.log(:info, "monitor alert: #{kind} — #{message}")
|
|
139
|
+
warn("[HeapScope watch] #{kind}: #{message}") if $stderr.tty? && !HeapScope.config.quiet
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def safe_delta(after_v, before_v)
|
|
143
|
+
return nil if after_v.nil? || before_v.nil?
|
|
144
|
+
|
|
145
|
+
after_v - before_v
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_timeline
|
|
149
|
+
samples.map do |s|
|
|
150
|
+
top = s.snapshot.top_classes(1).first
|
|
151
|
+
{
|
|
152
|
+
time: s.at.iso8601,
|
|
153
|
+
rss_bytes: s.snapshot.rss_bytes,
|
|
154
|
+
live_slots: s.snapshot.heap_live_slots,
|
|
155
|
+
top_class: top && top[:name]
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Known framework / VM noise filters. Affects findings, not raw snapshot data.
|
|
5
|
+
module Noise
|
|
6
|
+
DEFAULT_PATTERNS = [
|
|
7
|
+
/^RubyVM/,
|
|
8
|
+
/^Zeitwerk/,
|
|
9
|
+
/^Bootsnap/,
|
|
10
|
+
/^ActiveSupport::Dependencies/,
|
|
11
|
+
/^Module$/,
|
|
12
|
+
/^Class$/,
|
|
13
|
+
/^HeapScope::/
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
# Extra framework hints used by Suggest (never auto-applied to config).
|
|
17
|
+
FRAMEWORK_HINTS = [
|
|
18
|
+
/^ActiveSupport::/,
|
|
19
|
+
/^ActiveRecord::ConnectionAdapters/,
|
|
20
|
+
/^ActionDispatch::/,
|
|
21
|
+
/^ActionController::/,
|
|
22
|
+
/^ActionView::/,
|
|
23
|
+
/^Rack::/,
|
|
24
|
+
/^Sidekiq::/,
|
|
25
|
+
/^Concurrent::/
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def default_patterns
|
|
31
|
+
DEFAULT_PATTERNS.dup
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def suggestion_patterns
|
|
35
|
+
DEFAULT_PATTERNS + FRAMEWORK_HINTS
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def noisy?(name, patterns: HeapScope.config.ignore_patterns)
|
|
39
|
+
patterns.any? { |p| name.to_s.match?(p) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def apply_defaults!
|
|
43
|
+
DEFAULT_PATTERNS.each do |pattern|
|
|
44
|
+
HeapScope.config.ignore_patterns << pattern unless HeapScope.config.ignore_patterns.include?(pattern)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Optional ActiveSupport::Notifications adapter — not required in core.
|
|
5
|
+
module Notifications
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def available?
|
|
9
|
+
defined?(ActiveSupport::Notifications)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def subscriber_counts
|
|
13
|
+
raise CapabilityError, "ActiveSupport::Notifications not loaded" unless available?
|
|
14
|
+
|
|
15
|
+
# Best-effort: AS does not always expose a public subscriber registry.
|
|
16
|
+
# We sample notifier internals when present, otherwise return empty.
|
|
17
|
+
notifier = ActiveSupport::Notifications.notifier
|
|
18
|
+
counts = Hash.new(0)
|
|
19
|
+
if notifier.respond_to?(:listeners_for)
|
|
20
|
+
# Can't enumerate all patterns easily — return structure placeholder
|
|
21
|
+
{ note: "Use listen_growth to track specific patterns over time." }
|
|
22
|
+
elsif notifier.instance_variable_defined?(:@subscribers)
|
|
23
|
+
Array(notifier.instance_variable_get(:@subscribers)).each do |sub|
|
|
24
|
+
pattern = begin
|
|
25
|
+
sub.respond_to?(:pattern) ? sub.pattern : sub.class.name
|
|
26
|
+
rescue StandardError
|
|
27
|
+
"unknown"
|
|
28
|
+
end
|
|
29
|
+
counts[pattern.to_s] += 1
|
|
30
|
+
end
|
|
31
|
+
counts
|
|
32
|
+
else
|
|
33
|
+
{ note: "Subscriber registry not introspectable on this Rails version." }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Track a specific notification pattern count via wrapping — records samples.
|
|
38
|
+
class GrowthProbe
|
|
39
|
+
def initialize(pattern)
|
|
40
|
+
@pattern = pattern
|
|
41
|
+
@samples = []
|
|
42
|
+
@count = 0
|
|
43
|
+
@sub = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def start!
|
|
47
|
+
raise CapabilityError, "ActiveSupport::Notifications not loaded" unless Notifications.available?
|
|
48
|
+
|
|
49
|
+
@sub = ActiveSupport::Notifications.subscribe(@pattern) { @_count = (@_count || 0) + 1 }
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def sample!
|
|
54
|
+
# Prefer explicit counter if subscribe block used; otherwise inventory
|
|
55
|
+
@samples << (@_count || 0)
|
|
56
|
+
@samples.last
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def finish
|
|
60
|
+
ActiveSupport::Notifications.unsubscribe(@sub) if @sub
|
|
61
|
+
series = @samples
|
|
62
|
+
trend = Growth.analyze(series)
|
|
63
|
+
finding =
|
|
64
|
+
if %i[monotonic_growth linear_growth exponential_like].include?(trend[:pattern])
|
|
65
|
+
Finding.new(
|
|
66
|
+
code: "HS005",
|
|
67
|
+
severity: :high,
|
|
68
|
+
subject: @pattern.to_s,
|
|
69
|
+
facts: ["Observed fact: notification #{@pattern} sample counts #{series.inspect}."],
|
|
70
|
+
derived: ["Derived: pattern=#{trend[:pattern]}."],
|
|
71
|
+
hypothesis: "Possible repeated registration without unsubscribe.",
|
|
72
|
+
suggestions: ["Register subscribers once at boot", "Unsubscribe on teardown"]
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
{ series: series, trend: trend, finding: finding }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module HeapScope
|
|
7
|
+
# Export a self-contained local report bundle (JSON + HTML + Markdown + notes).
|
|
8
|
+
# Never uploads; writes only to the destination directory.
|
|
9
|
+
module Pack
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def export(report, dir, label: "heapscope-report")
|
|
13
|
+
FileUtils.mkdir_p(dir)
|
|
14
|
+
base = File.join(dir, label)
|
|
15
|
+
json_path = "#{base}.json"
|
|
16
|
+
html_path = "#{base}.html"
|
|
17
|
+
md_path = "#{base}.md"
|
|
18
|
+
readme_path = File.join(dir, "README.txt")
|
|
19
|
+
|
|
20
|
+
report.save(json_path)
|
|
21
|
+
File.write(html_path, Report::HTML.render(report))
|
|
22
|
+
File.write(md_path, report.to_markdown)
|
|
23
|
+
File.write(readme_path, readme_body(report, label: label))
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
dir: dir,
|
|
27
|
+
json: json_path,
|
|
28
|
+
html: html_path,
|
|
29
|
+
markdown: md_path,
|
|
30
|
+
readme: readme_path
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def readme_body(report, label:)
|
|
35
|
+
lines = []
|
|
36
|
+
lines << Branding.compact_banner
|
|
37
|
+
lines << ""
|
|
38
|
+
lines << "Local report bundle: #{label}"
|
|
39
|
+
lines << "Generated: #{Time.now.utc.iso8601}"
|
|
40
|
+
lines << "Result: #{report.summary[:healthy] ? 'HEALTHY' : 'ATTENTION'}"
|
|
41
|
+
lines << "Findings: #{report.findings.size}"
|
|
42
|
+
lines << ""
|
|
43
|
+
lines << "Files:"
|
|
44
|
+
lines << " #{label}.json — machine-readable report"
|
|
45
|
+
lines << " #{label}.html — shareable HTML (local file)"
|
|
46
|
+
lines << " #{label}.md — Markdown summary"
|
|
47
|
+
lines << ""
|
|
48
|
+
lines << "This bundle was generated offline. It was not uploaded anywhere."
|
|
49
|
+
lines << ""
|
|
50
|
+
Branding.funding_lines.each { |l| lines << l }
|
|
51
|
+
lines.join("\n")
|
|
52
|
+
end
|
|
53
|
+
private_class_method :readme_body
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Pretty retention-path formatting and reproduction helpers.
|
|
5
|
+
module Paths
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def format_tree(path)
|
|
9
|
+
return "Nearest observed retainer: unavailable" if path.nil? || Array(path.nodes).empty?
|
|
10
|
+
|
|
11
|
+
lines = []
|
|
12
|
+
lines << "LIKELY RETENTION PATH (confidence: #{path.confidence})"
|
|
13
|
+
lines << "Note: #{path.note}" if path.note
|
|
14
|
+
Array(path.nodes).each_with_index do |node, idx|
|
|
15
|
+
via = node[:via] || node["via"] || node[:note] || "ref"
|
|
16
|
+
prefix = idx.zero? ? "" : "#{" " * idx}└── "
|
|
17
|
+
lines << "#{prefix}#{via}"
|
|
18
|
+
end
|
|
19
|
+
lines.join("\n")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module Reproduction
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def command(metadata = {})
|
|
27
|
+
mode = metadata[:mode] || HeapScope.config.mode
|
|
28
|
+
script = metadata[:script] || "examples/import_leak.rb"
|
|
29
|
+
"HEAPSCOPE_MODE=#{mode} bundle exec ruby #{script}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def attach!(report, metadata = {})
|
|
33
|
+
report.instance_variable_set(
|
|
34
|
+
:@reproduction,
|
|
35
|
+
{
|
|
36
|
+
command: command(metadata),
|
|
37
|
+
metadata: metadata,
|
|
38
|
+
captured_at: Time.now.utc.iso8601
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
report
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Self-overhead profiler to avoid HeapScope dominating the workload.
|
|
46
|
+
module Overhead
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
def measure_snapshot(mode: :lightweight, runs: 3)
|
|
50
|
+
times = runs.times.map do
|
|
51
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
52
|
+
snap = HeapScope.snapshot(mode: mode, max_objects: 50_000)
|
|
53
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
54
|
+
{ seconds: elapsed, duration_ms: snap.duration_ms, classes: snap.class_stats.size }
|
|
55
|
+
end
|
|
56
|
+
{
|
|
57
|
+
mode: mode,
|
|
58
|
+
runs: times,
|
|
59
|
+
avg_seconds: (times.sum { |t| t[:seconds] } / times.size).round(4),
|
|
60
|
+
note: "HeapScope overhead for this mode on this machine."
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Optional Rails helpers. Loaded explicitly: require "heapscope/rails"
|
|
5
|
+
module Rails
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def install_middleware!(sample_rate: 0.01)
|
|
9
|
+
raise CapabilityError, "Rails is not loaded" unless defined?(::Rails)
|
|
10
|
+
|
|
11
|
+
::Rails.application.config.middleware.use(
|
|
12
|
+
HeapScope::Middleware,
|
|
13
|
+
sample_rate: sample_rate
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def request_retention(label: "request", cycles: 10, force_gc: true, &block)
|
|
18
|
+
raise ArgumentError, "block required" unless block_given?
|
|
19
|
+
|
|
20
|
+
HeapScope.retention_test(
|
|
21
|
+
cycles: cycles,
|
|
22
|
+
force_gc: force_gc,
|
|
23
|
+
metadata: { kind: "rails_request", label: label }, &block
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def measure_action(controller:, action:, force_gc: true, &block)
|
|
28
|
+
raise ArgumentError, "block required" unless block_given?
|
|
29
|
+
|
|
30
|
+
HeapScope.measure(
|
|
31
|
+
force_gc: force_gc,
|
|
32
|
+
metadata: {
|
|
33
|
+
kind: "rails_action",
|
|
34
|
+
controller: controller.to_s,
|
|
35
|
+
action: action.to_s,
|
|
36
|
+
label: "#{controller}##{action}"
|
|
37
|
+
}, &block
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def activerecord_population
|
|
42
|
+
return {} unless defined?(::ActiveRecord::Base)
|
|
43
|
+
|
|
44
|
+
counts = {}
|
|
45
|
+
ObjectSpace.each_object(Class) do |klass|
|
|
46
|
+
next unless klass < ::ActiveRecord::Base
|
|
47
|
+
next if klass.abstract_class?
|
|
48
|
+
|
|
49
|
+
counts[klass.name] = ObjectSpace.each_object(klass).count
|
|
50
|
+
rescue StandardError
|
|
51
|
+
next
|
|
52
|
+
end
|
|
53
|
+
counts
|
|
54
|
+
rescue StandardError
|
|
55
|
+
{}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def warn_if_autoloading!
|
|
59
|
+
return unless defined?(::Rails)
|
|
60
|
+
return unless ::Rails.respond_to?(:application)
|
|
61
|
+
return unless ::Rails.env.development?
|
|
62
|
+
|
|
63
|
+
HeapScope.config.log(
|
|
64
|
+
:verbose,
|
|
65
|
+
"Rails development autoloading can distort heap growth. Prefer warmups or cache_classes for experiments."
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
require_relative "middleware"
|