Module: DeadBro::AllocationSourceSampler

Defined in:
lib/dead_bro/allocation_source_sampler.rb

Overview

Deep, opt-in memory diagnostics that answer “what code allocated this?”. Only active when allocation tracking is on (see Configuration #allocation_tracking_active?), because turning on object allocation tracing adds ~2-5ms of per-request overhead.

Two complementary breakdowns, both produced from a single ObjectSpace walk after the request finishes:

* by_type_bytes — total *retained bytes* per Ruby class. This catches the
  "death by a million small strings" pattern (e.g. a deserialized
  Elasticsearch response) that MemoryTrackingSubscriber's >1MB
  single-object scan structurally misses, because it sums bytes per type
  instead of flagging individually-large objects.

* by_source — top allocation sites (file:line) by retained bytes. This is
  the gold-standard "this line allocated 300MB" attribution, available
  because trace_object_allocations was running for the request.

Constant Summary collapse

SAMPLE_RATE =

Fraction of live objects inspected during the post-request walk. Reported back as sample_rate so the consumer can extrapolate to the full heap.

0.10
MAX_RESULTS =
20
LARGE_TYPE_MIN_BYTES =

Only report a type if its sampled retained bytes clear this floor (keeps the breakdown to things that actually matter).

100_000
DEFAULT_MIN_GROWTH_MB =

Skip the walk entirely below this growth — no point profiling a request that didn’t move memory. Gates the expensive path even when the flag is on.

50

Class Method Summary collapse

Class Method Details

.analyze(memory_growth_mb: nil, min_growth_mb: DEFAULT_MIN_GROWTH_MB) ⇒ Object

Walk live objects once and build the two breakdowns. Returns {} when tracing is unavailable, or … when growth was below threshold.



72
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
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/dead_bro/allocation_source_sampler.rb', line 72

def self.analyze(memory_growth_mb: nil, min_growth_mb: DEFAULT_MIN_GROWTH_MB)
  return {} unless available?
  if memory_growth_mb && memory_growth_mb < min_growth_mb
    return {skipped: "memory_growth_below_threshold", min_growth_mb: min_growth_mb}
  end

  by_type = Hash.new { |h, k| h[k] = {count: 0, bytes: 0} }
  by_source = Hash.new { |h, k| h[k] = {count: 0, bytes: 0} }

  ObjectSpace.each_object do |obj|
    next unless rand < SAMPLE_RATE

    size = begin
      ObjectSpace.memsize_of(obj)
    rescue StandardError
      0
    end
    next unless size && size > 0

    klass = begin
      obj.class.name
    rescue StandardError
      nil
    end || "Unknown"
    type_bucket = by_type[klass]
    type_bucket[:count] += 1
    type_bucket[:bytes] += size

    file = begin
      ObjectSpace.allocation_sourcefile(obj)
    rescue StandardError
      nil
    end
    next unless file

    line = begin
      ObjectSpace.allocation_sourceline(obj)
    rescue StandardError
      nil
    end
    source_bucket = by_source["#{file}:#{line}"]
    source_bucket[:count] += 1
    source_bucket[:bytes] += size
  end

  {
    sample_rate: SAMPLE_RATE,
    by_type_bytes: top_by_bytes(by_type, LARGE_TYPE_MIN_BYTES),
    by_source: top_by_bytes(by_source, 0)
  }
rescue StandardError
  {}
end

.available?Boolean

Returns:

  • (Boolean)


43
44
45
46
47
48
49
# File 'lib/dead_bro/allocation_source_sampler.rb', line 43

def self.available?
  defined?(ObjectSpace) &&
    ObjectSpace.respond_to?(:trace_object_allocations_start) &&
    ObjectSpace.respond_to?(:memsize_of)
rescue StandardError
  false
end

.startObject

Begin recording allocation source locations. Must be called before the request allocates the objects we want to attribute.



53
54
55
56
57
58
# File 'lib/dead_bro/allocation_source_sampler.rb', line 53

def self.start
  return unless available?
  ObjectSpace.trace_object_allocations_start
rescue StandardError
  # Best-effort only.
end

.stopObject

Stop and discard recorded allocation data. Call this AFTER analyze, since clearing wipes the source locations analyze reads.



62
63
64
65
66
67
68
# File 'lib/dead_bro/allocation_source_sampler.rb', line 62

def self.stop
  return unless available?
  ObjectSpace.trace_object_allocations_stop
  ObjectSpace.trace_object_allocations_clear
rescue StandardError
  # Best-effort only.
end

.top_by_bytes(hash, min_bytes) ⇒ Object



126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/dead_bro/allocation_source_sampler.rb', line 126

def self.top_by_bytes(hash, min_bytes)
  hash.select { |_, v| v[:bytes] >= min_bytes }
    .sort_by { |_, v| -v[:bytes] }
    .first(MAX_RESULTS)
    .map do |key, v|
      {
        name: key,
        count: v[:count],
        bytes: v[:bytes],
        mb: (v[:bytes] / 1_000_000.0).round(2)
      }
    end
end