catpm 0.8.3 → 0.9.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: 68fffa206b7baa5919309c1d54110c58205fe7b48bf37111724ba00b5ed07ef7
4
- data.tar.gz: eba9254577cdbc6afa64d1ec352d2bdc55827e1668890dd0dbba2ff16696ac3c
3
+ metadata.gz: f73d14442e21e40a399d3df0635a640cf6067dc10f34e17a74054ab855ac212a
4
+ data.tar.gz: db34666075f0f5e2c2506a6e7ddad705673f8328ae22edcf1264ae0b94fe999a
5
5
  SHA512:
6
- metadata.gz: d7c6016b9f7f3087638e0b1ab47ecb140bd3eff31b2c0988ab953590c435f162336b29e380cc231a5eb66d95c5ad4c7b93a77399fd513a99f8bcf0e44aface84
7
- data.tar.gz: 9a923abb3d7f882c76b9665509adea19173368d0395ec1b99b73d6288901bfdff6d0d89336d9081e5f7b5ecea96f6274e59e0cf62d7f17f6be7ddc221bdff18c
6
+ metadata.gz: 16edf4257962cc5b52ee5207604d6f4643fd93b3219ef15aaf4fbaac14779fa09f49b22aff387a97796d9435b741820f4e9380e2e5276df4939b47af9202f01d
7
+ data.tar.gz: c5e5b2de2aa7994d827760f77ff7e30fa313d77a51b58573a5129a6a09010815ba30deade3603c15482076b6a4e3cd888123cea7dd78ba026db6fbeab6bc7bc3
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  gem build catpm.gemspec
2
- gem push catpm-0.8.0.gem
2
+ gem push catpm-0.8.4.gem
3
3
 
4
4
  # Catpm
5
5
 
@@ -72,11 +72,13 @@
72
72
  <% end %>
73
73
 
74
74
  <%
75
+ instrumented_count = (@metadata["_instrumented"] || @metadata[:"_instrumented"] || 0).to_f
76
+ instrumented_count = @count.to_f if instrumented_count == 0 # backward compat with pre-sampling data
75
77
  type_data = segment_colors.map { |type, color|
76
78
  count = (@metadata["#{type}_count"] || @metadata[:"#{type}_count"] || 0).to_f
77
79
  dur = (@metadata["#{type}_duration"] || @metadata[:"#{type}_duration"] || 0).to_f
78
- avg_dur = @count > 0 ? dur / @count : 0
79
- avg_count = @count > 0 ? count / @count : 0
80
+ avg_dur = instrumented_count > 0 ? dur / instrumented_count : 0
81
+ avg_count = instrumented_count > 0 ? count / instrumented_count : 0
80
82
  text_color = segment_text_colors[type] || "#4b5563"
81
83
  { type: type, label: segment_labels[type] || type.capitalize, bg: color, text: text_color, count: count, dur: dur, avg_dur: avg_dur, avg_count: avg_count }
82
84
  }.select { |d| d[:count] > 0 }
@@ -15,7 +15,7 @@
15
15
  <% end %>
16
16
  <input type="text" class="search-input" id="error-search" placeholder="Search errors... (/)" oninput="filterByText('error-search','errors-table')">
17
17
  <% if @tab == "active" && @active_count > 0 %>
18
- <span style="display:inline; margin-left:auto"><%= button_to "Resolve all", catpm.resolve_all_errors_path, method: :post, class: "btn", data: { confirm: "Resolve all #{@active_count} active errors?" } %></span>
18
+ <span style="display:inline; margin-left:auto"><%= button_to "Resolve all", catpm.resolve_all_errors_path, method: :post, class: "btn" %></span>
19
19
  <% end %>
20
20
  </div>
21
21
  <% end %>
@@ -17,8 +17,7 @@
17
17
  <span>Sample #<%= @sample.id %></span>
18
18
  </div>
19
19
  <%= button_to "Delete Sample", catpm.sample_path(@sample),
20
- method: :delete, class: "btn btn-danger",
21
- data: { confirm: "Delete this sample? This cannot be undone." } %>
20
+ method: :delete, class: "btn btn-danger" %>
22
21
  </div>
