catpm 0.6.6 → 0.8.0

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.
@@ -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
data/lib/catpm/flusher.rb CHANGED
@@ -16,6 +16,16 @@ module Catpm
16
16
  @thread = nil
17
17
  @pid = nil
18
18
  @mutex = Mutex.new
19
+ @sleep_mutex = Mutex.new
20
+ @flush_signal = ConditionVariable.new
21
+
22
+ # When buffer reaches capacity, wake flusher immediately
23
+ @buffer.on_flush_needed { signal_flush }
24
+ end
25
+
26
+ # Wake the flusher thread for an immediate flush cycle.
27
+ def signal_flush
28
+ @sleep_mutex.synchronize { @flush_signal.signal }
19
29
  end
20
30
 
21
31
  def start
@@ -32,7 +42,9 @@ module Catpm
32
42
  @pid = Process.pid
33
43
  @thread = Thread.new do
34
44
  while @running
35
- sleep(effective_interval)
45
+ @sleep_mutex.synchronize do
46
+ @flush_signal.wait(@sleep_mutex, effective_interval)
47
+ end
36
48
  flush_cycle if @running
37
49
  end
38
50
  rescue => e
@@ -61,6 +73,7 @@ module Catpm
61
73
  @thread = nil
62
74
  end
63
75
 
76
+ signal_flush # Wake flusher thread for clean exit
64
77
  thread&.join(timeout)
65
78
  flush_cycle # Final flush
66
79
  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
 
@@ -65,7 +70,12 @@ module Catpm
65
70
  def pop_span(index)
66
71
  return unless index
67
72
 
68
- @span_stack.delete(index)
73
+ # Pop from stack — typically it's the last element (LIFO)
74
+ if @span_stack.last == index
75
+ @span_stack.pop
76
+ else
77
+ @span_stack.delete(index)
78
+ end
69
79
  segment = @segments[index]
70
80
  return unless segment
71
81
 
@@ -74,8 +84,9 @@ module Catpm
74
84
  segment[:duration] = duration.round(2)
75
85
 
76
86
  type_key = segment[:type].to_sym
77
- @summary[:"#{type_key}_count"] += 1
78
- @summary[:"#{type_key}_duration"] += duration
87
+ count_key, dur_key = SUMMARY_KEYS[type_key]
88
+ @summary[count_key] += 1
89
+ @summary[dur_key] += duration
79
90
  end
80
91
 
81
92
  def stop_sampler
@@ -83,9 +94,15 @@ module Catpm
83
94
  end
84
95
 
85
96
  def sampler_segments
97
+ return [] if @call_tree # call tree mode produces segments via call_tree_segments
86
98
  @sampler&.to_segments(tracked_ranges: @tracked_ranges) || []
87
99
  end
88
100
 
101
+ def call_tree_segments
102
+ return [] unless @sampler && @call_tree
103
+ @sampler.to_call_tree(tracked_ranges: @tracked_ranges)
104
+ end
105
+
89
106
  def overflowed?
90
107
  @overflow
91
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.6.6'
4
+ VERSION = '0.8.0'
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.6.6
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
@@ -68,7 +68,9 @@ files:
68
68
  - app/views/catpm/shared/_segments_waterfall.html.erb
69
69
  - app/views/catpm/status/index.html.erb
70
70
  - app/views/catpm/system/index.html.erb
71
+ - app/views/catpm/system/pipeline.html.erb
71
72
  - app/views/layouts/catpm/application.html.erb
73
+ - app/views/layouts/catpm/pipeline.html.erb
72
74
  - config/routes.rb
73
75
  - db/migrate/20250601000001_create_catpm_tables.rb
74
76
  - lib/catpm.rb