catpm 0.9.7 → 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: 54937b58ef7d18fa437e232b7a660ac014737a6e716daed6e57ab7463dc38e27
4
- data.tar.gz: 76cfd9389ecb1f37794806353c2c56f1d7f799a9bf6f9e8c0c975c93b8423c53
3
+ metadata.gz: 5dab2a97464afff0ad59e3913f7f1aca84505d3ca109966a611eed911d5bb1a7
4
+ data.tar.gz: aa4c892ce8a39fb2ba5ceacf1aef5f1b9c6c867a682536f67c895d887d93607b
5
5
  SHA512:
6
- metadata.gz: a948c19294ca90dc60215f58e3d8f6fbdd377f4b62f468eba76678b223af37610d549d4a52ea7f42d7c6fec4ab93952bceeb6e19857ec9c67ecf601a4a1a9b51
7
- data.tar.gz: 298c9964d29d3fc9b2570720a7813b30adc5f21c9b7f75e49c288c9fc4f4cd65c4196c798dc05caeb7824ebefb2553cd82b6778a3f1e1a8eeaac4f3d893a703b
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.6.gem
2
+ gem push catpm-0.10.0.gem
3
3
 
4
4
  # Catpm
5
5
 
@@ -2,23 +2,43 @@
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!
9
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
10
+
11
+ def not_found
12
+ render 'catpm/shared/not_found', layout: 'catpm/application', status: :not_found
13
+ end
6
14
 
7
15
  private
8
16
 
17
+ def render_not_found
18
+ respond_to do |format|
19
+ format.html { render 'catpm/shared/record_not_found', layout: 'catpm/application', status: :not_found }
20
+ format.json { render json: { error: 'not_found' }, status: :not_found }
21
+ end
22
+ end
23
+
9
24
  def authenticate!
10
25
  if Catpm.config.access_policy
11
26
  unless Catpm.config.access_policy.call(request)
12
- render plain: "Unauthorized", status: :unauthorized
27
+ render plain: 'Unauthorized', status: :unauthorized
13
28
  end
14
29
  elsif Catpm.config.http_basic_auth_user.present? && Catpm.config.http_basic_auth_password.present?
15
- authenticate_or_request_with_http_basic("Catpm") do |username, password|
30
+ authenticate_or_request_with_http_basic('Catpm') do |username, password|
16
31
  ActiveSupport::SecurityUtils.secure_compare(username, Catpm.config.http_basic_auth_user) &
17
32
  ActiveSupport::SecurityUtils.secure_compare(password, Catpm.config.http_basic_auth_password)
18
33
  end
19
34
  end
20
35
  end
21
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
+
22
42
  def remembered_range
23
43
  if params[:range].present?
