rails_pulse 0.4.2 → 0.5.0.pre.2

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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +15 -4
  4. data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
  5. data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
  6. data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
  7. data/app/controllers/concerns/chart_table_concern.rb +28 -69
  8. data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
  9. data/app/controllers/concerns/metric_card_concern.rb +6 -7
  10. data/app/controllers/rails_pulse/api/v1/base_controller.rb +133 -0
  11. data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
  12. data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
  13. data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +40 -0
  14. data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +77 -0
  15. data/app/controllers/rails_pulse/api/v1/insights_controller.rb +62 -0
  16. data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
  17. data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
  18. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +123 -0
  19. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +74 -0
  20. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +103 -0
  21. data/app/controllers/rails_pulse/application_controller.rb +1 -1
  22. data/app/controllers/rails_pulse/dashboard_controller.rb +25 -21
  23. data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
  24. data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
  25. data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
  26. data/app/controllers/rails_pulse/queries_controller.rb +6 -5
  27. data/app/controllers/rails_pulse/requests_controller.rb +15 -14
  28. data/app/controllers/rails_pulse/routes_controller.rb +4 -8
  29. data/app/helpers/rails_pulse/application_helper.rb +15 -0
  30. data/app/helpers/rails_pulse/chart_helper.rb +9 -2
  31. data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
  32. data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
  33. data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
  34. data/app/jobs/rails_pulse/cloud_health_job.rb +11 -0
  35. data/app/jobs/rails_pulse/cloud_sync_job.rb +11 -0
  36. data/app/jobs/rails_pulse/summary_job.rb +13 -0
  37. data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
  38. data/app/models/concerns/rails_pulse/taggable.rb +5 -12
  39. data/app/models/rails_pulse/cards/base.rb +20 -11
  40. data/app/models/rails_pulse/charts/base.rb +5 -18
  41. data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -5
  42. data/app/models/rails_pulse/cleanup_run.rb +32 -0
  43. data/app/models/rails_pulse/cloud/buffered_batch.rb +55 -0
  44. data/app/models/rails_pulse/cloud/installation.rb +55 -0
  45. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
  46. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
  47. data/app/models/rails_pulse/dashboard/concerns/attention_classification.rb +104 -0
  48. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +25 -2
  49. data/app/models/rails_pulse/dashboard/health_summary.rb +38 -13
  50. data/app/models/rails_pulse/dashboard/needs_attention.rb +31 -98
  51. data/app/models/rails_pulse/dashboard/storage_pressure.rb +39 -63
  52. data/app/models/rails_pulse/dashboard/storage_status.rb +130 -39
  53. data/app/models/rails_pulse/deployment.rb +2 -7
  54. data/app/models/rails_pulse/event.rb +41 -0
  55. data/app/models/rails_pulse/exception_occurrence.rb +1 -1
  56. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
  57. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
  58. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
  59. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -7
  60. data/app/models/rails_pulse/job_run.rb +1 -1
  61. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
  62. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
  63. data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
  64. data/app/models/rails_pulse/jobs/charts/duration.rb +1 -2
  65. data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
  66. data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -2
  67. data/app/models/rails_pulse/like_pattern.rb +35 -0
  68. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
  69. data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
  70. data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
  71. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
  72. data/app/models/rails_pulse/queries/charts/database_load.rb +5 -8
  73. data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
  74. data/app/models/rails_pulse/request.rb +1 -1
  75. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
  76. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
  77. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
  78. data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
  79. data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
  80. data/app/models/rails_pulse/summary.rb +5 -0
  81. data/app/models/rails_pulse/tables/base.rb +5 -6
  82. data/app/models/rails_pulse/time_range.rb +309 -0
  83. data/app/models/rails_pulse/time_window.rb +15 -0
  84. data/app/models/rails_pulse/writer_heartbeat.rb +90 -0
  85. data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +23 -0
  86. data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
  87. data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
  88. data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
  89. data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
  90. data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
  91. data/app/serializers/rails_pulse/api/v1/request_serializer.rb +22 -0
  92. data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
  93. data/app/services/rails_pulse/cloud/batch.rb +83 -0
  94. data/app/services/rails_pulse/cloud/client.rb +68 -0
  95. data/app/services/rails_pulse/cloud/combined_metrics.rb +49 -0
  96. data/app/services/rails_pulse/cloud/health_item.rb +77 -0
  97. data/app/services/rails_pulse/cloud/path_prefix.rb +50 -0
  98. data/app/services/rails_pulse/cloud/preview.rb +83 -0
  99. data/app/services/rails_pulse/cloud/query_shape.rb +195 -0
  100. data/app/services/rails_pulse/cloud/record_items.rb +60 -0
  101. data/app/services/rails_pulse/cloud/route_pattern.rb +111 -0
  102. data/app/services/rails_pulse/cloud/summary_items.rb +188 -0
  103. data/app/services/rails_pulse/cloud/sync.rb +275 -0
  104. data/app/services/rails_pulse/config_recommendations.rb +132 -0
  105. data/app/services/rails_pulse/deployment_comparison.rb +177 -0
  106. data/app/services/rails_pulse/exception_capture_service.rb +1 -1
  107. data/app/services/rails_pulse/operations/change_point.rb +1 -1
  108. data/app/services/rails_pulse/operations/comparison.rb +1 -1
  109. data/app/services/rails_pulse/period_insights.rb +131 -0
  110. data/app/services/rails_pulse/sql_query_normalizer.rb +222 -122
  111. data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
  112. data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
  113. data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
  114. data/app/services/rails_pulse/summary_service.rb +121 -133
  115. data/app/services/rails_pulse/tag_filter_service.rb +5 -5
  116. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
  117. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  118. data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
  119. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
  120. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
  121. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
  122. data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +13 -3
  123. data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
  124. data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
  125. data/app/views/rails_pulse/jobs/index.html.erb +1 -1
  126. data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
  127. data/app/views/rails_pulse/queries/index.html.erb +1 -1
  128. data/app/views/rails_pulse/queries/show.html.erb +1 -1
  129. data/app/views/rails_pulse/requests/index.html.erb +1 -1
  130. data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
  131. data/app/views/rails_pulse/routes/index.html.erb +1 -1
  132. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  133. data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
  134. data/app/views/rails_pulse/storage/show.html.erb +39 -2
  135. data/config/routes.rb +23 -0
  136. data/db/rails_pulse_cloud_schema.rb +58 -0
  137. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
  138. data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
  139. data/db/rails_pulse_schema.rb +20 -2
  140. data/exe/rails-pulse +7 -0
  141. data/lib/generators/rails_pulse/base_methods.rb +19 -0
  142. data/lib/generators/rails_pulse/install_cloud_generator.rb +84 -0
  143. data/lib/generators/rails_pulse/templates/db/rails_pulse_cloud_schema.rb +58 -0
  144. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
  145. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_cloud_tables.rb +30 -0
  146. data/lib/generators/rails_pulse/templates/rails_pulse.rb +71 -19
  147. data/lib/generators/rails_pulse/upgrade_generator.rb +9 -16
  148. data/lib/rails_pulse/cleanup_service.rb +37 -20
  149. data/lib/rails_pulse/cli/agent_files/agents.md +89 -0
  150. data/lib/rails_pulse/cli/agent_files/claude_skill.md +182 -0
  151. data/lib/rails_pulse/cli/base_command.rb +37 -0
  152. data/lib/rails_pulse/cli/client.rb +64 -0
  153. data/lib/rails_pulse/cli/config.rb +87 -0
  154. data/lib/rails_pulse/cli/configure.rb +75 -0
  155. data/lib/rails_pulse/cli/coverage.rb +91 -0
  156. data/lib/rails_pulse/cli/deployments.rb +54 -0
  157. data/lib/rails_pulse/cli/exceptions.rb +116 -0
  158. data/lib/rails_pulse/cli/formatter.rb +31 -0
  159. data/lib/rails_pulse/cli/insights.rb +70 -0
  160. data/lib/rails_pulse/cli/install.rb +145 -0
  161. data/lib/rails_pulse/cli/job_runs.rb +53 -0
  162. data/lib/rails_pulse/cli/jobs.rb +71 -0
  163. data/lib/rails_pulse/cli/main.rb +76 -0
  164. data/lib/rails_pulse/cli/mcp.rb +47 -0
  165. data/lib/rails_pulse/cli/queries.rb +67 -0
  166. data/lib/rails_pulse/cli/requests.rb +57 -0
  167. data/lib/rails_pulse/cli/routes.rb +73 -0
  168. data/lib/rails_pulse/configuration.rb +100 -2
  169. data/lib/rails_pulse/current.rb +11 -0
  170. data/lib/rails_pulse/engine.rb +12 -3
  171. data/lib/rails_pulse/installers/config_updater.rb +9 -2
  172. data/lib/rails_pulse/job_run_collector.rb +15 -15
  173. data/lib/rails_pulse/mcp/server.rb +78 -0
  174. data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
  175. data/lib/rails_pulse/mcp/tools/deployments.rb +117 -0
  176. data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
  177. data/lib/rails_pulse/mcp/tools/errors.rb +104 -0
  178. data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
  179. data/lib/rails_pulse/mcp/tools/exceptions.rb +128 -0
  180. data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
  181. data/lib/rails_pulse/mcp/tools/insights.rb +99 -0
  182. data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
  183. data/lib/rails_pulse/mcp/tools/queries.rb +142 -0
  184. data/lib/rails_pulse/mcp/tools/routes.rb +108 -0
  185. data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
  186. data/lib/rails_pulse/middleware/request_collector.rb +9 -9
  187. data/lib/rails_pulse/scoped_inflector.rb +35 -0
  188. data/lib/rails_pulse/statistics.rb +27 -0
  189. data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
  190. data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
  191. data/lib/rails_pulse/tasks/status_reporter.rb +201 -0
  192. data/lib/rails_pulse/tracker.rb +76 -14
  193. data/lib/rails_pulse/version.rb +1 -1
  194. data/lib/rails_pulse.rb +0 -4
  195. data/lib/tasks/rails_pulse.rake +6 -0
  196. data/lib/tasks/rails_pulse_tasks.rake +7 -0
  197. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  198. data/public/rails-pulse-assets/rails-pulse.js +13 -13
  199. metadata +123 -24
  200. data/app/controllers/concerns/page_timings.rb +0 -10
  201. data/app/controllers/concerns/response_range_concern.rb +0 -45
  202. data/app/controllers/concerns/time_range_concern.rb +0 -152
  203. data/app/controllers/concerns/zoom_range_concern.rb +0 -86
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b54f69a078537dd6dabc4074623c3deaf4b0123f4c229b57c706583f104d1b62
4
- data.tar.gz: 2cd90650cc8ceb9cf15dac48c4d4695113c73a52fdb4661153fa5c45cbf3b378
3
+ metadata.gz: 208274704510167233904a19653cfe5bffcf6f29080344d088e9cbee8c425a87
4
+ data.tar.gz: fbbe9e3a6c35a09236a81158d47026a9ab913f1db6e79800b8c71183df4d9d63
5
5
  SHA512:
