catpm 0.9.4 → 0.9.5
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 +4 -4
- data/README.md +2 -21
- data/app/controllers/catpm/system_controller.rb +2 -1
- data/app/helpers/catpm/application_helper.rb +2 -4
- data/app/views/catpm/samples/show.html.erb +2 -2
- data/app/views/catpm/shared/_segments_waterfall.html.erb +7 -1
- data/app/views/catpm/system/index.html.erb +1 -1
- data/lib/catpm/buffer.rb +1 -1
- data/lib/catpm/collector.rb +14 -14
- data/lib/catpm/configuration.rb +25 -10
- data/lib/catpm/engine.rb +0 -4
- data/lib/catpm/lifecycle.rb +9 -1
- data/lib/catpm/middleware.rb +3 -3
- data/lib/catpm/patches/httpclient.rb +1 -1
- data/lib/catpm/patches/net_http.rb +1 -1
- data/lib/catpm/request_segments.rb +18 -7
- data/lib/catpm/segment_subscribers.rb +5 -5
- data/lib/catpm/trace.rb +4 -4
- data/lib/catpm/version.rb +1 -1
- data/lib/catpm.rb +6 -1
- data/lib/generators/catpm/templates/initializer.rb.tt +4 -6
- metadata +1 -2
- data/lib/catpm/auto_instrument.rb +0 -145
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 18b84e4c767fa3872bbf196d21c3d197e6d4f6517269357e7b24bf0514da3708
|
|
4
|
+
data.tar.gz: 6cc282d9f10f13546939f8e9c39b12b784687ef4c0d25e42a2a7e34a39cd3124
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1968d1e8c7ed1257d2f0bfc3e28f9e34b4e38057ec89e103c10f02bd42d99daa78cf7ba0a9fc31ff5c07a8dc9e0895d9f6c541c557345469a20e2c09da88dd9c
|
|
7
|
+
data.tar.gz: d9e5c1b664605c6e66c92e9e6520a1c606f979f10e5e0deb8883307b965f4b4c3d361ef2dcf7ddfe3fb840d99e107c4c666a1ef4487165543f5904285f2a5a07
|
data/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
gem build catpm.gemspec
|
|
2
|
-
gem push catpm-0.9.
|
|
2
|
+
gem push catpm-0.9.4.gem
|
|
3
3
|
|
|
4
4
|
# Catpm
|
|
5
5
|
|
|
@@ -16,7 +16,6 @@ Catpm is designed for small-to-medium Rails applications where a full APM (Datad
|
|
|
16
16
|
- **Error tracking** — fingerprinting, occurrence counting, context circular buffers
|
|
17
17
|
- **Built-in dashboard** — filterable by kind, endpoint drill-down, waterfall visualization
|
|
18
18
|
- **Custom events** — track business events (signups, payments, etc.) with `Catpm.event`
|
|
19
|
-
- **Auto-instrumentation** — service objects (`ApplicationService`, `BaseService`) traced automatically
|
|
20
19
|
- **Multi-database** — PostgreSQL (primary), SQLite (first-class)
|
|
21
20
|
- **Zero dependencies** — only Rails >= 7.1, no Redis or background queues required
|
|
22
21
|
- **Memory-safe** — configurable buffer limits, automatic downsampling with infinite retention
|
|
@@ -112,24 +111,6 @@ class PaymentService
|
|
|
112
111
|
end
|
|
113
112
|
```
|
|
114
113
|
|
|
115
|
-
### Auto-instrumentation
|
|
116
|
-
|
|
117
|
-
Service objects following the `ApplicationService.call` pattern are instrumented automatically — no configuration needed. If your base class has a different name:
|
|
118
|
-
|
|
119
|
-
```ruby
|
|
120
|
-
Catpm.configure do |config|
|
|
121
|
-
config.service_base_classes = ['MyServiceBase']
|
|
122
|
-
end
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
You can also instrument specific methods explicitly:
|
|
126
|
-
|
|
127
|
-
```ruby
|
|
128
|
-
Catpm.configure do |config|
|
|
129
|
-
config.auto_instrument_methods = ['Worker#process', 'Gateway.charge']
|
|
130
|
-
end
|
|
131
|
-
```
|
|
132
|
-
|
|
133
114
|
### Custom events
|
|
134
115
|
|
|
135
116
|
Track business-level events that aren't tied to performance:
|
|
@@ -182,7 +163,7 @@ Catpm.configure do |config|
|
|
|
182
163
|
]
|
|
183
164
|
|
|
184
165
|
# Tuning
|
|
185
|
-
config.
|
|
166
|
+
config.max_memory_per_thread = 2.megabytes # Memory budget per thread (buffer + request segments)
|
|
186
167
|
config.flush_interval = 30 # Seconds between DB flushes
|
|
187
168
|
end
|
|
188
169
|
```
|
|
@@ -6,6 +6,7 @@ module Catpm
|
|
|
6
6
|
@stats = Catpm.stats
|
|
7
7
|
@buffer_size = Catpm.buffer&.size || 0
|
|
8
8
|
@buffer_bytes = Catpm.buffer&.current_bytes || 0
|
|
9
|
+
@buffer_max_bytes = Catpm.buffer&.max_bytes || 0
|
|
9
10
|
@config = Catpm.config
|
|
10
11
|
@oldest_bucket = Catpm::Bucket.minimum(:bucket_start)
|
|
11
12
|
@active_error_count = Catpm::ErrorRecord.unresolved.count
|
|
@@ -13,7 +14,7 @@ module Catpm
|
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def pipeline
|
|
16
|
-
render layout:
|
|
17
|
+
render layout: 'catpm/pipeline'
|
|
17
18
|
end
|
|
18
19
|
end
|
|
19
20
|
end
|
|
@@ -68,7 +68,7 @@ module Catpm
|
|
|
68
68
|
slow_threshold: { group: 'Segments', label: 'Slow Threshold', desc: 'Requests slower than this are flagged as slow', fmt: :ms },
|
|
69
69
|
slow_threshold_per_kind: { group: 'Segments', label: 'Slow Threshold (per kind)', desc: 'Override slow threshold for specific request kinds (http, job, custom)', fmt: :hash_ms },
|
|
70
70
|
max_segments_per_request: { group: 'Segments', label: 'Max Segments / Request', desc: 'Cap on segments captured per request', fmt: :nullable_int },
|
|
71
|
-
|
|
71
|
+
min_segment_duration: { group: 'Segments', label: 'Min Segment Duration', desc: 'Segments shorter than this are counted in summary but not stored; 0 = store all', fmt: :ms_zero },
|
|
72
72
|
max_sql_length: { group: 'Segments', label: 'Max SQL Length', desc: 'Truncate SQL queries beyond this many characters', fmt: :nullable_chars },
|
|
73
73
|
ignored_targets: { group: 'Segments', label: 'Ignored Targets', desc: 'Endpoint patterns excluded from tracking (strings or regexps)', fmt: :list },
|
|
74
74
|
|
|
@@ -94,7 +94,7 @@ module Catpm
|
|
|
94
94
|
events_max_samples_per_name: { group: 'Events', label: 'Max Samples / Name', desc: 'Event samples retained per event name', fmt: :nullable_int },
|
|
95
95
|
|
|
96
96
|
# ── Buffer & Flush ──
|
|
97
|
-
|
|
97
|
+
max_memory_per_thread: { group: 'Buffer & Flush', label: 'Memory per Thread', desc: 'Memory budget per application thread (split between request segments and event buffer)', fmt: :bytes },
|
|
98
98
|
flush_interval: { group: 'Buffer & Flush', label: 'Flush Interval', desc: 'How often the background thread drains the buffer to the database', fmt: :seconds },
|
|
99
99
|
flush_jitter: { group: 'Buffer & Flush', label: 'Flush Jitter', desc: 'Random jitter added to flush interval to avoid thundering herd', fmt: :pm_seconds },
|
|
100
100
|
persistence_batch_size: { group: 'Buffer & Flush', label: 'Batch Size', desc: 'Number of events written per database transaction', fmt: :int },
|
|
@@ -121,8 +121,6 @@ module Catpm
|
|
|
121
121
|
# ── Advanced ──
|
|
122
122
|
shutdown_timeout: { group: 'Advanced', label: 'Shutdown Timeout', desc: 'Seconds to wait for buffer flush on application shutdown', fmt: :seconds },
|
|
123
123
|
caller_scan_depth: { group: 'Advanced', label: 'Caller Scan Depth', desc: 'Max stack frames scanned to find app code for source attribution', fmt: :int },
|
|
124
|
-
auto_instrument_methods: { group: 'Advanced', label: 'Auto-Instrument Methods', desc: 'Method signatures to automatically instrument (e.g. Worker#process)', fmt: :list },
|
|
125
|
-
service_base_classes: { group: 'Advanced', label: 'Service Base Classes', desc: 'Base classes for auto-detection of service objects; nil = auto-detect', fmt: :nullable_list },
|
|
126
124
|
}.freeze
|
|
127
125
|
|
|
128
126
|
def format_config_value(config, attr, meta)
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
<%# ─── Request Context ─── %>
|
|
42
42
|
<%
|
|
43
|
-
ctx_display = @context.except("segments", :segments, "segment_summary", :segment_summary, "segments_capped", :segments_capped, "backtrace", :backtrace, "method", :method, "path", :path, "status", :status)
|
|
43
|
+
ctx_display = @context.except("segments", :segments, "segment_summary", :segment_summary, "segments_capped", :segments_capped, "segments_filtered", :segments_filtered, "backtrace", :backtrace, "method", :method, "path", :path, "status", :status)
|
|
44
44
|
ctx_flat = ctx_display.select { |_, v| !v.is_a?(Hash) && !v.is_a?(Array) }
|
|
45
45
|
ctx_nested = ctx_display.select { |_, v| v.is_a?(Hash) || v.is_a?(Array) }
|
|
46
46
|
%>
|
|
@@ -103,5 +103,5 @@
|
|
|
103
103
|
|
|
104
104
|
<%# ─── Segments Waterfall (full width, no title) ─── %>
|
|
105
105
|
<% if @segments.any? %>
|
|
106
|
-
<%= render "catpm/shared/segments_waterfall", segments: @segments, total_duration: @sample.duration, segments_capped: @context["segments_capped"] || @context[:segments_capped], table_id: "segments-table" %>
|
|
106
|
+
<%= render "catpm/shared/segments_waterfall", segments: @segments, total_duration: @sample.duration, segments_capped: @context["segments_capped"] || @context[:segments_capped], segments_filtered: @context["segments_filtered"] || @context[:segments_filtered] || 0, table_id: "segments-table" %>
|
|
107
107
|
<% end %>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# Locals: segments, total_duration, segments_capped, table_id %>
|
|
1
|
+
<%# Locals: segments, total_duration, segments_capped, segments_filtered, table_id %>
|
|
2
2
|
|
|
3
3
|
<% if segments.any? %>
|
|
4
4
|
<% total_dur = total_duration.to_f %>
|
|
@@ -147,6 +147,12 @@
|
|
|
147
147
|
<% end %>
|
|
148
148
|
</tbody>
|
|
149
149
|
</table>
|
|
150
|
+
<% filtered = segments_filtered.to_i %>
|
|
151
|
+
<% if filtered > 0 %>
|
|
152
|
+
<div style="padding:6px 12px; font-size:12px; color:var(--text-2); border-top:1px solid var(--border)">
|
|
153
|
+
<%= filtered %> segment<%= filtered == 1 ? '' : 's' %> below <%= Catpm.config.min_segment_duration %>ms not shown (counted in Time Breakdown)
|
|
154
|
+
</div>
|
|
155
|
+
<% end %>
|
|
150
156
|
</div>
|
|
151
157
|
<% else %>
|
|
152
158
|
<div class="empty-state">
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<div class="diag-card">
|
|
11
11
|
<div class="diag-label">Buffer</div>
|
|
12
12
|
<div class="diag-value"><%= @buffer_size %> <span class="diag-unit">events</span></div>
|
|
13
|
-
<div class="diag-detail"><%= number_to_human_size(@buffer_bytes) %> / <%= number_to_human_size(@
|
|
13
|
+
<div class="diag-detail"><%= number_to_human_size(@buffer_bytes) %> / <%= number_to_human_size(@buffer_max_bytes) %></div>
|
|
14
14
|
</div>
|
|
15
15
|
<div class="diag-card">
|
|
16
16
|
<div class="diag-label">Flushes</div>
|
data/lib/catpm/buffer.rb
CHANGED
data/lib/catpm/collector.rb
CHANGED
|
@@ -4,6 +4,8 @@ module Catpm
|
|
|
4
4
|
module Collector
|
|
5
5
|
SYNTHETIC_MIDDLEWARE_OFFSET_MS = 0.5
|
|
6
6
|
MIN_GAP_MS = 1.0
|
|
7
|
+
DEFAULT_ERROR_STATUS = 500
|
|
8
|
+
DEFAULT_SUCCESS_STATUS = 200
|
|
7
9
|
# Cap global force-instrument counter to avoid cascade when many requests
|
|
8
10
|
# are slow. Without this cap, apps with 30% slow requests would see ~23%
|
|
9
11
|
# instrumentation instead of the configured 1/random_sample_rate.
|
|
@@ -19,7 +21,7 @@ module Catpm
|
|
|
19
21
|
return if Catpm.config.ignored?(target)
|
|
20
22
|
|
|
21
23
|
duration = event.duration # milliseconds
|
|
22
|
-
status = payload[:status] || (payload[:exception] ?
|
|
24
|
+
status = payload[:status] || (payload[:exception] ? DEFAULT_ERROR_STATUS : nil)
|
|
23
25
|
metadata = build_http_metadata(payload)
|
|
24
26
|
|
|
25
27
|
req_segments = Thread.current[:catpm_request_segments]
|
|
@@ -50,7 +52,7 @@ module Catpm
|
|
|
50
52
|
metadata[:_instrumented] = 1 if instrumented
|
|
51
53
|
|
|
52
54
|
# Early sampling decision — only build heavy context for sampled events
|
|
53
|
-
operation = payload[:method] || '
|
|
55
|
+
operation = payload[:method] || ''
|
|
54
56
|
sample_type = early_sample_type(
|
|
55
57
|
error: payload[:exception],
|
|
56
58
|
duration: duration,
|
|
@@ -159,6 +161,7 @@ module Catpm
|
|
|
159
161
|
context[:segments] = segments
|
|
160
162
|
context[:segment_summary] = segment_data[:segment_summary]
|
|
161
163
|
context[:segments_capped] = segment_data[:segments_capped]
|
|
164
|
+
context[:segments_filtered] = segment_data[:segments_filtered] if segment_data[:segments_filtered] > 0
|
|
162
165
|
|
|
163
166
|
# Append error marker segment inside the controller
|
|
164
167
|
if payload[:exception]
|
|
@@ -340,6 +343,7 @@ module Catpm
|
|
|
340
343
|
context[:segments] = segments
|
|
341
344
|
context[:segment_summary] = segment_data[:segment_summary]
|
|
342
345
|
context[:segments_capped] = segment_data[:segments_capped]
|
|
346
|
+
context[:segments_filtered] = segment_data[:segments_filtered] if segment_data[:segments_filtered] > 0
|
|
343
347
|
|
|
344
348
|
# Append error marker segment inside the controller
|
|
345
349
|
if error
|
|
@@ -376,7 +380,7 @@ module Catpm
|
|
|
376
380
|
operation: operation.to_s,
|
|
377
381
|
duration: duration,
|
|
378
382
|
started_at: Time.current,
|
|
379
|
-
status: error ?
|
|
383
|
+
status: error ? DEFAULT_ERROR_STATUS : DEFAULT_SUCCESS_STATUS,
|
|
380
384
|
context: context,
|
|
381
385
|
sample_type: sample_type,
|
|
382
386
|
metadata: metadata,
|
|
@@ -427,7 +431,7 @@ module Catpm
|
|
|
427
431
|
operation: operation.to_s,
|
|
428
432
|
duration: duration_so_far,
|
|
429
433
|
started_at: Time.current,
|
|
430
|
-
status:
|
|
434
|
+
status: DEFAULT_SUCCESS_STATUS,
|
|
431
435
|
context: checkpoint_context,
|
|
432
436
|
sample_type: 'random',
|
|
433
437
|
metadata: (metadata || {}).dup.merge(checkpoint_data[:summary] || {}),
|
|
@@ -627,13 +631,9 @@ module Catpm
|
|
|
627
631
|
|
|
628
632
|
is_slow = duration >= Catpm.config.slow_threshold_for(kind.to_sym)
|
|
629
633
|
|
|
630
|
-
# Non-instrumented slow requests still get a sample (for dashboard) but
|
|
631
|
-
# don't count towards filling phase (they have no segments).
|
|
632
|
-
return 'slow' if is_slow && !instrumented
|
|
633
|
-
|
|
634
634
|
# Non-instrumented requests have no segments — skip sample creation.
|
|
635
|
-
#
|
|
636
|
-
# so the NEXT request gets full instrumentation with segments.
|
|
635
|
+
# Slow/error spikes are handled by the caller via trigger_force_instrument
|
|
636
|
+
# so the NEXT request gets full instrumentation with useful segments.
|
|
637
637
|
return nil unless instrumented
|
|
638
638
|
|
|
639
639
|
# Count this instrumented request towards filling phase completion.
|
|
@@ -758,10 +758,10 @@ module Catpm
|
|
|
758
758
|
end
|
|
759
759
|
|
|
760
760
|
def build_http_metadata(payload)
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
761
|
+
metadata = {}
|
|
762
|
+
metadata[:db_runtime] = payload[:db_runtime] if payload[:db_runtime]
|
|
763
|
+
metadata[:view_runtime] = payload[:view_runtime] if payload[:view_runtime]
|
|
764
|
+
metadata
|
|
765
765
|
end
|
|
766
766
|
|
|
767
767
|
def scrub(hash)
|
data/lib/catpm/configuration.rb
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Catpm
|
|
4
4
|
class Configuration
|
|
5
|
+
# Memory budget shares — how max_memory_per_thread is split
|
|
6
|
+
BUFFER_MEMORY_SHARE = 0.5 # half per-thread budget goes to buffer pool
|
|
7
|
+
REQUEST_MEMORY_SHARE = 0.5 # half per-thread budget for request segments
|
|
8
|
+
MIN_REQUEST_MEMORY = 1_024 # 1 KB — floor for per-request (checkpoint viability, ~5 minimal segments)
|
|
9
|
+
MIN_BUFFER_MEMORY = 1_048_576 # 1 MB — floor for buffer (meaningful buffering)
|
|
10
|
+
DEFAULT_ASSUMED_THREADS = 5 # fallback when thread detection fails
|
|
11
|
+
|
|
5
12
|
# Boolean / non-numeric settings — plain attr_accessor
|
|
6
13
|
attr_accessor :enabled,
|
|
7
14
|
:instrument_http,
|
|
@@ -19,8 +26,6 @@ module Catpm
|
|
|
19
26
|
:http_basic_auth_password,
|
|
20
27
|
:access_policy,
|
|
21
28
|
:additional_filter_parameters,
|
|
22
|
-
:auto_instrument_methods,
|
|
23
|
-
:service_base_classes,
|
|
24
29
|
:events_enabled,
|
|
25
30
|
:track_own_requests,
|
|
26
31
|
:downsampling_thresholds,
|
|
@@ -28,11 +33,11 @@ module Catpm
|
|
|
28
33
|
|
|
29
34
|
# Numeric settings that must be positive numbers (nil not allowed)
|
|
30
35
|
REQUIRED_NUMERIC = %i[
|
|
31
|
-
slow_threshold
|
|
36
|
+
slow_threshold flush_interval flush_jitter
|
|
32
37
|
random_sample_rate cleanup_interval
|
|
33
38
|
circuit_breaker_failure_threshold circuit_breaker_recovery_timeout
|
|
34
39
|
sqlite_busy_timeout persistence_batch_size shutdown_timeout
|
|
35
|
-
stack_sample_interval
|
|
40
|
+
stack_sample_interval min_segment_duration
|
|
36
41
|
].freeze
|
|
37
42
|
|
|
38
43
|
# Numeric settings where nil means "no limit" / "disabled"
|
|
@@ -43,7 +48,7 @@ module Catpm
|
|
|
43
48
|
events_max_samples_per_name max_stack_samples_per_request
|
|
44
49
|
max_error_detail_length max_fingerprint_app_frames
|
|
45
50
|
max_fingerprint_gem_frames cleanup_batch_size caller_scan_depth
|
|
46
|
-
|
|
51
|
+
max_memory_per_thread
|
|
47
52
|
].freeze
|
|
48
53
|
|
|
49
54
|
(REQUIRED_NUMERIC + OPTIONAL_NUMERIC).each do |attr|
|
|
@@ -72,13 +77,13 @@ module Catpm
|
|
|
72
77
|
@instrument_stack_sampler = false
|
|
73
78
|
@instrument_middleware_stack = false
|
|
74
79
|
@max_segments_per_request = 50
|
|
75
|
-
@
|
|
80
|
+
@min_segment_duration = 5.0 # ms — segments shorter than this are counted in summary but not stored (0.0 = store all)
|
|
76
81
|
@max_sql_length = 200
|
|
77
82
|
@slow_threshold = 500 # milliseconds
|
|
78
83
|
@slow_threshold_per_kind = {}
|
|
79
84
|
@ignored_targets = []
|
|
80
85
|
@retention_period = nil # nil = keep forever (data is downsampled, not deleted)
|
|
81
|
-
@
|
|
86
|
+
@max_memory_per_thread = 2.megabytes
|
|
82
87
|
@flush_interval = 30 # seconds
|
|
83
88
|
@flush_jitter = 5 # ±seconds
|
|
84
89
|
@max_error_contexts = 5
|
|
@@ -88,8 +93,6 @@ module Catpm
|
|
|
88
93
|
@http_basic_auth_password = nil
|
|
89
94
|
@access_policy = nil
|
|
90
95
|
@additional_filter_parameters = []
|
|
91
|
-
@auto_instrument_methods = []
|
|
92
|
-
@service_base_classes = nil # nil = auto-detect (ApplicationService, BaseService)
|
|
93
96
|
@random_sample_rate = 20
|
|
94
97
|
@max_random_samples_per_endpoint = 5
|
|
95
98
|
@max_slow_samples_per_endpoint = 5
|
|
@@ -117,11 +120,23 @@ module Catpm
|
|
|
117
120
|
@max_fingerprint_gem_frames = 3
|
|
118
121
|
@cleanup_batch_size = 1_000
|
|
119
122
|
@caller_scan_depth = 50
|
|
120
|
-
@max_request_memory = 2.megabytes
|
|
121
123
|
@instrument_call_tree = false
|
|
122
124
|
@show_untracked_segments = false
|
|
123
125
|
end
|
|
124
126
|
|
|
127
|
+
def derived_request_memory_limit
|
|
128
|
+
return nil unless max_memory_per_thread
|
|
129
|
+
|
|
130
|
+
[max_memory_per_thread * REQUEST_MEMORY_SHARE, MIN_REQUEST_MEMORY].max
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def derived_buffer_memory_limit(detected_threads = nil)
|
|
134
|
+
return MIN_BUFFER_MEMORY unless max_memory_per_thread
|
|
135
|
+
|
|
136
|
+
threads = detected_threads || DEFAULT_ASSUMED_THREADS
|
|
137
|
+
[max_memory_per_thread * BUFFER_MEMORY_SHARE * threads, MIN_BUFFER_MEMORY].max
|
|
138
|
+
end
|
|
139
|
+
|
|
125
140
|
def slow_threshold_for(kind)
|
|
126
141
|
slow_threshold_per_kind.fetch(kind.to_sym, slow_threshold)
|
|
127
142
|
end
|
data/lib/catpm/engine.rb
CHANGED
|
@@ -12,7 +12,6 @@ module Catpm
|
|
|
12
12
|
if Catpm.enabled?
|
|
13
13
|
Catpm::Subscribers.subscribe!
|
|
14
14
|
Catpm::Lifecycle.register_hooks
|
|
15
|
-
Catpm::AutoInstrument.apply!
|
|
16
15
|
|
|
17
16
|
if Catpm.config.instrument_middleware_stack
|
|
18
17
|
app = Rails.application
|
|
@@ -26,8 +25,5 @@ module Catpm
|
|
|
26
25
|
end
|
|
27
26
|
end
|
|
28
27
|
|
|
29
|
-
config.to_prepare do
|
|
30
|
-
Catpm::AutoInstrument.apply! if Catpm.enabled?
|
|
31
|
-
end
|
|
32
28
|
end
|
|
33
29
|
end
|
data/lib/catpm/lifecycle.rb
CHANGED
|
@@ -39,7 +39,15 @@ module Catpm
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def initialize_buffer
|
|
42
|
-
|
|
42
|
+
max_bytes = Catpm.config.derived_buffer_memory_limit(detect_threads)
|
|
43
|
+
Catpm.buffer ||= Buffer.new(max_bytes: max_bytes)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def detect_threads
|
|
47
|
+
return Puma.cli_config.options[:max_threads] if defined?(Puma::Server) && Puma.respond_to?(:cli_config)
|
|
48
|
+
return ENV['RAILS_MAX_THREADS'].to_i if ENV['RAILS_MAX_THREADS'].present?
|
|
49
|
+
return Sidekiq[:concurrency] if defined?(Sidekiq) && Sidekiq.respond_to?(:[])
|
|
50
|
+
nil
|
|
43
51
|
end
|
|
44
52
|
|
|
45
53
|
def initialize_flusher
|
data/lib/catpm/middleware.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Catpm
|
|
|
21
21
|
request_start: env['catpm.request_start'],
|
|
22
22
|
stack_sample: use_sampler,
|
|
23
23
|
call_tree: Catpm.config.instrument_call_tree,
|
|
24
|
-
memory_limit: Catpm.config.
|
|
24
|
+
memory_limit: Catpm.config.derived_request_memory_limit
|
|
25
25
|
)
|
|
26
26
|
env['catpm.segments'] = req_segments
|
|
27
27
|
Thread.current[:catpm_request_segments] = req_segments
|
|
@@ -47,10 +47,10 @@ module Catpm
|
|
|
47
47
|
ev = Event.new(
|
|
48
48
|
kind: :http,
|
|
49
49
|
target: target_from_env(env),
|
|
50
|
-
operation: env['REQUEST_METHOD'] || '
|
|
50
|
+
operation: env['REQUEST_METHOD'] || '',
|
|
51
51
|
duration: elapsed_ms(env),
|
|
52
52
|
started_at: Time.current,
|
|
53
|
-
status:
|
|
53
|
+
status: Collector::DEFAULT_ERROR_STATUS,
|
|
54
54
|
sample_type: 'error',
|
|
55
55
|
error_class: exception.class.name,
|
|
56
56
|
error_message: exception.message,
|
|
@@ -17,7 +17,7 @@ module Catpm
|
|
|
17
17
|
status = response.status rescue nil
|
|
18
18
|
detail = "#{http_method} #{uri.host}#{uri.path}"
|
|
19
19
|
detail += " (#{status})" if status
|
|
20
|
-
source =
|
|
20
|
+
source = Catpm.segment_storable?(duration) ? extract_catpm_source : nil
|
|
21
21
|
|
|
22
22
|
segments.add(
|
|
23
23
|
type: :http, duration: duration, detail: detail,
|
|
@@ -12,7 +12,7 @@ module Catpm
|
|
|
12
12
|
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
|
|
13
13
|
|
|
14
14
|
detail = "#{req.method} #{@address}#{req.path} (#{response.code})"
|
|
15
|
-
source =
|
|
15
|
+
source = Catpm.segment_storable?(duration) ? extract_catpm_source : nil
|
|
16
16
|
|
|
17
17
|
segments.add(
|
|
18
18
|
type: :http, duration: duration, detail: detail,
|
|
@@ -9,7 +9,7 @@ module Catpm
|
|
|
9
9
|
SEGMENT_BASE_BYTES = Event::OBJECT_OVERHEAD + (6 * Event::HASH_ENTRY_SIZE)
|
|
10
10
|
SEGMENT_STRING_OVERHEAD = Event::OBJECT_OVERHEAD # per-string overhead in segment values
|
|
11
11
|
|
|
12
|
-
attr_reader :segments, :summary, :request_start, :estimated_bytes, :checkpoint_count, :request_id
|
|
12
|
+
attr_reader :segments, :summary, :request_start, :estimated_bytes, :checkpoint_count, :request_id, :segments_filtered
|
|
13
13
|
|
|
14
14
|
def initialize(max_segments:, request_start: nil, stack_sample: false, call_tree: false, memory_limit: nil)
|
|
15
15
|
@max_segments = max_segments
|
|
@@ -25,6 +25,7 @@ module Catpm
|
|
|
25
25
|
@checkpoint_callback = nil
|
|
26
26
|
@checkpoint_count = 0
|
|
27
27
|
@request_id = memory_limit ? SecureRandom.hex(8) : nil
|
|
28
|
+
@segments_filtered = 0
|
|
28
29
|
|
|
29
30
|
if stack_sample
|
|
30
31
|
@sampler = StackSampler.new(target_thread: Thread.current, request_start: @request_start, call_tree: call_tree)
|
|
@@ -42,6 +43,18 @@ module Catpm
|
|
|
42
43
|
@summary[count_key] += 1
|
|
43
44
|
@summary[dur_key] += duration
|
|
44
45
|
|
|
46
|
+
# Record time range so sampler can skip already-tracked periods
|
|
47
|
+
if started_at && duration > 0
|
|
48
|
+
@tracked_ranges << [started_at, started_at + duration / 1000.0]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Skip storing segment below minimum duration threshold —
|
|
52
|
+
# summary counters above are still updated for accurate Time Breakdown.
|
|
53
|
+
unless Catpm.segment_storable?(duration)
|
|
54
|
+
@segments_filtered += 1
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
45
58
|
offset = started_at ? ((started_at - @request_start) * 1000.0).round(2) : nil
|
|
46
59
|
|
|
47
60
|
segment = { type: type.to_s, duration: duration.round(2), detail: detail }
|
|
@@ -49,11 +62,6 @@ module Catpm
|
|
|
49
62
|
segment[:source] = source if source
|
|
50
63
|
segment[:parent_index] = @span_stack.last if @span_stack.any?
|
|
51
64
|
|
|
52
|
-
# Record time range so sampler can skip already-tracked periods
|
|
53
|
-
if started_at && duration > 0
|
|
54
|
-
@tracked_ranges << [started_at, started_at + duration / 1000.0]
|
|
55
|
-
end
|
|
56
|
-
|
|
57
65
|
if @max_segments.nil? || @segments.size < @max_segments
|
|
58
66
|
@segments << segment
|
|
59
67
|
else
|
|
@@ -132,6 +140,7 @@ module Catpm
|
|
|
132
140
|
@tracked_ranges = []
|
|
133
141
|
@sampler = nil
|
|
134
142
|
@estimated_bytes = 0
|
|
143
|
+
@segments_filtered = 0
|
|
135
144
|
end
|
|
136
145
|
|
|
137
146
|
def overflowed?
|
|
@@ -142,7 +151,8 @@ module Catpm
|
|
|
142
151
|
{
|
|
143
152
|
segments: @segments,
|
|
144
153
|
segment_summary: @summary,
|
|
145
|
-
segments_capped: @overflow
|
|
154
|
+
segments_capped: @overflow,
|
|
155
|
+
segments_filtered: @segments_filtered
|
|
146
156
|
}
|
|
147
157
|
end
|
|
148
158
|
|
|
@@ -217,6 +227,7 @@ module Catpm
|
|
|
217
227
|
@tracked_ranges = []
|
|
218
228
|
@overflow = false
|
|
219
229
|
@estimated_bytes = 0
|
|
230
|
+
@segments_filtered = 0
|
|
220
231
|
end
|
|
221
232
|
end
|
|
222
233
|
end
|
|
@@ -155,7 +155,7 @@ module Catpm
|
|
|
155
155
|
record_count = payload[:record_count] || 0
|
|
156
156
|
class_name = payload[:class_name] || 'ActiveRecord'
|
|
157
157
|
detail = "#{class_name} x#{record_count}"
|
|
158
|
-
source =
|
|
158
|
+
source = Catpm.segment_storable?(duration) ? extract_source_location : nil
|
|
159
159
|
|
|
160
160
|
# Fold into sql summary for cleaner breakdown
|
|
161
161
|
req_segments.add(
|
|
@@ -176,7 +176,7 @@ module Catpm
|
|
|
176
176
|
sql = payload[:sql].to_s
|
|
177
177
|
max_len = Catpm.config.max_sql_length
|
|
178
178
|
sql = sql.truncate(max_len) if max_len && sql.length > max_len
|
|
179
|
-
source =
|
|
179
|
+
source = Catpm.segment_storable?(duration) ? extract_source_location : nil
|
|
180
180
|
|
|
181
181
|
req_segments.add(
|
|
182
182
|
type: :sql, duration: duration, detail: sql,
|
|
@@ -193,7 +193,7 @@ module Catpm
|
|
|
193
193
|
hit = event.payload[:hit]
|
|
194
194
|
detail = "cache.#{operation} #{key}"
|
|
195
195
|
detail += hit ? ' (hit)' : ' (miss)' if operation == 'read' && !hit.nil?
|
|
196
|
-
source =
|
|
196
|
+
source = Catpm.segment_storable?(duration) ? extract_source_location : nil
|
|
197
197
|
|
|
198
198
|
req_segments.add(
|
|
199
199
|
type: :cache, duration: duration, detail: detail,
|
|
@@ -209,7 +209,7 @@ module Catpm
|
|
|
209
209
|
mailer = payload[:mailer].to_s
|
|
210
210
|
to = Array(payload[:to]).first.to_s
|
|
211
211
|
detail = to.empty? ? mailer : "#{mailer} to #{to}"
|
|
212
|
-
source = event.duration
|
|
212
|
+
source = Catpm.segment_storable?(event.duration) ? extract_source_location : nil
|
|
213
213
|
|
|
214
214
|
req_segments.add(
|
|
215
215
|
type: :mailer, duration: event.duration, detail: detail,
|
|
@@ -224,7 +224,7 @@ module Catpm
|
|
|
224
224
|
payload = event.payload
|
|
225
225
|
key = payload[:key].to_s
|
|
226
226
|
detail = "#{operation} #{key}"
|
|
227
|
-
source = event.duration
|
|
227
|
+
source = Catpm.segment_storable?(event.duration) ? extract_source_location : nil
|
|
228
228
|
|
|
229
229
|
req_segments.add(
|
|
230
230
|
type: :storage, duration: event.duration, detail: detail,
|
data/lib/catpm/trace.rb
CHANGED
|
@@ -47,7 +47,7 @@ module Catpm
|
|
|
47
47
|
|
|
48
48
|
req_segments = Thread.current[:catpm_request_segments]
|
|
49
49
|
if req_segments
|
|
50
|
-
source = duration_ms
|
|
50
|
+
source = segment_storable?(duration_ms) ? extract_trace_source : nil
|
|
51
51
|
req_segments.add(
|
|
52
52
|
type: :custom, duration: duration_ms, detail: name,
|
|
53
53
|
source: source, started_at: start_time
|
|
@@ -90,12 +90,12 @@ module Catpm
|
|
|
90
90
|
request_start: start_time,
|
|
91
91
|
stack_sample: use_sampler,
|
|
92
92
|
call_tree: config.instrument_call_tree,
|
|
93
|
-
memory_limit: config.
|
|
93
|
+
memory_limit: config.derived_request_memory_limit
|
|
94
94
|
)
|
|
95
95
|
Thread.current[:catpm_request_segments] = req_segments
|
|
96
96
|
owns_segments = true
|
|
97
97
|
|
|
98
|
-
if config.
|
|
98
|
+
if config.derived_request_memory_limit
|
|
99
99
|
req_segments.on_checkpoint do |checkpoint_data|
|
|
100
100
|
Collector.process_checkpoint(
|
|
101
101
|
kind: kind, target: target, operation: operation,
|
|
@@ -162,7 +162,7 @@ module Catpm
|
|
|
162
162
|
|
|
163
163
|
req_segments = Thread.current[:catpm_request_segments]
|
|
164
164
|
if req_segments
|
|
165
|
-
source =
|
|
165
|
+
source = Catpm.segment_storable?(duration_ms) ? Catpm.send(:extract_trace_source) : nil
|
|
166
166
|
req_segments.add(
|
|
167
167
|
type: :custom, duration: duration_ms, detail: @name,
|
|
168
168
|
source: source, started_at: @start_time
|
data/lib/catpm/version.rb
CHANGED
data/lib/catpm.rb
CHANGED
|
@@ -22,7 +22,6 @@ require 'catpm/segment_subscribers'
|
|
|
22
22
|
require 'catpm/lifecycle'
|
|
23
23
|
require 'catpm/trace'
|
|
24
24
|
require 'catpm/span_helpers'
|
|
25
|
-
require 'catpm/auto_instrument'
|
|
26
25
|
require 'catpm/engine'
|
|
27
26
|
|
|
28
27
|
module Catpm
|
|
@@ -64,5 +63,11 @@ module Catpm
|
|
|
64
63
|
return unless enabled? && config.events_enabled
|
|
65
64
|
buffer&.push(CustomEvent.new(name: name, payload: payload))
|
|
66
65
|
end
|
|
66
|
+
|
|
67
|
+
# Whether a segment with the given duration should be stored (and have source captured).
|
|
68
|
+
# Segments below min_segment_duration are still counted in summary but not stored.
|
|
69
|
+
def segment_storable?(duration)
|
|
70
|
+
duration >= config.min_segment_duration
|
|
71
|
+
end
|
|
67
72
|
end
|
|
68
73
|
end
|
|
@@ -23,13 +23,9 @@ Catpm.configure do |config|
|
|
|
23
23
|
# config.show_untracked_segments = false
|
|
24
24
|
# config.track_own_requests = false
|
|
25
25
|
|
|
26
|
-
# === Auto-instrumentation ===
|
|
27
|
-
# config.service_base_classes = nil # nil = auto-detect (ApplicationService, BaseService)
|
|
28
|
-
# config.auto_instrument_methods = [] # e.g. ["Worker#process", "Gateway.charge"]
|
|
29
|
-
|
|
30
26
|
# === Segments ===
|
|
31
27
|
# config.max_segments_per_request = 50 # nil = unlimited
|
|
32
|
-
# config.
|
|
28
|
+
# config.min_segment_duration = 5.0 # ms — segments shorter than this are counted but not stored (0 = store all)
|
|
33
29
|
# config.max_sql_length = 200 # nil = no truncation
|
|
34
30
|
# config.slow_threshold = 500 # ms
|
|
35
31
|
# config.slow_threshold_per_kind = {} # { http: 500, job: 5_000, custom: 1_000 }
|
|
@@ -53,8 +49,10 @@ Catpm.configure do |config|
|
|
|
53
49
|
# config.events_enabled = false
|
|
54
50
|
# config.events_max_samples_per_name = 20 # nil = unlimited
|
|
55
51
|
|
|
52
|
+
# === Memory ===
|
|
53
|
+
# config.max_memory_per_thread = 2.megabytes # memory budget per thread (buffer + request segments)
|
|
54
|
+
|
|
56
55
|
# === Buffering & Flushing ===
|
|
57
|
-
# config.max_buffer_memory = 8.megabytes
|
|
58
56
|
# config.flush_interval = 30 # seconds
|
|
59
57
|
# config.flush_jitter = 5 # ±seconds
|
|
60
58
|
# config.persistence_batch_size = 100
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: catpm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.9.
|
|
4
|
+
version: 0.9.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ''
|
|
@@ -78,7 +78,6 @@ files:
|
|
|
78
78
|
- lib/catpm/adapter/base.rb
|
|
79
79
|
- lib/catpm/adapter/postgresql.rb
|
|
80
80
|
- lib/catpm/adapter/sqlite.rb
|
|
81
|
-
- lib/catpm/auto_instrument.rb
|
|
82
81
|
- lib/catpm/buffer.rb
|
|
83
82
|
- lib/catpm/call_tracer.rb
|
|
84
83
|
- lib/catpm/circuit_breaker.rb
|
|
@@ -1,145 +0,0 @@
|
|
|
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
|