catpm 0.1.4 → 0.2.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: 4ee3b0b066416fbb74d3bdecd0397443272118d67a07b339e0d61a7c2f408351
4
+ data.tar.gz: 5b355e9a18cde63cd975c49300f8f56efa4d8a079e855a3747de518965ff1611
5
5
  SHA512:
6
- metadata.gz: c686066f30115d154c8d05e0a9a47d69c09a4221e02a2c46550c144426914da2aa34f2e6df7bd601326f7e43b009823a9956224afa10aa28536eb0f5e1495021
7
- data.tar.gz: 29581810d90caf496eb63f8e28493bbed6783919f48c134cada3fc7141540a74dc4bb84c8d933b28a0f44d5d6a7510fa514a686163794bc66b056c548e64a879
6
+ metadata.gz: '0811e5ecba637add9c81496c180eb65fd4d8864761bb4c4d5c40b0be40ad9f3ca7e5b6e9ce120316ad2ed17f144e68b8953cf3d2ce2721f2b942a9e43aebece4'
7
+ data.tar.gz: 43434262c2f712032f5cf7e2205ccf2db0638d48c672e89dd2787b90258c1cd861353d21b62b504b53daa576501614e8c3aa1b6105ed6fc8e4655056120478d5
@@ -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(params[:range] || '24h')
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
@@ -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
@@ -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 = {
@@ -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
@@ -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,6 +28,10 @@
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
37
  <%# ─── Request Context + Full JSON side by side ─── %>
@@ -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>
@@ -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
@@ -116,16 +116,22 @@ module Catpm
116
116
  merged_contexts = merge_contexts(
117
117
  existing.parsed_contexts, error_data[:new_contexts]
118
118
  )
119
+ merged_buckets = merge_occurrence_buckets(
120
+ existing.occurrence_buckets, error_data[:occurrence_times]
121
+ )
119
122
 
120
123
  attrs = {
121
124
  occurrences_count: existing.occurrences_count + error_data[:occurrences_count],
122
125
  last_occurred_at: [existing.last_occurred_at, error_data[:last_occurred_at]].max,
123
- contexts: merged_contexts.to_json
126
+ contexts: merged_contexts.to_json,
127
+ occurrence_buckets: merged_buckets.to_json
124
128
  }
125
129
  attrs[:resolved_at] = nil if existing.resolved?
126
130
 
127
131
  existing.update!(attrs)
128
132
  else
133
+ initial_buckets = merge_occurrence_buckets(nil, error_data[:occurrence_times])
134
+
129
135
  Catpm::ErrorRecord.create!(
130
136
  fingerprint: error_data[:fingerprint],
131
137
  kind: error_data[:kind],
@@ -134,7 +140,8 @@ module Catpm
134
140
  occurrences_count: error_data[:occurrences_count],
135
141
  first_occurred_at: error_data[:first_occurred_at],
136
142
  last_occurred_at: error_data[:last_occurred_at],
137
- contexts: error_data[:new_contexts].to_json
143
+ contexts: error_data[:new_contexts].to_json,
144
+ occurrence_buckets: initial_buckets.to_json
138
145
  )
139
146
  end
140
147
  end
@@ -8,7 +8,7 @@ module Catpm
8
8
 
9
9
  payload = event.payload
10
10
  target = "#{payload[:controller]}##{payload[:action]}"
11
- return if target.start_with?('Catpm::')
11
+ return if !Catpm.config.track_own_requests && target.start_with?('Catpm::')
12
12
  return if Catpm.config.ignored?(target)
13
13
 
14
14
  duration = event.duration # milliseconds
@@ -95,6 +95,26 @@ module Catpm
95
95
 
96
96
  # Use full request duration (including middleware) for the event
97
97
  duration = total_request_duration
98
+
99
+ # Append error marker segment inside the controller
100
+ if payload[:exception]
101
+ error_parent = ctrl_idx || 0
102
+ error_offset = if ctrl_idx
103
+ ctrl = segments[ctrl_idx]
104
+ ((ctrl[:offset] || 0) + (ctrl[:duration] || 0)).round(2)
105
+ else
106
+ duration.round(2)
107
+ end
108
+
109
+ context[:segments] << {
110
+ type: 'error',
111
+ detail: "#{payload[:exception].first}: #{payload[:exception].last}".truncate(200),
112
+ source: payload[:exception_object]&.backtrace&.first,
113
+ duration: 0,
114
+ offset: error_offset,
115
+ parent_index: error_parent
116
+ }
117
+ end
98
118
  end
99
119
 
100
120
  ev = Event.new(
@@ -205,6 +225,26 @@ module Catpm
205
225
  segment_data[:segment_summary]&.each do |k, v|
206
226
  metadata[k] = v
207
227
  end
228
+
229
+ # Append error marker segment inside the controller
230
+ if error
231
+ error_parent = ctrl_idx || 0
232
+ error_offset = if ctrl_idx
233
+ ctrl = segments[ctrl_idx]
234
+ ((ctrl[:offset] || 0) + (ctrl[:duration] || 0)).round(2)
235
+ else
236
+ duration.round(2)
237
+ end
238
+
239
+ context[:segments] << {
240
+ type: 'error',
241
+ detail: "#{error.class.name}: #{error.message}".truncate(200),
242
+ source: error.backtrace&.first,
243
+ duration: 0,
244
+ offset: error_offset,
245
+ parent_index: error_parent
246
+ }
247
+ end
208
248
  end
209
249
 
210
250
  ev = Event.new(
@@ -31,6 +31,7 @@ module Catpm
31
31
  :random_sample_rate,
32
32
  :max_random_samples_per_endpoint,
33
33
  :max_slow_samples_per_endpoint,
34
+ :max_error_samples_per_fingerprint,
34
35
  :cleanup_interval,
35
36
  :circuit_breaker_failure_threshold,
36
37
  :circuit_breaker_recovery_timeout,
@@ -39,7 +40,8 @@ module Catpm
39
40
  :backtrace_lines,
40
41
  :shutdown_timeout,
41
42
  :events_enabled,
42
- :events_max_samples_per_name
43
+ :events_max_samples_per_name,
44
+ :track_own_requests
43
45
 
44
46
  def initialize
45
47
  @enabled = true
@@ -71,15 +73,17 @@ module Catpm
71
73
  @random_sample_rate = 20
72
74
  @max_random_samples_per_endpoint = 5
73
75
  @max_slow_samples_per_endpoint = 5
76
+ @max_error_samples_per_fingerprint = 20
74
77
  @cleanup_interval = 1.hour
75
78
  @circuit_breaker_failure_threshold = 5
76
79
  @circuit_breaker_recovery_timeout = 60 # seconds
77
80
  @sqlite_busy_timeout = 5_000 # milliseconds
78
81
  @persistence_batch_size = 100
79
- @backtrace_lines = 10
82
+ @backtrace_lines = nil
80
83
  @shutdown_timeout = 5 # seconds
81
84
  @events_enabled = false
82
85
  @events_max_samples_per_name = 20
86
+ @track_own_requests = false
83
87
  end
84
88
 
85
89
  def slow_threshold_for(kind)
data/lib/catpm/event.rb CHANGED
@@ -45,7 +45,7 @@ module Catpm
45
45
  end
46
46
 
47
47
  def success?
48
- !error? && (status.nil? || (200..399).cover?(status.to_i))
48
+ !error?
49
49
  end
50
50
 
51
51
  def bucket_start
data/lib/catpm/flusher.rb CHANGED
@@ -155,10 +155,20 @@ module Catpm
155
155
  # TDigest
156
156
  bucket[:tdigest].add(event.duration)
157
157
 
158
+ # Compute error fingerprint (used for both samples and error grouping)
159
+ error_fp = nil
160
+ if event.error?
161
+ error_fp = Catpm::Fingerprint.generate(
162
+ kind: event.kind,
163
+ error_class: event.error_class,
164
+ backtrace: event.backtrace
165
+ )
166
+ end
167
+
158
168
  # Collect samples
159
169
  sample_type = determine_sample_type(event)
160
170
  if sample_type
161
- samples << {
171
+ sample_hash = {
162
172
  bucket_key: key,
163
173
  kind: event.kind,
164
174
  sample_type: sample_type,
@@ -166,29 +176,27 @@ module Catpm
166
176
  duration: event.duration,
167
177
  context: event.context
168
178
  }
179
+ sample_hash[:error_fingerprint] = error_fp if error_fp
180
+ samples << sample_hash
169
181
  end
170
182
 
171
183
  # Error grouping
172
- if event.error?
173
- fp = Catpm::Fingerprint.generate(
174
- kind: event.kind,
175
- error_class: event.error_class,
176
- backtrace: event.backtrace
177
- )
178
-
179
- error = error_groups[fp] ||= {
180
- fingerprint: fp,
184
+ if error_fp
185
+ error = error_groups[error_fp] ||= {
186
+ fingerprint: error_fp,
181
187
  kind: event.kind,
182
188
  error_class: event.error_class,
183
189
  message: event.error_message,
184
190
  occurrences_count: 0,
185
191
  first_occurred_at: event.started_at,
186
192
  last_occurred_at: event.started_at,
187
- new_contexts: []
193
+ new_contexts: [],
194
+ occurrence_times: []
188
195
  }
189
196
 
190
197
  error[:occurrences_count] += 1
191
198
  error[:last_occurred_at] = [ error[:last_occurred_at], event.started_at ].max
199
+ error[:occurrence_times] << event.started_at
192
200
 
193
201
  if error[:new_contexts].size < Catpm.config.max_error_contexts
194
202
  error[:new_contexts] << build_error_context(event)
@@ -265,6 +273,14 @@ module Catpm
265
273
  sample[:_skip] = true
266
274
  end
267
275
  end
276
+ when 'error'
277
+ fp = sample[:error_fingerprint]
278
+ if fp
279
+ existing = Catpm::Sample.where(sample_type: 'error', error_fingerprint: fp)
280
+ if existing.count >= Catpm.config.max_error_samples_per_fingerprint
281
+ existing.order(recorded_at: :asc).first.destroy
282
+ end
283
+ end
268
284
  end
269
285
  end
270
286
 
@@ -276,7 +292,11 @@ module Catpm
276
292
  occurred_at: event.started_at.iso8601,
277
293
  kind: event.kind,
278
294
  operation: event.context.slice(:method, :path, :params, :job_class, :job_id, :queue, :target, :metadata),
279
- backtrace: (event.backtrace || []).first(Catpm.config.backtrace_lines),
295
+ backtrace: begin
296
+ bt = event.backtrace || []
297
+ limit = Catpm.config.backtrace_lines
298
+ limit ? bt.first(limit) : bt
299
+ end,
280
300
  duration: event.duration,
281
301
  status: event.status
282
302
  }
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.1.4'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -20,6 +20,7 @@ Catpm.configure do |config|
20
20
  config.instrument_http = true # Track HTTP requests (default: true)
21
21
  config.instrument_jobs = false # Track ActiveJob (default: false)
22
22
  config.instrument_segments = true # Track SQL/view/cache segments (default: true)
23
+ # config.track_own_requests = false # Track catpm dashboard requests (default: false)
23
24
  # config.instrument_net_http = false # Patch Net::HTTP for outbound tracking (default: false)
24
25
  # config.instrument_middleware_stack = false # Decompose middleware into per-middleware segments (default: false)
25
26
  # config.max_segments_per_request = 50 # Cap segments per request (keeps slowest)
@@ -1,6 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # desc "Explaining what the task does"
4
- # task :catpm do
5
- # # Task goes here
6
- # end
3
+ namespace :catpm do
4
+ desc 'Add missing columns to catpm tables (safe to run multiple times)'
5
+ task upgrade: :environment do
6
+ connection = ActiveRecord::Base.connection
7
+
8
+ unless connection.column_exists?(:catpm_samples, :error_fingerprint)
9
+ connection.add_column :catpm_samples, :error_fingerprint, :string, limit: 64
10
+ connection.add_index :catpm_samples, :error_fingerprint, name: 'idx_catpm_samples_error_fp'
11
+ puts '[catpm] Added error_fingerprint column to catpm_samples'
12
+ else
13
+ puts '[catpm] catpm_samples.error_fingerprint already exists, skipping'
14
+ end
15
+
16
+ unless connection.column_exists?(:catpm_errors, :occurrence_buckets)
17
+ connection.add_column :catpm_errors, :occurrence_buckets, :json
18
+ puts '[catpm] Added occurrence_buckets column to catpm_errors'
19
+ else
20
+ puts '[catpm] catpm_errors.occurrence_buckets already exists, skipping'
21
+ end
22
+ end
23
+ 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.1.4
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''