catpm 0.8.4 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65885a7a84516a1752595759d38ad66987953d9f10f524b9ccbaacc8297324ca
4
- data.tar.gz: 6db3e32ff52377a09db8df4adfaa34a3fbd313364ca8f0a11dcbe4f7d8c7a346
3
+ metadata.gz: f73d14442e21e40a399d3df0635a640cf6067dc10f34e17a74054ab855ac212a
4
+ data.tar.gz: db34666075f0f5e2c2506a6e7ddad705673f8328ae22edcf1264ae0b94fe999a
5
5
  SHA512:
6
- metadata.gz: b05cb0a500e1752b7acb835c9804e547e976c8c516de631e14ae4ec016de3bbaf0046af66fcd16f7edb0415fb3e620c47cec2f2e9d89f0f256cf6be4b8d49f30
7
- data.tar.gz: ba00908dd6c24e2436d5d2986143020deacefc0b567e1cbe4748578344a46fbe5a91eae9fcae454e4ad7bf7940938189a6b3332a93dac0efb55a52651cea54ac
6
+ metadata.gz: 16edf4257962cc5b52ee5207604d6f4643fd93b3219ef15aaf4fbaac14779fa09f49b22aff387a97796d9435b741820f4e9380e2e5276df4939b47af9202f01d
7
+ data.tar.gz: c5e5b2de2aa7994d827760f77ff7e30fa313d77a51b58573a5129a6a09010815ba30deade3603c15482076b6a4e3cd888123cea7dd78ba026db6fbeab6bc7bc3
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  gem build catpm.gemspec
2
- gem push catpm-0.8.0.gem
2
+ gem push catpm-0.8.4.gem
3
3
 
4
4
  # Catpm
5
5
 
@@ -72,11 +72,13 @@
72
72
  <% end %>
73
73
 
74
74
  <%
75
+ instrumented_count = (@metadata["_instrumented"] || @metadata[:"_instrumented"] || 0).to_f
76
+ instrumented_count = @count.to_f if instrumented_count == 0 # backward compat with pre-sampling data
75
77
  type_data = segment_colors.map { |type, color|
76
78
  count = (@metadata["#{type}_count"] || @metadata[:"#{type}_count"] || 0).to_f
77
79
  dur = (@metadata["#{type}_duration"] || @metadata[:"#{type}_duration"] || 0).to_f
78
- avg_dur = @count > 0 ? dur / @count : 0
79
- avg_count = @count > 0 ? count / @count : 0
80
+ avg_dur = instrumented_count > 0 ? dur / instrumented_count : 0
81
+ avg_count = instrumented_count > 0 ? count / instrumented_count : 0
80
82
  text_color = segment_text_colors[type] || "#4b5563"
81
83
  { type: type, label: segment_labels[type] || type.capitalize, bg: color, text: text_color, count: count, dur: dur, avg_dur: avg_dur, avg_count: avg_count }
82
84
  }.select { |d| d[:count] > 0 }
@@ -15,7 +15,7 @@
15
15
  <% end %>
16
16
  <input type="text" class="search-input" id="error-search" placeholder="Search errors... (/)" oninput="filterByText('error-search','errors-table')">
17
17
  <% if @tab == "active" && @active_count > 0 %>
18
- <span style="display:inline; margin-left:auto"><%= button_to "Resolve all", catpm.resolve_all_errors_path, method: :post, class: "btn", data: { confirm: "Resolve all #{@active_count} active errors?" } %></span>
18
+ <span style="display:inline; margin-left:auto"><%= button_to "Resolve all", catpm.resolve_all_errors_path, method: :post, class: "btn" %></span>
19
19
  <% end %>
20
20
  </div>
21
21
  <% end %>
@@ -17,8 +17,7 @@
17
17
  <span>Sample #<%= @sample.id %></span>
18
18
  </div>
19
19
  <%= button_to "Delete Sample", catpm.sample_path(@sample),
20
- method: :delete, class: "btn btn-danger",
21
- data: { confirm: "Delete this sample? This cannot be undone." } %>
20
+ method: :delete, class: "btn btn-danger" %>
22
21
  </div>
