catpm 0.9.2 → 0.9.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: 7550489165d4ba6b3c476bd1f2637fc6e68eed8e3def5bd32420dfc0632ff2d7
4
- data.tar.gz: ec9ed224bcd90cd67835842385305146736ce634310b9b62eaf33997bf7e2ff8
3
+ metadata.gz: 10d81d0f093dc28f0f22a3bd9eda141ca89dfa44daacabf4fda1190c549681e2
4
+ data.tar.gz: 80c8cd25d825e3dc3eae16bf1fc58adddee4c5cfe5bf420ef1f663d0ca295d2e
5
5
  SHA512:
6
- metadata.gz: ad84a45f6d0b8b508c94a5a9d0d06b9671a7820fedd93eda19eeee070b41669e625a814a6a2fab6e69fa80ae83edbaa81edd9a8f20e915d921973ba8a6e70822
7
- data.tar.gz: 75cd050d65178c666e0369eecbc13c1fe1cb18f8d159f75c2078ecea7422e1844e4836c87320681d6b6d68718c9624f0b8abd67276888d28b09410265b43f8ad
6
+ metadata.gz: aafdfdedafaa9558c06da29ce3fc6c7af9e4f3120b7c1556b8e5162cbd781e72b1d8613720078e2ae2eb33e2d24d0304922c3de04cc622aa6df17602299e8ad5
7
+ data.tar.gz: 9f9eb35a16e182155b215323beafa593af8617de530dc36a993187ba8d2783b1b324855a783b27c7d998d97dd6e59d40b4449df852a4f1b70563ffa8009190b7
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  gem build catpm.gemspec
2
- gem push catpm-0.9.0.gem
2
+ gem push catpm-0.9.2.gem
3
3
 
4
4
  # Catpm
5
5
 
@@ -14,6 +14,10 @@ module Catpm
14
14
  scope :recent, ->(period = 1.hour) { where(recorded_at: period.ago..) }
15
15
  scope :for_error, ->(fingerprint) { where(error_fingerprint: fingerprint) }
16
16
 
17
+ def self.request_id_supported?
18
+ column_names.include?('request_id')
19
+ end
20
+
17
21
  def parsed_context
18
22
  case context
19
23
  when Hash then context
@@ -33,11 +33,13 @@ class CreateCatpmTables < ActiveRecord::Migration[8.0]
33
33
  t.float :duration, null: false
34
34
  t.json :context
35
35
  t.string :error_fingerprint, limit: 64
36
+ t.string :request_id, limit: 16
36
37
  end
37
38
 
38
39
  add_index :catpm_samples, :recorded_at, name: 'idx_catpm_samples_time'
39
40
  add_index :catpm_samples, [:kind, :recorded_at], name: 'idx_catpm_samples_kind_time'
40
41
  add_index :catpm_samples, :error_fingerprint, name: 'idx_catpm_samples_error_fp'
42
+ add_index :catpm_samples, :request_id, name: 'idx_catpm_samples_request_id'
41
43
 
42
44
  create_table :catpm_errors do |t|
43
45
  t.string :fingerprint, null: false, limit: 64
@@ -29,7 +29,7 @@ module Catpm
29
29
  bucket = bucket_map[sample_data[:bucket_key]]
30
30
  next unless bucket
31
31
 
32
- {
32
+ record = {
33
33
  bucket_id: bucket.id,
34
34
  kind: sample_data[:kind],
35
35
  sample_type: sample_data[:sample_type],
@@ -38,6 +38,8 @@ module Catpm
38
38
  context: sample_data[:context],
39
39
  error_fingerprint: sample_data[:error_fingerprint]
40
40
  }
41
+ record[:request_id] = sample_data[:request_id] if Catpm::Sample.request_id_supported?
42
+ record
41
43
  end
42
44
 
43
45
  Catpm::Sample.insert_all(records) if records.any?
@@ -60,11 +60,19 @@ module Catpm
60
60
  instrumented: instrumented
61
61
  )
62
62
 
63
- # Slow spike detection: force the NEXT HTTP request through middleware
64
- # to be fully instrumented (uses global counter for should_instrument_request?).
65
- # Skip if already handled by track_request (tracked_instrumented).
66
- if !instrumented && (payload[:exception] || duration >= Catpm.config.slow_threshold_for(:http))
67
- trigger_force_instrument
63
+ # Force the NEXT HTTP request to be fully instrumented when this one
64
+ # wasn't. Covers slow/error spikes and filling phase (new endpoints that
65
+ # haven't collected enough instrumented samples yet).
66
+ if !instrumented
67
+ if payload[:exception] || duration >= Catpm.config.slow_threshold_for(:http)
68
+ trigger_force_instrument
69
+ else
70
+ max = Catpm.config.max_random_samples_per_endpoint
71
+ if max
72
+ endpoint_key = ['http', target, operation]
73
+ trigger_force_instrument if instrumented_sample_counts[endpoint_key] < max
74
+ end
75
+ end
68
76
  end
