catpm 0.3.0 → 0.4.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: 30a794032ccb0fcb32152d5ae6c7285758260c6a80c5241c217924a887040885
4
+ data.tar.gz: 7691f6480aa62e4efd41d657fda9e1501a2765584a3d926a5d9ac3a8b6ac0c98
5
5
  SHA512:
6
- metadata.gz: ca29621896898fcf69b876260bb27f68df164931986695905737fee23080e55183a6440292d3a8f60f2668c417d20cfb39d9219b2882adbaa764a94e1a3345ea
7
- data.tar.gz: e8bc348a4c5fe4028403512a8c96f5b60dbc62676167fc41c56710440688aac206ec4b423c2ccd904a5bebef6428a19835af9e10dcc5961141459a9a0dce1073
6
+ metadata.gz: c902a837f12312f1f856d232b056eb3669c8648f5907e5088ced55972888d64682749f937a994d79b040688d1f59d929dccd11a1d28ffdcfe4e25f2458c02f49
7
+ data.tar.gz: 73dd36a0cb7ea32b405388bb62f54e9f327f30155b3b30794101feb056abe70b7b747cd5dda3dc67e6669e4289392964b37ae76385e6cb680c96e5a5bb550d55
@@ -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>
@@ -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;
@@ -41,7 +41,9 @@ module Catpm
41
41
  :shutdown_timeout,
42
42
  :events_enabled,
43
43
  :events_max_samples_per_name,
44
- :track_own_requests
44
+ :track_own_requests,
45
+ :stack_sample_interval,
46
+ :max_stack_samples_per_request
45
47
 
46
48
  def initialize
47
49
  @enabled = true
@@ -58,7 +60,7 @@ module Catpm
58
60
  @slow_threshold_per_kind = {}
59
61
  @ignored_targets = []
60
62
  @retention_period = nil # nil = keep forever (data is downsampled, not deleted)
61
- @max_buffer_memory = 32.megabytes
63
+ @max_buffer_memory = 8.megabytes
62
64
  @flush_interval = 30 # seconds
63
65
  @flush_jitter = 5 # ±seconds
64
66
  @max_error_contexts = 5
@@ -84,6 +86,8 @@ module Catpm
84
86
  @events_enabled = false
85
87
  @events_max_samples_per_name = 20
86
88
  @track_own_requests = false
89
+ @stack_sample_interval = 0.005 # seconds (5ms)
90
+ @max_stack_samples_per_request = 200
87
91
  end
88
92
 
89
93
  def slow_threshold_for(kind)
data/lib/catpm/event.rb CHANGED
@@ -10,18 +10,24 @@ module Catpm
10
10
  :metadata, :error_class, :error_message, :backtrace,
11
11
  :sample_type, :context, :status
12
12
 
13
+ EMPTY_HASH = {}.freeze
14
+ private_constant :EMPTY_HASH
15
+
13
16
  def initialize(kind:, target:, operation: '', duration: 0.0, started_at: nil,
14
- metadata: {}, error_class: nil, error_message: nil, backtrace: nil,
15
- sample_type: nil, context: {}, status: nil)
17
+ metadata: nil, error_class: nil, error_message: nil, backtrace: nil,
18
+ sample_type: nil, context: nil, status: nil)
16
19
  @kind = kind.to_s
17
20
  @target = target.to_s
18
21
  @operation = (operation || '').to_s
19
22
  @duration = duration.to_f
20
23
  @started_at = started_at || Time.current
21
- @metadata = metadata || {}
24
+ @metadata = metadata || EMPTY_HASH
22
25
  @error_class = error_class
23
26
  @error_message = error_message
24
- @backtrace = backtrace
27
+ @backtrace = if backtrace
28
+ limit = Catpm.config.backtrace_lines
29
+ limit ? backtrace.first(limit) : backtrace
30
+ end
25
31
  @sample_type = sample_type
26
32
  @context = context
27
33
  @status = status
@@ -67,7 +73,7 @@ module Catpm
67
73
  end
68
74
 
69
75
  def metadata_bytes
70
- return 0 if metadata.empty?
76
+ return 0 if metadata.nil? || metadata.empty?
71
77
 
72
78
  metadata.to_json.bytesize + REF_SIZE
73
79
  end
data/lib/catpm/flusher.rb CHANGED
@@ -225,23 +225,49 @@ module Catpm
225
225
 
226
226
 
227
227
  def rotate_samples(samples)