23
22
 
24
23
  <%# ─── Request Info Bar ─── %>
@@ -19,6 +19,8 @@ module Catpm
19
19
  metadata = build_http_metadata(payload)
20
20
 
21
21
  req_segments = Thread.current[:catpm_request_segments]
22
+ instrumented = !req_segments.nil?
23
+
22
24
  if req_segments
23
25
  segment_data = req_segments.to_h
24
26
 
@@ -28,8 +30,17 @@ module Catpm
28
30
 
29
31
  # Segment summary is always needed for bucket metadata aggregation
30
32
  segment_data[:segment_summary].each { |k, v| metadata[k] = v }
33
+ else
34
+ # Non-instrumented request — compute duration from thread-local start time
35
+ request_start = Thread.current[:catpm_request_start]
36
+ if request_start
37
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_start) * 1000.0
38
+ end
31
39
  end
32
40
 
41
+ # Track instrumented count for correct dashboard averaging
42
+ metadata[:_instrumented] = 1 if instrumented
43
+
33
44
  # Early sampling decision — only build heavy context for sampled events
34
45
  operation = payload[:method] || 'GET'
35
46
  sample_type = early_sample_type(
@@ -37,9 +48,15 @@ module Catpm
37
48
  duration: duration,
38
49
  kind: :http,
39
50
  target: target,
40
- operation: operation
51
+ operation: operation,
52
+ instrumented: instrumented
41
53
  )
42
54
 
55
+ # Slow spike detection: force instrument next request for this endpoint
56
+ if !instrumented && (payload[:exception] || duration >= Catpm.config.slow_threshold_for(:http))
57
+ trigger_force_instrument(kind: :http, target: target, operation: operation)
58
+ end
59
+
43
60
  if sample_type
44
61
  context = build_http_context(payload)
45
62
 
@@ -224,20 +241,30 @@ module Catpm
224
241
  return if Catpm.config.ignored?(target)
225
242
 
226
243
  metadata = (metadata || {}).dup
244
+ instrumented = !req_segments.nil?
227
245
 
228
246
  if req_segments
229
247
  segment_data = req_segments.to_h
230
248
  segment_data[:segment_summary]&.each { |k, v| metadata[k] = v }
231
249
  end
232
250
 
251
+ # Track instrumented count for correct dashboard averaging
252
+ metadata[:_instrumented] = 1 if instrumented
253
+
233
254
  sample_type = early_sample_type(
234
255
  error: error,
235
256
  duration: duration,
236
257
  kind: kind,
237
258
  target: target,
238
- operation: operation
259
+ operation: operation,
260
+ instrumented: instrumented
239
261
  )
240
262
 
263
+ # Slow spike detection: force instrument next request for this endpoint
264
+ if !instrumented && (error || duration >= Catpm.config.slow_threshold_for(kind.to_sym))
265
+ trigger_force_instrument(kind: kind, target: target, operation: operation)
266
+ end
267
+
241
268
  if sample_type
242
269
  context = (context || {}).dup
243
270
 
@@ -341,6 +368,53 @@ module Catpm
341
368
  Catpm.buffer&.push(ev)
342
369
  end
343
370
 
