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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +83 -111
  4. data/app/controllers/concerns/session_filters_concern.rb +4 -3
  5. data/app/controllers/concerns/time_range_concern.rb +29 -19
  6. data/app/controllers/rails_pulse/application_controller.rb +9 -5
  7. data/app/controllers/rails_pulse/dashboard_controller.rb +20 -11
  8. data/app/controllers/rails_pulse/routes_controller.rb +46 -0
  9. data/app/helpers/rails_pulse/application_helper.rb +4 -0
  10. data/app/javascript/rails_pulse/controllers/chart_controller.js +27 -0
  11. data/app/models/rails_pulse/cards/base.rb +61 -25
  12. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +38 -15
  13. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +36 -13
  14. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +3 -0
  15. data/app/models/rails_pulse/dashboard/health_summary.rb +3 -1
  16. data/app/models/rails_pulse/dashboard/needs_attention.rb +3 -1
  17. data/app/models/rails_pulse/dashboard/storage_status.rb +38 -68
  18. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +1 -5
  19. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +4 -4
  20. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -4
  21. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +7 -20
  22. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +5 -8
  23. data/app/models/rails_pulse/jobs/cards/total_runs.rb +4 -4
  24. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -9
  25. data/app/models/rails_pulse/queries/cards/database_load.rb +7 -10
  26. data/app/models/rails_pulse/queries/cards/execution_rate.rb +4 -6
  27. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +5 -8
  28. data/app/models/rails_pulse/route.rb +7 -1
  29. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -11
  30. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +3 -1
  31. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +4 -6
  32. data/app/models/rails_pulse/summary.rb +13 -18
  33. data/app/models/rails_pulse/time_range_preference.rb +28 -0
  34. data/app/models/rails_pulse/time_window.rb +61 -0
  35. data/app/services/rails_pulse/summary_service.rb +112 -162
  36. data/app/services/rails_pulse/tag_filter_service.rb +10 -1
  37. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +2 -2
  38. data/app/views/rails_pulse/dashboard/index.html.erb +6 -3
  39. data/app/views/rails_pulse/routes/_archived_summary_table.html.erb +46 -0
  40. data/app/views/rails_pulse/routes/_archived_table_pagination.html.erb +28 -0
  41. data/app/views/rails_pulse/routes/show.html.erb +14 -2
  42. data/lib/generators/rails_pulse/install_generator.rb +5 -2
  43. data/lib/generators/rails_pulse/templates/rails_pulse.rb +7 -2
  44. data/lib/rails_pulse/cleanup_service.rb +7 -7
  45. data/lib/rails_pulse/configuration.rb +9 -1
  46. data/lib/rails_pulse/engine.rb +14 -42
  47. data/lib/rails_pulse/middleware/request_collector.rb +2 -17
  48. data/lib/rails_pulse/standalone.rb +12 -0
  49. data/lib/rails_pulse/subscribers/operation_subscriber.rb +45 -3
  50. data/lib/rails_pulse/tracker.rb +241 -25
  51. data/lib/rails_pulse/version.rb +1 -1
  52. data/lib/rails_pulse.rb +3 -16
  53. data/lib/rails_pulse_server.ru +37 -23
  54. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  55. metadata +7 -9
  56. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  57. data/app/assets/images/rails_pulse/query-show.png +0 -0
  58. data/app/assets/images/rails_pulse/request-show.png +0 -0
  59. data/app/assets/images/rails_pulse/request.png +0 -0
  60. data/app/assets/images/rails_pulse/route-show.png +0 -0
  61. 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: 50a1c2016eee54e3ceda7d6e390669b7dc3f427a637cc975a50e9d682262411a
4
- data.tar.gz: dfd20c8b99bdb1f3986aa143fb8cfe5aaf3935f7ba7cd43e4c6883721de30ad7
3
+ metadata.gz: b24fa9677dc2c9b2dd4042acadb464247dd64d2136836e2197a1afe5b90f3f21
4
+ data.tar.gz: 2c45d0508fb24753b4d65b87b57afb19382b82277747a532e5182d6a6e2a8bdd
5
5
  SHA512:
