catpm 0.3.0 → 0.5.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/endpoints_controller.rb +29 -5
- data/app/controllers/catpm/errors_controller.rb +30 -6
- data/app/controllers/catpm/events_controller.rb +28 -4
- data/app/controllers/catpm/status_controller.rb +14 -2
- data/app/helpers/catpm/application_helper.rb +18 -6
- data/app/views/catpm/endpoints/show.html.erb +29 -12
- data/app/views/catpm/errors/show.html.erb +12 -7
- data/app/views/catpm/events/index.html.erb +2 -2
- data/app/views/catpm/events/show.html.erb +12 -8
- data/app/views/catpm/shared/_segments_waterfall.html.erb +5 -0
- data/app/views/catpm/status/index.html.erb +2 -2
- data/app/views/layouts/catpm/application.html.erb +62 -16
- data/lib/catpm/adapter/base.rb +8 -4
- data/lib/catpm/call_tracer.rb +85 -0
- data/lib/catpm/collector.rb +76 -19
- data/lib/catpm/configuration.rb +27 -2
- data/lib/catpm/event.rb +11 -5
- data/lib/catpm/fingerprint.rb +2 -2
- data/lib/catpm/flusher.rb +124 -89
- data/lib/catpm/middleware.rb +7 -0
- data/lib/catpm/request_segments.rb +2 -2
- data/lib/catpm/segment_subscribers.rb +6 -2
- data/lib/catpm/stack_sampler.rb +16 -11
- data/lib/catpm/tdigest.rb +2 -1
- data/lib/catpm/trace.rb +9 -1
- data/lib/catpm/version.rb +1 -1
- data/lib/catpm.rb +1 -0
- data/lib/generators/catpm/templates/initializer.rb.tt +69 -57
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 527b9950df4630b2c22f992c2e3e604eac9365c7faa0ea2a892cac5ed47d69af
|
|
4
|
+
data.tar.gz: d39f675cb7bea8ab51762f78d2319d29e2f88769b37594a45bb0571cdcdd2d32
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d47303a0a85e9f9773e37ef6ca2f0faf7627207ea1050ae456cf0bdff285d60f6ae043285216e5dbdcf61668ba6e4bffb91848bfd884f3e0c7548ea79a97ebf9
|
|
7
|
+
data.tar.gz: d4b68684377d6f350645dbfff66cd7faac777884c5dc0aae3b7ef5c2c612576bfc27c27ee55c7c3f3136e1897b3b2d48bc76e49b8aae5f6f9b0eb8240db7c543
|
|
@@ -8,7 +8,7 @@ module Catpm
|
|
|
8
8
|
@operation = params[:operation].presence || ''
|
|
9
9
|
|
|
10
10
|
# Time range filter
|
|
11
|
-
@range, period,
|
|
11
|
+
@range, period, bucket_seconds = helpers.parse_range(remembered_range)
|
|
12
12
|
|
|
13
13
|
scope = Catpm::Bucket
|
|
14
14
|
.where(kind: @kind, target: @target, operation: @operation)
|
|
@@ -49,14 +49,38 @@ module Catpm
|
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# Chart data — request volume, errors, avg duration
|
|
53
|
+
chart_buckets = scope.order(bucket_start: :asc).to_a
|
|
54
|
+
bucket_seconds = helpers.compute_bucket_seconds(chart_buckets) if @range == 'all'
|
|
55
|
+
|
|
56
|
+
if bucket_seconds
|
|
57
|
+
slots_count = {}
|
|
58
|
+
slots_errors = {}
|
|
59
|
+
slots_dur_sum = {}
|
|
60
|
+
slots_dur_count = {}
|
|
61
|
+
chart_buckets.each do |b|
|
|
62
|
+
slot_key = (b.bucket_start.to_i / bucket_seconds) * bucket_seconds
|
|
63
|
+
slots_count[slot_key] = (slots_count[slot_key] || 0) + b.count
|
|
64
|
+
slots_errors[slot_key] = (slots_errors[slot_key] || 0) + b.failure_count
|
|
65
|
+
slots_dur_sum[slot_key] = (slots_dur_sum[slot_key] || 0) + b.duration_sum
|
|
66
|
+
slots_dur_count[slot_key] = (slots_dur_count[slot_key] || 0) + b.count
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
now_slot = (Time.current.to_i / bucket_seconds) * bucket_seconds
|
|
70
|
+
@chart_requests = 60.times.map { |i| slots_count[now_slot - (59 - i) * bucket_seconds] || 0 }
|
|
71
|
+
@chart_errors = 60.times.map { |i| slots_errors[now_slot - (59 - i) * bucket_seconds] || 0 }
|
|
72
|
+
@chart_durations = 60.times.map do |i|
|
|
73
|
+
key = now_slot - (59 - i) * bucket_seconds
|
|
74
|
+
c = slots_dur_count[key] || 0
|
|
75
|
+
c > 0 ? (slots_dur_sum[key] / c).round(1) : 0
|
|
76
|
+
end
|
|
77
|
+
@chart_times = 60.times.map { |i| Time.at(now_slot - (59 - i) * bucket_seconds).strftime('%H:%M') }
|
|
78
|
+
end
|
|
79
|
+
|
|
52
80
|
endpoint_samples = Catpm::Sample
|
|
53
81
|
.joins(:bucket)
|
|
54
82
|
.where(catpm_buckets: { kind: @kind, target: @target, operation: @operation })
|
|
55
83
|
|
|
56
|
-
if @range != 'all'
|
|
57
|
-
endpoint_samples = endpoint_samples.where('catpm_samples.recorded_at >= ?', period.ago)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
84
|
@slow_samples = endpoint_samples.where(sample_type: 'slow').order(duration: :desc).limit(10)
|
|
61
85
|
@samples = endpoint_samples.where(sample_type: 'random').order(recorded_at: :desc).limit(10)
|
|
62
86
|
@error_samples = endpoint_samples.where(sample_type: 'error').order(recorded_at: :desc).limit(10)
|
|
@@ -66,12 +66,36 @@ module Catpm
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
slots = {}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
if @range == 'all'
|
|
70
|
+
# Collect all timestamps to compute dynamic bucket_seconds
|
|
71
|
+
all_timestamps = []
|
|
72
|
+
(ob[resolution] || {}).each do |ts_str, count|
|
|
73
|
+
ts = ts_str.to_i
|
|
74
|
+
all_timestamps << ts
|
|
75
|
+
slots[ts] = (slots[ts] || 0) + count
|
|
76
|
+
end
|
|
77
|
+
if all_timestamps.any?
|
|
78
|
+
span = all_timestamps.max - all_timestamps.min
|
|
79
|
+
span = 3600 if span < 3600
|
|
80
|
+
bucket_seconds = (span / 60.0).ceil
|
|
81
|
+
else
|
|
82
|
+
bucket_seconds = 60
|
|
83
|
+
end
|
|
84
|
+
# Re-bucket with computed bucket_seconds
|
|
85
|
+
rebucketed = {}
|
|
86
|
+
slots.each do |ts, count|
|
|
87
|
+
slot_key = (ts / bucket_seconds) * bucket_seconds
|
|
88
|
+
rebucketed[slot_key] = (rebucketed[slot_key] || 0) + count
|
|
89
|
+
end
|
|
90
|
+
slots = rebucketed
|
|
91
|
+
else
|
|
92
|
+
cutoff = period.ago.to_i
|
|
93
|
+
(ob[resolution] || {}).each do |ts_str, count|
|
|
94
|
+
ts = ts_str.to_i
|
|
95
|
+
next if ts < cutoff
|
|
96
|
+
slot_key = (ts / bucket_seconds) * bucket_seconds
|
|
97
|
+
slots[slot_key] = (slots[slot_key] || 0) + count
|
|
98
|
+
end
|
|
75
99
|
end
|
|
76
100
|
|
|
77
101
|
now_slot = (Time.current.to_i / bucket_seconds) * bucket_seconds
|
|
@@ -7,11 +7,23 @@ module Catpm
|
|
|
7
7
|
def index
|
|
8
8
|
@range, period, bucket_seconds = helpers.parse_range(remembered_range)
|
|
9
9
|
|
|
10
|
-
recent_buckets =
|
|
10
|
+
recent_buckets = if @range == 'all'
|
|
11
|
+
Catpm::EventBucket.all.to_a
|
|
12
|
+
else
|
|
13
|
+
Catpm::EventBucket.recent(period).to_a
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
bucket_seconds = helpers.compute_bucket_seconds(recent_buckets) if @range == 'all'
|
|
11
17
|
|
|
12
18
|
# Hero metrics
|
|
13
19
|
@total_events = recent_buckets.sum(&:count)
|
|
14
|
-
|
|
20
|
+
effective_period = if @range == 'all'
|
|
21
|
+
earliest = recent_buckets.min_by(&:bucket_start)&.bucket_start
|
|
22
|
+
earliest ? [Time.current - earliest, 60].max : 3600
|
|
23
|
+
else
|
|
24
|
+
period
|
|
25
|
+
end
|
|
26
|
+
period_minutes = effective_period.to_f / 60
|
|
15
27
|
@events_per_min = (period_minutes > 0 ? @total_events / period_minutes : 0).round(1)
|
|
16
28
|
|
|
17
29
|
# Group by name for table
|
|
@@ -61,11 +73,23 @@ module Catpm
|
|
|
61
73
|
@name = params[:name]
|
|
62
74
|
@range, period, bucket_seconds = helpers.parse_range(remembered_range)
|
|
63
75
|
|
|
64
|
-
recent_buckets =
|
|
76
|
+
recent_buckets = if @range == 'all'
|
|
77
|
+
Catpm::EventBucket.by_name(@name).all.to_a
|
|
78
|
+
else
|
|
79
|
+
Catpm::EventBucket.by_name(@name).recent(period).to_a
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
bucket_seconds = helpers.compute_bucket_seconds(recent_buckets) if @range == 'all'
|
|
65
83
|
|
|
66
84
|
# Hero metrics
|
|
67
85
|
@total_count = recent_buckets.sum(&:count)
|
|
68
|
-
|
|
86
|
+
effective_period = if @range == 'all'
|
|
87
|
+
earliest = recent_buckets.min_by(&:bucket_start)&.bucket_start
|
|
88
|
+
earliest ? [Time.current - earliest, 60].max : 3600
|
|
89
|
+
else
|
|
90
|
+
period
|
|
91
|
+
end
|
|
92
|
+
period_minutes = effective_period.to_f / 60
|
|
69
93
|
@events_per_min = (period_minutes > 0 ? @total_count / period_minutes : 0).round(1)
|
|
70
94
|
@last_seen = recent_buckets.map(&:bucket_start).max
|
|
71
95
|
|
|
@@ -8,7 +8,13 @@ module Catpm
|
|
|
8
8
|
# Time range (parsed first — everything below uses this)
|
|
9
9
|
@range, period, bucket_seconds = helpers.parse_range(remembered_range)
|
|
10
10
|
|
|
11
|
-
recent_buckets =
|
|
11
|
+
recent_buckets = if @range == 'all'
|
|
12
|
+
Catpm::Bucket.all.to_a
|
|
13
|
+
else
|
|
14
|
+
Catpm::Bucket.recent(period).to_a
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
bucket_seconds = helpers.compute_bucket_seconds(recent_buckets) if @range == 'all'
|
|
12
18
|
|
|
13
19
|
# Sparkline data
|
|
14
20
|
slots = {}
|
|
@@ -31,7 +37,13 @@ module Catpm
|
|
|
31
37
|
|
|
32
38
|
recent_count = recent_buckets.sum(&:count)
|
|
33
39
|
recent_failures = recent_buckets.sum(&:failure_count)
|
|
34
|
-
|
|
40
|
+
earliest_bucket = recent_buckets.min_by(&:bucket_start)&.bucket_start
|
|
41
|
+
effective_period = if @range == 'all'
|
|
42
|
+
earliest_bucket ? [Time.current - earliest_bucket, 60].max : 3600
|
|
43
|
+
else
|
|
44
|
+
earliest_bucket ? [[period, Time.current - earliest_bucket].min, 60].max : period
|
|
45
|
+
end
|
|
46
|
+
period_minutes = effective_period.to_f / 60
|
|
35
47
|
@recent_avg_duration = recent_count > 0 ? (recent_buckets.sum(&:duration_sum) / recent_count).round(1) : 0.0
|
|
36
48
|
@error_rate = recent_count > 0 ? (recent_failures.to_f / recent_count * 100).round(1) : 0.0
|
|
37
49
|
@requests_per_min = (recent_count / period_minutes).round(1)
|
|
@@ -116,10 +116,14 @@ module Catpm
|
|
|
116
116
|
circles = parsed.map.with_index do |(x, y, val), i|
|
|
117
117
|
label = labels ? labels[i] : val.is_a?(Float) ? ('%.1f' % val) : val
|
|
118
118
|
time_attr = time_labels ? %( data-time="#{time_labels[i]}") : ''
|
|
119
|
-
%(<circle cx="#{x}" cy="#{y}" r="
|
|
119
|
+
%(<circle cx="#{x}" cy="#{y}" r="0" data-value="#{label}"#{time_attr} class="sparkline-dot"/>)
|
|
120
120
|
end.join
|
|
121
121
|
|
|
122
|
-
%(<
|
|
122
|
+
capture = %(<rect width="#{width}" height="#{height}" fill="transparent"/>)
|
|
123
|
+
highlight = %(<circle cx="0" cy="0" r="3" fill="#{color}" class="sparkline-highlight" style="display:none"/>)
|
|
124
|
+
vline = %(<line x1="0" y1="0" x2="0" y2="#{height}" stroke="#{color}" stroke-width="0.5" opacity="0.4" class="sparkline-vline" style="display:none"/>)
|
|
125
|
+
|
|
126
|
+
%(<svg class="sparkline-chart" width="#{width}" height="#{height}" viewBox="0 0 #{width} #{height}" xmlns="http://www.w3.org/2000/svg" style="display:block">#{capture}#{fill_el}<polyline points="#{coords_str.join(" ")}" fill="none" stroke="#{color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>#{circles}#{vline}#{highlight}</svg>).html_safe
|
|
123
127
|
end
|
|
124
128
|
|
|
125
129
|
def bar_chart_svg(data_points, width: 600, height: 200, color: 'var(--accent)', time_labels: nil)
|
|
@@ -207,16 +211,16 @@ module Catpm
|
|
|
207
211
|
%(<span class="status-dot"><span class="dot" style="background:#{color}"></span> #{label}</span>).html_safe
|
|
208
212
|
end
|
|
209
213
|
|
|
210
|
-
def parse_range(range_str
|
|
211
|
-
|
|
212
|
-
key
|
|
213
|
-
return [key, nil, nil] if extra_valid.include?(key) && !RANGES.key?(key)
|
|
214
|
+
def parse_range(range_str)
|
|
215
|
+
key = (RANGE_KEYS + ['all']).include?(range_str) ? range_str : 'all'
|
|
216
|
+
return [key, nil, nil] if key == 'all'
|
|
214
217
|
period, bucket_seconds = RANGES[key]
|
|
215
218
|
[key, period, bucket_seconds]
|
|
216
219
|
end
|
|
217
220
|
|
|
218
221
|
def range_label(range)
|
|
219
222
|
case range
|
|
223
|
+
when 'all' then 'All time'
|
|
220
224
|
when '6h' then 'Last 6 hours'
|
|
221
225
|
when '24h' then 'Last 24 hours'
|
|
222
226
|
when '1w' then 'Last week'
|
|
@@ -227,6 +231,14 @@ module Catpm
|
|
|
227
231
|
end
|
|
228
232
|
end
|
|
229
233
|
|
|
234
|
+
def compute_bucket_seconds(buckets)
|
|
235
|
+
return 60 if buckets.empty?
|
|
236
|
+
times = buckets.map { |b| b.bucket_start.to_i }
|
|
237
|
+
span = times.max - times.min
|
|
238
|
+
span = 3600 if span < 3600
|
|
239
|
+
(span / 60.0).ceil
|
|
240
|
+
end
|
|
241
|
+
|
|
230
242
|
def pagination_nav(current_page, total_count, per_page, extra_params: {})
|
|
231
243
|
total_pages = (total_count.to_f / per_page).ceil
|
|
232
244
|
return '' if total_pages <= 1
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
<% ep_params = { kind: @kind, target: @target, operation: @operation } %>
|
|
23
23
|
<div class="time-range">
|
|
24
|
-
<% (Catpm::ApplicationHelper::RANGE_KEYS
|
|
24
|
+
<% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
|
|
25
25
|
<a href="<%= catpm.endpoint_path(ep_params.merge(range: r)) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
|
|
26
26
|
<% end %>
|
|
27
27
|
</div>
|
|
@@ -106,19 +106,36 @@
|
|
|
106
106
|
</div>
|
|
107
107
|
</div>
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
109
|
+
<% end %>
|
|
110
|
+
|
|
111
|
+
<% has_requests = @chart_requests&.any? { |v| v > 0 } %>
|
|
112
|
+
<% has_errors = @chart_errors&.any? { |v| v > 0 } %>
|
|
113
|
+
<% has_durations = @chart_durations&.any? { |v| v > 0 } %>
|
|
114
|
+
<% if has_requests || has_errors || has_durations %>
|
|
115
|
+
<div class="charts-row">
|
|
116
|
+
<% if has_requests %>
|
|
117
|
+
<div class="chart-card">
|
|
118
|
+
<div class="label">Request Volume</div>
|
|
119
|
+
<div class="value"><%= @chart_requests.sum %></div>
|
|
120
|
+
<div class="detail"><%= range_label(@range) %></div>
|
|
121
|
+
<div class="sparkline"><%= sparkline_svg(@chart_requests, width: 500, height: 64, color: "var(--accent)", fill: true, time_labels: @chart_times) %></div>
|
|
115
122
|
</div>
|
|
116
123
|
<% end %>
|
|
117
|
-
<% if
|
|
118
|
-
<div class="card">
|
|
119
|
-
<div class="label">
|
|
120
|
-
<div class="value"><%=
|
|
121
|
-
<div class="detail"
|
|
124
|
+
<% if has_errors %>
|
|
125
|
+
<div class="chart-card">
|
|
126
|
+
<div class="label">Error Volume</div>
|
|
127
|
+
<div class="value"><%= @chart_errors.sum %></div>
|
|
128
|
+
<div class="detail"><%= range_label(@range) %></div>
|
|
129
|
+
<div class="sparkline"><%= sparkline_svg(@chart_errors, width: 500, height: 64, color: "var(--red)", fill: true, time_labels: @chart_times) %></div>
|
|
130
|
+
</div>
|
|
131
|
+
<% end %>
|
|
132
|
+
<% if has_durations %>
|
|
133
|
+
<div class="chart-card">
|
|
134
|
+
<div class="label">Avg Duration</div>
|
|
135
|
+
<% nonzero = @chart_durations.select { |v| v > 0 } %>
|
|
136
|
+
<div class="value"><%= nonzero.any? ? format_duration(nonzero.sum / nonzero.size) : "—" %></div>
|
|
137
|
+
<div class="detail"><%= range_label(@range) %></div>
|
|
138
|
+
<div class="sparkline"><%= sparkline_svg(@chart_durations, width: 500, height: 64, color: "var(--accent)", fill: true, labels: @chart_durations.map { |d| format_duration(d) }, time_labels: @chart_times) %></div>
|
|
122
139
|
</div>
|
|
123
140
|
<% end %>
|
|
124
141
|
</div>
|
|
@@ -50,16 +50,21 @@
|
|
|
50
50
|
|
|
51
51
|
<%# ─── Error Frequency Chart ─── %>
|
|
52
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>
|
|
53
|
+
<% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
|
|
54
|
+
<a href="<%= catpm.error_path(@error, range: r) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
|
|
55
55
|
<% end %>
|
|
56
56
|
</div>
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
<div
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
<% if @chart_data&.any? { |v| v > 0 } %>
|
|
59
|
+
<div class="charts-row">
|
|
60
|
+
<div class="chart-card">
|
|
61
|
+
<div class="label">Error Frequency</div>
|
|
62
|
+
<div class="value"><%= @chart_data.sum %></div>
|
|
63
|
+
<div class="detail">occurrences · <%= range_label(@range) %></div>
|
|
64
|
+
<div class="sparkline"><%= sparkline_svg(@chart_data, width: 500, height: 64, color: "var(--red)", fill: true, time_labels: @chart_times) %></div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
<% end %>
|
|
63
68
|
|
|
64
69
|
<%# ─── Backtrace ─── %>
|
|
65
70
|
<% first_bt = @contexts.first && (@contexts.first["backtrace"] || @contexts.first[:backtrace] || []) %>
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
<%# ─── Time Range ─── %>
|
|
7
7
|
<div class="time-range">
|
|
8
|
-
<% Catpm::ApplicationHelper::RANGE_KEYS.each do |r| %>
|
|
9
|
-
<a href="<%= catpm.events_path(range: r) %>" class="<%= 'active' if @range == r %>"><%= r %></a>
|
|
8
|
+
<% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
|
|
9
|
+
<a href="<%= catpm.events_path(range: r) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
|
|
10
10
|
<% end %>
|
|
11
11
|
<span style="margin-left:auto; font-size:12px; color:var(--text-2)">
|
|
12
12
|
Updated <%= Time.current.strftime("%H:%M:%S") %>
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
<%# ─── Time Range ─── %>
|
|
13
13
|
<div class="time-range">
|
|
14
|
-
<% Catpm::ApplicationHelper::RANGE_KEYS.each do |r| %>
|
|
15
|
-
<a href="<%= catpm.event_path(name: @name, range: r) %>" class="<%= 'active' if @range == r %>"><%= r %></a>
|
|
14
|
+
<% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
|
|
15
|
+
<a href="<%= catpm.event_path(name: @name, range: r) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
|
|
16
16
|
<% end %>
|
|
17
17
|
</div>
|
|
18
18
|
|
|
@@ -34,12 +34,16 @@
|
|
|
34
34
|
</div>
|
|
35
35
|
</div>
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
<div
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
<% if @chart_data&.any? { |v| v > 0 } %>
|
|
38
|
+
<div class="charts-row">
|
|
39
|
+
<div class="chart-card">
|
|
40
|
+
<div class="label">Event Volume</div>
|
|
41
|
+
<div class="value"><%= @chart_data.sum %></div>
|
|
42
|
+
<div class="detail"><%= range_label(@range) %></div>
|
|
43
|
+
<div class="sparkline"><%= sparkline_svg(@chart_data, width: 500, height: 64, color: "var(--accent)", fill: true, time_labels: @chart_times) %></div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<% end %>
|
|
43
47
|
|
|
44
48
|
<%# ─── Recent Samples ─── %>
|
|
45
49
|
<h2>Recent Samples</h2>
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Sort children of each parent by timeline offset so segments appear chronologically
|
|
21
|
+
children.each_value do |kids|
|
|
22
|
+
kids.sort_by! { |i| (segments[i]["offset"] || segments[i][:offset] || 0).to_f }
|
|
23
|
+
end
|
|
24
|
+
|
|
20
25
|
depth_map = {}
|
|
21
26
|
ordered = []
|
|
22
27
|
build_order = ->(indices, depth) {
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
<%# ─── Time Range ─── %>
|
|
7
7
|
<div class="time-range">
|
|
8
|
-
<% Catpm::ApplicationHelper::RANGE_KEYS.each do |r| %>
|
|
9
|
-
<a href="<%= catpm.status_index_path(range: r) %>" class="<%= 'active' if @range == r %>"><%= r %></a>
|
|
8
|
+
<% (["all"] + Catpm::ApplicationHelper::RANGE_KEYS).each do |r| %>
|
|
9
|
+
<a href="<%= catpm.status_index_path(range: r) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
|
|
10
10
|
<% end %>
|
|
11
11
|
<span style="margin-left:auto; font-size:12px; color:var(--text-2)">
|
|
12
12
|
Updated <%= Time.current.strftime("%H:%M:%S") %> ·
|
|
@@ -230,7 +230,17 @@
|
|
|
230
230
|
.bar-chart-wrap { position: relative; }
|
|
231
231
|
.bar-chart-max { position: absolute; top: 2px; left: 4px; font-size: 11px; color: var(--text-2); font-family: var(--font-sans); pointer-events: none; line-height: 1; }
|
|
232
232
|
|
|
233
|
+
/* ─── Charts Row (2-up on wide screens) ─── */
|
|
234
|
+
.charts-row { display: grid; grid-template-columns: 1fr; gap: 12px; margin-bottom: 20px; }
|
|
235
|
+
@media (min-width: 900px) { .charts-row { grid-template-columns: 1fr 1fr; } }
|
|
236
|
+
.chart-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; }
|
|
237
|
+
.chart-card .label { color: var(--text-1); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500; }
|
|
238
|
+
.chart-card .value { color: var(--text-0); font-size: 20px; font-weight: 600; margin-top: 2px; line-height: 1.2; }
|
|
239
|
+
.chart-card .detail { color: var(--text-2); font-size: 12px; margin-top: 4px; }
|
|
240
|
+
.chart-card .sparkline { margin-top: 8px; }
|
|
241
|
+
|
|
233
242
|
/* ─── Sparkline Interaction ─── */
|
|
243
|
+
.sparkline-chart { cursor: default; }
|
|
234
244
|
.sparkline-tip { position: absolute; background: #ffffff; color: var(--text-0); font-size: 11px; font-family: var(--font-sans); padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0,0,0,0.12); pointer-events: none; white-space: nowrap; z-index: 10; display: none; line-height: 1.3; }
|
|
235
245
|
|
|
236
246
|
/* ─── Error Hero ─── */
|
|
@@ -344,29 +354,65 @@
|
|
|
344
354
|
});
|
|
345
355
|
}
|
|
346
356
|
|
|
347
|
-
/* Sparkline hover */
|
|
357
|
+
/* Sparkline hover — snap to nearest point by X */
|
|
348
358
|
var sparkTip = null;
|
|
349
|
-
var
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
359
|
+
var activeSvg = null;
|
|
360
|
+
|
|
361
|
+
function hideSparkline() {
|
|
362
|
+
if (activeSvg) {
|
|
363
|
+
var hl = activeSvg.querySelector('.sparkline-highlight');
|
|
364
|
+
var vl = activeSvg.querySelector('.sparkline-vline');
|
|
365
|
+
if (hl) hl.style.display = 'none';
|
|
366
|
+
if (vl) vl.style.display = 'none';
|
|
367
|
+
activeSvg = null;
|
|
368
|
+
}
|
|
369
|
+
if (sparkTip) sparkTip.style.display = 'none';
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
document.addEventListener('mousemove', function(e) {
|
|
373
|
+
var svg = e.target.closest('.sparkline-chart');
|
|
374
|
+
if (!svg) { hideSparkline(); return; }
|
|
375
|
+
|
|
376
|
+
var rect = svg.getBoundingClientRect();
|
|
377
|
+
var vb = svg.viewBox.baseVal;
|
|
378
|
+
var scaleX = vb.width / rect.width;
|
|
379
|
+
var mouseX = (e.clientX - rect.left) * scaleX;
|
|
380
|
+
|
|
381
|
+
var dots = svg.querySelectorAll('.sparkline-dot');
|
|
382
|
+
var nearest = null, nearestDist = Infinity;
|
|
383
|
+
for (var i = 0; i < dots.length; i++) {
|
|
384
|
+
var cx = parseFloat(dots[i].getAttribute('cx'));
|
|
385
|
+
var dist = Math.abs(cx - mouseX);
|
|
386
|
+
if (dist < nearestDist) { nearestDist = dist; nearest = dots[i]; }
|
|
387
|
+
}
|
|
388
|
+
if (!nearest) { hideSparkline(); return; }
|
|
389
|
+
|
|
390
|
+
var nx = nearest.getAttribute('cx');
|
|
391
|
+
var ny = nearest.getAttribute('cy');
|
|
392
|
+
|
|
393
|
+
// Highlight dot
|
|
394
|
+
var hl = svg.querySelector('.sparkline-highlight');
|
|
395
|
+
if (hl) { hl.setAttribute('cx', nx); hl.setAttribute('cy', ny); hl.style.display = ''; }
|
|
396
|
+
|
|
397
|
+
// Vertical guide line
|
|
398
|
+
var vl = svg.querySelector('.sparkline-vline');
|
|
399
|
+
if (vl) { vl.setAttribute('x1', nx); vl.setAttribute('x2', nx); vl.style.display = ''; }
|
|
400
|
+
|
|
401
|
+
activeSvg = svg;
|
|
402
|
+
|
|
403
|
+
// Tooltip
|
|
354
404
|
if (!sparkTip) { sparkTip = document.createElement('div'); sparkTip.className = 'sparkline-tip'; document.body.appendChild(sparkTip); }
|
|
355
|
-
var val =
|
|
356
|
-
var time =
|
|
405
|
+
var val = nearest.dataset.value || '';
|
|
406
|
+
var time = nearest.dataset.time || '';
|
|
357
407
|
sparkTip.innerHTML = '<strong>' + val + '</strong>' + (time ? '<br>' + time : '');
|
|
358
408
|
sparkTip.style.display = 'block';
|
|
359
|
-
});
|
|
360
|
-
document.addEventListener('mousemove', function(e) {
|
|
361
|
-
if (!sparkTip || sparkTip.style.display === 'none') return;
|
|
362
409
|
sparkTip.style.left = (e.pageX) + 'px';
|
|
363
410
|
sparkTip.style.top = (e.pageY + 16) + 'px';
|
|
364
411
|
});
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
});
|
|
412
|
+
|
|
413
|
+
document.addEventListener('mouseleave', function(e) {
|
|
414
|
+
if (e.target.closest && e.target.closest('.sparkline-chart')) hideSparkline();
|
|
415
|
+
}, true);
|
|
370
416
|
|
|
371
417
|
document.addEventListener('keydown', function(e) {
|
|
372
418
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
data/lib/catpm/adapter/base.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
module Catpm
|
|
4
4
|
module Adapter
|
|
5
5
|
module Base
|
|
6
|
+
MINUTE_BUCKET_RETENTION = 48 * 3600
|
|
7
|
+
HOUR_BUCKET_RETENTION = 90 * 86400
|
|
8
|
+
DAY_BUCKET_RETENTION = 2 * 365 * 86400
|
|
6
9
|
def persist_buckets(aggregated_buckets)
|
|
7
10
|
raise NotImplementedError
|
|
8
11
|
end
|
|
@@ -66,7 +69,8 @@ module Catpm
|
|
|
66
69
|
|
|
67
70
|
def merge_contexts(existing_contexts, new_contexts)
|
|
68
71
|
combined = (existing_contexts + new_contexts)
|
|
69
|
-
|
|
72
|
+
max = Catpm.config.max_error_contexts
|
|
73
|
+
max ? combined.last(max) : combined
|
|
70
74
|
end
|
|
71
75
|
|
|
72
76
|
# Merge new occurrence timestamps into the multi-resolution bucket structure.
|
|
@@ -90,9 +94,9 @@ module Catpm
|
|
|
90
94
|
|
|
91
95
|
# Compact old entries
|
|
92
96
|
now = Time.current.to_i
|
|
93
|
-
cutoff_m = now -
|
|
94
|
-
cutoff_h = now -
|
|
95
|
-
cutoff_d = now -
|
|
97
|
+
cutoff_m = now - MINUTE_BUCKET_RETENTION
|
|
98
|
+
cutoff_h = now - HOUR_BUCKET_RETENTION
|
|
99
|
+
cutoff_d = now - DAY_BUCKET_RETENTION
|
|
96
100
|
|
|
97
101
|
buckets['m'].reject! { |k, _| k.to_i < cutoff_m }
|
|
98
102
|
buckets['h'].reject! { |k, _| k.to_i < cutoff_h }
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
class CallTracer
|
|
5
|
+
def initialize(request_segments:)
|
|
6
|
+
@request_segments = request_segments
|
|
7
|
+
@call_stack = []
|
|
8
|
+
@path_cache = {}
|
|
9
|
+
@started = false
|
|
10
|
+
|
|
11
|
+
@tracepoint = TracePoint.new(:call, :return) do |tp|
|
|
12
|
+
case tp.event
|
|
13
|
+
when :call
|
|
14
|
+
handle_call(tp)
|
|
15
|
+
when :return
|
|
16
|
+
handle_return
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def start
|
|
22
|
+
return if @started
|
|
23
|
+
|
|
24
|
+
@started = true
|
|
25
|
+
@tracepoint.enable(target_thread: Thread.current)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stop
|
|
29
|
+
return unless @started
|
|
30
|
+
|
|
31
|
+
@tracepoint.disable
|
|
32
|
+
@started = false
|
|
33
|
+
flush_remaining_spans
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def handle_call(tp)
|
|
39
|
+
path = tp.path
|
|
40
|
+
app = app_frame?(path)
|
|
41
|
+
|
|
42
|
+
unless app
|
|
43
|
+
@call_stack.push(:skip)
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
48
|
+
detail = format_detail(tp.defined_class, tp.method_id)
|
|
49
|
+
index = @request_segments.push_span(type: :code, detail: detail, started_at: started_at)
|
|
50
|
+
@call_stack.push(index)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def handle_return
|
|
54
|
+
entry = @call_stack.pop
|
|
55
|
+
return if entry == :skip || entry.nil?
|
|
56
|
+
|
|
57
|
+
@request_segments.pop_span(entry)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def flush_remaining_spans
|
|
61
|
+
@call_stack.reverse_each do |entry|
|
|
62
|
+
next if entry == :skip || entry.nil?
|
|
63
|
+
|
|
64
|
+
@request_segments.pop_span(entry)
|
|
65
|
+
end
|
|
66
|
+
@call_stack.clear
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def app_frame?(path)
|
|
70
|
+
cached = @path_cache[path]
|
|
71
|
+
return cached unless cached.nil?
|
|
72
|
+
|
|
73
|
+
@path_cache[path] = Fingerprint.app_frame?(path)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def format_detail(defined_class, method_id)
|
|
77
|
+
if defined_class.singleton_class?
|
|
78
|
+
owner = defined_class.attached_object
|
|
79
|
+
"#{owner.name || owner.inspect}.#{method_id}"
|
|
80
|
+
else
|
|
81
|
+
"#{defined_class.name || defined_class.inspect}##{method_id}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|