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

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, meta|
      bytes = meta[: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 #{meta[: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: 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

Returns:

  • (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