catpm 0.10.0 → 0.10.2

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: 3b2fde17350e1e195b3e7aa1cf8084b435d474c8d73aee1e83b2924cf8809a40
4
+ data.tar.gz: 253e27024b75ef3a5d1cb538b87275afbe373b9645b41d56ff751d86c3c27612
5
5
  SHA512:
6
- metadata.gz: 0e73b3ea6819f8633104be971da72ad30c5de1b13bc4e0bece856d1e13b5987c705fb322f48c91622ae87d8acbd6b3d71ff0d2bb151939370a2685f4cf0ec7a7
7
- data.tar.gz: 988f46467fbf05398f71cefff884584fb1daf2b148500df100b0824049d2cc659e5e74038e65c8de30cec27b36aaa6d503c5267475b8b4c7313e35b4e208e375
6
+ metadata.gz: dd586cd99584f463ef1c77e25c8c4e2c0920dc66115528c90a53af483e3601f1b1d4a554c9b80e0f403174ceb67fa5e3956d10a60d4c96c206f1669bd74d7c0d
7
+ data.tar.gz: 74fc43ecc58017374a011982100e1780ff44e7c068b36c814626be37e85ac517b658527e7021bd7ea2b500b499935b807745cb52a54989f1e23b413f093cd90a
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.1.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
 
@@ -200,6 +201,10 @@ module Catpm
200
201
 
201
202
  duration = event.duration
202
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?
203
208
 
204
209
  queue_wait = if job.respond_to?(:enqueued_at) && job.enqueued_at
205
210
  ((Time.current - job.enqueued_at.to_time) * 1000.0) rescue nil
@@ -207,21 +212,110 @@ module Catpm
207
212
 
208
213
  metadata = { queue_wait: queue_wait }.compact
209
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
+
210
222
  sample_type = early_sample_type(
211
223
  error: exception,
212
224
  duration: duration,
213
225
  kind: :job,
214
226
  target: target,
215
- operation: job.queue_name
227
+ operation: job.queue_name,
228
+ instrumented: instrumented
216
229
  )
217
230
 
218
- context = if sample_type
219
- {
231
+ context = nil
232
+ if sample_type
233
+ context = {
220
234
  job_class: target,
221
235
  job_id: job.job_id,
222
236
  queue: job.queue_name,
223
237
  attempts: job.executions
224
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)
225
319
  end
226
320
 
227
321
  ev = Event.new(
@@ -239,6 +333,12 @@ module Catpm
239
333
  )
240
334
 
241
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
242
342
  end
243
343
 
244
344
  def process_tracked(kind:, target:, operation:, duration:, context:, metadata:, error:, req_segments:)
@@ -299,6 +399,7 @@ module Catpm
299
399
  seg[:parent_index] = tree_parent ? (tree_parent + base_idx) : (ctrl_idx || 0)
300
400
  segments << seg
301
401
  end
402
+ reparent_under_call_tree(segments, ctrl_idx)
302
403
  end
303
404
  end
304
405
 
@@ -434,6 +535,102 @@ module Catpm
434
535
 
435
536
  private
436
537
 
538
+ # Re-parent non-code segments (sql, cache, etc.) under call tree code segments
539
+ # when their offset falls within the code segment's time range.
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).
546
+ def reparent_under_call_tree(segments, ctrl_idx)
547
+ # Build index of code segments with their time ranges: [index, offset, end]
548
+ code_nodes = []
549
+ segments.each_with_index do |seg, i|
550
+ next unless seg[:type] == 'code' && seg[:offset] && seg[:duration]
551
+ code_nodes << [i, seg[:offset].to_f, seg[:offset].to_f + seg[:duration].to_f]
552
+ end
553
+ return if code_nodes.empty?
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
+
561
+ segments.each_with_index do |seg, i|
562
+ # Only reparent direct children of controller that aren't code segments
563
+ next if seg[:type] == 'code' || seg[:type] == 'controller' || seg[:type] == 'request'
564
+ next unless seg[:parent_index] == ctrl_idx
565
+ next unless seg[:offset]
566
+
567
+ seg_offset = seg[:offset].to_f
568
+
569
+ # Find the innermost (deepest-nested) code node that contains this segment.
570
+ # Innermost = the one with the smallest duration among all containing nodes.
571
+ best_idx = nil
572
+ best_dur = Float::INFINITY
573
+
574
+ code_nodes.each do |code_i, code_start, code_end|
575
+ next unless seg_offset >= (code_start - sampling_tolerance_ms) && seg_offset < code_end
576
+
577
+ dur = code_end - code_start
578
+ if dur < best_dur
579
+ best_dur = dur
580
+ best_idx = code_i
581
+ end
582
+ end
583
+
584
+ seg[:parent_index] = best_idx if best_idx
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)
632
+ end
633
+
437
634
  # Remove near-zero-duration "code" spans that merely wrap a "controller" span.
