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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ac5b510824ed9364db9d541a92eb95b1b0339c1972e48c2cfc634817d36d2600
4
- data.tar.gz: 6f7f990fd824795ea8b9ef66e51f28e120d45aa960a2d1fb1e42c85f31458021
3
+ metadata.gz: 527b9950df4630b2c22f992c2e3e604eac9365c7faa0ea2a892cac5ed47d69af
4
+ data.tar.gz: d39f675cb7bea8ab51762f78d2319d29e2f88769b37594a45bb0571cdcdd2d32
5
5
  SHA512:
6
- metadata.gz: ca29621896898fcf69b876260bb27f68df164931986695905737fee23080e55183a6440292d3a8f60f2668c417d20cfb39d9219b2882adbaa764a94e1a3345ea
7
- data.tar.gz: e8bc348a4c5fe4028403512a8c96f5b60dbc62676167fc41c56710440688aac206ec4b423c2ccd904a5bebef6428a19835af9e10dcc5961141459a9a0dce1073
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, _bucket_seconds = helpers.parse_range(remembered_range, extra_valid: ['all'])
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
- 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
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 = Catpm::EventBucket.recent(period).to_a
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
- period_minutes = period.to_f / 60
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 = Catpm::EventBucket.by_name(@name).recent(period).to_a
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
- period_minutes = period.to_f / 60
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 = Catpm::Bucket.recent(period).to_a
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
- period_minutes = period.to_f / 60
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="6" fill="transparent" data-value="#{label}"#{time_attr} class="sparkline-dot"/>)
119
+ %(<circle cx="#{x}" cy="#{y}" r="0" data-value="#{label}"#{time_attr} class="sparkline-dot"/>)
120
120
  end.join
121
121
 
122
- %(<svg width="#{width}" height="#{height}" viewBox="0 0 #{width} #{height}" xmlns="http://www.w3.org/2000/svg" style="display:block;position:relative">#{fill_el}<polyline points="#{coords_str.join(" ")}" fill="none" stroke="#{color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>#{circles}</svg>).html_safe
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, extra_valid: [])
211
- valid = RANGE_KEYS + extra_valid
212
- key = valid.include?(range_str) ? range_str : '1h'
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 + ["all"]).each do |r| %>
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
- <div class="grid">
110
- <% type_data.each do |d| %>
111
- <div class="card">
112
- <div class="label">Avg <%= d[:label] %></div>
113
- <div class="value"><%= "%.0f" % d[:avg_count] %></div>
114
- <div class="detail"><%= format_duration(d[:avg_dur]) %> avg</div>
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 avg_other > 0 %>
118
- <div class="card">
119
- <div class="label">Avg Other</div>
120
- <div class="value"><%= format_duration(avg_other) %></div>
121
- <div class="detail">App code, middleware, etc.</div>
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
- <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>
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 &middot; <%= 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
- <%# ─── Bar Chart ─── %>
38
- <h2>Event Volume</h2>
39
- <%= section_description("Events per time slot over the selected range.") %>
40
- <div style="border:1px solid var(--border); border-radius:var(--radius); padding:16px; margin-bottom:24px; position:relative">
41
- <%= bar_chart_svg(@chart_data, width: 600, height: 180, color: "var(--accent)", time_labels: @chart_times) %>
42
- </div>
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 sparkTarget = null;
350
- document.addEventListener('mouseover', function(e) {
351
- if (!e.target.classList.contains('sparkline-dot')) return;
352
- var dot = e.target;
353
- sparkTarget = dot;
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 = dot.dataset.value || '';
356
- var time = dot.dataset.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
- document.addEventListener('mouseout', function(e) {
366
- if (!e.target.classList.contains('sparkline-dot')) return;
367
- sparkTarget = null;
368
- if (sparkTip) sparkTip.style.display = 'none';
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;
@@ -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
- combined.last(Catpm.config.max_error_contexts)
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 - 48 * 3600
94
- cutoff_h = now - 90 * 86400
95
- cutoff_d = now - 2 * 365 * 86400
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