catpm 0.9.7 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54937b58ef7d18fa437e232b7a660ac014737a6e716daed6e57ab7463dc38e27
4
- data.tar.gz: 76cfd9389ecb1f37794806353c2c56f1d7f799a9bf6f9e8c0c975c93b8423c53
3
+ metadata.gz: '0283e922a2169d04e59405a273250c1fd226b4b72731f01ba6f6495b6a216ce2'
4
+ data.tar.gz: 5af58e9875ee9ce18afc02816e79d94cb025025218b78695e560a7a662ce2953
5
5
  SHA512:
6
- metadata.gz: a948c19294ca90dc60215f58e3d8f6fbdd377f4b62f468eba76678b223af37610d549d4a52ea7f42d7c6fec4ab93952bceeb6e19857ec9c67ecf601a4a1a9b51
7
- data.tar.gz: 298c9964d29d3fc9b2570720a7813b30adc5f21c9b7f75e49c288c9fc4f4cd65c4196c798dc05caeb7824ebefb2553cd82b6778a3f1e1a8eeaac4f3d893a703b
6
+ metadata.gz: 0e73b3ea6819f8633104be971da72ad30c5de1b13bc4e0bece856d1e13b5987c705fb322f48c91622ae87d8acbd6b3d71ff0d2bb151939370a2685f4cf0ec7a7
7
+ data.tar.gz: 988f46467fbf05398f71cefff884584fb1daf2b148500df100b0824049d2cc659e5e74038e65c8de30cec27b36aaa6d503c5267475b8b4c7313e35b4e208e375
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  gem build catpm.gemspec
2
- gem push catpm-0.9.6.gem
2
+ gem push catpm-0.9.7.gem
3
3
 
4
4
  # Catpm
5
5
 
@@ -3,16 +3,28 @@
3
3
  module Catpm
4
4
  class ApplicationController < ActionController::Base
5
5
  before_action :authenticate!
6
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
7
+
8
+ def not_found
9
+ render 'catpm/shared/not_found', layout: 'catpm/application', status: :not_found
10
+ end
6
11
 
7
12
  private
8
13
 
14
+ def render_not_found
15
+ respond_to do |format|
16
+ format.html { render 'catpm/shared/record_not_found', layout: 'catpm/application', status: :not_found }
17
+ format.json { render json: { error: 'not_found' }, status: :not_found }
18
+ end
19
+ end
20
+
9
21
  def authenticate!
10
22
  if Catpm.config.access_policy
11
23
  unless Catpm.config.access_policy.call(request)
12
- render plain: "Unauthorized", status: :unauthorized
24
+ render plain: 'Unauthorized', status: :unauthorized
13
25
  end
14
26
  elsif Catpm.config.http_basic_auth_user.present? && Catpm.config.http_basic_auth_password.present?
15
- authenticate_or_request_with_http_basic("Catpm") do |username, password|
27
+ authenticate_or_request_with_http_basic('Catpm') do |username, password|
16
28
  ActiveSupport::SecurityUtils.secure_compare(username, Catpm.config.http_basic_auth_user) &
17
29
  ActiveSupport::SecurityUtils.secure_compare(password, Catpm.config.http_basic_auth_password)
18
30
  end
