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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/app/controllers/catpm/application_controller.rb +15 -0
- data/app/controllers/catpm/system_controller.rb +4 -0
- data/app/helpers/catpm/application_helper.rb +142 -0
- data/app/views/catpm/system/index.html.erb +76 -480
- data/app/views/catpm/system/pipeline.html.erb +344 -0
- data/app/views/layouts/catpm/application.html.erb +40 -0
- data/app/views/layouts/catpm/pipeline.html.erb +79 -0
- data/config/routes.rb +1 -0
- data/lib/catpm/buffer.rb +22 -2
- data/lib/catpm/call_tracer.rb +18 -11
- data/lib/catpm/collector.rb +33 -3
- data/lib/catpm/configuration.rb +2 -2
- data/lib/catpm/fingerprint.rb +48 -8
- data/lib/catpm/flusher.rb +14 -1
- data/lib/catpm/middleware.rb +3 -8
- data/lib/catpm/request_segments.rb +24 -7
- data/lib/catpm/segment_subscribers.rb +3 -2
- data/lib/catpm/stack_sampler.rb +89 -2
- data/lib/catpm/version.rb +1 -1
- data/lib/catpm.rb +1 -0
- metadata +3 -1
data/lib/catpm/fingerprint.rb
CHANGED
|
@@ -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
|
-
#
|
|
36
|
+
# Cached wrapper — all callers benefit from the shared path cache.
|
|
34
37
|
def self.app_frame?(line)
|
|
35
|
-
|
|
36
|
-
return
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/catpm/middleware.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
25
|
-
@summary[
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
@summary[
|
|
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
|
-
|
|
37
|
-
|
|
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)
|
data/lib/catpm/stack_sampler.rb
CHANGED
|
@@ -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
|
-
|
|
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
data/lib/catpm.rb
CHANGED
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.
|
|
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
|