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,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module Catpm
|
|
6
|
+
module Adapter
|
|
7
|
+
module PostgreSQL
|
|
8
|
+
extend Base
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def persist_buckets(aggregated_buckets)
|
|
12
|
+
return if aggregated_buckets.empty?
|
|
13
|
+
|
|
14
|
+
ActiveRecord::Base.connection_pool.with_connection do |conn|
|
|
15
|
+
aggregated_buckets.each_slice(Catpm.config.persistence_batch_size) do |batch|
|
|
16
|
+
batch.each do |bucket_data|
|
|
17
|
+
lock_id = advisory_lock_key(
|
|
18
|
+
"bucket:#{bucket_data[:kind]}:#{bucket_data[:target]}:" \
|
|
19
|
+
"#{bucket_data[:operation]}:#{bucket_data[:bucket_start]}"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
ActiveRecord::Base.transaction do
|
|
23
|
+
conn.execute("SELECT pg_advisory_xact_lock(#{lock_id})")
|
|
24
|
+
|
|
25
|
+
existing = Catpm::Bucket.find_by(
|
|
26
|
+
kind: bucket_data[:kind],
|
|
27
|
+
target: bucket_data[:target],
|
|
28
|
+
operation: bucket_data[:operation],
|
|
29
|
+
bucket_start: bucket_data[:bucket_start]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if existing
|
|
33
|
+
merged_metadata = merge_metadata_sum(
|
|
34
|
+
existing.metadata_sum, bucket_data[:metadata_sum]
|
|
35
|
+
)
|
|
36
|
+
merged_digest = merge_digest(
|
|
37
|
+
existing.p95_digest, bucket_data[:p95_digest]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
existing.update!(
|
|
41
|
+
count: existing.count + bucket_data[:count],
|
|
42
|
+
success_count: existing.success_count + bucket_data[:success_count],
|
|
43
|
+
failure_count: existing.failure_count + bucket_data[:failure_count],
|
|
44
|
+
duration_sum: existing.duration_sum + bucket_data[:duration_sum],
|
|
45
|
+
duration_max: [existing.duration_max, bucket_data[:duration_max]].max,
|
|
46
|
+
duration_min: [existing.duration_min, bucket_data[:duration_min]].min,
|
|
47
|
+
metadata_sum: merged_metadata,
|
|
48
|
+
p95_digest: merged_digest
|
|
49
|
+
)
|
|
50
|
+
else
|
|
51
|
+
Catpm::Bucket.create!(
|
|
52
|
+
kind: bucket_data[:kind],
|
|
53
|
+
target: bucket_data[:target],
|
|
54
|
+
operation: bucket_data[:operation],
|
|
55
|
+
bucket_start: bucket_data[:bucket_start],
|
|
56
|
+
count: bucket_data[:count],
|
|
57
|
+
success_count: bucket_data[:success_count],
|
|
58
|
+
failure_count: bucket_data[:failure_count],
|
|
59
|
+
duration_sum: bucket_data[:duration_sum],
|
|
60
|
+
duration_max: bucket_data[:duration_max],
|
|
61
|
+
duration_min: bucket_data[:duration_min],
|
|
62
|
+
metadata_sum: bucket_data[:metadata_sum],
|
|
63
|
+
p95_digest: bucket_data[:p95_digest]
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def persist_event_buckets(event_buckets)
|
|
73
|
+
return if event_buckets.empty?
|
|
74
|
+
|
|
75
|
+
ActiveRecord::Base.connection_pool.with_connection do |conn|
|
|
76
|
+
event_buckets.each_slice(Catpm.config.persistence_batch_size) do |batch|
|
|
77
|
+
batch.each do |bucket_data|
|
|
78
|
+
lock_id = advisory_lock_key("event_bucket:#{bucket_data[:name]}:#{bucket_data[:bucket_start]}")
|
|
79
|
+
|
|
80
|
+
ActiveRecord::Base.transaction do
|
|
81
|
+
conn.execute("SELECT pg_advisory_xact_lock(#{lock_id})")
|
|
82
|
+
|
|
83
|
+
existing = Catpm::EventBucket.find_by(
|
|
84
|
+
name: bucket_data[:name],
|
|
85
|
+
bucket_start: bucket_data[:bucket_start]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if existing
|
|
89
|
+
existing.update!(count: existing.count + bucket_data[:count])
|
|
90
|
+
else
|
|
91
|
+
Catpm::EventBucket.create!(
|
|
92
|
+
name: bucket_data[:name],
|
|
93
|
+
bucket_start: bucket_data[:bucket_start],
|
|
94
|
+
count: bucket_data[:count]
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def persist_event_samples(event_samples)
|
|
104
|
+
return if event_samples.empty?
|
|
105
|
+
|
|
106
|
+
ActiveRecord::Base.connection_pool.with_connection do
|
|
107
|
+
max = Catpm.config.events_max_samples_per_name
|
|
108
|
+
|
|
109
|
+
event_samples.each_slice(Catpm.config.persistence_batch_size) do |batch|
|
|
110
|
+
records = batch.map do |sample_data|
|
|
111
|
+
{ name: sample_data[:name], payload: sample_data[:payload], recorded_at: sample_data[:recorded_at] }
|
|
112
|
+
end
|
|
113
|
+
Catpm::EventSample.insert_all(records) if records.any?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Rotate: delete oldest samples beyond max per name
|
|
117
|
+
event_samples.map { |s| s[:name] }.uniq.each do |name|
|
|
118
|
+
count = Catpm::EventSample.where(name: name).count
|
|
119
|
+
if count > max
|
|
120
|
+
excess_ids = Catpm::EventSample.where(name: name)
|
|
121
|
+
.order(recorded_at: :asc)
|
|
122
|
+
.limit(count - max)
|
|
123
|
+
.pluck(:id)
|
|
124
|
+
Catpm::EventSample.where(id: excess_ids).delete_all
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def persist_errors(error_records)
|
|
131
|
+
return if error_records.empty?
|
|
132
|
+
|
|
133
|
+
ActiveRecord::Base.connection_pool.with_connection do |conn|
|
|
134
|
+
error_records.each_slice(Catpm.config.persistence_batch_size) do |batch|
|
|
135
|
+
batch.each do |error_data|
|
|
136
|
+
lock_id = advisory_lock_key("error:#{error_data[:fingerprint]}")
|
|
137
|
+
|
|
138
|
+
ActiveRecord::Base.transaction do
|
|
139
|
+
conn.execute("SELECT pg_advisory_xact_lock(#{lock_id})")
|
|
140
|
+
|
|
141
|
+
existing = Catpm::ErrorRecord.find_by(fingerprint: error_data[:fingerprint])
|
|
142
|
+
|
|
143
|
+
if existing
|
|
144
|
+
merged_contexts = merge_contexts(
|
|
145
|
+
existing.parsed_contexts, error_data[:new_contexts]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
attrs = {
|
|
149
|
+
occurrences_count: existing.occurrences_count + error_data[:occurrences_count],
|
|
150
|
+
last_occurred_at: [existing.last_occurred_at, error_data[:last_occurred_at]].max,
|
|
151
|
+
contexts: merged_contexts
|
|
152
|
+
}
|
|
153
|
+
attrs[:resolved_at] = nil if existing.resolved?
|
|
154
|
+
|
|
155
|
+
existing.update!(attrs)
|
|
156
|
+
else
|
|
157
|
+
Catpm::ErrorRecord.create!(
|
|
158
|
+
fingerprint: error_data[:fingerprint],
|
|
159
|
+
kind: error_data[:kind],
|
|
160
|
+
error_class: error_data[:error_class],
|
|
161
|
+
message: error_data[:message],
|
|
162
|
+
occurrences_count: error_data[:occurrences_count],
|
|
163
|
+
first_occurred_at: error_data[:first_occurred_at],
|
|
164
|
+
last_occurred_at: error_data[:last_occurred_at],
|
|
165
|
+
contexts: error_data[:new_contexts]
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def modulo_bucket_sql(interval)
|
|
175
|
+
"EXTRACT(EPOCH FROM bucket_start)::integer % #{interval.to_i} = 0"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def advisory_lock_key(identifier)
|
|
181
|
+
Zlib.crc32(identifier.to_s) & 0x7FFFFFFF
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
module Adapter
|
|
5
|
+
module SQLite
|
|
6
|
+
extend Base
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def persist_buckets(aggregated_buckets)
|
|
10
|
+
return if aggregated_buckets.empty?
|
|
11
|
+
|
|
12
|
+
with_write_lock do
|
|
13
|
+
aggregated_buckets.each do |bucket_data|
|
|
14
|
+
existing = Catpm::Bucket.find_by(
|
|
15
|
+
kind: bucket_data[:kind],
|
|
16
|
+
target: bucket_data[:target],
|
|
17
|
+
operation: bucket_data[:operation],
|
|
18
|
+
bucket_start: bucket_data[:bucket_start]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if existing
|
|
22
|
+
merged_metadata = merge_metadata_sum(
|
|
23
|
+
existing.metadata_sum, bucket_data[:metadata_sum]
|
|
24
|
+
)
|
|
25
|
+
merged_digest = merge_digest(
|
|
26
|
+
existing.p95_digest, bucket_data[:p95_digest]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
existing.update!(
|
|
30
|
+
count: existing.count + bucket_data[:count],
|
|
31
|
+
success_count: existing.success_count + bucket_data[:success_count],
|
|
32
|
+
failure_count: existing.failure_count + bucket_data[:failure_count],
|
|
33
|
+
duration_sum: existing.duration_sum + bucket_data[:duration_sum],
|
|
34
|
+
duration_max: [existing.duration_max, bucket_data[:duration_max]].max,
|
|
35
|
+
duration_min: [existing.duration_min, bucket_data[:duration_min]].min,
|
|
36
|
+
metadata_sum: merged_metadata.to_json,
|
|
37
|
+
p95_digest: merged_digest
|
|
38
|
+
)
|
|
39
|
+
else
|
|
40
|
+
Catpm::Bucket.create!(
|
|
41
|
+
kind: bucket_data[:kind],
|
|
42
|
+
target: bucket_data[:target],
|
|
43
|
+
operation: bucket_data[:operation],
|
|
44
|
+
bucket_start: bucket_data[:bucket_start],
|
|
45
|
+
count: bucket_data[:count],
|
|
46
|
+
success_count: bucket_data[:success_count],
|
|
47
|
+
failure_count: bucket_data[:failure_count],
|
|
48
|
+
duration_sum: bucket_data[:duration_sum],
|
|
49
|
+
duration_max: bucket_data[:duration_max],
|
|
50
|
+
duration_min: bucket_data[:duration_min],
|
|
51
|
+
metadata_sum: bucket_data[:metadata_sum]&.to_json,
|
|
52
|
+
p95_digest: bucket_data[:p95_digest]
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def persist_event_buckets(event_buckets)
|
|
60
|
+
return if event_buckets.empty?
|
|
61
|
+
|
|
62
|
+
with_write_lock do
|
|
63
|
+
event_buckets.each do |bucket_data|
|
|
64
|
+
existing = Catpm::EventBucket.find_by(
|
|
65
|
+
name: bucket_data[:name],
|
|
66
|
+
bucket_start: bucket_data[:bucket_start]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if existing
|
|
70
|
+
existing.update!(count: existing.count + bucket_data[:count])
|
|
71
|
+
else
|
|
72
|
+
Catpm::EventBucket.create!(
|
|
73
|
+
name: bucket_data[:name],
|
|
74
|
+
bucket_start: bucket_data[:bucket_start],
|
|
75
|
+
count: bucket_data[:count]
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def persist_event_samples(event_samples)
|
|
83
|
+
return if event_samples.empty?
|
|
84
|
+
|
|
85
|
+
max = Catpm.config.events_max_samples_per_name
|
|
86
|
+
|
|
87
|
+
with_write_lock do
|
|
88
|
+
event_samples.each_slice(Catpm.config.persistence_batch_size) do |batch|
|
|
89
|
+
records = batch.map do |sample_data|
|
|
90
|
+
{ name: sample_data[:name], payload: sample_data[:payload]&.to_json, recorded_at: sample_data[:recorded_at] }
|
|
91
|
+
end
|
|
92
|
+
Catpm::EventSample.insert_all(records) if records.any?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
event_samples.map { |s| s[:name] }.uniq.each do |name|
|
|
96
|
+
count = Catpm::EventSample.where(name: name).count
|
|
97
|
+
if count > max
|
|
98
|
+
excess_ids = Catpm::EventSample.where(name: name)
|
|
99
|
+
.order(recorded_at: :asc)
|
|
100
|
+
.limit(count - max)
|
|
101
|
+
.pluck(:id)
|
|
102
|
+
Catpm::EventSample.where(id: excess_ids).delete_all
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def persist_errors(error_records)
|
|
109
|
+
return if error_records.empty?
|
|
110
|
+
|
|
111
|
+
with_write_lock do
|
|
112
|
+
error_records.each do |error_data|
|
|
113
|
+
existing = Catpm::ErrorRecord.find_by(fingerprint: error_data[:fingerprint])
|
|
114
|
+
|
|
115
|
+
if existing
|
|
116
|
+
merged_contexts = merge_contexts(
|
|
117
|
+
existing.parsed_contexts, error_data[:new_contexts]
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
attrs = {
|
|
121
|
+
occurrences_count: existing.occurrences_count + error_data[:occurrences_count],
|
|
122
|
+
last_occurred_at: [existing.last_occurred_at, error_data[:last_occurred_at]].max,
|
|
123
|
+
contexts: merged_contexts.to_json
|
|
124
|
+
}
|
|
125
|
+
attrs[:resolved_at] = nil if existing.resolved?
|
|
126
|
+
|
|
127
|
+
existing.update!(attrs)
|
|
128
|
+
else
|
|
129
|
+
Catpm::ErrorRecord.create!(
|
|
130
|
+
fingerprint: error_data[:fingerprint],
|
|
131
|
+
kind: error_data[:kind],
|
|
132
|
+
error_class: error_data[:error_class],
|
|
133
|
+
message: error_data[:message],
|
|
134
|
+
occurrences_count: error_data[:occurrences_count],
|
|
135
|
+
first_occurred_at: error_data[:first_occurred_at],
|
|
136
|
+
last_occurred_at: error_data[:last_occurred_at],
|
|
137
|
+
contexts: error_data[:new_contexts].to_json
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def modulo_bucket_sql(interval)
|
|
145
|
+
"CAST(strftime('%s', bucket_start) AS INTEGER) % #{interval.to_i} = 0"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def with_write_lock(&block)
|
|
151
|
+
ActiveRecord::Base.connection_pool.with_connection do |conn|
|
|
152
|
+
conn.raw_connection.busy_timeout = Catpm.config.sqlite_busy_timeout
|
|
153
|
+
ActiveRecord::Base.transaction(&block)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'catpm/adapter/base'
|
|
4
|
+
require 'catpm/adapter/sqlite'
|
|
5
|
+
require 'catpm/adapter/postgresql'
|
|
6
|
+
|
|
7
|
+
module Catpm
|
|
8
|
+
module Adapter
|
|
9
|
+
def self.current
|
|
10
|
+
@current ||= resolve
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.reset!
|
|
14
|
+
@current = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.resolve
|
|
18
|
+
adapter_name = ActiveRecord::Base.connection.adapter_name
|
|
19
|
+
case adapter_name
|
|
20
|
+
when /SQLite/i then Catpm::Adapter::SQLite
|
|
21
|
+
when /PostgreSQL/i then Catpm::Adapter::PostgreSQL
|
|
22
|
+
else
|
|
23
|
+
raise Catpm::UnsupportedAdapter,
|
|
24
|
+
"catpm does not support #{adapter_name}. Supported: PostgreSQL, SQLite."
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
# Zero-config service auto-instrumentation.
|
|
5
|
+
#
|
|
6
|
+
# Detects common service base classes (ApplicationService, BaseService)
|
|
7
|
+
# and prepends span tracking on their .call class method. Since subclasses
|
|
8
|
+
# inherit .call from the base, ALL service objects get instrumented
|
|
9
|
+
# automatically — no code changes, no configuration lists.
|
|
10
|
+
#
|
|
11
|
+
# The typical Rails service pattern:
|
|
12
|
+
# class ApplicationService
|
|
13
|
+
# def self.call(...) = new(...).call
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# class Sync::Processor < ApplicationService
|
|
17
|
+
# def call = ...
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# After auto-instrumentation, Sync::Processor.call creates a span
|
|
21
|
+
# named "Sync::Processor#call" that wraps the entire service execution.
|
|
22
|
+
#
|
|
23
|
+
# Custom base classes:
|
|
24
|
+
# Catpm.configure { |c| c.service_base_classes = ["MyBase"] }
|
|
25
|
+
#
|
|
26
|
+
# Explicit method list for edge cases:
|
|
27
|
+
# Catpm.configure do |c|
|
|
28
|
+
# c.auto_instrument_methods = ["Worker#process", "Gateway.charge"]
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
module AutoInstrument
|
|
32
|
+
DEFAULT_SERVICE_BASES = %w[
|
|
33
|
+
ApplicationService
|
|
34
|
+
BaseService
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def apply!
|
|
39
|
+
instrument_service_bases
|
|
40
|
+
instrument_explicit_methods
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reset!
|
|
44
|
+
@applied = Set.new
|
|
45
|
+
@bases_applied = Set.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# ─── Auto-detect service base classes ───
|
|
51
|
+
|
|
52
|
+
def instrument_service_bases
|
|
53
|
+
@bases_applied ||= Set.new
|
|
54
|
+
|
|
55
|
+
bases = Catpm.config.service_base_classes
|
|
56
|
+
bases = DEFAULT_SERVICE_BASES if bases.nil?
|
|
57
|
+
|
|
58
|
+
bases.each do |base_name|
|
|
59
|
+
next if @bases_applied.include?(base_name)
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
klass = Object.const_get(base_name)
|
|
63
|
+
rescue NameError
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
next unless klass.is_a?(Class)
|
|
68
|
+
|
|
69
|
+
# Prepend on the class-level .call so ALL subclasses get instrumented.
|
|
70
|
+
# Since subclasses inherit .call from the base and only override
|
|
71
|
+
# instance #call, this single prepend covers everything.
|
|
72
|
+
if klass.respond_to?(:call)
|
|
73
|
+
# Guard against double-prepend (e.g. code reloading in development)
|
|
74
|
+
already = klass.singleton_class.ancestors.any? do |a|
|
|
75
|
+
a.instance_variable_defined?(:@catpm_service_span)
|
|
76
|
+
end
|
|
77
|
+
next if already
|
|
78
|
+
|
|
79
|
+
mod = Module.new do
|
|
80
|
+
@catpm_service_span = true
|
|
81
|
+
|
|
82
|
+
define_method(:call) do |*args, **kwargs, &block|
|
|
83
|
+
Catpm.span("#{name}#call", type: :code) { super(*args, **kwargs, &block) }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
klass.singleton_class.prepend(mod)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
@bases_applied << base_name
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ─── Explicit method list ───
|
|
94
|
+
|
|
95
|
+
def instrument_explicit_methods
|
|
96
|
+
methods = Catpm.config.auto_instrument_methods
|
|
97
|
+
return if methods.nil? || methods.empty?
|
|
98
|
+
|
|
99
|
+
@applied ||= Set.new
|
|
100
|
+
|
|
101
|
+
methods.each do |method_spec|
|
|
102
|
+
next if @applied.include?(method_spec)
|
|
103
|
+
|
|
104
|
+
if method_spec.include?('#')
|
|
105
|
+
class_name, method_name = method_spec.split('#', 2)
|
|
106
|
+
instrument_instance_method(class_name, method_name, method_spec)
|
|
107
|
+
elsif method_spec.include?('.')
|
|
108
|
+
class_name, method_name = method_spec.split('.', 2)
|
|
109
|
+
instrument_class_method(class_name, method_name, method_spec)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def instrument_instance_method(class_name, method_name, spec)
|
|
115
|
+
klass = Object.const_get(class_name)
|
|
116
|
+
span_name = spec
|
|
117
|
+
|
|
118
|
+
mod = Module.new do
|
|
119
|
+
define_method(method_name.to_sym) do |*args, **kwargs, &block|
|
|
120
|
+
Catpm.span(span_name) { super(*args, **kwargs, &block) }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
klass.prepend(mod)
|
|
124
|
+
@applied << spec
|
|
125
|
+
rescue NameError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def instrument_class_method(class_name, method_name, spec)
|
|
130
|
+
klass = Object.const_get(class_name)
|
|
131
|
+
span_name = spec
|
|
132
|
+
|
|
133
|
+
mod = Module.new do
|
|
134
|
+
define_method(method_name.to_sym) do |*args, **kwargs, &block|
|
|
135
|
+
Catpm.span(span_name) { super(*args, **kwargs, &block) }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
klass.singleton_class.prepend(mod)
|
|
139
|
+
@applied << spec
|
|
140
|
+
rescue NameError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
data/lib/catpm/buffer.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
class Buffer
|
|
5
|
+
attr_reader :current_bytes, :dropped_count
|
|
6
|
+
|
|
7
|
+
def initialize(max_bytes:)
|
|
8
|
+
@monitor = Monitor.new
|
|
9
|
+
@events = []
|
|
10
|
+
@current_bytes = 0
|
|
11
|
+
@max_bytes = max_bytes
|
|
12
|
+
@dropped_count = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Called from request threads. Returns :accepted or :dropped.
|
|
16
|
+
# Never blocks — monitoring must not slow down the application.
|
|
17
|
+
def push(event)
|
|
18
|
+
@monitor.synchronize do
|
|
19
|
+
bytes = event.estimated_bytes
|
|
20
|
+
if @current_bytes + bytes > @max_bytes
|
|
21
|
+
@dropped_count += 1
|
|
22
|
+
Catpm.stats[:dropped_events] += 1
|
|
23
|
+
return :dropped
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@events << event
|
|
27
|
+
@current_bytes += bytes
|
|
28
|
+
:accepted
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Called from flusher thread. Atomically swaps out the entire buffer.
|
|
33
|
+
# Returns the array of events and resets internal state.
|
|
34
|
+
def drain
|
|
35
|
+
@monitor.synchronize do
|
|
36
|
+
events = @events
|
|
37
|
+
@events = []
|
|
38
|
+
@current_bytes = 0
|
|
39
|
+
events
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def size
|
|
44
|
+
@monitor.synchronize { @events.size }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def empty?
|
|
48
|
+
@monitor.synchronize { @events.empty? }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def reset!
|
|
52
|
+
@monitor.synchronize do
|
|
53
|
+
@events = []
|
|
54
|
+
@current_bytes = 0
|
|
55
|
+
@dropped_count = 0
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Catpm
|
|
4
|
+
class CircuitBreaker
|
|
5
|
+
attr_reader :state
|
|
6
|
+
|
|
7
|
+
def initialize(failure_threshold: Catpm.config.circuit_breaker_failure_threshold, recovery_timeout: Catpm.config.circuit_breaker_recovery_timeout)
|
|
8
|
+
@failure_threshold = failure_threshold
|
|
9
|
+
@recovery_timeout = recovery_timeout
|
|
10
|
+
@failures = 0
|
|
11
|
+
@state = :closed
|
|
12
|
+
@opened_at = nil
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def open?
|
|
17
|
+
@mutex.synchronize do
|
|
18
|
+
case @state
|
|
19
|
+
when :closed
|
|
20
|
+
false
|
|
21
|
+
when :open
|
|
22
|
+
if Time.now - @opened_at >= @recovery_timeout
|
|
23
|
+
@state = :half_open
|
|
24
|
+
false # Allow one probe attempt
|
|
25
|
+
else
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
when :half_open
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def record_success
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
@failures = 0
|
|
37
|
+
@state = :closed
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def record_failure
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
@failures += 1
|
|
44
|
+
if @failures >= @failure_threshold
|
|
45
|
+
@state = :open
|
|
46
|
+
@opened_at = Time.now
|
|
47
|
+
Catpm.stats[:circuit_opens] += 1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reset!
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
@failures = 0
|
|
55
|
+
@state = :closed
|
|
56
|
+
@opened_at = nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|