228
+ return samples if samples.empty?
229
+
230
+ # Pre-fetch counts for all endpoints and types in bulk
231
+ endpoint_keys = samples.map { |s| s[:bucket_key][0..2] }.uniq
232
+ error_fps = samples.filter_map { |s| s[:error_fingerprint] }.uniq
233
+
234
+ # Build counts cache: { [kind, target, op, type] => count }
235
+ counts_cache = {}
236
+ if endpoint_keys.any?
237
+ Catpm::Sample.joins(:bucket)
238
+ .where(catpm_buckets: { kind: endpoint_keys.map(&:first), target: endpoint_keys.map { |k| k[1] }, operation: endpoint_keys.map { |k| k[2] } })
239
+ .where(sample_type: %w[random slow])
240
+ .group('catpm_buckets.kind', 'catpm_buckets.target', 'catpm_buckets.operation', 'catpm_samples.sample_type')
241
+ .count
242
+ .each { |(kind, target, op, type), cnt| counts_cache[[kind, target, op, type]] = cnt }
243
+ end
244
+
245
+ error_counts = {}
246
+ if error_fps.any?
247
+ Catpm::Sample.where(sample_type: 'error', error_fingerprint: error_fps)
248
+ .group(:error_fingerprint).count
249
+ .each { |fp, cnt| error_counts[fp] = cnt }
250
+ end
251
+
228
252
  samples.each do |sample|
229
- kind, target, operation = sample[:bucket_key][0], sample[:bucket_key][1], sample[:bucket_key][2]
230
- endpoint_samples = Catpm::Sample
231
- .joins(:bucket)
232
- .where(catpm_buckets: { kind: kind, target: target, operation: operation })
253
+ kind, target, operation = sample[:bucket_key][0..2]
233
254
 
234
255
  case sample[:sample_type]
235
256
  when 'random'
236
- existing = endpoint_samples.where(sample_type: 'random')
237
- if existing.count >= Catpm.config.max_random_samples_per_endpoint
238
- existing.order(recorded_at: :asc).first.destroy
257
+ cache_key = [kind, target, operation, 'random']
258
+ if (counts_cache[cache_key] || 0) >= Catpm.config.max_random_samples_per_endpoint
259
+ oldest = Catpm::Sample.joins(:bucket)
260
+ .where(catpm_buckets: { kind: kind, target: target, operation: operation })
261
+ .where(sample_type: 'random').order(recorded_at: :asc).first
262
+ oldest&.destroy
239
263
  end
240
264
  when 'slow'
241
- existing = endpoint_samples.where(sample_type: 'slow')
242
- if existing.count >= Catpm.config.max_slow_samples_per_endpoint
243
- weakest = existing.order(duration: :asc).first
244
- if sample[:duration] > weakest.duration
265
+ cache_key = [kind, target, operation, 'slow']
266
+ if (counts_cache[cache_key] || 0) >= Catpm.config.max_slow_samples_per_endpoint
267
+ weakest = Catpm::Sample.joins(:bucket)
268
+ .where(catpm_buckets: { kind: kind, target: target, operation: operation })
269
+ .where(sample_type: 'slow').order(duration: :asc).first
270
+ if weakest && sample[:duration] > weakest.duration
245
271
  weakest.destroy
246
272
  else
247
273
  sample[:_skip] = true
@@ -249,11 +275,10 @@ module Catpm
249
275
  end
250
276
  when 'error'
251
277
  fp = sample[:error_fingerprint]
252
- if fp
253
- existing = Catpm::Sample.where(sample_type: 'error', error_fingerprint: fp)
254
- if existing.count >= Catpm.config.max_error_samples_per_fingerprint
255
- existing.order(recorded_at: :asc).first.destroy
256
- end
278
+ if fp && (error_counts[fp] || 0) >= Catpm.config.max_error_samples_per_fingerprint
279
+ oldest = Catpm::Sample.where(sample_type: 'error', error_fingerprint: fp)
280
+ .order(recorded_at: :asc).first
281
+ oldest&.destroy
257
282
  end
258
283
  end
259
284
  end
@@ -267,11 +292,7 @@ module Catpm
267
292
  occurred_at: event.started_at.iso8601,
268
293
  kind: event.kind,
269
294
  operation: event_context.slice(:method, :path, :params, :job_class, :job_id, :queue, :target, :metadata),
270
- backtrace: begin
271
- bt = event.backtrace || []
272
- limit = Catpm.config.backtrace_lines
273
- limit ? bt.first(limit) : bt
274
- end,
295
+ backtrace: event.backtrace || [],
275
296
  duration: event.duration,
276
297
  status: event.status
277
298
  }
@@ -377,60 +398,61 @@ module Catpm
377
398
  cutoff = age_threshold.ago
378
399
  target_seconds = target_interval.to_i
379
400
 
