rails_pulse 0.5.0.pre.1 → 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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +1 -1
  4. data/app/controllers/rails_pulse/api/v1/base_controller.rb +4 -1
  5. data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +17 -1
  6. data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +1 -0
  7. data/app/controllers/rails_pulse/api/v1/insights_controller.rb +62 -0
  8. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +10 -1
  9. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +17 -1
  10. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +6 -1
  11. data/app/controllers/rails_pulse/dashboard_controller.rb +7 -3
  12. data/app/jobs/rails_pulse/cloud_health_job.rb +11 -0
  13. data/app/jobs/rails_pulse/cloud_sync_job.rb +11 -0
  14. data/app/jobs/rails_pulse/summary_job.rb +13 -0
  15. data/app/models/rails_pulse/charts/base.rb +3 -12
  16. data/app/models/rails_pulse/charts/percentile_chart_base.rb +1 -2
  17. data/app/models/rails_pulse/cleanup_run.rb +32 -0
  18. data/app/models/rails_pulse/cloud/buffered_batch.rb +55 -0
  19. data/app/models/rails_pulse/cloud/installation.rb +55 -0
  20. data/app/models/rails_pulse/dashboard/concerns/attention_classification.rb +104 -0
  21. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +24 -0
  22. data/app/models/rails_pulse/dashboard/health_summary.rb +9 -11
  23. data/app/models/rails_pulse/dashboard/needs_attention.rb +30 -96
  24. data/app/models/rails_pulse/dashboard/storage_pressure.rb +7 -61
  25. data/app/models/rails_pulse/dashboard/storage_status.rb +94 -39
  26. data/app/models/rails_pulse/event.rb +3 -2
  27. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +1 -3
  28. data/app/models/rails_pulse/jobs/charts/duration.rb +1 -2
  29. data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
  30. data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -2
  31. data/app/models/rails_pulse/like_pattern.rb +6 -0
  32. data/app/models/rails_pulse/queries/charts/database_load.rb +1 -3
  33. data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
  34. data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
  35. data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
  36. data/app/models/rails_pulse/time_window.rb +15 -0
  37. data/app/models/rails_pulse/writer_heartbeat.rb +6 -2
  38. data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +5 -2
  39. data/app/serializers/rails_pulse/api/v1/request_serializer.rb +1 -0
  40. data/app/services/rails_pulse/cloud/batch.rb +83 -0
  41. data/app/services/rails_pulse/cloud/client.rb +68 -0
  42. data/app/services/rails_pulse/cloud/combined_metrics.rb +49 -0
  43. data/app/services/rails_pulse/cloud/health_item.rb +77 -0
  44. data/app/services/rails_pulse/cloud/path_prefix.rb +50 -0
  45. data/app/services/rails_pulse/cloud/preview.rb +83 -0
  46. data/app/services/rails_pulse/cloud/query_shape.rb +195 -0
  47. data/app/services/rails_pulse/cloud/record_items.rb +60 -0
  48. data/app/services/rails_pulse/cloud/route_pattern.rb +111 -0
  49. data/app/services/rails_pulse/cloud/summary_items.rb +188 -0
  50. data/app/services/rails_pulse/cloud/sync.rb +275 -0
  51. data/app/services/rails_pulse/config_recommendations.rb +132 -0
  52. data/app/services/rails_pulse/deployment_comparison.rb +177 -0
  53. data/app/services/rails_pulse/operations/change_point.rb +1 -1
  54. data/app/services/rails_pulse/period_insights.rb +131 -0
  55. data/app/services/rails_pulse/sql_query_normalizer.rb +222 -122
  56. data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +13 -3
  57. data/app/views/rails_pulse/storage/show.html.erb +2 -2
  58. data/config/routes.rb +4 -0
  59. data/db/rails_pulse_cloud_schema.rb +58 -0
  60. data/lib/generators/rails_pulse/base_methods.rb +18 -0
  61. data/lib/generators/rails_pulse/install_cloud_generator.rb +84 -0
  62. data/lib/generators/rails_pulse/templates/db/rails_pulse_cloud_schema.rb +58 -0
  63. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_cloud_tables.rb +30 -0
  64. data/lib/generators/rails_pulse/templates/rails_pulse.rb +35 -6
  65. data/lib/generators/rails_pulse/upgrade_generator.rb +9 -16
  66. data/lib/rails_pulse/cleanup_service.rb +11 -0
  67. data/lib/rails_pulse/cli/agent_files/agents.md +7 -5
  68. data/lib/rails_pulse/cli/agent_files/claude_skill.md +20 -3
  69. data/lib/rails_pulse/cli/deployments.rb +16 -2
  70. data/lib/rails_pulse/cli/exceptions.rb +5 -0
  71. data/lib/rails_pulse/cli/insights.rb +70 -0
  72. data/lib/rails_pulse/cli/main.rb +4 -0
  73. data/lib/rails_pulse/cli/queries.rb +5 -0
  74. data/lib/rails_pulse/cli/requests.rb +9 -1
  75. data/lib/rails_pulse/cli/routes.rb +7 -0
  76. data/lib/rails_pulse/configuration.rb +77 -1
  77. data/lib/rails_pulse/installers/config_updater.rb +9 -2
  78. data/lib/rails_pulse/mcp/server.rb +3 -1
  79. data/lib/rails_pulse/mcp/tools/deployments.rb +47 -10
  80. data/lib/rails_pulse/mcp/tools/errors.rb +11 -2
  81. data/lib/rails_pulse/mcp/tools/exceptions.rb +16 -2
  82. data/lib/rails_pulse/mcp/tools/insights.rb +99 -0
  83. data/lib/rails_pulse/mcp/tools/queries.rb +7 -1
  84. data/lib/rails_pulse/mcp/tools/routes.rb +12 -1
  85. data/lib/rails_pulse/tasks/status_reporter.rb +175 -0
  86. data/lib/rails_pulse/tracker.rb +2 -1
  87. data/lib/rails_pulse/version.rb +1 -1
  88. data/lib/tasks/rails_pulse.rake +6 -0
  89. data/lib/tasks/rails_pulse_tasks.rake +7 -0
  90. metadata +30 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a494a9f4ff4c1f388ed3f87f6911cfae02b9efb329673f1b697b8af5c589f4a
