catpm 0.1.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +222 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/catpm/application.css +15 -0
- data/app/controllers/catpm/application_controller.rb +6 -0
- data/app/controllers/catpm/endpoints_controller.rb +63 -0
- data/app/controllers/catpm/errors_controller.rb +63 -0
- data/app/controllers/catpm/events_controller.rb +89 -0
- data/app/controllers/catpm/samples_controller.rb +13 -0
- data/app/controllers/catpm/status_controller.rb +79 -0
- data/app/controllers/catpm/system_controller.rb +17 -0
- data/app/helpers/catpm/application_helper.rb +264 -0
- data/app/jobs/catpm/application_job.rb +6 -0
- data/app/mailers/catpm/application_mailer.rb +8 -0
- data/app/models/catpm/application_record.rb +7 -0
- data/app/models/catpm/bucket.rb +45 -0
- data/app/models/catpm/error_record.rb +37 -0
- data/app/models/catpm/event_bucket.rb +12 -0
- data/app/models/catpm/event_sample.rb +22 -0
- data/app/models/catpm/sample.rb +26 -0
- data/app/views/catpm/endpoints/_sample_table.html.erb +36 -0
- data/app/views/catpm/endpoints/show.html.erb +124 -0
- data/app/views/catpm/errors/index.html.erb +66 -0
- data/app/views/catpm/errors/show.html.erb +107 -0
- data/app/views/catpm/events/index.html.erb +73 -0
- data/app/views/catpm/events/show.html.erb +86 -0
- data/app/views/catpm/samples/show.html.erb +113 -0
- data/app/views/catpm/shared/_page_nav.html.erb +6 -0
- data/app/views/catpm/shared/_segments_waterfall.html.erb +147 -0
- data/app/views/catpm/status/index.html.erb +124 -0
- data/app/views/catpm/system/index.html.erb +454 -0
- data/app/views/layouts/catpm/application.html.erb +381 -0
- data/config/routes.rb +19 -0
- data/db/migrate/20250601000001_create_catpm_tables.rb +104 -0
- data/lib/catpm/adapter/base.rb +85 -0
- data/lib/catpm/adapter/postgresql.rb +186 -0
- data/lib/catpm/adapter/sqlite.rb +159 -0
- data/lib/catpm/adapter.rb +28 -0
- data/lib/catpm/auto_instrument.rb +145 -0
- data/lib/catpm/buffer.rb +59 -0
- data/lib/catpm/circuit_breaker.rb +60 -0
- data/lib/catpm/collector.rb +320 -0
- data/lib/catpm/configuration.rb +103 -0
- data/lib/catpm/custom_event.rb +37 -0
- data/lib/catpm/engine.rb +39 -0
- data/lib/catpm/errors.rb +6 -0
- data/lib/catpm/event.rb +75 -0
- data/lib/catpm/fingerprint.rb +52 -0
- data/lib/catpm/flusher.rb +462 -0
- data/lib/catpm/lifecycle.rb +76 -0
- data/lib/catpm/middleware.rb +75 -0
- data/lib/catpm/middleware_probe.rb +28 -0
- data/lib/catpm/patches/httpclient.rb +44 -0
- data/lib/catpm/patches/net_http.rb +39 -0
- data/lib/catpm/request_segments.rb +101 -0
- data/lib/catpm/segment_subscribers.rb +242 -0
- data/lib/catpm/span_helpers.rb +51 -0
- data/lib/catpm/stack_sampler.rb +226 -0
- data/lib/catpm/subscribers.rb +47 -0
- data/lib/catpm/tdigest.rb +174 -0
- data/lib/catpm/trace.rb +165 -0
- data/lib/catpm/version.rb +5 -0
- data/lib/catpm.rb +66 -0
- data/lib/generators/catpm/install_generator.rb +36 -0
- data/lib/generators/catpm/templates/initializer.rb.tt +77 -0
- data/lib/tasks/catpm_seed.rake +79 -0
- data/lib/tasks/catpm_tasks.rake +6 -0
- metadata +123 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
module ApplicationHelper
|
|
5
|
+
# Soft pastel palette — bars, breakdown, waterfall
|
|
6
|
+
SEGMENT_COLORS = {
|
|
7
|
+
'sql' => '#b8e4c6', 'view' => '#e4d4f4', 'cache' => '#fdd8b5',
|
|
8
|
+
'http' => '#f9c4c0', 'mailer' => '#e4d4f4', 'storage' => '#fdd8b5',
|
|
9
|
+
'custom' => '#dde2e8', 'code' => '#c8daf0', 'gem' => '#f0e0f0', 'other' => '#e8e8e8', 'controller' => '#b6d9f7',
|
|
10
|
+
'middleware' => '#f0dfa0', 'request' => '#b6d9f7'
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
SEGMENT_TEXT_COLORS = {
|
|
14
|
+
'sql' => '#1a7f37', 'view' => '#6639a6', 'cache' => '#953800',
|
|
15
|
+
'http' => '#a1110a', 'mailer' => '#6639a6', 'storage' => '#953800',
|
|
16
|
+
'custom' => '#4b5563', 'code' => '#3b5998', 'gem' => '#7b3f9e', 'other' => '#9ca3af', 'controller' => '#0550ae',
|
|
17
|
+
'middleware' => '#7c5c00', 'request' => '#0550ae'
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
BADGE_CLASSES = {
|
|
21
|
+
'http' => 'badge-http', 'job' => 'badge-job', 'custom' => 'badge-custom',
|
|
22
|
+
'sql' => 'badge-sql', 'view' => 'badge-view', 'cache' => 'badge-cache',
|
|
23
|
+
'mailer' => 'badge-mailer', 'storage' => 'badge-storage',
|
|
24
|
+
'error' => 'badge-error', 'slow' => 'badge-slow', 'random' => 'badge-random'
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
SAMPLE_TYPE_LABELS = {
|
|
28
|
+
'random' => 'sample', 'slow' => 'slow', 'error' => 'error'
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
SEGMENT_LABELS = {
|
|
32
|
+
'sql' => 'SQL Queries', 'view' => 'View Renders', 'cache' => 'Cache Ops',
|
|
33
|
+
'http' => 'HTTP Calls', 'mailer' => 'Mailer', 'storage' => 'Storage',
|
|
34
|
+
'custom' => 'Custom', 'code' => 'App Code', 'gem' => 'Gems', 'other' => 'Untracked',
|
|
35
|
+
'controller' => 'Controller', 'middleware' => 'Middleware', 'request' => 'Request'
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
RANGES = {
|
|
39
|
+
'1h' => [1.hour, 60],
|
|
40
|
+
'6h' => [6.hours, 360],
|
|
41
|
+
'24h' => [24.hours, 1440],
|
|
42
|
+
'1w' => [1.week, (1.week / 60).to_i],
|
|
43
|
+
'2w' => [2.weeks, (2.weeks / 60).to_i],
|
|
44
|
+
'1m' => [30.days, (30.days / 60).to_i],
|
|
45
|
+
'1y' => [365.days, (365.days / 60).to_i]
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
RANGE_KEYS = RANGES.keys.freeze
|
|
49
|
+
|
|
50
|
+
def segment_colors
|
|
51
|
+
SEGMENT_COLORS
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def segment_labels
|
|
55
|
+
SEGMENT_LABELS
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def segment_text_colors
|
|
59
|
+
SEGMENT_TEXT_COLORS
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def type_badge(type)
|
|
63
|
+
type_str = type.to_s
|
|
64
|
+
display = SAMPLE_TYPE_LABELS[type_str] || type_str
|
|
65
|
+
css = BADGE_CLASSES[type_str] || ''
|
|
66
|
+
%(<span class="badge #{css}">#{ERB::Util.html_escape(display)}</span>).html_safe
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def format_duration(ms)
|
|
70
|
+
ms = ms.to_f
|
|
71
|
+
if ms >= 1000
|
|
72
|
+
'%.2fs' % (ms / 1000.0)
|
|
73
|
+
else
|
|
74
|
+
'%.1fms' % ms
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def status_badge(status_val)
|
|
79
|
+
return '' unless status_val
|
|
80
|
+
s = status_val.to_i
|
|
81
|
+
css = s >= 500 ? 'badge-err' : s >= 400 ? 'badge-warn' : 'badge-ok'
|
|
82
|
+
%(<span class="badge #{css}">#{status_val}</span>).html_safe
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def segment_count_summary(summary)
|
|
86
|
+
return '' if summary.blank?
|
|
87
|
+
parts = SEGMENT_COLORS.keys.filter_map do |type|
|
|
88
|
+
count = (summary["#{type}_count"] || summary[:"#{type}_count"] || 0).to_i
|
|
89
|
+
next if count == 0
|
|
90
|
+
"#{count} #{type}"
|
|
91
|
+
end
|
|
92
|
+
parts.join(', ')
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def sparkline_svg(data_points, width: 120, height: 48, color: '#539bf5', fill: false, labels: nil, time_labels: nil)
|
|
96
|
+
return '' if data_points.blank?
|
|
97
|
+
points = data_points.map(&:to_f)
|
|
98
|
+
max_val = points.max
|
|
99
|
+
max_val = 1.0 if max_val == 0
|
|
100
|
+
step = width.to_f / [points.size - 1, 1].max
|
|
101
|
+
|
|
102
|
+
parsed = points.each_with_index.map do |val, i|
|
|
103
|
+
x = (i * step).round(1)
|
|
104
|
+
y = (height - (val / max_val * (height - 4)) - 2).round(1)
|
|
105
|
+
[x, y, val]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
coords_str = parsed.map { |x, y, _| "#{x},#{y}" }
|
|
109
|
+
|
|
110
|
+
fill_el = ''
|
|
111
|
+
if fill
|
|
112
|
+
fill_coords = coords_str + ["#{width},#{height}", "0,#{height}"]
|
|
113
|
+
fill_el = %(<polygon points="#{fill_coords.join(" ")}" fill="#{color}" opacity="0.08"/>)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
circles = parsed.map.with_index do |(x, y, val), i|
|
|
117
|
+
label = labels ? labels[i] : val.is_a?(Float) ? ('%.1f' % val) : val
|
|
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"/>)
|
|
120
|
+
end.join
|
|
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
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def bar_chart_svg(data_points, width: 600, height: 200, color: 'var(--accent)', time_labels: nil)
|
|
126
|
+
return '' if data_points.blank?
|
|
127
|
+
points = data_points.map(&:to_i)
|
|
128
|
+
max_val = points.max
|
|
129
|
+
max_val = 1 if max_val == 0
|
|
130
|
+
bar_count = points.size
|
|
131
|
+
gap = 2
|
|
132
|
+
bar_width = ((width.to_f - (bar_count - 1) * gap) / bar_count).round(2)
|
|
133
|
+
bar_width = [bar_width, 1].max
|
|
134
|
+
|
|
135
|
+
bars = points.each_with_index.map do |val, i|
|
|
136
|
+
x = (i * (bar_width + gap)).round(2)
|
|
137
|
+
bar_h = (val.to_f / max_val * (height - 20)).round(2)
|
|
138
|
+
y = height - bar_h
|
|
139
|
+
time_attr = time_labels ? %( data-time="#{time_labels[i]}") : ''
|
|
140
|
+
rx = [bar_width / 4, 2].min.round(1)
|
|
141
|
+
# Visible bar
|
|
142
|
+
bar = %(<rect x="#{x}" y="#{y}" width="#{bar_width}" height="#{bar_h}" fill="#{color}" rx="#{rx}" ry="#{rx}" opacity="0.85"/>)
|
|
143
|
+
# Invisible hover target (full height)
|
|
144
|
+
hover = %(<rect x="#{x}" y="0" width="#{bar_width}" height="#{height}" fill="transparent" data-value="#{val}"#{time_attr} class="sparkline-dot"/>)
|
|
145
|
+
bar + hover
|
|
146
|
+
end.join
|
|
147
|
+
|
|
148
|
+
# Gridlines
|
|
149
|
+
gridlines = [0.25, 0.5, 0.75].map do |pct|
|
|
150
|
+
gy = (height - pct * (height - 20)).round(1)
|
|
151
|
+
%(<line x1="0" y1="#{gy}" x2="#{width}" y2="#{gy}" stroke="var(--border)" stroke-width="0.5" stroke-dasharray="4,3"/>)
|
|
152
|
+
end.join
|
|
153
|
+
|
|
154
|
+
svg = %(<svg width="100%" height="#{height}" viewBox="0 0 #{width} #{height}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" style="display:block">#{gridlines}#{bars}</svg>)
|
|
155
|
+
max_label = %(<span class="bar-chart-max">#{max_val}</span>)
|
|
156
|
+
|
|
157
|
+
%(<div class="bar-chart-wrap">#{svg}#{max_label}</div>).html_safe
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def relative_time(time)
|
|
161
|
+
return '—' unless time
|
|
162
|
+
time = Time.parse(time) if time.is_a?(String)
|
|
163
|
+
diff = (Time.current - time).to_i
|
|
164
|
+
if diff < 60
|
|
165
|
+
'just now'
|
|
166
|
+
elsif diff < 3600
|
|
167
|
+
"#{diff / 60}m ago"
|
|
168
|
+
elsif diff < 86_400
|
|
169
|
+
"#{diff / 3600}h ago"
|
|
170
|
+
elsif diff < 172_800
|
|
171
|
+
'yesterday'
|
|
172
|
+
elsif diff < 604_800
|
|
173
|
+
"#{diff / 86_400}d ago"
|
|
174
|
+
else
|
|
175
|
+
time.strftime('%b %-d')
|
|
176
|
+
end
|
|
177
|
+
rescue ArgumentError, TypeError
|
|
178
|
+
'—'
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def time_with_tooltip(time)
|
|
182
|
+
return '—' unless time
|
|
183
|
+
time = Time.parse(time) if time.is_a?(String)
|
|
184
|
+
full = time.strftime('%Y-%m-%d %H:%M:%S')
|
|
185
|
+
rel = relative_time(time)
|
|
186
|
+
%(<span title="#{full}" class="time-rel">#{rel}</span>).html_safe
|
|
187
|
+
rescue ArgumentError, TypeError
|
|
188
|
+
'—'
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def sort_header(label, column, current_sort, current_dir, extra_params: {})
|
|
192
|
+
active = current_sort == column.to_s
|
|
193
|
+
new_dir = active && current_dir == 'asc' ? 'desc' : 'asc'
|
|
194
|
+
arrow = active ? (current_dir == 'asc' ? ' ▲' : ' ▼') : ''
|
|
195
|
+
params_hash = extra_params.merge(sort: column, dir: new_dir)
|
|
196
|
+
url = '?' + params_hash.map { |k, v| "#{k}=#{v}" }.join('&')
|
|
197
|
+
%(<a href="#{url}" class="sort-link#{active ? ' active' : ''}">#{label}#{arrow}</a>).html_safe
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def section_description(text)
|
|
201
|
+
%(<p class="section-desc">#{text}</p>).html_safe
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def status_dot(resolved)
|
|
205
|
+
color = resolved ? 'var(--green)' : 'var(--red)'
|
|
206
|
+
label = resolved ? 'Resolved' : 'Active'
|
|
207
|
+
%(<span class="status-dot"><span class="dot" style="background:#{color}"></span> #{label}</span>).html_safe
|
|
208
|
+
end
|
|
209
|
+
|
|
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
|
+
period, bucket_seconds = RANGES[key]
|
|
215
|
+
[key, period, bucket_seconds]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def range_label(range)
|
|
219
|
+
case range
|
|
220
|
+
when '6h' then 'Last 6 hours'
|
|
221
|
+
when '24h' then 'Last 24 hours'
|
|
222
|
+
when '1w' then 'Last week'
|
|
223
|
+
when '2w' then 'Last 2 weeks'
|
|
224
|
+
when '1m' then 'Last month'
|
|
225
|
+
when '1y' then 'Last year'
|
|
226
|
+
else 'Last hour'
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def pagination_nav(current_page, total_count, per_page, extra_params: {})
|
|
231
|
+
total_pages = (total_count.to_f / per_page).ceil
|
|
232
|
+
return '' if total_pages <= 1
|
|
233
|
+
|
|
234
|
+
prev_params = extra_params.merge(page: current_page - 1)
|
|
235
|
+
next_params = extra_params.merge(page: current_page + 1)
|
|
236
|
+
prev_url = '?' + prev_params.compact.map { |k, v| "#{k}=#{v}" }.join('&')
|
|
237
|
+
next_url = '?' + next_params.compact.map { |k, v| "#{k}=#{v}" }.join('&')
|
|
238
|
+
|
|
239
|
+
html = '<div class="pagination">'
|
|
240
|
+
if current_page > 1
|
|
241
|
+
html << %(<a href="#{prev_url}" class="btn">← Previous</a>)
|
|
242
|
+
else
|
|
243
|
+
html << '<span class="btn" style="opacity:0.3;cursor:default">← Previous</span>'
|
|
244
|
+
end
|
|
245
|
+
html << %(<span class="pagination-info">Page #{current_page} of #{total_pages}</span>)
|
|
246
|
+
if current_page < total_pages
|
|
247
|
+
html << %(<a href="#{next_url}" class="btn">Next →</a>)
|
|
248
|
+
else
|
|
249
|
+
html << '<span class="btn" style="opacity:0.3;cursor:default">Next →</span>'
|
|
250
|
+
end
|
|
251
|
+
html << '</div>'
|
|
252
|
+
html.html_safe
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def trend_indicator(error)
|
|
256
|
+
return '' unless error.last_occurred_at
|
|
257
|
+
if error.last_occurred_at > 1.hour.ago
|
|
258
|
+
%(<span title="Active in the last hour" style="color:var(--red)">↑</span>).html_safe
|
|
259
|
+
else
|
|
260
|
+
%(<span title="Quiet" style="color:var(--text-2)">—</span>).html_safe
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
class Bucket < ApplicationRecord
|
|
5
|
+
self.table_name = 'catpm_buckets'
|
|
6
|
+
|
|
7
|
+
has_many :samples, class_name: 'Catpm::Sample', foreign_key: :bucket_id, dependent: :delete_all
|
|
8
|
+
|
|
9
|
+
validates :kind, :target, :bucket_start, presence: true
|
|
10
|
+
|
|
11
|
+
scope :by_kind, ->(kind) { where(kind: kind) }
|
|
12
|
+
scope :recent, ->(period = 1.hour) { where(bucket_start: period.ago..) }
|
|
13
|
+
|
|
14
|
+
def average_duration
|
|
15
|
+
return 0.0 if count.zero?
|
|
16
|
+
duration_sum / count
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def failure_rate
|
|
20
|
+
return 0.0 if count.zero?
|
|
21
|
+
failure_count.to_f / count
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def percentile(p)
|
|
25
|
+
digest = tdigest
|
|
26
|
+
return nil if digest.nil? || digest.empty?
|
|
27
|
+
digest.percentile(p)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def tdigest
|
|
31
|
+
return nil if p95_digest.blank?
|
|
32
|
+
TDigest.deserialize(p95_digest)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parsed_metadata_sum
|
|
36
|
+
case metadata_sum
|
|
37
|
+
when Hash then metadata_sum
|
|
38
|
+
when String then JSON.parse(metadata_sum)
|
|
39
|
+
else {}
|
|
40
|
+
end
|
|
41
|
+
rescue JSON::ParserError
|
|
42
|
+
{}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
class ErrorRecord < ApplicationRecord
|
|
5
|
+
self.table_name = 'catpm_errors'
|
|
6
|
+
|
|
7
|
+
validates :fingerprint, :kind, :error_class, :first_occurred_at, :last_occurred_at, presence: true
|
|
8
|
+
validates :fingerprint, uniqueness: true
|
|
9
|
+
|
|
10
|
+
scope :by_kind, ->(kind) { where(kind: kind) }
|
|
11
|
+
scope :unresolved, -> { where(resolved_at: nil) }
|
|
12
|
+
scope :resolved, -> { where.not(resolved_at: nil) }
|
|
13
|
+
scope :recent, ->(period = 24.hours) { where(last_occurred_at: period.ago..) }
|
|
14
|
+
|
|
15
|
+
def resolved?
|
|
16
|
+
resolved_at.present?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def resolve!
|
|
20
|
+
update!(resolved_at: Time.current)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def unresolve!
|
|
24
|
+
update!(resolved_at: nil)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parsed_contexts
|
|
28
|
+
case contexts
|
|
29
|
+
when Array then contexts
|
|
30
|
+
when String then JSON.parse(contexts)
|
|
31
|
+
else []
|
|
32
|
+
end
|
|
33
|
+
rescue JSON::ParserError
|
|
34
|
+
[]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
class EventBucket < ApplicationRecord
|
|
5
|
+
self.table_name = 'catpm_event_buckets'
|
|
6
|
+
|
|
7
|
+
validates :name, :bucket_start, presence: true
|
|
8
|
+
|
|
9
|
+
scope :by_name, ->(name) { where(name: name) }
|
|
10
|
+
scope :recent, ->(period = 1.hour) { where(bucket_start: period.ago..) }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
class EventSample < ApplicationRecord
|
|
5
|
+
self.table_name = 'catpm_event_samples'
|
|
6
|
+
|
|
7
|
+
validates :name, :recorded_at, presence: true
|
|
8
|
+
|
|
9
|
+
scope :by_name, ->(name) { where(name: name) }
|
|
10
|
+
scope :recent, ->(period = 1.hour) { where(recorded_at: period.ago..) }
|
|
11
|
+
|
|
12
|
+
def parsed_payload
|
|
13
|
+
case payload
|
|
14
|
+
when Hash then payload
|
|
15
|
+
when String then JSON.parse(payload)
|
|
16
|
+
else {}
|
|
17
|
+
end
|
|
18
|
+
rescue JSON::ParserError
|
|
19
|
+
{}
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
class Sample < ApplicationRecord
|
|
5
|
+
self.table_name = 'catpm_samples'
|
|
6
|
+
|
|
7
|
+
belongs_to :bucket, class_name: 'Catpm::Bucket'
|
|
8
|
+
|
|
9
|
+
validates :kind, :sample_type, :recorded_at, :duration, presence: true
|
|
10
|
+
|
|
11
|
+
scope :by_kind, ->(kind) { where(kind: kind) }
|
|
12
|
+
scope :slow, -> { where(sample_type: 'slow') }
|
|
13
|
+
scope :errors, -> { where(sample_type: 'error') }
|
|
14
|
+
scope :recent, ->(period = 1.hour) { where(recorded_at: period.ago..) }
|
|
15
|
+
|
|
16
|
+
def parsed_context
|
|
17
|
+
case context
|
|
18
|
+
when Hash then context
|
|
19
|
+
when String then JSON.parse(context)
|
|
20
|
+
else {}
|
|
21
|
+
end
|
|
22
|
+
rescue JSON::ParserError
|
|
23
|
+
{}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<h2><%= title %> (<%= samples.size %>)</h2>
|
|
2
|
+
<% if samples.any? %>
|
|
3
|
+
<div class="table-scroll">
|
|
4
|
+
<table>
|
|
5
|
+
<thead>
|
|
6
|
+
<tr>
|
|
7
|
+
<th>Duration</th>
|
|
8
|
+
<th>Segments</th>
|
|
9
|
+
<th>Time</th>
|
|
10
|
+
</tr>
|
|
11
|
+
</thead>
|
|
12
|
+
<tbody>
|
|
13
|
+
<% samples.each do |s| %>
|
|
14
|
+
<% ctx = s.parsed_context; summary = ctx["segment_summary"] || ctx[:segment_summary] || {} %>
|
|
15
|
+
<tr class="linked">
|
|
16
|
+
<td><a href="<%= catpm.sample_path(s) %>" class="row-link mono"><%= format_duration(s.duration) %></a></td>
|
|
17
|
+
<td>
|
|
18
|
+
<% sc = (summary["sql_count"] || summary[:sql_count] || 0).to_i %>
|
|
19
|
+
<% vc = (summary["view_count"] || summary[:view_count] || 0).to_i %>
|
|
20
|
+
<% if sc > 0 || vc > 0 %>
|
|
21
|
+
<span class="mono text-muted"><%= sc %> sql, <%= vc %> views</span>
|
|
22
|
+
<% else %>
|
|
23
|
+
<span class="text-muted">—</span>
|
|
24
|
+
<% end %>
|
|
25
|
+
</td>
|
|
26
|
+
<td><%= time_with_tooltip(s.recorded_at) %></td>
|
|
27
|
+
</tr>
|
|
28
|
+
<% end %>
|
|
29
|
+
</tbody>
|
|
30
|
+
</table>
|
|
31
|
+
</div>
|
|
32
|
+
<% else %>
|
|
33
|
+
<div class="empty-state">
|
|
34
|
+
<div class="empty-hint"><%= empty_msg %></div>
|
|
35
|
+
</div>
|
|
36
|
+
<% end %>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<% content_for :title, @target %>
|
|
2
|
+
<% content_for :subtitle do %>
|
|
3
|
+
<%= type_badge(@kind) %>
|
|
4
|
+
<%= @target %> <%= @operation.presence %>
|
|
5
|
+
· <%= @count %> requests
|
|
6
|
+
· <%= @range == "all" ? "All time" : range_label(@range) %>
|
|
7
|
+
<% end %>
|
|
8
|
+
|
|
9
|
+
<%= render "catpm/shared/page_nav", active: "performance" %>
|
|
10
|
+
|
|
11
|
+
<div class="breadcrumbs">
|
|
12
|
+
<a href="<%= catpm.status_index_path %>">Performance</a>
|
|
13
|
+
<span class="sep">/</span>
|
|
14
|
+
<span><%= @target %></span>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<% ep_params = { kind: @kind, target: @target, operation: @operation } %>
|
|
18
|
+
<div class="time-range">
|
|
19
|
+
<% (Catpm::ApplicationHelper::RANGE_KEYS + ["all"]).each do |r| %>
|
|
20
|
+
<a href="<%= catpm.endpoint_path(ep_params.merge(range: r)) %>" class="<%= 'active' if @range == r %>"><%= r == "all" ? "All" : r %></a>
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="grid">
|
|
25
|
+
<div class="card">
|
|
26
|
+
<div class="label">Requests</div>
|
|
27
|
+
<div class="value"><%= @count %></div>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="card">
|
|
30
|
+
<div class="label">Avg Duration</div>
|
|
31
|
+
<div class="value"><%= format_duration(@avg_duration) %></div>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="card">
|
|
34
|
+
<div class="label">p95</div>
|
|
35
|
+
<div class="value"><%= @tdigest ? format_duration(@tdigest.percentile(0.95)) : "—" %></div>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="card">
|
|
38
|
+
<div class="label">Max</div>
|
|
39
|
+
<div class="value"><%= format_duration(@duration_max) %></div>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="card">
|
|
42
|
+
<div class="label">Min</div>
|
|
43
|
+
<div class="value"><%= format_duration(@duration_min) %></div>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="card">
|
|
46
|
+
<div class="label">Failures</div>
|
|
47
|
+
<div class="value"><%= @failure_count %></div>
|
|
48
|
+
<div class="detail"><%= "%.1f" % (@failure_rate * 100) %>%</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<% if @tdigest && @tdigest.count > 0 %>
|
|
53
|
+
<h2>Percentile Distribution</h2>
|
|
54
|
+
<%= section_description("p95 means 95% of requests completed faster than this value. Higher percentiles reveal tail latency.") %>
|
|
55
|
+
<div class="grid">
|
|
56
|
+
<% [0.5, 0.75, 0.9, 0.95, 0.99].each do |p| %>
|
|
57
|
+
<div class="card">
|
|
58
|
+
<div class="label">p<%= (p * 100).to_i %></div>
|
|
59
|
+
<div class="value"><%= format_duration(@tdigest.percentile(p)) %></div>
|
|
60
|
+
</div>
|
|
61
|
+
<% end %>
|
|
62
|
+
</div>
|
|
63
|
+
<% end %>
|
|
64
|
+
|
|
65
|
+
<%
|
|
66
|
+
type_data = segment_colors.map { |type, color|
|
|
67
|
+
count = (@metadata["#{type}_count"] || @metadata[:"#{type}_count"] || 0).to_f
|
|
68
|
+
dur = (@metadata["#{type}_duration"] || @metadata[:"#{type}_duration"] || 0).to_f
|
|
69
|
+
avg_dur = @count > 0 ? dur / @count : 0
|
|
70
|
+
avg_count = @count > 0 ? count / @count : 0
|
|
71
|
+
text_color = segment_text_colors[type] || "#4b5563"
|
|
72
|
+
{ type: type, label: segment_labels[type] || type.capitalize, bg: color, text: text_color, count: count, dur: dur, avg_dur: avg_dur, avg_count: avg_count }
|
|
73
|
+
}.select { |d| d[:count] > 0 }
|
|
74
|
+
%>
|
|
75
|
+
|
|
76
|
+
<% if type_data.any? %>
|
|
77
|
+
<h2>Average Time Breakdown</h2>
|
|
78
|
+
|
|
79
|
+
<%
|
|
80
|
+
avg_total = @avg_duration
|
|
81
|
+
typed_avg = type_data.sum { |d| d[:avg_dur] }
|
|
82
|
+
avg_other = [avg_total - typed_avg, 0].max
|
|
83
|
+
bar_data = type_data.map { |d| [d[:label], d[:avg_dur], d[:bg], d[:text]] }
|
|
84
|
+
bar_data << ["Other", avg_other, "#dde2e8", "#4b5563"] if avg_other > 0
|
|
85
|
+
%>
|
|
86
|
+
|
|
87
|
+
<div style="margin-bottom:16px">
|
|
88
|
+
<div style="display:flex; height:32px; border-radius:4px; overflow:hidden; border:1px solid var(--border)">
|
|
89
|
+
<% bar_data.each do |label, dur, bg, text| %>
|
|
90
|
+
<% if dur > 0 && avg_total > 0 %>
|
|
91
|
+
<div title="<%= label %> — <%= format_duration(dur) %> (<%= "%.0f" % (dur/avg_total*100) %>%)" style="flex:<%= dur %>; background:<%= bg %>; display:flex; align-items:center; justify-content:center; color:<%= text %>; font-size:11px; font-weight:500; overflow:hidden; white-space:nowrap; padding:0 4px;">
|
|
92
|
+
<% if dur / avg_total > 0.08 %><%= label %><% end %>
|
|
93
|
+
</div>
|
|
94
|
+
<% end %>
|
|
95
|
+
<% end %>
|
|
96
|
+
</div>
|
|
97
|
+
<div style="display:flex; gap:14px; flex-wrap:wrap; margin-top:6px; font-size:12px; color:var(--text-1)">
|
|
98
|
+
<% bar_data.each do |label, dur, bg, text| %>
|
|
99
|
+
<span><span style="display:inline-block;width:10px;height:10px;background:<%= bg %>;border-radius:2px;margin-right:4px;border:1px solid <%= text %>22"></span><%= label %> <%= format_duration(dur) %> (<%= "%.0f" % (avg_total > 0 ? dur/avg_total*100 : 0) %>%)</span>
|
|
100
|
+
<% end %>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div class="grid">
|
|
105
|
+
<% type_data.each do |d| %>
|
|
106
|
+
<div class="card">
|
|
107
|
+
<div class="label">Avg <%= d[:label] %></div>
|
|
108
|
+
<div class="value"><%= "%.0f" % d[:avg_count] %></div>
|
|
109
|
+
<div class="detail"><%= format_duration(d[:avg_dur]) %> avg</div>
|
|
110
|
+
</div>
|
|
111
|
+
<% end %>
|
|
112
|
+
<% if avg_other > 0 %>
|
|
113
|
+
<div class="card">
|
|
114
|
+
<div class="label">Avg Other</div>
|
|
115
|
+
<div class="value"><%= format_duration(avg_other) %></div>
|
|
116
|
+
<div class="detail">App code, middleware, etc.</div>
|
|
117
|
+
</div>
|
|
118
|
+
<% end %>
|
|
119
|
+
</div>
|
|
120
|
+
<% end %>
|
|
121
|
+
|
|
122
|
+
<%= render "catpm/endpoints/sample_table", title: "Error Requests", samples: @error_samples, empty_msg: "No errors for this endpoint." %>
|
|
123
|
+
<%= render "catpm/endpoints/sample_table", title: "Slowest Requests", samples: @slow_samples, empty_msg: "No slow requests captured yet." %>
|
|
124
|
+
<%= render "catpm/endpoints/sample_table", title: "Sample Requests", samples: @samples, empty_msg: "No samples captured yet." %>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<% content_for :title, "Errors" %>
|
|
2
|
+
<% content_for :subtitle, "#{@active_count} active, #{@resolved_count} resolved" %>
|
|
3
|
+
|
|
4
|
+
<%= render "catpm/shared/page_nav", active: "errors" %>
|
|
5
|
+
|
|
6
|
+
<div class="tabs">
|
|
7
|
+
<a href="<%= catpm.errors_path(tab: "active") %>" class="tab <%= 'active' if @tab == 'active' %>">Active (<%= @active_count %>)</a>
|
|
8
|
+
<a href="<%= catpm.errors_path(tab: "resolved") %>" class="tab <%= 'active' if @tab == 'resolved' %>">Resolved (<%= @resolved_count %>)</a>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<% if @errors.any? || @kind_filter.present? %>
|
|
12
|
+
<div class="filters">
|
|
13
|
+
<% @available_kinds.each do |k| %>
|
|
14
|
+
<a href="<%= catpm.errors_path(tab: @tab, kind: @kind_filter == k ? nil : k) %>" class="filter-pill<%= ' active' if @kind_filter == k %>"><%= k %></a>
|
|
15
|
+
<% end %>
|
|
16
|
+
<input type="text" class="search-input" id="error-search" placeholder="Search errors... (/)" oninput="filterByText('error-search','errors-table')">
|
|
17
|
+
<% if @tab == "active" && @active_count > 0 %>
|
|
18
|
+
<span style="display:inline; margin-left:auto"><%= button_to "Resolve all", catpm.resolve_all_errors_path, method: :post, class: "btn", data: { confirm: "Resolve all #{@active_count} active errors?" } %></span>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
21
|
+
<% end %>
|
|
22
|
+
|
|
23
|
+
<% extra = { tab: @tab }; extra[:kind] = @kind_filter if @kind_filter %>
|
|
24
|
+
|
|
25
|
+
<% if @errors.any? %>
|
|
26
|
+
<div class="table-scroll">
|
|
27
|
+
<table id="errors-table">
|
|
28
|
+
<thead>
|
|
29
|
+
<tr>
|
|
30
|
+
<th>Kind</th>
|
|
31
|
+
<th><%= sort_header("Error Class", "error_class", @sort, @dir, extra_params: extra) %></th>
|
|
32
|
+
<th>Message</th>
|
|
33
|
+
<th><%= sort_header("Count", "occurrences_count", @sort, @dir, extra_params: extra) %></th>
|
|
34
|
+
<th>Trend</th>
|
|
35
|
+
<th><%= sort_header("Last Seen", "last_occurred_at", @sort, @dir, extra_params: extra) %></th>
|
|
36
|
+
<% if @tab == "resolved" %><th>Resolved</th><% end %>
|
|
37
|
+
</tr>
|
|
38
|
+
</thead>
|
|
39
|
+
<tbody>
|
|
40
|
+
<% @errors.each do |e| %>
|
|
41
|
+
<tr class="linked">
|
|
42
|
+
<td><a href="<%= catpm.error_path(e) %>" class="row-link"><%= type_badge(e.kind) %></a></td>
|
|
43
|
+
<td class="mono"><%= e.error_class %></td>
|
|
44
|
+
<td style="color:var(--text-1)"><%= truncate(e.message, length: 60) %></td>
|
|
45
|
+
<td<%= " style=\"font-weight:600\"".html_safe if e.occurrences_count >= 10 %>><%= e.occurrences_count %></td>
|
|
46
|
+
<td><%= trend_indicator(e) %></td>
|
|
47
|
+
<td><%= time_with_tooltip(e.last_occurred_at) %></td>
|
|
48
|
+
<% if @tab == "resolved" %><td><%= time_with_tooltip(e.resolved_at) %></td><% end %>
|
|
49
|
+
</tr>
|
|
50
|
+
<% end %>
|
|
51
|
+
</tbody>
|
|
52
|
+
</table>
|
|
53
|
+
</div>
|
|
54
|
+
<% page_extra = extra.merge(sort: @sort, dir: @dir) %>
|
|
55
|
+
<%= pagination_nav(@page, @total_count, Catpm::ErrorsController::PER_PAGE, extra_params: page_extra) %>
|
|
56
|
+
<% else %>
|
|
57
|
+
<div class="empty-state">
|
|
58
|
+
<% if @tab == "active" %>
|
|
59
|
+
<div class="empty-title">No active errors</div>
|
|
60
|
+
<div class="empty-hint">Your application is running smoothly. Errors will appear here when they occur.</div>
|
|
61
|
+
<% else %>
|
|
62
|
+
<div class="empty-title">No resolved errors</div>
|
|
63
|
+
<div class="empty-hint">When you resolve active errors, they'll move here for reference.</div>
|
|
64
|
+
<% end %>
|
|
65
|
+
</div>
|
|
66
|
+
<% end %>
|