catpm 0.7.0 → 0.8.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9d4f65535550d3a1201fe91d55206136dac09a1fb028e37b01e860a81260abc
4
- data.tar.gz: 5ba5802a2361921a743745b88983399a1f792c7539f40c5a0fc804375c435101
3
+ metadata.gz: 5e79483c6546d21e4b6054d481f3625616eb0c4044f1251d11be907983dadeb7
4
+ data.tar.gz: 555afaca6bfb09499fea0c24359ee7709d8b982759fb6717a577882d64c543cc
5
5
  SHA512:
6
- metadata.gz: b3aee4ecf169a3254ff5193c77e7d1579559d0cfd7915ceeba7d6608ebb397c813dd9e3bf1170ea253ef5c893a1c8a42d009d5d911a8b46eb9a5c194b51fed23
7
- data.tar.gz: fc242376c61f8b0f6943bb534922f6b854f6896e554ab2aa26f72a63a3aea615b243c4d14d3ec1c9738ab2457aeaabfbbbb277ca5a4c8d7b91a8a220e1685e97
6
+ metadata.gz: 2971732b88f54566048545753164728886136408fc0db9d82e20652c08b098973d0889a839fd66cd4a504def1a9c576cf2eab822dac9b381eda0f2e40d908123
7
+ data.tar.gz: c8c7b98c039e7c483e8d4e3e3a9e8c9f06a58e7cd396d303da5bd37c9771d6b463d84d62c9da5c9f87e3e1f688ebefe7bb8b016d7eac477030588513b2e0988c
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  gem build catpm.gemspec
2
- gem push catpm-0.6.6.gem
2
+ gem push catpm-0.7.0.gem
3
3
 
4
4
  # Catpm
5
5
 
@@ -4,25 +4,6 @@ module Catpm
4
4
  class CallTracer
5
5
  MAX_CALL_DEPTH = 64
6
6
 
7
- # Global thread-safe path classification cache — avoids repeated Fingerprint.app_frame? calls
8
- @global_path_cache = {}
9
- @global_path_mutex = Mutex.new
10
-
11
- class << self
12
- def app_frame_cached?(path)
13
- cached = @global_path_cache[path]
14
- return cached unless cached.nil?
15
-
16
- result = Fingerprint.app_frame?(path)
17
- @global_path_mutex.synchronize do
18
- # Cap cache to prevent unbounded growth across process lifetime
19
- @global_path_cache.clear if @global_path_cache.size > 2000
20
- @global_path_cache[path] = result
21
- end
22
- result
23
- end
24
- end
25
-
26
7
  def initialize(request_segments:)
27
8
  @request_segments = request_segments
28
9
  @call_stack = []
@@ -60,7 +41,7 @@ module Catpm
60
41
  @depth += 1
61
42
 
62
43
  path = tp.path
63
- app = self.class.app_frame_cached?(path)
44
+ app = Fingerprint.app_frame?(path)
64
45
 
65
46
  unless app
66
47
  @call_stack.push(:skip)
@@ -97,11 +78,14 @@ module Catpm
97
78
  end
98
79
 
99
80
  def format_detail(defined_class, method_id)
100
- if defined_class.singleton_class?
81
+ name = defined_class.name
82
+ if name
83
+ "#{name}##{method_id}"
84
+ elsif defined_class.singleton_class?
101
85
  owner = defined_class.attached_object
102
86
  "#{owner.name || owner.inspect}.#{method_id}"
103
87
  else
104
- "#{defined_class.name || defined_class.inspect}##{method_id}"
88
+ "#{defined_class.inspect}##{method_id}"
105
89
  end
106
90
  end
107
91
  end
@@ -90,6 +90,20 @@ module Catpm
90
90
  end
91
91
  end
92
92
 
93
+ # Inject call tree segments from sampler (replaces TracePoint-based CallTracer)
94
+ ctrl_idx = segments.index { |s| s[:type] == 'controller' }
95
+ if Catpm.config.instrument_call_tree && req_segments
96
+ tree_segs = req_segments.call_tree_segments
97
+ if tree_segs.any?
98
+ base_idx = segments.size
99
+ tree_segs.each do |seg|
100
+ tree_parent = seg.delete(:_tree_parent)
101
+ seg[:parent_index] = tree_parent ? (tree_parent + base_idx) : (ctrl_idx || 0)
102
+ segments << seg
103
+ end
104
+ end
105
+ end
106
+
93
107
  # Fill untracked controller time with sampler data or synthetic segment