371
+ def process_checkpoint(kind:, target:, operation:, context:, metadata:, checkpoint_data:, request_start:)
372
+ return unless Catpm.enabled?
373
+
374
+ segments = checkpoint_data[:segments].dup
375
+ collapse_code_wrappers(segments)
376
+
377
+ duration_so_far = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_start) * 1000.0
378
+
379
+ # Inject root request segment
380
+ root_segment = {
381
+ type: 'request',
382
+ detail: "#{operation.presence || kind} #{target}",
383
+ duration: duration_so_far.round(2),
384
+ offset: 0.0
385
+ }
386
+ segments.each do |seg|
387
+ if seg.key?(:parent_index)
388
+ seg[:parent_index] += 1
389
+ else
390
+ seg[:parent_index] = 0
391
+ end
392
+ end
393
+ segments.unshift(root_segment)
394
+
395
+ checkpoint_context = (context || {}).dup
396
+ checkpoint_context[:segments] = segments
397
+ checkpoint_context[:segment_summary] = checkpoint_data[:summary]
398
+ checkpoint_context[:segments_capped] = checkpoint_data[:overflow]
399
+ checkpoint_context[:partial] = true
400
+ checkpoint_context[:checkpoint_number] = checkpoint_data[:checkpoint_number]
401
+ checkpoint_context = scrub(checkpoint_context)
402
+
403
+ ev = Event.new(
404
+ kind: kind,
405
+ target: target,
406
+ operation: operation.to_s,
407
+ duration: duration_so_far,
408
+ started_at: Time.current,
409
+ status: 200,
410
+ context: checkpoint_context,
411
+ sample_type: 'random',
412
+ metadata: (metadata || {}).dup.merge(checkpoint_data[:summary] || {})
413
+ )
414
+
415
+ Catpm.buffer&.push(ev)
416
+ end
417
+
344
418
  def process_custom(name:, duration:, metadata: {}, error: nil, context: {})
345
419
  return unless Catpm.enabled?
346
420
  return if Catpm.config.ignored?(name)
@@ -361,7 +435,64 @@ module Catpm
361
435
  Catpm.buffer&.push(ev)
362
436
  end
363
437
 
364
- private
438
+ # --- Pre-sampling: decide BEFORE request whether to instrument ---
439
+
440
+ # For HTTP middleware where endpoint is unknown at start.
441
+ # Returns true if this request should get full instrumentation.
442
+ def should_instrument_request?
443
+ # Force after slow spike detection
444
+ if (@force_instrument_count || 0) > 0
445
+ @force_instrument_count -= 1
446
+ return true
447
+ end
448
+
449
+ rand(Catpm.config.random_sample_rate) == 0
450
+ end
451
+
452
+ # For track_request where endpoint is known at start.
453
+ # Filling phase ensures new endpoints get instrumented samples quickly.
454
+ def should_instrument?(kind, target, operation)
455
+ endpoint_key = [kind.to_s, target.to_s, (operation || '').to_s]
456
+
457
+ # Force after slow spike
458
+ if force_instrument_endpoints.delete(endpoint_key)
459
+ return true
460
+ end
461
+
462
+ # Filling phase — endpoint hasn't collected enough instrumented samples yet
463
+ max = Catpm.config.max_random_samples_per_endpoint
464
+ if max.nil? || instrumented_sample_counts[endpoint_key] < max
465
+ return true
466
+ end
467
+
468
+ rand(Catpm.config.random_sample_rate) == 0
469
+ end
470
+
471
+ # Called when a slow/error request had no instrumentation —
472
+ # forces the NEXT request(s) to be fully instrumented.
473
+ def trigger_force_instrument(kind: nil, target: nil, operation: nil)
474
+ if kind && target
475
+ endpoint_key = [kind.to_s, target.to_s, (operation || '').to_s]
476
+ force_instrument_endpoints[endpoint_key] = true
477
+ end
478
+ @force_instrument_count = (@force_instrument_count || 0) + 1
479
+ end
480
+
481
+ def reset_sample_counts!
482
+ @instrumented_sample_counts = nil
483
+ @force_instrument_endpoints = nil
484
+ @force_instrument_count = nil
485
+ end
486
+
487
+ private
488
+
489
+ def force_instrument_endpoints
490
+ @force_instrument_endpoints ||= {}
491
+ end
492
+
493
+ def instrumented_sample_counts
494
+ @instrumented_sample_counts ||= Hash.new(0)
495
+ end
365
496
 
366
497
  # Remove near-zero-duration "code" spans that merely wrap a "controller" span.
367
498
  # This happens when CallTracer (TracePoint) captures a thin dispatch method
@@ -417,31 +548,30 @@ module Catpm
417
548
  end
418
549
 
419
550
  # Determine sample type at event creation time so only sampled events
