catpm 0.10.0 → 0.10.1

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: '0283e922a2169d04e59405a273250c1fd226b4b72731f01ba6f6495b6a216ce2'
4
- data.tar.gz: 5af58e9875ee9ce18afc02816e79d94cb025025218b78695e560a7a662ce2953
3
+ metadata.gz: 5dab2a97464afff0ad59e3913f7f1aca84505d3ca109966a611eed911d5bb1a7
4
+ data.tar.gz: aa4c892ce8a39fb2ba5ceacf1aef5f1b9c6c867a682536f67c895d887d93607b
5
5
  SHA512:
6
- metadata.gz: 0e73b3ea6819f8633104be971da72ad30c5de1b13bc4e0bece856d1e13b5987c705fb322f48c91622ae87d8acbd6b3d71ff0d2bb151939370a2685f4cf0ec7a7
7
- data.tar.gz: 988f46467fbf05398f71cefff884584fb1daf2b148500df100b0824049d2cc659e5e74038e65c8de30cec27b36aaa6d503c5267475b8b4c7313e35b4e208e375
6
+ metadata.gz: dbfa610b0355bf1f99b41c6be9476f622e4f756c75c79764abee15721bb2a729113425b6b81416e86e09f734d253301c36f8b0f577b8b15075c584d0e0728dae
7
+ data.tar.gz: ebaef872bc07283485e72ef2ccb5b9f14b836f581c66053ed0f7329621e83a5b7e3b58ef7e021cb43c5bdece61d20dda39867c85a73382bef055364424481f16
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  gem build catpm.gemspec
2
- gem push catpm-0.9.7.gem
2
+ gem push catpm-0.10.0.gem
3
3
 
4
4
  # Catpm
5
5
 
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Catpm
4
4
  class ApplicationController < ActionController::Base
5
+ SAMPLES_PER_PAGE = 15
6
+ SAMPLES_PER_PAGE_OPTIONS = [10, 15, 25, 50].freeze
7
+
5
8
  before_action :authenticate!
6
9
  rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
7
10
 
@@ -31,6 +34,11 @@ module Catpm
31
34
  end
32
35
  end
33
36
 
37
+ def sanitized_per_page(param_key = :per_page)
38
+ val = params[param_key].to_i
39
+ SAMPLES_PER_PAGE_OPTIONS.include?(val) ? val : SAMPLES_PER_PAGE
40
+ end
41
+
34
42
  def remembered_range
35
43
  if params[:range].present?
36
44
  cookies[:catpm_range] = { value: params[:range], expires: 1.year.from_now }
@@ -81,9 +81,23 @@ module Catpm
81
81
  .joins(:bucket)
82
82
  .where(catpm_buckets: { kind: @kind, target: @target, operation: @operation })
83
83
 
84
+ @error_per_page = sanitized_per_page(:error_per_page)
85
+ @error_page = [params[:error_page].to_i, 1].max
86
+ @error_total = endpoint_samples.where(sample_type: 'error').count
87
+ @error_samples = endpoint_samples.where(sample_type: 'error').order(recorded_at: :desc)
88
+ .offset((@error_page - 1) * @error_per_page).limit(@error_per_page)
89
+
90
+ @slow_per_page = sanitized_per_page(:slow_per_page)
91
+ @slow_page = [params[:slow_page].to_i, 1].max
92
+ @slow_total = endpoint_samples.where(sample_type: 'slow').count
84
93
  @slow_samples = endpoint_samples.where(sample_type: 'slow').order(duration: :desc)
94
+ .offset((@slow_page - 1) * @slow_per_page).limit(@slow_per_page)
95
+
96
+ @samples_per_page = sanitized_per_page(:samples_per_page)
97
+ @samples_page = [params[:samples_page].to_i, 1].max
98
+ @samples_total = endpoint_samples.where(sample_type: 'random').count
85
99
  @samples = endpoint_samples.where(sample_type: 'random').order(recorded_at: :desc)