94
108
  ctrl_idx = segments.index { |s| s[:type] == 'controller' }
95
109
  if ctrl_idx
@@ -246,6 +260,20 @@ module Catpm
246
260
  end
247
261
  segments.unshift(root_segment)
248
262
 
263
+ # Inject call tree segments from sampler
264
+ ctrl_idx = segments.index { |s| s[:type] == 'controller' }
265
+ if Catpm.config.instrument_call_tree && req_segments
266
+ tree_segs = req_segments.call_tree_segments
267
+ if tree_segs.any?
268
+ base_idx = segments.size
269
+ tree_segs.each do |seg|
270
+ tree_parent = seg.delete(:_tree_parent)
271
+ seg[:parent_index] = tree_parent ? (tree_parent + base_idx) : (ctrl_idx || 0)
272
+ segments << seg
273
+ end
274
+ end
275
+ end
276
+
249
277
  # Fill untracked controller time with sampler data or synthetic segment
250
278
  ctrl_idx = segments.index { |s| s[:type] == 'controller' }
251
279
  if ctrl_idx
@@ -339,14 +367,18 @@ module Catpm
339
367
  # ActiveSupport controller notification finishes.
340
368
  # Mutates segments in place: removes the wrapper and re-indexes parent references.
341
369
  def collapse_code_wrappers(segments)
370
+ # Pre-build set of parent indices that have a controller child — O(n)
371
+ parents_with_controller = {}
372
+ segments.each do |seg|
373
+ parents_with_controller[seg[:parent_index]] = true if seg[:type] == 'controller' && seg[:parent_index]
374
+ end
375
+
342
376
  # Identify code spans to collapse: near-zero duration wrapping a controller child
343
377
  collapse = {}
344
378
  segments.each_with_index do |seg, i|
345
379
  next unless seg[:type] == 'code'
346
380
  next unless (seg[:duration] || 0).to_f < 1.0
347
-
348
- has_controller_child = segments.any? { |s| s[:parent_index] == i && s[:type] == 'controller' }
349
- next unless has_controller_child
381
+ next unless parents_with_controller[i]
350
382
 
351
383
  collapse[i] = seg[:parent_index]
352
384
  end
@@ -71,7 +71,7 @@ module Catpm
71
71
  @instrument_stack_sampler = false
72
72
  @instrument_middleware_stack = false
73
73
  @max_segments_per_request = 50
74
- @segment_source_threshold = 0.0 # ms — capture caller_locations for all segments (set higher to reduce overhead)
74
+ @segment_source_threshold = 5.0 # ms — capture caller_locations only for segments >= 5ms (set to 0.0 to capture all)
75
75
  @max_sql_length = 200
76
76
  @slow_threshold = 500 # milliseconds
77
77
  @slow_threshold_per_kind = {}
@@ -98,7 +98,7 @@ module Catpm
98
98
  @circuit_breaker_recovery_timeout = 60 # seconds
99
99
  @sqlite_busy_timeout = 5_000 # milliseconds
100
100
  @persistence_batch_size = 100
101
- @backtrace_lines = nil
101
+ @backtrace_lines = 20 # frames per error backtrace (nil = unlimited)
102
102
  @shutdown_timeout = 5 # seconds
103
103
  @events_enabled = false
104
104
  @events_max_samples_per_name = 20
@@ -4,6 +4,9 @@ require 'digest'
4
4
 
5
5
  module Catpm
6
6
  module Fingerprint
7
+ @path_cache = {}
8
+ @path_cache_mutex = Mutex.new
9
+
7
10
  # Generates a stable fingerprint for error grouping.
8
11
  # Includes kind so the same exception in HTTP vs job = different groups.
9
12
  def self.generate(kind:, error_class:, backtrace:)
@@ -30,23 +33,60 @@ module Catpm
30
33
  .join("\n")
31
34
  end
32
35
 
33
- # Checks if a backtrace line belongs to the host application (not a gem or stdlib)
36
+ # Cached wrapper all callers benefit from the shared path cache.
34
37
  def self.app_frame?(line)