69
77
 
70
78
  if sample_type
@@ -360,6 +368,8 @@ module Catpm
360
368
  context = nil
361
369
  end
362
370
 
371
+ request_id = req_segments&.request_id
372
+
363
373
  ev = Event.new(
364
374
  kind: kind,
365
375
  target: target,
@@ -372,13 +382,14 @@ module Catpm
372
382
  metadata: metadata,
373
383
  error_class: error&.class&.name,
374
384
  error_message: error&.message,
375
- backtrace: error&.backtrace
385
+ backtrace: error&.backtrace,
386
+ request_id: request_id
376
387
  )
377
388
 
378
389
  Catpm.buffer&.push(ev)
379
390
  end
380
391
 
381
- def process_checkpoint(kind:, target:, operation:, context:, metadata:, checkpoint_data:, request_start:)
392
+ def process_checkpoint(kind:, target:, operation:, context:, metadata:, checkpoint_data:, request_start:, request_id: nil)
382
393
  return unless Catpm.enabled?
383
394
 
384
395
  segments = checkpoint_data[:segments].dup
@@ -419,7 +430,8 @@ module Catpm
419
430
  status: 200,
420
431
  context: checkpoint_context,
421
432
  sample_type: 'random',
422
- metadata: (metadata || {}).dup.merge(checkpoint_data[:summary] || {})
433
+ metadata: (metadata || {}).dup.merge(checkpoint_data[:summary] || {}),
434
+ request_id: request_id
423
435
  )
424
436
 
425
437
  Catpm.buffer&.push(ev)
@@ -600,10 +612,12 @@ module Catpm
600
612
  # Determine sample type at event creation time so only sampled events
601
613
  # carry full context in the buffer.
602
614
  #
603
- # When instrumented: false, only error/slow get a sample_type
604
- # non-instrumented normal requests just contribute duration/count.
605
- # Filling counter only increments for instrumented requests so
606
- # non-instrumented requests don't waste filling slots.
615
+ # Non-instrumented requests never get a sample (they have no segments).
616
+ # Filling phase is handled by the caller via trigger_force_instrument,
617
+ # so the NEXT request gets full instrumentation with segments.
618
+ #
619
+ # Post-filling: non-instrumented requests just contribute duration/count
620
+ # to the bucket, no sample created.
607
621
  def early_sample_type(error:, duration:, kind:, target:, operation:, instrumented: true)
608
622
  # Errors: only create sample for instrumented requests (with segments).
609
623
  # Non-instrumented errors are still tracked in error_groups via
@@ -617,7 +631,9 @@ module Catpm
617
631
  # don't count towards filling phase (they have no segments).
618
632
  return 'slow' if is_slow && !instrumented
619
633
 
620
- # Non-instrumented requests have no segments — skip sample creation
634
+ # Non-instrumented requests have no segments — skip sample creation.
635
+ # Filling phase is handled by the caller via trigger_force_instrument
636
+ # so the NEXT request gets full instrumentation with segments.
621
637
  return nil unless instrumented
622
638
 
623
639
  # Count this instrumented request towards filling phase completion.
data/lib/catpm/event.rb CHANGED
@@ -9,14 +9,14 @@ module Catpm
9
9
 
10
10
  attr_accessor :kind, :target, :operation, :duration, :started_at,
11
11
  :metadata, :error_class, :error_message, :backtrace,
12
- :sample_type, :context, :status
12
+ :sample_type, :context, :status, :request_id
13
13
 
14
14
  EMPTY_HASH = {}.freeze
15
15
  private_constant :EMPTY_HASH
16
16
 
17
17
  def initialize(kind:, target:, operation: '', duration: 0.0, started_at: nil,
18
18
  metadata: nil, error_class: nil, error_message: nil, backtrace: nil,
19
- sample_type: nil, context: nil, status: nil)
19
+ sample_type: nil, context: nil, status: nil, request_id: nil)
20
20
  @kind = kind.to_s
21
21
  @target = target.to_s
22
22
  @operation = (operation || '').to_s
@@ -32,6 +32,7 @@ module Catpm
32
32
  @sample_type = sample_type
33
33
  @context = context
34
34
  @status = status
35
+ @request_id = request_id
35
36
  end
