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 +4 -4
- data/README.md +1 -1
- data/app/controllers/catpm/application_controller.rb +8 -0
- data/app/controllers/catpm/endpoints_controller.rb +15 -1
- data/app/controllers/catpm/errors_controller.rb +11 -8
- data/app/controllers/catpm/events_controller.rb +5 -1
- data/app/helpers/catpm/application_helper.rb +50 -11
- data/app/views/catpm/endpoints/_sample_table.html.erb +4 -1
- data/app/views/catpm/endpoints/show.html.erb +9 -3
- data/app/views/catpm/errors/show.html.erb +4 -2
- data/app/views/catpm/events/show.html.erb +3 -1
- data/app/views/layouts/catpm/application.html.erb +46 -2
- data/lib/catpm/collector.rb +41 -0
- data/lib/catpm/configuration.rb +34 -5
- data/lib/catpm/flusher.rb +16 -10
- data/lib/catpm/middleware.rb +1 -1
- data/lib/catpm/stack_sampler.rb +1 -3
- data/lib/catpm/trace.rb +1 -1
- data/lib/catpm/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5dab2a97464afff0ad59e3913f7f1aca84505d3ca109966a611eed911d5bb1a7
|
|
4
|
+
data.tar.gz: aa4c892ce8a39fb2ba5ceacf1aef5f1b9c6c867a682536f67c895d887d93607b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dbfa610b0355bf1f99b41c6be9476f622e4f756c75c79764abee15721bb2a729113425b6b81416e86e09f734d253301c36f8b0f577b8b15075c584d0e0728dae
|
|
7
|
+
data.tar.gz: ebaef872bc07283485e72ef2ccb5b9f14b836f581c66053ed0f7329621e83a5b7e3b58ef7e021cb43c5bdece61d20dda39867c85a73382bef055364424481f16
|
data/README.md
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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' ? ' ▲' : ' ▼') : ''
|
|
310
311
|
params_hash = extra_params.merge(sort: column, dir: new_dir)
|
|
311
|
-
url = '?' +
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
prev_url = '?' +
|
|
360
|
-
next_url = '?' +
|
|
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"
|
|
365
|
+
html << %(<a href="#{prev_url}" class="page-btn">‹</a>)
|
|
365
366
|
else
|
|
366
|
-
html << '<span class="btn
|
|
367
|
+
html << '<span class="page-btn disabled">‹</span>'
|
|
367
368
|
end
|
|
368
|
-
html << %(<span class="pagination-info"
|
|
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"
|
|
371
|
+
html << %(<a href="#{next_url}" class="page-btn">›</a>)
|
|
371
372
|
else
|
|
372
|
-
html << '<span class="btn
|
|
373
|
+
html << '<span class="page-btn disabled">›</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">‹</a>)
|
|
391
|
+
else
|
|
392
|
+
html << '<span class="page-btn disabled">‹</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">›</a>)
|
|
397
|
+
else
|
|
398
|
+
html << '<span class="page-btn disabled">›</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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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:
|
|
187
|
-
.pagination-info {
|
|
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/lib/catpm/collector.rb
CHANGED
|
@@ -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
|
data/lib/catpm/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/catpm/middleware.rb
CHANGED
|
@@ -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.
|
|
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
|
data/lib/catpm/stack_sampler.rb
CHANGED
|
@@ -85,9 +85,7 @@ module Catpm
|
|
|
85
85
|
|
|
86
86
|
# Called by SamplingLoop from the global thread
|
|
87
87
|
def capture(now)
|
|
88
|
-
|
|
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.
|
|
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