380
- # Find all buckets older than cutoff
381
- source_buckets = Catpm::Bucket.where(bucket_start: ...cutoff).to_a
382
- return if source_buckets.empty?
383
-
384
- # Group by (kind, target, operation) + target-aligned bucket_start
385
- groups = source_buckets.group_by do |bucket|
386
- epoch = bucket.bucket_start.to_i
387
- aligned_epoch = epoch - (epoch % target_seconds)
388
- aligned_start = Time.at(aligned_epoch).utc
389
-
390
- [bucket.kind, bucket.target, bucket.operation, aligned_start]
391
- end
392
-
393
- groups.each do |(kind, target, operation, aligned_start), buckets|
394
- # Skip if only one bucket already at the target alignment
395
- next if buckets.size == 1 && buckets.first.bucket_start.to_i % target_seconds == 0
401
+ # Process in batches to avoid loading all old buckets into memory
402
+ Catpm::Bucket.where(bucket_start: ...cutoff)
403
+ .select(:id, :kind, :target, :operation, :bucket_start)
404
+ .group_by { |b| [b.kind, b.target, b.operation] }
405
+ .each do |(_kind, _target, _operation), endpoint_buckets|
406
+ groups = endpoint_buckets.group_by do |bucket|
407
+ epoch = bucket.bucket_start.to_i
408
+ aligned_epoch = epoch - (epoch % target_seconds)
409
+ Time.at(aligned_epoch).utc
410
+ end
396
411
 
397
- merged = {
398
- kind: kind,
399
- target: target,
400
- operation: operation,
401
- bucket_start: aligned_start,
402
- count: buckets.sum(&:count),
403
- success_count: buckets.sum(&:success_count),
404
- failure_count: buckets.sum(&:failure_count),
405
- duration_sum: buckets.sum(&:duration_sum),
406
- duration_max: buckets.map(&:duration_max).max,
407
- duration_min: buckets.map(&:duration_min).min,
408
- metadata_sum: merge_bucket_metadata(buckets, adapter),
409
- p95_digest: merge_bucket_digests(buckets)
410
- }
411
-
412
- source_ids = buckets.map(&:id)
413
- survivor = buckets.first
414
-
415
- # Reassign all samples to the survivor bucket
416
- Catpm::Sample.where(bucket_id: source_ids).update_all(bucket_id: survivor.id)
417
-
418
- # Delete non-survivor source buckets (now sample-free)
419
- Catpm::Bucket.where(id: source_ids - [survivor.id]).delete_all
420
-
421
- # Overwrite survivor with merged data
422
- survivor.update!(
423
- bucket_start: aligned_start,
424
- count: merged[:count],
425
- success_count: merged[:success_count],
426
- failure_count: merged[:failure_count],
427
- duration_sum: merged[:duration_sum],
428
- duration_max: merged[:duration_max],
429
- duration_min: merged[:duration_min],
430
- metadata_sum: merged[:metadata_sum],
431
- p95_digest: merged[:p95_digest]
432
- )
433
- end
412
+ groups.each do |aligned_start, stub_buckets|
413
+ next if stub_buckets.size == 1 && stub_buckets.first.bucket_start.to_i % target_seconds == 0
414
+
415
+ # Load full records only for groups that need merging
416
+ bucket_ids = stub_buckets.map(&:id)
417
+ buckets = Catpm::Bucket.where(id: bucket_ids).to_a
418
+
419
+ merged = {
420
+ kind: buckets.first.kind,
421
+ target: buckets.first.target,
422
+ operation: buckets.first.operation,
423
+ bucket_start: aligned_start,
424
+ count: buckets.sum(&:count),
425
+ success_count: buckets.sum(&:success_count),
426
+ failure_count: buckets.sum(&:failure_count),
427
+ duration_sum: buckets.sum(&:duration_sum),
428
+ duration_max: buckets.map(&:duration_max).max,
429
+ duration_min: buckets.map(&:duration_min).min,
430
+ metadata_sum: merge_bucket_metadata(buckets, adapter),
431
+ p95_digest: merge_bucket_digests(buckets)
432
+ }
433
+
434
+ survivor = buckets.first
435
+
436
+ # Reassign all samples to the survivor bucket
437
+ Catpm::Sample.where(bucket_id: bucket_ids).update_all(bucket_id: survivor.id)
438
+
439
+ # Delete non-survivor source buckets (now sample-free)
440
+ Catpm::Bucket.where(id: bucket_ids - [survivor.id]).delete_all
441
+
442
+ # Overwrite survivor with merged data
443
+ survivor.update!(
444
+ bucket_start: aligned_start,
445
+ count: merged[:count],
446
+ success_count: merged[:success_count],
447
+ failure_count: merged[:failure_count],
448
+ duration_sum: merged[:duration_sum],
449
+ duration_max: merged[:duration_max],
450
+ duration_min: merged[:duration_min],
451
+ metadata_sum: merged[:metadata_sum],
452
+ p95_digest: merged[:p95_digest]
453
+ )
454
+ end
455
+ end
434
456
  end
