Module: DeadBro::MemoryPhaseTracker

Defined in:
lib/dead_bro/memory_phase_tracker.rb

Overview

Attributes a request’s object allocations to the phase that produced them —e.g. “92% of this request’s allocations happened during Elasticsearch”.

This is the always-on, low-overhead companion to GcTracker: GcTracker tells you *how many* objects a request allocated and whether they were retained; MemoryPhaseTracker tells you where they were allocated, so a 400MB request can be localized to ES deserialization vs view rendering vs controller code without running a full allocation profiler.

Attribution is exclusive: a sql.active_record event nested inside a view render is charged only to :sql, not to both. A thread-local stack records the allocation counter when each phase becomes active; entering a child phase flushes the parent’s accumulated delta and pauses it, leaving the child resumes the parent. Whatever isn’t captured by an instrumented phase stays “unattributed” (controller/application code) and is derivable on the backend as gc_pressure.allocated_objects minus the sum of these buckets.

Defined Under Namespace

Classes: Listener

Constant Summary collapse

THREAD_KEY =
:dead_bro_memory_phases
EVENT_PHASES =

ActiveSupport event name => phase bucket. Each maps to a coarse phase so the breakdown stays readable (all view render events collapse to :view).

{
  "sql.active_record" => :sql,
  "render_template.action_view" => :view,
  "render_partial.action_view" => :view,
  "render_collection.action_view" => :view,
  "render_layout.action_view" => :view,
  "request.elasticsearch" => :elasticsearch,
  "request.elastic_transport" => :elasticsearch
}.freeze

Class Method Summary collapse

Class Method Details

.allocated_objectsObject

Single-key GC.stat returns just the Integer (no hash allocation), so this is cheap enough to call on every instrumented event boundary.



119
120
121
122
123
# File 'lib/dead_bro/memory_phase_tracker.rb', line 119

def self.allocated_objects
  GC.stat(:total_allocated_objects)
rescue StandardError
  0
end

.allocation_counter_available?Boolean

Returns:

  • (Boolean)


125
126
127
128
129
# File 'lib/dead_bro/memory_phase_tracker.rb', line 125

def self.allocation_counter_available?
  defined?(GC) && GC.respond_to?(:stat) && !GC.stat(:total_allocated_objects).nil?
rescue StandardError
  false
end

.enter(phase) ⇒ Object



83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/dead_bro/memory_phase_tracker.rb', line 83

def self.enter(phase)
  state = Thread.current[THREAD_KEY]
  return unless state.is_a?(Hash)

  now = allocated_objects
  stack = state[:stack]
  if (parent = stack.last)
    # Pause the parent: bank what it allocated up to this point.
    state[:buckets][parent[:phase]] += now - parent[:checkpoint]
  end
  stack << {phase: phase, checkpoint: now}
rescue StandardError
  # Best-effort only.
end

.leave(phase) ⇒ Object



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/dead_bro/memory_phase_tracker.rb', line 98

def self.leave(phase)
  state = Thread.current[THREAD_KEY]
  return unless state.is_a?(Hash)

  stack = state[:stack]
  frame = stack.pop
  return unless frame

  now = allocated_objects
  state[:buckets][frame[:phase]] += now - frame[:checkpoint]
  # Resume the parent from this point so the child's allocations aren't
  # double-counted into it.
  if (parent = stack.last)
    parent[:checkpoint] = now
  end
rescue StandardError
  # Best-effort only.
end

.start_request_trackingObject



65
66
67
# File 'lib/dead_bro/memory_phase_tracker.rb', line 65

def self.start_request_tracking
  Thread.current[THREAD_KEY] = {buckets: Hash.new(0), stack: []}
end

.stop_request_trackingObject

Returns { sql: n, view: n, elasticsearch: n } of objects allocated exclusively within each phase, omitting phases that allocated nothing.



71
72
73
74
75
76
77
78
79
80
81
# File 'lib/dead_bro/memory_phase_tracker.rb', line 71

def self.stop_request_tracking
  state = Thread.current[THREAD_KEY]
  return {} unless state.is_a?(Hash)

  buckets = state[:buckets]
  buckets.each_with_object({}) do |(phase, count), result|
    result[phase] = count if count.positive?
  end
ensure
  Thread.current[THREAD_KEY] = nil
end

.subscribe!Object



53
54
55
56
57
58
59
60
61
62
63
# File 'lib/dead_bro/memory_phase_tracker.rb', line 53

def self.subscribe!
  return if @subscribed
  @subscribed = true
  return unless allocation_counter_available?

  EVENT_PHASES.each do |event_name, phase|
    ActiveSupport::Notifications.subscribe(event_name, Listener.new(phase))
  end
rescue StandardError
  # Never raise from instrumentation install.
end