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
@@ -2,13 +2,12 @@ module RailsPulse
2
2
  module Dashboard
3
3
  module Charts
4
4
  class ThroughputAndErrors
5
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
5
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", window: nil)
6
6
  @disabled_tags = disabled_tags
7
7
  @show_non_tagged = show_non_tagged
8
8
  @period = period
9
9
  @period_type = period_type
10
- @start_time = start_time
11
- @end_time = end_time
10
+ @window = window
12
11
  end
13
12
 
14
13
  def to_chart_data
@@ -125,10 +124,10 @@ module RailsPulse
125
124
 
126
125
  private
127
126
 
128
- # nil unless both start_time and end_time were given, so the default
127
+ # nil unless a window was given, so the default
129
128
  # "recent" view keeps using the trailing-@period-days fallback below.
130
129
  def time_window
131
- @time_window ||= RailsPulse::TimeWindow.build(@start_time, @end_time)
130
+ @window
132
131
  end
133
132
 
134
133
  def default_hour_range
@@ -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
@@ -5,11 +5,34 @@ module RailsPulse
5
5
  private
6
6
 
7
7
  def period_range
8
- window = RailsPulse::TimeWindow.build(@start_time, @end_time)
9
- return [ window.start_time, window.end_time ] if window
8
+ return [ @window.start_time, @window.end_time ] if @window
10
9
 
11
10
  [ @period.days.ago.beginning_of_day, Time.current ]
12
11
  end
12
+
13
+ # Summary rows that cover the period exactly once. The same traffic is
14
+ # summarized per hour, day, week and month, so summing across period
15
+ # types would count it once per granularity. A window of 25 hours or
16
+ # less reads hourly rows. A longer one reads daily rows up to the start
17
+ # of today, or to its own end if that comes first, and hourly rows
18
+ # after today's start, because a day is summarized only once it has
19
+ # ended.
20
+ def period_summaries
21
+ start, finish = period_range
22
+ return RailsPulse::Summary.where(period_type: "hour", period_start: start..finish) if hourly_period?(start, finish)
23
+
24
+ today = Time.zone.now.beginning_of_day
25
+ RailsPulse::Summary.where(period_type: "day", period_start: start...[ today, finish ].min)
26
+ .or(RailsPulse::Summary.where(period_type: "hour", period_start: today..finish))
27
+ end
28
+
29
+ # The dashboard passes the period type TimeRange resolved, so these
30
+ # panels read the same granularity as the cards and charts beside them.
31
+ def hourly_period?(start, finish)
32
+ return @period_type == "hour" if @period_type
33
+
34
+ finish - start <= 25.hours
35
+ end
13
36
  end
14
37
  end
15
38
  end
@@ -4,12 +4,13 @@ module RailsPulse
4
4
  include Concerns::ThresholdConstants
5
5
  include Concerns::TimeRangeHelper
6
6
 
7
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7, start_time: nil, end_time: nil)
7
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: nil, window: nil, storage_pressure: nil)
8
8
  @disabled_tags = disabled_tags
9
9
  @show_non_tagged = show_non_tagged
10
10
  @period = period
11
- @start_time = start_time
12
- @end_time = end_time
11
+ @period_type = period_type
12
+ @window = window
13
+ @storage_pressure = storage_pressure
13
14
  @route_thresholds = RailsPulse.configuration.route_thresholds
14
15
  @query_thresholds = RailsPulse.configuration.query_thresholds
15
16
  @job_thresholds = RailsPulse.configuration.job_thresholds
@@ -22,6 +23,7 @@ module RailsPulse
22
23
  queries: query_counts,
23
24
  jobs: job_counts,
24
25
  exceptions: exception_counts,
26
+ tracking: tracking_counts,
25
27
  storage: storage_counts
26
28
  }
27
29
  end
@@ -29,11 +31,10 @@ module RailsPulse
29
31
  private
30
32
 
31
33
  def route_counts
32
- start, finish = period_range
33
-
34
34
  data = RailsPulse::Summary
35
35
  .with_tag_filters(@disabled_tags, @show_non_tagged)
36
- .where(summarizable_type: "RailsPulse::Route", period_start: start..finish)
36
+ .where(summarizable_type: "RailsPulse::Route")
37
+ .merge(period_summaries)
37
38
  .group("summarizable_id")