23
22
 
24
23
  <%# ─── Request Info Bar ─── %>
@@ -19,6 +19,8 @@ module Catpm
19
19
  metadata = build_http_metadata(payload)
20
20
 
21
21
  req_segments = Thread.current[:catpm_request_segments]
22
+ instrumented = !req_segments.nil?
23
+
22
24
  if req_segments
23
25
  segment_data = req_segments.to_h
24
26
 
@@ -28,8 +30,17 @@ module Catpm
28
30
 
29
31
  # Segment summary is always needed for bucket metadata aggregation
30
32
  segment_data[:segment_summary].each { |k, v| metadata[k] = v }
33
+ else
34
+ # Non-instrumented request — compute duration from thread-local start time
35
+ request_start = Thread.current[:catpm_request_start]
36
+ if request_start
37
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_start) * 1000.0
38
+ end
31
39
  end
32
40
 
41
+ # Track instrumented count for correct dashboard averaging
42
+ metadata[:_instrumented] = 1 if instrumented
43
+
33
44
  # Early sampling decision — only build heavy context for sampled events
34
45
  operation = payload[:method] || 'GET'
35
46
  sample_type = early_sample_type(
@@ -37,9 +48,15 @@ module Catpm
37
48
  duration: duration,
38
49
  kind: :http,
39
50
  target: target,
40
- operation: operation
51
+ operation: operation,
52
+ instrumented: instrumented
41
53
  )
42
54
 
55
+ # Slow spike detection: force instrument next request for this endpoint
56
+ if !instrumented && (payload[:exception] || duration >= Catpm.config.slow_threshold_for(:http))
57
+ trigger_force_instrument(kind: :http, target: target, operation: operation)
58
+ end
59
+
43
60
  if sample_type
44
61
  context = build_http_context(payload)
45
62
 
@@ -145,7 +162,6 @@ module Catpm
145
162
  }
146
163
  end
147
164
 
148
- req_segments.release! # free internal state for GC
149
165
  end
150
166
 
151
167
  context = scrub(context)
@@ -225,20 +241,30 @@ module Catpm
225
241
  return if Catpm.config.ignored?(target)
226
242
 
227
243
  metadata = (metadata || {}).dup
244
+ instrumented = !req_segments.nil?
228
245
 
229
246
  if req_segments
230
247
  segment_data = req_segments.to_h
231
248
  segment_data[:segment_summary]&.each { |k, v| metadata[k] = v }
232
249
  end
233
250
 
251
+ # Track instrumented count for correct dashboard averaging
252
+ metadata[:_instrumented] = 1 if instrumented
253
+
234
254
  sample_type = early_sample_type(
235
255
  error: error,
236
256
  duration: duration,
237
257
  kind: kind,
238
258
  target: target,
239
- operation: operation
259
+ operation: operation,
260
+ instrumented: instrumented
240
261
  )
241
262
 
263
+ # Slow spike detection: force instrument next request for this endpoint
264
+ if !instrumented && (error || duration >= Catpm.config.slow_threshold_for(kind.to_sym))
265
+ trigger_force_instrument(kind: kind, target: target, operation: operation)
266
+ end
267
+
242
268
  if sample_type
243
269
  context = (context || {}).dup
244
270
 
@@ -317,7 +343,6 @@ module Catpm
317
343
  }
318
344
  end
319
345
 
320
- req_segments.release! # free internal state for GC
321
346
  end
322
347
 
323
348
  context = scrub(context)
@@ -343,6 +368,53 @@ module Catpm
343
368
  Catpm.buffer&.push(ev)
344
369
  end
345
370
 
