catpm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +222 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/catpm/application.css +15 -0
  6. data/app/controllers/catpm/application_controller.rb +6 -0
  7. data/app/controllers/catpm/endpoints_controller.rb +63 -0
  8. data/app/controllers/catpm/errors_controller.rb +63 -0
  9. data/app/controllers/catpm/events_controller.rb +89 -0
  10. data/app/controllers/catpm/samples_controller.rb +13 -0
  11. data/app/controllers/catpm/status_controller.rb +79 -0
  12. data/app/controllers/catpm/system_controller.rb +17 -0
  13. data/app/helpers/catpm/application_helper.rb +264 -0
  14. data/app/jobs/catpm/application_job.rb +6 -0
  15. data/app/mailers/catpm/application_mailer.rb +8 -0
  16. data/app/models/catpm/application_record.rb +7 -0
  17. data/app/models/catpm/bucket.rb +45 -0
  18. data/app/models/catpm/error_record.rb +37 -0
  19. data/app/models/catpm/event_bucket.rb +12 -0
  20. data/app/models/catpm/event_sample.rb +22 -0
  21. data/app/models/catpm/sample.rb +26 -0
  22. data/app/views/catpm/endpoints/_sample_table.html.erb +36 -0
  23. data/app/views/catpm/endpoints/show.html.erb +124 -0
  24. data/app/views/catpm/errors/index.html.erb +66 -0
  25. data/app/views/catpm/errors/show.html.erb +107 -0
  26. data/app/views/catpm/events/index.html.erb +73 -0
  27. data/app/views/catpm/events/show.html.erb +86 -0
  28. data/app/views/catpm/samples/show.html.erb +113 -0
  29. data/app/views/catpm/shared/_page_nav.html.erb +6 -0
  30. data/app/views/catpm/shared/_segments_waterfall.html.erb +147 -0
  31. data/app/views/catpm/status/index.html.erb +124 -0
  32. data/app/views/catpm/system/index.html.erb +454 -0
  33. data/app/views/layouts/catpm/application.html.erb +381 -0
  34. data/config/routes.rb +19 -0
  35. data/db/migrate/20250601000001_create_catpm_tables.rb +104 -0
  36. data/lib/catpm/adapter/base.rb +85 -0
  37. data/lib/catpm/adapter/postgresql.rb +186 -0
  38. data/lib/catpm/adapter/sqlite.rb +159 -0
  39. data/lib/catpm/adapter.rb +28 -0
  40. data/lib/catpm/auto_instrument.rb +145 -0
  41. data/lib/catpm/buffer.rb +59 -0
  42. data/lib/catpm/circuit_breaker.rb +60 -0
  43. data/lib/catpm/collector.rb +320 -0
  44. data/lib/catpm/configuration.rb +103 -0
  45. data/lib/catpm/custom_event.rb +37 -0
  46. data/lib/catpm/engine.rb +39 -0
  47. data/lib/catpm/errors.rb +6 -0
  48. data/lib/catpm/event.rb +75 -0
  49. data/lib/catpm/fingerprint.rb +52 -0
  50. data/lib/catpm/flusher.rb +462 -0
  51. data/lib/catpm/lifecycle.rb +76 -0
  52. data/lib/catpm/middleware.rb +75 -0
  53. data/lib/catpm/middleware_probe.rb +28 -0
  54. data/lib/catpm/patches/httpclient.rb +44 -0
  55. data/lib/catpm/patches/net_http.rb +39 -0
  56. data/lib/catpm/request_segments.rb +101 -0
  57. data/lib/catpm/segment_subscribers.rb +242 -0
  58. data/lib/catpm/span_helpers.rb +51 -0
  59. data/lib/catpm/stack_sampler.rb +226 -0
  60. data/lib/catpm/subscribers.rb +47 -0
  61. data/lib/catpm/tdigest.rb +174 -0
  62. data/lib/catpm/trace.rb +165 -0
  63. data/lib/catpm/version.rb +5 -0
  64. data/lib/catpm.rb +66 -0
  65. data/lib/generators/catpm/install_generator.rb +36 -0
  66. data/lib/generators/catpm/templates/initializer.rb.tt +77 -0
  67. data/lib/tasks/catpm_seed.rake +79 -0
  68. data/lib/tasks/catpm_tasks.rake +6 -0
  69. metadata +123 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 338d2cf2951113dc9253467fe765f8ec7a2d5c451c67b28dd26e35687d1ca5d7