38
39
  .select(
39
40
  "summarizable_id",
@@ -62,11 +63,10 @@ module RailsPulse
62
63
  end
63
64
 
64
65
  def query_counts
65
- start, finish = period_range
66
-
67
66
  data = RailsPulse::Summary
68
67
  .with_tag_filters(@disabled_tags, @show_non_tagged)
69
- .where(summarizable_type: "RailsPulse::Query", period_start: start..finish)
68
+ .where(summarizable_type: "RailsPulse::Query")
69
+ .merge(period_summaries)
70
70
  .group("summarizable_id")
71
71
  .select(
72
72
  "summarizable_id",
@@ -90,7 +90,34 @@ module RailsPulse
90
90
  end
91
91
 
92
92
  def storage_counts
93
- StoragePressure.new.storage_counts
93
+ (@storage_pressure || StoragePressure.new).storage_counts
94
+ end
95
+
96
+ # A process that dropped and has since gone away still counts as
97
+ # critical for that hour. Nil until any writer has reported.
98
+ def tracking_counts
99
+ return nil unless RailsPulse::Event.table_available?
100
+
101
+ live = RailsPulse::WriterHeartbeat.live_processes
102
+ dropped_by_process = RailsPulse::WriterHeartbeat.dropped_by_process(window: 1.hour).select { |_, n| n.positive? }
103
+ return nil if live.empty? && dropped_by_process.empty?
104
+
105
+ critical = dropped_by_process.size
106
+ healthy = slow = 0
107
+ live.each do |process|
108
+ next if dropped_by_process.key?(process.process_label)
109
+
110
+ # queue_size <= 0 means unreadable metadata, not an empty queue
111
+ if process.queue_size <= 0
112
+ critical += 1
113
+ elsif process.queue_depth * 2 >= process.queue_size
114
+ slow += 1
115
+ else
116
+ healthy += 1
117
+ end
118
+ end
119
+
120
+ { healthy: healthy, slow: slow, critical: critical }
94
121
  end
95
122
 
96
123
  def job_counts
@@ -125,12 +152,10 @@ module RailsPulse
125
152
  return nil unless RailsPulse.configuration.track_exceptions
126
153
  return nil unless exception_summaries_available?
127
154
 
128
- start, finish = period_range
129
-
130
155
  counts = RailsPulse::Summary
131
156
  .for_exceptions
132
157
  .where.not(summarizable_id: 0)
133
- .where(period_start: start..finish)
158
+ .merge(period_summaries)
134
159
  .group(:summarizable_id)
135
160
  .sum(:count)
136
161
 
@@ -1,15 +1,16 @@
1
1
  module RailsPulse
2
2
  module Dashboard
3
3
  class NeedsAttention
4
- include Concerns::ThresholdConstants
4
+ include Concerns::AttentionClassification
5
5
  include Concerns::TimeRangeHelper
6
6
 
7
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7, start_time: nil, end_time: nil)
7
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: nil, window: nil, storage_pressure: nil)
8
8
  @disabled_tags = disabled_tags
9
9
  @show_non_tagged = show_non_tagged
10
10
  @period = period
11
- @start_time = start_time
12
- @end_time = end_time
11
+ @period_type = period_type
12
+ @window = window
13
+ @storage_pressure = storage_pressure
13
14
  @route_thresholds = RailsPulse.configuration.route_thresholds
14
15
  @query_thresholds = RailsPulse.configuration.query_thresholds
15
16
  @job_thresholds = RailsPulse.configuration.job_thresholds
@@ -41,7 +42,18 @@ module RailsPulse
41
42
  private
42
43
 
43
44
  def storage_pressure_items
44
- StoragePressure.new.pressure_items
45
+ (@storage_pressure || StoragePressure.new).pressure_items
46
+ end
47
+
48
+ # The dashboard window is user-selected, so the error-rate reason names
49
+ # the window's real length rather than assuming a week.
50
+ def span_phrase
51
+ start, finish = period_range
52
+ hours = ((finish - start) / 1.hour).round
53
+ return "in the last #{hours} hour#{"s" unless hours == 1}" if hours <= 25
54
+
55
+ days = (hours / 24.0).round
56
+ "in the last #{days} day#{"s" unless days == 1}"
45
57
  end
46
58
 