420
- # carry full context in the buffer. Includes filling phase via
421
- # process-level counter (resets on restart — acceptable approximation).
422
- def early_sample_type(error:, duration:, kind:, target:, operation:)
551
+ # carry full context in the buffer.
552
+ #
553
+ # When instrumented: false, only error/slow get a sample_type
554
+ # non-instrumented normal requests just contribute duration/count.
555
+ # Filling counter only increments for instrumented requests so
556
+ # non-instrumented requests don't waste filling slots.
557
+ def early_sample_type(error:, duration:, kind:, target:, operation:, instrumented: true)
423
558
  return 'error' if error
424
559
  return 'slow' if duration >= Catpm.config.slow_threshold_for(kind.to_sym)
425
560
 
426
- # Filling phase: always sample until endpoint has enough random samples
561
+ # Non-instrumented requests have no segments skip sample creation
562
+ return nil unless instrumented
563
+
564
+ # Filling phase: always sample until endpoint has enough instrumented samples
427
565
  endpoint_key = [kind.to_s, target, operation.to_s]
428
- count = random_sample_counts[endpoint_key]
566
+ count = instrumented_sample_counts[endpoint_key]
429
567
  max_random = Catpm.config.max_random_samples_per_endpoint
430
568
  if max_random.nil? || count < max_random
431
- random_sample_counts[endpoint_key] = count + 1
569
+ instrumented_sample_counts[endpoint_key] = count + 1
432
570
  return 'random'
433
571
  end
434
572
 
435
- return 'random' if rand(Catpm.config.random_sample_rate) == 0
436
- nil
437
- end
438
-
439
- def random_sample_counts
440
- @random_sample_counts ||= Hash.new(0)
441
- end
442
-
443
- def reset_sample_counts!
444
- @random_sample_counts = nil
573
+ # Instrumented request was already chosen by dice roll at start — always sample
574
+ 'random'
445
575
  end
446
576
 
447
577
  def inject_gap_segments(segments, req_segments, gap, ctrl_idx, ctrl_seg)
@@ -43,6 +43,7 @@ module Catpm
43
43
  events_max_samples_per_name max_stack_samples_per_request
44
44
  max_error_detail_length max_fingerprint_app_frames
45
45
  max_fingerprint_gem_frames cleanup_batch_size caller_scan_depth
46
+ max_request_memory
46
47
  ].freeze
47
48
 
48
49
  (REQUIRED_NUMERIC + OPTIONAL_NUMERIC).each do |attr|
@@ -116,6 +117,7 @@ module Catpm
116
117
  @max_fingerprint_gem_frames = 3
117
118
  @cleanup_batch_size = 1_000
118
119
  @caller_scan_depth = 50
120
+ @max_request_memory = 2.megabytes
119
121
  @instrument_call_tree = false
120
122
  @show_untracked_segments = false
121
123
  end
@@ -12,14 +12,16 @@ module Catpm
12
12
  Catpm.flusher&.ensure_running!
13
13
 
14
14
  env['catpm.request_start'] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
+ Thread.current[:catpm_request_start] = env['catpm.request_start']
15
16
 
16
- if Catpm.config.instrument_segments
17
+ if Catpm.config.instrument_segments && Collector.should_instrument_request?
17
18
  use_sampler = Catpm.config.instrument_stack_sampler || Catpm.config.instrument_call_tree
18
19
  req_segments = RequestSegments.new(
19
20
  max_segments: Catpm.config.max_segments_per_request,
20
21
  request_start: env['catpm.request_start'],
21
22
  stack_sample: use_sampler,
22
- call_tree: Catpm.config.instrument_call_tree
23
+ call_tree: Catpm.config.instrument_call_tree,
24
+ memory_limit: Catpm.config.max_request_memory
23
25
  )
24
26
  env['catpm.segments'] = req_segments
25
27
  Thread.current[:catpm_request_segments] = req_segments
@@ -30,11 +32,10 @@ module Catpm
30
32
  record_exception(env, e)
31
33
  raise
32
34
  ensure
