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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +222 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/catpm/application.css +15 -0
  6. data/app/controllers/catpm/application_controller.rb +6 -0
  7. data/app/controllers/catpm/endpoints_controller.rb +63 -0
  8. data/app/controllers/catpm/errors_controller.rb +63 -0
  9. data/app/controllers/catpm/events_controller.rb +89 -0
  10. data/app/controllers/catpm/samples_controller.rb +13 -0
  11. data/app/controllers/catpm/status_controller.rb +79 -0
  12. data/app/controllers/catpm/system_controller.rb +17 -0
  13. data/app/helpers/catpm/application_helper.rb +264 -0
  14. data/app/jobs/catpm/application_job.rb +6 -0
  15. data/app/mailers/catpm/application_mailer.rb +8 -0
  16. data/app/models/catpm/application_record.rb +7 -0
  17. data/app/models/catpm/bucket.rb +45 -0
  18. data/app/models/catpm/error_record.rb +37 -0
  19. data/app/models/catpm/event_bucket.rb +12 -0
  20. data/app/models/catpm/event_sample.rb +22 -0
  21. data/app/models/catpm/sample.rb +26 -0
  22. data/app/views/catpm/endpoints/_sample_table.html.erb +36 -0
  23. data/app/views/catpm/endpoints/show.html.erb +124 -0
  24. data/app/views/catpm/errors/index.html.erb +66 -0
  25. data/app/views/catpm/errors/show.html.erb +107 -0
  26. data/app/views/catpm/events/index.html.erb +73 -0
  27. data/app/views/catpm/events/show.html.erb +86 -0
  28. data/app/views/catpm/samples/show.html.erb +113 -0
  29. data/app/views/catpm/shared/_page_nav.html.erb +6 -0
  30. data/app/views/catpm/shared/_segments_waterfall.html.erb +147 -0
  31. data/app/views/catpm/status/index.html.erb +124 -0
  32. data/app/views/catpm/system/index.html.erb +454 -0
  33. data/app/views/layouts/catpm/application.html.erb +381 -0
  34. data/config/routes.rb +19 -0
  35. data/db/migrate/20250601000001_create_catpm_tables.rb +104 -0
  36. data/lib/catpm/adapter/base.rb +85 -0
  37. data/lib/catpm/adapter/postgresql.rb +186 -0
  38. data/lib/catpm/adapter/sqlite.rb +159 -0
  39. data/lib/catpm/adapter.rb +28 -0
  40. data/lib/catpm/auto_instrument.rb +145 -0
  41. data/lib/catpm/buffer.rb +59 -0
  42. data/lib/catpm/circuit_breaker.rb +60 -0
  43. data/lib/catpm/collector.rb +320 -0
  44. data/lib/catpm/configuration.rb +103 -0
  45. data/lib/catpm/custom_event.rb +37 -0
  46. data/lib/catpm/engine.rb +39 -0
  47. data/lib/catpm/errors.rb +6 -0
  48. data/lib/catpm/event.rb +75 -0
  49. data/lib/catpm/fingerprint.rb +52 -0
  50. data/lib/catpm/flusher.rb +462 -0
  51. data/lib/catpm/lifecycle.rb +76 -0
  52. data/lib/catpm/middleware.rb +75 -0
  53. data/lib/catpm/middleware_probe.rb +28 -0
  54. data/lib/catpm/patches/httpclient.rb +44 -0
  55. data/lib/catpm/patches/net_http.rb +39 -0
  56. data/lib/catpm/request_segments.rb +101 -0
  57. data/lib/catpm/segment_subscribers.rb +242 -0
  58. data/lib/catpm/span_helpers.rb +51 -0
  59. data/lib/catpm/stack_sampler.rb +226 -0
  60. data/lib/catpm/subscribers.rb +47 -0
  61. data/lib/catpm/tdigest.rb +174 -0
  62. data/lib/catpm/trace.rb +165 -0
  63. data/lib/catpm/version.rb +5 -0
  64. data/lib/catpm.rb +66 -0
  65. data/lib/generators/catpm/install_generator.rb +36 -0
  66. data/lib/generators/catpm/templates/initializer.rb.tt +77 -0
  67. data/lib/tasks/catpm_seed.rake +79 -0
  68. data/lib/tasks/catpm_tasks.rake +6 -0
  69. 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
@@ -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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class CircuitOpenError < StandardError; end
5
+ class UnsupportedAdapter < StandardError; end
6
+ end
@@ -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