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.
@@ -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
- base_idx = segments.size
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
- # Inject root job segment with full duration
245
- root_segment = {
246
- type: 'request',
247
- detail: "job #{target}",
248
- duration: duration.round(2),
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
- base_idx = segments.size
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 controller time with sampler data or synthetic segment
276
- ctrl_idx = segments.index { |s| s[:type] == 'controller' }
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] = segment_data[: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 inside the controller
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
- base_idx = segments.size
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 controller time with sampler data or synthetic segment
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
- parent[:parent_index] = ctrl_idx
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
@@ -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 = true
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
 
@@ -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/')