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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ module Patches
5
+ module NetHttp
6
+ def request(req, body = nil, &block)
7
+ segments = Thread.current[:catpm_request_segments]
8
+ return super unless segments
9
+
10
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
11
+ response = super
12
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
13
+
14
+ detail = "#{req.method} #{@address}#{req.path} (#{response.code})"
15
+ source = duration >= Catpm.config.segment_source_threshold ? extract_catpm_source : nil
16
+
17
+ segments.add(
18
+ type: :http, duration: duration, detail: detail,
19
+ source: source, started_at: start
20
+ )
21
+
22
+ response
23
+ end
24
+
25
+ private
26
+
27
+ def extract_catpm_source
28
+ locations = caller_locations(2, 30)
29
+ locations&.each do |loc|
30
+ path = loc.path.to_s
31
+ if Catpm::Fingerprint.app_frame?(path)
32
+ return "#{path}:#{loc.lineno}"
33
+ end
34
+ end
35
+ nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class RequestSegments
5
+ attr_reader :segments, :summary, :request_start
6
+
7
+ def initialize(max_segments:, request_start: nil, stack_sample: false)
8
+ @max_segments = max_segments
9
+ @request_start = request_start || Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+ @segments = []
11
+ @overflow = false
12
+ @summary = Hash.new(0)
13
+ @span_stack = []
14
+ @tracked_ranges = []
15
+
16
+ if stack_sample
17
+ @sampler = StackSampler.new(target_thread: Thread.current, request_start: @request_start)
18
+ @sampler.start
19
+ end
20
+ end
21
+
22
+ def add(type:, duration:, detail:, source: nil, started_at: nil)
23
+ type_key = type.to_sym
24
+ @summary[:"#{type_key}_count"] += 1
25
+ @summary[:"#{type_key}_duration"] += duration
26
+
27
+ offset = started_at ? ((started_at - @request_start) * 1000.0).round(2) : nil
28
+
29
+ segment = { type: type.to_s, duration: duration.round(2), detail: detail }
30
+ segment[:offset] = offset if offset
31
+ segment[:source] = source if source
32
+ segment[:parent_index] = @span_stack.last if @span_stack.any?
33
+
34
+ # Record time range so sampler can skip already-tracked periods
35
+ if started_at && duration > 0
36
+ @tracked_ranges << [started_at, started_at + duration / 1000.0]
37
+ end
38
+
39
+ if @segments.size < @max_segments
40
+ @segments << segment
41
+ else
42
+ @overflow = true
43
+ min_idx = @segments.each_with_index.min_by { |s, _| s[:duration] || Float::INFINITY }.last
44
+ if duration > (@segments[min_idx][:duration] || Float::INFINITY)
45
+ @segments[min_idx] = segment
46
+ end
47
+ end
48
+ end
49
+
50
+ def push_span(type:, detail:, started_at: nil)
51
+ offset = started_at ? ((started_at - @request_start) * 1000.0).round(2) : nil
52
+
53
+ segment = { type: type.to_s, detail: detail }
54
+ segment[:offset] = offset if offset
55
+ segment[:parent_index] = @span_stack.last if @span_stack.any?
56
+
57
+ return nil if @segments.size >= @max_segments
58
+
59
+ index = @segments.size
60
+ @segments << segment
61
+ @span_stack.push(index)
62
+ index
63
+ end
64
+
65
+ def pop_span(index)
66
+ return unless index
67
+
68
+ @span_stack.delete(index)
69
+ segment = @segments[index]
70
+ return unless segment
71
+
72
+ started_at = segment[:offset] ? @request_start + (segment[:offset] / 1000.0) : @request_start
73
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
74
+ segment[:duration] = duration.round(2)
75
+
76
+ type_key = segment[:type].to_sym
77
+ @summary[:"#{type_key}_count"] += 1
78
+ @summary[:"#{type_key}_duration"] += duration
79
+ end
80
+
81
+ def stop_sampler
82
+ @sampler&.stop
83
+ end
84
+
85
+ def sampler_segments
86
+ @sampler&.to_segments(tracked_ranges: @tracked_ranges) || []
87
+ end
88
+
89
+ def overflowed?
90
+ @overflow
91
+ end
92
+
93
+ def to_h
94
+ {
95
+ segments: @segments,
96
+ segment_summary: @summary,
97
+ segments_capped: @overflow
98
+ }
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ module SegmentSubscribers
5
+ # Subscriber with start/finish callbacks so all segments (SQL, views, etc.)
6
+ # fired during a controller action are automatically nested under the controller span.
7
+ class ControllerSpanSubscriber
8
+ def start(_name, _id, payload)
9
+ req_segments = Thread.current[:catpm_request_segments]
10
+ return unless req_segments
11
+
12
+ detail = "#{payload[:controller]}##{payload[:action]}"
13
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
+ index = req_segments.push_span(type: :controller, detail: detail, started_at: started_at)
15
+ payload[:_catpm_controller_span_index] = index
16
+ end
17
+
18
+ def finish(_name, _id, payload)
19
+ req_segments = Thread.current[:catpm_request_segments]
20
+ return unless req_segments
21
+
22
+ req_segments.pop_span(payload[:_catpm_controller_span_index])
23
+ end
24
+ end
25
+
26
+ # Subscriber object with start/finish callbacks so SQL queries
27
+ # fired during view rendering are automatically nested under the view span.
28
+ class ViewSpanSubscriber
29
+ def start(_name, _id, payload)
30
+ req_segments = Thread.current[:catpm_request_segments]
31
+ return unless req_segments
32
+
33
+ identifier = payload[:identifier].to_s
34
+ if defined?(Rails.root) && identifier.start_with?(Rails.root.to_s)
35
+ identifier = identifier.sub("#{Rails.root}/", '')
36
+ end
37
+
38
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
39
+ index = req_segments.push_span(type: :view, detail: identifier, started_at: started_at)
40
+ payload[:_catpm_span_index] = index
41
+ end
42
+
43
+ def finish(_name, _id, payload)
44
+ req_segments = Thread.current[:catpm_request_segments]
45
+ return unless req_segments
46
+
47
+ req_segments.pop_span(payload[:_catpm_span_index])
48
+ end
49
+ end
50
+
51
+ IGNORED_SQL_NAMES = Set.new([
52
+ 'SCHEMA', 'EXPLAIN',
53
+ 'ActiveRecord::SchemaMigration Load',
54
+ 'ActiveRecord::InternalMetadata Load'
55
+ ]).freeze
56
+
57
+ class << self
58
+ def subscribe!
59
+ unsubscribe!
60
+
61
+ @controller_span_subscriber = ActiveSupport::Notifications.subscribe(
62
+ 'process_action.action_controller', ControllerSpanSubscriber.new
63
+ )
64
+
65
+ @sql_subscriber = ActiveSupport::Notifications.subscribe(
66
+ 'sql.active_record'
67
+ ) do |event|
68
+ record_sql_segment(event)
69
+ end
70
+
71
+ @instantiation_subscriber = ActiveSupport::Notifications.subscribe(
72
+ 'instantiation.active_record'
73
+ ) do |event|
74
+ record_instantiation_segment(event)
75
+ end
76
+
77
+ @render_template_subscriber = ActiveSupport::Notifications.subscribe(
78
+ 'render_template.action_view', ViewSpanSubscriber.new
79
+ )
80
+
81
+ @render_partial_subscriber = ActiveSupport::Notifications.subscribe(
82
+ 'render_partial.action_view', ViewSpanSubscriber.new
83
+ )
84
+
85
+ @cache_read_subscriber = ActiveSupport::Notifications.subscribe(
86
+ 'cache_read.active_support'
87
+ ) do |event|
88
+ record_cache_segment(event, 'read')
89
+ end
90
+
91
+ @cache_write_subscriber = ActiveSupport::Notifications.subscribe(
92
+ 'cache_write.active_support'
93
+ ) do |event|
94
+ record_cache_segment(event, 'write')
95
+ end
96
+
97
+ if defined?(ActionMailer)
98
+ @mailer_subscriber = ActiveSupport::Notifications.subscribe(
99
+ 'deliver.action_mailer'
100
+ ) do |event|
101
+ record_mailer_segment(event)
102
+ end
103
+ end
104
+
105
+ if defined?(ActiveStorage)
106
+ @storage_upload_subscriber = ActiveSupport::Notifications.subscribe(
107
+ 'service_upload.active_storage'
108
+ ) do |event|
109
+ record_storage_segment(event, 'upload')
110
+ end
111
+
112
+ @storage_download_subscriber = ActiveSupport::Notifications.subscribe(
113
+ 'service_download.active_storage'
114
+ ) do |event|
115
+ record_storage_segment(event, 'download')
116
+ end
117
+ end
118
+ end
119
+
120
+ def unsubscribe!
121
+ [
122
+ @controller_span_subscriber,
123
+ @sql_subscriber, @instantiation_subscriber,
124
+ @render_template_subscriber, @render_partial_subscriber,
125
+ @cache_read_subscriber, @cache_write_subscriber,
126
+ @mailer_subscriber, @storage_upload_subscriber, @storage_download_subscriber
127
+ ].each do |sub|
128
+ ActiveSupport::Notifications.unsubscribe(sub) if sub
129
+ end
130
+ @controller_span_subscriber = nil
131
+ @sql_subscriber = nil
132
+ @instantiation_subscriber = nil
133
+ @render_template_subscriber = nil
134
+ @render_partial_subscriber = nil
135
+ @cache_read_subscriber = nil
136
+ @cache_write_subscriber = nil
137
+ @mailer_subscriber = nil
138
+ @storage_upload_subscriber = nil
139
+ @storage_download_subscriber = nil
140
+ end
141
+
142
+ private
143
+
144
+ def record_instantiation_segment(event)
145
+ req_segments = Thread.current[:catpm_request_segments]
146
+ return unless req_segments
147
+
148
+ duration = event.duration
149
+ return if duration < 0.1 # skip trivial instantiations
150
+
151
+ payload = event.payload
152
+ record_count = payload[:record_count] || 0
153
+ class_name = payload[:class_name] || 'ActiveRecord'
154
+ detail = "#{class_name} x#{record_count}"
155
+ source = duration >= Catpm.config.segment_source_threshold ? extract_source_location : nil
156
+
157
+ # Fold into sql summary for cleaner breakdown
158
+ req_segments.add(
159
+ type: :sql, duration: duration, detail: detail,
160
+ source: source, started_at: event.time
161
+ )
162
+ end
163
+
164
+ def record_sql_segment(event)
165
+ req_segments = Thread.current[:catpm_request_segments]
166
+ return unless req_segments
167
+
168
+ payload = event.payload
169
+ return if payload[:name].nil? || IGNORED_SQL_NAMES.include?(payload[:name])
170
+ return if payload[:sql].nil?
171
+
172
+ duration = event.duration
173
+ sql = payload[:sql].to_s
174
+ source = duration >= Catpm.config.segment_source_threshold ? extract_source_location : nil
175
+
176
+ req_segments.add(
177
+ type: :sql, duration: duration, detail: sql,
178
+ source: source, started_at: event.time
179
+ )
180
+ end
181
+
182
+ def record_cache_segment(event, operation)
183
+ req_segments = Thread.current[:catpm_request_segments]
184
+ return unless req_segments
185
+
186
+ duration = event.duration
187
+ key = event.payload[:key].to_s
188
+ hit = event.payload[:hit]
189
+ detail = "cache.#{operation} #{key}"
190
+ detail += hit ? ' (hit)' : ' (miss)' if operation == 'read' && !hit.nil?
191
+ source = duration >= Catpm.config.segment_source_threshold ? extract_source_location : nil
192
+
193
+ req_segments.add(
194
+ type: :cache, duration: duration, detail: detail,
195
+ source: source, started_at: event.time
196
+ )
197
+ end
198
+
199
+ def record_mailer_segment(event)
200
+ req_segments = Thread.current[:catpm_request_segments]
201
+ return unless req_segments
202
+
203
+ payload = event.payload
204
+ mailer = payload[:mailer].to_s
205
+ to = Array(payload[:to]).first.to_s
206
+ detail = to.empty? ? mailer : "#{mailer} to #{to}"
207
+ source = event.duration >= Catpm.config.segment_source_threshold ? extract_source_location : nil
208
+
209
+ req_segments.add(
210
+ type: :mailer, duration: event.duration, detail: detail,
211
+ source: source, started_at: event.time
212
+ )
213
+ end
214
+
215
+ def record_storage_segment(event, operation)
216
+ req_segments = Thread.current[:catpm_request_segments]
217
+ return unless req_segments
218
+
219
+ payload = event.payload
220
+ key = payload[:key].to_s
221
+ detail = "#{operation} #{key}"
222
+ source = event.duration >= Catpm.config.segment_source_threshold ? extract_source_location : nil
223
+
224
+ req_segments.add(
225
+ type: :storage, duration: event.duration, detail: detail,
226
+ source: source, started_at: event.time
227
+ )
228
+ end
229
+
230
+ def extract_source_location
231
+ locations = caller_locations(4, 50)
232
+ locations&.each do |loc|
233
+ path = loc.path.to_s
234
+ if Fingerprint.app_frame?(path)
235
+ return "#{path}:#{loc.lineno}"
236
+ end
237
+ end
238
+ nil
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ # Declarative method tracing, similar to Elastic APM's SpanHelpers.
5
+ #
6
+ # class PaymentService
7
+ # include Catpm::SpanHelpers
8
+ #
9
+ # def process(order)
10
+ # # ...
11
+ # end
12
+ # span_method :process
13
+ #
14
+ # def self.bulk_charge(users)
15
+ # # ...
16
+ # end
17
+ # span_class_method :bulk_charge
18
+ # end
19
+ #
20
+ module SpanHelpers
21
+ def self.included(base)
22
+ base.extend(ClassMethods)
23
+ end
24
+
25
+ module ClassMethods
26
+ def span_method(method_name, span_name = nil)
27
+ method_name = method_name.to_sym
28
+ span_name ||= "#{name}##{method_name}"
29
+
30
+ mod = Module.new do
31
+ define_method(method_name) do |*args, **kwargs, &block|
32
+ Catpm.span(span_name) { super(*args, **kwargs, &block) }
33
+ end
34
+ end
35
+ prepend(mod)
36
+ end
37
+
38
+ def span_class_method(method_name, span_name = nil)
39
+ method_name = method_name.to_sym
40
+ span_name ||= "#{name}.#{method_name}"
41
+
42
+ mod = Module.new do
43
+ define_method(method_name) do |*args, **kwargs, &block|
44
+ Catpm.span(span_name) { super(*args, **kwargs, &block) }
45
+ end
46
+ end
47
+ singleton_class.prepend(mod)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class StackSampler
5
+ SAMPLE_INTERVAL = 0.005 # 5ms
6
+
7
+ def initialize(target_thread:, request_start:)
8
+ @target = target_thread
9
+ @request_start = request_start
10
+ @samples = []
11
+ @running = false
12
+ end
13
+
14
+ def start
15
+ @running = true
16
+ @thread = Thread.new do
17
+ while @running
18
+ locs = @target.backtrace_locations
19
+ @samples << [Process.clock_gettime(Process::CLOCK_MONOTONIC), locs] if locs
20
+ sleep(SAMPLE_INTERVAL)
21
+ end
22
+ end
23
+ @thread.priority = -1
24
+ end
25
+
26
+ def stop
27
+ @running = false
28
+ @thread&.join(0.1)
29
+ end
30
+
31
+ # Returns array of { parent: {segment}, children: [{segment}, ...] }
32
+ # Parent = app code frame that initiated the work
33
+ # Children = gem internals (collapsed by default in UI)
34
+ def to_segments(tracked_ranges: [])
35
+ return [] if @samples.size < 2
36
+
37
+ untracked = @samples.reject do |time, _|
38
+ tracked_ranges.any? { |s, e| time >= s && time <= e }
39
+ end
40
+ return [] if untracked.empty?
41
+
42
+ # Annotate: [time, app_frame (caller), leaf_frame (execution point)]
43
+ annotated = untracked.filter_map do |time, locs|
44
+ pair = extract_frame_pair(locs)
45
+ next unless pair
46
+ [time, pair[0], pair[1]]
47
+ end
48
+ return [] if annotated.empty?
49
+
50
+ # Group consecutive samples by app_frame
51
+ groups = []
52
+ current = nil
53
+
54
+ annotated.each do |time, app_frame, leaf_frame|
55
+ app_key = app_frame ? frame_key(app_frame) : nil
56
+
57
+ if current && current[:app_key] == app_key
58
+ current[:end_time] = time
59
+ current[:count] += 1
60
+ current[:leaves] << [time, leaf_frame]
61
+ else
62
+ groups << current if current
63
+ current = {
64
+ app_key: app_key,
65
+ app_frame: app_frame,
66
+ start_time: time,
67
+ end_time: time,
68
+ count: 1,
69
+ leaves: [[time, leaf_frame]]
70
+ }
71
+ end
72
+ end
73
+ groups << current if current
74
+
75
+ groups.filter_map do |group|
76
+ duration = estimate_duration(group)
77
+ next if duration < 1.0
78
+
79
+ offset = ((group[:start_time] - @request_start) * 1000.0).round(2)
80
+ app_frame = group[:app_frame]
81
+ leaf = group[:leaves].first&.last
82
+
83
+ # Build parent segment — always the app frame if available
84
+ if app_frame
85
+ app_path = app_frame.path.to_s
86
+ parent = {
87
+ type: 'code',
88
+ detail: build_app_detail(app_frame),
89
+ duration: duration.round(2),
90
+ offset: offset,
91
+ source: "#{app_path}:#{app_frame.lineno}",
92
+ started_at: group[:start_time]
93
+ }
94
+
95
+ # Children = gem internals (only if leaf differs from app frame)
96
+ children = build_children(group[:leaves])
97
+ # Skip children that are identical to parent (pure app code)
98
+ children.reject! { |c| c[:detail] == parent[:detail] }
99
+
100
+ { parent: parent, children: children }
101
+ elsif leaf
102
+ # No app frame — show leaf directly, no children
103
+ path = leaf.path.to_s
104
+ parent = {
105
+ type: classify_path(path),
106
+ detail: build_gem_detail(leaf),
107
+ duration: duration.round(2),
108
+ offset: offset,
109
+ started_at: group[:start_time]
110
+ }
111
+ { parent: parent, children: [] }
112
+ end
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ # Walk the stack: find the leaf (deepest interesting frame)
119
+ # and the app_frame (nearest app code above the leaf)
120
+ def extract_frame_pair(locations)
121
+ leaf_frame = nil
122
+ app_frame = nil
123
+
124
+ locations.each do |loc|
125
+ path = loc.path.to_s
126
+ next if path.start_with?('<internal:')
127
+ next if path.include?('/catpm/')
128
+ next if path.include?('/ruby/') && !path.include?('/gems/')
129
+
130
+ leaf_frame ||= loc
131
+
132
+ if Fingerprint.app_frame?(path)
133
+ app_frame = loc
134
+ break
135
+ end
136
+ end
137
+
138
+ return nil unless leaf_frame
139
+ [app_frame, leaf_frame]
140
+ end
141
+
142
+ def build_children(leaves)
143
+ spans = []
144
+ current = nil
145
+
146
+ leaves.each do |time, frame|
147
+ key = frame_key(frame)
148
+
149
+ if current && current[:key] == key
150
+ current[:end_time] = time
151
+ current[:count] += 1
152
+ else
153
+ spans << current if current
154
+ current = { key: key, frame: frame, start_time: time, end_time: time, count: 1 }
155
+ end
156
+ end
157
+ spans << current if current
158
+
159
+ spans.filter_map do |span|
160
+ duration = [
161
+ (span[:end_time] - span[:start_time]) * 1000.0,
162
+ span[:count] * SAMPLE_INTERVAL * 1000.0
163
+ ].max
164
+ next if duration < 1.0
165
+
166
+ frame = span[:frame]
167
+ path = frame.path.to_s
168
+
169
+ {
170
+ type: classify_path(path),
171
+ detail: build_gem_detail(frame),
172
+ duration: duration.round(2),
173
+ offset: ((span[:start_time] - @request_start) * 1000.0).round(2),
174
+ started_at: span[:start_time]
175
+ }
176
+ end
177
+ end
178
+
179
+ def estimate_duration(group)
180
+ [
181
+ (group[:end_time] - group[:start_time]) * 1000.0,
182
+ group[:count] * SAMPLE_INTERVAL * 1000.0
183
+ ].max
184
+ end
185
+
186
+ def frame_key(frame)
187
+ "#{frame.path}:#{frame.label}"
188
+ end
189
+
190
+ def classify_path(path)
191
+ return 'code' if Fingerprint.app_frame?(path)
192
+
193
+ gem = extract_gem_name(path)
194
+ case gem
195
+ when /\A(httpclient|net-http|faraday|httpx|typhoeus|excon|http)\z/ then 'http'
196
+ when /\A(pg|mysql2|sqlite3|trilogy)\z/ then 'sql'
197
+ when /\A(redis|dalli|hiredis)\z/ then 'cache'
198
+ when /\A(aws-sdk|google-cloud|fog)\z/ then 'storage'
199
+ when /\A(mail|net-smtp)\z/ then 'mailer'
200
+ else 'gem'
201
+ end
202
+ end
203
+
204
+ def build_app_detail(frame)
205
+ path = frame.path.to_s
206
+ short = path.sub(%r{.*/app/}, 'app/').sub(%r{.*/lib/}, 'lib/')
207
+ "#{short} in #{frame.label}"
208
+ end
209
+
210
+ def build_gem_detail(frame)
211
+ path = frame.path.to_s
212
+ if Fingerprint.app_frame?(path)
213
+ build_app_detail(frame)
214
+ else
215
+ gem = extract_gem_name(path) || 'unknown'
216
+ "#{gem}: #{frame.label}"
217
+ end
218
+ end
219
+
220
+ def extract_gem_name(path)
221
+ if path =~ /\/gems\/([a-zA-Z0-9_-]+)-[\d.]+/
222
+ $1
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ module Subscribers
5
+ class << self
6
+ def subscribe!
7
+ unsubscribe!
8
+
9
+ # IMPORTANT: SegmentSubscribers must be subscribed BEFORE the Collector.
10
+ # ActiveSupport::Notifications calls finish callbacks in subscription order.
11
+ # ControllerSpanSubscriber.finish (pop_span) must set the controller span
12
+ # duration BEFORE the Collector reads the segments.
13
+ SegmentSubscribers.subscribe! if Catpm.config.instrument_segments
14
+
15
+ if Catpm.config.instrument_http
16
+ @http_subscriber = ActiveSupport::Notifications.subscribe(
17
+ 'process_action.action_controller'
18
+ ) do |event|
19
+ Collector.process_action_controller(event)
20
+ end
21
+ end
22
+
23
+ if Catpm.config.instrument_jobs
24
+ @job_subscriber = ActiveSupport::Notifications.subscribe(
25
+ 'perform.active_job'
26
+ ) do |event|
27
+ Collector.process_active_job(event)
28
+ end
29
+ end
30
+ end
31
+
32
+ def unsubscribe!
33
+ if @http_subscriber
34
+ ActiveSupport::Notifications.unsubscribe(@http_subscriber)
35
+ @http_subscriber = nil
36
+ end
37
+
38
+ if @job_subscriber
39
+ ActiveSupport::Notifications.unsubscribe(@job_subscriber)
40
+ @job_subscriber = nil
41
+ end
42
+
43
+ SegmentSubscribers.unsubscribe!
44
+ end
45
+ end
46
+ end
47
+ end