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