371
+ def process_checkpoint(kind:, target:, operation:, context:, metadata:, checkpoint_data:, request_start:)
372
+ return unless Catpm.enabled?
373
+
374
+ segments = checkpoint_data[:segments].dup
375
+ collapse_code_wrappers(segments)
376
+
377
+ duration_so_far = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_start) * 1000.0
378
+
379
+ # Inject root request segment
380
+ root_segment = {
381
+ type: 'request',
382
+ detail: "#{operation.presence || kind} #{target}",
383
+ duration: duration_so_far.round(2),
384
+ offset: 0.0
385
+ }
386
+ segments.each do |seg|
387
+ if seg.key?(:parent_index)
388
+ seg[:parent_index] += 1
389
+ else
390
+ seg[:parent_index] = 0
391
+ end
392
+ end
393
+ segments.unshift(root_segment)
394
+
395
+ checkpoint_context = (context || {}).dup
396
+ checkpoint_context[:segments] = segments
397
+ checkpoint_context[:segment_summary] = checkpoint_data[:summary]
398
+ checkpoint_context[:segments_capped] = checkpoint_data[:overflow]
399
+ checkpoint_context[:partial] = true
400
+ checkpoint_context[:checkpoint_number] = checkpoint_data[:checkpoint_number]
401
+ checkpoint_context = scrub(checkpoint_context)
402
+
403
+ ev = Event.new(
404
+ kind: kind,
405
+ target: target,
406
+ operation: operation.to_s,
407
+ duration: duration_so_far,
408
+ started_at: Time.current,
409
+ status: 200,
410
+ context: checkpoint_context,
411
+ sample_type: 'random',
412
+ metadata: (metadata || {}).dup.merge(checkpoint_data[:summary] || {})
413
+ )
414
+
415
+ Catpm.buffer&.push(ev)
416
+ end
417
+
346
418
  def process_custom(name:, duration:, metadata: {}, error: nil, context: {})
347
419
  return unless Catpm.enabled?
348
420
  return if Catpm.config.ignored?(name)
@@ -363,7 +435,64 @@ module Catpm
363
435
  Catpm.buffer&.push(ev)
364
436
  end
365
437
 
366
- private
438
+ # --- Pre-sampling: decide BEFORE request whether to instrument ---
439
+
440
+ # For HTTP middleware where endpoint is unknown at start.
441
+ # Returns true if this request should get full instrumentation.
442
+ def should_instrument_request?
443
+ # Force after slow spike detection
444
+ if (@force_instrument_count || 0) > 0
445
+ @force_instrument_count -= 1
446
+ return true
447
+ end
448
+
449
+ rand(Catpm.config.random_sample_rate) == 0
450
+ end
451
+
452
+ # For track_request where endpoint is known at start.
453
+ # Filling phase ensures new endpoints get instrumented samples quickly.
454
+ def should_instrument?(kind, target, operation)
455
+ endpoint_key = [kind.to_s, target.to_s, (operation || '').to_s]
456
+
457
+ # Force after slow spike
458
+ if force_instrument_endpoints.delete(endpoint_key)
459
+ return true
460
+ end
461
+
462
+ # Filling phase — endpoint hasn't collected enough instrumented samples yet
463
+ max = Catpm.config.max_random_samples_per_endpoint
464
+ if max.nil? || instrumented_sample_counts[endpoint_key] < max
465
+ return true
466
+ end
467
+
468
+ rand(Catpm.config.random_sample_rate) == 0
469
+ end
470
+
471
+ # Called when a slow/error request had no instrumentation —
472
+ # forces the NEXT request(s) to be fully instrumented.
473
+ def trigger_force_instrument(kind: nil, target: nil, operation: nil)
474
+ if kind && target
475
+ endpoint_key = [kind.to_s, target.to_s, (operation || '').to_s]
476
+ force_instrument_endpoints[endpoint_key] = true
477
+ end
478
+ @force_instrument_count = (@force_instrument_count || 0) + 1
479
+ end
480
+
481
+ def reset_sample_counts!
482
+ @instrumented_sample_counts = nil
483
+ @force_instrument_endpoints = nil
484
+ @force_instrument_count = nil
485
+ end
486
+
487
+ private
488
+
489
+ def force_instrument_endpoints
490
+ @force_instrument_endpoints ||= {}
491
+ end
492
+
493
+ def instrumented_sample_counts
494
+ @instrumented_sample_counts ||= Hash.new(0)
495
+ end
367
496
 
368
497
  # Remove near-zero-duration "code" spans that merely wrap a "controller" span.
369
498
  # This happens when CallTracer (TracePoint) captures a thin dispatch method