86
- @error_samples = endpoint_samples.where(sample_type: 'error').order(recorded_at: :desc)
100
+ .offset((@samples_page - 1) * @samples_per_page).limit(@samples_per_page)
87
101
 
88
102
  @pref = Catpm::EndpointPref.find_by(kind: @kind, target: @target, operation: @operation)
89
103
  @active_error_count = Catpm::ErrorRecord.unresolved.count
@@ -38,23 +38,26 @@ module Catpm
38
38
 
39
39
  @range, period, bucket_seconds = helpers.parse_range(remembered_range)
40
40
 
41
- # Samples table: 20 most recent linked by fingerprint
42
- @samples = Catpm::Sample.where(error_fingerprint: @error.fingerprint)
43
- .order(recorded_at: :desc)
44
- .limit(Catpm.config.max_error_samples_per_fingerprint)
41
+ # Samples table: linked by fingerprint
42
+ samples_scope = Catpm::Sample.where(error_fingerprint: @error.fingerprint)
43
+ .order(recorded_at: :desc)
45
44
 
46
45
  # Fallback: match error samples by recorded_at from contexts
47
- if @samples.empty? && @contexts.any?
46
+ if !samples_scope.exists? && @contexts.any?
48
47
  occurred_times = @contexts.filter_map { |c|
49
48
  Time.parse(c['occurred_at'] || c[:occurred_at]) rescue nil
50
49
  }
51
50
  if occurred_times.any?
52
- @samples = Catpm::Sample.where(sample_type: 'error', kind: @error.kind, recorded_at: occurred_times)
53
- .order(recorded_at: :desc)
54
- .limit(Catpm.config.max_error_samples_per_fingerprint)
51
+ samples_scope = Catpm::Sample.where(sample_type: 'error', kind: @error.kind, recorded_at: occurred_times)
52
+ .order(recorded_at: :desc)
55
53
  end
56
54
  end
57
55
 
56
+ @samples_per_page = sanitized_per_page
57
+ @samples_page = [params[:page].to_i, 1].max
58
+ @samples_total = samples_scope.count
59
+ @samples = samples_scope.offset((@samples_page - 1) * @samples_per_page).limit(@samples_per_page)
60
+
58
61
  # Chart from occurrence_buckets (multi-resolution, no dependency on samples)
59
62
  ob = @error.parsed_occurrence_buckets
60
63
 
@@ -114,7 +114,11 @@ module Catpm
114
114
  @chart_times = 60.times.map { |i| Time.at(now_slot - (59 - i) * bucket_seconds).strftime('%H:%M') }
115
115
 
116
116
  # Recent samples
117
- @samples = Catpm::EventSample.by_name(@name).order(recorded_at: :desc).limit(Catpm.config.events_max_samples_per_name)
117
+ samples_scope = Catpm::EventSample.by_name(@name).order(recorded_at: :desc)
118
+ @samples_per_page = sanitized_per_page
119
+ @samples_page = [params[:page].to_i, 1].max
120
+ @samples_total = samples_scope.count
121
+ @samples = samples_scope.offset((@samples_page - 1) * @samples_per_page).limit(@samples_per_page)
118
122
 
119
123
  @pref = Catpm::EventPref.find_by(name: @name)
120
124
  @active_error_count = Catpm::ErrorRecord.unresolved.count
@@ -78,6 +78,7 @@ module Catpm
78
78
 
79
79
  # ── Sampling ──
80
80
  random_sample_rate: { group: 'Sampling', label: 'Random Sample Rate', desc: '1-in-N requests are sampled randomly for detailed traces', fmt: :one_in_n },
81
+ always_sample_targets: { group: 'Sampling', label: 'Always Sample Targets', desc: 'Endpoint patterns that are always sampled regardless of random rate', fmt: :list },
81
82
  max_random_samples_per_endpoint: { group: 'Sampling', label: 'Max Random / Endpoint', desc: 'Random samples retained per endpoint', fmt: :nullable_int },
82
83
  max_slow_samples_per_endpoint: { group: 'Sampling', label: 'Max Slow / Endpoint', desc: 'Slow samples retained per endpoint', fmt: :nullable_int },