24
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>
@@ -0,0 +1,24 @@
1
+ <% content_for :head_extra do %>
2
+ <style>
3
+ body { display: flex; flex-direction: column; min-height: 100vh; }
4
+ .not-found-content { flex: 1; display: flex; align-items: center; justify-content: center; }
5
+ .not-found-inner { text-align: center; padding: 24px; }
6
+ .not-found-cat {
7
+ font-family: var(--font-mono); font-size: 14px; line-height: 1.3;
8
+ color: var(--accent); white-space: pre; margin-bottom: 20px; user-select: none;
9
+ }
10
+ .not-found-code { font-size: 13px; color: var(--text-2); font-weight: 500; letter-spacing: 1px; margin-bottom: 24px; }
11
+ .not-found-link { font-size: 13px; color: var(--accent); }
12
+ .not-found-link:hover { text-decoration: underline; }
13
+ </style>
14
+ <% end %>
15
+
16
+ <div class="not-found-content">
17
+ <div class="not-found-inner">
18
+ <div class="not-found-cat"> /\_/\
19
+ ( o.o )
20
+ > ^ <</div>
21
+ <div class="not-found-code">404</div>
22
+ <a href="<%= catpm.status_index_path %>" class="not-found-link">&larr; Dashboard</a>
23
+ </div>
24
+ </div>
@@ -0,0 +1,24 @@
1
+ <% content_for :head_extra do %>
2
+ <style>
3
+ body { display: flex; flex-direction: column; min-height: 100vh; }
4
+ .not-found-content { flex: 1; display: flex; align-items: center; justify-content: center; }
5
+ .not-found-inner { text-align: center; padding: 24px; }
6
+ .not-found-paws { font-size: 40px; color: var(--accent); margin-bottom: 20px; letter-spacing: 6px; }
7
+ .not-found-title { font-size: 15px; font-weight: 600; color: var(--text-0); margin-bottom: 6px; }
8
+ .not-found-hint { font-size: 13px; color: var(--text-2); max-width: 380px; margin: 0 auto 24px; line-height: 1.6; }
9
+ .not-found-link { font-size: 13px; color: var(--accent); }
10
+ .not-found-link:hover { text-decoration: underline; }
11
+ </style>
12
+ <% end %>
13
+
14
+ <div class="not-found-content">
15
+ <div class="not-found-inner">
16
+ <div class="not-found-paws">&#x1F43E;</div>
17
+ <div class="not-found-title">This record is no longer available</div>
18
+ <p class="not-found-hint">
19
+ catpm rotates older performance data to keep memory usage low.
20
+ This resource was likely purged during a routine cleanup.
21
+ </p>
22
+ <a href="<%= catpm.status_index_path %>" class="not-found-link">&larr; Dashboard</a>
23
+ </div>
24
+ </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>
data/config/routes.rb CHANGED
@@ -29,4 +29,5 @@ Catpm::Engine.routes.draw do
29
29
  patch :toggle_pin
30
30
  end
31
31
  end
32
+ match '*unmatched', to: 'application#not_found', via: :all
32
33
  end
@@ -6,6 +6,10 @@ module Catpm
6
6
  MIN_GAP_MS = 1.0
7
7
  DEFAULT_ERROR_STATUS = 500
8
8
  DEFAULT_SUCCESS_STATUS = 200
9
+
10
+ @instrumentation_mutex = Mutex.new
11
+ @active_instrumented_count = 0
12
+
9
13
  class << self
10
14
  def process_action_controller(event)
11
15
  return unless Catpm.enabled?
@@ -118,6 +122,7 @@ module Catpm
118
122
  seg[:parent_index] = tree_parent ? (tree_parent + base_idx) : (ctrl_idx || 0)
119
123
  segments << seg
120
124
  end
125
+ reparent_under_call_tree(segments, ctrl_idx)
121
126
  end
122
127
  end
123
128
 
@@ -295,6 +300,7 @@ module Catpm
295
300
  seg[:parent_index] = tree_parent ? (tree_parent + base_idx) : (ctrl_idx || 0)
296
301
  segments << seg
297
302
  end
303
+ reparent_under_call_tree(segments, ctrl_idx)
298
304
  end
299
305
  end
300
306
 
@@ -389,16 +395,86 @@ module Catpm
389
395
 
390
396
  # For HTTP middleware where endpoint is unknown at start.
391
397
  def should_instrument_request?
392
- rand(Catpm.config.random_sample_rate) == 0
398
+ rand(Catpm.config.random_sample_rate) == 0 && try_start_instrumentation
393
399
  end
394
400
 
395
401
  # For track_request where endpoint is known at start.
396
- def should_instrument?(_kind, _target, _operation)
397
- rand(Catpm.config.random_sample_rate) == 0
402
+ # Always instruments targets matched by always_sample_targets.
403
+ def should_instrument?(_kind, target, _operation)
404
+ if Catpm.config.always_sample?(target)
405
+ try_start_instrumentation
406
+ else
407
+ rand(Catpm.config.random_sample_rate) == 0 && try_start_instrumentation
408
+ end
409
+ end
410
+
411
+ # Reset the concurrency counter (for tests and config reloads).
412
+ def reset_instrumentation_counter!
413
+ @instrumentation_mutex.synchronize { @active_instrumented_count = 0 }
414
+ end
415
+
416
+ # Release an instrumentation slot. Must be called in ensure block
417
+ # for every request where try_start_instrumentation returned true.
418
+ def end_instrumentation
419
+ @instrumentation_mutex.synchronize do
420
+ @active_instrumented_count = [@active_instrumented_count - 1, 0].max
421
+ end
422
+ end
423
+
424
+ # Atomically claim an instrumentation slot if within budget.
425
+ # Returns true if the slot was acquired, false if at capacity.
426
+ # Already-running instrumented requests are not affected.
427
+ def try_start_instrumentation
428
+ @instrumentation_mutex.synchronize do
429
+ max = Catpm.config.effective_max_concurrent_instrumented
430
+ return false if @active_instrumented_count >= max
431
+
432
+ @active_instrumented_count += 1
433
+ true
434
+ end
398
435
  end