6
- metadata.gz: 125303510a989e6de226b732d14fd76d001b5641feb4f93bd8ae5daf07ec73b2328c022097c36be6b4153cd270a9344bf8d4f14e1364843b96fdabd07beb0aab
7
- data.tar.gz: d262a5d599007772f1cccc087b6166fd3a151f93f3cdf990bcd37b3ffed6dc6df3aa785a959ae0ae92ac56df545d9a299aed1ec41e3f3d605adb868ce9bb22d0
6
+ metadata.gz: 83b22f6771f0c1a99bada73bd60e1af0abb7a7931b5523afa9c1a852c85bf7664d4af32ec025d955ff6db47dfde7c3b2563fee86301d2a7032b0a6f7f1615543
7
+ data.tar.gz: '08fd08858f4eafa689a17e92c922623f15f11ff9663de193b3f99830faa59a171bef0a7c5357dbb7bcd2df9afd1ab97f7f6c3ff52ca4cb0f67d7835f11c99730'
data/CHANGELOG.md CHANGED
@@ -5,7 +5,64 @@ All notable changes to Rails Pulse will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## 0.4.2 - 2025-10-01
8
+ ## [Unreleased]
9
+
10
+ ## [0.5.0.pre.2] - 2026-10-11
11
+
12
+ ### Added
13
+
14
+ - **Before-and-after comparison for every deployment.** The deployments API, `rails-pulse deployments list` and the `rails_pulse_deployments` tool now say whether each release made things worse: response time or error rate materially worse in the hour after the deploy finished than in the hour before it started, or too little traffic to tell.
15
+ - **What needs attention, and whether the thresholds fit.** `GET /rails_pulse/api/v1/insights`, `rails-pulse insights show` and the `rails_pulse_insights` tool list the routes, queries and jobs past their thresholds over one hour, day, week or month, and suggest an initializer line when `route_thresholds` or `query_thresholds` is too noisy or never fires.
16
+ - **Exact lookups for the keys Rails Pulse Cloud holds.** The API, CLI and MCP tools can now find a query by `hashed_sql`, an exception group by `fingerprint`, a route by exact path and controller action, a deployment by revision, and requests by path prefix, so a Cloud alert leads straight to the local detail.
17
+ - **Rails Pulse Cloud sync.** With `config.cloud.api_key` and `config.cloud.application` set, hourly summaries, exception groups, deployments and a per-minute health update are sent to Rails Pulse Cloud (never request URLs, SQL values or exception messages), and `rails rails_pulse:cloud:preview` prints exactly what would be sent; to use it, run `rails generate rails_pulse:install_cloud`, migrate, and schedule `RailsPulse::CloudHealthJob` every minute.
18
+ - **`rails rails_pulse:status` checks how the install is run.** It reports whether `SummaryJob` has skipped hours and whether `CleanupJob` is running, how long hourly summaries are kept, and whether jobs are tracked, and lists suggestions for each without changing the exit status. Each cleanup run is now recorded in `rails_pulse_events` so the task can tell.
19
+
20
+ ### Changed
21
+
22
+ - **A query is the same query however many values it loads.** `WHERE id IN (…)` with 3 ids and with 12 ids, and a batch insert of 2 rows and of 50, used to be tracked as separate queries, each with its own stats, N+1 flags and EXPLAIN; they are now one entry. Entries recorded by earlier versions keep their old form until retention removes them, so a query may appear twice on the Queries page for a while after upgrading.
23
+ - **Hourly summaries are kept for 7 days instead of 2.** Deployment comparisons and hour-precise change points read them, so at 2 days a deploy from earlier in the week could no longer be compared; `config.hourly_summary_retention` tunes it.
24
+
25
+ ### Fixed
26
+
27
+ - **Normalised SQL no longer shows some string values.** A value written in one of SQL's less common string forms — only possible in hand-written SQL — stayed visible in the normalised query on the dashboard, the API and the MCP server, and split that query's stats into one entry per value; it is now replaced with `?` like any other value. On MySQL the same happened to any value containing an apostrophe or any double-quoted value shaped like a column name, and on every database to very small or very large decimals. (#335, #313)
28
+ - **The dashboard no longer counts every Rails Pulse table on each load.** On PostgreSQL and MySQL its storage panel now shows the database's row estimates, and on SQLite it leaves record counts to the Storage page, so large installs no longer wait tens of seconds per page view; the storage-pressure checks also run once per page instead of four times. On MySQL 8, record counts and disk sizes on the dashboard and the Storage page are read current instead of from a statistics cache up to a day old. (#363)
29
+ - **Needs Attention and System Health no longer overcount traffic.** Request, error, query and exception counts were added up once per summary granularity, which inflated them and could raise an exception group's severity; today's traffic now counts in windows longer than a day. (#338)
30
+ - **Threshold recommendations no longer suggest a critical threshold equal to `very_slow` or below a rarely run slow query.** A suggested critical threshold now sits at least one step above `very_slow`, and the query check reads the period's slowest query rather than only its most expensive ones.
31
+ - **The dashboard no longer reports cleanup as blocked when it is not.** The critical "summarize first" Storage item appeared whenever expired requests were waiting for the next daily cleanup, which deletes them by age regardless of summaries; it has been removed, and a stalled summary job is still flagged.
32
+ - **PostgreSQL Unicode escape strings (`U&'…'`) are normalised to `?`** like other prefixed strings, instead of leaving `U&?` in the normalised query.
33
+ - **Daily charts no longer go blank after a daylight-saving change.** In a `config.time_zone` with daylight saving, every day after the clocks changed was drawn empty in a range that crossed the change; chart days now follow the zone's calendar.
34
+
35
+ ## [0.5.0.pre.1] - 2026-10-03
36
+
37
+ ### Added
38
+
39
+ - **JSON API, `rails-pulse` CLI and MCP server.** A read-only API under `/rails_pulse/api/v1`, a `rails-pulse` executable and an MCP server (`rails-pulse mcp`; add `gem "mcp"` to your Gemfile) give scripts, CI and coding agents what the dashboard shows: routes, requests, queries, jobs, job runs, exceptions and deployments. (#309)
40
+ - **Coverage reporting for agents.** `rails-pulse coverage show` and the `rails_pulse_coverage` tool report what has been recorded, how recently, any collection gaps, and which application and environment answered, so missing data is not mistaken for an all-clear. (#309)
41
+ - **Fixed windows and drilldown.** Every windowed command and tool takes ISO 8601 `since`/`until` and reports the window it measured, and `rails_pulse_queries` takes an endpoint's `route_id` to show the SQL inside it with the file and line each query came from. (#309)
42
+ - **Agent instructions.** `rails-pulse install claude` installs a Claude Code skill, and `rails-pulse install agents` writes a Rails Pulse section into `AGENTS.md`, alongside a project's own instructions with `--append`. (#309)
43
+ - `rails rails_pulse:status` reports whether the API and deployment tokens are set. (#309)
44
+ - **Dropped requests are now visible.** Each background writer records a heartbeat once a minute (queue depth and requests dropped since the last one), pruned after a day. The dashboard's health bar gains a Tracking badge, shown only when a writer is backlogged or dropping requests; the Storage page lists every live writer with its queue and drops; and `rails rails_pulse:status` reports the totals and exits 1 when anything was dropped in the last hour. (#281)
45
+ - **A `rails_pulse_events` table** for what Rails Pulse notices rather than measures, starting with the writer heartbeats above. `config.event_retention_period` (default 90 days) prunes it. Run `rails generate rails_pulse:upgrade` and migrate; tracking pauses until the table exists.
46
+ - CI now exercises the separate-database upgrade path (`bin/test_separate_database_upgrade`, SQLite and PostgreSQL), and the migration regression suite gains a 0.3.2 baseline. (#284)
47
+ - **Charts say which time zone they are in.** Every chart carries a zone badge (hover for the full zone name and the exact window shown), chart tooltips end with the zone, and the custom date range picker states the zone its inputs are read in. (#303)
48
+
49
+ ### Changed
50
+
51
+ - **Daily, weekly and monthly summaries are built from shorter periods instead of raw rows.** The summary job's memory no longer grows with the length of the period or with raised retention caps, and longer periods stay complete after raw data is pruned; their P50/P95/P99 are now traffic-weighted from the hours or days below them, as the dashboard already shows for multi-period ranges. A period is summarized only once it ends, so the backfill task no longer writes a partial row for the current day. (#279)
52
+ - **`config.deployment_api_token` is now `config.deployment_token`.** The old name still works and still only records deployments; the JSON API reads a separate `config.api_token`, so a token given to a coding agent cannot record a release. (#309)
53
+ - **Recording a deployment over HTTP requires `config.deployment_token`.** The deployments endpoint no longer accepts a dashboard login in place of the token, which let any page a signed-in admin visited record a release; set the token for CI, or use the `rails_pulse:record_deployment` task, which needs none. (#309)
54
+ - **All timestamps display in the app's `config.time_zone`.** Chart axes and tooltips are formatted in that zone rather than the browser's, so a daily point no longer lands on the wrong calendar day for viewers in another zone. Request, job, exception and operation timestamps also use it instead of the server's OS zone; on a host whose server runs in UTC with a different `config.time_zone`, those pages now show the configured zone. (#303)
55
+ - **Dashboard health bar badges omit zero counts.** "26 healthy · 0 slow · 0 critical" now reads "26 healthy"; the Storage badge is shown only under warning or critical pressure.
56
+ - **Dropped the `request_store` runtime dependency.** Per-request tracking state now goes through `RailsPulse::Current`, built on Rails' own `ActiveSupport::CurrentAttributes`. No configuration or behavior change; a host that read `RequestStore.store[:rails_pulse_request_id]` directly (undocumented, but reachable) needs to switch to `RailsPulse::Current.rails_pulse_request_id`. (#277)
57
+ - **Requires Ruby 3.2+ and Rails 7.2+.** The gemspec advertised Ruby 3.1 and Rails 7.1 but CI never ran them; the floors now match what is tested, and the untested Rails 7.1 and Ruby 3.1 code paths are gone. (#270)
58
+
59
+ ### Fixed
60
+
61
+ - Weekly and monthly summaries written by `SummaryJob` now include the period's last day. (#279)
62
+ - Backfill date arguments are read in the app's time zone, so backfilled summaries land on the same period boundaries the scheduled job writes instead of UTC midnights. (#279)
63
+ - **The exception-group row cap no longer counts preserved and ignored groups.** Once those exempt groups approached the cap, every cleanup run deleted the oldest deletable groups without ever getting under it. The cap now applies to deletable groups only. (#285)
64
+
65
+ ## [0.4.2] - 2026-10-01
9
66
 
10
67
  ### Changed
11
68
 
data/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ![Gem Version](https://img.shields.io/gem/v/rails_pulse)
10
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)
11
+ ![Ruby Version](https://img.shields.io/badge/Ruby-3.2%2B-red)
12
12
  ![License](https://img.shields.io/badge/License-MIT-green)
13
13
 
14
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.
@@ -27,6 +27,7 @@ Rails Pulse is a Rails engine. It hooks into the instrumentation Rails already e
27
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
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
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
+ - **Your coding agent can read it.** A read-only JSON API, a `rails-pulse` CLI and an MCP server give Claude Code, Codex, Cursor or a CI script the same data: the slowest endpoints since the last deploy, the queries behind them, the jobs that failed. Ask the agent why checkout got slow and it can go and look.
30
31
 
31
32
  <table>
32
33
  <tr>
@@ -89,7 +90,7 @@ production:
89
90
 
90
91
  Open `http://localhost:3000/rails_pulse`. That's the whole setup.
91
92
 
92
- Requirements: Ruby 3.1+, Rails 7.2+ (tested on 7.2, 8.0 and 8.1), SQLite, PostgreSQL or MySQL.
93
+ Requirements: Ruby 3.2+, Rails 7.2+ (tested on 7.2, 8.0 and 8.1), SQLite, PostgreSQL or MySQL.
93
94
 
94
95
  Full install guide, including a separate database and plain cron: [railspulse.com/documentation/installation](https://railspulse.com/documentation/installation)
95
96
 
@@ -109,7 +110,17 @@ With nothing configured it falls back to HTTP Basic against `RAILS_PULSE_USERNAM
109
110
 
110
111
  **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)
111
112
 
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.
113
+ **Mark your deploys.** `rails rails_pulse:record_deployment[sha]` from a release script, or `POST /rails_pulse/deployments` with the API token from CI, and every chart draws a line at that moment.
114
+
115
+ **Brief your agent.** Set `config.api_token`, then on your machine:
116
+
117
+ ```bash
118
+ rails-pulse configure # URL and token, saved to ~/.rails-pulse
119
+ rails-pulse routes list --since 2026-06-01T00:00:00Z
120
+ rails-pulse install claude # Claude Code skill: when and how to use the tools
121
+ ```
122
+
123
+ Add `gem "mcp"` to your Gemfile (a development group is enough), register `rails-pulse mcp` as an MCP server, and the agent gets eleven read-only tools: routes, slow requests, errors, exception groups and their backtraces, one endpoint in depth, expensive and N+1 queries, job health, deployments and whether each one made things worse, what needs attention and whether your thresholds fit, and what has actually been recorded. Nothing the agent can call changes production. [Agent tooling](docs/api.md)
113
124
 
114
125
  **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)
115
126
 
@@ -126,7 +137,7 @@ Upgrading from 0.3.x to 0.4? **Back up first**, run `rails rails_pulse:migrate_r
126
137
 
127
138
  ## Contributing
128
139
 
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.
140
+ 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. Building on top of Rails Pulse (a plugin, scripting the CLI)? [`docs/api.md`](docs/api.md) states what's public and stable across minor releases.
130
141
 
131
142
  ```bash
132
143
  git config core.hooksPath .githooks # once, after cloning
@@ -85,6 +85,17 @@
85
85
  border-left: none;
86
86
  }
87
87
 
88
+ /* Aggregation-zone badge beside a chart title or tab strip. Its title
89
+ attribute carries the full zone name and the resolved window, hence the
90
+ help cursor. */
91
+ .zone-badge {
92
+ font-size: var(--text-xs);
93
+ font-weight: var(--font-normal);
94
+ color: var(--color-text-subtle);
95
+ white-space: nowrap;
96
+ cursor: help;
97
+ }
98
+
88
99
  .panel-tab {
89
100
  display: inline-flex;
90
101
  align-items: center;
@@ -1,3 +1,9 @@
1
+ /* @container rules match descendants of the container, not the container
2
+ itself, so the containment context has to live on the parent. */
3
+ :has(> .descriptive-list) {
4
+ container-type: inline-size;
5
+ }
6
+
1
7
  .descriptive-list {
2
8
  display: grid;
3
9
  grid-template-columns: 200px 1fr;
@@ -6,9 +12,21 @@
6
12
 
7
13
  .descriptive-list dt, .descriptive-list dd {
8
14
  font-size: var(--text-sm);
15
+ min-width: 0;
9
16
  }
10
17
 
11
18
  .descriptive-list dd {
12
19
  overflow-wrap: break-word;
13
- min-width: 0;
20
+ }
21
+
22
+ /* Below this the fixed 200px label column crowds out the value column */
23
+ @container (max-width: 340px) {
24
+ .descriptive-list {
25
+ grid-template-columns: 1fr;
26
+ gap: 0.125rem 0.5rem;
27
+ }
28
+
29
+ .descriptive-list dd {
30
+ margin-block-end: 0.5rem;
31
+ }
14
32
  }
@@ -23,7 +23,7 @@
23
23
  flex: 1;
24
24
  }
25
25
 
26
- /* Responsive layout for screens smaller than 768px */
26
+ /* Single column below 768px */
27
27
  @media (max-width: 768px) {
28
28
  .row {
29
29
  display: flex;
@@ -34,35 +34,21 @@
34
34
  }
35
35
 
36
36
  .row > * {
37
- flex: 0 0 calc(50% - 0.25rem);
37
+ flex: 0 0 100%;
38
38
  min-width: 0;
39
39
  height: auto;
40
40
  }
41
41
 
42
42
  .row > .grid-item {
43
43
  height: auto;
44
+ min-height: auto;
44
45
  }
45
46
 
46
47
  .row > .grid-item > * {
47
48
  flex: none;
48
49
  }
49
50
 
50
- /* Tables and chart panels should stack in single column on tablet */
51
- .row:has(.table-container) > *,
52
- .row:has(.chart-container) > * {
53
- flex: 0 0 100%;
54
- }
55
-
56
- /* Single column for very small screens */
57
51
  @media (max-width: 480px) {
58
- .row > * {
59
- flex: 0 0 100%;
60
- }
61
-
62
- .row > .grid-item {
63
- min-height: auto;
64
- }
65
-
66
52
  /* Make metric cards more compact on mobile */
67
53
  .row > .grid-item .card {
68
54
  padding: var(--size-3);
@@ -2,7 +2,6 @@
2
2
  #
3
3
  # Core concern for controllers that display both charts and tables (routes, requests, queries, jobs).
4
4
  # Orchestrates time range setup, chart generation, table queries, and zoom/filter handling.
5
- # Includes TimeRangeConcern, ResponseRangeConcern, and ZoomRangeConcern for filter management.
6
5
  #
7
6
  # Controllers including this concern must implement:
8
7
  # - chart_model, table_model, chart_definitions, default_table_sort, build_table_results
@@ -13,9 +12,7 @@ module ChartTableConcern
13
12
  VALID_PERIOD_TYPES = %w[hour day].freeze
14
13
 
15
14
  included do
16
- include TimeRangeConcern
17
- include ResponseRangeConcern
18
- include ZoomRangeConcern
15
+ include RansackParamsConcern
19
16
  include DeploymentMarkersConcern
20
17
 
21
18
  before_action :setup_page_timings
@@ -24,32 +21,16 @@ module ChartTableConcern
24
21
 
25
22
  private
26
23
 
24
+ # Hook: override to change the default time range (e.g. :last_7_days).
25
+ def default_time_range_key = :last_24_hours
26
+
27
27
  def setup_page_timings
28
- start_time, end_time, selected_time_range, time_diff_hours = setup_time_range
29
- start_duration, selected_response_range = setup_duration_range(duration_range_type)
30
- zoom_start, zoom_end, table_start_time, table_end_time =
31
- setup_zoom_range(start_time, end_time)
32
-
33
- @page_timings = PageTimings.new(
34
- start_time: start_time, end_time: end_time,
35
- table_start_time: table_start_time, table_end_time: table_end_time,
36
- zoom_start: zoom_start, zoom_end: zoom_end,
37
- time_diff_hours: time_diff_hours, start_duration: start_duration,
38
- selected_time_range: selected_time_range, selected_response_range: selected_response_range
28
+ @time_range = RailsPulse::TimeRange.resolve(
29
+ params: params,
30
+ session: session,
31
+ default_key: default_time_range_key,
32
+ duration_range_type: duration_range_type
39
33
  )
40
-
41
- # Keep individual @vars for backward compat with views, helpers, and other
42
- # concerns (DeploymentMarkersConcern, MetricCardConcern, ChartHelper).
43
- @start_time = start_time
44
- @end_time = end_time
45
- @time_diff_hours = time_diff_hours
46
- @start_duration = start_duration
47
- @selected_time_range = selected_time_range
48
- @selected_response_range = selected_response_range
49
- @zoom_start = zoom_start
50
- @zoom_end = zoom_end
51
- @table_start_time = table_start_time
52
- @table_end_time = table_end_time
53
34
  end
54
35
 
55
36
  def setup_chart_and_table_data
@@ -78,9 +59,8 @@ module ChartTableConcern
78
59
  common_options = {
79
60
  ransack_query: chart_ransack_query,
80
61
  period_type: period_type,
81
- start_time: @page_timings.start_time,
82
- end_time: @page_timings.end_time,
83
- start_duration: @page_timings.start_duration,
62
+ window: @time_range&.window,
63
+ start_duration: @time_range&.start_duration,
84
64
  disabled_tags: session_disabled_tags,
85
65
  show_non_tagged: session[:show_non_tagged] != false,
86
66
  **chart_options
@@ -110,22 +90,7 @@ module ChartTableConcern
110
90
  end
111
91
 
112
92
  def period_type
113
- time_diff = @page_timings&.time_diff_hours
114
-
115
- type = if time_diff.nil?
116
- "day" # Default to day for "recent" mode or when time_diff isn't set
117
- elsif time_diff <= 25
118
- "hour"
119
- else
120
- "day"
121
- end
122
-
123
- # Validate period type to prevent SQL injection via string interpolation
124
- unless VALID_PERIOD_TYPES.include?(type)
125
- raise ArgumentError, "Invalid period_type: #{type}. Must be one of: #{VALID_PERIOD_TYPES.join(", ")}"
126
- end
127
-
128
- type
93
+ @time_range&.period_type || "day"
129
94
  end
130
95
 
131
96
  def meaningful_chart_data?
@@ -181,15 +146,13 @@ module ChartTableConcern
181
146
 
182
147
  # Builds ransack parameters for chart queries
183
148
  # Common pattern: time range + optional duration filter + resource scope
184
- # Handles "recent" mode where @page_timings.start_time/@end_time may be nil
185
149
  def build_chart_ransack_params(ransack_params)
186
150
  base_params = ransack_params.except(:s, *chart_filter_exclusions)
187
151
 
188
- # Add time filters if we have time boundaries (not in "recent" mode)
189
- if @page_timings&.start_time && @page_timings&.end_time
152
+ if @time_range&.window
190
153
  base_params.merge!(
191
- period_start_gteq: Time.at(@page_timings.start_time),
192
- period_start_lt: Time.at(@page_timings.end_time)
154
+ period_start_gteq: @time_range.window.start_time,
155
+ period_start_lt: @time_range.window.end_time
193
156
  )
194
157
  end
195
158
 
@@ -197,8 +160,8 @@ module ChartTableConcern
197
160
  base_params.merge!(summarizable_type_eq: summarizable_type) if summarizable_type
198
161
 
199
162
  # Only add duration filter if we have a meaningful threshold
200
- if @page_timings&.start_duration && @page_timings.start_duration > 0
201
- base_params[:avg_duration_gteq] = @page_timings.start_duration
163
+ if @time_range&.start_duration && @time_range.start_duration > 0
164
+ base_params[:avg_duration_gteq] = @time_range.start_duration
202
165
  end
203
166
 
204
167
  # Scope to specific resource on show pages
@@ -220,41 +183,37 @@ module ChartTableConcern
220
183
  end
221
184
 
222
185
  # Builds table params for show pages (individual records like Request, JobRun)
223
- # Handles "recent" mode where time boundaries may be nil
224
186
  def build_show_table_ransack_params(ransack_params)
225
187
  params = ransack_params.dup
226
188
 
227
- # Add time filters if we have time boundaries (not in "recent" mode)
228
- if @page_timings&.table_start_time && @page_timings&.table_end_time
189
+ if @time_range&.table_window
229
190
  params.merge!(
230
- occurred_at_gteq: Time.at(@page_timings.table_start_time),
231
- occurred_at_lt: Time.at(@page_timings.table_end_time)
191
+ occurred_at_gteq: @time_range.table_window.start_time,
192
+ occurred_at_lt: @time_range.table_window.end_time
232
193
  )
233
194
  end
234
195
 
235
196
  params.merge!(show_resource_filter)
236
- if @page_timings&.start_duration && @page_timings.start_duration > 0
237
- params[:duration_gteq] = @page_timings.start_duration
197
+ if @time_range&.start_duration && @time_range.start_duration > 0
198
+ params[:duration_gteq] = @time_range.start_duration
238
199
  end
239
200
  params
240
201
  end
241
202
 
242
203
  # Builds table params for index pages (summary records)
243
- # Handles "recent" mode where time boundaries may be nil
244
204
  def build_index_table_ransack_params(ransack_params)
245
205
  params = ransack_params.dup
246
206
 
247
- # Add time filters if we have time boundaries (not in "recent" mode)
248
- if @page_timings&.table_start_time && @page_timings&.table_end_time
207
+ if @time_range&.table_window
249
208
  params.merge!(
250
- period_start_gteq: Time.at(@page_timings.table_start_time),
251
- period_start_lt: Time.at(@page_timings.table_end_time)
209
+ period_start_gteq: @time_range.table_window.start_time,
210
+ period_start_lt: @time_range.table_window.end_time
252
211
  )
253
212
  end
254
213
 
255
214
  params.merge!(summarizable_type_eq: summarizable_type) if summarizable_type
256
- if @page_timings&.start_duration && @page_timings.start_duration > 0
257
- params[:avg_duration_gteq] = @page_timings.start_duration
215
+ if @time_range&.start_duration && @time_range.start_duration > 0
216
+ params[:avg_duration_gteq] = @time_range.start_duration
258
217
  end
259
218
  params
260
219
  end
@@ -285,7 +244,7 @@ module ChartTableConcern
285
244
  raise NotImplementedError, "#{self.class} must implement #chart_definitions"
286
245
  end
287
246
 
288
- # Returns options passed to chart classes (e.g., { route: @route })
247
+ # Returns options passed to chart classes (e.g., { subject: @route })
289
248
  def chart_options
290
249
  {}
291
250
  end
@@ -1,7 +1,9 @@
1
1
  module DeploymentMarkersConcern
2
2
  def populate_deployment_markers
3
+ return unless @time_range&.window
4
+
3
5
  @deployment_markers = RailsPulse::Deployment
4
- .for_range(Time.zone.at(@start_time), Time.zone.at(@end_time))
6
+ .for_range(@time_range.window.start_time, @time_range.window.end_time)
5
7
  .map(&:to_chart_marker)
6
8
  end
7
9
  end
@@ -33,14 +33,13 @@ module MetricCardConcern
33
33
  end
34
34
  end
35
35
 
36
- # Common parameters passed to all metric card classes
36
+ # Common parameters passed to all metric card classes. Deliberately does
37
+ # NOT pass window: — these cards show a "trailing period" figure, not the
38
+ # exact selected range (ChartTableConcern's charts get the exact window;
39
+ # see Cards::Base#window_days).
37
40
  def metric_card_params
38
- # For "recent" mode with no time filtering, use a default period of 7 days
39
- period_days = if @start_time.nil? || @end_time.nil?
40
- 7
41
- else
42
- ((@end_time - @start_time) / 1.day).round
43
- end
41
+ window = @time_range&.window
42
+ period_days = window ? ((window.end_time - window.start_time) / 1.day).round : 7
44
43
 
45
44
  {
46
45
  resource_key => current_resource,
@@ -0,0 +1,133 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ # Read-only JSON API for the rails-pulse CLI, the MCP server, CI scripts
5
+ # and coding agents. Authenticated by config.api_token alone: the
6
+ # dashboard session is never consulted, and with no token configured
7
+ # every request is refused.
8
+ class BaseController < RailsPulse::ApplicationController
9
+ skip_before_action :authenticate_rails_pulse_user!
10
+ skip_before_action :set_show_non_tagged_default
11
+ skip_before_action :set_onboarding_state
12
+ skip_before_action :load_deployment_markers
13
+
14
+ # Prepended so it runs ahead of the inherited require_current_schema!:
15
+ # the schema report lists missing tables and columns and must not be
16
+ # served to anonymous callers.
17
+ prepend_before_action :authenticate_api_token!
18
+
19
+ # After authentication, so an anonymous caller learns nothing from
20
+ # which of its parameters were refused.
21
+ before_action :validate_params!
22
+
23
+ # Every action is a GET read from the query string. Rails would
24
+ # otherwise copy a JSON request's parameters into a hash named after
25
+ # the controller (`route`, `job`), which collides with the filters of
26
+ # the same name whenever a client sends Content-Type: application/json.
27
+ wrap_parameters false
28
+
29
+ # Parameters every endpoint reads as one string. A repeated or nested
30
+ # one (`search[]=x`) is refused rather than reaching a String method.
31
+ SCALAR_PARAMS = %i[
32
+ limit offset min_requests occurrences since until search route status sort job period at
33
+ hashed_sql fingerprint path controller_action path_prefix revision
34
+ ].freeze
35
+ INTEGER_PARAMS = %i[limit offset min_requests occurrences].freeze
36
+
37
+ # Past any real table, and inside a 64-bit integer on every adapter.
38
+ MAX_INTEGER = 1_000_000_000
39
+
40
+ # A date, optionally a time, optionally a zone. Time.parse would also
41
+ # take "10" as the 10th of this month, which is not a window anyone
42
+ # asked for.
43
+ ISO8601 = /\A\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?(?:Z|[+-]\d{2}:?\d{2})?\z/i
44
+
45
+ private
46
+
47
+ def authenticate_api_token!
48
+ token = RailsPulse.configuration.api_token.to_s
49
+ provided = request.headers["X-Rails-Pulse-Token"].to_s
50
+ return if token.present? && ActiveSupport::SecurityUtils.secure_compare(provided, token)
51
+
52
+ render json: { error: "Unauthorized" }, status: :unauthorized
53
+ end
54
+
55
+ # The schema report picks its format from the request; API callers
56
+ # rarely send an Accept header, so answer JSON regardless.
57
+ def require_current_schema!
58
+ request.format = :json
59
+ super
60
+ end
61
+
62
+ def validate_params!
63
+ SCALAR_PARAMS.each do |name|
64
+ value = params[name]
65
+ next if value.nil? || value.is_a?(String)
66
+
67
+ return render_bad_request("'#{name}' must be a single value")
68
+ end
69
+
70
+ INTEGER_PARAMS.each do |name|
71
+ value = params[name]
72
+ next if value.blank? || value.match?(/\A\d+\z/)
73
+
74
+ return render_bad_request("'#{name}' must be a whole number")
75
+ end
76
+
77
+ @since_time = parse_time_param(:since)
78
+ return if performed?
79
+ @until_time = parse_time_param(:until)
80
+ return if performed?
81
+
82
+ if @since_time && @until_time && @until_time <= @since_time
83
+ render_bad_request("'until' must be later than 'since'")
84
+ end
85
+ end
86
+
87
+ def render_bad_request(message)
88
+ render json: { error: message }, status: :bad_request
89
+ end
90
+
91
+ def limit
92
+ integer_param(:limit, 25, 1..500)
93
+ end
94
+
95
+ def offset
96
+ integer_param(:offset, 0, 0..MAX_INTEGER)
97
+ end
98
+
99
+ def integer_param(name, default, range)
100
+ params[name].blank? ? default : params[name].to_i.clamp(range)
101
+ end
102
+
103
+ # A time with no zone is read as UTC, so a window means the same thing
104
+ # wherever the caller and the server are.
105
+ def parse_time_param(name)
106
+ value = params[name]
107
+ return if value.blank?
108
+
109
+ string = value.strip
110
+ raise ArgumentError unless string.match?(ISO8601)
111
+
112
+ ActiveSupport::TimeZone["UTC"].parse(string)
113
+ rescue ArgumentError
114
+ render_bad_request(
115
+ "Invalid time format for '#{name}'. Use ISO 8601, such as 2026-09-24T12:00:00Z; " \
116
+ "a time with no zone is read as UTC."
117
+ )
118
+ end
119
+
120
+ # Parsed and checked by validate_params! before the action runs.
121
+ def time_range
122
+ [ @since_time, @until_time ]
123
+ end
124
+
125
+ def paginated(collection)
126
+ total = collection.count
127
+ data = collection.limit(limit).offset(offset)
128
+ [ data, { total: total, limit: limit, offset: offset } ]
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,27 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ # Which installation answered, so a caller can tell one deployment's
5
+ # numbers from another's before it starts reading them.
6
+ class CapabilitiesController < BaseController
7
+ def show
8
+ render json: {
9
+ rails_pulse_version: RailsPulse::VERSION,
10
+ environment: Rails.env.to_s,
11
+ # Names the installation a response came from, so two profiles
12
+ # pointed at staging and production cannot be confused.
13
+ application: application_name
14
+ }
15
+ end
16
+
17
+ private
18
+
19
+ def application_name
20
+ Rails.application.class.module_parent_name
21
+ rescue StandardError
22
+ nil
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end