36
37
 
37
38
  def estimated_bytes
data/lib/catpm/flusher.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  module Catpm
4
4
  class Flusher
5
5
  ERROR_LOG_BACKTRACE_LINES = 5
6
+ PARTIAL_STALE_TIMEOUT = 600 # seconds — orphaned partial samples cleaned after 10 minutes
6
7
 
7
8
  attr_reader :running
8
9
 
@@ -181,7 +182,8 @@ module Catpm
181
182
  sample_type: sample_type,
182
183
  recorded_at: event.started_at,
183
184
  duration: event.duration,
184
- context: event.context || {}
185
+ context: event.context || {},
186
+ request_id: event.request_id
185
187
  }
186
188
  sample_hash[:error_fingerprint] = error_fp if error_fp
187
189
  samples << sample_hash
@@ -219,6 +221,8 @@ module Catpm
219
221
  b
220
222
  end
221
223
 
224
+ samples = merge_request_samples(samples)
225
+
222
226
  [ buckets, samples, error_groups.values ]
223
227
  end
224
228
 
@@ -346,6 +350,7 @@ module Catpm
346
350
  @last_cleanup_at = Time.now
347
351
  downsample_buckets
348
352
  cleanup_expired_data if Catpm.config.retention_period
353
+ cleanup_orphaned_partials
349
354
  Collector.reset_sample_counts!
350
355
  end
351
356
 
@@ -489,6 +494,136 @@ module Catpm
489
494
  combined.empty? ? nil : combined.serialize
490
495
  end
491
496
 
497
+ def merge_request_samples(samples)
498
+ return samples unless Catpm::Sample.request_id_supported?
499
+
500
+ by_request = {} # request_id => { partials: [], final: nil }
501
+ regular = []
502
+
503
+ samples.each do |s|
504
+ rid = s[:request_id]
505
+ if rid
506
+ entry = (by_request[rid] ||= { partials: [], final: nil })
507
+ if s[:context].is_a?(Hash) && s[:context][:partial]
508
+ entry[:partials] << s
509
+ else
510
+ entry[:final] = s
511
+ end
512
+ else
513
+ regular << s
514
+ end
515
+ end
516
+
517
+ merged = []
518
+ by_request.each do |rid, entry|
519
+ if entry[:final]
520
+ # Merge in-batch partials
521
+ if entry[:partials].any?
522
+ merge_checkpoint_contexts(
523
+ entry[:final][:context],
524
+ entry[:partials].map { |p| p[:context] }
525
+ )
526
+ end
527
+
528
+ # Merge cross-batch partials from DB
529
+ db_partials = Catpm::Sample.where(request_id: rid)
530
+ if db_partials.exists?
531
+ merge_checkpoint_contexts(
532
+ entry[:final][:context],
533
+ db_partials.map(&:parsed_context)
534
+ )
535
+ db_partials.delete_all
536
+ end
537
+
538
+ # Clear request_id so persisted final sample won't be treated as orphan
539
+ entry[:final][:request_id] = nil
540
+ merged << entry[:final]
541
+ else
542
+ # Only partials, no final yet — persist as-is
543
+ merged.concat(entry[:partials])
544
+ end
545
+ end
546
+
547
+ regular + merged
548
+ end
549
+
550
+ def merge_checkpoint_contexts(final_ctx, checkpoint_ctxs)
551
+ final_segments = final_ctx[:segments] || final_ctx['segments']
552
+ return unless final_segments
553
+
554
+ final_ctrl_idx = final_segments.index { |s|
555
+ (s[:type] || s['type']) == 'controller'
556
+ }
557
+
558
+ sorted = checkpoint_ctxs.sort_by { |c|
559
+ c[:checkpoint_number] || c['checkpoint_number'] || 0
560
+ }
561
+
562
+ sorted.each do |cp_ctx|
563
+ cp_segments = cp_ctx[:segments] || cp_ctx['segments'] || []
564
+
565
+ old_to_new = {}
566
+ kept = []
567
+
568
+ cp_segments.each_with_index do |seg, i|
569
+ seg_type = seg[:type] || seg['type']
570
+ next if seg_type == 'request'
571
+ next if seg_type == 'controller'
572
+ old_to_new[i] = final_segments.size + kept.size
573
+ kept << seg.dup
574
+ end
575
+
576
+ kept.each do |seg|
577
+ pi_key = seg.key?(:parent_index) ? :parent_index : 'parent_index'
578
+ pi = seg[pi_key]
579
+ next unless pi
580
+
581
+ if old_to_new.key?(pi)
582
+ seg[pi_key] = old_to_new[pi]
583
+ else
584
+ seg[pi_key] = final_ctrl_idx || 0
585
+ end
586
+ end
587
+
588
+ final_segments.concat(kept)
589
+
590
+ # Merge summary
591
+ cp_summary = cp_ctx[:segment_summary] || cp_ctx['segment_summary']
592
+ if cp_summary
593
+ use_symbols = final_ctx.key?(:segment_summary)
594
+ summary_key = use_symbols ? :segment_summary : 'segment_summary'
595
+ final_summary = final_ctx[summary_key] ||= {}
596
+ cp_summary.each do |k, v|
597
+ nk = use_symbols ? k.to_sym : k.to_s
598
+ final_summary[nk] = (final_summary[nk] || 0) + v.to_f
599
+ end
600
+ end
601
+
602
+ # Merge capped flag
603
+ capped_key = final_ctx.key?(:segments_capped) ? :segments_capped : 'segments_capped'
604
+ cp_capped = cp_ctx[:segments_capped] || cp_ctx['segments_capped']
605
+ final_ctx[capped_key] = true if cp_capped
606
+ end
607
+
608
+ # Clean up checkpoint markers
609
+ final_ctx.delete(:partial)
610
+ final_ctx.delete('partial')
611
+ final_ctx.delete(:request_id)
612
+ final_ctx.delete('request_id')
613
+ final_ctx.delete(:checkpoint_number)
614
+ final_ctx.delete('checkpoint_number')
615
+ end
616
+
617
+ def cleanup_orphaned_partials
618
+ return unless Catpm::Sample.request_id_supported?
619
+
620
+ Catpm::Sample.where.not(request_id: nil)
621
+ .where(recorded_at: ..PARTIAL_STALE_TIMEOUT.seconds.ago)
622
+ .delete_all
623
+ rescue => e
624
+ Catpm.config.error_handler&.call(e)
625
+ end
626
+
492
627
  def cleanup_expired_data