399
436
 
400
437
  private
401
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
+
402
478
  # Remove near-zero-duration "code" spans that merely wrap a "controller" span.
403
479
  # This happens when CallTracer (TracePoint) captures a thin dispatch method
404
480
  # (e.g. Telegram::WebhookController#process) whose :return fires before the
@@ -456,8 +532,9 @@ module Catpm
456
532
  # carry full context in the buffer.
457
533
  # Non-instrumented requests have no segments — skip sample creation.
458
534
  def early_sample_type(error:, duration:, kind:, target:, operation:, instrumented: true)
459
- return 'error' if error && instrumented
460
- return nil unless instrumented
535
+ always = Catpm.config.always_sample?(target)
536
+ return 'error' if error && (instrumented || always)
537
+ return nil unless instrumented || always
461
538
  return 'slow' if duration >= Catpm.config.slow_threshold_for(kind.to_sym)
462
539
 
463
540
  'random'
@@ -9,6 +9,18 @@ module Catpm
9
9
  BUFFER_MEMORY_SHARE = 0.5 # 50% of max_memory for event buffer
10
10
  CACHE_ENTRIES_PER_MB = 10_000 # ~100 bytes/entry in path_cache
11
11
  PATH_CACHE_BUDGET_SHARE = 0.05 # 5% of max_memory for path_cache
12
+ INSTRUMENTATION_BUDGET_SHARE = 0.30 # 30% of max_memory for concurrent instrumented requests
13
+
14
+ # Estimated per-request memory for instrumentation budget
15
+ ESTIMATED_BYTES_PER_STACK_SAMPLE = 10_000 # ~10 KB: backtrace_locations array (~100 frames × ~100 bytes)
16
+ ESTIMATED_BYTES_PER_SEGMENT = 500 # segment hash with strings
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
12
24
 
13
25
  # Boolean / non-numeric settings — plain attr_accessor
14
26
  attr_accessor :enabled,
@@ -30,7 +42,8 @@ module Catpm
30
42
  :events_enabled,
31
43
  :track_own_requests,
32
44
  :downsampling_thresholds,
33
- :show_untracked_segments
45
+ :show_untracked_segments,
46
+ :always_sample_targets
34
47
 
35
48
  # Numeric settings that must be positive numbers (nil not allowed)