47
59
  def url_helpers
@@ -49,12 +61,11 @@ module RailsPulse
49
61
  end
50
62
 
51
63
  def route_items
52
- start, finish = period_range
53
-
54
64
  route_data = RailsPulse::Summary
55
65
  .with_tag_filters(@disabled_tags, @show_non_tagged)
56
66
  .joins("INNER JOIN rails_pulse_routes ON rails_pulse_routes.id = rails_pulse_summaries.summarizable_id")
57
- .where(summarizable_type: "RailsPulse::Route", period_start: start..finish)
67
+ .where(summarizable_type: "RailsPulse::Route")
68
+ .merge(period_summaries)
58
69
  .group("rails_pulse_summaries.summarizable_id, rails_pulse_routes.path")
59
70
  .select(
60
71
  "rails_pulse_summaries.summarizable_id as route_id",
@@ -71,7 +82,7 @@ module RailsPulse
71
82
  errors = record.total_errors.to_i
72
83
  error_rate = total > 0 ? (errors * 100.0 / total).round(1) : 0.0
73
84
 
74
- severity, reason, metric, metric_sub, sort_score = classify_route(p95, total, errors, error_rate)
85
+ severity, reason, metric, metric_sub, sort_score = classify_route(p95, total, errors, error_rate, span_phrase)
75
86
  next unless severity
76
87
 
77
88
  items << {
@@ -89,45 +100,12 @@ module RailsPulse
89
100
  items
90
101
  end
91
102
 
92
- def classify_route(p95, total, errors, error_rate)
93
- if p95 >= @route_thresholds[:critical] || error_rate >= CRITICAL_ERROR_RATE
94
- if error_rate >= CRITICAL_ERROR_RATE
95
- [ :critical,
96
- "#{error_rate}% error rate · #{total} requests this week",
97
- "#{errors} errors",
98
- "P95 #{p95.round(0).to_i}ms",
99
- errors * total.to_f ]
100
- else
101
- [ :critical,
102
- "#{p95.round(0).to_i}ms P95 · exceeds #{@route_thresholds[:critical]}ms threshold",
103
- "#{p95.round(0).to_i}ms P95",
104
- "#{total} requests",
105
- p95 ]
106
- end
107
- elsif p95 >= @route_thresholds[:slow] || error_rate >= WARNING_ERROR_RATE
108
- if error_rate >= WARNING_ERROR_RATE && p95 < @route_thresholds[:slow]
109
- [ :warning,
110
- "#{error_rate}% error rate · #{total} requests this week",
111
- "#{errors} errors",
112
- "P95 #{p95.round(0).to_i}ms",
113
- errors * total.to_f ]
114
- else
115
- [ :warning,
116
- "#{p95.round(0).to_i}ms P95 · #{error_rate > 0 ? "#{error_rate}% error rate" : "above slow threshold"}",
117
- "#{p95.round(0).to_i}ms P95",
118
- "#{total} requests",
119
- p95 ]
120
- end
121
- end
122
- end
123
-
124
103
  def query_items
125
- start, finish = period_range
126
-
127
104
  query_data = RailsPulse::Summary
128
105
  .with_tag_filters(@disabled_tags, @show_non_tagged)
129
106
  .joins("INNER JOIN rails_pulse_queries ON rails_pulse_queries.id = rails_pulse_summaries.summarizable_id")
130
- .where(summarizable_type: "RailsPulse::Query", period_start: start..finish)
107
+ .where(summarizable_type: "RailsPulse::Query")
108
+ .merge(period_summaries)
131
109
  .group("rails_pulse_summaries.summarizable_id, rails_pulse_queries.normalized_sql")
132
110
  .select(
133
111
  "rails_pulse_summaries.summarizable_id as query_id",
@@ -143,26 +121,19 @@ module RailsPulse
143
121
  p95 = record.p95_duration.to_f
144
122
  count = record.total_count.to_i
145
123
 
146
- if p95 >= @query_thresholds[:critical]
147
- severity = :critical
148
- reason = "#{p95.round(0).to_i}ms P95 · exceeds #{@query_thresholds[:critical]}ms threshold"
149
- elsif p95 >= @query_thresholds[:slow]
150
- severity = :warning
151
- reason = "#{p95.round(0).to_i}ms P95 · above #{@query_thresholds[:slow]}ms slow threshold"
152
- else
153
- next
154
- end
124
+ severity, reason, metric, metric_sub, sort_score = classify_query(p95, count)
125
+ next unless severity
155
126
 
156
127
  classified_ids << record.query_id
157
128
  items << {
158
129
  type: "QUERY",
159
130
  name: truncate_sql(record.normalized_sql),
160
131
  reason: reason,
161
- metric: "#{p95.round(0).to_i}ms P95",
162
- metric_sub: "#{count} execution#{count == 1 ? "" : "s"}",
132
+ metric: metric,
133
+ metric_sub: metric_sub,
163
134
  link: url_helpers.query_path(record.query_id),
164
135
  severity: severity,
165
- sort_score: p95,
136
+ sort_score: sort_score,
166
137
  monospace: true
167
138
  }
168
139
  end
@@ -200,7 +171,9 @@ module RailsPulse
200
171
  failure_rate = job.failure_rate
201
172
  p95 = job.p95_duration.to_f
202
173
 
203
- severity, reason, metric, metric_sub, sort_score = classify_job(job, failure_rate, p95)
174
+ severity, reason, metric, metric_sub, sort_score = classify_job(
175
+ failure_rate, p95, job.runs_count, job.failures_count, job.queue_name.presence || "default"
176
+ )
204
177
  next unless severity
205
178
 
206
179
  items << {
@@ -218,38 +191,6 @@ module RailsPulse
218
191
  items
219
192
  end
220
193
 
221
- def classify_job(job, failure_rate, p95)
222
- if failure_rate >= CRITICAL_JOB_FAILURE_RATE || p95 >= @job_thresholds[:critical]
223
- if failure_rate >= CRITICAL_JOB_FAILURE_RATE
224
- [ :critical,
225
- "#{job.queue_name.presence || "default"} queue · #{failure_rate}% failure rate",
226
- "#{job.failures_count} / #{job.runs_count} failed",
227
- "P95 #{p95.round(0).to_i}ms",
228
- job.failures_count * job.runs_count.to_f ]
229
- else
230
- [ :critical,
231
- "P95 #{p95.round(0).to_i}ms · exceeds #{@job_thresholds[:critical]}ms threshold",
232
- "#{p95.round(0).to_i}ms P95",
233
- "#{job.runs_count} runs",
234
- p95 ]
235
- end
236
- elsif failure_rate >= WARNING_JOB_FAILURE_RATE || p95 >= @job_thresholds[:slow]
237
- if failure_rate >= WARNING_JOB_FAILURE_RATE
238
- [ :warning,
239
- "#{job.queue_name.presence || "default"} queue · #{failure_rate}% failure rate",
240
- "#{job.failures_count} / #{job.runs_count} failed",
241
- "P95 #{p95.round(0).to_i}ms",
242
- job.failures_count * job.runs_count.to_f ]
243
- else
244
- [ :warning,
245
- "P95 #{p95.round(0).to_i}ms · above slow threshold",
246
- "#{p95.round(0).to_i}ms P95",
247
- "#{job.runs_count} runs",
248
- p95 ]
249
- end
250
- end
251
- end
252
-
253
194
  # Exception groups that fired often enough over the period to be worth
254
195
  # looking at. Read from summaries, not from ExceptionGroup#occurrence_count
255
196
  # — that is a lifetime counter, so a group that was noisy last year and is
@@ -262,12 +203,10 @@ module RailsPulse
262
203
  return [] unless RailsPulse.configuration.track_exceptions
263
204
  return [] unless exception_tables_available?
264
205
 
265
- start, finish = period_range
266
-
267
206
  counts = RailsPulse::Summary
268
207
  .for_exceptions
269
208
  .where.not(summarizable_id: 0)
270
- .where(period_start: start..finish)
209
+ .merge(period_summaries)
271
210
  .group(:summarizable_id)
272
211
  .sum(:count)
273
212
 
@@ -320,12 +259,6 @@ module RailsPulse
320
259
  rescue ActiveRecord::ActiveRecordError
321
260
  false
322
261
  end
323
-
324
- def truncate_sql(sql)
325
- return "" if sql.blank?
326
- cleaned = sql.gsub(/\s+/, " ").strip
327
- cleaned.length > 80 ? "#{cleaned[0..79]}..." : cleaned
328
- end
329
262
  end
330
263
  end
331
264
  end
@@ -8,12 +8,15 @@ module RailsPulse
8
8
  @config = RailsPulse.configuration
9
9
  end
10
10
 
11
+ # Memoized: computing the items costs several aggregate queries (one a
12
+ # raw-request count), and one dashboard render asks for them repeatedly.
11
13
  def pressure_items
12
- summary_staleness_items + stuck_records_items + sub_hour_retention_items
14
+ @pressure_items ||= summary_staleness_items + sub_hour_retention_items + writer_drop_items
13
15
  end
14
16
 
17
+ # Dropped requests get their own Tracking badge, not this one
15
18
  def storage_counts
16
- items = pressure_items
19
+ items = pressure_items.reject { |i| i[:type] == "TRACKING" }
17
20
  critical = items.any? { |i| i[:severity] == :critical } ? 1 : 0
18
21
  slow = (critical.zero? && items.any? { |i| i[:severity] == :warning }) ? 1 : 0
19
22
  healthy = (critical.zero? && slow.zero?) ? 1 : 0
@@ -36,53 +39,17 @@ module RailsPulse
36
39
  if age >= STALE_CRITICAL_THRESHOLD
37
40
  hours = (age / 1.hour).round
38
41
  [ stale_item(:critical, "Summary job is #{hours}h behind",
39
- "#{hours}h stale", "Last run: #{latest_end.strftime("%Y-%m-%d %H:%M UTC")}") ]
42
+ "#{hours}h stale", "Last run: #{latest_end.strftime("%Y-%m-%d %H:%M")} (#{RailsPulse::TimeRange.aggregation_zone_label})") ]
40
43
  elsif age >= STALE_WARNING_THRESHOLD
41
44
  hours = (age / 1.hour).round
42
45
  [ stale_item(:warning, "Summary job is #{hours}h behind",
43
- "#{hours}h stale", "Last run: #{latest_end.strftime("%Y-%m-%d %H:%M UTC")}") ]
46
+ "#{hours}h stale", "Last run: #{latest_end.strftime("%Y-%m-%d %H:%M")} (#{RailsPulse::TimeRange.aggregation_zone_label})") ]
44
47
  else