4
- data.tar.gz: bd9fc701eccf980bca1c8aa1269dc5549fa3882f905c8691cb9fb70cc842b399
3
+ metadata.gz: 208274704510167233904a19653cfe5bffcf6f29080344d088e9cbee8c425a87
4
+ data.tar.gz: fbbe9e3a6c35a09236a81158d47026a9ab913f1db6e79800b8c71183df4d9d63
5
5
  SHA512:
6
- metadata.gz: 642d5edc8eb02e9b7d4b5e393995ebd84782f2068eef36e075820ce9fed47db44ec3ce8bce4fec076e9a4d2b055a95b7c390309ab83dc51a3c13f2c4babc8608
7
- data.tar.gz: adcc2d2f7ce7176cbadd781abe14cc13df14002a4ba5e48f178c2853074118a0996d0c09e752077b65cf5c4eab77387f49a5ffff5b45b08422a460fab7a4f95a
6
+ metadata.gz: 83b22f6771f0c1a99bada73bd60e1af0abb7a7931b5523afa9c1a852c85bf7664d4af32ec025d955ff6db47dfde7c3b2563fee86301d2a7032b0a6f7f1615543
7
+ data.tar.gz: '08fd08858f4eafa689a17e92c922623f15f11ff9663de193b3f99830faa59a171bef0a7c5357dbb7bcd2df9afd1ab97f7f6c3ff52ca4cb0f67d7835f11c99730'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
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
+
10
35
  ## [0.5.0.pre.1] - 2026-10-03
11
36
 
12
37
  ### Added
data/README.md CHANGED
@@ -120,7 +120,7 @@ rails-pulse routes list --since 2026-06-01T00:00:00Z
120
120
  rails-pulse install claude # Claude Code skill: when and how to use the tools