6
- metadata.gz: caaf4f2932c7c639e7684fe3a8f4a789b4fe2d7a6b888d9cbeaf302487319efe758b91699f0eef1372cdad385f12269a1a78d9989bf29f855c20bf86c1c322e2
7
- data.tar.gz: 852e0524002c525ca784c981c21234a750a2e5d9bc29024290c49d653df4fc806edd0c23e767b9e26be945ed81fafa37cf7474a7354e89ff533cf92a142eafda
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="200" />
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
- **Self-hosted performance monitoring for Rails apps**
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
  ![Gem Version](https://img.shields.io/gem/v/rails_pulse)
12
10
  ![Rails Version](https://img.shields.io/badge/Rails-7.2%2B-blue)
11
+ ![Ruby Version](https://img.shields.io/badge/Ruby-3.1%2B-red)
13
12
  ![License](https://img.shields.io/badge/License-MIT-green)
14
- ![Ruby Version](https://img.shields.io/badge/Ruby-3.0%2B-red)
15
13
 
16
- Rails Pulse is a Rails engine that monitors your app's performance from the inside. It tracks slow requests, N+1 queries, SQL performance, background jobs, and unhandled exceptions. All data stays in your own database, no third-party cloud, no SaaS subscription, no data leaving your servers.
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
- <table border="0">
19
- <tr border="0" style="border:0">
20
- <td border="0" style="border:0">
21
- <img src="app/assets/images/rails_pulse/dashboard.png" alt="Dashboard" width="400" /></td>
22
- <td style="border:0"><img src="app/assets/images/rails_pulse/request-show.png" alt="Request detail" width="400" /></td>
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 style="border:0"><img src="app/assets/images/rails_pulse/query-show.png" alt="Query detail" width="400" /></td>
26
- <td style="border:0"><img src="app/assets/images/rails_pulse/route-show.png" alt="Route detail" width="400" /></td>
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
- ## Installation
31
-
32
- Add to your Gemfile:
59
+ ## Quick start
33
60
 
34
61
  ```ruby
35
- gem 'rails_pulse'
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
- Rails.application.routes.draw do
50
- mount RailsPulse::Engine => "/rails_pulse"
51
- end
73
+ # config/routes.rb
74
+ mount RailsPulse::Engine => "/rails_pulse"
52
75
  ```
53
76
 
54
- Schedule the background jobs:
55
-
56
- ```ruby
57
- RailsPulse::SummaryJob.perform_later # cron: 5 * * * *
58
- RailsPulse::CleanupJob.perform_later # cron: 0 1 * * *
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
- Your dashboard is now at `http://localhost:3000/rails_pulse`.
90
+ Open `http://localhost:3000/rails_pulse`. That's the whole setup.
62
91
 
63
- ## Upgrading
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
- From 0.3.3:
94
+ Full install guide, including a separate database and plain cron: [railspulse.com/documentation/installation](https://railspulse.com/documentation/installation)
66
95
 
67
- ```bash
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 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
- 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.enabled = true
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
- Full configuration reference: [railspulse.com/documentation/advanced](https://railspulse.com/documentation/advanced)
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
- ## Authentication
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
- Rails Pulse has no built-in user accounts; you protect the dashboard using your app's existing auth. Authentication is on by default outside development and test, and with nothing configured it falls back to HTTP Basic against `RAILS_PULSE_USERNAME` / `RAILS_PULSE_PASSWORD` (denying everything if the password is unset).
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
- The simplest hook is a predicate that receives the controller and returns `true` to allow — anything else is a 403:
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
- If you need to redirect to a login page instead, use `authentication_method`, which runs inside the controller and denies by rendering or redirecting:
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
- ```ruby
136
- RailsPulse.configure do |config|
137
- config.authentication_redirect_path = "/login"
116
+ ## Upgrading
138
117
 
139
- config.authentication_method = proc {
140
- unless user_signed_in? && current_user.admin?
141
- redirect_to main_app.root_path, alert: "Access denied"
142
- end
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
- Returning `false` from `authentication_method` without responding is also treated as a denial, but `nil` (what `unless … end` returns on success) allows the request — so keep predicate-style checks in `authorize`.
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
- # Can be a symbol/string for presets or a hash for custom ranges
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 session[:time_range_preference].present?
80
- preference = session[:time_range_preference]
81
- if preference.is_a?(Hash) && preference["type"] == "custom"
82
- # Custom range from time range selector
83
- start_time = parse_time_param(preference["start_time"]) || start_time
84
- end_time = parse_time_param(preference["end_time"]) || end_time
85
- selected_time_range = :custom
86
- else
87
- # Preset from time range selector
88
- selected_time_range = preference.to_sym
89
- start_time =
90
- case selected_time_range
91
- when :last_24_hours then 1.day.ago
92
- when :last_7_days then 1.week.ago
93
- when :last_14_days then 2.weeks.ago
94
- when :last_30_days then 1.month.ago
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: "custom",
91
- start_time: start_time,
92
- end_time: 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 if self.class.instance_variable_get(:@_rails_pulse_standalone_auth_noted)
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
- # Convert time range to period in days for dashboard cards/charts
12
- @period = ((@end_time - @start_time) / 1.day).round
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
- @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, period_type: @period_type).to_metric_card
23
- @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, period_type: @period_type).to_metric_card
24
- @error_rates_metric_card = RailsPulse::Routes::Cards::ErrorRates.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, period_type: @period_type).to_metric_card
25
- @job_failure_rate_metric_card = RailsPulse::Jobs::Cards::FailureRate.new(period: @period, period_type: @period_type).to_metric_card if RailsPulse.configuration.track_jobs
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(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, period_type: @period_type).to_chart_data
29
- @throughput_and_errors_chart_data = RailsPulse::Dashboard::Charts::ThroughputAndErrors.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, period_type: @period_type).to_chart_data
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) {