Module: HeapScope::Detectors
- Defined in:
- lib/heapscope/detectors.rb
Overview
Pattern detectors for cache-vs-leak, unbounded collections, and thread-locals. Findings remain evidence-based: facts, derived behavior, hypothesis, suspected cause.
Class Method Summary collapse
- .analyze_thread_locals(runtime: Runtime.current) ⇒ Object
- .classify_collection_growth(name:, sizes:, owner: nil) ⇒ Object
- .collection_findings(class_series) ⇒ Object
- .format_bytes(bytes) ⇒ Object
- .suspicious_key?(key) ⇒ Boolean
Class Method Details
.analyze_thread_locals(runtime: Runtime.current) ⇒ Object
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
# File 'lib/heapscope/detectors.rb', line 9 def analyze_thread_locals(runtime: Runtime.current) findings = [] inventory = Graph.new(runtime: runtime).thread_local_inventory inventory.each do |thread| thread[:keys].each do |key, | bytes = [:shallow_bytes].to_i next if bytes < 64_000 && !suspicious_key?(key) findings << Finding.new( code: "HS003", severity: bytes >= 1_000_000 ? :high : :medium, subject: "#{thread[:name]}#{key}", facts: [ "Observed fact: thread #{thread[:name]} holds key #{key} of class #{[:class]}." ], derived: [ "Derived: shallow size estimate #{format_bytes(bytes)}." ], hypothesis: "Thread-local state may retain request/job context across reuse of this thread.", suspected_cause: "Thread-local #{key}", suggestions: [ "Clear the key in an ensure block after each request/job", "Avoid storing full request graphs on long-lived Puma/Sidekiq threads", "Confirm whether this cache is intentional and bounded" ], evidence: [{ thread: thread[:name], key: key, meta: }] ) end end { inventory: inventory, findings: findings } end |
.classify_collection_growth(name:, sizes:, owner: nil) ⇒ Object
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/heapscope/detectors.rb', line 41 def classify_collection_growth(name:, sizes:, owner: nil) return { kind: :insufficient_data } if sizes.size < 3 trend = Growth.analyze(sizes) max_size = sizes.max min_size = sizes.min last = sizes.last(3) plateau = last.max - last.min <= [last.max * 0.05, 2].max shrinking = sizes.each_cons(2).any? { |a, b| b < a } kind = if plateau && max_size > 0 && sizes.first < max_size :likely_cache elsif %i[monotonic_growth linear_growth exponential_like].include?(trend[:pattern]) && !shrinking :unbounded_candidate elsif trend[:pattern] == :sawtooth :recovering_collection else :stable_or_noisy end { name: name, owner: owner, kind: kind, trend: trend, sizes: sizes, max: max_size, min: min_size } end |
.collection_findings(class_series) ⇒ Object
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'lib/heapscope/detectors.rb', line 73 def collection_findings(class_series) findings = [] class_series.each_value do |entry| next unless %w[Array Hash Set].include?(entry[:name]) || entry[:name].end_with?("Registry", "Cache", "Store") classification = classify_collection_growth(name: entry[:name], sizes: entry[:series]) case classification[:kind] when :unbounded_candidate findings << Finding.new( code: "HS004", severity: entry[:delta] > 200 ? :high : :medium, subject: entry[:name], facts: ["Observed fact: #{entry[:name]} sizes #{entry[:series].inspect}."], derived: [ "Derived: pattern=#{classification[:trend][:pattern]}, no shrinkage observed across samples." ], hypothesis: "This structure is an unbounded collection candidate.", suggestions: [ "Identify the owner/root retaining the collection", "Cap growth or add eviction", "Verify appends are not accidental per-request registrations" ], evidence: [classification] ) when :likely_cache findings << Finding.new( code: "HS001", severity: :low, subject: entry[:name], title: "Likely cache plateau", facts: ["Observed fact: #{entry[:name]} reached a plateau near #{classification[:max]}."], derived: ["Derived: growth appears bounded (cache-like)."], hypothesis: "Intentional cache behavior is more likely than a leak.", suggestions: ["Confirm max size / LRU / TTL expectations"], evidence: [classification] ) end end findings end |
.format_bytes(bytes) ⇒ Object
118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/heapscope/detectors.rb', line 118 def format_bytes(bytes) return "n/a" if bytes.nil? abs = bytes.abs.to_f if abs >= 1024 * 1024 format("%.2f MB", abs / (1024 * 1024)) elsif abs >= 1024 format("%.1f KB", abs / 1024) else "#{bytes} B" end end |
.suspicious_key?(key) ⇒ Boolean
114 115 116 |
# File 'lib/heapscope/detectors.rb', line 114 def suspicious_key?(key) key.to_s.match?(/context|request|session|payload|current|job|controller|env/i) end |