@@ -0,0 +1,24 @@
1
+ <% content_for :head_extra do %>
2
+ <style>
3
+ body { display: flex; flex-direction: column; min-height: 100vh; }
4
+ .not-found-content { flex: 1; display: flex; align-items: center; justify-content: center; }
5
+ .not-found-inner { text-align: center; padding: 24px; }
6
+ .not-found-cat {
7
+ font-family: var(--font-mono); font-size: 14px; line-height: 1.3;
8
+ color: var(--accent); white-space: pre; margin-bottom: 20px; user-select: none;
9
+ }
10
+ .not-found-code { font-size: 13px; color: var(--text-2); font-weight: 500; letter-spacing: 1px; margin-bottom: 24px; }
11
+ .not-found-link { font-size: 13px; color: var(--accent); }
12
+ .not-found-link:hover { text-decoration: underline; }
13
+ </style>
14
+ <% end %>
15
+
16
+ <div class="not-found-content">
17
+ <div class="not-found-inner">
18
+ <div class="not-found-cat"> /\_/\
19
+ ( o.o )
20
+ > ^ <</div>
21
+ <div class="not-found-code">404</div>
22
+ <a href="<%= catpm.status_index_path %>" class="not-found-link">&larr; Dashboard</a>
23
+ </div>
24
+ </div>
@@ -0,0 +1,24 @@
1
+ <% content_for :head_extra do %>
2
+ <style>
3
+ body { display: flex; flex-direction: column; min-height: 100vh; }
4
+ .not-found-content { flex: 1; display: flex; align-items: center; justify-content: center; }
5
+ .not-found-inner { text-align: center; padding: 24px; }
6
+ .not-found-paws { font-size: 40px; color: var(--accent); margin-bottom: 20px; letter-spacing: 6px; }
7
+ .not-found-title { font-size: 15px; font-weight: 600; color: var(--text-0); margin-bottom: 6px; }
8
+ .not-found-hint { font-size: 13px; color: var(--text-2); max-width: 380px; margin: 0 auto 24px; line-height: 1.6; }
9
+ .not-found-link { font-size: 13px; color: var(--accent); }
10
+ .not-found-link:hover { text-decoration: underline; }
11
+ </style>
12
+ <% end %>
13
+
14
+ <div class="not-found-content">
15
+ <div class="not-found-inner">
16
+ <div class="not-found-paws">&#x1F43E;</div>
17
+ <div class="not-found-title">This record is no longer available</div>
18
+ <p class="not-found-hint">
19
+ catpm rotates older performance data to keep memory usage low.
20
+ This resource was likely purged during a routine cleanup.
21
+ </p>
22
+ <a href="<%= catpm.status_index_path %>" class="not-found-link">&larr; Dashboard</a>
23
+ </div>
24
+ </div>
data/config/routes.rb CHANGED
@@ -29,4 +29,5 @@ Catpm::Engine.routes.draw do
29
29
  patch :toggle_pin
30
30
  end
31
31
  end
32
+ match '*unmatched', to: 'application#not_found', via: :all
32
33
  end
@@ -6,6 +6,10 @@ module Catpm
6
6
  MIN_GAP_MS = 1.0
7
7
  DEFAULT_ERROR_STATUS = 500
8
8
  DEFAULT_SUCCESS_STATUS = 200
9
+
10
+ @instrumentation_mutex = Mutex.new
11
+ @active_instrumented_count = 0
12
+
9
13
  class << self
10
14
  def process_action_controller(event)
11
15
  return unless Catpm.enabled?
@@ -389,12 +393,43 @@ module Catpm
389
393
 
390
394
  # For HTTP middleware where endpoint is unknown at start.
391
395
  def should_instrument_request?
392
- rand(Catpm.config.random_sample_rate) == 0
396
+ rand(Catpm.config.random_sample_rate) == 0 && try_start_instrumentation
393
397
  end
394
398
 
395
399
  # For track_request where endpoint is known at start.
396
- def should_instrument?(_kind, _target, _operation)
397
- rand(Catpm.config.random_sample_rate) == 0
400
+ # Always instruments targets matched by always_sample_targets.
401
+ def should_instrument?(_kind, target, _operation)
402
+ if Catpm.config.always_sample?(target)
403
+ try_start_instrumentation
404
+ else
405
+ rand(Catpm.config.random_sample_rate) == 0 && try_start_instrumentation
406
+ end
407
+ end
408
+
409
+ # Reset the concurrency counter (for tests and config reloads).
410
+ def reset_instrumentation_counter!
411
+ @instrumentation_mutex.synchronize { @active_instrumented_count = 0 }
412
+ end
413
+
414
+ # Release an instrumentation slot. Must be called in ensure block
415
+ # for every request where try_start_instrumentation returned true.
416
+ def end_instrumentation
417
+ @instrumentation_mutex.synchronize do
418
+ @active_instrumented_count = [@active_instrumented_count - 1, 0].max
419
+ end
420
+ end
421
+
422
+ # Atomically claim an instrumentation slot if within budget.
423
+ # Returns true if the slot was acquired, false if at capacity.
424
+ # Already-running instrumented requests are not affected.
425
+ def try_start_instrumentation
426
+ @instrumentation_mutex.synchronize do
427
+ max = Catpm.config.effective_max_concurrent_instrumented
428
+ return false if @active_instrumented_count >= max
429
+
430
+ @active_instrumented_count += 1
431
+ true
432
+ end
398
433
  end