493
628
  cutoff = Catpm.config.retention_period.ago
494
629
  batch_size = Catpm.config.cleanup_batch_size
@@ -9,7 +9,7 @@ module Catpm
9
9
  SEGMENT_BASE_BYTES = Event::OBJECT_OVERHEAD + (6 * Event::HASH_ENTRY_SIZE)
10
10
  SEGMENT_STRING_OVERHEAD = Event::OBJECT_OVERHEAD # per-string overhead in segment values
11
11
 
12
- attr_reader :segments, :summary, :request_start, :estimated_bytes, :checkpoint_count
12
+ attr_reader :segments, :summary, :request_start, :estimated_bytes, :checkpoint_count, :request_id
13
13
 
14
14
  def initialize(max_segments:, request_start: nil, stack_sample: false, call_tree: false, memory_limit: nil)
15
15
  @max_segments = max_segments
@@ -24,6 +24,7 @@ module Catpm
24
24
  @estimated_bytes = 0
25
25
  @checkpoint_callback = nil
26
26
  @checkpoint_count = 0
27
+ @request_id = memory_limit ? SecureRandom.hex(8) : nil
27
28
 
28
29
  if stack_sample
29
30
  @sampler = StackSampler.new(target_thread: Thread.current, request_start: @request_start, call_tree: call_tree)
data/lib/catpm/trace.rb CHANGED
@@ -101,7 +101,8 @@ module Catpm
101
101
  kind: kind, target: target, operation: operation,
102
102
  context: context, metadata: metadata,
103
103
  checkpoint_data: checkpoint_data,
104
- request_start: start_time
104
+ request_start: start_time,
105
+ request_id: req_segments.request_id
105
106
  )
106
107
  end
107
108
  end
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.9.2'
4
+ VERSION = '0.9.3'
5
5
  end
@@ -42,6 +42,14 @@ namespace :catpm do
42
42
  puts '[catpm] catpm_errors.pinned already exists, skipping'
43
43
  end
44
44
 
45
+ unless connection.column_exists?(:catpm_samples, :request_id)
46
+ connection.add_column :catpm_samples, :request_id, :string, limit: 16
47
+ connection.add_index :catpm_samples, :request_id, name: 'idx_catpm_samples_request_id'
48
+ puts '[catpm] Added request_id column to catpm_samples'
49
+ else
50
+ puts '[catpm] catpm_samples.request_id already exists, skipping'
51
+ end
52
+
45
53
  unless connection.table_exists?(:catpm_event_prefs)
46
54
  connection.create_table :catpm_event_prefs do |t|
47
55
  t.string :name, null: false
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.9.2
4
+ version: 0.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''