36
49
  REQUIRED_NUMERIC = %i[
@@ -122,6 +135,7 @@ module Catpm
122
135
  @caller_scan_depth = 50
123
136
  @instrument_call_tree = false
124
137
  @show_untracked_segments = false
138
+ @always_sample_targets = []
125
139
  end
126
140
 
127
141
  # Buffer gets BUFFER_MEMORY_SHARE of max_memory, scaled by thread count
@@ -135,12 +149,56 @@ module Catpm
135
149
  (max_memory * CACHE_ENTRIES_PER_MB * PATH_CACHE_BUDGET_SHARE).to_i
136
150
  end
137
151
 
152
+ # Max concurrent instrumented requests derived from max_memory.
153
+ # Each instrumented request holds stack samples + segments in memory
154
+ # until the request completes. This limits how many can run at once
155
+ # so total live instrumentation data stays within budget.
156
+ def effective_max_concurrent_instrumented
157
+ budget_bytes = max_memory * 1_048_576 * INSTRUMENTATION_BUDGET_SHARE
158
+ (budget_bytes / estimated_instrumented_request_bytes).clamp(1, 1000).to_i
159
+ end
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
+
138
186
  def slow_threshold_for(kind)
139
187
  slow_threshold_per_kind.fetch(kind.to_sym, slow_threshold)
140
188
  end
141
189
 
142
190
  def ignored?(target)
143
- ignored_targets.any? do |pattern|
191
+ match_target?(ignored_targets, target)
192
+ end
193
+
194
+ def always_sample?(target)
195
+ match_target?(always_sample_targets, target)
196
+ end
197
+
198
+ private
199
+
200
+ def match_target?(patterns, target)
201
+ patterns.any? do |pattern|
144
202
  case pattern
145
203
  when Regexp then pattern.match?(target)
146
204
  when String
@@ -152,5 +210,17 @@ module Catpm
152
210
  end
153
211
  end
154
212
  end
213
+
214
+ def estimated_instrumented_request_bytes
215
+ bytes = 0
216
+
217
+ if instrument_stack_sampler || instrument_call_tree
218
+ bytes += effective_max_stack_samples_per_request * ESTIMATED_BYTES_PER_STACK_SAMPLE
219
+ end
220
+
221
+ bytes += effective_max_segments_per_request * ESTIMATED_BYTES_PER_SEGMENT
222
+
223
+ [bytes, 50_000].max # minimum 50 KB estimate per request
224
+ end
155
225
  end
156
226
  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
@@ -33,6 +33,7 @@ module Catpm
33
33
  ensure
34
34
  req_segments&.stop_sampler
35
35
  req_segments&.release!
36
+ Collector.end_instrumentation if req_segments
36
37
  Thread.current[:catpm_request_segments] = nil
37
38
  Thread.current[:catpm_request_start] = nil
38
39
  Thread.current[:catpm_tracked_instrumented] = 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
@@ -75,18 +75,19 @@ module Catpm
75
75
  # process_update(...)
76
76
  # end
77
77
  #
78
- def self.track_request(kind: :http, target:, operation: '', context: {}, metadata: {})
78
+ def self.track_request(kind: :http, target:, operation: '', context: {}, metadata: {}, always_sample: false)
79
79
  return yield unless enabled?
80
80
 
81
81
  req_segments = Thread.current[:catpm_request_segments]
82
82
  owns_segments = false
83
83
 
84
84
  if req_segments.nil? && config.instrument_segments
85
- if Collector.should_instrument?(kind, target, operation)
85
+ force = always_sample || config.always_sample?(target)
86
+ if force ? Collector.try_start_instrumentation : Collector.should_instrument?(kind, target, operation)
86
87
  use_sampler = config.instrument_stack_sampler || config.instrument_call_tree
87
88
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
89
  req_segments = RequestSegments.new(
89
- max_segments: config.max_segments_per_request,
90
+ max_segments: config.effective_max_segments_per_request,
90
91
  request_start: start_time,
91
92
  stack_sample: use_sampler,
92
93
  call_tree: config.instrument_call_tree
@@ -122,6 +123,7 @@ module Catpm
122
123
 
123
124
  if owns_segments
124
125
  req_segments&.release!
126
+ Collector.end_instrumentation
125
127
  Thread.current[:catpm_request_segments] = nil
126
128
  # Mark that this request was already instrumented and processed by
127
129
  # track_request. Without this, process_action_controller would see
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.7'
4
+ VERSION = '0.10.1'
5
5
  end
data/lib/catpm.rb CHANGED
@@ -39,6 +39,7 @@ module Catpm
39
39
  @buffer = nil
40
40
  @flusher = nil
41
41
  Fingerprint.reset_caches!
42
+ Collector.reset_instrumentation_counter!
42
43
  end
43
44
 
44
45
  def enabled?
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.7
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
@@ -66,6 +66,8 @@ files:
66
66
  - app/views/catpm/samples/show.html.erb
67
67
  - app/views/catpm/shared/_page_nav.html.erb
68
68
  - app/views/catpm/shared/_segments_waterfall.html.erb
69
+ - app/views/catpm/shared/not_found.html.erb
70
+ - app/views/catpm/shared/record_not_found.html.erb
69
71
  - app/views/catpm/status/index.html.erb
70
72
  - app/views/catpm/system/index.html.erb
71
73
  - app/views/catpm/system/pipeline.html.erb