33
- if Catpm.config.instrument_segments
34
- req_segments&.stop_sampler
35
- req_segments&.release!
36
- Thread.current[:catpm_request_segments] = nil
37
- end
35
+ req_segments&.stop_sampler
36
+ req_segments&.release!
37
+ Thread.current[:catpm_request_segments] = nil
38
+ Thread.current[:catpm_request_start] = nil
38
39
  end
39
40
 
40
41
  private
@@ -5,9 +5,13 @@ module Catpm
5
5
  # Pre-computed symbol pairs — each type computed once per process lifetime.
6
6
  SUMMARY_KEYS = Hash.new { |h, k| h[k] = [:"#{k}_count", :"#{k}_duration"] }
7
7
 
8
- attr_reader :segments, :summary, :request_start
8
+ # Per-segment byte estimate: Hash overhead + typical keys (type, duration, detail, offset, source, parent_index)
9
+ SEGMENT_BASE_BYTES = Event::OBJECT_OVERHEAD + (6 * Event::HASH_ENTRY_SIZE)
10
+ SEGMENT_STRING_OVERHEAD = Event::OBJECT_OVERHEAD # per-string overhead in segment values
9
11
 
10
- def initialize(max_segments:, request_start: nil, stack_sample: false, call_tree: false)
12
+ attr_reader :segments, :summary, :request_start, :estimated_bytes, :checkpoint_count
13
+
14
+ def initialize(max_segments:, request_start: nil, stack_sample: false, call_tree: false, memory_limit: nil)
11
15
  @max_segments = max_segments
12
16
  @request_start = request_start || Process.clock_gettime(Process::CLOCK_MONOTONIC)
13
17
  @segments = []
@@ -16,6 +20,10 @@ module Catpm
16
20
  @span_stack = []
17
21
  @tracked_ranges = []
18
22
  @call_tree = call_tree
23
+ @memory_limit = memory_limit
24
+ @estimated_bytes = 0
25
+ @checkpoint_callback = nil
26
+ @checkpoint_count = 0
19
27
 
20
28
  if stack_sample
21
29
  @sampler = StackSampler.new(target_thread: Thread.current, request_start: @request_start, call_tree: call_tree)
@@ -23,6 +31,10 @@ module Catpm
23
31
  end
24
32
  end
25
33
 
34
+ def on_checkpoint(&block)
35
+ @checkpoint_callback = block
36
+ end
37
+
26
38
  def add(type:, duration:, detail:, source: nil, started_at: nil)
27
39
  type_key = type.to_sym
28
40
  count_key, dur_key = SUMMARY_KEYS[type_key]
@@ -50,6 +62,9 @@ module Catpm
50
62
  @segments[min_idx] = segment
51
63
  end
52
64
  end
65
+
66
+ @estimated_bytes += estimate_segment_bytes(segment)
67
+ maybe_checkpoint
53
68
  end
54
69
 
55
70
  def push_span(type:, detail:, started_at: nil)
@@ -64,6 +79,7 @@ module Catpm
64
79
  index = @segments.size
65
80
  @segments << segment
66
81
  @span_stack.push(index)
82
+ @estimated_bytes += estimate_segment_bytes(segment)
67
83
  index
68
84
  end
69
85
 
@@ -114,6 +130,7 @@ module Catpm
114
130
  @summary = {}
115
131
  @tracked_ranges = []
116
132
  @sampler = nil
133
+ @estimated_bytes = 0
117
134
  end
118
135
 
119
136
  def overflowed?
@@ -127,5 +144,78 @@ module Catpm
127
144
  segments_capped: @overflow
128
145
  }
129
146
  end