35
- return false if line.include?('/gems/')
36
- return false if line.include?('/ruby/')
37
- return false if line.include?('<internal:')
38
- return false if line.include?('/catpm/')
38
+ cached = @path_cache[line]
39
+ return cached unless cached.nil?
40
+
41
+ result = _app_frame?(line)
42
+ @path_cache_mutex.synchronize do
43
+ @path_cache.clear if @path_cache.size > 4000
44
+ @path_cache[line] = result
45
+ end
46
+ result
47
+ end
48
+
49
+ # Cached Rails.root.to_s — computed once, never changes after boot.
50
+ def self.cached_rails_root
51
+ return @cached_rails_root if defined?(@cached_rails_root)
39
52
 
40
- if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
41
- return line.start_with?(Rails.root.to_s) if line.start_with?('/')
53
+ @cached_rails_root = if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
54
+ Rails.root.to_s.freeze
42
55
  end
56
+ end
43
57
 
44
- line.start_with?('app/') || line.include?('/app/')
58
+ # Cached "#{Rails.root}/" for path stripping.
59
+ def self.cached_rails_root_slash
60
+ return @cached_rails_root_slash if defined?(@cached_rails_root_slash)
61
+
62
+ root = cached_rails_root
63
+ @cached_rails_root_slash = root ? "#{root}/".freeze : nil
64
+ end
65
+
66
+ def self.reset_caches!
67
+ @path_cache_mutex.synchronize { @path_cache.clear }
68
+ remove_instance_variable(:@cached_rails_root) if defined?(@cached_rails_root)
69
+ remove_instance_variable(:@cached_rails_root_slash) if defined?(@cached_rails_root_slash)
45
70
  end
46
71
 
47
72
  # Strips line numbers: "app/models/user.rb:42:in `validate'" → "app/models/user.rb:in `validate'"
48
73
  def self.strip_line_number(line)
49
74
  line.sub(/:\d+:in /, ':in ')
50
75
  end
76
+
77
+ # The actual classification logic (uncached).
78
+ def self._app_frame?(line)
79
+ return false if line.include?('/gems/')
80
+ return false if line.include?('/ruby/')
81
+ return false if line.include?('<internal:')
82
+ return false if line.include?('/catpm/')
83
+
84
+ if (root = cached_rails_root)
85
+ return line.start_with?(root) if line.start_with?('/')
86
+ end
87
+
88
+ line.start_with?('app/') || line.include?('/app/')
89
+ end
90
+ private_class_method :_app_frame?
51
91
  end
52
92
  end
@@ -14,19 +14,15 @@ module Catpm
14
14
  env['catpm.request_start'] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
15
 
16
16
  if Catpm.config.instrument_segments
17
+ use_sampler = Catpm.config.instrument_stack_sampler || Catpm.config.instrument_call_tree
17
18
  req_segments = RequestSegments.new(
18
19
  max_segments: Catpm.config.max_segments_per_request,
19
20
  request_start: env['catpm.request_start'],
20
- stack_sample: Catpm.config.instrument_stack_sampler
21
+ stack_sample: use_sampler,
22
+ call_tree: Catpm.config.instrument_call_tree
21
23
  )
22
24
  env['catpm.segments'] = req_segments
23
25
  Thread.current[:catpm_request_segments] = req_segments
24
-
25
- if Catpm.config.instrument_call_tree
26
- call_tracer = CallTracer.new(request_segments: req_segments)
27
- call_tracer.start
28
- env['catpm.call_tracer'] = call_tracer
29
- end
30
26
  end
31
27
 
32
28
  @app.call(env)
@@ -35,7 +31,6 @@ module Catpm
35
31
  raise
36
32
  ensure
37
33
  if Catpm.config.instrument_segments
38
- env['catpm.call_tracer']&.stop
39
34
  req_segments&.stop_sampler
40
35
  Thread.current[:catpm_request_segments] = nil
41
36
  end
@@ -2,9 +2,12 @@
2
2
 
3
3
  module Catpm
4
4
  class RequestSegments