@@ -419,31 +548,30 @@ module Catpm
419
548
  end
420
549
 
421
550
  # Determine sample type at event creation time so only sampled events
422
- # carry full context in the buffer. Includes filling phase via
423
- # process-level counter (resets on restart — acceptable approximation).
424
- def early_sample_type(error:, duration:, kind:, target:, operation:)
551
+ # carry full context in the buffer.
552
+ #
553
+ # When instrumented: false, only error/slow get a sample_type
554
+ # non-instrumented normal requests just contribute duration/count.
555
+ # Filling counter only increments for instrumented requests so
556
+ # non-instrumented requests don't waste filling slots.
557
+ def early_sample_type(error:, duration:, kind:, target:, operation:, instrumented: true)
425
558
  return 'error' if error
426
559
  return 'slow' if duration >= Catpm.config.slow_threshold_for(kind.to_sym)
427
560
 
428
- # Filling phase: always sample until endpoint has enough random samples
561
+ # Non-instrumented requests have no segments skip sample creation
562
+ return nil unless instrumented
563
+
564
+ # Filling phase: always sample until endpoint has enough instrumented samples
429
565
  endpoint_key = [kind.to_s, target, operation.to_s]
430
- count = random_sample_counts[endpoint_key]
566
+ count = instrumented_sample_counts[endpoint_key]
431
567
  max_random = Catpm.config.max_random_samples_per_endpoint
432
568
  if max_random.nil? || count < max_random
433
- random_sample_counts[endpoint_key] = count + 1
569
+ instrumented_sample_counts[endpoint_key] = count + 1
434
570
  return 'random'
435
571
  end
436
572
 
437
- return 'random' if rand(Catpm.config.random_sample_rate) == 0
438
- nil
439
- end
440
-
441
- def random_sample_counts
442
- @random_sample_counts ||= Hash.new(0)
443
- end
444
-
445
- def reset_sample_counts!
446
- @random_sample_counts = nil
573
+ # Instrumented request was already chosen by dice roll at start — always sample
574
+ 'random'
447
575
  end
448
576
 
449
577
  def inject_gap_segments(segments, req_segments, gap, ctrl_idx, ctrl_seg)
@@ -43,6 +43,7 @@ module Catpm
43
43
  events_max_samples_per_name max_stack_samples_per_request
44
44
  max_error_detail_length max_fingerprint_app_frames
45
45
  max_fingerprint_gem_frames cleanup_batch_size caller_scan_depth
46
+ max_request_memory
46
47
  ].freeze
47
48
 
48
49
  (REQUIRED_NUMERIC + OPTIONAL_NUMERIC).each do |attr|
@@ -116,6 +117,7 @@ module Catpm
116
117
  @max_fingerprint_gem_frames = 3
117
118
  @cleanup_batch_size = 1_000
118
119
  @caller_scan_depth = 50
120
+ @max_request_memory = 2.megabytes
119
121
  @instrument_call_tree = false
120
122
  @show_untracked_segments = false
121
123
  end
@@ -12,14 +12,16 @@ module Catpm
12
12
  Catpm.flusher&.ensure_running!
13
13
 
14
14
  env['catpm.request_start'] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
+ Thread.current[:catpm_request_start] = env['catpm.request_start']
15
16
 
16
- if Catpm.config.instrument_segments
17
+ if Catpm.config.instrument_segments && Collector.should_instrument_request?
17
18
  use_sampler = Catpm.config.instrument_stack_sampler || Catpm.config.instrument_call_tree
18
19
  req_segments = RequestSegments.new(
19
20
  max_segments: Catpm.config.max_segments_per_request,
20
21
  request_start: env['catpm.request_start'],
21
22
  stack_sample: use_sampler,
22
- call_tree: Catpm.config.instrument_call_tree
23
+ call_tree: Catpm.config.instrument_call_tree,
24
+ memory_limit: Catpm.config.max_request_memory
23
25
  )
24
26
  env['catpm.segments'] = req_segments
25
27
  Thread.current[:catpm_request_segments] = req_segments
@@ -30,11 +32,10 @@ module Catpm
30
32
  record_exception(env, e)
31
33
  raise
