rails_pulse 0.4.0.pre.4 → 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 +38 -0
- data/README.md +83 -111
- 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 +9 -5
- data/app/controllers/rails_pulse/dashboard_controller.rb +20 -11
- data/app/controllers/rails_pulse/routes_controller.rb +46 -0
- data/app/helpers/rails_pulse/application_helper.rb +4 -0
- 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/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/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/lib/generators/rails_pulse/install_generator.rb +5 -2
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +7 -2
- data/lib/rails_pulse/cleanup_service.rb +7 -7
- data/lib/rails_pulse/configuration.rb +9 -1
- data/lib/rails_pulse/engine.rb +14 -42
- data/lib/rails_pulse/middleware/request_collector.rb +2 -17
- data/lib/rails_pulse/standalone.rb +12 -0
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +45 -3
- data/lib/rails_pulse/tracker.rb +241 -25
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +3 -16
- data/lib/rails_pulse_server.ru +37 -23
- data/public/rails-pulse-assets/rails-pulse.js +1 -1
- metadata +7 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b24fa9677dc2c9b2dd4042acadb464247dd64d2136836e2197a1afe5b90f3f21
|
|
4
|
+
data.tar.gz: 2c45d0508fb24753b4d65b87b57afb19382b82277747a532e5182d6a6e2a8bdd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 83270431a2752bb64ada4380744b5fc087696c36109bfcdd157412eb9689124e0dc8d1945371978455a8a2baefceced920ff27f82c3d311c4cd297b73f8ae52f
|
|
7
|
+
data.tar.gz: d3c00d842cebe64770e3039e0c50b029bdde9169590857ec17526a8a05bad7abf71eecebe0fe614ebd1f726a5567d8c4070f08831ccc5847b28c0018184c9ced
|
data/CHANGELOG.md
CHANGED
|
@@ -7,8 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0.pre.6] - 2026-09-20
|
|
11
|
+
|
|
12
|
+
## [0.4.0.pre.5] - 2026-09-20
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **Services now autoload through Zeitwerk.** `app/services` was hidden from the Rails autoloader and wired up by hand, so services never reloaded in development and every new one had to be registered in the engine. They now load and reload like the rest of `app/`, with the few acronym-prone file names pinned so a host's `inflect.acronym` declarations cannot change the constants the gem expects.
|
|
17
|
+
|
|
18
|
+
### Removed
|
|
19
|
+
|
|
20
|
+
- `RailsPulse.warm_metric_cache!` (a no-op) and `RailsPulse.clear_metric_cache!` (used `delete_matched`, which some cache stores do not support). Neither was referenced by the dashboard.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Dashboard pages issue fewer queries.** Tag filtering now runs as subqueries inside each card and chart query instead of plucking every route, query and job id first; the route-backfill check is a single indexed query once every route has an action; and the dashboard's storage headline reuses table sizes for five minutes instead of measuring every table on every load.
|
|
25
|
+
- **Lower per-query capture overhead.** The SQL, template and cache subscribers materialised the whole call stack on every event to find the calling app file; they now walk it lazily and stop at the first app frame, which cuts the per-event cost by half to two thirds on a typical controller stack.
|
|
26
|
+
- **Summary aggregation writes each period in a handful of statements.** Every route, query and job summary used to be found and saved individually, so an hour with a few hundred routes and queries cost over 700 SQL statements; rows are now upserted in bulk against the summaries unique index.
|
|
27
|
+
- **Background tracking no longer spawns a thread per request.** A burst of traffic used to fan out into one writer thread per request, each holding one of the app's database connections, so the app's own request threads could wait seconds for a connection. One writer thread per process now drains a bounded queue on a single connection; when the queue is full the newest request is dropped and counted rather than slowing the app. Adds `config.async_queue_size` (default 1000), `RailsPulse::Tracker.stats`, and an exit hook that drains the queue on restart. SQL normalisation and N+1 detection have moved off the request thread as well.
|
|
28
|
+
- **Metric card sparklines are now correct in time zones east of UTC.** Daily buckets were computed from the stored UTC timestamp, so in zones such as London, Melbourne or Tokyo every card showed each day's value a day early with the latest day at zero, and half-hour zones got empty hourly sparklines. Grouping now follows `config.time_zone`, and the gem no longer adds `group_by_date` / `group_by_hour` to the host's `ActiveRecord::Relation`.
|
|
29
|
+
- **Summary aggregation now runs its transaction on the Rails Pulse connection.** On separate-database installs it was opened on the host's primary database, so a failure part-way through a period could leave partial summaries behind.
|
|
30
|
+
- **The dashboard's own HTTP, mailer, job and storage events are no longer recorded.** These subscribers skipped the recursion guard that SQL and template events already honoured.
|
|
31
|
+
- **Storage page reports real table sizes again.** A leftover screenshot fixture replaced every table's live count, size, and age with hard-coded sample numbers in any environment other than `test`.
|
|
32
|
+
- **Standalone dashboard settings forms no longer fail CSRF verification.** The standalone server (`rails_pulse_server`) used the plain `rack-session` gem's `Rack::Session::Cookie`, which knows nothing about Rails' CSRF handling: a token generated for a form is only written into the session by `commit_csrf_token`, a hook that only Rails' own `ActionDispatch::Session::CookieStore` calls. Every generated token was silently discarded, so every submission failed verification. Switched to `ActionDispatch::Cookies` + `ActionDispatch::Session::CookieStore` (seeding the `action_dispatch.*` env Rails normally sets up before reaching the engine).
|
|
33
|
+
- **Standalone dashboard no longer 404s on the time range and global filters pickers.** Those forms submit `POST` with a hidden `_method=patch` field — the standard verb-override trick — which the mounted engine translates via the host app's default middleware stack. The standalone server (`rails_pulse_server`) builds its own minimal Rack stack and never added `Rack::MethodOverride`, so the request reached routing as a plain `POST` and 404'd against the `PATCH`-only route.
|
|
34
|
+
- **Cleanup no longer risks statement timeouts on large tables.** `CleanupService`'s orphan checks for queries, routes, jobs, and exception groups used a `NOT IN` subquery, which some databases (notably PostgreSQL at scale) execute by materializing and rescanning the full subquery result instead of using an index. Switched to a correlated `NOT EXISTS`, which lets the planner use an index per row. A stalled cleanup stage previously blocked all later stages, including hourly summary pruning. (#253)
|
|
35
|
+
- **Idle periods no longer trigger false "summary job not running" warnings.** `SummaryJob` now records a zero-count overall summary for hours/days with no requests, so the dashboard banner, `rails_pulse:status`, the storage-pressure card, and count-based cleanup no longer mistake "no traffic" for "job stopped running." (#250)
|
|
36
|
+
- **Custom date range charts on the Routes, Queries, and Jobs pages no longer render blank when the server's OS timezone differs from `config.time_zone`.** A custom range was rounded to a day/hour boundary in whatever offset the parsed time happened to carry rather than `Time.zone`, so the boundary could land hours away from where summary data is actually bucketed — every chart series came back all-nil while the metric cards and table (queried differently) kept showing data, which was the visible symptom.
|
|
37
|
+
- **Dashboard charts and metric cards now honor a custom date range instead of always showing the trailing days.** The dashboard collapsed the selected range to a day count and had every chart, card, and sparkline re-derive "the last N days ending now" from it, so a range in the past rendered recent data under the selected range's labels, and the day count itself was truncated by integer division. Charts and cards now bucket the exact selected range.
|
|
38
|
+
- **Custom date range no longer 500s the dashboard on Marshal-backed session stores.** The custom range was written with symbol keys but read back expecting string keys, so stores that preserve symbols (e.g. `activerecord-session_store`) broke every page until the session was cleared; both shapes are now accepted and unreadable preferences fall back to the default range. (#252)
|
|
39
|
+
- **`config.logger` is now honored.** `RailsPulse.logger` previously ignored a custom logger set in the initializer and always wrote to the tagged `Rails.logger`; the configured logger now receives all Rails Pulse log output. (#244)
|
|
40
|
+
- **Cached SQL reads no longer captured as operations.** Query-cache hits were going through the same stack-walk and operation-allocation path as real queries, adding measurable overhead on requests with heavy cache reuse. `config.ignored_queries` now also works — it was previously validated but never consulted when collecting SQL operations.
|
|
41
|
+
- **Dashboard status bar badges are now all clickable.** Routes, Queries, and Jobs badges link to their respective pages, matching Exceptions and Storage.
|
|
42
|
+
- **Standalone auth notice logged once per process.** The "standalone dashboard ignores config.authentication_method / config.authorize" notice kept its once-only flag on each controller class, so it repeated for every engine controller a visitor reached. The flag now lives on `RailsPulse::Standalone` and the notice is logged once per process.
|
|
43
|
+
|
|
10
44
|
## [0.4.0.pre.4] - 2026-09-07
|
|
11
45
|
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- Dependency and packaging fixes only; no user-facing changes beyond 0.4.0.pre.3.
|
|
49
|
+
|
|
12
50
|
## [0.4.0.pre.3] - 2026-09-06
|
|
13
51
|
|
|
14
52
|
### Added
|
data/README.md
CHANGED
|
@@ -1,166 +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
72
|
```ruby
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
end
|
|
73
|
+
# config/routes.rb
|
|
74
|
+
mount RailsPulse::Engine => "/rails_pulse"
|
|
52
75
|
```
|
|
53
76
|
|
|
54
|
-
Schedule the
|
|
55
|
-
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
|
|
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 * * *"
|
|
59
88
|
```
|
|
60
89
|
|
|
61
|
-
|
|
90
|
+
Open `http://localhost:3000/rails_pulse`. That's the whole setup.
|
|
62
91
|
|
|
63
|
-
|
|
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"`.
|
|
64
93
|
|
|
65
|
-
|
|
94
|
+
Full install guide, including a separate database and plain cron: [railspulse.com/documentation/installation](https://railspulse.com/documentation/installation)
|
|
66
95
|
|
|
67
|
-
|
|
68
|
-
bundle update rails_pulse
|
|
69
|
-
rails generate rails_pulse:upgrade
|
|
70
|
-
rails db:migrate # or: rails db:migrate:rails_pulse
|
|
71
|
-
rails rails_pulse:migrate_routes # required — fills Action and merges same-action paths
|
|
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.
|
|
96
|
+
## Going further
|
|
75
97
|
|
|
76
|
-
The
|
|
77
|
-
|
|
78
|
-
To see where an install stands at any point — schema, unrun migration files, route backfill, initializer — run `rails rails_pulse:status`. It exits 1 when something needs action, so it can gate a deploy.
|
|
79
|
-
|
|
80
|
-
Exception tracking is **off** for existing installs. The generator inserts `config.track_exceptions = false`; set it to `true` after migrating to opt in:
|
|
81
|
-
|
|
82
|
-
```ruby
|
|
83
|
-
config.track_exceptions = true
|
|
84
|
-
config.capture_exception_params = true # params are filtered via Rails' filter_parameters
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
Separate-database hosts: set `schema_dump: false` on the `rails_pulse` entry in `config/database.yml` and delete `db/rails_pulse_structure.sql` if that file exists.
|
|
88
|
-
|
|
89
|
-
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
|
-
|
|
91
|
-
Full install guide: [railspulse.com/documentation/installation](https://railspulse.com/documentation/installation)
|
|
92
|
-
|
|
93
|
-
Separate database setup: [railspulse.com/documentation/database](https://railspulse.com/documentation/database)
|
|
94
|
-
|
|
95
|
-
## Configuration
|
|
96
|
-
|
|
97
|
-
Rails Pulse works out of the box with sensible defaults. To customise, edit `config/initializers/rails_pulse.rb`:
|
|
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.
|
|
98
99
|
|
|
99
100
|
```ruby
|
|
100
101
|
RailsPulse.configure do |config|
|
|
101
|
-
config.
|
|
102
|
-
|
|
103
|
-
config.request_thresholds = {
|
|
104
|
-
slow: 700,
|
|
105
|
-
very_slow: 2000,
|
|
106
|
-
critical: 4000
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
config.track_jobs = true
|
|
110
|
-
config.capture_job_arguments = false # keep false to protect sensitive data
|
|
111
|
-
|
|
112
|
-
config.track_exceptions = true
|
|
113
|
-
config.capture_exception_params = true # params are filtered via Rails' filter_parameters
|
|
114
|
-
|
|
115
|
-
config.full_retention_period = 30.days
|
|
102
|
+
config.authorize = ->(controller) { controller.current_user&.admin? }
|
|
116
103
|
end
|
|
117
104
|
```
|
|
118
105
|
|
|
119
|
-
|
|
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)
|
|
120
107
|
|
|
121
|
-
|
|
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)
|
|
122
109
|
|
|
123
|
-
|
|
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)
|
|
124
111
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
```ruby
|
|
128
|
-
RailsPulse.configure do |config|
|
|
129
|
-
config.authorize = ->(controller) { controller.current_user&.admin? }
|
|
130
|
-
end
|
|
131
|
-
```
|
|
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.
|
|
132
113
|
|
|
133
|
-
|
|
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)
|
|
134
115
|
|
|
135
|
-
|
|
136
|
-
RailsPulse.configure do |config|
|
|
137
|
-
config.authentication_redirect_path = "/login"
|
|
116
|
+
## Upgrading
|
|
138
117
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
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
|
|
145
123
|
```
|
|
146
124
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
Authentication guide: [railspulse.com/documentation/authentication](https://railspulse.com/documentation/authentication)
|
|
150
|
-
|
|
151
|
-
## Features
|
|
152
|
-
|
|
153
|
-
- **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.
|
|
154
|
-
- **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.
|
|
155
|
-
- **Job tracking** — monitors background job duration, queue wait time, and failure rates. Works with any Active Job adapter.
|
|
156
|
-
- **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.
|
|
157
|
-
- **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.
|
|
158
|
-
- **No data leaves your app** — everything is stored in your own database. No third-party cloud, no SaaS subscription, no outbound connections.
|
|
159
|
-
- **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).
|
|
160
126
|
|
|
161
127
|
## Contributing
|
|
162
128
|
|
|
163
|
-
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
|
+
```
|
|
164
136
|
|
|
165
137
|
## License
|
|
166
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
|
|
@@ -86,10 +86,12 @@ module RailsPulse
|
|
|
86
86
|
session[:time_range_preference] = preset if TIME_RANGE_PRESETS.include?(preset)
|
|
87
87
|
elsif start_time && end_time
|
|
88
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.
|
|
89
91
|
session[:time_range_preference] = {
|
|
90
|
-
type
|
|
91
|
-
start_time
|
|
92
|
-
end_time
|
|
92
|
+
"type" => "custom",
|
|
93
|
+
"start_time" => start_time,
|
|
94
|
+
"end_time" => end_time
|
|
93
95
|
}
|
|
94
96
|
end
|
|
95
97
|
|
|
@@ -172,11 +174,13 @@ module RailsPulse
|
|
|
172
174
|
run_authentication_method(hook)
|
|
173
175
|
end
|
|
174
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.
|
|
175
180
|
def note_ignored_host_authentication(config)
|
|
176
181
|
return if config.authentication_method.nil? && config.authorize.nil?
|
|
177
|
-
return
|
|
182
|
+
return unless RailsPulse.note_host_authentication_ignored!
|
|
178
183
|
|
|
179
|
-
self.class.instance_variable_set(:@_rails_pulse_standalone_auth_noted, true)
|
|
180
184
|
logger.info "RailsPulse: standalone dashboard ignores config.authentication_method / config.authorize " \
|
|
181
185
|
"(host session helpers are not available here); using HTTP Basic auth. " \
|
|
182
186
|
"Set config.standalone_authentication_method to customise."
|
|
@@ -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.
|
|
@@ -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
|
|
@@ -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) {
|