121
121
  ```
122
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 ten 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 what has actually been recorded. Nothing the agent can call changes production. [Agent tooling](https://railspulse.com/documentation/mcp)
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)
124
124
 
125
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)
126
126
 
@@ -28,7 +28,10 @@ module RailsPulse
28
28
 
29
29
  # Parameters every endpoint reads as one string. A repeated or nested
30
30
  # one (`search[]=x`) is refused rather than reaching a String method.
31
- SCALAR_PARAMS = %i[limit offset min_requests occurrences since until search route status sort job].freeze
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
32
35
  INTEGER_PARAMS = %i[limit offset min_requests occurrences].freeze
33
36
 
34
37
  # Past any real table, and inside a 64-bit integer on every adapter.
@@ -10,14 +10,30 @@ module RailsPulse
10
10
  collection = RailsPulse::Deployment.recent.order(id: :desc)
11
11
  collection = collection.where(started_at: since_start..) if since_start
12
12
  collection = collection.where(started_at: ..until_end) if until_end
13
+ collection = apply_revision_filter(collection)
13
14
 
14
15
  data, meta = paginated(collection)
16
+ deployments = data.to_a
17
+ comparisons = RailsPulse::DeploymentComparison.for(deployments)
15
18
 
16
19
  render json: {
17
- data: data.map { |deployment| DeploymentSerializer.serialize(deployment) },
20
+ data: deployments.map { |deployment| DeploymentSerializer.serialize(deployment, comparison: comparisons[deployment.id]) },
18
21
  meta: meta
19
22
  }
20
23
  end
24
+
25
+ private
26
+
27
+ # The full revision or the start of one, so the short SHA a person
28
+ # reads from the CLI finds the same deployment as the full SHA.
29
+ def apply_revision_filter(scope)
30
+ return scope unless params[:revision].present?
31
+
32
+ scope.where(
33
+ "revision LIKE :prefix #{RailsPulse::LikePattern::CLAUSE}",
34
+ prefix: RailsPulse::LikePattern.starting_with(params[:revision])
35
+ )
36
+ end
21
37
  end
22
38
  end
23
39
  end
@@ -33,6 +33,7 @@ module RailsPulse
33
33
 
34
34
  collection = RailsPulse::ExceptionGroup.order(sort => :desc, id: :desc)
35
35
  collection = collection.where(status: status) if status
36
+ collection = collection.where(fingerprint: params[:fingerprint]) if params[:fingerprint].present?
36
37
  collection = collection.where(last_seen_at: since_start..) if since_start
37
38
  collection = collection.where(last_seen_at: ..until_end) if until_end
38
39
  collection = apply_search(collection)
@@ -0,0 +1,62 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ # What needs attention over one summary period, and whether the
5
+ # configured route and query thresholds fit what that period recorded.
6
+ # One period rather than a since/until window because a P95 cannot be
7
+ # combined across periods: an hour, day, week or month is read from its
8
+ # own summary rows.
9
+ class InsightsController < BaseController
10
+ PERIOD_TYPES = RailsPulse::Summary::PERIOD_TYPES
11
+ DEFAULT_PERIOD_TYPE = "week".freeze
12
+ STEPS = { "hour" => 1.hour, "day" => 1.day, "week" => 1.week, "month" => 1.month }.freeze
13
+
14
+ def show
15
+ period_type = params[:period].presence || DEFAULT_PERIOD_TYPE
16
+ unless PERIOD_TYPES.include?(period_type)
17
+ return render_bad_request("Unknown period '#{period_type}'. Use one of: #{PERIOD_TYPES.join(', ')}.")
18
+ end
19
+
20
+ at = parse_time_param(:at)
21
+ return if performed?
22
+
23
+ period_start = period_start_for(period_type, at)
24
+ period_end = RailsPulse::Summary.calculate_period_end(period_type, period_start)
25
+
26
+ render json: {
27
+ period: {
28
+ type: period_type,
29
+ start: period_start,
30
+ end: period_end,
31
+ summarized: summarized?(period_type, period_start)
32
+ },
33
+ thresholds: {
34
+ routes: RailsPulse.configuration.route_thresholds,
35
+ queries: RailsPulse.configuration.query_thresholds,
36
+ jobs: RailsPulse.configuration.job_thresholds
37
+ },
38
+ needs_attention: RailsPulse::PeriodInsights.new(period_type: period_type, period_start: period_start).to_insights_data,
39
+ threshold_recommendations: RailsPulse::ConfigRecommendations
40
+ .for_period(period_type: period_type, period_start: period_start)
41
+ .to_recommendations
42
+ }
43
+ end
44
+
45
+ private
46
+
47
+ # The period containing `at`, or the latest one that has ended: the
48
+ # current period has no summary until it is over.
49
+ def period_start_for(period_type, at)
50
+ time = at ? at.in_time_zone : Time.current - STEPS[period_type]
51
+ RailsPulse::Summary.normalize_period_start(period_type, time)
52
+ end
53
+
54
+ # SummaryJob writes the overall request row for every period it
55
+ # summarizes, even an empty one.
56
+ def summarized?(period_type, period_start)
57
+ RailsPulse::Summary.overall_requests.exists?(period_type: period_type, period_start: period_start)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -20,16 +20,25 @@ module RailsPulse
20
20
  since_start ||= 24.hours.ago if until_end.nil?
21
21
  render_with_stats(since_start..until_end, sort || "total_duration")
22
22
  else
23
- data, meta = paginated(RailsPulse::Query.all.order(:id))
23
+ data, meta = paginated(queries_scope.order(:id))
24
24
  render json: { data: data.map { |query| QuerySerializer.serialize(query) }, meta: meta }
25
25
  end
26
26
  end
27
27
 
28
28
  private
29
29
 
30
+ # `hashed_sql` is the key Rails Pulse Cloud holds for a query, so an
31
+ # exact match on it is how a caller gets from Cloud back to the SQL.
32
+ def queries_scope
33
+ scope = RailsPulse::Query.all
34
+ scope = scope.where(hashed_sql: params[:hashed_sql]) if params[:hashed_sql].present?
35
+ scope
36
+ end
37
+
30
38
  def render_with_stats(range, sort)
31
39
  base = RailsPulse::Operation.where.not(query_id: nil).where(occurred_at: range)
32
40
  base = apply_route_filter(base)
41
+ base = base.where(query_id: queries_scope.select(:id)) if params[:hashed_sql].present?
33
42
  total = base.distinct.count(:query_id)
34
43
 
35
44
  rows = base
@@ -6,7 +6,7 @@ module RailsPulse
6
6
  STATUS_CODE = /\A\d{3}\z/
7
7
 
8
8
  def index
9
- collection = RailsPulse::Request.all.order(occurred_at: :desc, id: :desc)
9
+ collection = RailsPulse::Request.preload(:route).order(occurred_at: :desc, id: :desc)
10
10
 
11
11
  parsed_range = time_range
12
12
  return unless parsed_range
@@ -15,6 +15,7 @@ module RailsPulse
15
15
  collection = collection.where(occurred_at: since_start..) if since_start
16
16
  collection = collection.where(occurred_at: ..until_end) if until_end
17
17
  collection = apply_route_filter(collection)
18
+ collection = apply_path_prefix_filter(collection)
18
19
  collection = apply_status_filter(collection)
19
20
  return if performed?
20
21
 
@@ -38,6 +39,21 @@ module RailsPulse
38
39
  )
39
40
  end
40
41
 
42
+ # Requests whose route path starts with the prefix. A request the
43
+ # router did not recognise is stored under its raw path, so this is how
44
+ # a caller finds the paths behind a burst of 404s. A trailing `*` is
45
+ # dropped, so a grouped prefix such as `/wp-admin/*` can be passed as
46
+ # it is; `/*` and `*` therefore match every path.
47
+ def apply_path_prefix_filter(scope)
48
+ return scope unless params[:path_prefix].present?
49
+
50
+ prefix = params[:path_prefix].to_s.delete_suffix("*")
51
+ scope.joins(:route).where(
52
+ "rails_pulse_routes.path LIKE :prefix #{RailsPulse::LikePattern::CLAUSE}",
53
+ prefix: RailsPulse::LikePattern.starting_with(prefix)
54
+ )
55
+ end
56
+
41
57
  def apply_status_filter(scope)
42
58
  return scope unless params[:status].present?
43
59
 
@@ -15,6 +15,11 @@ module RailsPulse
15
15
  end
16
16
 
17
17
  scope = RailsPulse::Route.all
18
+ # Exact matches, unlike `search`: a route is identified by its path
19
+ # and controller action together, and a substring would also return
20
+ # `/orders/:id/items` for `/orders/:id`.
21
+ scope = scope.where(path: params[:path]) if params[:path].present?
22
+ scope = scope.where(controller_action: params[:controller_action]) if params[:controller_action].present?
18
23
  if params[:search].present?
19
24
  term = RailsPulse::LikePattern.containing(params[:search].downcase)
20
25
  scope = scope.where(
@@ -37,7 +42,7 @@ module RailsPulse
37
42
 
38
43
  def render_with_stats(scope, range, sort)
39
44
  base = RailsPulse::Request.where(occurred_at: range)
40
- base = base.where(route_id: scope.select(:id)) if params[:search].present?
45
+ base = base.where(route_id: scope.select(:id)) if %i[search path controller_action].any? { |name| params[name].present? }
41
46
 
42
47
  # HAVING rather than a filter over the fetched page: a route busy
43
48
  # enough to qualify can rank below the limit, and dropping it after
@@ -31,13 +31,17 @@ module RailsPulse
31
31
  @response_time_percentiles_chart_data = RailsPulse::Dashboard::Charts::ResponseTimePercentiles.new(**card_and_chart_options).to_chart_data
32
32
  @throughput_and_errors_chart_data = RailsPulse::Dashboard::Charts::ThroughputAndErrors.new(**card_and_chart_options).to_chart_data
33
33
 
34
+ # One StoragePressure serves the whole page: its items cost several
35
+ # aggregate queries, and three panels below ask for them.
36
+ storage_pressure = RailsPulse::Dashboard::StoragePressure.new
37
+
34
38
  # Needs Attention panel
35
- @needs_attention = RailsPulse::Dashboard::NeedsAttention.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: period, window: window).to_attention_data
39
+ @needs_attention = RailsPulse::Dashboard::NeedsAttention.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: period, period_type: period_type, window: window, storage_pressure: storage_pressure).to_attention_data
36
40
 
37
41
  # System Health bar
38
- @health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: period, window: window).to_health_data
42
+ @health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: period, period_type: period_type, window: window, storage_pressure: storage_pressure).to_health_data
39
43
 
40
- @storage_status = RailsPulse::Dashboard::StorageStatus.new(cached_sizes: true)
44
+ @storage_status = RailsPulse::Dashboard::StorageStatus.new(estimated: true, storage_pressure: storage_pressure)
41
45
 
42
46
  # Deployments panel — scoped to the same window as the chart markers so
43
47
  # the panel and the markers drawn on the charts always agree.
@@ -0,0 +1,11 @@
1
+ module RailsPulse
2
+ # Sends the health update for the minute just ended to Rails Pulse Cloud,
3
+ # deployments recorded since the last run, and any buffered batches whose
4
+ # retry is due. Schedule it every minute alongside SummaryJob. Does nothing
5
+ # unless config.cloud is set.
6
+ class CloudHealthJob < ApplicationJob
7
+ def perform
8
+ Cloud::Sync.new.minutely
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module RailsPulse
2
+ # Sends newly summarized hours, changed exception groups and deployments to
3
+ # Rails Pulse Cloud. SummaryJob enqueues it once its summaries are written,
4
+ # so it needs no schedule of its own. Does nothing unless config.cloud is
5
+ # set.
6
+ class CloudSyncJob < ApplicationJob
7
+ def perform
8
+ Cloud::Sync.new.hourly
9
+ end
10
+ end
11
+ end
@@ -19,6 +19,8 @@ module RailsPulse
19
19
  # Check if we should run monthly summary (first day of month)
20
20
  process_monthly_summary((target_hour.to_date - 1.month).beginning_of_month) if first_of_month?(target_hour)
21
21
  end
22
+
23
+ enqueue_cloud_sync
22
24
  rescue StandardError => e
23
25
  log_error(e)
24
26
  raise
@@ -26,6 +28,17 @@ module RailsPulse
26
28
 
27
29
  private
28
30
 
31
+ # Cloud receives an hour only once it is summarized, so the sync follows
32
+ # this job rather than running on a schedule of its own. A queue that
33
+ # refuses the job must not fail the summaries already written.
34
+ def enqueue_cloud_sync
35
+ return unless RailsPulse.configuration.cloud.enabled?
36
+
37
+ CloudSyncJob.perform_later
38
+ rescue StandardError => e
39
+ RailsPulse.logger.error "Could not enqueue the Rails Pulse Cloud sync: #{e.class} - #{e.message}"
40
+ end
41
+
29
42
  def midnight?(time)
30
43
  time.hour == 0
31
44
  end
@@ -24,23 +24,14 @@ module RailsPulse
24
24
  .then { |q| @subject ? q.where(summarizable_id: @subject.id) : q }
25
25
  end
26
26
 
27
- # Common helper for time step calculation
28
- def time_step
29
- @period_type.to_s == "hour" ? 3600 : 86400
30
- end
31
-
32
27
  # Abstract method - must be implemented by subclasses
33
28
  def summarizable_type
34
29
  raise NotImplementedError, "#{self.class} must implement summarizable_type"
35
30
  end
36
31
 
37
- # Common helper for padding missing data points with zeros
38
- def pad_data_with_zeros(raw_data, start_time, end_time, step)
39
- {}.tap do |padded|
40
- (start_time.to_i..end_time.to_i).step(step) do |timestamp|
41
- padded[timestamp] = raw_data[timestamp] || 0
42
- end
43
- end
32
+ # Every bucket in the window, with zero where there is no data.
33
+ def pad_data_with_zeros(raw_data)
34
+ @window.bucket_timestamps(@period_type).index_with { |timestamp| raw_data[timestamp] || 0 }
44
35
  end
45
36
  end
46
37
  end
@@ -47,9 +47,8 @@ module RailsPulse
47
47
  end
48
48
 
49
49
  # Convert to final values (weighted averages) and pad missing data
50
- step = @period_type.to_s == "hour" ? 3600 : 86400
51
50
  daily_data = {}
52
- (@window.start_time.to_i..@window.end_time.to_i).step(step) do |timestamp|
51
+ @window.bucket_timestamps(@period_type).each do |timestamp|
53
52
  if raw_data[timestamp]
54
53
  count = raw_data[timestamp][:total_count]
55
54
  daily_data[timestamp] = {
@@ -0,0 +1,32 @@
1
+ module RailsPulse
2
+ # One CleanupService run, stored as an Event of kind cleanup_run: `value`
3
+ # is the rows it deleted, `outcome` completed or failed, and `metadata` the
4
+ # per-table counts and any stages that failed. rails_pulse:status reads the
5
+ # latest to say whether cleanup is running at all; nothing else depends on
6
+ # these rows, so event retention prunes them like any other kind.
7
+ class CleanupRun
8
+ KIND = "cleanup_run".freeze
9
+ SUBJECT = "cleanup".freeze
10
+
11
+ class << self
12
+ def events
13
+ RailsPulse::Event.of_kind(KIND)
14
+ end
15
+
16
+ def record!(stats:, failed_stages: [], ran_at: Time.current)
17
+ RailsPulse::Event.create!(
18
+ kind: KIND,
19
+ subject: SUBJECT,
20
+ outcome: failed_stages.any? ? "failed" : "completed",
21
+ value: stats[:total_deleted].to_i,
22
+ occurred_at: ran_at,
23
+ metadata: { time_based: stats[:time_based], count_based: stats[:count_based], failed_stages: failed_stages }.to_json
24
+ )
25
+ end
26
+
27
+ def latest
28
+ events.recent.first
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ require "zlib"
2
+
3
+ module RailsPulse
4
+ module Cloud
5
+ # A batch waiting to be sent to Rails Pulse Cloud, stored gzipped. It
6
+ # keeps its batch_id across retries, so Cloud recognises a resend. The
7
+ # buffer holds at most MAX_AGE or MAX_BYTES of batches, dropping the
8
+ # oldest first, so an outage cannot grow it without bound.
9
+ class BufferedBatch < RailsPulse::ApplicationRecord
10
+ self.table_name = "rails_pulse_cloud_batches"
11
+
12
+ MAX_AGE = 7.days
13
+ MAX_BYTES = 50.megabytes
14
+
15
+ validates :batch_id, :payload, :byte_size, :item_count, :next_attempt_at, presence: true
16
+
17
+ scope :due, ->(now = Time.current) { where(next_attempt_at: ..now).order(:created_at, :id) }
18
+
19
+ def self.ransackable_attributes(_auth_object = nil)
20
+ %w[batch_id byte_size item_count attempts next_attempt_at created_at]
21
+ end
22
+
23
+ def self.ransackable_associations(_auth_object = nil)
24
+ []
25
+ end
26
+
27
+ def self.enqueue!(batch, now: Time.current)
28
+ payload = Zlib.gzip(batch.to_json)
29
+ create!(batch_id: batch.batch_id, payload: payload, byte_size: payload.bytesize, item_count: batch.items.size,
30
+ next_attempt_at: now)
31
+ end
32
+
33
+ # Drops batches past MAX_AGE, then the oldest until the rest fit in
34
+ # MAX_BYTES. Returns how many were dropped.
35
+ def self.prune!(now = Time.current)
36
+ dropped = where(created_at: ...(now - MAX_AGE)).delete_all
37
+ total = sum(:byte_size)
38
+ return dropped if total <= MAX_BYTES
39
+
40
+ order(:created_at, :id).pluck(:id, :byte_size).each do |id, size|
41
+ break if total <= MAX_BYTES
42
+
43
+ dropped += where(id: id).delete_all
44
+ total -= size
45
+ end
46
+ dropped
47
+ end
48
+
49
+ # The stored batch as a hash with string keys.
50
+ def contents
51
+ JSON.parse(Zlib.gunzip(payload))
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,55 @@
1
+ module RailsPulse
2
+ module Cloud
3
+ # The one row naming this Rails Pulse database to Rails Pulse Cloud, and
4
+ # where its sync has got to. Hosts that share the database share the row,
5
+ # so their data is counted once; hosts with separate databases are
6
+ # separate installations, and Cloud adds them together.
7
+ class Installation < RailsPulse::ApplicationRecord
8
+ self.table_name = "rails_pulse_cloud_installations"
9
+
10
+ validates :installation_id, presence: true
11
+
12
+ def self.ransackable_attributes(_auth_object = nil)
13
+ %w[installation_id last_success_at last_error_at paused_until created_at]
14
+ end
15
+
16
+ def self.ransackable_associations(_auth_object = nil)
17
+ []
18
+ end
19
+
20
+ # The row, created with a new installation ID the first time it is
21
+ # asked for. A second process creating it at the same moment loses on
22
+ # the unique index and reads the winner's.
23
+ def self.current
24
+ first || create!(installation_id: SecureRandom.uuid)
25
+ rescue ActiveRecord::RecordNotUnique
26
+ first!
27
+ end
28
+
29
+ # The row if one exists, without creating it.
30
+ def self.existing
31
+ first
32
+ rescue ActiveRecord::ActiveRecordError
33
+ nil
34
+ end
35
+
36
+ def paused?(now = Time.current)
37
+ paused_until.present? && paused_until > now
38
+ end
39
+
40
+ def record_success!(deprecated_on: nil, now: Time.current)
41
+ update!(last_success_at: now, last_error: nil, last_error_at: nil, paused_until: nil, pause_reason: nil,
42
+ contract_deprecated_on: deprecated_on)
43
+ end
44
+
45
+ def record_error!(message, now: Time.current)
46
+ update!(last_error: message.to_s.truncate(2_000), last_error_at: now)
47
+ end
48
+
49
+ def pause!(until_time, reason, now: Time.current)
50
+ update!(paused_until: until_time, pause_reason: reason.to_s.truncate(2_000),
51
+ last_error: reason.to_s.truncate(2_000), last_error_at: now)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,104 @@
1
+ module RailsPulse
2
+ module Dashboard
3
+ module Concerns
4
+ # How a route, query or job earns a place in a needs-attention list,
5
+ # shared by the dashboard panel (NeedsAttention, over the selected
6
+ # window) and the API's PeriodInsights (over one summary period) so the
7
+ # two never disagree about what is critical.
8
+ #
9
+ # Each classify method returns nil for something healthy, otherwise
10
+ # [severity, reason, metric, metric_sub, sort_score]. The includer sets
11
+ # @route_thresholds, @query_thresholds and @job_thresholds.
12
+ module AttentionClassification
13
+ include ThresholdConstants
14
+
15
+ private
16
+
17
+ # `span` finishes the error-rate reason: "this week", "this period".
18
+ def classify_route(p95, total, errors, error_rate, span)
19
+ if p95 >= @route_thresholds[:critical] || error_rate >= CRITICAL_ERROR_RATE
20
+ if error_rate >= CRITICAL_ERROR_RATE
21
+ [ :critical,
22
+ "#{error_rate}% error rate · #{total} requests #{span}",
23
+ "#{errors} errors",
24
+ "P95 #{p95.round(0).to_i}ms",
25
+ errors * total.to_f ]
26
+ else
27
+ [ :critical,
28
+ "#{p95.round(0).to_i}ms P95 · exceeds #{@route_thresholds[:critical]}ms threshold",
29
+ "#{p95.round(0).to_i}ms P95",
30
+ "#{total} requests",
31
+ p95 ]
32
+ end
33
+ elsif p95 >= @route_thresholds[:slow] || error_rate >= WARNING_ERROR_RATE
34
+ if error_rate >= WARNING_ERROR_RATE && p95 < @route_thresholds[:slow]
35
+ [ :warning,
36
+ "#{error_rate}% error rate · #{total} requests #{span}",
37
+ "#{errors} errors",
38
+ "P95 #{p95.round(0).to_i}ms",
39
+ errors * total.to_f ]
40
+ else
41
+ [ :warning,
42
+ "#{p95.round(0).to_i}ms P95 · #{error_rate > 0 ? "#{error_rate}% error rate" : "above slow threshold"}",
43
+ "#{p95.round(0).to_i}ms P95",
44
+ "#{total} requests",
45
+ p95 ]
46
+ end
47
+ end
48
+ end
49
+
50
+ def classify_query(p95, count)
51
+ if p95 >= @query_thresholds[:critical]
52
+ severity = :critical
53
+ reason = "#{p95.round(0).to_i}ms P95 · exceeds #{@query_thresholds[:critical]}ms threshold"
54
+ elsif p95 >= @query_thresholds[:slow]
55
+ severity = :warning
56
+ reason = "#{p95.round(0).to_i}ms P95 · above #{@query_thresholds[:slow]}ms slow threshold"
57
+ else
58
+ return
59
+ end
60
+
61
+ [ severity, reason, "#{p95.round(0).to_i}ms P95", "#{count} execution#{count == 1 ? "" : "s"}", p95 ]
62
+ end
63
+
64
+ def classify_job(failure_rate, p95, runs, failures, queue)
65
+ if failure_rate >= CRITICAL_JOB_FAILURE_RATE || p95 >= @job_thresholds[:critical]
66
+ if failure_rate >= CRITICAL_JOB_FAILURE_RATE
67
+ [ :critical,
68
+ "#{queue} queue · #{failure_rate}% failure rate",
69
+ "#{failures} / #{runs} failed",
70
+ "P95 #{p95.round(0).to_i}ms",
71
+ failures * runs.to_f ]
72
+ else
73
+ [ :critical,
74
+ "P95 #{p95.round(0).to_i}ms · exceeds #{@job_thresholds[:critical]}ms threshold",
75
+ "#{p95.round(0).to_i}ms P95",
76
+ "#{runs} runs",
77
+ p95 ]
78
+ end
79
+ elsif failure_rate >= WARNING_JOB_FAILURE_RATE || p95 >= @job_thresholds[:slow]
80
+ if failure_rate >= WARNING_JOB_FAILURE_RATE
81
+ [ :warning,
82
+ "#{queue} queue · #{failure_rate}% failure rate",
83
+ "#{failures} / #{runs} failed",
84
+ "P95 #{p95.round(0).to_i}ms",
85
+ failures * runs.to_f ]
86
+ else
87
+ [ :warning,
88
+ "P95 #{p95.round(0).to_i}ms · above slow threshold",
89
+ "#{p95.round(0).to_i}ms P95",
90
+ "#{runs} runs",
91
+ p95 ]
92
+ end
93
+ end
94
+ end
95
+
96
+ def truncate_sql(sql)
97
+ return "" if sql.blank?
98
+ cleaned = sql.gsub(/\s+/, " ").strip
99
+ cleaned.length > 80 ? "#{cleaned[0..79]}..." : cleaned
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end