399
434
 
400
435
  private
@@ -456,8 +491,9 @@ module Catpm
456
491
  # carry full context in the buffer.
457
492
  # Non-instrumented requests have no segments — skip sample creation.
458
493
  def early_sample_type(error:, duration:, kind:, target:, operation:, instrumented: true)
459
- return 'error' if error && instrumented
460
- return nil unless instrumented
494
+ always = Catpm.config.always_sample?(target)
495
+ return 'error' if error && (instrumented || always)
496
+ return nil unless instrumented || always
461
497
  return 'slow' if duration >= Catpm.config.slow_threshold_for(kind.to_sym)
462
498
 
463
499
  'random'
@@ -9,6 +9,12 @@ module Catpm
9
9
  BUFFER_MEMORY_SHARE = 0.5 # 50% of max_memory for event buffer
10
10
  CACHE_ENTRIES_PER_MB = 10_000 # ~100 bytes/entry in path_cache
11
11
  PATH_CACHE_BUDGET_SHARE = 0.05 # 5% of max_memory for path_cache
12
+ INSTRUMENTATION_BUDGET_SHARE = 0.30 # 30% of max_memory for concurrent instrumented requests
13
+
14
+ # Estimated per-request memory for instrumentation budget
15
+ ESTIMATED_BYTES_PER_STACK_SAMPLE = 10_000 # ~10 KB: backtrace_locations array (~100 frames × ~100 bytes)
16
+ ESTIMATED_BYTES_PER_SEGMENT = 500 # segment hash with strings
17
+ DEFAULT_SEGMENTS_ESTIMATE = 200 # used when max_segments_per_request is nil
12
18
 
13
19
  # Boolean / non-numeric settings — plain attr_accessor
14
20
  attr_accessor :enabled,
@@ -30,7 +36,8 @@ module Catpm
30
36
  :events_enabled,
31
37
  :track_own_requests,
32
38
  :downsampling_thresholds,
33
- :show_untracked_segments
39
+ :show_untracked_segments,
40
+ :always_sample_targets
34
41
 
35
42
  # Numeric settings that must be positive numbers (nil not allowed)