32
34
  ensure
33
- if Catpm.config.instrument_segments
34
- req_segments&.stop_sampler
35
- req_segments&.release!
36
- Thread.current[:catpm_request_segments] = nil
37
- end
35
+ req_segments&.stop_sampler
36
+ req_segments&.release!
37
+ Thread.current[:catpm_request_segments] = nil
38
+ Thread.current[:catpm_request_start] = nil
38
39
  end
39
40
 
40
41
  private
@@ -5,9 +5,13 @@ module Catpm
5
5
  # Pre-computed symbol pairs — each type computed once per process lifetime.
6
6
  SUMMARY_KEYS = Hash.new { |h, k| h[k] = [:"#{k}_count", :"#{k}_duration"] }
7
7
 
8
- attr_reader :segments, :summary, :request_start
8
+ # Per-segment byte estimate: Hash overhead + typical keys (type, duration, detail, offset, source, parent_index)
9
+ SEGMENT_BASE_BYTES = Event::OBJECT_OVERHEAD + (6 * Event::HASH_ENTRY_SIZE)
10
+ SEGMENT_STRING_OVERHEAD = Event::OBJECT_OVERHEAD # per-string overhead in segment values
9
11
 
10
- def initialize(max_segments:, request_start: nil, stack_sample: false, call_tree: false)
12
+ attr_reader :segments, :summary, :request_start, :estimated_bytes, :checkpoint_count
13
+
14
+ def initialize(max_segments:, request_start: nil, stack_sample: false, call_tree: false, memory_limit: nil)
11
15
  @max_segments = max_segments
12
16
  @request_start = request_start || Process.clock_gettime(Process::CLOCK_MONOTONIC)
13
17
  @segments = []
@@ -16,6 +20,10 @@ module Catpm
16
20
  @span_stack = []
17
21
  @tracked_ranges = []
18
22
  @call_tree = call_tree
23
+ @memory_limit = memory_limit
24
+ @estimated_bytes = 0
25
+ @checkpoint_callback = nil
26
+ @checkpoint_count = 0
19
27
 
20
28
  if stack_sample
21
29
  @sampler = StackSampler.new(target_thread: Thread.current, request_start: @request_start, call_tree: call_tree)
@@ -23,6 +31,10 @@ module Catpm
23
31
  end
24
32
  end
25
33
 
34
+ def on_checkpoint(&block)
35
+ @checkpoint_callback = block
36
+ end
37
+
26
38
  def add(type:, duration:, detail:, source: nil, started_at: nil)
27
39
  type_key = type.to_sym
28
40
  count_key, dur_key = SUMMARY_KEYS[type_key]
@@ -50,6 +62,9 @@ module Catpm
50
62
  @segments[min_idx] = segment
51
63
  end
52
64
  end
65
+
66
+ @estimated_bytes += estimate_segment_bytes(segment)
67
+ maybe_checkpoint
53
68
  end
54
69
 
55
70
  def push_span(type:, detail:, started_at: nil)
@@ -64,6 +79,7 @@ module Catpm
64
79
  index = @segments.size
65
80
  @segments << segment
66
81
  @span_stack.push(index)
82
+ @estimated_bytes += estimate_segment_bytes(segment)
67
83
  index
68
84
  end
69
85
 
@@ -114,6 +130,7 @@ module Catpm
114
130
  @summary = {}
115
131
  @tracked_ranges = []
116
132
  @sampler = nil
133
+ @estimated_bytes = 0
117
134
  end
118
135
 
119
136
  def overflowed?
@@ -127,5 +144,78 @@ module Catpm
127
144
  segments_capped: @overflow
128
145
  }
129
146
  end
