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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b71e3f131bb8edb7a07b1f0996aa4ad2be18a365bf2724c2b67d160c7716ae8c
4
- data.tar.gz: f0961bf02dd509df819634f29fb5717345bc3d3efeabfaa8192476cd2c94917e
3
+ metadata.gz: 18b84e4c767fa3872bbf196d21c3d197e6d4f6517269357e7b24bf0514da3708
4
+ data.tar.gz: 6cc282d9f10f13546939f8e9c39b12b784687ef4c0d25e42a2a7e34a39cd3124
5
5
  SHA512:
6
- metadata.gz: 4e44d829fcb7ec331225d7539c00d76fafadceeb3192a7645c5f3149c2f060f1fb996c30f306c39166ca060074c78328372ff5a79e57696b03ce72abc0633fb8
7
- data.tar.gz: de507352426ba225ec5c8dfb2cf4805ea8ca29ae095944276948cb9441993c84bfed5f7f7a4c9c6dd313fe7c82ec22df94e98b7af727b2ee362f15f9921eccb4
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.3.gem
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.max_buffer_memory = 32.megabytes # In-memory buffer limit
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: "catpm/pipeline"
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
- segment_source_threshold: { group: 'Segments', label: 'Source Capture Threshold', desc: 'Minimum segment duration (ms) before caller_locations is captured; 0 = always', fmt: :ms_zero },
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
- max_buffer_memory: { group: 'Buffer & Flush', label: 'Max Buffer Memory', desc: 'Maximum memory for the in-memory event queue before events are dropped', fmt: :bytes },
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(@config.max_buffer_memory) %></div>
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Catpm
4
4
  class Buffer
5
- attr_reader :current_bytes, :dropped_count
5
+ attr_reader :current_bytes, :max_bytes, :dropped_count
6
6
 
7
7
  def initialize(max_bytes:)
8
8
  @monitor = Monitor.new
@@ -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] ? 500 : nil)
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] || 'GET'
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 ? 500 : 200,
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: 200,
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
- # Filling phase is handled by the caller via trigger_force_instrument
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
- h = {}
762
- h[:db_runtime] = payload[:db_runtime] if payload[:db_runtime]
763
- h[:view_runtime] = payload[:view_runtime] if payload[:view_runtime]
764
- h
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)
@@ -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 max_buffer_memory flush_interval flush_jitter
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 segment_source_threshold
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
- max_request_memory
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
- @segment_source_threshold = 5.0 # ms — capture caller_locations only for segments >= 5ms (set to 0.0 to capture all)
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
- @max_buffer_memory = 8.megabytes
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
@@ -39,7 +39,15 @@ module Catpm
39
39
  end
40
40
 
41
41
  def initialize_buffer
42
- Catpm.buffer ||= Buffer.new(max_bytes: Catpm.config.max_buffer_memory)
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
@@ -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.max_request_memory
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'] || 'GET',
50
+ operation: env['REQUEST_METHOD'] || '',
51
51
  duration: elapsed_ms(env),
52
52
  started_at: Time.current,
53
- status: 500,
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 = duration >= Catpm.config.segment_source_threshold ? extract_catpm_source : nil
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 = duration >= Catpm.config.segment_source_threshold ? extract_catpm_source : nil
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 = duration >= Catpm.config.segment_source_threshold ? extract_source_location : nil
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 = duration >= Catpm.config.segment_source_threshold ? extract_source_location : nil
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 = duration >= Catpm.config.segment_source_threshold ? extract_source_location : nil
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 >= Catpm.config.segment_source_threshold ? extract_source_location : nil
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 >= Catpm.config.segment_source_threshold ? extract_source_location : nil
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 >= config.segment_source_threshold ? extract_trace_source : nil
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.max_request_memory
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.max_request_memory
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 = duration_ms >= Catpm.config.segment_source_threshold ? Catpm.send(:extract_trace_source) : nil
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Catpm
4
- VERSION = '0.9.4'
4
+ VERSION = '0.9.5'
5
5
  end
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.segment_source_threshold = 0.0 # ms — capture caller_locations above this
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
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