438
635
  # This happens when CallTracer (TracePoint) captures a thin dispatch method
439
636
  # (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
@@ -51,6 +51,44 @@ 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
+ if Collector.should_instrument?(:job, target, job.queue_name)
65
+ use_sampler = Catpm.config.instrument_stack_sampler || Catpm.config.instrument_call_tree
66
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ req_segments = RequestSegments.new(
68
+ max_segments: Catpm.config.effective_max_segments_per_request,
69
+ request_start: start_time,
70
+ stack_sample: use_sampler,
71
+ call_tree: Catpm.config.instrument_call_tree
72
+ )
73
+ Thread.current[:catpm_request_segments] = req_segments
74
+
75
+ index = req_segments.push_span(type: :controller, detail: target, started_at: start_time)
76
+ payload[:_catpm_job_span_index] = index
77
+ payload[:_catpm_job_owns_segments] = true
78
+ end
79
+ end
80
+
81
+ def finish(_name, _id, payload)
82
+ return unless payload[:_catpm_job_owns_segments]
83
+
84
+ req_segments = Thread.current[:catpm_request_segments]
85
+ return unless req_segments
86
+
87
+ req_segments.pop_span(payload[:_catpm_job_span_index])
88
+ req_segments.stop_sampler
89
+ end
90
+ end
91
+
54
92
  IGNORED_SQL_NAMES = Set.new([
55
93
  'SCHEMA', 'EXPLAIN',
56
94
  'ActiveRecord::SchemaMigration Load',
@@ -65,6 +103,12 @@ module Catpm
65
103
  'process_action.action_controller', ControllerSpanSubscriber.new
66
104
  )
67
105
 
106
+ if Catpm.config.instrument_jobs
107
+ @job_span_subscriber = ActiveSupport::Notifications.subscribe(
108
+ 'perform.active_job', JobSpanSubscriber.new
109
+ )
110
+ end
111
+
68
112
  @sql_subscriber = ActiveSupport::Notifications.subscribe(
69
113
  'sql.active_record'
70
114
  ) do |event|
@@ -122,7 +166,7 @@ module Catpm
122
166
 
123
167
  def unsubscribe!
124
168
  [
125
- @controller_span_subscriber,
169
+ @controller_span_subscriber, @job_span_subscriber,
126
170
  @sql_subscriber, @instantiation_subscriber,
127
171
  @render_template_subscriber, @render_partial_subscriber,
128
172
  @cache_read_subscriber, @cache_write_subscriber,
@@ -131,6 +175,7 @@ module Catpm
131
175
  ActiveSupport::Notifications.unsubscribe(sub) if sub
132
176
  end
133
177
  @controller_span_subscriber = nil
178
+ @job_span_subscriber = nil
134
179
  @sql_subscriber = nil
135
180
  @instantiation_subscriber = nil
136
181
  @render_template_subscriber = nil
@@ -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.2'
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''