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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +222 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/catpm/application.css +15 -0
- data/app/controllers/catpm/application_controller.rb +6 -0
- data/app/controllers/catpm/endpoints_controller.rb +63 -0
- data/app/controllers/catpm/errors_controller.rb +63 -0
- data/app/controllers/catpm/events_controller.rb +89 -0
- data/app/controllers/catpm/samples_controller.rb +13 -0
- data/app/controllers/catpm/status_controller.rb +79 -0
- data/app/controllers/catpm/system_controller.rb +17 -0
- data/app/helpers/catpm/application_helper.rb +264 -0
- data/app/jobs/catpm/application_job.rb +6 -0
- data/app/mailers/catpm/application_mailer.rb +8 -0
- data/app/models/catpm/application_record.rb +7 -0
- data/app/models/catpm/bucket.rb +45 -0
- data/app/models/catpm/error_record.rb +37 -0
- data/app/models/catpm/event_bucket.rb +12 -0
- data/app/models/catpm/event_sample.rb +22 -0
- data/app/models/catpm/sample.rb +26 -0
- data/app/views/catpm/endpoints/_sample_table.html.erb +36 -0
- data/app/views/catpm/endpoints/show.html.erb +124 -0
- data/app/views/catpm/errors/index.html.erb +66 -0
- data/app/views/catpm/errors/show.html.erb +107 -0
- data/app/views/catpm/events/index.html.erb +73 -0
- data/app/views/catpm/events/show.html.erb +86 -0
- data/app/views/catpm/samples/show.html.erb +113 -0
- data/app/views/catpm/shared/_page_nav.html.erb +6 -0
- data/app/views/catpm/shared/_segments_waterfall.html.erb +147 -0
- data/app/views/catpm/status/index.html.erb +124 -0
- data/app/views/catpm/system/index.html.erb +454 -0
- data/app/views/layouts/catpm/application.html.erb +381 -0
- data/config/routes.rb +19 -0
- data/db/migrate/20250601000001_create_catpm_tables.rb +104 -0
- data/lib/catpm/adapter/base.rb +85 -0
- data/lib/catpm/adapter/postgresql.rb +186 -0
- data/lib/catpm/adapter/sqlite.rb +159 -0
- data/lib/catpm/adapter.rb +28 -0
- data/lib/catpm/auto_instrument.rb +145 -0
- data/lib/catpm/buffer.rb +59 -0
- data/lib/catpm/circuit_breaker.rb +60 -0
- data/lib/catpm/collector.rb +320 -0
- data/lib/catpm/configuration.rb +103 -0
- data/lib/catpm/custom_event.rb +37 -0
- data/lib/catpm/engine.rb +39 -0
- data/lib/catpm/errors.rb +6 -0
- data/lib/catpm/event.rb +75 -0
- data/lib/catpm/fingerprint.rb +52 -0
- data/lib/catpm/flusher.rb +462 -0
- data/lib/catpm/lifecycle.rb +76 -0
- data/lib/catpm/middleware.rb +75 -0
- data/lib/catpm/middleware_probe.rb +28 -0
- data/lib/catpm/patches/httpclient.rb +44 -0
- data/lib/catpm/patches/net_http.rb +39 -0
- data/lib/catpm/request_segments.rb +101 -0
- data/lib/catpm/segment_subscribers.rb +242 -0
- data/lib/catpm/span_helpers.rb +51 -0
- data/lib/catpm/stack_sampler.rb +226 -0
- data/lib/catpm/subscribers.rb +47 -0
- data/lib/catpm/tdigest.rb +174 -0
- data/lib/catpm/trace.rb +165 -0
- data/lib/catpm/version.rb +5 -0
- data/lib/catpm.rb +66 -0
- data/lib/generators/catpm/install_generator.rb +36 -0
- data/lib/generators/catpm/templates/initializer.rb.tt +77 -0
- data/lib/tasks/catpm_seed.rake +79 -0
- data/lib/tasks/catpm_tasks.rake +6 -0
- 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,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,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
|