catpm 0.10.1 → 0.10.3

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: 5dab2a97464afff0ad59e3913f7f1aca84505d3ca109966a611eed911d5bb1a7
4
- data.tar.gz: aa4c892ce8a39fb2ba5ceacf1aef5f1b9c6c867a682536f67c895d887d93607b
3
+ metadata.gz: c4147059f707d6d81dfc82021c4c56344701560054f5f11b1fb5411b58d94f2e
4
+ data.tar.gz: ce4180ae0fde1b5300d67d4919ff7883204ea61bcc2fe4b13d23fc8471abef2a
5
5
  SHA512:
6
- metadata.gz: dbfa610b0355bf1f99b41c6be9476f622e4f756c75c79764abee15721bb2a729113425b6b81416e86e09f734d253301c36f8b0f577b8b15075c584d0e0728dae
7
- data.tar.gz: ebaef872bc07283485e72ef2ccb5b9f14b836f581c66053ed0f7329621e83a5b7e3b58ef7e021cb43c5bdece61d20dda39867c85a73382bef055364424481f16
6
+ metadata.gz: 39314b333fefeab949b3cb10948e8efa5dd09fd51bf77194e7cc0542e159d1a693d83fea4fdbd7272e0968915154d68387a0e49bc0c28f10e0eb33d2934ae3db
7
+ data.tar.gz: ff02750df626ada2511ba6ead16311d8ea5afe42835b25a031328e765a432c7839dabdacc336e4a07ba0d69fffcac07b0795b767ca7905de4b8cc36cc2f81a11
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  gem build catpm.gemspec
2
- gem push catpm-0.10.0.gem
2
+ gem push catpm-0.10.2.gem
3
3
 
4
4
  # Catpm
5
5
 
@@ -201,6 +201,10 @@ module Catpm
201
201
 
202
202
  duration = event.duration
203
203
  exception = payload[:exception_object]
204
+ owns_segments = payload[:_catpm_job_owns_segments]
205
+
206
+ req_segments = Thread.current[:catpm_request_segments] if owns_segments
207
+ instrumented = !req_segments.nil?
204
208
 
205
209
  queue_wait = if job.respond_to?(:enqueued_at) && job.enqueued_at
206
210
  ((Time.current - job.enqueued_at.to_time) * 1000.0) rescue nil
@@ -208,21 +212,110 @@ module Catpm
208
212
 
209
213
  metadata = { queue_wait: queue_wait }.compact
210
214
 
215
+ if req_segments
216
+ segment_data = req_segments.to_h
217
+ segment_data[:segment_summary].each { |k, v| metadata[k] = v }
218
+ end
219
+
220
+ metadata[:_instrumented] = 1 if instrumented
221
+
211
222
  sample_type = early_sample_type(
212
223
  error: exception,
213
224
  duration: duration,
214
225
  kind: :job,
215
226
  target: target,
216
- operation: job.queue_name
227
+ operation: job.queue_name,
228
+ instrumented: instrumented
217
229
  )
218
230
 