83
84
  max_error_samples_per_fingerprint: { group: 'Sampling', label: 'Max Error / Fingerprint', desc: 'Error samples retained per error fingerprint', fmt: :nullable_int },
@@ -308,7 +309,7 @@ module Catpm
308
309
  new_dir = active && current_dir == 'asc' ? 'desc' : 'asc'
309
310
  arrow = active ? (current_dir == 'asc' ? ' &#9650;' : ' &#9660;') : ''
310
311
  params_hash = extra_params.merge(sort: column, dir: new_dir)
311
- url = '?' + params_hash.map { |k, v| "#{k}=#{v}" }.join('&')
312
+ url = '?' + Rack::Utils.build_query(params_hash)
312
313
  %(<a href="#{url}" class="sort-link#{active ? ' active' : ''}">#{label}#{arrow}</a>).html_safe
313
314
  end
314
315
 
@@ -350,31 +351,69 @@ module Catpm
350
351
  (span / 60.0).ceil
351
352
  end
352
353
 
353
- def pagination_nav(current_page, total_count, per_page, extra_params: {})
354
+ def pagination_nav(current_page, total_count, per_page, extra_params: {}, page_param: :page, anchor: nil)
354
355
  total_pages = (total_count.to_f / per_page).ceil
355
356
  return '' if total_pages <= 1
356
357
 
357
- prev_params = extra_params.merge(page: current_page - 1)
358
- next_params = extra_params.merge(page: current_page + 1)
359
- prev_url = '?' + prev_params.compact.map { |k, v| "#{k}=#{v}" }.join('&')
360
- next_url = '?' + next_params.compact.map { |k, v| "#{k}=#{v}" }.join('&')
358
+ fragment = anchor ? "##{anchor}" : ''
359
+
360
+ prev_url = '?' + Rack::Utils.build_query(extra_params.merge(page_param => current_page - 1).compact) + fragment
361
+ next_url = '?' + Rack::Utils.build_query(extra_params.merge(page_param => current_page + 1).compact) + fragment
361
362
 
362
363
  html = +'<div class="pagination">'
363
364
  if current_page > 1
364
- html << %(<a href="#{prev_url}" class="btn">← Previous</a>)
365
+ html << %(<a href="#{prev_url}" class="page-btn">&lsaquo;</a>)
365
366
  else
366
- html << '<span class="btn" style="opacity:0.3;cursor:default">← Previous</span>'
367
+ html << '<span class="page-btn disabled">&lsaquo;</span>'
367
368
  end
368
- html << %(<span class="pagination-info">Page #{current_page} of #{total_pages}</span>)
369
+ html << %(<span class="pagination-info">#{current_page} / #{total_pages}</span>)
369
370
  if current_page < total_pages
370
- html << %(<a href="#{next_url}" class="btn">Next →</a>)
371
+ html << %(<a href="#{next_url}" class="page-btn">&rsaquo;</a>)
371
372
  else
372
- html << '<span class="btn" style="opacity:0.3;cursor:default">Next →</span>'
373
+ html << '<span class="page-btn disabled">&rsaquo;</span>'
373
374
  end
374
375
  html << '</div>'
375
376
  html.html_safe
376
377
  end
377
378
 
379
+ def samples_pagination(current_page, total_count, per_page, extra_params: {}, page_param: :page, anchor: nil, storage_key:, per_page_param:)
380
+ total_pages = (total_count.to_f / per_page).ceil
381
+ fragment = anchor ? "##{anchor}" : ''
382
+
383
+ html = +'<div class="pagination">'
384
+
385
+ if total_pages > 1
386
+ prev_url = '?' + Rack::Utils.build_query(extra_params.merge(page_param => current_page - 1).compact) + fragment
387
+ next_url = '?' + Rack::Utils.build_query(extra_params.merge(page_param => current_page + 1).compact) + fragment
388
+
389
+ if current_page > 1
390
+ html << %(<a href="#{prev_url}" class="page-btn">&lsaquo;</a>)
391
+ else
392
+ html << '<span class="page-btn disabled">&lsaquo;</span>'
393
+ end
394
+ html << %(<span class="pagination-info">#{current_page} / #{total_pages}</span>)
395
+ if current_page < total_pages
396
+ html << %(<a href="#{next_url}" class="page-btn">&rsaquo;</a>)
397
+ else
398
+ html << '<span class="page-btn disabled">&rsaquo;</span>'
399
+ end
400
+ end
401
+
402
+ # Per-page select — right-aligned
403
+ html << '<select class="per-page-select" data-storage-key="'
404
+ html << ERB::Util.html_escape(storage_key)
405
+ html << '" data-page-param="' << ERB::Util.html_escape(page_param)
406
+ html << '" data-per-page-param="' << ERB::Util.html_escape(per_page_param) << '">'
407
+ Catpm::ApplicationController::SAMPLES_PER_PAGE_OPTIONS.each do |opt|
408
+ sel = opt == per_page ? ' selected' : ''
409
+ html << %(<option value="#{opt}"#{sel}>#{opt}</option>)
410
+ end
411
+ html << '</select>'
412
+
413
+ html << '</div>'
414
+ html.html_safe
415
+ end
416
+
378
417
  def trend_indicator(error)
379
418
  return '' unless error.last_occurred_at
380
419
  if error.last_occurred_at > 1.hour.ago
@@ -1,4 +1,5 @@
1
- <h2><%= title %> (<%= samples.size %>)</h2>
1
+ <% anchor_id = "samples-#{page_param.to_s.delete_suffix('_page')}" %>
2
+ <h2 id="<%= anchor_id %>"><%= title %> (<%= total_count %>)</h2>
2
3
  <% if samples.any? %>
3
4
  <div class="table-scroll">
4
5
  <table>
@@ -36,6 +37,8 @@
36
37
  </tbody>
37
38
  </table>
38
39
  </div>
40
+ <% base_params = request.query_parameters.symbolize_keys.except(page_param.to_sym) %>
41
+ <%= samples_pagination(page, total_count, per_page, extra_params: base_params, page_param: page_param.to_sym, anchor: anchor_id, storage_key: storage_key, per_page_param: per_page_param) %>
39
42
  <% else %>
40
43
  <div class="empty-state">
41
44
  <div class="empty-hint"><%= empty_msg %></div>
@@ -147,6 +147,12 @@
147
147
  </div>
148
148
  <% end %>
149
149
 
150
- <%= render "catpm/endpoints/sample_table", title: "Error Requests", samples: @error_samples, empty_msg: "No errors for this endpoint." %>
151
- <%= render "catpm/endpoints/sample_table", title: "Slowest Requests", samples: @slow_samples, empty_msg: "No slow requests captured yet." %>
152
- <%= render "catpm/endpoints/sample_table", title: "Sample Requests", samples: @samples, empty_msg: "No samples captured yet." %>
150
+ <%= render "catpm/endpoints/sample_table", title: "Error Requests", samples: @error_samples, empty_msg: "No errors for this endpoint.",
151
+ page: @error_page, total_count: @error_total, per_page: @error_per_page,
152
+ page_param: "error_page", per_page_param: "error_per_page", storage_key: "catpm_per_page_errors" %>
153
+ <%= render "catpm/endpoints/sample_table", title: "Slowest Requests", samples: @slow_samples, empty_msg: "No slow requests captured yet.",
154
+ page: @slow_page, total_count: @slow_total, per_page: @slow_per_page,
155
+ page_param: "slow_page", per_page_param: "slow_per_page", storage_key: "catpm_per_page_slow" %>
156
+ <%= render "catpm/endpoints/sample_table", title: "Sample Requests", samples: @samples, empty_msg: "No samples captured yet.",
157
+ page: @samples_page, total_count: @samples_total, per_page: @samples_per_page,
158
+ page_param: "samples_page", per_page_param: "samples_per_page", storage_key: "catpm_per_page_requests" %>
@@ -94,8 +94,8 @@
94
94
  </div>
95
95
 
96
96
  <%# ─── Samples ─── %>
97
- <% if @samples.any? %>
98
- <h2>Recent Samples</h2>
97
+ <% if @samples.any? || @samples_page > 1 %>
98
+ <h2 id="samples-error">Recent Samples (<%= @samples_total %>)</h2>
99
99
  <%= section_description("Linked request samples for this error. Click to view full details.") %>
100
100
  <div class="table-scroll">
101
101
  <table>
@@ -129,6 +129,8 @@
129
129
  </tbody>
130
130
  </table>
131
131
  </div>
132
+ <% base_params = request.query_parameters.symbolize_keys.except(:page) %>
133
+ <%= samples_pagination(@samples_page, @samples_total, @samples_per_page, extra_params: base_params, anchor: "samples-error", storage_key: "catpm_per_page_errors", per_page_param: "per_page") %>
132
134
  <% end %>
133
135
 
134
136
  <%# ─── Legacy Occurrences (for errors without linked samples) ─── %>
@@ -55,7 +55,7 @@
55
55
  <% end %>
56
56
 
57
57
  <%# ─── Recent Samples ─── %>
58
- <h2>Recent Samples</h2>
58
+ <h2 id="samples-events">Recent Samples (<%= @samples_total %>)</h2>
59
59
  <%= section_description("Most recent event payloads captured.") %>
60
60
 
61
61
  <% if @samples.any? %>
@@ -99,6 +99,8 @@
99
99
  <% end %>
100
100
  </tbody>
101
101
  </table>
102
+ <% base_params = request.query_parameters.symbolize_keys.except(:page) %>
103
+ <%= samples_pagination(@samples_page, @samples_total, @samples_per_page, extra_params: base_params, anchor: "samples-events", storage_key: "catpm_per_page_requests", per_page_param: "per_page") %>
102
104
  <% else %>
103
105
  <div class="empty-state">
104
106
  <div class="empty-title">No samples recorded</div>
@@ -183,8 +183,12 @@
183
183
  .empty-state .empty-hint { font-size: 13px; }
184
184
 
185
185
  /* ─── Pagination ─── */
186
- .pagination { display: flex; align-items: center; gap: 12px; margin: 16px 0; }
187
- .pagination-info { font-size: 13px; color: var(--text-2); }
186
+ .pagination { display: flex; align-items: center; gap: 6px; margin: 8px 0; font-size: 12px; }
187
+ .pagination-info { color: var(--text-2); min-width: 32px; text-align: center; }
188
+ .page-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: 1px solid var(--border); border-radius: 4px; color: var(--text-1); font-size: 14px; line-height: 1; }
189
+ .page-btn:hover { background: var(--bg-1); color: var(--text-0); text-decoration: none; }
190
+ .page-btn.disabled { opacity: 0.3; cursor: default; pointer-events: none; }
191
+ .per-page-select { margin-left: 6px; padding: 2px 4px; border: 1px solid var(--border); border-radius: 4px; font-size: 12px; background: var(--bg-0); color: var(--text-1); cursor: pointer; }
188
192
 
189
193
  /* ─── Code / Backtrace ─── */
190
194
  .source { color: var(--green); font-size: 12px; font-family: var(--font-mono); }
@@ -602,6 +606,46 @@
602
606
  /* Close menu on outside click */
603
607
  closeActionMenu();
604
608
  });
609
+
610
+ /* ─── Per-Page Selector: persist in localStorage, update URL ─── */
611
+ document.addEventListener('DOMContentLoaded', function() {
612
+ var selects = document.querySelectorAll('.per-page-select');
613
+ var urlParams = new URLSearchParams(window.location.search);
614
+ var needsRedirect = false;
615
+
616
+ // Batch: apply all stored per_page values missing from URL
617
+ selects.forEach(function(sel) {
618
+ var storageKey = sel.dataset.storageKey;
619
+ var perPageParam = sel.dataset.perPageParam;
620
+ var pageParam = sel.dataset.pageParam;
621
+ if (!urlParams.has(perPageParam) && storageKey) {
622
+ var stored = localStorage.getItem(storageKey);
623
+ if (stored && stored !== sel.value) {
624
+ urlParams.set(perPageParam, stored);
625
+ urlParams.delete(pageParam);
626
+ needsRedirect = true;
627
+ }
628
+ }
629
+ });
630
+ if (needsRedirect) { window.location.search = urlParams.toString(); return; }
631
+
632
+ // Change handler: persist and reload
633
+ selects.forEach(function(sel) {
634
+ sel.addEventListener('change', function() {
635
+ var storageKey = sel.dataset.storageKey;
636
+ if (storageKey) localStorage.setItem(storageKey, sel.value);
637
+ var params = new URLSearchParams(window.location.search);
638
+ params.set(sel.dataset.perPageParam, sel.value);
639
+ params.delete(sel.dataset.pageParam);
640
+ // Find heading anchor above this pagination bar
641
+ var el = sel.closest('.pagination');
642
+ while (el && !el.id) el = el.previousElementSibling;
643
+ if (!el) { el = sel.closest('.pagination')?.parentElement; while (el && !el.id) el = el.previousElementSibling; }
644
+ var hash = el?.id ? '#' + el.id : '';
645
+ window.location.href = window.location.pathname + '?' + params.toString() + hash;
646
+ });
647
+ });
648
+ });
605
649
  </script>
606
650
  </body>
607
651
  </html>
@@ -122,6 +122,7 @@ module Catpm
122
122
  seg[:parent_index] = tree_parent ? (tree_parent + base_idx) : (ctrl_idx || 0)
123
123
  segments << seg
124
124
  end
125
+ reparent_under_call_tree(segments, ctrl_idx)
125
126
  end
126
127
  end
127
128
 
@@ -299,6 +300,7 @@ module Catpm
299
300
  seg[:parent_index] = tree_parent ? (tree_parent + base_idx) : (ctrl_idx || 0)
300
301
  segments << seg
301
302
  end
303
+ reparent_under_call_tree(segments, ctrl_idx)
302
304
  end
303
305
  end
304
306
 
@@ -434,6 +436,45 @@ module Catpm
434
436
 
435
437
  private
436
438
 
439
+ # Re-parent non-code segments (sql, cache, etc.) under call tree code segments
440
+ # when their offset falls within the code segment's time range.
441
+ # This gives proper nesting: code → sql, instead of both being siblings under controller.
442
+ def reparent_under_call_tree(segments, ctrl_idx)
443
+ # Build index of code segments with their time ranges: [index, offset, end]
444
+ code_nodes = []
445
+ segments.each_with_index do |seg, i|
446
+ next unless seg[:type] == 'code' && seg[:offset] && seg[:duration]
447
+ code_nodes << [i, seg[:offset].to_f, seg[:offset].to_f + seg[:duration].to_f]
448
+ end
449
+ return if code_nodes.empty?
450
+
451
+ segments.each_with_index do |seg, i|
452
+ # Only reparent direct children of controller that aren't code segments
453
+ next if seg[:type] == 'code' || seg[:type] == 'controller' || seg[:type] == 'request'
454
+ next unless seg[:parent_index] == ctrl_idx
455
+ next unless seg[:offset]
456
+
457
+ seg_offset = seg[:offset].to_f
458
+
459
+ # Find the innermost (deepest-nested) code node that contains this segment.
460
+ # Innermost = the one with the smallest duration among all containing nodes.
461
+ best_idx = nil
462
+ best_dur = Float::INFINITY
463
+
464
+ code_nodes.each do |code_i, code_start, code_end|
465
+ next unless seg_offset >= code_start && seg_offset < code_end
466
+
467
+ dur = code_end - code_start
468
+ if dur < best_dur
469
+ best_dur = dur
470
+ best_idx = code_i
471
+ end
472
+ end
473
+
474
+ seg[:parent_index] = best_idx if best_idx
475
+ end
476
+ end
477
+
437
478
  # Remove near-zero-duration "code" spans that merely wrap a "controller" span.
438
479
  # This happens when CallTracer (TracePoint) captures a thin dispatch method
439
480
  # (e.g. Telegram::WebhookController#process) whose :return fires before the
@@ -14,7 +14,13 @@ module Catpm
14
14
  # Estimated per-request memory for instrumentation budget
15
15
  ESTIMATED_BYTES_PER_STACK_SAMPLE = 10_000 # ~10 KB: backtrace_locations array (~100 frames × ~100 bytes)
16
16
  ESTIMATED_BYTES_PER_SEGMENT = 500 # segment hash with strings
17
- DEFAULT_SEGMENTS_ESTIMATE = 200 # used when max_segments_per_request is nil
17
+
18
+ # Within per-request instrumentation budget, split between stack samples and segments
19
+ STACK_SAMPLES_BUDGET_SHARE = 0.7
20
+ SEGMENT_BUDGET_SHARE = 0.3
21
+ MIN_EFFECTIVE_STACK_SAMPLES = 10
22
+ MIN_EFFECTIVE_SEGMENTS = 20
23
+ MAX_EFFECTIVE_SEGMENTS = 1000
18
24
 
19
25
  # Boolean / non-numeric settings — plain attr_accessor
20
26
  attr_accessor :enabled,
@@ -152,6 +158,31 @@ module Catpm
152
158
  (budget_bytes / estimated_instrumented_request_bytes).clamp(1, 1000).to_i
153
159
  end
154
160
 
161
+ # Stack samples per request, auto-derived from max_memory when not explicitly set.
162
+ # Ensures per-request memory fits within the instrumentation budget.
163
+ def effective_max_stack_samples_per_request
164
+ if max_stack_samples_per_request
165
+ [max_stack_samples_per_request, StackSampler::HARD_SAMPLE_CAP].min
166
+ else
167
+ return StackSampler::HARD_SAMPLE_CAP unless instrument_stack_sampler || instrument_call_tree
168
+
169
+ per_request_budget = max_memory * 1_048_576 * INSTRUMENTATION_BUDGET_SHARE
170
+ stack_budget = per_request_budget * STACK_SAMPLES_BUDGET_SHARE
171
+ (stack_budget / ESTIMATED_BYTES_PER_STACK_SAMPLE).to_i.clamp(MIN_EFFECTIVE_STACK_SAMPLES, StackSampler::HARD_SAMPLE_CAP)
172
+ end
173
+ end
174
+
175
+ # Segments per request, auto-derived from max_memory when not explicitly set.
176
+ def effective_max_segments_per_request
177
+ return max_segments_per_request if max_segments_per_request
178
+
179
+ per_request_budget = max_memory * 1_048_576 * INSTRUMENTATION_BUDGET_SHARE
180
+ use_sampler = instrument_stack_sampler || instrument_call_tree
181
+ segment_share = use_sampler ? SEGMENT_BUDGET_SHARE : 0.9
182
+ segment_budget = per_request_budget * segment_share
183
+ (segment_budget / ESTIMATED_BYTES_PER_SEGMENT).to_i.clamp(MIN_EFFECTIVE_SEGMENTS, MAX_EFFECTIVE_SEGMENTS)
184
+ end
185
+
155
186
  def slow_threshold_for(kind)
156
187
  slow_threshold_per_kind.fetch(kind.to_sym, slow_threshold)
157
188
  end
@@ -184,12 +215,10 @@ module Catpm
184
215
  bytes = 0
185
216
 
186
217
  if instrument_stack_sampler || instrument_call_tree
187
- samples = max_stack_samples_per_request || StackSampler::HARD_SAMPLE_CAP
188
- bytes += samples * ESTIMATED_BYTES_PER_STACK_SAMPLE
218
+ bytes += effective_max_stack_samples_per_request * ESTIMATED_BYTES_PER_STACK_SAMPLE
189
219
  end
190
220
 
191
- segments = max_segments_per_request || DEFAULT_SEGMENTS_ESTIMATE
192
- bytes += segments * ESTIMATED_BYTES_PER_SEGMENT
221
+ bytes += effective_max_segments_per_request * ESTIMATED_BYTES_PER_SEGMENT
193
222
 
194
223
  [bytes, 50_000].max # minimum 50 KB estimate per request
195
224
  end
data/lib/catpm/flusher.rb CHANGED
@@ -243,6 +243,22 @@ module Catpm
243
243
  # Trim excess samples AFTER insert. Simpler and guaranteed correct —
244
244
  # no stale-cache issues when a single flush batch crosses the limit.
245
245
  def trim_samples(samples)
246
+ # Errors: per-fingerprint cap (keep newest within each fingerprint)
247
+ # Query DB for all fingerprints that exceed the limit — the current batch
248
+ # may not contain error samples, so this runs regardless of batch contents.
249
+ max_err_fp = Catpm.config.max_error_samples_per_fingerprint
250
+ if max_err_fp
251
+ over_limit_fps = Catpm::Sample
252
+ .where(sample_type: 'error')
253
+ .group(:error_fingerprint)
254
+ .having('COUNT(*) > ?', max_err_fp)
255
+ .pluck(:error_fingerprint)
256
+
257
+ over_limit_fps.each do |fp|
258
+ trim_by_column(Catpm::Sample.where(sample_type: 'error', error_fingerprint: fp), max_err_fp, :recorded_at)
259
+ end
260
+ end
261
+
246
262
  return if samples.empty?
247
263
 
248
264
  endpoint_keys = samples.map { |s| s[:bucket_key][0..2] }.uniq
@@ -258,16 +274,6 @@ module Catpm
258
274
  # Slow: keep highest-duration N
259
275
  max_slow = Catpm.config.max_slow_samples_per_endpoint
260
276
  trim_by_column(endpoint_scope.where(sample_type: 'slow'), max_slow, :duration) if max_slow
261
-
262
- end
263
-
264
- # Errors: per-fingerprint cap (keep newest within each fingerprint)
265
- max_err_fp = Catpm.config.max_error_samples_per_fingerprint
266
- if max_err_fp
267
- fps = samples.filter_map { |s| s[:error_fingerprint] }.uniq
268
- fps.each do |fp|
269
- trim_by_column(Catpm::Sample.where(sample_type: 'error', error_fingerprint: fp), max_err_fp, :recorded_at)
270
- end
271
277
  end
272
278
  end
273
279
 
@@ -17,7 +17,7 @@ module Catpm
17
17
  if Catpm.config.instrument_segments && Collector.should_instrument_request?
18
18
  use_sampler = Catpm.config.instrument_stack_sampler || Catpm.config.instrument_call_tree
19
19
  req_segments = RequestSegments.new(
20
- max_segments: Catpm.config.max_segments_per_request,
20
+ max_segments: Catpm.config.effective_max_segments_per_request,
21
21
  request_start: env['catpm.request_start'],
22
22
  stack_sample: use_sampler,
23
23
  call_tree: Catpm.config.instrument_call_tree
@@ -85,9 +85,7 @@ module Catpm
85
85
 
86
86
  # Called by SamplingLoop from the global thread
87
87
  def capture(now)
88
- max = Catpm.config.max_stack_samples_per_request
89
- cap = max ? [max, HARD_SAMPLE_CAP].min : HARD_SAMPLE_CAP
90
- return if @samples.size >= cap
88
+ return if @samples.size >= Catpm.config.effective_max_stack_samples_per_request
91
89
 
92
90
  locs = @target&.backtrace_locations
93
91
  @samples << [now, locs] if locs
data/lib/catpm/trace.rb CHANGED
@@ -87,7 +87,7 @@ module Catpm
87
87
  use_sampler = config.instrument_stack_sampler || config.instrument_call_tree
88
88
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
89
89
  req_segments = RequestSegments.new(
90
- max_segments: config.max_segments_per_request,
90
+ max_segments: config.effective_max_segments_per_request,
91
91
  request_start: start_time,
92
92
  stack_sample: use_sampler,
93
93
  call_tree: config.instrument_call_tree
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.0'
4
+ VERSION = '0.10.1'
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.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''