147
+
148
+ private
149
+
150
+ def estimate_segment_bytes(segment)
151
+ bytes = SEGMENT_BASE_BYTES
152
+ bytes += segment[:detail].bytesize + SEGMENT_STRING_OVERHEAD if segment[:detail]
153
+ bytes += segment[:type].bytesize + SEGMENT_STRING_OVERHEAD if segment[:type]
154
+ bytes += segment[:source].bytesize + SEGMENT_STRING_OVERHEAD if segment[:source]
155
+ bytes
156
+ end
157
+
158
+ def maybe_checkpoint
159
+ return unless @memory_limit && @estimated_bytes > @memory_limit && @checkpoint_callback
160
+
161
+ checkpoint_data = {
162
+ segments: @segments,
163
+ summary: @summary,
164
+ overflow: @overflow,
165
+ sampler_segments: @sampler ? sampler_segments_for_checkpoint : [],
166
+ checkpoint_number: @checkpoint_count
167
+ }
168
+
169
+ @checkpoint_count += 1
170
+ rebuild_after_checkpoint
171
+ @checkpoint_callback.call(checkpoint_data)
172
+ end
173
+
174
+ def sampler_segments_for_checkpoint
175
+ if @call_tree
176
+ result = @sampler&.to_call_tree(tracked_ranges: @tracked_ranges) || []
177
+ else
178
+ result = @sampler&.to_segments(tracked_ranges: @tracked_ranges) || []
179
+ end
180
+ @sampler&.clear_samples!
181
+ result
182
+ end
183
+
184
+ # After checkpoint: keep only active spans from @span_stack, reset everything else.
185
+ def rebuild_after_checkpoint
186
+ if @span_stack.any?
187
+ # Clone active spans with corrected indices
188
+ new_segments = []
189
+ old_to_new = {}
190
+
191
+ @span_stack.each do |old_idx|
192
+ seg = @segments[old_idx]
193
+ next unless seg
194
+
195
+ new_idx = new_segments.size
196
+ old_to_new[old_idx] = new_idx
197
+ new_segments << seg.dup
198
+ end
199
+
200
+ # Fix parent_index references in cloned spans
201
+ new_segments.each do |seg|
202
+ if seg.key?(:parent_index) && old_to_new.key?(seg[:parent_index])
203
+ seg[:parent_index] = old_to_new[seg[:parent_index]]
204
+ else
205
+ seg.delete(:parent_index)
206
+ end
207
+ end
208
+
209
+ @span_stack = @span_stack.filter_map { |old_idx| old_to_new[old_idx] }
210
+ @segments = new_segments
211
+ else
212
+ @segments = []
213
+ end
214
+
215
+ @summary = Hash.new(0)
216
+ @tracked_ranges = []
217
+ @overflow = false
218
+ @estimated_bytes = 0
219
+ end
130
220
  end
131
221
  end
data/lib/catpm/trace.rb CHANGED
@@ -76,15 +76,30 @@ module Catpm
76
76
  owns_segments = false
77
77
 
78
78
  if req_segments.nil? && config.instrument_segments
79
- use_sampler = config.instrument_stack_sampler || config.instrument_call_tree
80
- req_segments = RequestSegments.new(
81
- max_segments: config.max_segments_per_request,
82
- request_start: Process.clock_gettime(Process::CLOCK_MONOTONIC),
83
- stack_sample: use_sampler,
84
- call_tree: config.instrument_call_tree
85
- )
86
- Thread.current[:catpm_request_segments] = req_segments
87
- owns_segments = true
79
+ if Collector.should_instrument?(kind, target, operation)
80
+ use_sampler = config.instrument_stack_sampler || config.instrument_call_tree
81
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ req_segments = RequestSegments.new(
83
+ max_segments: config.max_segments_per_request,
84
+ request_start: start_time,
85
+ stack_sample: use_sampler,
86
+ call_tree: config.instrument_call_tree,
87
+ memory_limit: config.max_request_memory
88
+ )
89
+ Thread.current[:catpm_request_segments] = req_segments
90
+ owns_segments = true
91
+
92
+ if config.max_request_memory
93
+ req_segments.on_checkpoint do |checkpoint_data|
94
+ Collector.process_checkpoint(
95
+ kind: kind, target: target, operation: operation,
96
+ context: context, metadata: metadata,
97
+ checkpoint_data: checkpoint_data,
98
+ request_start: start_time
99
+ )
100
+ end
101
+ end
102
+ end
88
103
  end
89
104
 
90
105
  if req_segments
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.8.3'
4
+ VERSION = '0.9.0'
5
5
  end
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.8.3
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''