219
- context = if sample_type
220
- {
231
+ context = nil
232
+ if sample_type
233
+ context = {
221
234
  job_class: target,
222
235
  job_id: job.job_id,
223
236
  queue: job.queue_name,
224
237
  attempts: job.executions
225
238
  }
239
+
240
+ if req_segments
241
+ segments = segment_data[:segments]
242
+ collapse_code_wrappers(segments)
243
+
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
257
+ end
258
+ segments.unshift(root_segment)
259
+
260
+ # Inject call tree segments from sampler
261
+ ctrl_idx = segments.index { |s| s[:type] == 'controller' }
262
+ if Catpm.config.instrument_call_tree && req_segments
263
+ tree_segs = req_segments.call_tree_segments
264
+ 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
271
+ reparent_under_call_tree(segments, ctrl_idx)
272
+ end
273
+ end
274
+
275
+ # Fill untracked controller time with sampler data or synthetic segment
276
+ ctrl_idx = segments.index { |s| s[:type] == 'controller' }
277
+ if ctrl_idx
278
+ ctrl_seg = segments[ctrl_idx]
279
+ ctrl_dur = (ctrl_seg[:duration] || 0).to_f
280
+ child_dur = segments.each_with_index.sum do |pair|
281
+ seg, i = pair
282
+ next 0.0 if i == ctrl_idx
283
+ (seg[:parent_index] == ctrl_idx) ? (seg[:duration] || 0).to_f : 0.0
284
+ end
285
+ gap = ctrl_dur - child_dur
286
+
287
+ if gap > MIN_GAP_MS && Catpm.config.show_untracked_segments
288
+ inject_gap_segments(segments, req_segments, gap, ctrl_idx, ctrl_seg)
289
+ end
290
+ end
291
+
292
+ context[:segments] = segments
293
+ context[:segment_summary] = segment_data[:segment_summary]
294
+ context[:segments_capped] = segment_data[:segments_capped]
295
+ context[:segments_filtered] = segment_data[:segments_filtered] if segment_data[:segments_filtered] > 0
296
+
297
+ # Append error marker segment inside the controller
298
+ if exception
299
+ error_parent = ctrl_idx || 0
300
+ error_offset = if ctrl_idx
301
+ ctrl = segments[ctrl_idx]
302
+ ((ctrl[:offset] || 0) + (ctrl[:duration] || 0)).round(2)
303
+ else
304
+ duration.round(2)
305
+ end
306
+
307
+ context[:segments] << {
308
+ type: 'error',
309
+ detail: "#{exception.class.name}: #{exception.message}".truncate(Catpm.config.max_error_detail_length),
310
+ source: exception.backtrace&.first,
311
+ duration: 0,
312
+ offset: error_offset,
313
+ parent_index: error_parent
314
+ }
315
+ end
316
+ end
317
+
318
+ context = scrub(context)
226
319
  end
227
320
 
228
321
  ev = Event.new(
@@ -240,6 +333,12 @@ module Catpm
240
333
  )
241
334
 
242
335
  Catpm.buffer&.push(ev)
336
+ ensure
337
+ if owns_segments
338
+ req_segments&.release!
339
+ Collector.end_instrumentation
340
+ Thread.current[:catpm_request_segments] = nil
341
+ end
243
342
  end
244
343
 
245
344
  def process_tracked(kind:, target:, operation:, duration:, context:, metadata:, error:, req_segments:)
@@ -439,6 +538,11 @@ module Catpm
439
538
  # Re-parent non-code segments (sql, cache, etc.) under call tree code segments
440
539
  # when their offset falls within the code segment's time range.
441
540
  # This gives proper nesting: code → sql, instead of both being siblings under controller.
541
+ #
542
+ # After reparenting, extends code segment durations up the call tree chain
543
+ # when children (e.g. external HTTP spans) extend beyond the code segment's
544
+ # sampler-derived duration. This happens because the stack sampler may hit its
545
+ # sample cap before the code finishes (e.g. during a long I/O call).
442
546
  def reparent_under_call_tree(segments, ctrl_idx)
443
547
  # Build index of code segments with their time ranges: [index, offset, end]
444
548
  code_nodes = []
@@ -448,6 +552,12 @@ module Catpm
448
552
  end
449
553
  return if code_nodes.empty?
450
554
 
555
+ # Tolerance for offset matching: spans created via push_span record exact timing,
556
+ # while call tree code segments start from the first sampler capture (up to one
557
+ # sampling interval later). Without tolerance, the span's offset falls just before
558
+ # the code segment's range and reparenting silently fails.
559
+ sampling_tolerance_ms = Catpm.config.stack_sample_interval * 1000.0
560
+
451
561
  segments.each_with_index do |seg, i|
452
562
  # Only reparent direct children of controller that aren't code segments
453
563
  next if seg[:type] == 'code' || seg[:type] == 'controller' || seg[:type] == 'request'
@@ -462,7 +572,7 @@ module Catpm
462
572
  best_dur = Float::INFINITY
463
573
 
464
574
  code_nodes.each do |code_i, code_start, code_end|
465
- next unless seg_offset >= code_start && seg_offset < code_end
575
+ next unless seg_offset >= (code_start - sampling_tolerance_ms) && seg_offset < code_end
466
576
 
467
577
  dur = code_end - code_start
468
578
  if dur < best_dur
@@ -473,6 +583,52 @@ module Catpm
473
583
 
474
584
  seg[:parent_index] = best_idx if best_idx
475
585
  end
586
+
587
+ # Extend code segment durations when reparented children extend beyond them.
588
+ # The stack sampler may hit its cap early, producing short code segments that
589
+ # don't cover the full wall-clock time of long I/O calls within them.
590
+ extend_call_tree_durations(segments)
591
+ end
592
+
593
+ # Walk all segments; when a child's end time exceeds its parent code segment's
594
+ # end time, extend the parent (and propagate up the chain).
595
+ def extend_call_tree_durations(segments)
596
+ segments.each do |seg|
597
+ parent_idx = seg[:parent_index]
598
+ next unless parent_idx
599
+
600
+ parent = segments[parent_idx]
601
+ next unless parent && parent[:type] == 'code'
602
+
603
+ seg_end = (seg[:offset] || 0).to_f + (seg[:duration] || 0).to_f
604
+ parent_offset = (parent[:offset] || 0).to_f
605
+ parent_end = parent_offset + (parent[:duration] || 0).to_f
606
+
607
+ next unless seg_end > parent_end
608
+
609
+ parent[:duration] = (seg_end - parent_offset).round(2)
610
+
611
+ # Propagate up the call tree chain
612
+ propagate_duration_up(segments, parent_idx)
613
+ end
614
+ end
615
+
616
+ def propagate_duration_up(segments, idx)
617
+ seg = segments[idx]
618
+ parent_idx = seg[:parent_index]
619
+ return unless parent_idx
620
+
621
+ parent = segments[parent_idx]
622
+ return unless parent && parent[:type] == 'code'
623
+
624
+ seg_end = (seg[:offset] || 0).to_f + (seg[:duration] || 0).to_f
625
+ parent_offset = (parent[:offset] || 0).to_f
626
+ parent_end = parent_offset + (parent[:duration] || 0).to_f
627
+
628
+ return unless seg_end > parent_end
629
+
630
+ parent[:duration] = (seg_end - parent_offset).round(2)
631
+ propagate_duration_up(segments, parent_idx)
476
632
  end
477
633
 
478
634
  # Remove near-zero-duration "code" spans that merely wrap a "controller" span.
@@ -51,6 +51,51 @@ module Catpm
51
51
  end
52
52
  end
53
53
 
54
+ # Subscriber with start/finish callbacks so all segments (SQL, cache, etc.)
55
+ # fired during a job are automatically captured and nested under the job span.
56
+ class JobSpanSubscriber
57
+ def start(_name, _id, payload)
58
+ return unless Catpm.config.instrument_segments
59
+
60
+ job = payload[:job]
61
+ target = job.class.name
62
+ return if Catpm.config.ignored?(target)
63
+
64
+ # Mark that we're inside a job so nested Catpm.span calls don't
65
+ # promote themselves to standalone endpoints when this job is not
66
+ # selected for segment sampling.
67
+ Thread.current[:catpm_job_active] = true
68
+
69
+ if Collector.should_instrument?(:job, target, job.queue_name)
70
+ use_sampler = Catpm.config.instrument_stack_sampler || Catpm.config.instrument_call_tree
71
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
72
+ req_segments = RequestSegments.new(
73
+ max_segments: Catpm.config.effective_max_segments_per_request,
74
+ request_start: start_time,
75
+ stack_sample: use_sampler,
76
+ call_tree: Catpm.config.instrument_call_tree
77
+ )
78
+ Thread.current[:catpm_request_segments] = req_segments
79
+
80
+ index = req_segments.push_span(type: :controller, detail: target, started_at: start_time)
81
+ payload[:_catpm_job_span_index] = index
82
+ payload[:_catpm_job_owns_segments] = true
83
+ end
84
+ end
85
+
86
+ def finish(_name, _id, payload)
87
+ Thread.current[:catpm_job_active] = nil
88
+
89
+ return unless payload[:_catpm_job_owns_segments]
90
+
91
+ req_segments = Thread.current[:catpm_request_segments]
92
+ return unless req_segments
93
+
94
+ req_segments.pop_span(payload[:_catpm_job_span_index])
95
+ req_segments.stop_sampler
96
+ end
97
+ end
98
+
54
99
  IGNORED_SQL_NAMES = Set.new([
55
100
  'SCHEMA', 'EXPLAIN',
56
101
  'ActiveRecord::SchemaMigration Load',
@@ -65,6 +110,12 @@ module Catpm
65
110
  'process_action.action_controller', ControllerSpanSubscriber.new
66
111
  )
67
112
 
113
+ if Catpm.config.instrument_jobs
114
+ @job_span_subscriber = ActiveSupport::Notifications.subscribe(
115
+ 'perform.active_job', JobSpanSubscriber.new
116
+ )
117
+ end
118
+
68
119
  @sql_subscriber = ActiveSupport::Notifications.subscribe(
69
120
  'sql.active_record'
70
121
  ) do |event|
@@ -122,7 +173,7 @@ module Catpm
122
173
 
123
174
  def unsubscribe!
124
175
  [
125
- @controller_span_subscriber,
176
+ @controller_span_subscriber, @job_span_subscriber,
126
177
  @sql_subscriber, @instantiation_subscriber,
127
178
  @render_template_subscriber, @render_partial_subscriber,
128
179
  @cache_read_subscriber, @cache_write_subscriber,
@@ -131,6 +182,7 @@ module Catpm
131
182
  ActiveSupport::Notifications.unsubscribe(sub) if sub
132
183
  end
133
184
  @controller_span_subscriber = nil
185
+ @job_span_subscriber = nil
134
186
  @sql_subscriber = nil
135
187
  @instantiation_subscriber = nil
136
188
  @render_template_subscriber = nil
data/lib/catpm/trace.rb CHANGED
@@ -9,9 +9,9 @@ module Catpm
9
9
 
10
10
  req_segments = Thread.current[:catpm_request_segments]
11
11
  unless req_segments
12
- # Inside a non-instrumented HTTP request — just run the block
12
+ # Inside a non-instrumented request/job — just run the block
13
13
  # without promoting the span to a standalone endpoint
14
- if Thread.current[:catpm_request_start]
14
+ if Thread.current[:catpm_request_start] || Thread.current[:catpm_job_active]
15
15
  return block.call if block
16
16
  return nil
17
17
  end
@@ -52,7 +52,7 @@ module Catpm
52
52
  type: :custom, duration: duration_ms, detail: name,
53
53
  source: source, started_at: start_time
54
54
  )
55
- elsif buffer && !Thread.current[:catpm_request_start]
55
+ elsif buffer && !Thread.current[:catpm_request_start] && !Thread.current[:catpm_job_active]
56
56
  Collector.process_custom(
57
57
  name: name, duration: duration_ms,
58
58
  metadata: metadata, error: error, context: context
@@ -155,7 +155,7 @@ module Catpm
155
155
  type: :custom, duration: duration_ms, detail: @name,
156
156
  source: source, started_at: @start_time
157
157
  )
158
- elsif Catpm.enabled? && Catpm.buffer && !Thread.current[:catpm_request_start]
158
+ elsif Catpm.enabled? && Catpm.buffer && !Thread.current[:catpm_request_start] && !Thread.current[:catpm_job_active]
159
159
  Collector.process_custom(
160
160
  name: @name, duration: duration_ms,
161
161
  metadata: @metadata, error: error, context: @context
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.10.1'
4
+ VERSION = '0.10.3'
5
5
  end
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.10.1
4
+ version: 0.10.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''