catpm 0.1.4 → 0.3.0
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/app/controllers/catpm/application_controller.rb +8 -0
- data/app/controllers/catpm/endpoints_controller.rb +16 -3
- data/app/controllers/catpm/errors_controller.rb +42 -0
- data/app/controllers/catpm/events_controller.rb +3 -3
- data/app/controllers/catpm/samples_controller.rb +3 -0
- data/app/controllers/catpm/status_controller.rb +1 -1
- data/app/controllers/catpm/system_controller.rb +0 -3
- data/app/helpers/catpm/application_helper.rb +4 -4
- data/app/models/catpm/error_record.rb +15 -0
- data/app/models/catpm/sample.rb +1 -0
- data/app/views/catpm/endpoints/show.html.erb +13 -8
- data/app/views/catpm/errors/show.html.erb +58 -18
- data/app/views/catpm/samples/show.html.erb +24 -34
- data/app/views/catpm/shared/_page_nav.html.erb +3 -1
- data/app/views/catpm/shared/_segments_waterfall.html.erb +5 -1
- data/app/views/catpm/system/index.html.erb +2 -2
- data/config/routes.rb +1 -0
- data/db/migrate/20250601000001_create_catpm_tables.rb +3 -0
- data/lib/catpm/adapter/base.rb +43 -1
- data/lib/catpm/adapter/postgresql.rb +9 -2
- data/lib/catpm/adapter/sqlite.rb +9 -2
- data/lib/catpm/collector.rb +217 -112
- data/lib/catpm/configuration.rb +6 -2
- data/lib/catpm/event.rb +3 -3
- data/lib/catpm/flusher.rb +60 -53
- data/lib/catpm/stack_sampler.rb +53 -12
- data/lib/catpm/version.rb +1 -1
- data/lib/generators/catpm/templates/initializer.rb.tt +1 -0
- data/lib/tasks/catpm_tasks.rake +21 -4
- 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: ac5b510824ed9364db9d541a92eb95b1b0339c1972e48c2cfc634817d36d2600
|
|
4
|
+
data.tar.gz: 6f7f990fd824795ea8b9ef66e51f28e120d45aa960a2d1fb1e42c85f31458021
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca29621896898fcf69b876260bb27f68df164931986695905737fee23080e55183a6440292d3a8f60f2668c417d20cfb39d9219b2882adbaa764a94e1a3345ea
|
|
7
|
+
data.tar.gz: e8bc348a4c5fe4028403512a8c96f5b60dbc62676167fc41c56710440688aac206ec4b423c2ccd904a5bebef6428a19835af9e10dcc5961141459a9a0dce1073
|
|
@@ -2,5 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Catpm
|
|
4
4
|
class ApplicationController < ActionController::Base
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def remembered_range
|
|
8
|
+
if params[:range].present?
|
|
9
|
+
cookies[:catpm_range] = { value: params[:range], expires: 1.year.from_now }
|
|
10
|
+
end
|
|
11
|
+
params[:range] || cookies[:catpm_range]
|
|
12
|
+
end
|
|
5
13
|
end
|
|
6
14
|
end
|
|
@@ -8,7 +8,7 @@ module Catpm
|
|
|
8
8
|
@operation = params[:operation].presence || ''
|
|
9
9
|
|
|
10
10
|
# Time range filter
|
|
11
|
-
@range, period, _bucket_seconds = helpers.parse_range(
|
|
11
|
+
@range, period, _bucket_seconds = helpers.parse_range(remembered_range, extra_valid: ['all'])
|
|
12
12
|
|
|
13
13
|
scope = Catpm::Bucket
|
|
14
14
|
.where(kind: @kind, target: @target, operation: @operation)
|
|
@@ -23,11 +23,15 @@ module Catpm
|
|
|
23
23
|
'MAX(duration_max)',
|
|
24
24
|
'MIN(duration_min)',
|
|
25
25
|
'SUM(failure_count)',
|
|
26
|
-
'SUM(success_count)'
|
|
26
|
+
'SUM(success_count)',
|
|
27
|
+
'MIN(bucket_start)',
|
|
28
|
+
'MAX(bucket_start)'
|
|
27
29
|
)
|
|
28
30
|
|
|
29
31
|
@count, @duration_sum, @duration_max, @duration_min, @failure_count, @success_count =
|
|
30
|
-
@aggregate.map { |v| v || 0 }
|
|
32
|
+
@aggregate[0..5].map { |v| v || 0 }
|
|
33
|
+
@first_event_at = @aggregate[6]
|
|
34
|
+
@last_event_at = @aggregate[7]
|
|
31
35
|
|
|
32
36
|
@avg_duration = @count > 0 ? @duration_sum / @count : 0.0
|
|
33
37
|
@failure_rate = @count > 0 ? @failure_count.to_f / @count : 0.0
|
|
@@ -59,5 +63,14 @@ module Catpm
|
|
|
59
63
|
|
|
60
64
|
@active_error_count = Catpm::ErrorRecord.unresolved.count
|
|
61
65
|
end
|
|
66
|
+
|
|
67
|
+
def destroy
|
|
68
|
+
kind = params[:kind]
|
|
69
|
+
target = params[:target]
|
|
70
|
+
operation = params[:operation].presence || ''
|
|
71
|
+
|
|
72
|
+
Catpm::Bucket.where(kind: kind, target: target, operation: operation).destroy_all
|
|
73
|
+
redirect_to catpm.status_index_path, notice: 'Endpoint deleted'
|
|
74
|
+
end
|
|
62
75
|
end
|
|
63
76
|
end
|
|
@@ -35,6 +35,48 @@ module Catpm
|
|
|
35
35
|
@error = Catpm::ErrorRecord.find(params[:id])
|
|
36
36
|
@contexts = @error.parsed_contexts
|
|
37
37
|
@active_error_count = Catpm::ErrorRecord.unresolved.count
|
|
38
|
+
|
|
39
|
+
@range, period, bucket_seconds = helpers.parse_range(remembered_range)
|
|
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)
|
|
45
|
+
|
|
46
|
+
# Fallback: match error samples by recorded_at from contexts
|
|
47
|
+
if @samples.empty? && @contexts.any?
|
|
48
|
+
occurred_times = @contexts.filter_map { |c|
|
|
49
|
+
Time.parse(c['occurred_at'] || c[:occurred_at]) rescue nil
|
|
50
|
+
}
|
|
51
|
+
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)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Chart from occurrence_buckets (multi-resolution, no dependency on samples)
|
|
59
|
+
ob = @error.parsed_occurrence_buckets
|
|
60
|
+
|
|
61
|
+
# Pick resolution: minute for short ranges, hour for medium, day for long
|
|
62
|
+
resolution = case @range
|
|
63
|
+
when '1h', '6h', '24h' then 'm'
|
|
64
|
+
when '1w', '2w', '1m' then 'h'
|
|
65
|
+
else 'd'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
slots = {}
|
|
69
|
+
cutoff = period.ago.to_i
|
|
70
|
+
(ob[resolution] || {}).each do |ts_str, count|
|
|
71
|
+
ts = ts_str.to_i
|
|
72
|
+
next if ts < cutoff
|
|
73
|
+
slot_key = (ts / bucket_seconds) * bucket_seconds
|
|
74
|
+
slots[slot_key] = (slots[slot_key] || 0) + count
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
now_slot = (Time.current.to_i / bucket_seconds) * bucket_seconds
|
|
78
|
+
@chart_data = 60.times.map { |i| slots[now_slot - (59 - i) * bucket_seconds] || 0 }
|
|
79
|
+
@chart_times = 60.times.map { |i| Time.at(now_slot - (59 - i) * bucket_seconds).strftime('%H:%M') }
|
|
38
80
|
end
|
|
39
81
|
|
|
40
82
|
def resolve
|
|
@@ -5,7 +5,7 @@ module Catpm
|
|
|
5
5
|
PER_PAGE = 25
|
|
6
6
|
|
|
7
7
|
def index
|
|
8
|
-
@range, period, bucket_seconds = helpers.parse_range(
|
|
8
|
+
@range, period, bucket_seconds = helpers.parse_range(remembered_range)
|
|
9
9
|
|
|
10
10
|
recent_buckets = Catpm::EventBucket.recent(period).to_a
|
|
11
11
|
|
|
@@ -59,7 +59,7 @@ module Catpm
|
|
|
59
59
|
|
|
60
60
|
def show
|
|
61
61
|
@name = params[:name]
|
|
62
|
-
@range, period, bucket_seconds = helpers.parse_range(
|
|
62
|
+
@range, period, bucket_seconds = helpers.parse_range(remembered_range)
|
|
63
63
|
|
|
64
64
|
recent_buckets = Catpm::EventBucket.by_name(@name).recent(period).to_a
|
|
65
65
|
|
|
@@ -81,7 +81,7 @@ module Catpm
|
|
|
81
81
|
@chart_times = 60.times.map { |i| Time.at(now_slot - (59 - i) * bucket_seconds).strftime('%H:%M') }
|
|
82
82
|
|
|
83
83
|
# Recent samples
|
|
84
|
-
@samples = Catpm::EventSample.by_name(@name).order(recorded_at: :desc).limit(
|
|
84
|
+
@samples = Catpm::EventSample.by_name(@name).order(recorded_at: :desc).limit(Catpm.config.events_max_samples_per_name)
|
|
85
85
|
|
|
86
86
|
@active_error_count = Catpm::ErrorRecord.unresolved.count
|
|
87
87
|
end
|
|
@@ -8,6 +8,9 @@ module Catpm
|
|
|
8
8
|
@context = @sample.parsed_context
|
|
9
9
|
@segments = @context['segments'] || @context[:segments] || []
|
|
10
10
|
@summary = @context['segment_summary'] || @context[:segment_summary] || {}
|
|
11
|
+
@error_record = if @sample.error_fingerprint.present?
|
|
12
|
+
Catpm::ErrorRecord.find_by(fingerprint: @sample.error_fingerprint)
|
|
13
|
+
end
|
|
11
14
|
end
|
|
12
15
|
end
|
|
13
16
|
end
|
|
@@ -6,7 +6,7 @@ module Catpm
|
|
|
6
6
|
|
|
7
7
|
def index
|
|
8
8
|
# Time range (parsed first — everything below uses this)
|
|
9
|
-
@range, period, bucket_seconds = helpers.parse_range(
|
|
9
|
+
@range, period, bucket_seconds = helpers.parse_range(remembered_range)
|
|
10
10
|
|
|
11
11
|
recent_buckets = Catpm::Bucket.recent(period).to_a
|
|
12
12
|
|
|
@@ -7,9 +7,6 @@ module Catpm
|
|
|
7
7
|
@buffer_size = Catpm.buffer&.size || 0
|
|
8
8
|
@buffer_bytes = Catpm.buffer&.current_bytes || 0
|
|
9
9
|
@config = Catpm.config
|
|
10
|
-
@bucket_count = Catpm::Bucket.count
|
|
11
|
-
@sample_count = Catpm::Sample.count
|
|
12
|
-
@error_count = Catpm::ErrorRecord.count
|
|
13
10
|
@oldest_bucket = Catpm::Bucket.minimum(:bucket_start)
|
|
14
11
|
@active_error_count = Catpm::ErrorRecord.unresolved.count
|
|
15
12
|
end
|
|
@@ -7,14 +7,14 @@ module Catpm
|
|
|
7
7
|
'sql' => '#b8e4c6', 'view' => '#e4d4f4', 'cache' => '#fdd8b5',
|
|
8
8
|
'http' => '#f9c4c0', 'mailer' => '#e4d4f4', 'storage' => '#fdd8b5',
|
|
9
9
|
'custom' => '#dde2e8', 'code' => '#c8daf0', 'gem' => '#f0e0f0', 'other' => '#e8e8e8', 'controller' => '#b6d9f7',
|
|
10
|
-
'middleware' => '#f0dfa0', 'request' => '#b6d9f7'
|
|
10
|
+
'middleware' => '#f0dfa0', 'request' => '#b6d9f7', 'error' => '#fca5a5'
|
|
11
11
|
}.freeze
|
|
12
12
|
|
|
13
13
|
SEGMENT_TEXT_COLORS = {
|
|
14
14
|
'sql' => '#1a7f37', 'view' => '#6639a6', 'cache' => '#953800',
|
|
15
15
|
'http' => '#a1110a', 'mailer' => '#6639a6', 'storage' => '#953800',
|
|
16
16
|
'custom' => '#4b5563', 'code' => '#3b5998', 'gem' => '#7b3f9e', 'other' => '#9ca3af', 'controller' => '#0550ae',
|
|
17
|
-
'middleware' => '#7c5c00', 'request' => '#0550ae'
|
|
17
|
+
'middleware' => '#7c5c00', 'request' => '#0550ae', 'error' => '#991b1b'
|
|
18
18
|
}.freeze
|
|
19
19
|
|
|
20
20
|
BADGE_CLASSES = {
|
|
@@ -32,7 +32,7 @@ module Catpm
|
|
|
32
32
|
'sql' => 'SQL Queries', 'view' => 'View Renders', 'cache' => 'Cache Ops',
|
|
33
33
|
'http' => 'HTTP Calls', 'mailer' => 'Mailer', 'storage' => 'Storage',
|
|
34
34
|
'custom' => 'Custom', 'code' => 'App Code', 'gem' => 'Gems', 'other' => 'Untracked',
|
|
35
|
-
'controller' => 'Controller', 'middleware' => 'Middleware', 'request' => 'Request'
|
|
35
|
+
'controller' => 'Controller', 'middleware' => 'Middleware', 'request' => 'Request', 'error' => 'Error'
|
|
36
36
|
}.freeze
|
|
37
37
|
|
|
38
38
|
RANGES = {
|
|
@@ -236,7 +236,7 @@ module Catpm
|
|
|
236
236
|
prev_url = '?' + prev_params.compact.map { |k, v| "#{k}=#{v}" }.join('&')
|
|
237
237
|
next_url = '?' + next_params.compact.map { |k, v| "#{k}=#{v}" }.join('&')
|
|
238
238
|
|
|
239
|
-
html = '<div class="pagination">'
|
|
239
|
+
html = +'<div class="pagination">'
|
|
240
240
|
if current_page > 1
|
|
241
241
|
html << %(<a href="#{prev_url}" class="btn">← Previous</a>)
|
|
242
242
|
else
|
|
@@ -33,5 +33,20 @@ module Catpm
|
|
|
33
33
|
rescue JSON::ParserError
|
|
34
34
|
[]
|
|
35
35
|
end
|
|
36
|
+
|
|
37
|
+
def parsed_occurrence_buckets
|
|
38
|
+
raw = case occurrence_buckets
|
|
39
|
+
when Hash then occurrence_buckets
|
|
40
|
+
when String then JSON.parse(occurrence_buckets)
|
|
41
|
+
else {}
|
|
42
|
+
end
|
|
43
|
+
{
|
|
44
|
+
'm' => (raw['m'].is_a?(Hash) ? raw['m'] : {}),
|
|
45
|
+
'h' => (raw['h'].is_a?(Hash) ? raw['h'] : {}),
|
|
46
|
+
'd' => (raw['d'].is_a?(Hash) ? raw['d'] : {})
|
|
47
|
+
}
|
|
48
|
+
rescue JSON::ParserError
|
|
49
|
+
{ 'm' => {}, 'h' => {}, 'd' => {} }
|
|
50
|
+
end
|
|
36
51
|
end
|
|
37
52
|
end
|
data/app/models/catpm/sample.rb
CHANGED
|
@@ -12,6 +12,7 @@ module Catpm
|
|
|
12
12
|
scope :slow, -> { where(sample_type: 'slow') }
|
|
13
13
|
scope :errors, -> { where(sample_type: 'error') }
|
|
14
14
|
scope :recent, ->(period = 1.hour) { where(recorded_at: period.ago..) }
|
|
15
|
+
scope :for_error, ->(fingerprint) { where(error_fingerprint: fingerprint) }
|
|
15
16
|
|
|
16
17
|
def parsed_context
|
|
17
18
|
case context
|
|
@@ -8,10 +8,15 @@
|
|
|
8
8
|
|
|
9
9
|
<%= render "catpm/shared/page_nav", active: "performance" %>
|
|
10
10
|
|
|
11
|
-
<div class="breadcrumbs">
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
<div class="breadcrumbs" style="display:flex; align-items:center; justify-content:space-between">
|
|
12
|
+
<div>
|
|
13
|
+
<a href="<%= catpm.status_index_path %>">Performance</a>
|
|
14
|
+
<span class="sep">/</span>
|
|
15
|
+
<span><%= @target %></span>
|
|
16
|
+
</div>
|
|
17
|
+
<%= button_to "Delete Endpoint", catpm.endpoint_path(kind: @kind, target: @target, operation: @operation),
|
|
18
|
+
method: :delete, class: "btn btn-danger",
|
|
19
|
+
data: { confirm: "Delete this endpoint and all its data? This cannot be undone." } %>
|
|
15
20
|
</div>
|
|
16
21
|
|
|
17
22
|
<% ep_params = { kind: @kind, target: @target, operation: @operation } %>
|
|
@@ -27,12 +32,12 @@
|
|
|
27
32
|
<div class="value"><%= @count %></div>
|
|
28
33
|
</div>
|
|
29
34
|
<div class="card">
|
|
30
|
-
<div class="label">
|
|
31
|
-
<div class="value"><%=
|
|
35
|
+
<div class="label">First Event</div>
|
|
36
|
+
<div class="value"><%= @first_event_at ? time_with_tooltip(@first_event_at) : "—" %></div>
|
|
32
37
|
</div>
|
|
33
38
|
<div class="card">
|
|
34
|
-
<div class="label">
|
|
35
|
-
<div class="value"><%= @
|
|
39
|
+
<div class="label">Last Event</div>
|
|
40
|
+
<div class="value"><%= @last_event_at ? time_with_tooltip(@last_event_at) : "—" %></div>
|
|
36
41
|
</div>
|
|
37
42
|
<div class="card">
|
|
38
43
|
<div class="label">Max</div>
|
|
@@ -48,6 +48,19 @@
|
|
|
48
48
|
</div>
|
|
49
49
|
</div>
|
|
50
50
|
|
|
51
|
+
<%# ─── Error Frequency Chart ─── %>
|
|
52
|
+
<div class="time-range">
|
|
53
|
+
<% Catpm::ApplicationHelper::RANGE_KEYS.each do |r| %>
|
|
54
|
+
<a href="<%= catpm.error_path(@error, range: r) %>" class="<%= 'active' if @range == r %>"><%= r %></a>
|
|
55
|
+
<% end %>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<h2>Error Frequency</h2>
|
|
59
|
+
<%= section_description("Occurrences per time slot over the selected range.") %>
|
|
60
|
+
<div style="border:1px solid var(--border); border-radius:var(--radius); padding:16px; margin-bottom:24px; position:relative">
|
|
61
|
+
<%= bar_chart_svg(@chart_data, width: 600, height: 180, color: "var(--red, #e5534b)", time_labels: @chart_times) %>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
51
64
|
<%# ─── Backtrace ─── %>
|
|
52
65
|
<% first_bt = @contexts.first && (@contexts.first["backtrace"] || @contexts.first[:backtrace] || []) %>
|
|
53
66
|
<% if first_bt.any? %>
|
|
@@ -55,8 +68,17 @@
|
|
|
55
68
|
<%= section_description("All occurrences share the same fingerprint and backtrace.") %>
|
|
56
69
|
<div style="border:1px solid var(--border); border-radius:var(--radius); padding:14px; margin-bottom:12px; position:relative">
|
|
57
70
|
<button class="copy-btn" style="position:absolute; top:8px; right:8px" onclick="copyText(this)">Copy</button>
|
|
58
|
-
|
|
71
|
+
<% preview_lines = first_bt.first(10) %>
|
|
72
|
+
<% remaining_lines = first_bt.drop(10) %>
|
|
73
|
+
<pre class="mono" style="font-size:12px; white-space:pre-wrap; margin:0; line-height:1.8"><% preview_lines.each do |line| %><span class="<%= line.match?(%r{/(gems|ruby|vendor|bundle)/}) ? 'backtrace-lib' : 'backtrace-app' %>"><%= line %></span>
|
|
59
74
|
<% end %></pre>
|
|
75
|
+
<% if remaining_lines.any? %>
|
|
76
|
+
<details style="margin-top:4px">
|
|
77
|
+
<summary style="cursor:pointer; font-size:12px; color:var(--text-2)">Show full backtrace (<%= first_bt.size %> lines)</summary>
|
|
78
|
+
<pre class="mono" style="font-size:12px; white-space:pre-wrap; margin:0; line-height:1.8"><% remaining_lines.each do |line| %><span class="<%= line.match?(%r{/(gems|ruby|vendor|bundle)/}) ? 'backtrace-lib' : 'backtrace-app' %>"><%= line %></span>
|
|
79
|
+
<% end %></pre>
|
|
80
|
+
</details>
|
|
81
|
+
<% end %>
|
|
60
82
|
</div>
|
|
61
83
|
<% end %>
|
|
62
84
|
|
|
@@ -66,14 +88,14 @@
|
|
|
66
88
|
<span class="mono" style="word-break:break-all"><%= @error.fingerprint %></span>
|
|
67
89
|
</div>
|
|
68
90
|
|
|
69
|
-
<%# ───
|
|
70
|
-
<% if @
|
|
71
|
-
<h2>
|
|
91
|
+
<%# ─── Samples ─── %>
|
|
92
|
+
<% if @samples.any? %>
|
|
93
|
+
<h2>Recent Samples</h2>
|
|
94
|
+
<%= section_description("Linked request samples for this error. Click to view full details.") %>
|
|
72
95
|
<div class="table-scroll">
|
|
73
96
|
<table>
|
|
74
97
|
<thead>
|
|
75
98
|
<tr>
|
|
76
|
-
<th>#</th>
|
|
77
99
|
<th>Time</th>
|
|
78
100
|
<th>Duration</th>
|
|
79
101
|
<th>Status</th>
|
|
@@ -82,24 +104,42 @@
|
|
|
82
104
|
</tr>
|
|
83
105
|
</thead>
|
|
84
106
|
<tbody>
|
|
85
|
-
<% @
|
|
86
|
-
<%
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
<td class="mono
|
|
107
|
+
<% @samples.each do |sample| %>
|
|
108
|
+
<% ctx = sample.parsed_context %>
|
|
109
|
+
<tr class="clickable-row" onclick="window.location='<%= catpm.sample_path(sample) %>';" style="cursor:pointer">
|
|
110
|
+
<td><%= time_with_tooltip(sample.recorded_at) %></td>
|
|
111
|
+
<td class="mono"><%= format_duration(sample.duration) %></td>
|
|
112
|
+
<td><%= status_badge(ctx["status"] || ctx[:status]) %></td>
|
|
113
|
+
<td class="mono"><%= sample.bucket&.target || "—" %></td>
|
|
114
|
+
<td class="mono text-muted"><%= segment_count_summary(ctx["segment_summary"] || ctx[:segment_summary]).presence || "—" %></td>
|
|
115
|
+
</tr>
|
|
116
|
+
<% end %>
|
|
117
|
+
</tbody>
|
|
118
|
+
</table>
|
|
119
|
+
</div>
|
|
120
|
+
<% end %>
|
|
121
|
+
|
|
122
|
+
<%# ─── Legacy Occurrences (for errors without linked samples) ─── %>
|
|
123
|
+
<% if @samples.empty? && @contexts.any? %>
|
|
124
|
+
<h2>Last <%= @contexts.size %> Captured Occurrences</h2>
|
|
125
|
+
<div class="table-scroll">
|
|
126
|
+
<table>
|
|
127
|
+
<thead>
|
|
128
|
+
<tr>
|
|
129
|
+
<th>Time</th>
|
|
130
|
+
<th>Duration</th>
|
|
131
|
+
<th>Status</th>
|
|
132
|
+
<th>Target</th>
|
|
133
|
+
</tr>
|
|
134
|
+
</thead>
|
|
135
|
+
<tbody>
|
|
136
|
+
<% @contexts.each do |ctx| %>
|
|
137
|
+
<tr>
|
|
90
138
|
<td><%= time_with_tooltip(ctx["occurred_at"] || ctx[:occurred_at]) %></td>
|
|
91
139
|
<td class="mono"><%= (ctx["duration"] || ctx[:duration]) ? format_duration((ctx["duration"] || ctx[:duration]).to_f) : "—" %></td>
|
|
92
140
|
<td><%= status_badge(ctx["status"] || ctx[:status]) %></td>
|
|
93
141
|
<td class="mono"><%= ctx["target"] || ctx[:target] || "—" %></td>
|
|
94
|
-
<td class="mono text-muted"><%= segment_count_summary(ctx["segment_summary"] || ctx[:segment_summary]).presence || "—" %></td>
|
|
95
142
|
</tr>
|
|
96
|
-
<% if has_detail %>
|
|
97
|
-
<tr id="detail-<%= i %>" style="display:none">
|
|
98
|
-
<td colspan="6" style="padding:14px; background:var(--bg-1)">
|
|
99
|
-
<%= render "catpm/shared/segments_waterfall", segments: segments, total_duration: (ctx["duration"] || ctx[:duration] || 1), segments_capped: ctx["segments_capped"] || ctx[:segments_capped], table_id: "segments-table-#{i}" %>
|
|
100
|
-
</td>
|
|
101
|
-
</tr>
|
|
102
|
-
<% end %>
|
|
103
143
|
<% end %>
|
|
104
144
|
</tbody>
|
|
105
145
|
</table>
|
|
@@ -28,50 +28,40 @@
|
|
|
28
28
|
<span class="mono"><%= format_duration(@sample.duration) %></span>
|
|
29
29
|
<span class="sep">·</span>
|
|
30
30
|
<span class="text-muted"><%= time_with_tooltip(@sample.recorded_at) %></span>
|
|
31
|
+
<% if @error_record %>
|
|
32
|
+
<span class="sep">·</span>
|
|
33
|
+
<a href="<%= catpm.error_path(@error_record) %>" class="badge badge-error" style="text-decoration:none">Error: <%= @error_record.error_class %></a>
|
|
34
|
+
<% end %>
|
|
31
35
|
</div>
|
|
32
36
|
|
|
33
|
-
<%# ─── Request Context
|
|
37
|
+
<%# ─── Request Context ─── %>
|
|
34
38
|
<%
|
|
35
|
-
ctx_display = @context.except("segments", :segments, "segment_summary", :segment_summary, "segments_capped", :segments_capped, "backtrace", :backtrace)
|
|
39
|
+
ctx_display = @context.except("segments", :segments, "segment_summary", :segment_summary, "segments_capped", :segments_capped, "backtrace", :backtrace, "method", :method, "path", :path, "status", :status)
|
|
36
40
|
ctx_flat = ctx_display.select { |_, v| !v.is_a?(Hash) && !v.is_a?(Array) }
|
|
37
41
|
ctx_nested = ctx_display.select { |_, v| v.is_a?(Hash) || v.is_a?(Array) }
|
|
38
42
|
%>
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
<%
|
|
45
|
-
<div class="
|
|
46
|
-
|
|
47
|
-
<div class="ctx-key"><%= k %></div>
|
|
48
|
-
<div class="ctx-val"><%= v.to_s.truncate(200) %></div>
|
|
49
|
-
<% end %>
|
|
50
|
-
</div>
|
|
51
|
-
<% end %>
|
|
52
|
-
<% if ctx_nested.any? %>
|
|
53
|
-
<% ctx_nested.each do |k, v| %>
|
|
54
|
-
<details class="collapsible" open>
|
|
55
|
-
<summary><%= k %></summary>
|
|
56
|
-
<div class="details-body">
|
|
57
|
-
<pre class="mono" style="color:var(--text-1); white-space:pre-wrap; font-size:12px"><%= JSON.pretty_generate(v) rescue v.inspect %></pre>
|
|
58
|
-
</div>
|
|
59
|
-
</details>
|
|
60
|
-
<% end %>
|
|
44
|
+
<% if ctx_display.any? %>
|
|
45
|
+
<h2>Request Context</h2>
|
|
46
|
+
<% if ctx_flat.any? %>
|
|
47
|
+
<div class="context-grid" style="margin-bottom:12px">
|
|
48
|
+
<% ctx_flat.each do |k, v| %>
|
|
49
|
+
<div class="ctx-key"><%= k %></div>
|
|
50
|
+
<div class="ctx-val"><%= v.to_s.truncate(200) %></div>
|
|
61
51
|
<% end %>
|
|
62
52
|
</div>
|
|
63
53
|
<% end %>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
</
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
54
|
+
<% if ctx_nested.any? %>
|
|
55
|
+
<% ctx_nested.each do |k, v| %>
|
|
56
|
+
<details class="collapsible" open>
|
|
57
|
+
<summary><%= k %></summary>
|
|
58
|
+
<div class="details-body">
|
|
59
|
+
<pre class="mono" style="color:var(--text-1); white-space:pre-wrap; font-size:12px"><%= JSON.pretty_generate(v) rescue v.inspect %></pre>
|
|
60
|
+
</div>
|
|
61
|
+
</details>
|
|
62
|
+
<% end %>
|
|
63
|
+
<% end %>
|
|
64
|
+
<% end %>
|
|
75
65
|
|
|
76
66
|
<%# ─── Time Breakdown (full width, above waterfall) ─── %>
|
|
77
67
|
<% if @summary.any? %>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<div class="page-nav">
|
|
2
2
|
<a href="<%= catpm.status_index_path %>"<%= ' class="active"'.html_safe if active == "performance" %>>Performance</a>
|
|
3
|
-
|
|
3
|
+
<% if Catpm.config.events_enabled || Catpm::EventBucket.exists? %>
|
|
4
|
+
<a href="<%= catpm.events_path %>"<%= ' class="active"'.html_safe if active == "events" %>>Events</a>
|
|
5
|
+
<% end %>
|
|
4
6
|
<a href="<%= catpm.errors_path %>"<%= ' class="active"'.html_safe if active == "errors" %>>Errors<% if @active_error_count.to_i > 0 %><span class="nav-count alert"><%= @active_error_count %></span><% end %></a>
|
|
5
7
|
<a href="<%= catpm.system_index_path %>"<%= ' class="active"'.html_safe if active == "system" %>>System</a>
|
|
6
8
|
</div>
|
|
@@ -131,7 +131,11 @@
|
|
|
131
131
|
</td>
|
|
132
132
|
<td>
|
|
133
133
|
<div class="bar-container">
|
|
134
|
-
|
|
134
|
+
<% if type == 'error' %>
|
|
135
|
+
<div style="position:absolute; left:<%= left_pct %>%; top:2px; bottom:2px; width:3px; background:#dc2626; border-radius:2px"></div>
|
|
136
|
+
<% else %>
|
|
137
|
+
<div class="bar-fill" style="margin-left:<%= left_pct %>%; width:<%= width_pct %>%; background:<%= bar_color %>"></div>
|
|
138
|
+
<% end %>
|
|
135
139
|
</div>
|
|
136
140
|
</td>
|
|
137
141
|
</tr>
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
<div class="pipeline-node">
|
|
39
39
|
<div class="node-icon"><svg width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="var(--text-2)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="14" cy="8" rx="8" ry="4"/><path d="M6 8v12c0 2.2 3.58 4 8 4s8-1.8 8-4V8"/><path d="M6 14c0 2.2 3.58 4 8 4s8-1.8 8-4"/></svg></div>
|
|
40
40
|
<div class="node-label">Database</div>
|
|
41
|
-
<div class="node-value"
|
|
42
|
-
<div class="node-detail">Aggregated stats are stored as time buckets,
|
|
41
|
+
<div class="node-value" style="font-size:14px">Storage</div>
|
|
42
|
+
<div class="node-detail">Aggregated stats are stored as time buckets, with detailed samples and error fingerprints.<br><%= @oldest_bucket ? "Data since #{@oldest_bucket.strftime('%b %-d')}, retained #{@config.retention_period ? "#{(@config.retention_period / 1.day).to_i} days" : "forever"}." : "No data yet." %></div>
|
|
43
43
|
</div>
|
|
44
44
|
</div>
|
|
45
45
|
|
data/config/routes.rb
CHANGED
|
@@ -5,6 +5,7 @@ Catpm::Engine.routes.draw do
|
|
|
5
5
|
resources :status, only: [:index]
|
|
6
6
|
resources :system, only: [:index]
|
|
7
7
|
get 'endpoint', to: 'endpoints#show', as: :endpoint
|
|
8
|
+
delete 'endpoint', to: 'endpoints#destroy'
|
|
8
9
|
resources :samples, only: [:show]
|
|
9
10
|
resources :events, only: [:index, :show], param: :name
|
|
10
11
|
resources :errors, only: [:index, :show, :destroy] do
|
|
@@ -32,10 +32,12 @@ class CreateCatpmTables < ActiveRecord::Migration[8.0]
|
|
|
32
32
|
t.datetime :recorded_at, null: false
|
|
33
33
|
t.float :duration, null: false
|
|
34
34
|
t.json :context
|
|
35
|
+
t.string :error_fingerprint, limit: 64
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
add_index :catpm_samples, :recorded_at, name: 'idx_catpm_samples_time'
|
|
38
39
|
add_index :catpm_samples, [:kind, :recorded_at], name: 'idx_catpm_samples_kind_time'
|
|
40
|
+
add_index :catpm_samples, :error_fingerprint, name: 'idx_catpm_samples_error_fp'
|
|
39
41
|
|
|
40
42
|
create_table :catpm_errors do |t|
|
|
41
43
|
t.string :fingerprint, null: false, limit: 64
|
|
@@ -46,6 +48,7 @@ class CreateCatpmTables < ActiveRecord::Migration[8.0]
|
|
|
46
48
|
t.datetime :first_occurred_at, null: false
|
|
47
49
|
t.datetime :last_occurred_at, null: false
|
|
48
50
|
t.json :contexts
|
|
51
|
+
t.json :occurrence_buckets
|
|
49
52
|
t.datetime :resolved_at
|
|
50
53
|
end
|
|
51
54
|
|
data/lib/catpm/adapter/base.rb
CHANGED
|
@@ -32,7 +32,8 @@ module Catpm
|
|
|
32
32
|
sample_type: sample_data[:sample_type],
|
|
33
33
|
recorded_at: sample_data[:recorded_at],
|
|
34
34
|
duration: sample_data[:duration],
|
|
35
|
-
context: sample_data[:context]
|
|
35
|
+
context: sample_data[:context],
|
|
36
|
+
error_fingerprint: sample_data[:error_fingerprint]
|
|
36
37
|
}
|
|
37
38
|
end
|
|
38
39
|
|
|
@@ -68,8 +69,49 @@ module Catpm
|
|
|
68
69
|
combined.last(Catpm.config.max_error_contexts)
|
|
69
70
|
end
|
|
70
71
|
|
|
72
|
+
# Merge new occurrence timestamps into the multi-resolution bucket structure.
|
|
73
|
+
# Structure: { "m" => {epoch => count}, "h" => {epoch => count}, "d" => {epoch => count} }
|
|
74
|
+
# - "m" (minute): kept for 48 hours
|
|
75
|
+
# - "h" (hour): kept for 90 days
|
|
76
|
+
# - "d" (day): kept for 2 years
|
|
77
|
+
def merge_occurrence_buckets(existing, new_times)
|
|
78
|
+
buckets = parse_occurrence_buckets(existing)
|
|
79
|
+
|
|
80
|
+
(new_times || []).each do |t|
|
|
81
|
+
ts = t.to_i
|
|
82
|
+
m_key = ((ts / 60) * 60).to_s
|
|
83
|
+
h_key = ((ts / 3600) * 3600).to_s
|
|
84
|
+
d_key = ((ts / 86400) * 86400).to_s
|
|
85
|
+
|
|
86
|
+
buckets['m'][m_key] = (buckets['m'][m_key] || 0) + 1
|
|
87
|
+
buckets['h'][h_key] = (buckets['h'][h_key] || 0) + 1
|
|
88
|
+
buckets['d'][d_key] = (buckets['d'][d_key] || 0) + 1
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Compact old entries
|
|
92
|
+
now = Time.current.to_i
|
|
93
|
+
cutoff_m = now - 48 * 3600
|
|
94
|
+
cutoff_h = now - 90 * 86400
|
|
95
|
+
cutoff_d = now - 2 * 365 * 86400
|
|
96
|
+
|
|
97
|
+
buckets['m'].reject! { |k, _| k.to_i < cutoff_m }
|
|
98
|
+
buckets['h'].reject! { |k, _| k.to_i < cutoff_h }
|
|
99
|
+
buckets['d'].reject! { |k, _| k.to_i < cutoff_d }
|
|
100
|
+
|
|
101
|
+
buckets
|
|
102
|
+
end
|
|
103
|
+
|
|
71
104
|
private
|
|
72
105
|
|
|
106
|
+
def parse_occurrence_buckets(value)
|
|
107
|
+
raw = parse_json(value)
|
|
108
|
+
{
|
|
109
|
+
'm' => (raw['m'].is_a?(Hash) ? raw['m'] : {}),
|
|
110
|
+
'h' => (raw['h'].is_a?(Hash) ? raw['h'] : {}),
|
|
111
|
+
'd' => (raw['d'].is_a?(Hash) ? raw['d'] : {})
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
73
115
|
def parse_json(value)
|
|
74
116
|
case value
|
|
75
117
|
when Hash then value.transform_keys(&:to_s)
|
|
@@ -144,16 +144,22 @@ module Catpm
|
|
|
144
144
|
merged_contexts = merge_contexts(
|
|
145
145
|
existing.parsed_contexts, error_data[:new_contexts]
|
|
146
146
|
)
|
|
147
|
+
merged_buckets = merge_occurrence_buckets(
|
|
148
|
+
existing.occurrence_buckets, error_data[:occurrence_times]
|
|
149
|
+
)
|
|
147
150
|
|
|
148
151
|
attrs = {
|
|
149
152
|
occurrences_count: existing.occurrences_count + error_data[:occurrences_count],
|
|
150
153
|
last_occurred_at: [existing.last_occurred_at, error_data[:last_occurred_at]].max,
|
|
151
|
-
contexts: merged_contexts
|
|
154
|
+
contexts: merged_contexts,
|
|
155
|
+
occurrence_buckets: merged_buckets
|
|
152
156
|
}
|
|
153
157
|
attrs[:resolved_at] = nil if existing.resolved?
|
|
154
158
|
|
|
155
159
|
existing.update!(attrs)
|
|
156
160
|
else
|
|
161
|
+
initial_buckets = merge_occurrence_buckets(nil, error_data[:occurrence_times])
|
|
162
|
+
|
|
157
163
|
Catpm::ErrorRecord.create!(
|
|
158
164
|
fingerprint: error_data[:fingerprint],
|
|
159
165
|
kind: error_data[:kind],
|
|
@@ -162,7 +168,8 @@ module Catpm
|
|
|
162
168
|
occurrences_count: error_data[:occurrences_count],
|
|
163
169
|
first_occurred_at: error_data[:first_occurred_at],
|
|
164
170
|
last_occurred_at: error_data[:last_occurred_at],
|
|
165
|
-
contexts: error_data[:new_contexts]
|
|
171
|
+
contexts: error_data[:new_contexts],
|
|
172
|
+
occurrence_buckets: initial_buckets
|
|
166
173
|
)
|
|
167
174
|
end
|
|
168
175
|
end
|