rails_pulse 0.4.0.pre.2 → 0.4.0.pre.6
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 +4 -4
- data/CHANGELOG.md +115 -171
- data/README.md +83 -109
- data/app/controllers/concerns/session_filters_concern.rb +4 -3
- data/app/controllers/concerns/time_range_concern.rb +29 -19
- data/app/controllers/rails_pulse/application_controller.rb +67 -4
- data/app/controllers/rails_pulse/dashboard_controller.rb +20 -11
- data/app/controllers/rails_pulse/deployments_controller.rb +1 -4
- data/app/controllers/rails_pulse/routes_controller.rb +46 -0
- data/app/helpers/rails_pulse/application_helper.rb +4 -0
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +6 -3
- data/app/javascript/rails_pulse/controllers/chart_controller.js +27 -0
- data/app/models/rails_pulse/cards/base.rb +61 -25
- data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +38 -15
- data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +36 -13
- data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +3 -0
- data/app/models/rails_pulse/dashboard/health_summary.rb +3 -1
- data/app/models/rails_pulse/dashboard/needs_attention.rb +3 -1
- data/app/models/rails_pulse/dashboard/storage_status.rb +38 -68
- data/app/models/rails_pulse/deployment.rb +6 -0
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +1 -5
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +4 -4
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -4
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +7 -20
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +5 -8
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +4 -4
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -9
- data/app/models/rails_pulse/queries/cards/database_load.rb +7 -10
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +4 -6
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +5 -8
- data/app/models/rails_pulse/route.rb +7 -1
- data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -11
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +3 -1
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +4 -6
- data/app/models/rails_pulse/summary.rb +13 -18
- data/app/models/rails_pulse/time_range_preference.rb +28 -0
- data/app/models/rails_pulse/time_window.rb +61 -0
- data/app/services/rails_pulse/exception_capture_service.rb +2 -0
- data/app/services/rails_pulse/route_controller_action_backfiller.rb +0 -4
- data/app/services/rails_pulse/summary_service.rb +112 -162
- data/app/services/rails_pulse/tag_filter_service.rb +10 -1
- data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/index.html.erb +6 -3
- data/app/views/rails_pulse/routes/_archived_summary_table.html.erb +46 -0
- data/app/views/rails_pulse/routes/_archived_table_pagination.html.erb +28 -0
- data/app/views/rails_pulse/routes/show.html.erb +14 -2
- data/app/views/rails_pulse/shared/schema_outdated.html.erb +96 -0
- data/exe/rails_pulse_server +9 -0
- data/lib/generators/rails_pulse/install_generator.rb +7 -2
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +28 -2
- data/lib/generators/rails_pulse/upgrade_generator.rb +56 -5
- data/lib/rails_pulse/cleanup_service.rb +7 -7
- data/lib/rails_pulse/configuration.rb +30 -1
- data/lib/rails_pulse/engine.rb +19 -44
- data/lib/rails_pulse/installers/config_updater.rb +17 -0
- data/lib/rails_pulse/job_run_collector.rb +1 -1
- data/lib/rails_pulse/middleware/asset_server.rb +5 -4
- data/lib/rails_pulse/middleware/request_collector.rb +2 -17
- data/lib/rails_pulse/schema_check.rb +136 -0
- data/lib/rails_pulse/standalone.rb +62 -0
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +45 -3
- data/lib/rails_pulse/tasks/status_reporter.rb +217 -0
- data/lib/rails_pulse/tracker.rb +242 -25
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +5 -16
- data/lib/rails_pulse_server.ru +58 -11
- data/lib/tasks/rails_pulse.rake +48 -2
- data/lib/tasks/rails_pulse_tasks.rake +5 -0
- data/public/rails-pulse-assets/rails-pulse.js +1 -1
- metadata +11 -9
- data/app/assets/images/rails_pulse/dashboard.png +0 -0
- data/app/assets/images/rails_pulse/query-show.png +0 -0
- data/app/assets/images/rails_pulse/request-show.png +0 -0
- data/app/assets/images/rails_pulse/request.png +0 -0
- data/app/assets/images/rails_pulse/route-show.png +0 -0
- data/lib/rails_pulse/extensions/active_record.rb +0 -124
data/README.md
CHANGED
|
@@ -1,164 +1,138 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
-
<img src="app/assets/images/rails_pulse/rails-pulse-logo.png" alt="Rails Pulse" width="
|
|
2
|
+
<img src="app/assets/images/rails_pulse/rails-pulse-logo.png" alt="Rails Pulse" width="160" />
|
|
3
3
|
</div>
|
|
4
4
|
|
|
5
|
-
---
|
|
6
|
-
|
|
7
5
|
# Rails Pulse
|
|
8
6
|
|
|
9
|
-
**
|
|
7
|
+
**Performance monitoring that lives inside your Rails app.** Slow requests, N+1 queries, background jobs and exceptions, stored in your own database. No agent, no account, no data leaving your servers.
|
|
10
8
|
|
|
11
9
|

|
|
12
10
|

|
|
11
|
+