5
+ # Pre-computed symbol pairs — each type computed once per process lifetime.
6
+ SUMMARY_KEYS = Hash.new { |h, k| h[k] = [:"#{k}_count", :"#{k}_duration"] }
7
+
5
8
  attr_reader :segments, :summary, :request_start
6
9
 
7
- def initialize(max_segments:, request_start: nil, stack_sample: false)
10
+ def initialize(max_segments:, request_start: nil, stack_sample: false, call_tree: false)
8
11
  @max_segments = max_segments
9
12
  @request_start = request_start || Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
13
  @segments = []
@@ -12,17 +15,19 @@ module Catpm
12
15
  @summary = Hash.new(0)
13
16
  @span_stack = []
14
17
  @tracked_ranges = []
18
+ @call_tree = call_tree
15
19
 
16
20
  if stack_sample
17
- @sampler = StackSampler.new(target_thread: Thread.current, request_start: @request_start)
21
+ @sampler = StackSampler.new(target_thread: Thread.current, request_start: @request_start, call_tree: call_tree)
18
22
  @sampler.start
19
23
  end
20
24
  end
21
25
 
22
26
  def add(type:, duration:, detail:, source: nil, started_at: nil)
23
27
  type_key = type.to_sym
24
- @summary[:"#{type_key}_count"] += 1
25
- @summary[:"#{type_key}_duration"] += duration
28
+ count_key, dur_key = SUMMARY_KEYS[type_key]
29
+ @summary[count_key] += 1
30
+ @summary[dur_key] += duration
26
31
 
27
32
  offset = started_at ? ((started_at - @request_start) * 1000.0).round(2) : nil
28
33
 
@@ -79,8 +84,9 @@ module Catpm
79
84
  segment[:duration] = duration.round(2)
80
85
 
81
86
  type_key = segment[:type].to_sym
82
- @summary[:"#{type_key}_count"] += 1
83
- @summary[:"#{type_key}_duration"] += duration
87
+ count_key, dur_key = SUMMARY_KEYS[type_key]
88
+ @summary[count_key] += 1
89
+ @summary[dur_key] += duration
84
90
  end
85
91
 
86
92
  def stop_sampler
@@ -88,9 +94,15 @@ module Catpm
88
94
  end
89
95
 
90
96
  def sampler_segments
97
+ return [] if @call_tree # call tree mode produces segments via call_tree_segments
91
98
  @sampler&.to_segments(tracked_ranges: @tracked_ranges) || []
92
99
  end
93
100
 
101
+ def call_tree_segments
102
+ return [] unless @sampler && @call_tree
103
+ @sampler.to_call_tree(tracked_ranges: @tracked_ranges)
104
+ end
105
+
94
106
  def overflowed?
95
107
  @overflow
96
108
  end
@@ -33,8 +33,9 @@ module Catpm
33
33
  return unless req_segments
34
34
 
35
35
  identifier = payload[:identifier].to_s
36
- if defined?(Rails.root) && identifier.start_with?(Rails.root.to_s)
37
- identifier = identifier.sub("#{Rails.root}/", '')
36
+ root_slash = Fingerprint.cached_rails_root_slash
37
+ if root_slash
38
+ identifier = identifier.delete_prefix(root_slash)
38
39
  end
39
40
 
40
41
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -4,6 +4,7 @@ module Catpm
4
4
  class StackSampler
5
5
  MS_PER_SECOND = 1000.0
6
6
  MIN_SEGMENT_DURATION_MS = 1.0
7
+ CALL_TREE_SAMPLE_INTERVAL = 0.001 # 1ms — higher resolution for call tree reconstruction
7
8
  SAMPLING_THREAD_PRIORITY = -1
8
9
 
9
10
  # Single global thread that samples all active requests.
@@ -31,7 +32,12 @@ module Catpm
31
32
  def start_thread
32
33
  @thread = Thread.new do
33
34
  loop do
34
- sleep(Catpm.config.stack_sample_interval)
35
+ interval = if Catpm.config.instrument_call_tree
36
+ [CALL_TREE_SAMPLE_INTERVAL, Catpm.config.stack_sample_interval].min
37
+ else
38
+ Catpm.config.stack_sample_interval
39
+ end
40
+ sleep(interval)
35
41
  sample_all
36
42
  end
37
43
  end
