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,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
module Collector
|
|
5
|
+
class << self
|
|
6
|
+
def process_action_controller(event)
|
|
7
|
+
return unless Catpm.enabled?
|
|
8
|
+
|
|
9
|
+
payload = event.payload
|
|
10
|
+
target = "#{payload[:controller]}##{payload[:action]}"
|
|
11
|
+
return if target.start_with?('Catpm::')
|
|
12
|
+
return if Catpm.config.ignored?(target)
|
|
13
|
+
|
|
14
|
+
duration = event.duration # milliseconds
|
|
15
|
+
status = payload[:status] || (payload[:exception] ? 500 : nil)
|
|
16
|
+
context = build_http_context(payload)
|
|
17
|
+
metadata = build_http_metadata(payload)
|
|
18
|
+
|
|
19
|
+
req_segments = Thread.current[:catpm_request_segments]
|
|
20
|
+
if req_segments
|
|
21
|
+
segment_data = req_segments.to_h
|
|
22
|
+
segments = segment_data[:segments]
|
|
23
|
+
|
|
24
|
+
# Compute full request duration from middleware start to now
|
|
25
|
+
# (event.duration only covers the controller action, not middleware)
|
|
26
|
+
total_request_duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - req_segments.request_start) * 1000.0
|
|
27
|
+
|
|
28
|
+
# Inject root request segment with full duration
|
|
29
|
+
root_segment = {
|
|
30
|
+
type: 'request',
|
|
31
|
+
detail: "#{payload[:method]} #{payload[:path]}",
|
|
32
|
+
duration: total_request_duration.round(2),
|
|
33
|
+
offset: 0.0
|
|
34
|
+
}
|
|
35
|
+
segments.each do |seg|
|
|
36
|
+
if seg.key?(:parent_index)
|
|
37
|
+
seg[:parent_index] += 1
|
|
38
|
+
else
|
|
39
|
+
seg[:parent_index] = 0
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
segments.unshift(root_segment)
|
|
43
|
+
|
|
44
|
+
# Inject synthetic middleware segment if there's a time gap before the controller action
|
|
45
|
+
# (only when real per-middleware segments are not present)
|
|
46
|
+
ctrl_idx = segments.index { |s| s[:type] == 'controller' }
|
|
47
|
+
if ctrl_idx
|
|
48
|
+
has_real_middleware = segments.any? { |s| s[:type] == 'middleware' }
|
|
49
|
+
ctrl_offset = (segments[ctrl_idx][:offset] || 0.0).to_f
|
|
50
|
+
if ctrl_offset > 0.5 && !has_real_middleware
|
|
51
|
+
middleware_seg = {
|
|
52
|
+
type: 'middleware',
|
|
53
|
+
detail: 'Middleware Stack',
|
|
54
|
+
duration: ctrl_offset.round(2),
|
|
55
|
+
offset: 0.0,
|
|
56
|
+
parent_index: 0
|
|
57
|
+
}
|
|
58
|
+
segments.insert(1, middleware_seg)
|
|
59
|
+
# Shift parent_index for segments that moved down
|
|
60
|
+
segments.each_with_index do |seg, i|
|
|
61
|
+
next if i <= 1
|
|
62
|
+
next unless seg.key?(:parent_index)
|
|
63
|
+
seg[:parent_index] += 1 if seg[:parent_index] >= 1
|
|
64
|
+
end
|
|
65
|
+
# Add to summary so Time Breakdown shows middleware
|
|
66
|
+
segment_data[:segment_summary][:middleware_count] = 1
|
|
67
|
+
segment_data[:segment_summary][:middleware_duration] = ctrl_offset.round(2)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Fill untracked controller time with sampler data or synthetic segment
|
|
72
|
+
ctrl_idx = segments.index { |s| s[:type] == 'controller' }
|
|
73
|
+
if ctrl_idx
|
|
74
|
+
ctrl_seg = segments[ctrl_idx]
|
|
75
|
+
ctrl_dur = (ctrl_seg[:duration] || 0).to_f
|
|
76
|
+
child_dur = segments.each_with_index.sum do |pair|
|
|
77
|
+
seg, i = pair
|
|
78
|
+
next 0.0 if i == ctrl_idx
|
|
79
|
+
(seg[:parent_index] == ctrl_idx) ? (seg[:duration] || 0).to_f : 0.0
|
|
80
|
+
end
|
|
81
|
+
gap = ctrl_dur - child_dur
|
|
82
|
+
|
|
83
|
+
if gap > 1.0
|
|
84
|
+
inject_gap_segments(segments, req_segments, gap, ctrl_idx, ctrl_seg)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
context[:segments] = segments
|
|
89
|
+
context[:segment_summary] = segment_data[:segment_summary]
|
|
90
|
+
context[:segments_capped] = segment_data[:segments_capped]
|
|
91
|
+
|
|
92
|
+
segment_data[:segment_summary].each do |k, v|
|
|
93
|
+
metadata[k] = v
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Use full request duration (including middleware) for the event
|
|
97
|
+
duration = total_request_duration
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
ev = Event.new(
|
|
101
|
+
kind: :http,
|
|
102
|
+
target: target,
|
|
103
|
+
operation: payload[:method] || 'GET',
|
|
104
|
+
duration: duration,
|
|
105
|
+
started_at: Time.current,
|
|
106
|
+
status: status,
|
|
107
|
+
context: scrub(context),
|
|
108
|
+
metadata: metadata,
|
|
109
|
+
error_class: payload[:exception]&.first,
|
|
110
|
+
error_message: payload[:exception]&.last,
|
|
111
|
+
backtrace: payload[:exception_object]&.backtrace
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
Catpm.buffer&.push(ev)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def process_active_job(event)
|
|
118
|
+
return unless Catpm.enabled?
|
|
119
|
+
|
|
120
|
+
payload = event.payload
|
|
121
|
+
job = payload[:job]
|
|
122
|
+
target = job.class.name
|
|
123
|
+
return if Catpm.config.ignored?(target)
|
|
124
|
+
|
|
125
|
+
duration = event.duration
|
|
126
|
+
exception = payload[:exception_object]
|
|
127
|
+
|
|
128
|
+
queue_wait = if job.respond_to?(:enqueued_at) && job.enqueued_at
|
|
129
|
+
((Time.current - job.enqueued_at.to_time) * 1000.0) rescue nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
context = {
|
|
133
|
+
job_class: target,
|
|
134
|
+
job_id: job.job_id,
|
|
135
|
+
queue: job.queue_name,
|
|
136
|
+
attempts: job.executions
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
metadata = { queue_wait: queue_wait }.compact
|
|
140
|
+
|
|
141
|
+
ev = Event.new(
|
|
142
|
+
kind: :job,
|
|
143
|
+
target: target,
|
|
144
|
+
operation: job.queue_name,
|
|
145
|
+
duration: duration,
|
|
146
|
+
started_at: Time.current,
|
|
147
|
+
context: context,
|
|
148
|
+
metadata: metadata,
|
|
149
|
+
error_class: exception&.class&.name,
|
|
150
|
+
error_message: exception&.message,
|
|
151
|
+
backtrace: exception&.backtrace
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
Catpm.buffer&.push(ev)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def process_tracked(kind:, target:, operation:, duration:, context:, metadata:, error:, req_segments:)
|
|
158
|
+
return unless Catpm.enabled?
|
|
159
|
+
return if Catpm.config.ignored?(target)
|
|
160
|
+
|
|
161
|
+
context = (context || {}).dup
|
|
162
|
+
metadata = (metadata || {}).dup
|
|
163
|
+
|
|
164
|
+
if req_segments
|
|
165
|
+
segment_data = req_segments.to_h
|
|
166
|
+
segments = segment_data[:segments]
|
|
167
|
+
|
|
168
|
+
# Inject root request segment
|
|
169
|
+
root_segment = {
|
|
170
|
+
type: 'request',
|
|
171
|
+
detail: "#{operation.presence || kind} #{target}",
|
|
172
|
+
duration: duration.round(2),
|
|
173
|
+
offset: 0.0
|
|
174
|
+
}
|
|
175
|
+
segments.each do |seg|
|
|
176
|
+
if seg.key?(:parent_index)
|
|
177
|
+
seg[:parent_index] += 1
|
|
178
|
+
else
|
|
179
|
+
seg[:parent_index] = 0
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
segments.unshift(root_segment)
|
|
183
|
+
|
|
184
|
+
# Fill untracked controller time with sampler data or synthetic segment
|
|
185
|
+
ctrl_idx = segments.index { |s| s[:type] == 'controller' }
|
|
186
|
+
if ctrl_idx
|
|
187
|
+
ctrl_seg = segments[ctrl_idx]
|
|
188
|
+
ctrl_dur = (ctrl_seg[:duration] || 0).to_f
|
|
189
|
+
child_dur = segments.each_with_index.sum do |pair|
|
|
190
|
+
seg, i = pair
|
|
191
|
+
next 0.0 if i == ctrl_idx
|
|
192
|
+
(seg[:parent_index] == ctrl_idx) ? (seg[:duration] || 0).to_f : 0.0
|
|
193
|
+
end
|
|
194
|
+
gap = ctrl_dur - child_dur
|
|
195
|
+
|
|
196
|
+
if gap > 1.0
|
|
197
|
+
inject_gap_segments(segments, req_segments, gap, ctrl_idx, ctrl_seg)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
context[:segments] = segments
|
|
202
|
+
context[:segment_summary] = segment_data[:segment_summary]
|
|
203
|
+
context[:segments_capped] = segment_data[:segments_capped]
|
|
204
|
+
|
|
205
|
+
segment_data[:segment_summary]&.each do |k, v|
|
|
206
|
+
metadata[k] = v
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
ev = Event.new(
|
|
211
|
+
kind: kind,
|
|
212
|
+
target: target,
|
|
213
|
+
operation: operation.to_s,
|
|
214
|
+
duration: duration,
|
|
215
|
+
started_at: Time.current,
|
|
216
|
+
status: error ? 500 : 200,
|
|
217
|
+
context: scrub(context),
|
|
218
|
+
metadata: metadata,
|
|
219
|
+
error_class: error&.class&.name,
|
|
220
|
+
error_message: error&.message,
|
|
221
|
+
backtrace: error&.backtrace
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
Catpm.buffer&.push(ev)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def process_custom(name:, duration:, metadata: {}, error: nil, context: {})
|
|
228
|
+
return unless Catpm.enabled?
|
|
229
|
+
return if Catpm.config.ignored?(name)
|
|
230
|
+
|
|
231
|
+
ev = Event.new(
|
|
232
|
+
kind: :custom,
|
|
233
|
+
target: name,
|
|
234
|
+
operation: '',
|
|
235
|
+
duration: duration,
|
|
236
|
+
started_at: Time.current,
|
|
237
|
+
context: context,
|
|
238
|
+
metadata: metadata || {},
|
|
239
|
+
error_class: error&.class&.name,
|
|
240
|
+
error_message: error&.message,
|
|
241
|
+
backtrace: error&.backtrace
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
Catpm.buffer&.push(ev)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
def inject_gap_segments(segments, req_segments, gap, ctrl_idx, ctrl_seg)
|
|
250
|
+
sampler_groups = req_segments&.sampler_segments || []
|
|
251
|
+
|
|
252
|
+
if sampler_groups.any?
|
|
253
|
+
sampler_dur = 0.0
|
|
254
|
+
|
|
255
|
+
sampler_groups.each do |group|
|
|
256
|
+
parent = group[:parent]
|
|
257
|
+
children = group[:children] || []
|
|
258
|
+
|
|
259
|
+
parent_idx = segments.size
|
|
260
|
+
parent[:parent_index] = ctrl_idx
|
|
261
|
+
segments << parent
|
|
262
|
+
sampler_dur += (parent[:duration] || 0).to_f
|
|
263
|
+
|
|
264
|
+
children.each do |child|
|
|
265
|
+
child[:parent_index] = parent_idx
|
|
266
|
+
child[:collapsed] = true
|
|
267
|
+
segments << child
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
remaining = gap - sampler_dur
|
|
272
|
+
if remaining > 1.0
|
|
273
|
+
segments << {
|
|
274
|
+
type: 'other',
|
|
275
|
+
detail: 'Untracked',
|
|
276
|
+
duration: remaining.round(2),
|
|
277
|
+
offset: (ctrl_seg[:offset] || 0.0),
|
|
278
|
+
parent_index: ctrl_idx
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
else
|
|
282
|
+
segments << {
|
|
283
|
+
type: 'other',
|
|
284
|
+
detail: 'Untracked',
|
|
285
|
+
duration: gap.round(2),
|
|
286
|
+
offset: (ctrl_seg[:offset] || 0.0),
|
|
287
|
+
parent_index: ctrl_idx
|
|
288
|
+
}
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def build_http_context(payload)
|
|
293
|
+
{
|
|
294
|
+
method: payload[:method],
|
|
295
|
+
path: payload[:path],
|
|
296
|
+
params: (payload[:params] || {}).except('controller', 'action'),
|
|
297
|
+
status: payload[:status]
|
|
298
|
+
}
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def build_http_metadata(payload)
|
|
302
|
+
h = {}
|
|
303
|
+
h[:db_runtime] = payload[:db_runtime] if payload[:db_runtime]
|
|
304
|
+
h[:view_runtime] = payload[:view_runtime] if payload[:view_runtime]
|
|
305
|
+
h
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def scrub(hash)
|
|
309
|
+
parameter_filter.filter(hash)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def parameter_filter
|
|
313
|
+
@parameter_filter ||= begin
|
|
314
|
+
filters = Rails.application.config.filter_parameters + Catpm.config.additional_filter_parameters
|
|
315
|
+
ActiveSupport::ParameterFilter.new(filters)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :enabled,
|
|
6
|
+
:instrument_http,
|
|
7
|
+
:instrument_jobs,
|
|
8
|
+
:instrument_segments,
|
|
9
|
+
:instrument_net_http,
|
|
10
|
+
:instrument_stack_sampler,
|
|
11
|
+
:max_segments_per_request,
|
|
12
|
+
:segment_source_threshold,
|
|
13
|
+
:max_sql_length,
|
|
14
|
+
:slow_threshold,
|
|
15
|
+
:slow_threshold_per_kind,
|
|
16
|
+
:ignored_targets,
|
|
17
|
+
:retention_period,
|
|
18
|
+
:max_buffer_memory,
|
|
19
|
+
:flush_interval,
|
|
20
|
+
:flush_jitter,
|
|
21
|
+
:max_error_contexts,
|
|
22
|
+
:bucket_sizes,
|
|
23
|
+
:error_handler,
|
|
24
|
+
:http_basic_auth_user,
|
|
25
|
+
:http_basic_auth_password,
|
|
26
|
+
:access_policy,
|
|
27
|
+
:additional_filter_parameters,
|
|
28
|
+
:instrument_middleware_stack,
|
|
29
|
+
:auto_instrument_methods,
|
|
30
|
+
:service_base_classes,
|
|
31
|
+
:random_sample_rate,
|
|
32
|
+
:max_random_samples_per_endpoint,
|
|
33
|
+
:max_slow_samples_per_endpoint,
|
|
34
|
+
:cleanup_interval,
|
|
35
|
+
:circuit_breaker_failure_threshold,
|
|
36
|
+
:circuit_breaker_recovery_timeout,
|
|
37
|
+
:sqlite_busy_timeout,
|
|
38
|
+
:persistence_batch_size,
|
|
39
|
+
:backtrace_lines,
|
|
40
|
+
:shutdown_timeout,
|
|
41
|
+
:events_enabled,
|
|
42
|
+
:events_max_samples_per_name
|
|
43
|
+
|
|
44
|
+
def initialize
|
|
45
|
+
@enabled = true
|
|
46
|
+
@instrument_http = true
|
|
47
|
+
@instrument_jobs = false
|
|
48
|
+
@instrument_segments = true
|
|
49
|
+
@instrument_net_http = false
|
|
50
|
+
@instrument_stack_sampler = false
|
|
51
|
+
@instrument_middleware_stack = false
|
|
52
|
+
@max_segments_per_request = 50
|
|
53
|
+
@segment_source_threshold = 0.0 # ms — capture caller_locations for all segments (set higher to reduce overhead)
|
|
54
|
+
@max_sql_length = 200
|
|
55
|
+
@slow_threshold = 500 # milliseconds
|
|
56
|
+
@slow_threshold_per_kind = {}
|
|
57
|
+
@ignored_targets = []
|
|
58
|
+
@retention_period = nil # nil = keep forever (data is downsampled, not deleted)
|
|
59
|
+
@max_buffer_memory = 32.megabytes
|
|
60
|
+
@flush_interval = 30 # seconds
|
|
61
|
+
@flush_jitter = 5 # ±seconds
|
|
62
|
+
@max_error_contexts = 5
|
|
63
|
+
@bucket_sizes = { recent: 1.minute, medium: 5.minutes, hourly: 1.hour, daily: 1.day, weekly: 1.week }
|
|
64
|
+
@error_handler = ->(e) { Rails.logger.error("[catpm] #{e.message}") }
|
|
65
|
+
@http_basic_auth_user = nil
|
|
66
|
+
@http_basic_auth_password = nil
|
|
67
|
+
@access_policy = nil
|
|
68
|
+
@additional_filter_parameters = []
|
|
69
|
+
@auto_instrument_methods = []
|
|
70
|
+
@service_base_classes = nil # nil = auto-detect (ApplicationService, BaseService)
|
|
71
|
+
@random_sample_rate = 20
|
|
72
|
+
@max_random_samples_per_endpoint = 5
|
|
73
|
+
@max_slow_samples_per_endpoint = 5
|
|
74
|
+
@cleanup_interval = 1.hour
|
|
75
|
+
@circuit_breaker_failure_threshold = 5
|
|
76
|
+
@circuit_breaker_recovery_timeout = 60 # seconds
|
|
77
|
+
@sqlite_busy_timeout = 5_000 # milliseconds
|
|
78
|
+
@persistence_batch_size = 100
|
|
79
|
+
@backtrace_lines = 10
|
|
80
|
+
@shutdown_timeout = 5 # seconds
|
|
81
|
+
@events_enabled = false
|
|
82
|
+
@events_max_samples_per_name = 20
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def slow_threshold_for(kind)
|
|
86
|
+
slow_threshold_per_kind.fetch(kind.to_sym, slow_threshold)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ignored?(target)
|
|
90
|
+
ignored_targets.any? do |pattern|
|
|
91
|
+
case pattern
|
|
92
|
+
when Regexp then pattern.match?(target)
|
|
93
|
+
when String
|
|
94
|
+
if pattern.include?('*')
|
|
95
|
+
File.fnmatch(pattern, target)
|
|
96
|
+
else
|
|
97
|
+
pattern == target
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
class CustomEvent
|
|
5
|
+
OBJECT_OVERHEAD = 40
|
|
6
|
+
REF_SIZE = 8
|
|
7
|
+
|
|
8
|
+
attr_accessor :name, :payload, :recorded_at
|
|
9
|
+
|
|
10
|
+
def initialize(name:, payload: {}, recorded_at: nil)
|
|
11
|
+
@name = name.to_s
|
|
12
|
+
@payload = payload || {}
|
|
13
|
+
@recorded_at = recorded_at || Time.current
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def bucket_start
|
|
17
|
+
recorded_at.change(sec: 0)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def estimated_byte_size
|
|
21
|
+
OBJECT_OVERHEAD +
|
|
22
|
+
name.bytesize + REF_SIZE +
|
|
23
|
+
payload_bytes
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Alias to match Event interface used by Buffer
|
|
27
|
+
alias_method :estimated_bytes, :estimated_byte_size
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def payload_bytes
|
|
32
|
+
payload.sum { |k, v| k.to_s.bytesize + v.to_s.bytesize + REF_SIZE }
|
|
33
|
+
rescue
|
|
34
|
+
0
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/catpm/engine.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Catpm
|
|
6
|
+
|
|
7
|
+
initializer 'catpm.migrations' do |app|
|
|
8
|
+
config.paths['db/migrate'].expanded.each do |path|
|
|
9
|
+
app.config.paths['db/migrate'] << path unless app.config.paths['db/migrate'].include?(path)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer 'catpm.middleware' do |app|
|
|
14
|
+
app.middleware.insert_before 0, Catpm::Middleware
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
config.after_initialize do
|
|
18
|
+
if Catpm.enabled?
|
|
19
|
+
Catpm::Subscribers.subscribe!
|
|
20
|
+
Catpm::Lifecycle.register_hooks
|
|
21
|
+
Catpm::AutoInstrument.apply!
|
|
22
|
+
|
|
23
|
+
if Catpm.config.instrument_middleware_stack
|
|
24
|
+
app = Rails.application
|
|
25
|
+
names = app.middleware.filter_map { |m| m.name }.reject { |n| n.start_with?('Catpm::') }
|
|
26
|
+
names.reverse_each do |name|
|
|
27
|
+
app.middleware.insert_before(name, Catpm::MiddlewareProbe, name)
|
|
28
|
+
rescue ArgumentError, RuntimeError
|
|
29
|
+
# Middleware not found in stack — skip
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
config.to_prepare do
|
|
36
|
+
Catpm::AutoInstrument.apply! if Catpm.enabled?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/catpm/errors.rb
ADDED
data/lib/catpm/event.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
class Event
|
|
5
|
+
OBJECT_OVERHEAD = 40 # bytes, Ruby object header
|
|
6
|
+
REF_SIZE = 8 # bytes, pointer on 64-bit
|
|
7
|
+
NUMERIC_FIELDS_SIZE = 64 # fixed numeric fields (duration, timestamps, etc.)
|
|
8
|
+
|
|
9
|
+
attr_accessor :kind, :target, :operation, :duration, :started_at,
|
|
10
|
+
:metadata, :error_class, :error_message, :backtrace,
|
|
11
|
+
:sample_type, :context, :status
|
|
12
|
+
|
|
13
|
+
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)
|
|
16
|
+
@kind = kind.to_s
|
|
17
|
+
@target = target.to_s
|
|
18
|
+
@operation = (operation || '').to_s
|
|
19
|
+
@duration = duration.to_f
|
|
20
|
+
@started_at = started_at || Time.current
|
|
21
|
+
@metadata = metadata || {}
|
|
22
|
+
@error_class = error_class
|
|
23
|
+
@error_message = error_message
|
|
24
|
+
@backtrace = backtrace
|
|
25
|
+
@sample_type = sample_type
|
|
26
|
+
@context = context || {}
|
|
27
|
+
@status = status
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def estimated_bytes
|
|
31
|
+
OBJECT_OVERHEAD +
|
|
32
|
+
target.bytesize + REF_SIZE +
|
|
33
|
+
operation.bytesize +
|
|
34
|
+
kind.bytesize +
|
|
35
|
+
(error_class&.bytesize || 0) +
|
|
36
|
+
(error_message&.bytesize || 0) +
|
|
37
|
+
backtrace_bytes +
|
|
38
|
+
context_bytes +
|
|
39
|
+
metadata_bytes +
|
|
40
|
+
NUMERIC_FIELDS_SIZE
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def error?
|
|
44
|
+
!error_class.nil?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def success?
|
|
48
|
+
!error? && (status.nil? || (200..399).cover?(status.to_i))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def bucket_start
|
|
52
|
+
started_at.change(sec: 0) # Round to minute
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def backtrace_bytes
|
|
58
|
+
return 0 unless backtrace
|
|
59
|
+
|
|
60
|
+
backtrace.sum { |line| line.bytesize + REF_SIZE } + REF_SIZE
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def context_bytes
|
|
64
|
+
return 0 if context.empty?
|
|
65
|
+
|
|
66
|
+
context.to_json.bytesize + REF_SIZE
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def metadata_bytes
|
|
70
|
+
return 0 if metadata.empty?
|
|
71
|
+
|
|
72
|
+
metadata.to_json.bytesize + REF_SIZE
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Catpm
|
|
6
|
+
module Fingerprint
|
|
7
|
+
# Generates a stable fingerprint for error grouping.
|
|
8
|
+
# Includes kind so the same exception in HTTP vs job = different groups.
|
|
9
|
+
def self.generate(kind:, error_class:, backtrace:)
|
|
10
|
+
normalized = normalize_backtrace(backtrace || [])
|
|
11
|
+
raw = "#{kind}:#{error_class}\n#{normalized}"
|
|
12
|
+
Digest::SHA256.hexdigest(raw)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.normalize_backtrace(backtrace)
|
|
16
|
+
app_frames = backtrace
|
|
17
|
+
.select { |line| app_frame?(line) }
|
|
18
|
+
.first(5)
|
|
19
|
+
.map { |line| strip_line_number(line) }
|
|
20
|
+
|
|
21
|
+
# If there are app frames, group by app code (like Sentry)
|
|
22
|
+
return app_frames.join("\n") if app_frames.any?
|
|
23
|
+
|
|
24
|
+
# No app frames = error in a gem/library. Group by crash location
|
|
25
|
+
# so the same bug is always one issue regardless of the caller.
|
|
26
|
+
backtrace
|
|
27
|
+
.reject { |line| line.include?('<internal:') }
|
|
28
|
+
.first(3)
|
|
29
|
+
.map { |line| strip_line_number(line) }
|
|
30
|
+
.join("\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Checks if a backtrace line belongs to the host application (not a gem or stdlib)
|
|
34
|
+
def self.app_frame?(line)
|
|
35
|
+
return false if line.include?('/gems/')
|
|
36
|
+
return false if line.include?('/ruby/')
|
|
37
|
+
return false if line.include?('<internal:')
|
|
38
|
+
return false if line.include?('/catpm/')
|
|
39
|
+
|
|
40
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
41
|
+
return line.start_with?(Rails.root.to_s) if line.start_with?('/')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
line.start_with?('app/') || line.include?('/app/')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Strips line numbers: "app/models/user.rb:42:in `validate'" → "app/models/user.rb:in `validate'"
|
|
48
|
+
def self.strip_line_number(line)
|
|
49
|
+
line.sub(/:\d+:in /, ':in ')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|