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,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
|