|
|
13
12
|

|
|
14
|
-

|
|
15
13
|
|
|
16
|
-
Rails Pulse is a Rails engine
|
|
14
|
+
Rails Pulse is a Rails engine. It hooks into the instrumentation Rails already emits, writes what it sees to a handful of tables, and mounts a dashboard that shows you where the time went. Install the gem, run one migration, schedule two jobs, and you have monitoring that works the same on SQLite, PostgreSQL and MySQL.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
<picture>
|
|
18
|
+
<source media="(prefers-color-scheme: dark)" srcset=".github/images/dashboard-dark.png">
|
|
19
|
+
<img src=".github/images/dashboard-light.png" alt="Rails Pulse dashboard: health bar for routes, queries, jobs, exceptions and storage; P95 response time, request rate and error rate with sparklines; response time percentiles against service level objective lines; and a ranked list of jobs and routes needing attention" width="100%">
|
|
20
|
+
</picture>
|
|
21
|
+
|
|
22
|
+
## What you get
|
|
17
23
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
- **The state of the app in one screen.** A health bar counts healthy, slow and critical routes, queries, jobs and exception groups. A ranked "needs attention" list tells you what to fix first.
|
|
25
|
+
- **Every request, broken down.** Each request stores its route, status, duration and a timeline of the SQL, view, cache, HTTP, mailer and Active Storage operations inside it.
|
|
26
|
+
- **Queries you can act on.** SQL is normalised and fingerprinted, so you see execution counts and P95 per statement shape, N+1 patterns, an EXPLAIN plan and index suggestions.
|
|
27
|
+
- **Jobs and exceptions in the same place.** Duration, queue wait and failure rate for every Active Job class on any adapter. Unhandled exceptions from requests and jobs grouped by class and location, with filtered params and backtraces.
|
|
28
|
+
- **Numbers over time.** Hourly, daily, weekly and monthly summaries with P50, P95 and P99, your service level objectives drawn as lines on the charts, and a marker for every deploy so a regression lines up with the release that caused it.
|
|
29
|
+
- **Built for production.** Tracking is queued off the request thread and dropped rather than blocked under load. If the gem is deployed before its migrations, tracking pauses and tells you what to run. Retention is enforced by age and by row count so the tables never grow without bound.
|
|
30
|
+
|
|
31
|
+
<table>
|
|
32
|
+
<tr>
|
|
33
|
+
<td width="50%" valign="top">
|
|
34
|
+
<picture>
|
|
35
|
+
<source media="(prefers-color-scheme: dark)" srcset=".github/images/request-dark.png">
|
|
36
|
+
<img src=".github/images/request-light.png" alt="Request detail: duration, status and response size, a performance breakdown by database, view and application time, and a request trace showing action, view and database operations on a timeline">
|
|
37
|
+
</picture>
|
|
38
|
+
<p align="center"><sub>A request and where its time went</sub></p>
|
|
39
|
+
</td>
|
|
40
|
+
<td width="50%" valign="top">
|
|
41
|
+
<picture>
|
|
42
|
+
<source media="(prefers-color-scheme: dark)" srcset=".github/images/query-dark.png">
|
|
43
|
+
<img src=".github/images/query-light.png" alt="Query diagnostics: query characteristics, an issue detected, an optimisation suggestion to add a composite index, and the execution plan">
|
|
44
|
+
</picture>
|
|
45
|
+
<p align="center"><sub>Diagnostics and an index suggestion for one query</sub></p>
|
|
46
|
+
</td>
|
|
23
47
|
</tr>
|
|
24
48
|
<tr>
|
|
25
|
-
<td
|
|
26
|
-
|
|
49
|
+
<td colspan="2">
|
|
50
|
+
<picture>
|
|
51
|
+
<source media="(prefers-color-scheme: dark)" srcset=".github/images/route-dark.png">
|
|
52
|
+
<img src=".github/images/route-light.png" alt="Route detail: P95 response time, request rate and error rate cards, and a two-week P95 and P99 chart with service level objective lines and vertical deploy markers">
|
|
53
|
+
</picture>
|
|
54
|
+
<p align="center"><sub>One route over two weeks, with objective lines and deploy markers</sub></p>
|
|
55
|
+
</td>
|
|
27
56
|
</tr>
|
|
28
57
|
</table>
|
|
29
58
|
|
|
30
|
-
##
|
|
31
|
-
|
|
32
|
-
Add to your Gemfile:
|
|
59
|
+
## Quick start
|
|
33
60
|
|
|
34
61
|
```ruby
|
|
35
|
-
|
|
62
|
+
# Gemfile
|
|
63
|
+
gem "rails_pulse"
|
|
36
64
|
```
|
|
37
65
|
|
|
38
|
-
Run the installer:
|
|
39
|
-
|
|
40
66
|
```bash
|
|
41
67
|
bundle install
|
|
42
68
|
rails generate rails_pulse:install
|
|
43
69
|
rails db:migrate
|
|
44
70
|
```
|
|
45
71
|
|
|
46
|
-
Mount the dashboard in `config/routes.rb`:
|
|
47
|
-
|
|
48
|
-
```ruby
|
|
49
|
-
Rails.application.routes.draw do
|
|
50
|
-
mount RailsPulse::Engine => "/rails_pulse"
|
|
51
|
-
end
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
Schedule the background jobs:
|
|
55
|
-
|
|
56
72
|
```ruby
|
|
57
|
-
|
|
58
|
-
RailsPulse::
|
|
73
|
+
# config/routes.rb
|
|
74
|
+
mount RailsPulse::Engine => "/rails_pulse"
|
|
59
75
|
```
|
|
60
76
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
Restart all processes after migrate. This release changes how routes are stored (`method` moves off the route onto each request), so mixed old/new processes are not supported.
|
|
75
|
-
|
|
76
|
-
The upgrade generator appends new settings to `config/initializers/rails_pulse.rb` without rewriting what you already set. Review with `git diff` and keep or discard hunks.
|
|
77
|
-
|
|
78
|
-
Exception tracking is **off** for existing installs. The generator inserts `config.track_exceptions = false`; set it to `true` after migrating to opt in:
|
|
79
|
-
|
|
80
|
-
```ruby
|
|
81
|
-
config.track_exceptions = true
|
|
82
|
-
config.capture_exception_params = true # params are filtered via Rails' filter_parameters
|
|
77
|
+
Schedule the summary job hourly and the cleanup job daily with whatever your queue adapter provides. With Solid Queue:
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
# config/recurring.yml
|
|
81
|
+
production:
|
|
82
|
+
rails_pulse_summary:
|
|
83
|
+
class: RailsPulse::SummaryJob
|
|
84
|
+
schedule: "5 * * * *"
|
|
85
|
+
rails_pulse_cleanup:
|
|
86
|
+
class: RailsPulse::CleanupJob
|
|
87
|
+
schedule: "0 1 * * *"
|
|
83
88
|
```
|
|
84
89
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
If you previously added `rails-pulse.js` / `rails-pulse.css` to `config.assets.precompile`, remove those entries — the gem no longer registers dashboard assets with Sprockets (that re-minify OOMs small hosts). Production deploys that use `config.asset_host` or a CDN-only CSP should run `assets:precompile` so `rails_pulse:install_assets` copies digested files into `public/assets`.
|
|
90
|
+
Open `http://localhost:3000/rails_pulse`. That's the whole setup.
|
|
88
91
|
|
|
89
|
-
|
|
92
|
+
Requirements: Ruby 3.1+, Rails 7.1+ (tested on 7.2, 8.0 and 8.1), SQLite, PostgreSQL or MySQL. Until 0.4.0 ships, pin the pre-release with `gem "rails_pulse", "~> 0.4.0.pre"`.
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
Full install guide, including a separate database and plain cron: [railspulse.com/documentation/installation](https://railspulse.com/documentation/installation)
|
|
92
95
|
|
|
93
|
-
##
|
|
96
|
+
## Going further
|
|
94
97
|
|
|
95
|
-
|
|
98
|
+
**Lock it down.** The dashboard is authenticated by default outside development and test. Point it at your own auth with a predicate; anything but `true` is a 403.
|
|
96
99
|
|
|
97
100
|
```ruby
|
|
98
101
|
RailsPulse.configure do |config|
|
|
99
|
-
config.
|
|
100
|
-
|
|
101
|
-
config.request_thresholds = {
|
|
102
|
-
slow: 700,
|
|
103
|
-
very_slow: 2000,
|
|
104
|
-
critical: 4000
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
config.track_jobs = true
|
|
108
|
-
config.capture_job_arguments = false # keep false to protect sensitive data
|
|
109
|
-
|
|
110
|
-
config.track_exceptions = true
|
|
111
|
-
config.capture_exception_params = true # params are filtered via Rails' filter_parameters
|
|
112
|
-
|
|
113
|
-
config.full_retention_period = 30.days
|
|
102
|
+
config.authorize = ->(controller) { controller.current_user&.admin? }
|
|
114
103
|
end
|
|
115
104
|
```
|
|
116
105
|
|
|
117
|
-
|
|
106
|
+
With nothing configured it falls back to HTTP Basic against `RAILS_PULSE_USERNAME` and `RAILS_PULSE_PASSWORD`. [Authentication guide](https://railspulse.com/documentation/authentication)
|
|
118
107
|
|
|
119
|
-
|
|
108
|
+
**Tune it.** Thresholds for slow, very slow and critical, service level objectives per percentile, what to ignore, what to tag, how long to keep. All in `config/initializers/rails_pulse.rb`. [Configuration reference](https://railspulse.com/documentation/advanced)
|
|
120
109
|
|
|
121
|
-
|
|
110
|
+
**Run the dashboard on its own.** `bundle exec rails_pulse_server` serves the UI from a separate process with its own health endpoint, so a slow report never competes with your app for a thread. [Deployment modes](https://railspulse.com/documentation/deployment-modes)
|
|
122
111
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
```ruby
|
|
126
|
-
RailsPulse.configure do |config|
|
|
127
|
-
config.authorize = ->(controller) { controller.current_user&.admin? }
|
|
128
|
-
end
|
|
129
|
-
```
|
|
112
|
+
**Mark your deploys.** `rails rails_pulse:record_deployment[sha]` from a release script, or `POST /rails_pulse/deployments` with a token from CI, and every chart draws a line at that moment.
|
|
130
113
|
|
|
131
|
-
|
|
114
|
+
**Keep it in its own database.** `rails generate rails_pulse:install --database=separate` puts the tables somewhere your primary never has to vacuum. [Database setup](https://railspulse.com/documentation/database)
|
|
132
115
|
|
|
133
|
-
|
|
134
|
-
RailsPulse.configure do |config|
|
|
135
|
-
config.authentication_redirect_path = "/login"
|
|
116
|
+
## Upgrading
|
|
136
117
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
end
|
|
118
|
+
```bash
|
|
119
|
+
bundle update rails_pulse
|
|
120
|
+
rails generate rails_pulse:upgrade
|
|
121
|
+
rails db:migrate # separate Pulse database: rails db:migrate:rails_pulse
|
|
122
|
+
rails rails_pulse:status # exits 1 while anything still needs action
|
|
143
123
|
```
|
|
144
124
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
Authentication guide: [railspulse.com/documentation/authentication](https://railspulse.com/documentation/authentication)
|
|
148
|
-
|
|
149
|
-
## Features
|
|
150
|
-
|
|
151
|
-
- **Request monitoring** — every request is timed and stored with its route, status, SQL count, and duration. Slow requests are flagged automatically based on thresholds you control.
|
|
152
|
-
- **Query analysis** — captures the queries behind each request, detects N+1 patterns, and tracks normalized SQL across requests so you can see which queries are hurting you in production, not just in development.
|
|
153
|
-
- **Job tracking** — monitors background job duration, queue wait time, and failure rates. Works with any Active Job adapter.
|
|
154
|
-
- **Exception tracking** — captures unhandled exceptions from web requests and background jobs, groups them by class and location, and shows full backtraces with filtered request params. See recurring errors in production without a separate error monitoring service.
|
|
155
|
-
- **System health bar** — at-a-glance dashboard summary showing healthy, slow, and critical counts across your routes, queries, jobs, and storage. Lets you see the overall state of your app before drilling into specifics.
|
|
156
|
-
- **No data leaves your app** — everything is stored in your own database. No third-party cloud, no SaaS subscription, no outbound connections.
|
|
157
|
-
- **Low overhead** — tracking is async and uses a thread-local flag to skip recording Rails Pulse's own internal requests.
|
|
125
|
+
Upgrading from 0.3.x to 0.4? **Back up first**, run `rails rails_pulse:migrate_routes` after migrating, and restart every process together. The details are in the [changelog](CHANGELOG.md).
|
|
158
126
|
|
|
159
127
|
## Contributing
|
|
160
128
|
|
|
161
|
-
Bug reports and pull requests are welcome on [GitHub](https://github.com/railspulse/rails_pulse).
|
|
129
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/railspulse/rails_pulse). `docs/` explains how the pieces fit and why they are built the way they are.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
git config core.hooksPath .githooks # once, after cloning
|
|
133
|
+
DB=sqlite3 rake test # or DB=postgresql / DB=mysql2
|
|
134
|
+
bundle exec rubocop
|
|
135
|
+
```
|
|
162
136
|
|
|
163
137
|
## License
|
|
164
138
|
|
|
@@ -24,10 +24,11 @@ module SessionFiltersConcern
|
|
|
24
24
|
session_global_filters["disabled_tags"] || []
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
# Returns the time range preference from session
|
|
28
|
-
#
|
|
27
|
+
# Returns the time range preference from session, normalized so every
|
|
28
|
+
# session serializer yields the same shape: a String preset name, a
|
|
29
|
+
# string-keyed Hash for a custom range, or nil for anything unrecognized.
|
|
29
30
|
def session_time_range_preference
|
|
30
|
-
session[:time_range_preference]
|
|
31
|
+
RailsPulse::TimeRangePreference.normalize(session[:time_range_preference])
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
# Returns whether deployment markers should be shown by default
|
|
@@ -41,6 +41,11 @@ module TimeRangeConcern
|
|
|
41
41
|
end_time = Time.zone.now
|
|
42
42
|
selected_time_range = default_key
|
|
43
43
|
|
|
44
|
+
# Normalized up front so a symbol-keyed hash from a Marshal-backed session
|
|
45
|
+
# store reads the same as the string-keyed one the cookie store produces;
|
|
46
|
+
# anything unrecognized becomes nil and falls through to later priorities.
|
|
47
|
+
session_preference = RailsPulse::TimeRangePreference.normalize(session[:time_range_preference])
|
|
48
|
+
|
|
44
49
|
# Priority 1: Page-specific preset from dropdown (check this first!)
|
|
45
50
|
if ransack_params[:period_start_range].present? && ransack_params[:period_start_range].to_sym != :custom
|
|
46
51
|
# Predefined time range from dropdown
|
|
@@ -76,25 +81,22 @@ module TimeRangeConcern
|
|
|
76
81
|
selected_time_range = :custom
|
|
77
82
|
end
|
|
78
83
|
# Priority 4: Time range selector (from session)
|
|
79
|
-
elsif
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
selected_time_range
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
else start_time
|
|
96
|
-
end
|
|
97
|
-
end
|
|
84
|
+
elsif RailsPulse::TimeRangePreference.custom?(session_preference)
|
|
85
|
+
# Custom range from time range selector
|
|
86
|
+
start_time = parse_time_param(session_preference["start_time"]) || start_time
|
|
87
|
+
end_time = parse_time_param(session_preference["end_time"]) || end_time
|
|
88
|
+
selected_time_range = :custom
|
|
89
|
+
elsif session_preference.is_a?(String) && session_preference.present?
|
|
90
|
+
# Preset from time range selector
|
|
91
|
+
selected_time_range = session_preference.to_sym
|
|
92
|
+
start_time =
|
|
93
|
+
case selected_time_range
|
|
94
|
+
when :last_24_hours then 1.day.ago
|
|
95
|
+
when :last_7_days then 1.week.ago
|
|
96
|
+
when :last_14_days then 2.weeks.ago
|
|
97
|
+
when :last_30_days then 1.month.ago
|
|
98
|
+
else start_time
|
|
99
|
+
end
|
|
98
100
|
# Priority 5: Global filters (from session)
|
|
99
101
|
elsif session_global_filters["start_time"].present? || session_global_filters["end_time"].present?
|
|
100
102
|
start_time = parse_time_param(session_global_filters["start_time"]) || start_time
|
|
@@ -105,6 +107,14 @@ module TimeRangeConcern
|
|
|
105
107
|
|
|
106
108
|
time_diff = (end_time.to_i - start_time.to_i) / 3600.0
|
|
107
109
|
|
|
110
|
+
# in_time_zone before rounding: a parsed custom-range string carries the
|
|
111
|
+
# server OS's local offset, not Time.zone, and beginning_of_day/_hour
|
|
112
|
+
# round in whatever offset the receiver has. Summary data is always
|
|
113
|
+
# bucketed by Time.zone, so without this the boundary can miss every
|
|
114
|
+
# summary row and render charts empty. No-op for already-Time.zone values.
|
|
115
|
+
start_time = start_time.in_time_zone
|
|
116
|
+
end_time = end_time.in_time_zone
|
|
117
|
+
|
|
108
118
|
if time_diff <= 25
|
|
109
119
|
start_time = start_time.beginning_of_hour
|
|
110
120
|
end_time = end_time.end_of_hour
|
|
@@ -15,6 +15,7 @@ module RailsPulse
|
|
|
15
15
|
protect_from_forgery with: :exception
|
|
16
16
|
|
|
17
17
|
before_action :authenticate_rails_pulse_user!
|
|
18
|
+
before_action :require_current_schema!
|
|
18
19
|
before_action :set_onboarding_state
|
|
19
20
|
before_action :load_deployment_markers
|
|
20
21
|
|
|
@@ -85,10 +86,12 @@ module RailsPulse
|
|
|
85
86
|
session[:time_range_preference] = preset if TIME_RANGE_PRESETS.include?(preset)
|
|
86
87
|
elsif start_time && end_time
|
|
87
88
|
# Store custom range
|
|
89
|
+
# String keys: a Marshal-backed session store would otherwise hand the
|
|
90
|
+
# symbol keys back verbatim, and the readers expect the JSON shape.
|
|
88
91
|
session[:time_range_preference] = {
|
|
89
|
-
type
|
|
90
|
-
start_time
|
|
91
|
-
end_time
|
|
92
|
+
"type" => "custom",
|
|
93
|
+
"start_time" => start_time,
|
|
94
|
+
"end_time" => end_time
|
|
92
95
|
}
|
|
93
96
|
end
|
|
94
97
|
|
|
@@ -132,6 +135,8 @@ module RailsPulse
|
|
|
132
135
|
config = RailsPulse.configuration
|
|
133
136
|
return unless config.authentication_enabled
|
|
134
137
|
|
|
138
|
+
return authenticate_standalone!(config) if RailsPulse.standalone?
|
|
139
|
+
|
|
135
140
|
if config.authentication_method.nil? && config.authorize.nil?
|
|
136
141
|
return fallback_http_basic_auth
|
|
137
142
|
end
|
|
@@ -144,7 +149,41 @@ module RailsPulse
|
|
|
144
149
|
run_authorize_predicate(config.authorize) if config.authorize
|
|
145
150
|
rescue StandardError => e
|
|
146
151
|
logger.warn "RailsPulse authentication failed: #{e.message}"
|
|
147
|
-
|
|
152
|
+
# The redirect target is a host app page; from the standalone server
|
|
153
|
+
# it would point at a route this process does not serve.
|
|
154
|
+
if RailsPulse.standalone?
|
|
155
|
+
render plain: "Forbidden", status: :forbidden
|
|
156
|
+
else
|
|
157
|
+
redirect_to config.authentication_redirect_path
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The standalone server has the host's models but not its middleware:
|
|
162
|
+
# no host session store, Warden, or Devise helpers, and a different
|
|
163
|
+
# hostname so no shared cookie. A session-based `authentication_method`
|
|
164
|
+
# or `authorize` would raise (rescued above into a useless redirect) or,
|
|
165
|
+
# worse, be written so that it silently allows. Ignore both here; use
|
|
166
|
+
# `standalone_authentication_method` when set, else HTTP Basic.
|
|
167
|
+
def authenticate_standalone!(config)
|
|
168
|
+
hook = config.standalone_authentication_method
|
|
169
|
+
if hook.nil?
|
|
170
|
+
note_ignored_host_authentication(config)
|
|
171
|
+
return fallback_http_basic_auth
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
run_authentication_method(hook)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Logged once per process, not once per controller: the flag lives on
|
|
178
|
+
# RailsPulse::Standalone, so RequestsController and QueriesController do
|
|
179
|
+
# not each repeat it on their first request.
|
|
180
|
+
def note_ignored_host_authentication(config)
|
|
181
|
+
return if config.authentication_method.nil? && config.authorize.nil?
|
|
182
|
+
return unless RailsPulse.note_host_authentication_ignored!
|
|
183
|
+
|
|
184
|
+
logger.info "RailsPulse: standalone dashboard ignores config.authentication_method / config.authorize " \
|
|
185
|
+
"(host session helpers are not available here); using HTTP Basic auth. " \
|
|
186
|
+
"Set config.standalone_authentication_method to customise."
|
|
148
187
|
end
|
|
149
188
|
|
|
150
189
|
def run_authentication_method(hook)
|
|
@@ -196,6 +235,30 @@ module RailsPulse
|
|
|
196
235
|
end
|
|
197
236
|
end
|
|
198
237
|
|
|
238
|
+
# New gem code against un-migrated tables would otherwise 500 on every
|
|
239
|
+
# page that touches a missing column. Runs after authentication so the
|
|
240
|
+
# schema report is not exposed to anonymous callers.
|
|
241
|
+
def require_current_schema!
|
|
242
|
+
return if RailsPulse::SchemaCheck.current?
|
|
243
|
+
|
|
244
|
+
RailsPulse::SchemaCheck.warn_once!
|
|
245
|
+
missing = RailsPulse::SchemaCheck.missing
|
|
246
|
+
|
|
247
|
+
respond_to do |format|
|
|
248
|
+
format.html do
|
|
249
|
+
render template: "rails_pulse/shared/schema_outdated", layout: false,
|
|
250
|
+
status: :service_unavailable, locals: { missing: missing }
|
|
251
|
+
end
|
|
252
|
+
format.any do
|
|
253
|
+
render json: {
|
|
254
|
+
error: "Rails Pulse #{RailsPulse::VERSION} requires a schema upgrade",
|
|
255
|
+
missing: missing,
|
|
256
|
+
instructions: RailsPulse::SchemaCheck::UPGRADE_INSTRUCTIONS
|
|
257
|
+
}, status: :service_unavailable
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
199
262
|
def load_deployment_markers
|
|
200
263
|
@deployment_markers = []
|
|
201
264
|
@show_deployment_markers = session_show_deployment_markers
|
|
@@ -8,8 +8,11 @@ module RailsPulse
|
|
|
8
8
|
@start_time, @end_time, @selected_time_range, @time_diff = setup_time_range
|
|
9
9
|
populate_deployment_markers
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
|
|
11
|
+
# Rounded rather than truncated, so a range a few seconds short of N
|
|
12
|
+
# whole days still counts as N. Cards/charts also get @start_time/
|
|
13
|
+
# @end_time directly so they bucket the exact range, not "the last
|
|
14
|
+
# @period days ending now".
|
|
15
|
+
@period = RailsPulse::TimeWindow.new(@start_time, @end_time).days
|
|
13
16
|
|
|
14
17
|
# Determine period type based on time range
|
|
15
18
|
# If 24 hours or less, use hourly summaries, otherwise use daily
|
|
@@ -19,22 +22,28 @@ module RailsPulse
|
|
|
19
22
|
disabled_tags = session_disabled_tags
|
|
20
23
|
show_non_tagged = session[:show_non_tagged] != false
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
card_and_chart_options = {
|
|
26
|
+
disabled_tags: disabled_tags, show_non_tagged: show_non_tagged,
|
|
27
|
+
period: @period, period_type: @period_type,
|
|
28
|
+
start_time: @start_time, end_time: @end_time
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil, **card_and_chart_options).to_metric_card
|
|
32
|
+
@request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil, **card_and_chart_options).to_metric_card
|
|
33
|
+
@error_rates_metric_card = RailsPulse::Routes::Cards::ErrorRates.new(route: nil, **card_and_chart_options).to_metric_card
|
|
34
|
+
@job_failure_rate_metric_card = RailsPulse::Jobs::Cards::FailureRate.new(**card_and_chart_options).to_metric_card if RailsPulse.configuration.track_jobs
|
|
26
35
|
|
|
27
36
|
# Generate chart data for inline rendering
|
|
28
|
-
@response_time_percentiles_chart_data = RailsPulse::Dashboard::Charts::ResponseTimePercentiles.new(
|
|
29
|
-
@throughput_and_errors_chart_data = RailsPulse::Dashboard::Charts::ThroughputAndErrors.new(
|
|
37
|
+
@response_time_percentiles_chart_data = RailsPulse::Dashboard::Charts::ResponseTimePercentiles.new(**card_and_chart_options).to_chart_data
|
|
38
|
+
@throughput_and_errors_chart_data = RailsPulse::Dashboard::Charts::ThroughputAndErrors.new(**card_and_chart_options).to_chart_data
|
|
30
39
|
|
|
31
40
|
# Needs Attention panel
|
|
32
|
-
@needs_attention = RailsPulse::Dashboard::NeedsAttention.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period).to_attention_data
|
|
41
|
+
@needs_attention = RailsPulse::Dashboard::NeedsAttention.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, start_time: @start_time, end_time: @end_time).to_attention_data
|
|
33
42
|
|
|
34
43
|
# System Health bar
|
|
35
|
-
@health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period).to_health_data
|
|
44
|
+
@health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, start_time: @start_time, end_time: @end_time).to_health_data
|
|
36
45
|
|
|
37
|
-
@storage_status = RailsPulse::Dashboard::StorageStatus.new
|
|
46
|
+
@storage_status = RailsPulse::Dashboard::StorageStatus.new(cached_sizes: true)
|
|
38
47
|
|
|
39
48
|
# Deployments panel — scoped to the same window as the chart markers so
|
|
40
49
|
# the panel and the markers drawn on the charts always agree.
|
|
@@ -36,10 +36,7 @@ module RailsPulse
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def finish
|
|
39
|
-
deployment = RailsPulse::Deployment
|
|
40
|
-
.where(revision: finish_params[:revision])
|
|
41
|
-
.order(started_at: :desc)
|
|
42
|
-
.first
|
|
39
|
+
deployment = RailsPulse::Deployment.latest_for_revision(finish_params[:revision])
|
|
43
40
|
|
|
44
41
|
return render json: { status: "error", error: "Deployment not found" }, status: :not_found unless deployment
|
|
45
42
|
|
|
@@ -14,6 +14,7 @@ module RailsPulse
|
|
|
14
14
|
def show
|
|
15
15
|
setup_metric_cards
|
|
16
16
|
setup_chart_and_table_data
|
|
17
|
+
setup_archived_summary_data
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
private
|
|
@@ -126,6 +127,51 @@ module RailsPulse
|
|
|
126
127
|
@route = Route.find(params[:id])
|
|
127
128
|
end
|
|
128
129
|
|
|
130
|
+
ARCHIVED_SUMMARY_PAGE_LIMIT = 20
|
|
131
|
+
|
|
132
|
+
# Raw Request rows get purged by CleanupService after full_retention_period.
|
|
133
|
+
# For any part of the selected window older than that cutoff, fall back to
|
|
134
|
+
# pre-aggregated Summary rows (which CleanupService never deletes for
|
|
135
|
+
# day/week/month periods) so the table isn't just blank for old ranges.
|
|
136
|
+
def setup_archived_summary_data
|
|
137
|
+
cutoff = retention_cutoff
|
|
138
|
+
window_start = @page_timings&.table_start_time
|
|
139
|
+
|
|
140
|
+
scope = if cutoff && window_start && Time.at(window_start) < cutoff
|
|
141
|
+
scope = Summary.for_routes
|
|
142
|
+
.where(summarizable_id: @route.id)
|
|
143
|
+
.where(period_type: period_type)
|
|
144
|
+
.where("period_start < ?", cutoff)
|
|
145
|
+
|
|
146
|
+
if @page_timings&.table_end_time
|
|
147
|
+
scope = scope.where("period_start < ?", Time.at(@page_timings.table_end_time))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
scope.order(period_start: :desc)
|
|
151
|
+
else
|
|
152
|
+
Summary.none
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
@archived_pagination, @archived_summary_data =
|
|
156
|
+
paginate_archived(scope, limit: ARCHIVED_SUMMARY_PAGE_LIMIT)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Mirrors PaginationConcern#paginate but keys off its own `archived_page`
|
|
160
|
+
# param so the archived table's pagination doesn't fight over the same
|
|
161
|
+
# `page`/`limit` params as the live requests table above it on the page.
|
|
162
|
+
def paginate_archived(collection, limit:)
|
|
163
|
+
page = [ params[:archived_page].to_i, 1 ].max
|
|
164
|
+
paginator = RailsPulse::Paginator.new(count: collection.count(:all), page: page, limit: limit)
|
|
165
|
+
records = collection.offset((paginator.page - 1) * limit).limit(limit)
|
|
166
|
+
[ paginator, records ]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def retention_cutoff
|
|
170
|
+
config = RailsPulse.configuration rescue nil
|
|
171
|
+
period = config&.full_retention_period
|
|
172
|
+
period ? period.ago : nil
|
|
173
|
+
end
|
|
174
|
+
|
|
129
175
|
def ordering_by_computed_column?
|
|
130
176
|
# Check if we're ordering by status_indicator (computed column)
|
|
131
177
|
@ransack_query.sorts.any? { |sort| sort.name == "status_indicator" }
|
|
@@ -31,5 +31,9 @@ module RailsPulse
|
|
|
31
31
|
def page_url(page_number)
|
|
32
32
|
url_for(request.query_parameters.merge(page: page_number))
|
|
33
33
|
end
|
|
34
|
+
|
|
35
|
+
def archived_page_url(page_number)
|
|
36
|
+
url_for(request.query_parameters.merge(archived_page: page_number, anchor: "archived-data"))
|
|
37
|
+
end
|
|
34
38
|
end
|
|
35
39
|
end
|
|
@@ -30,13 +30,16 @@ module RailsPulse
|
|
|
30
30
|
# Only keep segments after the mount point
|
|
31
31
|
path_segments = path_segments[(mount_end_index + 1)..-1]
|
|
32
32
|
|
|
33
|
-
# Build the engine root path directly from mount segments (avoids relying on named route helper)
|
|
34
|
-
|
|
33
|
+
# Build the engine root path directly from mount segments (avoids relying on named route helper).
|
|
34
|
+
# Served at "/" (standalone server, or a host that mounts the engine at root) the prefix
|
|
35
|
+
# is empty, not "/": segments are appended as "/#{segment}", and "/" + "/queries" would be
|
|
36
|
+
# "//queries" — a protocol-relative URL that browsers resolve to a host called "queries".
|
|
37
|
+
engine_root = mount_segments.empty? ? "" : "/" + mount_segments.join("/")
|
|
35
38
|
|
|
36
39
|
# Start with the Home link
|
|
37
40
|
crumbs = [ {
|
|
38
41
|
title: "Home",
|
|
39
|
-
path: engine_root,
|
|
42
|
+
path: engine_root.empty? ? "/" : engine_root,
|
|
40
43
|
current: path_segments.empty?
|
|
41
44
|
} ]
|
|
42
45
|
|
|
@@ -160,6 +160,12 @@ export default class extends Controller {
|
|
|
160
160
|
config.xAxis = config.xAxis || {}
|
|
161
161
|
if (isTimePairs) {
|
|
162
162
|
config.xAxis.type = 'time'
|
|
163
|
+
// Without this, ECharts spaces ticks by pixel width, not by the data's
|
|
164
|
+
// actual bucket size — a multi-day "time" axis can end up with more
|
|
165
|
+
// ticks than days, and since the label formatter only shows the date,
|
|
166
|
+
// adjacent ticks on the same day render as duplicate-looking labels.
|
|
167
|
+
const bucketMs = this._minTimestampGapMs(data.series)
|
|
168
|
+
if (bucketMs) config.xAxis.minInterval = bucketMs
|
|
163
169
|
} else {
|
|
164
170
|
config.xAxis.type = 'category'
|
|
165
171
|
config.xAxis.data = data.labels
|
|
@@ -240,6 +246,27 @@ export default class extends Controller {
|
|
|
240
246
|
return Array.isArray(firstPoint) || Array.isArray(firstPoint?.value)
|
|
241
247
|
}
|
|
242
248
|
|
|
249
|
+
// Smallest gap (ms) between any two distinct timestamps across all series,
|
|
250
|
+
// used as xAxis.minInterval so auto-placed ticks never fall closer together
|
|
251
|
+
// than the data actually does.
|
|
252
|
+
_minTimestampGapMs(series) {
|
|
253
|
+
const timestamps = new Set()
|
|
254
|
+
series.forEach(s => {
|
|
255
|
+
(s.data || []).forEach(point => {
|
|
256
|
+
const pair = Array.isArray(point) ? point : (Array.isArray(point?.value) ? point.value : null)
|
|
257
|
+
if (pair && typeof pair[0] === 'number') timestamps.add(pair[0])
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const sorted = Array.from(timestamps).sort((a, b) => a - b)
|
|
262
|
+
let minGap = null
|
|
263
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
264
|
+
const gap = sorted[i] - sorted[i - 1]
|
|
265
|
+
if (gap > 0 && (minGap === null || gap < minGap)) minGap = gap
|
|
266
|
+
}
|
|
267
|
+
return minGap
|
|
268
|
+
}
|
|
269
|
+
|
|
243
270
|
deploymentMarkerSeriesId = 'rails-pulse-deployment-markers'
|
|
244
271
|
|
|
245
272
|
_buildDeploymentMarkerSeries(markers, visible = true) {
|