36
43
  REQUIRED_NUMERIC = %i[
@@ -122,6 +129,7 @@ module Catpm
122
129
  @caller_scan_depth = 50
123
130
  @instrument_call_tree = false
124
131
  @show_untracked_segments = false
132
+ @always_sample_targets = []
125
133
  end
126
134
 
127
135
  # Buffer gets BUFFER_MEMORY_SHARE of max_memory, scaled by thread count
@@ -135,12 +143,31 @@ module Catpm
135
143
  (max_memory * CACHE_ENTRIES_PER_MB * PATH_CACHE_BUDGET_SHARE).to_i
136
144
  end
137
145
 
146
+ # Max concurrent instrumented requests derived from max_memory.
147
+ # Each instrumented request holds stack samples + segments in memory
148
+ # until the request completes. This limits how many can run at once
149
+ # so total live instrumentation data stays within budget.
150
+ def effective_max_concurrent_instrumented
151
+ budget_bytes = max_memory * 1_048_576 * INSTRUMENTATION_BUDGET_SHARE
152
+ (budget_bytes / estimated_instrumented_request_bytes).clamp(1, 1000).to_i
153
+ end
154
+
138
155
  def slow_threshold_for(kind)
139
156
  slow_threshold_per_kind.fetch(kind.to_sym, slow_threshold)
140
157
  end
141
158
 
142
159
  def ignored?(target)
143
- ignored_targets.any? do |pattern|
160
+ match_target?(ignored_targets, target)
161
+ end
162
+
163
+ def always_sample?(target)
164
+ match_target?(always_sample_targets, target)
165
+ end
166
+
167
+ private
168
+
169
+ def match_target?(patterns, target)
170
+ patterns.any? do |pattern|
144
171
  case pattern
145
172
  when Regexp then pattern.match?(target)
146
173
  when String
@@ -152,5 +179,19 @@ module Catpm
152
179
  end
153
180
  end
154
181
  end
182
+
183
+ def estimated_instrumented_request_bytes
184
+ bytes = 0
185
+
186
+ if instrument_stack_sampler || instrument_call_tree
187
+ samples = max_stack_samples_per_request || StackSampler::HARD_SAMPLE_CAP
188
+ bytes += samples * ESTIMATED_BYTES_PER_STACK_SAMPLE
189
+ end
190
+
191
+ segments = max_segments_per_request || DEFAULT_SEGMENTS_ESTIMATE
192
+ bytes += segments * ESTIMATED_BYTES_PER_SEGMENT
193
+
194
+ [bytes, 50_000].max # minimum 50 KB estimate per request
195
+ end
155
196
  end
156
197
  end
@@ -33,6 +33,7 @@ module Catpm
33
33
  ensure
34
34
  req_segments&.stop_sampler
35
35
  req_segments&.release!
36
+ Collector.end_instrumentation if req_segments
36
37
  Thread.current[:catpm_request_segments] = nil
37
38
  Thread.current[:catpm_request_start] = nil
38
39
  Thread.current[:catpm_tracked_instrumented] = nil
data/lib/catpm/trace.rb CHANGED
@@ -75,14 +75,15 @@ module Catpm
75
75
  # process_update(...)
76
76
  # end
77
77
  #
78
- def self.track_request(kind: :http, target:, operation: '', context: {}, metadata: {})
78
+ def self.track_request(kind: :http, target:, operation: '', context: {}, metadata: {}, always_sample: false)
79
79
  return yield unless enabled?
80
80
 
81
81
  req_segments = Thread.current[:catpm_request_segments]
82
82
  owns_segments = false
83
83
 
84
84
  if req_segments.nil? && config.instrument_segments
85
- if Collector.should_instrument?(kind, target, operation)
85
+ force = always_sample || config.always_sample?(target)
86
+ if force ? Collector.try_start_instrumentation : Collector.should_instrument?(kind, target, operation)
86
87
  use_sampler = config.instrument_stack_sampler || config.instrument_call_tree
87
88
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
89
  req_segments = RequestSegments.new(
@@ -122,6 +123,7 @@ module Catpm
122
123
 
123
124
  if owns_segments
124
125
  req_segments&.release!
126
+ Collector.end_instrumentation
125
127
  Thread.current[:catpm_request_segments] = nil
126
128
  # Mark that this request was already instrumented and processed by
127
129
  # track_request. Without this, process_action_controller would see
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.7'
4
+ VERSION = '0.10.0'
5
5
  end
data/lib/catpm.rb CHANGED
@@ -39,6 +39,7 @@ module Catpm
39
39
  @buffer = nil
40
40
  @flusher = nil
41
41
  Fingerprint.reset_caches!
42
+ Collector.reset_instrumentation_counter!
42
43
  end
43
44
 
44
45
  def enabled?
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.7
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
@@ -66,6 +66,8 @@ files:
66
66
  - app/views/catpm/samples/show.html.erb
67
67
  - app/views/catpm/shared/_page_nav.html.erb
68
68
  - app/views/catpm/shared/_segments_waterfall.html.erb
69
+ - app/views/catpm/shared/not_found.html.erb
70
+ - app/views/catpm/shared/record_not_found.html.erb
69
71
  - app/views/catpm/status/index.html.erb
70
72
  - app/views/catpm/system/index.html.erb
71
73
  - app/views/catpm/system/pipeline.html.erb