catpm 0.10.3 → 0.11.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/helpers/catpm/application_helper.rb +5 -3
- data/app/views/catpm/errors/show.html.erb +1 -1
- data/app/views/catpm/samples/show.html.erb +17 -3
- data/app/views/catpm/shared/_segments_timeline.html.erb +155 -0
- data/app/views/catpm/shared/_segments_waterfall.html.erb +15 -14
- data/app/views/catpm/status/index.html.erb +4 -4
- data/app/views/catpm/system/pipeline.html.erb +1 -1
- data/app/views/layouts/catpm/application.html.erb +147 -6
- data/db/migrate/20250601000001_create_catpm_tables.rb +34 -32
- data/lib/catpm/call_tracer.rb +2 -1
- data/lib/catpm/collector.rb +434 -46
- data/lib/catpm/configuration.rb +6 -2
- data/lib/catpm/engine.rb +9 -0
- data/lib/catpm/fingerprint.rb +6 -0
- data/lib/catpm/middleware.rb +3 -4
- data/lib/catpm/request_segments.rb +18 -1
- data/lib/catpm/segment_subscribers.rb +1 -1
- data/lib/catpm/sidekiq_server_middleware.rb +90 -0
- data/lib/catpm/stack_sampler.rb +3 -3
- data/lib/catpm/version.rb +1 -1
- data/lib/catpm.rb +5 -0
- data/lib/generators/catpm/templates/initializer.rb.tt +2 -2
- metadata +3 -1
data/lib/catpm/collector.rb
CHANGED
|
@@ -11,6 +11,19 @@ module Catpm
|
|
|
11
11
|
@active_instrumented_count = 0
|
|
12
12
|
|
|
13
13
|
class << self
|
|
14
|
+
# Build minimal context for unhandled exceptions caught by middleware
|
|
15
|
+
# (before Rails has a chance to process the request).
|
|
16
|
+
def build_exception_context(env)
|
|
17
|
+
ctx = {
|
|
18
|
+
method: env['REQUEST_METHOD'],
|
|
19
|
+
path: env['PATH_INFO']
|
|
20
|
+
}
|
|
21
|
+
ctx[:remote_ip] = extract_remote_ip(env)
|
|
22
|
+
headers = extract_request_headers(env)
|
|
23
|
+
ctx[:request_headers] = headers if headers.any?
|
|
24
|
+
ctx
|
|
25
|
+
end
|
|
26
|
+
|
|
14
27
|
def process_action_controller(event)
|
|
15
28
|
return unless Catpm.enabled?
|
|
16
29
|
|
|
@@ -108,6 +121,12 @@ module Catpm
|
|
|
108
121
|
# Add to summary so Time Breakdown shows middleware
|
|
109
122
|
segment_data[:segment_summary][:middleware_count] = 1
|
|
110
123
|
segment_data[:segment_summary][:middleware_duration] = ctrl_offset.round(2)
|
|
124
|
+
|
|
125
|
+
# Re-parent pre-controller root-children under Middleware Stack.
|
|
126
|
+
# Without MiddlewareProbe, SQL/cache during middleware processing
|
|
127
|
+
# have no span parent and end up orphaned under root.
|
|
128
|
+
ctrl_idx = segments.index { |s| s[:type] == 'controller' }
|
|
129
|
+
reparent_pre_controller_segments(segments, ctrl_idx)
|
|
111
130
|
end
|
|
112
131
|
end
|
|
113
132
|
|
|
@@ -116,12 +135,7 @@ module Catpm
|
|
|
116
135
|
if Catpm.config.instrument_call_tree && req_segments
|
|
117
136
|
tree_segs = req_segments.call_tree_segments
|
|
118
137
|
if tree_segs.any?
|
|
119
|
-
|
|
120
|
-
tree_segs.each do |seg|
|
|
121
|
-
tree_parent = seg.delete(:_tree_parent)
|
|
122
|
-
seg[:parent_index] = tree_parent ? (tree_parent + base_idx) : (ctrl_idx || 0)
|
|
123
|
-
segments << seg
|
|
124
|
-
end
|
|
138
|
+
inject_call_tree_segments(segments, tree_segs, ctrl_idx)
|
|
125
139
|
reparent_under_call_tree(segments, ctrl_idx)
|
|
126
140
|
end
|
|
127
141
|
end
|
|
@@ -143,6 +157,11 @@ module Catpm
|
|
|
143
157
|
end
|
|
144
158
|
end
|
|
145
159
|
|
|
160
|
+
# Final pass: move any remaining pre-controller root-children under Middleware Stack.
|
|
161
|
+
# This catches call tree and gap-fill segments added after the initial re-parenting.
|
|
162
|
+
ctrl_idx = segments.index { |s| s[:type] == 'controller' }
|
|
163
|
+
reparent_pre_controller_segments(segments, ctrl_idx) if ctrl_idx
|
|
164
|
+
|
|
146
165
|
context[:segments] = segments
|
|
147
166
|
context[:segment_summary] = segment_data[:segment_summary]
|
|
148
167
|
context[:segments_capped] = segment_data[:segments_capped]
|
|
@@ -194,6 +213,9 @@ module Catpm
|
|
|
194
213
|
def process_active_job(event)
|
|
195
214
|
return unless Catpm.enabled?
|
|
196
215
|
|
|
216
|
+
# No middleware in job workers — restart flusher after fork
|
|
217
|
+
Catpm.flusher&.ensure_running!
|
|
218
|
+
|
|
197
219
|
payload = event.payload
|
|
198
220
|
job = payload[:job]
|
|
199
221
|
target = job.class.name
|
|
@@ -241,39 +263,23 @@ module Catpm
|
|
|
241
263
|
segments = segment_data[:segments]
|
|
242
264
|
collapse_code_wrappers(segments)
|
|
243
265
|
|
|
244
|
-
#
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
duration
|
|
249
|
-
offset: 0.0
|
|
250
|
-
}
|
|
251
|
-
segments.each do |seg|
|
|
252
|
-
if seg.key?(:parent_index)
|
|
253
|
-
seg[:parent_index] += 1
|
|
254
|
-
else
|
|
255
|
-
seg[:parent_index] = 0
|
|
256
|
-
end
|
|
266
|
+
# Job span is already the root — no synthetic wrapper needed.
|
|
267
|
+
# Clamp its duration to the event duration to avoid >100% in breakdown.
|
|
268
|
+
ctrl_idx = segments.index { |s| s[:type] == 'job' }
|
|
269
|
+
if ctrl_idx
|
|
270
|
+
segments[ctrl_idx][:duration] = duration.round(2)
|
|
257
271
|
end
|
|
258
|
-
segments.unshift(root_segment)
|
|
259
272
|
|
|
260
|
-
# Inject call tree segments from sampler
|
|
261
|
-
ctrl_idx = segments.index { |s| s[:type] == 'controller' }
|
|
262
273
|
if Catpm.config.instrument_call_tree && req_segments
|
|
263
274
|
tree_segs = req_segments.call_tree_segments
|
|
264
275
|
if tree_segs.any?
|
|
265
|
-
|
|
266
|
-
tree_segs.each do |seg|
|
|
267
|
-
tree_parent = seg.delete(:_tree_parent)
|
|
268
|
-
seg[:parent_index] = tree_parent ? (tree_parent + base_idx) : (ctrl_idx || 0)
|
|
269
|
-
segments << seg
|
|
270
|
-
end
|
|
276
|
+
inject_call_tree_segments(segments, tree_segs, ctrl_idx)
|
|
271
277
|
reparent_under_call_tree(segments, ctrl_idx)
|
|
272
278
|
end
|
|
273
279
|
end
|
|
274
280
|
|
|
275
|
-
# Fill untracked
|
|
276
|
-
ctrl_idx = segments.index { |s| s[:type] == '
|
|
281
|
+
# Fill untracked job time with sampler data or synthetic segment
|
|
282
|
+
ctrl_idx = segments.index { |s| s[:type] == 'job' }
|
|
277
283
|
if ctrl_idx
|
|
278
284
|
ctrl_seg = segments[ctrl_idx]
|
|
279
285
|
ctrl_dur = (ctrl_seg[:duration] || 0).to_f
|
|
@@ -289,12 +295,18 @@ module Catpm
|
|
|
289
295
|
end
|
|
290
296
|
end
|
|
291
297
|
|
|
298
|
+
# Normalize job_duration in summary to match event duration
|
|
299
|
+
summary = segment_data[:segment_summary].dup
|
|
300
|
+
if summary[:job_duration]
|
|
301
|
+
summary[:job_duration] = [summary[:job_duration], duration.round(2)].min
|
|
302
|
+
end
|
|
303
|
+
|
|
292
304
|
context[:segments] = segments
|
|
293
|
-
context[:segment_summary] =
|
|
305
|
+
context[:segment_summary] = summary
|
|
294
306
|
context[:segments_capped] = segment_data[:segments_capped]
|
|
295
307
|
context[:segments_filtered] = segment_data[:segments_filtered] if segment_data[:segments_filtered] > 0
|
|
296
308
|
|
|
297
|
-
# Append error marker segment
|
|
309
|
+
# Append error marker segment
|
|
298
310
|
if exception
|
|
299
311
|
error_parent = ctrl_idx || 0
|
|
300
312
|
error_offset = if ctrl_idx
|
|
@@ -341,6 +353,125 @@ module Catpm
|
|
|
341
353
|
end
|
|
342
354
|
end
|
|
343
355
|
|
|
356
|
+
# Process a native Sidekiq::Worker job (not ActiveJob).
|
|
357
|
+
# Called from SidekiqServerMiddleware after job execution.
|
|
358
|
+
def process_sidekiq_job(target:, job_id:, queue:, retry_count:, queue_wait:, duration:, error:, req_segments:, instrumented:)
|
|
359
|
+
return unless Catpm.enabled?
|
|
360
|
+
|
|
361
|
+
metadata = { queue_wait: queue_wait }.compact
|
|
362
|
+
|
|
363
|
+
if req_segments
|
|
364
|
+
segment_data = req_segments.to_h
|
|
365
|
+
segment_data[:segment_summary].each { |k, v| metadata[k] = v }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
metadata[:_instrumented] = 1 if instrumented
|
|
369
|
+
|
|
370
|
+
sample_type = early_sample_type(
|
|
371
|
+
error: error,
|
|
372
|
+
duration: duration,
|
|
373
|
+
kind: :job,
|
|
374
|
+
target: target,
|
|
375
|
+
operation: queue,
|
|
376
|
+
instrumented: instrumented
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
context = nil
|
|
380
|
+
if sample_type
|
|
381
|
+
context = {
|
|
382
|
+
job_class: target,
|
|
383
|
+
job_id: job_id,
|
|
384
|
+
queue: queue,
|
|
385
|
+
attempts: retry_count
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if req_segments && segment_data
|
|
389
|
+
segments = segment_data[:segments]
|
|
390
|
+
collapse_code_wrappers(segments)
|
|
391
|
+
|
|
392
|
+
# Job span is already the root — no synthetic wrapper needed.
|
|
393
|
+
# Clamp its duration to the event duration to avoid >100% in breakdown.
|
|
394
|
+
ctrl_idx = segments.index { |s| s[:type] == 'job' }
|
|
395
|
+
if ctrl_idx
|
|
396
|
+
segments[ctrl_idx][:duration] = duration.round(2)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
if Catpm.config.instrument_call_tree && req_segments
|
|
400
|
+
tree_segs = req_segments.call_tree_segments
|
|
401
|
+
if tree_segs.any?
|
|
402
|
+
inject_call_tree_segments(segments, tree_segs, ctrl_idx)
|
|
403
|
+
reparent_under_call_tree(segments, ctrl_idx)
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Fill untracked job time
|
|
408
|
+
ctrl_idx = segments.index { |s| s[:type] == 'job' }
|
|
409
|
+
if ctrl_idx
|
|
410
|
+
ctrl_seg = segments[ctrl_idx]
|
|
411
|
+
ctrl_dur = (ctrl_seg[:duration] || 0).to_f
|
|
412
|
+
child_dur = segments.each_with_index.sum do |pair|
|
|
413
|
+
seg, i = pair
|
|
414
|
+
next 0.0 if i == ctrl_idx
|
|
415
|
+
(seg[:parent_index] == ctrl_idx) ? (seg[:duration] || 0).to_f : 0.0
|
|
416
|
+
end
|
|
417
|
+
gap = ctrl_dur - child_dur
|
|
418
|
+
|
|
419
|
+
if gap > MIN_GAP_MS && Catpm.config.show_untracked_segments
|
|
420
|
+
inject_gap_segments(segments, req_segments, gap, ctrl_idx, ctrl_seg)
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Normalize job_duration in summary to match event duration
|
|
425
|
+
summary = segment_data[:segment_summary].dup
|
|
426
|
+
if summary[:job_duration]
|
|
427
|
+
summary[:job_duration] = [summary[:job_duration], duration.round(2)].min
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
context[:segments] = segments
|
|
431
|
+
context[:segment_summary] = summary
|
|
432
|
+
context[:segments_capped] = segment_data[:segments_capped]
|
|
433
|
+
context[:segments_filtered] = segment_data[:segments_filtered] if segment_data[:segments_filtered] > 0
|
|
434
|
+
|
|
435
|
+
if error
|
|
436
|
+
error_parent = ctrl_idx || 0
|
|
437
|
+
error_offset = if ctrl_idx
|
|
438
|
+
ctrl = segments[ctrl_idx]
|
|
439
|
+
((ctrl[:offset] || 0) + (ctrl[:duration] || 0)).round(2)
|
|
440
|
+
else
|
|
441
|
+
duration.round(2)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
context[:segments] << {
|
|
445
|
+
type: 'error',
|
|
446
|
+
detail: "#{error.class.name}: #{error.message}".truncate(Catpm.config.max_error_detail_length),
|
|
447
|
+
source: error.backtrace&.first,
|
|
448
|
+
duration: 0,
|
|
449
|
+
offset: error_offset,
|
|
450
|
+
parent_index: error_parent
|
|
451
|
+
}
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
context = scrub(context)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
ev = Event.new(
|
|
459
|
+
kind: :job,
|
|
460
|
+
target: target,
|
|
461
|
+
operation: queue,
|
|
462
|
+
duration: duration,
|
|
463
|
+
started_at: Time.current,
|
|
464
|
+
context: context,
|
|
465
|
+
sample_type: sample_type,
|
|
466
|
+
metadata: metadata,
|
|
467
|
+
error_class: error&.class&.name,
|
|
468
|
+
error_message: error&.message,
|
|
469
|
+
backtrace: error&.backtrace
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
Catpm.buffer&.push(ev)
|
|
473
|
+
end
|
|
474
|
+
|
|
344
475
|
def process_tracked(kind:, target:, operation:, duration:, context:, metadata:, error:, req_segments:)
|
|
345
476
|
return unless Catpm.enabled?
|
|
346
477
|
return if Catpm.config.ignored?(target)
|
|
@@ -389,22 +520,17 @@ module Catpm
|
|
|
389
520
|
segments.unshift(root_segment)
|
|
390
521
|
|
|
391
522
|
# Inject call tree segments from sampler
|
|
392
|
-
ctrl_idx = segments.index { |s| s[:type] == 'controller' }
|
|
523
|
+
ctrl_idx = segments.index { |s| s[:type] == 'controller' || s[:type] == 'job' }
|
|
393
524
|
if Catpm.config.instrument_call_tree && req_segments
|
|
394
525
|
tree_segs = req_segments.call_tree_segments
|
|
395
526
|
if tree_segs.any?
|
|
396
|
-
|
|
397
|
-
tree_segs.each do |seg|
|
|
398
|
-
tree_parent = seg.delete(:_tree_parent)
|
|
399
|
-
seg[:parent_index] = tree_parent ? (tree_parent + base_idx) : (ctrl_idx || 0)
|
|
400
|
-
segments << seg
|
|
401
|
-
end
|
|
527
|
+
inject_call_tree_segments(segments, tree_segs, ctrl_idx)
|
|
402
528
|
reparent_under_call_tree(segments, ctrl_idx)
|
|
403
529
|
end
|
|
404
530
|
end
|
|
405
531
|
|
|
406
|
-
# Fill untracked
|
|
407
|
-
ctrl_idx = segments.index { |s| s[:type] == 'controller' }
|
|
532
|
+
# Fill untracked time with sampler data or synthetic segment
|
|
533
|
+
ctrl_idx = segments.index { |s| s[:type] == 'controller' || s[:type] == 'job' }
|
|
408
534
|
if ctrl_idx
|
|
409
535
|
ctrl_seg = segments[ctrl_idx]
|
|
410
536
|
ctrl_dur = (ctrl_seg[:duration] || 0).to_f
|
|
@@ -535,6 +661,122 @@ module Catpm
|
|
|
535
661
|
|
|
536
662
|
private
|
|
537
663
|
|
|
664
|
+
# Check whether segment at idx is a transitive child of ancestor_idx.
|
|
665
|
+
def in_subtree?(segments, idx, ancestor_idx)
|
|
666
|
+
current = idx
|
|
667
|
+
depth = 0
|
|
668
|
+
while current
|
|
669
|
+
return true if current == ancestor_idx
|
|
670
|
+
current = segments[current]&.[](:parent_index)
|
|
671
|
+
depth += 1
|
|
672
|
+
return false if depth > segments.size # cycle guard
|
|
673
|
+
end
|
|
674
|
+
false
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# Inject call tree segments with correct parenting.
|
|
678
|
+
# Middleware-type nodes from the call tree are filtered out — they duplicate
|
|
679
|
+
# the synthetic/real Middleware Stack and create impossible timeline nesting
|
|
680
|
+
# (outer middleware wraps everything including the controller).
|
|
681
|
+
# Code children of filtered middleware nodes are re-rooted: walk up to the
|
|
682
|
+
# nearest surviving code ancestor, or default to controller/MW Stack by offset.
|
|
683
|
+
def inject_call_tree_segments(segments, tree_segs, ctrl_idx)
|
|
684
|
+
ctrl_offset = ctrl_idx ? (segments[ctrl_idx][:offset] || 0).to_f : 0.0
|
|
685
|
+
mw_idx = segments.index { |s| s[:type] == 'middleware' }
|
|
686
|
+
|
|
687
|
+
# Extract tree parent info before modifying segments
|
|
688
|
+
tree_parents = tree_segs.map { |seg| seg[:_tree_parent] }
|
|
689
|
+
|
|
690
|
+
# Pass 1: Assign new indices for kept (non-middleware) segments
|
|
691
|
+
old_to_new = {}
|
|
692
|
+
next_idx = segments.size
|
|
693
|
+
tree_segs.each_with_index do |seg, i|
|
|
694
|
+
if seg[:type] == 'middleware'
|
|
695
|
+
old_to_new[i] = nil
|
|
696
|
+
else
|
|
697
|
+
old_to_new[i] = next_idx
|
|
698
|
+
next_idx += 1
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# Pass 2: Resolve parents and append kept segments.
|
|
703
|
+
# A code node in controller phase (offset ≥ ctrl_offset) must not end up
|
|
704
|
+
# under a pre-controller parent — if its resolved tree ancestor is in the
|
|
705
|
+
# middleware phase, re-parent under controller instead.
|
|
706
|
+
tree_segs.each_with_index do |seg, i|
|
|
707
|
+
next unless old_to_new[i] # skip filtered middleware nodes
|
|
708
|
+
|
|
709
|
+
seg.delete(:_tree_parent)
|
|
710
|
+
tree_parent = tree_parents[i]
|
|
711
|
+
seg_offset = (seg[:offset] || 0).to_f
|
|
712
|
+
in_ctrl_phase = ctrl_idx && seg_offset >= ctrl_offset
|
|
713
|
+
|
|
714
|
+
new_parent = nil
|
|
715
|
+
if tree_parent
|
|
716
|
+
resolved = resolve_filtered_tree_parent(tree_parents, tree_parent, old_to_new)
|
|
717
|
+
if resolved
|
|
718
|
+
# Check if parent is pre-controller but this segment is in controller phase
|
|
719
|
+
parent_offset = (segments[resolved][:offset] || 0).to_f
|
|
720
|
+
if in_ctrl_phase && parent_offset < ctrl_offset
|
|
721
|
+
new_parent = ctrl_idx
|
|
722
|
+
else
|
|
723
|
+
new_parent = resolved
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
# Determine default parent based on phase.
|
|
729
|
+
# A code segment that starts before the controller but extends past
|
|
730
|
+
# ctrl_offset is a wrapping middleware (e.g. Bullet::Rack#call) —
|
|
731
|
+
# parent it under root so it doesn't visually overflow the
|
|
732
|
+
# synthetic Middleware Stack segment.
|
|
733
|
+
seg_end = seg_offset + (seg[:duration] || 0).to_f
|
|
734
|
+
spans_both_phases = !in_ctrl_phase && ctrl_idx && seg_end > ctrl_offset
|
|
735
|
+
default_parent = if in_ctrl_phase
|
|
736
|
+
ctrl_idx
|
|
737
|
+
elsif spans_both_phases
|
|
738
|
+
0
|
|
739
|
+
else
|
|
740
|
+
mw_idx || 0
|
|
741
|
+
end
|
|
742
|
+
seg[:parent_index] = new_parent || default_parent
|
|
743
|
+
segments << seg
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# Walk up the call tree to find the nearest surviving (non-middleware) ancestor.
|
|
748
|
+
def resolve_filtered_tree_parent(tree_parents, parent_idx, old_to_new)
|
|
749
|
+
visited = Set.new
|
|
750
|
+
current = parent_idx
|
|
751
|
+
while current && !visited.include?(current)
|
|
752
|
+
visited << current
|
|
753
|
+
return old_to_new[current] if old_to_new[current]
|
|
754
|
+
current = tree_parents[current]
|
|
755
|
+
end
|
|
756
|
+
nil
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# Re-parent leaf segments (SQL, cache, etc.) that fired during middleware
|
|
760
|
+
# processing under the Middleware Stack segment. Only moves segments that
|
|
761
|
+
# end entirely before the controller starts — call tree spans (code/middleware)
|
|
762
|
+
# that wrap the controller are left under root.
|
|
763
|
+
REPARENTABLE_TYPES = Set.new(%w[sql cache http view storage mailer instantiation]).freeze
|
|
764
|
+
|
|
765
|
+
def reparent_pre_controller_segments(segments, ctrl_idx)
|
|
766
|
+
mw_idx = segments.index { |s| s[:type] == 'middleware' }
|
|
767
|
+
return unless mw_idx
|
|
768
|
+
|
|
769
|
+
ctrl_offset = (segments[ctrl_idx][:offset] || 0).to_f
|
|
770
|
+
|
|
771
|
+
segments.each_with_index do |seg, i|
|
|
772
|
+
next if i == mw_idx || i == 0
|
|
773
|
+
next unless seg[:parent_index] == 0
|
|
774
|
+
next unless REPARENTABLE_TYPES.include?(seg[:type])
|
|
775
|
+
seg_end = (seg[:offset] || 0).to_f + (seg[:duration] || 0).to_f
|
|
776
|
+
seg[:parent_index] = mw_idx if seg_end <= ctrl_offset
|
|
777
|
+
end
|
|
778
|
+
end
|
|
779
|
+
|
|
538
780
|
# Re-parent non-code segments (sql, cache, etc.) under call tree code segments
|
|
539
781
|
# when their offset falls within the code segment's time range.
|
|
540
782
|
# This gives proper nesting: code → sql, instead of both being siblings under controller.
|
|
@@ -545,9 +787,13 @@ module Catpm
|
|
|
545
787
|
# sample cap before the code finishes (e.g. during a long I/O call).
|
|
546
788
|
def reparent_under_call_tree(segments, ctrl_idx)
|
|
547
789
|
# Build index of code segments with their time ranges: [index, offset, end]
|
|
790
|
+
# Only consider code segments that belong to the controller's subtree —
|
|
791
|
+
# wrapping middleware code segments (parented under root) must not
|
|
792
|
+
# steal controller-phase children via time-range overlap.
|
|
548
793
|
code_nodes = []
|
|
549
794
|
segments.each_with_index do |seg, i|
|
|
550
795
|
next unless seg[:type] == 'code' && seg[:offset] && seg[:duration]
|
|
796
|
+
next unless in_subtree?(segments, i, ctrl_idx)
|
|
551
797
|
code_nodes << [i, seg[:offset].to_f, seg[:offset].to_f + seg[:duration].to_f]
|
|
552
798
|
end
|
|
553
799
|
return if code_nodes.empty?
|
|
@@ -560,7 +806,7 @@ module Catpm
|
|
|
560
806
|
|
|
561
807
|
segments.each_with_index do |seg, i|
|
|
562
808
|
# Only reparent direct children of controller that aren't code segments
|
|
563
|
-
next if seg[:type] == 'code' || seg[:type] == 'controller' || seg[:type] == 'request'
|
|
809
|
+
next if seg[:type] == 'code' || seg[:type] == 'controller' || seg[:type] == 'job' || seg[:type] == 'request'
|
|
564
810
|
next unless seg[:parent_index] == ctrl_idx
|
|
565
811
|
next unless seg[:offset]
|
|
566
812
|
|
|
@@ -584,12 +830,62 @@ module Catpm
|
|
|
584
830
|
seg[:parent_index] = best_idx if best_idx
|
|
585
831
|
end
|
|
586
832
|
|
|
833
|
+
# Extend code segment offsets backwards when reparented children start
|
|
834
|
+
# before the code segment's recorded start. This happens because the sampler
|
|
835
|
+
# captures at intervals — the code's real start may be up to one sample
|
|
836
|
+
# interval before the first capture. Without this, the child's offset
|
|
837
|
+
# visually precedes its parent, breaking the waterfall timeline.
|
|
838
|
+
extend_call_tree_offsets(segments)
|
|
839
|
+
|
|
587
840
|
# Extend code segment durations when reparented children extend beyond them.
|
|
588
841
|
# The stack sampler may hit its cap early, producing short code segments that
|
|
589
842
|
# don't cover the full wall-clock time of long I/O calls within them.
|
|
590
843
|
extend_call_tree_durations(segments)
|
|
591
844
|
end
|
|
592
845
|
|
|
846
|
+
# Extend code segment start time backwards when reparented children
|
|
847
|
+
# have offsets before the code segment's recorded start.
|
|
848
|
+
def extend_call_tree_offsets(segments)
|
|
849
|
+
segments.each do |seg|
|
|
850
|
+
parent_idx = seg[:parent_index]
|
|
851
|
+
next unless parent_idx
|
|
852
|
+
|
|
853
|
+
parent = segments[parent_idx]
|
|
854
|
+
next unless parent && parent[:type] == 'code'
|
|
855
|
+
|
|
856
|
+
seg_offset = (seg[:offset] || 0).to_f
|
|
857
|
+
parent_offset = (parent[:offset] || 0).to_f
|
|
858
|
+
|
|
859
|
+
next unless seg_offset < parent_offset
|
|
860
|
+
|
|
861
|
+
extension = parent_offset - seg_offset
|
|
862
|
+
parent[:offset] = seg_offset.round(2)
|
|
863
|
+
parent[:duration] = ((parent[:duration] || 0).to_f + extension).round(2)
|
|
864
|
+
|
|
865
|
+
# Propagate up the chain
|
|
866
|
+
propagate_offset_up(segments, parent_idx)
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def propagate_offset_up(segments, idx)
|
|
871
|
+
seg = segments[idx]
|
|
872
|
+
parent_idx = seg[:parent_index]
|
|
873
|
+
return unless parent_idx
|
|
874
|
+
|
|
875
|
+
parent = segments[parent_idx]
|
|
876
|
+
return unless parent && parent[:type] == 'code'
|
|
877
|
+
|
|
878
|
+
seg_offset = (seg[:offset] || 0).to_f
|
|
879
|
+
parent_offset = (parent[:offset] || 0).to_f
|
|
880
|
+
|
|
881
|
+
return unless seg_offset < parent_offset
|
|
882
|
+
|
|
883
|
+
extension = parent_offset - seg_offset
|
|
884
|
+
parent[:offset] = seg_offset.round(2)
|
|
885
|
+
parent[:duration] = ((parent[:duration] || 0).to_f + extension).round(2)
|
|
886
|
+
propagate_offset_up(segments, parent_idx)
|
|
887
|
+
end
|
|
888
|
+
|
|
593
889
|
# Walk all segments; when a child's end time exceeds its parent code segment's
|
|
594
890
|
# end time, extend the parent (and propagate up the chain).
|
|
595
891
|
def extend_call_tree_durations(segments)
|
|
@@ -640,7 +936,7 @@ module Catpm
|
|
|
640
936
|
# Pre-build set of parent indices that have a controller child — O(n)
|
|
641
937
|
parents_with_controller = {}
|
|
642
938
|
segments.each do |seg|
|
|
643
|
-
parents_with_controller[seg[:parent_index]] = true if seg[:type] == 'controller' && seg[:parent_index]
|
|
939
|
+
parents_with_controller[seg[:parent_index]] = true if (seg[:type] == 'controller' || seg[:type] == 'job') && seg[:parent_index]
|
|
644
940
|
end
|
|
645
941
|
|
|
646
942
|
# Identify code spans to collapse: near-zero duration wrapping a controller child
|
|
@@ -700,16 +996,29 @@ module Catpm
|
|
|
700
996
|
sampler_groups = req_segments&.sampler_segments || []
|
|
701
997
|
|
|
702
998
|
if sampler_groups.any?
|
|
999
|
+
ctrl_offset = (ctrl_seg[:offset] || 0.0).to_f
|
|
1000
|
+
ctrl_end = ctrl_offset + (ctrl_seg[:duration] || 0.0).to_f
|
|
1001
|
+
mw_idx = segments.index { |s| s[:type] == 'middleware' }
|
|
1002
|
+
|
|
703
1003
|
sampler_dur = 0.0
|
|
704
1004
|
|
|
705
1005
|
sampler_groups.each do |group|
|
|
706
1006
|
parent = group[:parent]
|
|
707
1007
|
children = group[:children] || []
|
|
1008
|
+
parent_offset = (parent[:offset] || 0.0).to_f
|
|
708
1009
|
|
|
709
1010
|
parent_idx = segments.size
|
|
710
|
-
|
|
1011
|
+
|
|
1012
|
+
# Parent under controller only if segment falls within controller's time range;
|
|
1013
|
+
# otherwise parent under middleware span or root request.
|
|
1014
|
+
if parent_offset >= ctrl_offset && parent_offset < ctrl_end
|
|
1015
|
+
parent[:parent_index] = ctrl_idx
|
|
1016
|
+
sampler_dur += (parent[:duration] || 0).to_f
|
|
1017
|
+
else
|
|
1018
|
+
parent[:parent_index] = mw_idx || 0
|
|
1019
|
+
end
|
|
1020
|
+
|
|
711
1021
|
segments << parent
|
|
712
|
-
sampler_dur += (parent[:duration] || 0).to_f
|
|
713
1022
|
|
|
714
1023
|
children.each do |child|
|
|
715
1024
|
child[:parent_index] = parent_idx
|
|
@@ -792,13 +1101,33 @@ module Catpm
|
|
|
792
1101
|
end
|
|
793
1102
|
end
|
|
794
1103
|
|
|
1104
|
+
RACK_HTTP_PREFIX = 'HTTP_'
|
|
1105
|
+
RACK_HTTP_PREFIX_LENGTH = RACK_HTTP_PREFIX.length
|
|
1106
|
+
|
|
1107
|
+
# Rack env keys that are not prefixed with HTTP_ but still relevant
|
|
1108
|
+
RACK_EXTRA_KEYS = %w[CONTENT_TYPE CONTENT_LENGTH REMOTE_ADDR].freeze
|
|
1109
|
+
|
|
1110
|
+
# env keys to skip — internal Rack/Rails metadata, not real headers
|
|
1111
|
+
RACK_SKIP_KEYS = %w[HTTP_VERSION HTTP_HOST].freeze
|
|
1112
|
+
|
|
1113
|
+
FILTERED_HEADER_VALUE = '[FILTERED]'
|
|
1114
|
+
|
|
795
1115
|
def build_http_context(payload)
|
|
796
|
-
{
|
|
1116
|
+
context = {
|
|
797
1117
|
method: payload[:method],
|
|
798
1118
|
path: payload[:path],
|
|
799
1119
|
params: (payload[:params] || {}).except('controller', 'action'),
|
|
800
1120
|
status: payload[:status]
|
|
801
1121
|
}
|
|
1122
|
+
|
|
1123
|
+
env = Thread.current[:catpm_request_env]
|
|
1124
|
+
if env
|
|
1125
|
+
context[:remote_ip] = extract_remote_ip(env)
|
|
1126
|
+
headers = extract_request_headers(env)
|
|
1127
|
+
context[:request_headers] = headers if headers.any?
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
context
|
|
802
1131
|
end
|
|
803
1132
|
|
|
804
1133
|
def build_http_metadata(payload)
|
|
@@ -818,6 +1147,65 @@ module Catpm
|
|
|
818
1147
|
ActiveSupport::ParameterFilter.new(filters)
|
|
819
1148
|
end
|
|
820
1149
|
end
|
|
1150
|
+
|
|
1151
|
+
# Extract client IP, preferring X-Forwarded-For (common behind proxies).
|
|
1152
|
+
# Optionally anonymizes by zeroing the last octet (IPv4) or last 80 bits (IPv6).
|
|
1153
|
+
def extract_remote_ip(env)
|
|
1154
|
+
raw_ip = env['HTTP_X_FORWARDED_FOR']&.split(',')&.first&.strip || env['REMOTE_ADDR']
|
|
1155
|
+
return nil unless raw_ip
|
|
1156
|
+
|
|
1157
|
+
Catpm.config.anonymize_ip ? anonymize_ip(raw_ip) : raw_ip
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
def anonymize_ip(ip)
|
|
1161
|
+
if ip.include?(':')
|
|
1162
|
+
# IPv6: zero last 80 bits (keep /48 prefix)
|
|
1163
|
+
parts = ip.split(':')
|
|
1164
|
+
parts[3..] = Array.new([parts.length - 3, 0].max, '0')
|
|
1165
|
+
parts.join(':')
|
|
1166
|
+
else
|
|
1167
|
+
# IPv4: zero last octet
|
|
1168
|
+
parts = ip.split('.')
|
|
1169
|
+
parts[3] = '0' if parts.length == 4
|
|
1170
|
+
parts.join('.')
|
|
1171
|
+
end
|
|
1172
|
+
end
|
|
1173
|
+
|
|
1174
|
+
# Extract HTTP headers from Rack env, normalizing names and filtering sensitive values.
|
|
1175
|
+
def extract_request_headers(env)
|
|
1176
|
+
sensitive = Catpm.config.sensitive_headers.map { |h| normalize_header_name(h) }.to_set
|
|
1177
|
+
headers = {}
|
|
1178
|
+
|
|
1179
|
+
env.each do |key, value|
|
|
1180
|
+
next unless value.is_a?(String)
|
|
1181
|
+
|
|
1182
|
+
if key.start_with?(RACK_HTTP_PREFIX)
|
|
1183
|
+
next if RACK_SKIP_KEYS.include?(key)
|
|
1184
|
+
header_name = rack_key_to_header(key)
|
|
1185
|
+
elsif RACK_EXTRA_KEYS.include?(key)
|
|
1186
|
+
header_name = rack_key_to_header(key)
|
|
1187
|
+
else
|
|
1188
|
+
next
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
headers[header_name] = sensitive.include?(header_name) ? FILTERED_HEADER_VALUE : value
|
|
1192
|
+
end
|
|
1193
|
+
|
|
1194
|
+
headers
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
# Convert Rack env key to normalized header name:
|
|
1198
|
+
# HTTP_USER_AGENT -> User-Agent, CONTENT_TYPE -> Content-Type
|
|
1199
|
+
def rack_key_to_header(key)
|
|
1200
|
+
key.delete_prefix(RACK_HTTP_PREFIX)
|
|
1201
|
+
.split('_')
|
|
1202
|
+
.map(&:capitalize)
|
|
1203
|
+
.join('-')
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1206
|
+
def normalize_header_name(name)
|
|
1207
|
+
name.tr('_', '-').split('-').map(&:capitalize).join('-')
|
|
1208
|
+
end
|
|
821
1209
|
end
|
|
822
1210
|
end
|
|
823
1211
|
end
|
data/lib/catpm/configuration.rb
CHANGED
|
@@ -43,7 +43,9 @@ module Catpm
|
|
|
43
43
|
:track_own_requests,
|
|
44
44
|
:downsampling_thresholds,
|
|
45
45
|
:show_untracked_segments,
|
|
46
|
-
:always_sample_targets
|
|
46
|
+
:always_sample_targets,
|
|
47
|
+
:anonymize_ip,
|
|
48
|
+
:sensitive_headers
|
|
47
49
|
|
|
48
50
|
# Numeric settings that must be positive numbers (nil not allowed)
|
|
49
51
|
REQUIRED_NUMERIC = %i[
|
|
@@ -82,7 +84,7 @@ module Catpm
|
|
|
82
84
|
end
|
|
83
85
|
|
|
84
86
|
def initialize
|
|
85
|
-
@enabled =
|
|
87
|
+
@enabled = false
|
|
86
88
|
@instrument_http = true
|
|
87
89
|
@instrument_jobs = false
|
|
88
90
|
@instrument_segments = true
|
|
@@ -136,6 +138,8 @@ module Catpm
|
|
|
136
138
|
@instrument_call_tree = false
|
|
137
139
|
@show_untracked_segments = false
|
|
138
140
|
@always_sample_targets = []
|
|
141
|
+
@anonymize_ip = false
|
|
142
|
+
@sensitive_headers = %w[Authorization Cookie Set-Cookie X-API-Key X-CSRF-Token]
|
|
139
143
|
end
|
|
140
144
|
|
|
141
145
|
# Buffer gets BUFFER_MEMORY_SHARE of max_memory, scaled by thread count
|
data/lib/catpm/engine.rb
CHANGED
|
@@ -22,6 +22,15 @@ module Catpm
|
|
|
22
22
|
# Middleware not found in stack — skip
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
|
+
|
|
26
|
+
if Catpm.config.instrument_jobs && defined?(::Sidekiq)
|
|
27
|
+
require 'catpm/sidekiq_server_middleware' unless defined?(Catpm::SidekiqServerMiddleware)
|
|
28
|
+
::Sidekiq.configure_server do |config|
|
|
29
|
+
config.server_middleware do |chain|
|
|
30
|
+
chain.add Catpm::SidekiqServerMiddleware
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
25
34
|
end
|
|
26
35
|
end
|
|
27
36
|
|
data/lib/catpm/fingerprint.rb
CHANGED
|
@@ -74,6 +74,12 @@ module Catpm
|
|
|
74
74
|
line.sub(/:\d+:in /, ':in ')
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
# Classify app-code path: middleware vs regular code.
|
|
78
|
+
# Files under a /middleware/ or /middlewares/ directory are middleware.
|
|
79
|
+
def self.app_code_type(path)
|
|
80
|
+
path.include?('/middleware/') || path.include?('/middlewares/') ? 'middleware' : 'code'
|
|
81
|
+
end
|
|
82
|
+
|
|
77
83
|
# The actual classification logic (uncached).
|
|
78
84
|
def self._app_frame?(line)
|
|
79
85
|
return false if line.include?('/gems/')
|