45
48
  []
46
49
  end
47
50
  end
48
51
 
49
- # Signal B — Records past retention that haven't been summarized (cleanup is blocked)
50
- def stuck_records_items
51
- return [] unless @config.archiving_enabled
52
- return [] unless @config.full_retention_period
53
-
54
- oldest_start = oldest_overall_request_summary_start
55
- return [] if oldest_start.nil?
56
-
57
- stuck_count = RailsPulse::Request
58
- .where("occurred_at < ?", @config.full_retention_period.ago)
59
- .where("occurred_at < ?", oldest_start)
60
- .count
61
-
62
- return [] if stuck_count.zero?
63
-
64
- [ {
65
- type: "STORAGE",
66
- name: "Storage pressure",
67
- reason: "#{stuck_count} #{"request".pluralize(stuck_count)} past the retention window cannot be cleaned up — summarize first",
68
- metric: "#{stuck_count} stuck",
69
- metric_sub: "older than #{humanize_duration(@config.full_retention_period)}",
70
- link: "#",
71
- severity: :critical,
72
- sort_score: stuck_count.to_f,
73
- popover_title: "Cleanup is blocked on unsummarised records",
74
- popover_body: "#{stuck_count} #{"request".pluralize(stuck_count)} are older than your retention period " \
75
- "(#{humanize_duration(@config.full_retention_period)}) but predate any hourly summary record. " \
76
- "CleanupService will not delete records from a time period until that period has been summarised — " \
77
- "otherwise those data points would be permanently lost from dashboard charts.<br><br>" \
78
- "<strong>How to fix:</strong> Run <code>RailsPulse::SummaryJob</code> to summarise the historical periods. " \
79
- "If the job is not yet scheduled, run the backfill task to summarise all historical data:<br><br>" \
80
- "<code>rails rails_pulse:backfill_summaries</code><br><br>" \
81
- "Once the affected periods are summarised, the next cleanup run will delete these records automatically."
82
- } ]
83
- end
84
-
85
- # Signal C — Retention period shorter than 1-hour cleanup minimum
52
+ # Signal B — Retention period shorter than 1-hour cleanup minimum
86
53
  def sub_hour_retention_items