435
457
 
436
458
  def downsample_event_tier(target_interval:, age_threshold:, adapter:)
@@ -171,6 +171,8 @@ module Catpm
171
171
 
172
172
  duration = event.duration
173
173
  sql = payload[:sql].to_s
174
+ max_len = Catpm.config.max_sql_length
175
+ sql = sql.truncate(max_len) if max_len && sql.length > max_len
174
176
  source = duration >= Catpm.config.segment_source_threshold ? extract_source_location : nil
175
177
 
176
178
  req_segments.add(
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Catpm
4
4
  class StackSampler
5
- SAMPLE_INTERVAL = 0.005 # 5ms
5
+ MS_PER_SECOND = 1000.0
6
6
 
7
7
  # Single global thread that samples all active requests.
8
8
  # Avoids creating a thread per request.
@@ -29,7 +29,7 @@ module Catpm
29
29
  def start_thread
30
30
  @thread = Thread.new do
31
31
  loop do
32
- sleep(SAMPLE_INTERVAL)
32
+ sleep(Catpm.config.stack_sample_interval)
33
33
  sample_all
34
34
  end
35
35
  end
@@ -65,6 +65,9 @@ module Catpm
65
65
 
66
66
  # Called by SamplingLoop from the global thread
67
67
  def capture(now)
68
+ max = Catpm.config.max_stack_samples_per_request
69
+ return if max && @samples.size >= max
70
+
68
71
  locs = @target.backtrace_locations
69
72
  @samples << [now, locs] if locs
70
73
  end
@@ -117,7 +120,7 @@ module Catpm
117
120
  duration = estimate_duration(group)
118
121
  next if duration < 1.0
119
122
 
120
- offset = ((group[:start_time] - @request_start) * 1000.0).round(2)
123
+ offset = ((group[:start_time] - @request_start) * MS_PER_SECOND).round(2)
121
124
  app_frame = group[:app_frame]
122
125
  leaf = group[:leaves].first&.last
123
126
 
@@ -199,8 +202,8 @@ module Catpm
199
202
 
200
203
  spans.filter_map do |span|
201
204
  duration = [
202
- (span[:end_time] - span[:start_time]) * 1000.0,
203
- span[:count] * SAMPLE_INTERVAL * 1000.0
205
+ (span[:end_time] - span[:start_time]) * MS_PER_SECOND,
206
+ span[:count] * Catpm.config.stack_sample_interval * MS_PER_SECOND
204
207
  ].max
205
208
  next if duration < 1.0
206
209
 
@@ -211,7 +214,7 @@ module Catpm
211
214
  type: classify_path(path),
212
215
  detail: build_gem_detail(frame),
213
216
  duration: duration.round(2),
214
- offset: ((span[:start_time] - @request_start) * 1000.0).round(2),
217
+ offset: ((span[:start_time] - @request_start) * MS_PER_SECOND).round(2),
215
218
  started_at: span[:start_time]
216
219
  }
217
220
  end
@@ -219,8 +222,8 @@ module Catpm
219
222
 
220
223
  def estimate_duration(group)
221
224
  [
222
- (group[:end_time] - group[:start_time]) * 1000.0,
223
- group[:count] * SAMPLE_INTERVAL * 1000.0
225
+ (group[:end_time] - group[:start_time]) * MS_PER_SECOND,
226
+ group[:count] * Catpm.config.stack_sample_interval * MS_PER_SECOND
224
227
  ].max
225
228
  end
226
229
 
data/lib/catpm/tdigest.rb CHANGED
@@ -12,6 +12,7 @@ module Catpm
12
12
  Centroid = Struct.new(:mean, :weight)
13
13
 
14
14
  COMPRESSION = 100 # Controls accuracy vs. memory trade-off
15
+ BUFFER_FLUSH_FACTOR = 2 # Lower = more frequent flushes (better accuracy), higher = fewer flushes (better performance)
15
16
 
16
17
  attr_reader :count
17
18
 
@@ -22,7 +23,7 @@ module Catpm
22
23
  @min = Float::INFINITY
23
24
  @max = -Float::INFINITY
24
25
  @buffer = []
25
- @buffer_limit = @compression * 5
26
+ @buffer_limit = @compression * BUFFER_FLUSH_FACTOR
26
27
  end
27
28
 
28
29
  def add(value, weight = 1)
data/lib/catpm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Catpm
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: catpm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''