4
+ data.tar.gz: 151c5f0739cd6e0e239167347c9bf175ca3583647a18fbe9fbcd5c76cc5b236c
5
+ SHA512:
6
+ metadata.gz: caa5d925cdc322ab4208ce06c85fe245e969efab1c3bf60c1fb338bfd8e829abd1646c7996cf4836fb9435853598b65b4cf880dee934cc5e43e0cdb7ccd2ccc6
7
+ data.tar.gz: bedb5e46a7d2a6c6e50cdb567e84e456e7e9d21238086dafd5309e6e45559b241f2a7ee03139afed1f31904e6244914b0fdd1c9a6ac6c592ca4e7bf004746015
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # Catpm
2
+
3
+ Lightweight, self-hosted performance monitoring for Rails. Track requests, background jobs, errors, and custom traces — all stored in your existing database. No external services, no Redis, no extra infrastructure.
4
+
5
+ Catpm is designed for small-to-medium Rails applications where a full APM (Datadog, New Relic) is overkill but flying blind is not an option.
6
+
7
+ ## Features
8
+
9
+ - **HTTP request tracking** — automatic via Rack middleware, zero configuration
10
+ - **Background job monitoring** — ActiveJob with queue wait time
11
+ - **Custom traces** — instrument any code block with `Catpm.trace` / `Catpm.span`
12
+ - **Segment waterfall** — nested breakdown of SQL, views, cache, HTTP, mailers per request
13
+ - **Error tracking** — fingerprinting, occurrence counting, context circular buffers
14
+ - **Built-in dashboard** — filterable by kind, endpoint drill-down, waterfall visualization
15
+ - **Custom events** — track business events (signups, payments, etc.) with `Catpm.event`
16
+ - **Auto-instrumentation** — service objects (`ApplicationService`, `BaseService`) traced automatically
17
+ - **Multi-database** — PostgreSQL (primary), SQLite (first-class)
18
+ - **Zero dependencies** — only Rails >= 7.1, no Redis or background queues required
19
+ - **Memory-safe** — configurable buffer limits, automatic downsampling with infinite retention
20
+ - **Resilient** — circuit breaker protects your app if the monitoring DB has issues
21
+
22
+ ## Installation
23
+
24
+ Add to your Gemfile:
25
+
26
+ ```ruby
27
+ gem 'catpm'
28
+ ```
29
+
30
+ Run the install generator:
31
+
32
+ ```bash
33
+ bin/rails generate catpm:install
34
+ bin/rails db:migrate
35
+ ```
36
+ Visit `/catpm` in your browser — done.
37
+
38
+ ## Usage
39
+
40
+ ### HTTP requests
41
+
42
+ Tracked automatically. Every controller action is recorded with duration, status, and segment breakdown (SQL queries, view rendering, cache operations, etc.).
43
+
44
+ ### Background jobs
45
+
46
+ Enable in the initializer:
47
+
48
+ ```ruby
49
+ Catpm.configure do |config|
50
+ config.instrument_jobs = true
51
+ end
52
+ ```
53
+
54
+ All ActiveJob classes will be tracked with duration and queue wait time.
55
+
56
+ ### Custom traces
57
+
58
+ Wrap any code block to track it as a standalone operation:
59
+
60
+ ```ruby
61
+ Catpm.trace('Stripe::Charge') do
62
+ Stripe::Charge.create(amount: 1000, currency: 'usd')
63
+ end
64
+ ```
65
+
66
+ Inside an existing request, `Catpm.span` adds a segment to the waterfall instead of creating a separate trace:
67
+
68
+ ```ruby
69
+ Catpm.span('geocode', type: :external) do
70
+ Geocoder.search(address)
71
+ end
72
+ ```
73
+
74
+ For cases where a block doesn't work, use the manual API:
75
+
76
+ ```ruby
77
+ span = Catpm.start_trace('long_operation')
78
+ # ... do work ...
79
+ span.finish
80
+ ```
81
+
82
+ ### Track non-controller requests
83
+
84
+ For webhooks, custom Rack endpoints, or anything outside ActionController:
85
+
86
+ ```ruby
87
+ Catpm.track_request(kind: :http, target: 'WebhookController#stripe') do
88
+ process_webhook(payload)
89
+ end
90
+ ```
91
+
92
+ ### Declarative method tracing
93
+
94
+ Include `SpanHelpers` to trace methods without changing their implementation:
95
+
96
+ ```ruby
97
+ class PaymentService
98
+ include Catpm::SpanHelpers
99
+
100
+ def process(order)
101
+ # ...
102
+ end
103
+ span_method :process
104
+
105
+ def self.bulk_charge(users)
106
+ # ...
107
+ end
108
+ span_class_method :bulk_charge
109
+ end
110
+ ```
111
+
112
+ ### Auto-instrumentation
113
+
114
+ Service objects following the `ApplicationService.call` pattern are instrumented automatically — no configuration needed. If your base class has a different name:
115
+
116
+ ```ruby
117
+ Catpm.configure do |config|
118
+ config.service_base_classes = ['MyServiceBase']
119
+ end
120
+ ```
121
+
122
+ You can also instrument specific methods explicitly:
123
+
124
+ ```ruby
125
+ Catpm.configure do |config|
126
+ config.auto_instrument_methods = ['Worker#process', 'Gateway.charge']
127
+ end
128
+ ```
129
+
130
+ ### Custom events
131
+
132
+ Track business-level events that aren't tied to performance:
133
+
134
+ ```ruby
135
+ Catpm.event('user.signed_up', plan: 'pro', source: 'landing_page')
136
+ Catpm.event('order.completed', total: 49.99)
137
+ ```
138
+
139
+ Events are aggregated into time buckets with sample payloads preserved. Enable in the initializer:
140
+
141
+ ```ruby
142
+ Catpm.configure do |config|
143
+ config.events_enabled = true
144
+ end
145
+ ```
146
+
147
+ ## Configuration
148
+
149
+ The generated initializer (`config/initializers/catpm.rb`) documents all options. Key settings:
150
+
151
+ ```ruby
152
+ Catpm.configure do |config|
153
+ # Only run in production/staging
154
+ config.enabled = Rails.env.production? || Rails.env.staging?
155
+
156
+ # Protect the dashboard
157
+ config.http_basic_auth_user = ENV['CATPM_USER']
158
+ config.http_basic_auth_password = ENV['CATPM_PASSWORD']
159
+ # Or use a custom policy:
160
+ # config.access_policy = ->(request) { request.env["warden"].user&.admin? }
161
+
162
+ # Instrumentation
163
+ config.instrument_jobs = true # ActiveJob tracking (default: false)
164
+ config.instrument_net_http = true # Outbound HTTP tracking (default: false)
165
+ config.instrument_middleware_stack = true # Per-middleware segments (default: false)
166
+
167
+ # Thresholds
168
+ config.slow_threshold = 500 # ms — global slow threshold
169
+ config.slow_threshold_per_kind = { # Override per kind
170
+ http: 500,
171
+ job: 5_000,
172
+ custom: 1_000
173
+ }
174
+
175
+ # Ignore noisy endpoints
176
+ config.ignored_targets = [
177
+ 'HealthcheckController#index',
178
+ '/assets/*',
179
+ ]
180
+
181
+ # Tuning
182
+ config.max_buffer_memory = 32.megabytes # In-memory buffer limit
183
+ config.flush_interval = 30 # Seconds between DB flushes
184
+ end
185
+ ```
186
+
187
+ ## How it works
188
+
189
+ Catpm collects events in a thread-safe in-memory buffer. A background thread flushes the buffer to your database every 30 seconds (configurable). Data is aggregated into time buckets with percentile digests (t-digest), so storage grows slowly regardless of traffic volume.
190
+
191
+ Data is kept forever with progressively coarser resolution:
192
+ - Last hour: 1-minute buckets
193
+ - 1 hour – 24 hours: 5-minute buckets
194
+ - 1 day – 1 week: 1-hour buckets
195
+ - 1 week – 3 months: 1-day buckets
196
+ - Older than 3 months: 1-week buckets
197
+
198
+ This means storage grows logarithmically — years of history take barely more space than a single week of raw data.
199
+
200
+ A circuit breaker protects your application — if the monitoring DB fails repeatedly, Catpm stops trying and recovers automatically once the DB is healthy again.
201
+
202
+ ## Database support
203
+
204
+ Catpm stores all data in its own namespaced tables (`catpm_buckets`, `catpm_samples`, `catpm_errors`, `catpm_event_buckets`, `catpm_event_samples`) using your application's existing database connection.
205
+
206
+ ## Requirements
207
+
208
+ - Ruby >= 3.1
209
+ - Rails >= 7.1
210
+
211
+ ## Contributing
212
+
213
+ 1. Fork the repo
214
+ 2. Create your feature branch (`git checkout -b my-feature`)
215
+ 3. Run tests: `bin/rails test`
216
+ 4. Run linter: `bin/rubocop`
217
+ 5. Commit and push
218
+ 6. Open a Pull Request
219
+
220
+ ## License
221
+
222
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'bundler/setup'
2
+
3
+ APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
4
+ load 'rails/tasks/engine.rake'
5
+
6
+ require 'bundler/gem_tasks'
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class ApplicationController < ActionController::Base
5
+ end
6
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class EndpointsController < ApplicationController
5
+ def show
6
+ @kind = params[:kind]
7
+ @target = params[:target]
8
+ @operation = params[:operation].presence || ''
9
+
10
+ # Time range filter
11
+ @range, period, _bucket_seconds = helpers.parse_range(params[:range], extra_valid: ['all'])
12
+
13
+ scope = Catpm::Bucket
14
+ .where(kind: @kind, target: @target, operation: @operation)
15
+
16
+ if @range != 'all'
17
+ scope = scope.where('bucket_start >= ?', period.ago)
18
+ end
19
+
20
+ @aggregate = scope.pick(
21
+ 'SUM(count)',
22
+ 'SUM(duration_sum)',
23
+ 'MAX(duration_max)',
24
+ 'MIN(duration_min)',
25
+ 'SUM(failure_count)',
26
+ 'SUM(success_count)'
27
+ )
28
+
29
+ @count, @duration_sum, @duration_max, @duration_min, @failure_count, @success_count =
30
+ @aggregate.map { |v| v || 0 }
31
+
32
+ @avg_duration = @count > 0 ? @duration_sum / @count : 0.0
33
+ @failure_rate = @count > 0 ? @failure_count.to_f / @count : 0.0
34
+
35
+ @buckets = scope.order(bucket_start: :desc)
36
+
37
+ # Merge all TDigests for combined percentiles
38
+ @tdigest = @buckets.filter_map(&:tdigest).reduce { |merged, td| merged.merge(td); merged }
39
+
40
+ # Aggregate metadata across all buckets
41
+ @metadata = {}
42
+ @buckets.each do |b|
43
+ b.parsed_metadata_sum.each do |k, v|
44
+ @metadata[k] = (@metadata[k] || 0) + (v.is_a?(Numeric) ? v : 0)
45
+ end
46
+ end
47
+
48
+ endpoint_samples = Catpm::Sample
49
+ .joins(:bucket)
50
+ .where(catpm_buckets: { kind: @kind, target: @target, operation: @operation })
51
+
52
+ if @range != 'all'
53
+ endpoint_samples = endpoint_samples.where('catpm_samples.recorded_at >= ?', period.ago)
54
+ end
55
+
56
+ @slow_samples = endpoint_samples.where(sample_type: 'slow').order(duration: :desc).limit(10)
57
+ @samples = endpoint_samples.where(sample_type: 'random').order(recorded_at: :desc).limit(10)
58
+ @error_samples = endpoint_samples.where(sample_type: 'error').order(recorded_at: :desc).limit(10)
59
+
60
+ @active_error_count = Catpm::ErrorRecord.unresolved.count
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class ErrorsController < ApplicationController
5
+ PER_PAGE = 30
6
+
7
+ def index
8
+ @tab = params[:tab] == 'resolved' ? 'resolved' : 'active'
9
+ @active_count = Catpm::ErrorRecord.unresolved.count
10
+ @resolved_count = Catpm::ErrorRecord.resolved.count
11
+ @active_error_count = @active_count
12
+
13
+ scope = if @tab == 'resolved'
14
+ Catpm::ErrorRecord.resolved
15
+ else
16
+ Catpm::ErrorRecord.unresolved
17
+ end
18
+
19
+ @available_kinds = scope.distinct.pluck(:kind).sort
20
+
21
+ if params[:kind].present? && @available_kinds.include?(params[:kind])
22
+ @kind_filter = params[:kind]
23
+ scope = scope.where(kind: @kind_filter)
24
+ end
25
+
26
+ @sort = %w[error_class occurrences_count last_occurred_at].include?(params[:sort]) ? params[:sort] : 'last_occurred_at'
27
+ @dir = params[:dir] == 'asc' ? 'asc' : 'desc'
28
+
29
+ @total_count = scope.count
30
+ @page = [params[:page].to_i, 1].max
31
+ @errors = scope.order(@sort => @dir).offset((@page - 1) * PER_PAGE).limit(PER_PAGE)
32
+ end
33
+
34
+ def show
35
+ @error = Catpm::ErrorRecord.find(params[:id])
36
+ @contexts = @error.parsed_contexts
37
+ @active_error_count = Catpm::ErrorRecord.unresolved.count
38
+ end
39
+
40
+ def resolve
41
+ error = Catpm::ErrorRecord.find(params[:id])
42
+ error.resolve!
43
+ redirect_to catpm.error_path(error), notice: 'Marked as resolved'
44
+ end
45
+
46
+ def unresolve
47
+ error = Catpm::ErrorRecord.find(params[:id])
48
+ error.unresolve!
49
+ redirect_to catpm.error_path(error), notice: 'Reopened'
50
+ end
51
+
52
+ def destroy
53
+ error = Catpm::ErrorRecord.find(params[:id])
54
+ error.destroy!
55
+ redirect_to catpm.errors_path, notice: 'Error deleted'
56
+ end
57
+
58
+ def resolve_all
59
+ Catpm::ErrorRecord.unresolved.update_all(resolved_at: Time.current)
60
+ redirect_to catpm.errors_path, notice: 'All errors resolved'
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class EventsController < ApplicationController
5
+ PER_PAGE = 25
6
+
7
+ def index
8
+ @range, period, bucket_seconds = helpers.parse_range(params[:range])
9
+
10
+ recent_buckets = Catpm::EventBucket.recent(period).to_a
11
+
12
+ # Hero metrics
13
+ @total_events = recent_buckets.sum(&:count)
14
+ period_minutes = period.to_f / 60
15
+ @events_per_min = (period_minutes > 0 ? @total_events / period_minutes : 0).round(1)
16
+
17
+ # Group by name for table
18
+ grouped = recent_buckets.group_by(&:name)
19
+ @unique_names = grouped.keys.size
20
+
21
+ events_list = grouped.map do |name, bs|
22
+ total_count = bs.sum(&:count)
23
+
24
+ # Sparkline data for this name
25
+ slots = {}
26
+ bs.each do |b|
27
+ slot_key = (b.bucket_start.to_i / bucket_seconds) * bucket_seconds
28
+ slots[slot_key] = (slots[slot_key] || 0) + b.count
29
+ end
30
+ now_slot = (Time.current.to_i / bucket_seconds) * bucket_seconds
31
+ sparkline = 60.times.map { |i| slots[now_slot - (59 - i) * bucket_seconds] || 0 }
32
+
33
+ {
34
+ name: name,
35
+ total_count: total_count,
36
+ sparkline: sparkline,
37
+ last_seen: bs.map(&:bucket_start).max
38
+ }
39
+ end
40
+
41
+ # Sort
42
+ @sort = %w[name total_count last_seen].include?(params[:sort]) ? params[:sort] : 'total_count'
43
+ @dir = params[:dir] == 'asc' ? 'asc' : 'desc'
44
+ events_list = events_list.sort_by { |e| e[@sort.to_sym] || '' }
45
+ events_list = events_list.reverse if @dir == 'desc'
46
+
47
+ @total_event_names = events_list.size
48
+
49
+ # Sparkline times for tooltips
50
+ now_slot = (Time.current.to_i / bucket_seconds) * bucket_seconds
51
+ @sparkline_times = 60.times.map { |i| Time.at(now_slot - (59 - i) * bucket_seconds).strftime('%H:%M') }
52
+
53
+ # Pagination
54
+ @page = [ params[:page].to_i, 1 ].max
55
+ @events = events_list.drop((@page - 1) * PER_PAGE).first(PER_PAGE)
56
+
57
+ @active_error_count = Catpm::ErrorRecord.unresolved.count
58
+ end
59
+
60
+ def show
61
+ @name = params[:name]
62
+ @range, period, bucket_seconds = helpers.parse_range(params[:range])
63
+
64
+ recent_buckets = Catpm::EventBucket.by_name(@name).recent(period).to_a
65
+
66
+ # Hero metrics
67
+ @total_count = recent_buckets.sum(&:count)
68
+ period_minutes = period.to_f / 60
69
+ @events_per_min = (period_minutes > 0 ? @total_count / period_minutes : 0).round(1)
70
+ @last_seen = recent_buckets.map(&:bucket_start).max
71
+
72
+ # Bar chart data
73
+ slots = {}
74
+ recent_buckets.each do |b|
75
+ slot_key = (b.bucket_start.to_i / bucket_seconds) * bucket_seconds
76
+ slots[slot_key] = (slots[slot_key] || 0) + b.count
77
+ end
78
+
79
+ now_slot = (Time.current.to_i / bucket_seconds) * bucket_seconds
80
+ @chart_data = 60.times.map { |i| slots[now_slot - (59 - i) * bucket_seconds] || 0 }
81
+ @chart_times = 60.times.map { |i| Time.at(now_slot - (59 - i) * bucket_seconds).strftime('%H:%M') }
82
+
83
+ # Recent samples
84
+ @samples = Catpm::EventSample.by_name(@name).order(recorded_at: :desc).limit(20)
85
+
86
+ @active_error_count = Catpm::ErrorRecord.unresolved.count
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class SamplesController < ApplicationController
5
+ def show
6
+ @sample = Catpm::Sample.find(params[:id])
7
+ @bucket = @sample.bucket
8
+ @context = @sample.parsed_context
9
+ @segments = @context['segments'] || @context[:segments] || []
10
+ @summary = @context['segment_summary'] || @context[:segment_summary] || {}
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class StatusController < ApplicationController
5
+ PER_PAGE = 25
6
+
7
+ def index
8
+ # Time range (parsed first — everything below uses this)
9
+ @range, period, bucket_seconds = helpers.parse_range(params[:range])
10
+
11
+ recent_buckets = Catpm::Bucket.recent(period).to_a
12
+
13
+ # Sparkline data
14
+ slots = {}
15
+ recent_buckets.each do |b|
16
+ slot_key = (b.bucket_start.to_i / bucket_seconds) * bucket_seconds
17
+ (slots[slot_key] ||= []) << b
18
+ end
19
+
20
+ now_slot = (Time.current.to_i / bucket_seconds) * bucket_seconds
21
+
22
+ @sparkline_requests = 60.times.map { |i| bs = slots[now_slot - (59 - i) * bucket_seconds]; bs ? bs.sum(&:count) : 0 }
23
+ @sparkline_errors = 60.times.map { |i| bs = slots[now_slot - (59 - i) * bucket_seconds]; bs ? bs.sum(&:failure_count) : 0 }
24
+ @sparkline_durations = 60.times.map do |i|
25
+ bs = slots[now_slot - (59 - i) * bucket_seconds]
26
+ next 0.0 unless bs
27
+ total = bs.sum(&:count)
28
+ total > 0 ? bs.sum(&:duration_sum) / total : 0.0
29
+ end
30
+ @sparkline_times = 60.times.map { |i| Time.at(now_slot - (59 - i) * bucket_seconds).strftime('%H:%M') }
31
+
32
+ recent_count = recent_buckets.sum(&:count)
33
+ recent_failures = recent_buckets.sum(&:failure_count)
34
+ period_minutes = period.to_f / 60
35
+ @recent_avg_duration = recent_count > 0 ? (recent_buckets.sum(&:duration_sum) / recent_count).round(1) : 0.0
36
+ @error_rate = recent_count > 0 ? (recent_failures.to_f / recent_count * 100).round(1) : 0.0
37
+ @requests_per_min = (recent_count / period_minutes).round(1)
38
+ @recent_count = recent_count
39
+
40
+ # Endpoints — aggregated from the SAME time range as hero metrics
41
+ grouped = recent_buckets.group_by { |b| [b.kind, b.target, b.operation] }
42
+
43
+ endpoints = grouped.map do |key, bs|
44
+ kind, target, operation = key
45
+ total_count = bs.sum(&:count)
46
+ {
47
+ kind: kind,
48
+ target: target,
49
+ operation: operation,
50
+ total_count: total_count,
51
+ avg_duration: total_count > 0 ? bs.sum(&:duration_sum) / total_count : 0.0,
52
+ max_duration: bs.map(&:duration_max).max,
53
+ total_failures: bs.sum(&:failure_count),
54
+ last_seen: bs.map(&:bucket_start).max
55
+ }
56
+ end
57
+
58
+ # Kind filter (URL-based)
59
+ @available_kinds = endpoints.map { |e| e[:kind] }.uniq.sort
60
+ @kind_filter = params[:kind] if params[:kind].present? && @available_kinds.include?(params[:kind])
61
+ endpoints = endpoints.select { |e| e[:kind] == @kind_filter } if @kind_filter
62
+
63
+ # Server-side sort
64
+ @sort = %w[target total_count avg_duration max_duration total_failures last_seen].include?(params[:sort]) ? params[:sort] : 'last_seen'
65
+ @dir = params[:dir] == 'asc' ? 'asc' : 'desc'
66
+ endpoints = endpoints.sort_by { |e| e[@sort.to_sym] || '' }
67
+ endpoints = endpoints.reverse if @dir == 'desc'
68
+
69
+ @total_endpoint_count = endpoints.size
70
+
71
+ # Pagination
72
+ @page = [params[:page].to_i, 1].max
73
+ @endpoints = endpoints.drop((@page - 1) * PER_PAGE).first(PER_PAGE)
74
+ @endpoint_count = @endpoints.size
75
+
76
+ @active_error_count = Catpm::ErrorRecord.unresolved.count
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class SystemController < ApplicationController
5
+ def index
6
+ @stats = Catpm.stats
7
+ @buffer_size = Catpm.buffer&.size || 0
8
+ @buffer_bytes = Catpm.buffer&.current_bytes || 0
9
+ @config = Catpm.config
10
+ @bucket_count = Catpm::Bucket.count
11
+ @sample_count = Catpm::Sample.count
12
+ @error_count = Catpm::ErrorRecord.count
13
+ @oldest_bucket = Catpm::Bucket.minimum(:bucket_start)
14
+ @active_error_count = Catpm::ErrorRecord.unresolved.count
15
+ end
16
+ end
17
+ end