147
+
148
+ private
149
+
150
+ def estimate_segment_bytes(segment)
151
+ bytes = SEGMENT_BASE_BYTES
152
+ bytes += segment[:detail].bytesize + SEGMENT_STRING_OVERHEAD if segment[:detail]
153
+ bytes += segment[:type].bytesize + SEGMENT_STRING_OVERHEAD if segment[:type]
154
+ bytes += segment[:source].bytesize + SEGMENT_STRING_OVERHEAD if segment[:source]
155
+ bytes
156
+ end
157
+
158
+ def maybe_checkpoint
159
+ return unless @memory_limit && @estimated_bytes > @memory_limit && @checkpoint_callback
160
+
161
+ checkpoint_data = {
162
+ segments: @segments,
163
+ summary: @summary,
164
+ overflow: @overflow,
165
+ sampler_segments: @sampler ? sampler_segments_for_checkpoint : [],
166
+ checkpoint_number: @checkpoint_count
167
+ }
168
+
169
+ @checkpoint_count += 1
170
+ rebuild_after_checkpoint
171
+ @checkpoint_callback.call(checkpoint_data)
172
+ end
173
+
174
+ def sampler_segments_for_checkpoint
175
+ if @call_tree
176
+ result = @sampler&.to_call_tree(tracked_ranges: @tracked_ranges) || []
177
+ else
178
+ result = @sampler&.to_segments(tracked_ranges: @tracked_ranges) || []
179
+ end
180
+ @sampler&.clear_samples!
181
+ result
182
+ end
183
+
184
+ # After checkpoint: keep only active spans from @span_stack, reset everything else.
185
+ def rebuild_after_checkpoint
186
+ if @span_stack.any?
187
+ # Clone active spans with corrected indices
188
+ new_segments = []
189
+ old_to_new = {}
190
+
191
+ @span_stack.each do |old_idx|
192
+ seg = @segments[old_idx]
193
+ next unless seg
194
+
195
+ new_idx = new_segments.size
196
+ old_to_new[old_idx] = new_idx
197
+ new_segments << seg.dup
198
+ end
199
+
200
+ # Fix parent_index references in cloned spans
201
+ new_segments.each do |seg|
202
+ if seg.key?(:parent_index) && old_to_new.key?(seg[:parent_index])
203
+ seg[:parent_index] = old_to_new[seg[:parent_index]]
204
+ else
205
+ seg.delete(:parent_index)
206
+ end
207
+ end
208
+
209
+ @span_stack = @span_stack.filter_map { |old_idx| old_to_new[old_idx] }
210
+ @segments = new_segments
211
+ else
212
+ @segments = []
213
+ end
214
+
215
+ @summary = Hash.new(0)
216
+ @tracked_ranges = []
217
+ @overflow = false
218
+ @estimated_bytes = 0
219
+ end
130
220
  end
131
221
  end
data/lib/catpm/trace.rb CHANGED
@@ -76,15 +76,30 @@ module Catpm
76
76
  owns_segments = false
77
77
 
78
78
  if req_segments.nil? && config.instrument_segments
79
- use_sampler = config.instrument_stack_sampler || config.instrument_call_tree
80
- req_segments = RequestSegments.new(
81
- max_segments: config.max_segments_per_request,
82
- request_start: Process.clock_gettime(Process::CLOCK_MONOTONIC),
83
- stack_sample: use_sampler,
84
- call_tree: config.instrument_call_tree
85
- )
86
- Thread.current[:catpm_request_segments] = req_segments
87
- owns_segments = true
79
+ if Collector.should_instrument?(kind, target, operation)
80
+ use_sampler = config.instrument_stack_sampler || config.instrument_call_tree
81
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ req_segments = RequestSegments.new(
83
+ max_segments: config.max_segments_per_request,
84
+ request_start: start_time,
85
+ stack_sample: use_sampler,
86
+ call_tree: config.instrument_call_tree,
87
+ memory_limit: config.max_request_memory
88
+ )
89
+ Thread.current[:catpm_request_segments] = req_segments
90
+ owns_segments = true
91
+
92
+ if config.max_request_memory
93
+ req_segments.on_checkpoint do |checkpoint_data|
94
+ Collector.process_checkpoint(
95
+ kind: kind, target: target, operation: operation,
96
+ context: context, metadata: metadata,
97
+ checkpoint_data: checkpoint_data,
98
+ request_start: start_time
99
+ )
100
+ end
101
+ end
102
+ end
88
103
  end
89
104
 
90
105
  if req_segments
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.8.4'
4
+ VERSION = '0.9.0'
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.8.4
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''