87
54
  return [] unless @config.full_retention_period
88
55
  return [] unless @config.full_retention_period < 1.hour
@@ -107,6 +74,35 @@ module RailsPulse
107
74
  } ]
108
75
  end
109
76
 
77
+ # Signal C — the background writer is discarding requests
78
+ def writer_drop_items
79
+ return [] unless RailsPulse::Event.table_available?
80
+
81
+ summary = RailsPulse::WriterHeartbeat.summary
82
+ dropped = summary[:dropped].to_i
83
+ return [] if dropped.zero?
84
+
85
+ [ {
86
+ type: "TRACKING",
87
+ name: "Writer queue dropping requests",
88
+ reason: "#{dropped} #{"request".pluralize(dropped)} dropped in the last hour — charts are missing samples",
89
+ metric: "#{dropped} dropped",
90
+ metric_sub: "last hour, #{summary[:processes]} live #{"writer".pluralize(summary[:processes])}",
91
+ link: "#",
92
+ severity: :critical,
93
+ sort_score: dropped.to_f,
94
+ popover_title: "The tracking queue is overflowing",
95
+ popover_body: "Each process queues tracked requests for one background writer. When the queue (#{summary[:queue_size]} requests, " \
96
+ "<code>config.async_queue_size</code>) is full the newest request is dropped rather than slowing the app, so the " \
97
+ "dashboard undercounts traffic while this lasts.<br><br>" \
98
+ "<strong>How to fix:</strong> raise <code>config.async_queue_size</code> in the Rails Pulse initializer, or find out " \
99
+ "why the writer cannot keep up: database latency, a saturated connection pool, or a burst far above normal traffic. " \
100
+ "<code>rails rails_pulse:status</code> reports the same numbers from the shell."
101
+ } ]
102
+ rescue ActiveRecord::ActiveRecordError
103
+ []
104
+ end
105
+
110
106
  def stale_item(severity, reason, metric, metric_sub)
