catpm 0.1.4 → 0.3.0

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