@@ -51,10 +57,11 @@ module Catpm
51
57
  attr_reader :loop
52
58
  end
53
59
 
54
- def initialize(target_thread:, request_start:)
60
+ def initialize(target_thread:, request_start:, call_tree: false)
55
61
  @target = target_thread
56
62
  @request_start = request_start
57
63
  @samples = []
64
+ @call_tree = call_tree
58
65
  end
59
66
 
60
67
  def start
@@ -159,8 +166,88 @@ module Catpm
159
166
  end
160
167
  end
161
168
 
169
+ # Build a call tree from samples — replacement for TracePoint-based CallTracer.
170
+ # Returns flat array of segments with :_tree_parent (relative index or nil for top-level).
171
+ def to_call_tree(tracked_ranges: [])
172
+ return [] if @samples.size < 2
173
+
174
+ # Build a tree of app-frame call chains across all samples
175
+ tree = {} # key → {frame:, children: {}, count:, first_time:, last_time:}
176
+
177
+ @samples.each do |time, locs|
178
+ chain = extract_app_chain(locs)
179
+ next if chain.empty?
180
+
181
+ current_level = tree
182
+ chain.each do |frame|
183
+ key = frame_key(frame)
184
+ unless current_level.key?(key)
185
+ current_level[key] = {
186
+ frame: frame, children: {}, count: 0,
187
+ first_time: time, last_time: time
188
+ }
189
+ end
190
+ node = current_level[key]
191
+ node[:count] += 1
192
+ node[:last_time] = time
193
+ current_level = node[:children]
194
+ end
195
+ end
196
+
197
+ # Flatten tree into segments with relative parent references
198
+ segments = []
199
+ flatten_call_tree(tree, segments, nil)
200
+ segments
201
+ end
202
+
162
203
  private
163
204
 
205
+ # Extract all app frames from a backtrace, ordered caller-first (outer → inner).
206
+ def extract_app_chain(locations)
207
+ frames = []
208
+ locations.each do |loc|
209
+ path = loc.path.to_s
210
+ next if path.start_with?('<internal:')
211
+ next if path.include?('/catpm/')
212
+ next if path.include?('/ruby/') && !path.include?('/gems/')
213
+
214
+ frames << loc if Fingerprint.app_frame?(path)
215
+ end
216
+ frames.reverse
217
+ end
218
+
219
+ def flatten_call_tree(children_hash, segments, parent_idx)
220
+ children_hash.each_value do |node|
221
+ duration = call_tree_node_duration(node)
222
+ next if duration < MIN_SEGMENT_DURATION_MS
223
+
224
+ frame = node[:frame]
225
+ seg = {
226
+ type: 'code',
227
+ detail: build_app_detail(frame),
228
+ duration: duration.round(2),
229
+ offset: ((node[:first_time] - @request_start) * MS_PER_SECOND).round(2),
230
+ source: "#{frame.path}:#{frame.lineno}",
231
+ _tree_parent: parent_idx
232
+ }
233
+
234
+ idx = segments.size
235
+ segments << seg
236
+
237
+ flatten_call_tree(node[:children], segments, idx)
238
+ end
239
+ end
240
+
241
+ def call_tree_node_duration(node)
242
+ interval = Catpm.config.instrument_call_tree ?
243
+ [CALL_TREE_SAMPLE_INTERVAL, Catpm.config.stack_sample_interval].min :
244
+ Catpm.config.stack_sample_interval
245
+ [
246
+ (node[:last_time] - node[:first_time]) * MS_PER_SECOND,
247
+ node[:count] * interval * MS_PER_SECOND
248
+ ].max
249
+ end
250
+
164
251
  # Walk the stack: find the leaf (deepest interesting frame)
165
252
  # and the app_frame (nearest app code above the leaf)
166
253
  def extract_frame_pair(locations)
data/lib/catpm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Catpm
4
- VERSION = '0.7.0'
4
+ VERSION = '0.8.1'
5
5
  end
data/lib/catpm.rb CHANGED
@@ -39,6 +39,7 @@ module Catpm
39
39
  @config = Configuration.new
40
40
  @buffer = nil
41
41
  @flusher = nil
42
+ Fingerprint.reset_caches!
42
43
  end
43
44
 
44
45
  def enabled?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: catpm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''