111
107
  {
112
108
  type: "STORAGE",
@@ -118,8 +114,8 @@ module RailsPulse
118
114
  severity: severity,
119
115
  sort_score: severity == :critical ? Float::INFINITY : 1.0,
120
116
  popover_title: "Summary job is not running",
121
- popover_body: "Rails Pulse summarises raw request data into hourly aggregates before cleanup can safely delete them. " \
122
- "Until a period is summarised, CleanupService will not delete records from it — so your database will grow until the summary job catches up.<br><br>" \
117
+ popover_body: "Rails Pulse summarises raw request data into hourly aggregates for the dashboard's charts. " \
118
+ "Count-based cleanup only trims records from periods that have been summarised, so until the job catches up tables can grow past <code>max_table_records</code>.<br><br>" \
123
119
  "<strong>How to fix:</strong> Schedule <code>RailsPulse::SummaryJob</code> to run every hour in your job scheduler " \
124
120
  "(Sidekiq-Cron, GoodJob, Solid Queue, etc.). Once it has run, cleanup will resume automatically on its next execution.<br><br>" \
125
121
  "To backfill any historical gaps, run: <code>rails rails_pulse:backfill_summaries</code>"
@@ -131,26 +127,6 @@ module RailsPulse
131
127
  .where(summarizable_type: "RailsPulse::Request", summarizable_id: 0, period_type: "hour")
132
128
  .maximum(:period_end)
133
129
  end
134
-
135
- def oldest_overall_request_summary_start
136
- RailsPulse::Summary
137
- .where(summarizable_type: "RailsPulse::Request", summarizable_id: 0, period_type: "hour")
138
- .minimum(:period_start)
139
- end
140
-
141
- def humanize_duration(duration)
142
- total_seconds = duration.to_i
143
- if total_seconds >= 86_400
144
- days = total_seconds / 86_400
145
- "#{days} #{"day".pluralize(days)}"
146
- elsif total_seconds >= 3_600
147
- hours = total_seconds / 3_600
148
- "#{hours} #{"hour".pluralize(hours)}"
149
- else
150
- minutes = total_seconds / 60
151
- "#{minutes} #{"minute".pluralize(minutes)}"
152
- end
153
- end
154
130
  end
155
131
  end
156
132
  end