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
@@ -0,0 +1,99 @@
1
+ module RailsPulse
2
+ module Mcp
3
+ module Tools
4
+ class Insights < ::MCP::Tool
5
+ extend Helpers
6
+
7
+ PERIODS = %w[hour day week month].freeze
8
+
9
+ tool_name "rails_pulse_insights"
10
+ description "What needs attention over one summary period, and whether the configured thresholds fit " \
11
+ "it. Lists the routes, queries and jobs past their slow or critical thresholds (critical " \
12
+ "first, ten at most), then checks route_thresholds and query_thresholds against the " \
13
+ "period's slowest routes and most expensive queries and returns a config line to paste " \
14
+ "when one is too noisy or never fires. Use it for 'what should I look at' and 'are my " \
15
+ "thresholds right'. Reads whole periods, so the P95s are exact."
16
+
17
+ annotations(
18
+ read_only_hint: true,
19
+ destructive_hint: false,
20
+ open_world_hint: false
21
+ )
22
+
23
+ input_schema(
24
+ properties: {
25
+ period: {
26
+ type: "string",
27
+ enum: PERIODS,
28
+ description: "Summary period to read: 'hour', 'day', 'week' or 'month'.",
29
+ default: "week"
30
+ },
31
+ at: {
32
+ type: "string",
33
+ description: "A time inside the period, ISO 8601 (read as UTC with no zone). Omit for the last " \
34
+ "complete period; the current one has no summary until it ends."
35
+ }
36
+ }
37
+ )
38
+
39
+ def self.call(period: "week", at: nil, server_context:, **_options)
40
+ respond(server_context) do |client|
41
+ unless PERIODS.include?(period)
42
+ raise Helpers::WindowError, "Unknown period #{period.inspect}. Use #{PERIODS.join(', ')}."
43
+ end
44
+
45
+ params = { period: period }
46
+ params[:at] = parse_time(at, "at").iso8601 if at
47
+ data = client.get("/insights", params)
48
+ attention = data["needs_attention"] || {}
49
+ recommendations = data["threshold_recommendations"] || []
50
+
51
+ {
52
+ period: data["period"],
53
+ thresholds: data["thresholds"],
54
+ needs_attention: attention,
55
+ threshold_recommendations: recommendations,
56
+ summary: build_summary(data["period"] || {}, attention, recommendations),
57
+ next_steps: build_next_steps(data["period"] || {}, attention, recommendations)
58
+ }
59
+ end
60
+ end
61
+
62
+ private_class_method def self.build_summary(period, attention, recommendations)
63
+ critical = Array(attention["critical"]).size
64
+ warning = Array(attention["warning"]).size
65
+ if !period["summarized"] && critical.zero? && warning.zero?
66
+ return "The #{period['type']} starting #{period['start']} has not been summarized yet."
67
+ end
68
+
69
+ parts = [ "#{period['type'].to_s.capitalize} starting #{period['start']}: #{critical} critical, #{warning} warning." ]
70
+ parts << "#{recommendations.size} threshold change(s) suggested." if recommendations.any?
71
+ parts << "SummaryJob has not finished this period, so the list may be incomplete." unless period["summarized"]
72
+ parts.join(" ")
73
+ end
74
+
75
+ private_class_method def self.build_next_steps(period, attention, recommendations)
76
+ items = Array(attention["critical"]) + Array(attention["warning"])
77
+ if !period["summarized"] && items.empty?
78
+ return [ "Pass an earlier 'at', or call rails_pulse_coverage to see how far summaries reach." ]
79
+ end
80
+
81
+ steps = []
82
+ if (route = items.find { |i| i["type"] == "route" })
83
+ steps << "Profile #{route['name']} with rails_pulse_endpoint, and pass route: #{route['id']} to " \
84
+ "rails_pulse_queries for the SQL inside it."
85
+ end
86
+ if items.any? { |i| i["type"] == "job" }
87
+ steps << "Call rails_pulse_jobs for the failing or slow jobs' recent runs and error classes."
88
+ end
89
+ if recommendations.any?
90
+ steps << "Each config_snippet replaces the whole setting in config/initializers/rails_pulse.rb; " \
91
+ "check the period was typical before changing a threshold."
92
+ end
93
+ steps << "Nothing passed its thresholds; compare an earlier period to confirm." if items.empty? && recommendations.empty?
94
+ steps
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,155 @@
1
+ module RailsPulse
2
+ module Mcp
3
+ module Tools
4
+ class Jobs < ::MCP::Tool
5
+ extend Helpers
6
+
7
+ ERROR_MESSAGE_LENGTH = 200
8
+
9
+ # Failed runs fetched for recent_failures. The count reported beside
10
+ # them is the API's total, not the size of this sample.
11
+ FAILED_RUN_SAMPLE = 100
12
+
13
+ tool_name "rails_pulse_jobs"
14
+ description "Background job health: failure rates, slow jobs, and recent failures with error classes. " \
15
+ "Use this to find failing or slow background jobs."
16
+
17
+ annotations(
18
+ read_only_hint: true,
19
+ destructive_hint: false,
20
+ open_world_hint: false
21
+ )
22
+
23
+ input_schema(
24
+ properties: {
25
+ **Helpers.window_properties("last_24_hours"),
26
+ limit: {
27
+ type: "integer",
28
+ description: "Maximum number of jobs to return (1-50)",
29
+ default: 10
30
+ },
31
+ job: {
32
+ type: "string",
33
+ description: "Restrict to one job class (e.g. 'GenerateReportJob')"
34
+ }
35
+ }
36
+ )
37
+
38
+ def self.call(period: "last_24_hours", limit: 10, job: nil, server_context:, **options)
39
+ respond(server_context) do |client|
40
+ window = resolve_window(period: period, since: options[:since], until_time: options[:until])
41
+ limit = limit.to_i.clamp(1, 50)
42
+
43
+ # Windowed: the API answers these from summaries so the counts and
44
+ # durations cover the same period as recent_failures. Every job is
45
+ # fetched because the API orders by name and the ranking here is
46
+ # by failure rate; a first page would drop the worst after the
47
+ # alphabet ran past it.
48
+ job_params = window_params(window)
49
+ job_params[:job] = job if job
50
+ jobs, result = fetch_all(client, "/jobs", job_params)
51
+ granularity = result.dig("meta", "window", "period_type")
52
+
53
+ run_params = window_params(window).merge(status: "failed", limit: FAILED_RUN_SAMPLE)
54
+ run_params[:job] = job if job
55
+ runs_result = client.get("/job_runs", run_params)
56
+ failed_runs = runs_result["data"] || []
57
+ failed_total = runs_result.dig("meta", "total") || failed_runs.size
58
+
59
+ formatted = jobs.map { |j| format_job(j) }
60
+ .sort_by { |j| [ -j[:failure_rate].to_f, -slowness(j) ] }
61
+ .first(limit)
62
+
63
+ payload = {
64
+ window: window.merge(granularity ? { summary_period: granularity } : {}),
65
+ jobs: formatted,
66
+ failed_runs: failed_total,
67
+ recent_failures: format_failures(failed_runs),
68
+ summary: build_summary(formatted, jobs.size, failed_total, failed_runs.size),
69
+ next_steps: build_next_steps(formatted, failed_runs)
70
+ }
71
+ note = formatted.filter_map { |j| j[:percentiles_note] }.first
72
+ payload[:note] = note if note
73
+ payload
74
+ end
75
+ end
76
+
77
+ # The tool always asks for a window, so every figure comes from the
78
+ # window's `stats`. The job's lifetime counters are never substituted:
79
+ # a withheld percentile stays nil rather than becoming an all-time one.
80
+ private_class_method def self.format_job(job)
81
+ stats = job["stats"] || {}
82
+ formatted = {
83
+ name: job["name"],
84
+ queue: job["queue_name"],
85
+ runs: stats["runs_count"].to_i,
86
+ failures: stats["failures_count"].to_i,
87
+ failure_rate: stats["failure_rate"].to_f.round(1),
88
+ avg_ms: stats["avg_duration"]&.to_f&.round(1),
89
+ max_ms: stats["max_duration"]&.to_f&.round(1),
90
+ p95_ms: stats["p95_duration"]&.to_f&.round(1),
91
+ p99_ms: stats["p99_duration"]&.to_f&.round(1)
92
+ }
93
+ formatted[:percentiles_note] = stats["percentiles_note"] if stats["percentiles_note"]
94
+ formatted
95
+ end
96
+
97
+ # p95 when the window has one, otherwise the mean.
98
+ private_class_method def self.slowness(job)
99
+ (job[:p95_ms] || job[:avg_ms]).to_f
100
+ end
101
+
102
+ private_class_method def self.format_failures(runs)
103
+ runs.group_by { |r| r["job_name"] || "unknown" }.map do |name, group|
104
+ latest = group.max_by { |r| r["occurred_at"].to_s }
105
+ {
106
+ job: name,
107
+ count: group.size,
108
+ error_classes: group.map { |r| r["error_class"] || "unknown" }.tally,
109
+ latest: {
110
+ occurred_at: latest["occurred_at"],
111
+ error_class: latest["error_class"],
112
+ error_message: truncate(latest["error_message"], ERROR_MESSAGE_LENGTH),
113
+ attempts: latest["attempts"]
114
+ }
115
+ }
116
+ end.sort_by { |f| -f[:count] }
117
+ end
118
+
119
+ private_class_method def self.build_summary(jobs, job_total, failed_total, failed_sampled)
120
+ return "No background jobs recorded." if jobs.empty?
121
+
122
+ failed = "#{failed_total} failed run(s) in period"
123
+ failed += " (latest #{failed_sampled} grouped in recent_failures)" if failed_total > failed_sampled
124
+ parts = [ "#{job_total} job(s) ran, #{failed}" ]
125
+ worst = jobs.max_by { |j| j[:failure_rate] }
126
+ parts << "Highest failure rate: #{worst[:name]} (#{worst[:failure_rate]}%)" if worst[:failure_rate] > 0
127
+ slowest = jobs.max_by { |j| slowness(j) }
128
+ if slowest
129
+ label = slowest[:p95_ms] ? "p95 #{slowest[:p95_ms]}ms" : "avg #{slowest[:avg_ms]}ms"
130
+ parts << "Slowest: #{slowest[:name]} (#{label})"
131
+ end
132
+ parts.join(". ") + "."
133
+ end
134
+
135
+ private_class_method def self.build_next_steps(jobs, failed_runs)
136
+ steps = []
137
+ if jobs.empty?
138
+ steps << "No jobs tracked — confirm `config.track_jobs` is enabled in the Rails Pulse initializer."
139
+ end
140
+ if failed_runs.any?
141
+ steps << "Group failures by error_class in recent_failures; a single class dominating usually points at one root cause."
142
+ end
143
+ if jobs.any? { |j| j[:failure_rate] > 5 }
144
+ steps << "Jobs with failure rate above 5% need retry/discard handling reviewed."
145
+ end
146
+ if jobs.any? { |j| slowness(j) > 60_000 }
147
+ steps << "Jobs taking over 60s (p95, or the mean where no p95 is given) may be blocking queues — consider splitting or batching."
148
+ end
149
+ steps << "Use rails_pulse_queries to check whether slow jobs are dominated by SQL."
150
+ steps
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,142 @@
1
+ module RailsPulse
2
+ module Mcp
3
+ module Tools
4
+ class Queries < ::MCP::Tool
5
+ extend Helpers
6
+
7
+ SQL_LENGTH = 300
8
+ MAX_PAGE = 500
9
+
10
+ tool_name "rails_pulse_queries"
11
+ description "Find the most expensive SQL queries for a time period: execution count, average/max/total time, " \
12
+ "and N+1 detection. Use this to see which queries are worth optimising."
13
+
14
+ annotations(
15
+ read_only_hint: true,
16
+ destructive_hint: false,
17
+ open_world_hint: false
18
+ )
19
+
20
+ input_schema(
21
+ properties: {
22
+ **Helpers.window_properties("last_24_hours"),
23
+ limit: {
24
+ type: "integer",
25
+ description: "Maximum number of queries (1-50)",
26
+ default: 10
27
+ },
28
+ sort: {
29
+ type: "string",
30
+ description: "Order by 'total_duration' (default), 'avg_duration', 'max_duration', or 'executions'",
31
+ default: "total_duration"
32
+ },
33
+ n_plus_one_only: {
34
+ type: "boolean",
35
+ description: "Only return queries flagged as likely N+1",
36
+ default: false
37
+ },
38
+ # rails_pulse_endpoint and rails_pulse_slow_requests hand back an
39
+ # integer route_id, so the next call passes a number.
40
+ route: {
41
+ type: %w[integer string],
42
+ description: "Restrict to SQL issued while serving one endpoint. A route_id from rails_pulse_routes " \
43
+ "or rails_pulse_slow_requests, or a controller action or path to match on."
44
+ },
45
+ hashed_sql: {
46
+ type: "string",
47
+ description: "Exact hashed_sql of one query, as returned by this tool or by Rails Pulse Cloud"
48
+ }
49
+ }
50
+ )
51
+
52
+ def self.call(period: "last_24_hours", limit: 10, sort: "total_duration", n_plus_one_only: false, route: nil, hashed_sql: nil, server_context:, **options)
53
+ respond(server_context) do |client|
54
+ window = resolve_window(period: period, since: options[:since], until_time: options[:until])
55
+ limit = limit.to_i.clamp(1, 50)
56
+ # The N+1 flag is filtered here, not by the API, so fetch the
57
+ # largest page it allows and keep the first `limit` matches.
58
+ page = n_plus_one_only ? MAX_PAGE : limit
59
+ params = window_params(window).merge(sort: sort, limit: page)
60
+ params[:route] = route if route
61
+ params[:hashed_sql] = hashed_sql if hashed_sql
62
+ result = client.get("/queries", params)
63
+
64
+ queries = (result["data"] || []).map { |q| format_query(q) }
65
+ queries = queries.select { |q| q[:n_plus_one][:likely] }.first(limit) if n_plus_one_only
66
+
67
+ payload = {
68
+ window: window,
69
+ sort: sort,
70
+ total_queries: result.dig("meta", "total") || queries.size,
71
+ queries: queries,
72
+ summary: build_summary(queries),
73
+ next_steps: build_next_steps(queries)
74
+ }
75
+ payload[:route] = route if route
76
+ payload
77
+ end
78
+ end
79
+
80
+ # Transaction control repeats within a request by design; it is never
81
+ # an N+1 however often it appears.
82
+ TRANSACTION_STATEMENT = /\A(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\b/i
83
+
84
+ private_class_method def self.format_query(query)
85
+ stats = query["stats"] || {}
86
+ analysis = query["n_plus_one"] || {}
87
+ repetition = stats["max_repetition_count"].to_i
88
+ sql = query["normalized_sql"].to_s.gsub(/\s+/, " ").strip
89
+ repeated = repetition > 1 && !sql.match?(TRANSACTION_STATEMENT)
90
+
91
+ {
92
+ id: query["id"],
93
+ hashed_sql: query["hashed_sql"],
94
+ sql: truncate(sql, SQL_LENGTH),
95
+ executions: stats["executions"],
96
+ avg_duration_ms: stats["avg_duration_ms"],
97
+ max_duration_ms: stats["max_duration_ms"],
98
+ total_duration_ms: stats["total_duration_ms"],
99
+ n_plus_one: {
100
+ likely: analysis["likely"] == true || repeated,
101
+ confidence: analysis["confidence"],
102
+ max_repetition_count: repetition > 0 ? repetition : nil
103
+ },
104
+ issue_count: Array(query["issues"]).size,
105
+ suggestions: Array(query["suggestions"]),
106
+ # Where the query was issued from, most frequent first, so the
107
+ # investigation reaches the code rather than stopping at the SQL.
108
+ source_locations: Array(stats["source_locations"])
109
+ }
110
+ end
111
+
112
+ private_class_method def self.build_summary(queries)
113
+ return "No query activity found for this period." if queries.empty?
114
+
115
+ top = queries.first
116
+ n_plus_one = queries.count { |q| q[:n_plus_one][:likely] }
117
+ parts = [ "Top query by #{top[:executions]} executions / #{top[:total_duration_ms]}ms total: #{truncate(top[:sql], 80)}" ]
118
+ parts << "#{n_plus_one} likely N+1 quer#{n_plus_one == 1 ? 'y' : 'ies'}" if n_plus_one > 0
119
+ parts.join(". ") + "."
120
+ end
121
+
122
+ private_class_method def self.build_next_steps(queries)
123
+ steps = []
124
+ if queries.any? { |q| q[:n_plus_one][:likely] }
125
+ steps << "N+1 candidates: find the call site and preload the association (`includes`/`preload`) or batch the lookup."
126
+ end
127
+ if queries.any? { |q| q[:avg_duration_ms].to_f > 100 }
128
+ steps << "Queries averaging over 100ms: check `suggestions` for missing indexes and run EXPLAIN on the normalized SQL."
129
+ end
130
+ if queries.any? { |q| q[:executions].to_i > 1000 && q[:avg_duration_ms].to_f < 5 }
131
+ steps << "Very frequent fast queries add up — consider caching or reducing calls per request."
132
+ end
133
+ if queries.any? { |q| q[:source_locations].any? }
134
+ steps << "source_locations names the file and line each query was issued from — start there rather than searching for the SQL."
135
+ end
136
+ steps << "Use rails_pulse_endpoint to see which endpoints are slow and correlate with these queries."
137
+ steps
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,108 @@
1
+ module RailsPulse
2
+ module Mcp
3
+ module Tools
4
+ class Routes < ::MCP::Tool
5
+ extend Helpers
6
+
7
+ tool_name "rails_pulse_routes"
8
+ description "List the application's tracked routes/endpoints with request volume, average latency, and error " \
9
+ "counts for a period. Use this to discover endpoint names before calling rails_pulse_endpoint."
10
+
11
+ annotations(
12
+ read_only_hint: true,
13
+ destructive_hint: false,
14
+ open_world_hint: false
15
+ )
16
+
17
+ input_schema(
18
+ properties: {
19
+ **Helpers.window_properties("last_7_days"),
20
+ search: {
21
+ type: "string",
22
+ description: "Case-insensitive substring match on path or controller action (e.g. 'checkout')"
23
+ },
24
+ path: {
25
+ type: "string",
26
+ description: "Exact route path, e.g. '/orders/:id'. With controller_action, identifies one route " \
27
+ "the way Rails Pulse Cloud does."
28
+ },
29
+ controller_action: {
30
+ type: "string",
31
+ description: "Exact controller action, e.g. 'orders#show'"
32
+ },
33
+ limit: {
34
+ type: "integer",
35
+ description: "Maximum number of routes (1-100)",
36
+ default: 50
37
+ },
38
+ sort: {
39
+ type: "string",
40
+ description: "Order by 'request_count' (default), 'avg_duration', or 'error_count'",
41
+ default: "request_count"
42
+ }
43
+ }
44
+ )
45
+
46
+ def self.call(period: "last_7_days", search: nil, path: nil, controller_action: nil, limit: 50, sort: "request_count", server_context:, **options)
47
+ respond(server_context) do |client|
48
+ window = resolve_window(period: period, since: options[:since], until_time: options[:until])
49
+ limit = limit.to_i.clamp(1, 100)
50
+
51
+ params = window_params(window).merge(sort: sort, limit: limit)
52
+ params[:search] = search if search
53
+ params[:path] = path if path
54
+ params[:controller_action] = controller_action if controller_action
55
+ result = client.get("/routes", params)
56
+
57
+ routes = (result["data"] || []).map { |r| format_route(r) }
58
+ routes.select! { |r| matches?(r, search) } if search
59
+
60
+ {
61
+ window: window,
62
+ sort: sort,
63
+ total_routes: result.dig("meta", "total") || routes.size,
64
+ routes: routes,
65
+ summary: build_summary(routes, search),
66
+ next_steps: [
67
+ "Pass a route's controller_action (e.g. 'CheckoutController#create') to rails_pulse_endpoint for a full profile.",
68
+ "Routes with traffic but no controller_action are usually engine or Rack endpoints."
69
+ ]
70
+ }
71
+ end
72
+ end
73
+
74
+ private_class_method def self.format_route(route)
75
+ stats = route["stats"] || {}
76
+ {
77
+ id: route["id"],
78
+ http_methods: route["http_methods"],
79
+ path: route["path"],
80
+ controller_action: route["controller_action"],
81
+ request_count: stats["request_count"],
82
+ avg_duration_ms: stats["avg_duration_ms"],
83
+ error_count: stats["error_count"],
84
+ tags: route["tags"]
85
+ }
86
+ end
87
+
88
+ private_class_method def self.matches?(route, search)
89
+ term = search.downcase
90
+ route[:path].to_s.downcase.include?(term) || route[:controller_action].to_s.downcase.include?(term)
91
+ end
92
+
93
+ private_class_method def self.build_summary(routes, search)
94
+ if routes.empty?
95
+ return search ? "No routes matching '#{search}' had traffic in this period." : "No routes had traffic in this period."
96
+ end
97
+
98
+ busiest = routes.max_by { |r| r[:request_count].to_i }
99
+ parts = [ "#{routes.size} route(s) with traffic" ]
100
+ parts << "Busiest: #{busiest[:controller_action] || busiest[:path]} (#{busiest[:request_count]} requests)"
101
+ slowest = routes.max_by { |r| r[:avg_duration_ms].to_f }
102
+ parts << "Slowest: #{slowest[:controller_action] || slowest[:path]} (avg #{slowest[:avg_duration_ms]}ms)" if slowest
103
+ parts.join(". ") + "."
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,118 @@
1
+ require "json"
2
+
3
+ module RailsPulse
4
+ module Mcp
5
+ module Tools
6
+ class SlowRequests < ::MCP::Tool
7
+ extend Helpers
8
+
9
+ tool_name "rails_pulse_slow_requests"
10
+ description "The slowest endpoints for a time period, ranked by average response time over every " \
11
+ "request in the window, with request volume and error counts. " \
12
+ "Use this to find which routes are taking the longest to respond."
13
+
14
+ annotations(
15
+ read_only_hint: true,
16
+ destructive_hint: false,
17
+ open_world_hint: false
18
+ )
19
+
20
+ input_schema(
21
+ properties: {
22
+ **Helpers.window_properties("last_24_hours"),
23
+ limit: {
24
+ type: "integer",
25
+ description: "Maximum number of endpoints (1-100)",
26
+ default: 10
27
+ },
28
+ min_requests: {
29
+ type: "integer",
30
+ description: "Skip endpoints with fewer requests than this in the period, so a single slow hit does not top the list",
31
+ default: 1
32
+ }
33
+ }
34
+ )
35
+
36
+ # The routes endpoint aggregates every request in the window on the
37
+ # server, so the ranking covers the whole period rather than a page
38
+ # of the most recent requests.
39
+ def self.call(period: "last_24_hours", limit: 10, min_requests: 1, server_context:, **options)
40
+ respond(server_context) do |client|
41
+ window = resolve_window(period: period, since: options[:since], until_time: options[:until])
42
+ limit = limit.to_i.clamp(1, 100)
43
+ min_requests = min_requests.to_i.clamp(1, 1_000_000)
44
+
45
+ result = client.get("/routes", window_params(window).merge(sort: "avg_duration", limit: limit, min_requests: min_requests))
46
+ routes = result["data"] || []
47
+ routes_with_traffic = result.dig("meta", "routes_with_traffic") || result.dig("meta", "total") || 0
48
+
49
+ endpoints = routes.map do |route|
50
+ stats = route["stats"] || {}
51
+ request_count = stats["request_count"].to_i
52
+ error_count = stats["error_count"].to_i
53
+ {
54
+ # The stable handle for a follow-up call: passing it to
55
+ # rails_pulse_queries avoids a name lookup that a path
56
+ # containing a LIKE metacharacter could get wrong.
57
+ route_id: route["id"],
58
+ endpoint: route["controller_action"] || route["path"],
59
+ path: route["path"],
60
+ http_methods: route["http_methods"],
61
+ request_count: request_count,
62
+ avg_duration_ms: stats["avg_duration_ms"].to_f.round(1),
63
+ error_count: error_count,
64
+ error_rate: request_count > 0 ? ((error_count.to_f / request_count) * 100).round(1) : 0
65
+ }
66
+ end
67
+
68
+ {
69
+ window: window,
70
+ routes_with_traffic: routes_with_traffic,
71
+ min_requests: min_requests,
72
+ endpoints: endpoints,
73
+ summary: build_summary(endpoints, routes_with_traffic, min_requests),
74
+ next_steps: build_next_steps(endpoints, routes_with_traffic, min_requests)
75
+ }
76
+ end
77
+ end
78
+
79
+ # An empty result means one of two things, and they lead to different
80
+ # next steps: nothing was recorded at all, or endpoints ran but none
81
+ # reached min_requests.
82
+ private_class_method def self.build_summary(endpoints, routes_with_traffic, min_requests)
83
+ if endpoints.empty?
84
+ return "No request data found for this period." if routes_with_traffic.zero?
85
+
86
+ return "#{routes_with_traffic} #{routes_with_traffic == 1 ? 'endpoint' : 'endpoints'} received traffic, " \
87
+ "but none reached min_requests of #{min_requests}."
88
+ end
89
+
90
+ slowest = endpoints.first
91
+ parts = [ "Slowest endpoint: #{slowest[:endpoint]} (avg #{slowest[:avg_duration_ms]}ms over #{slowest[:request_count]} requests)" ]
92
+
93
+ error_endpoints = endpoints.select { |e| e[:error_rate] > 0 }
94
+ if error_endpoints.any?
95
+ worst = error_endpoints.max_by { |e| e[:error_rate] }
96
+ parts << "Highest error rate: #{worst[:endpoint]} (#{worst[:error_rate]}%)"
97
+ end
98
+
99
+ parts.join(". ") + "."
100
+ end
101
+
102
+ private_class_method def self.build_next_steps(endpoints, routes_with_traffic, min_requests)
103
+ if endpoints.empty?
104
+ return [ "Lower min_requests below #{min_requests} to rank the #{routes_with_traffic} #{routes_with_traffic == 1 ? 'endpoint' : 'endpoints'} that did receive traffic." ] if routes_with_traffic > 0
105
+
106
+ return [ "Widen the period or confirm requests are being recorded (rails_pulse_routes with period: \"last_7_days\")." ]
107
+ end
108
+
109
+ steps = [ "Pass an endpoint's controller_action to rails_pulse_endpoint for percentiles and recent errors." ]
110
+ steps << "Pass an endpoint's route_id to rails_pulse_queries to see the SQL that ran inside it and where it was issued from."
111
+ steps << "Low-volume endpoints at the top may be one slow hit; raise min_requests to rank by sustained latency." if endpoints.any? { |e| e[:request_count] < 5 }
112
+ steps << "Use rails_pulse_queries with the same period to see whether SQL accounts for the time."
113
+ steps
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -10,7 +10,7 @@ module RailsPulse
10
10
  return @app.call(env) unless RailsPulse.configuration.enabled
11
11
 
12
12
  # Skip logging if we are already recording RailsPulse activity. This is to avoid recursion issues
13
- return @app.call(env) if RequestStore.store[:skip_recording_rails_pulse_activity]
13
+ return @app.call(env) if RailsPulse::Current.skip_recording_rails_pulse_activity
14
14
 
15
15
  req = ActionDispatch::Request.new(env)
16
16
 
@@ -26,8 +26,8 @@ module RailsPulse
26
26
  end
27
27
 
28
28
  # Clear any previous request data and set a placeholder ID
29
- RequestStore.store[:rails_pulse_request_id] = SecureRandom.uuid
30
- RequestStore.store[:rails_pulse_operations] = []
29
+ RailsPulse::Current.rails_pulse_request_id = SecureRandom.uuid
30
+ RailsPulse::Current.rails_pulse_operations = []
31
31
 
32
32
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
33
  occurred_at = Time.current
@@ -38,7 +38,7 @@ module RailsPulse
38
38
 
39
39
  # Collect all tracking data. Operations are copied because the writer
40
40
  # thread annotates them (N+1 detection) after this request has moved on.
41
- operations = RequestStore.store[:rails_pulse_operations] || []
41
+ operations = RailsPulse::Current.rails_pulse_operations || []
42
42
  path_params = env["action_dispatch.request.path_parameters"] || {}
43
43
  controller_action = [ path_params[:controller], path_params[:action] ].compact.join("#").presence
44
44
  tracking_data = {
@@ -63,18 +63,18 @@ module RailsPulse
63
63
 
64
64
  [ status, headers, response ]
65
65
  ensure
66
- RequestStore.store[:skip_recording_rails_pulse_activity] = false
67
- RequestStore.store[:rails_pulse_request_id] = nil
68
- RequestStore.store[:rails_pulse_operations] = nil
66
+ RailsPulse::Current.skip_recording_rails_pulse_activity = false
67
+ RailsPulse::Current.rails_pulse_request_id = nil
68
+ RailsPulse::Current.rails_pulse_operations = nil
69
69
  end
70
70
 
71
71
  private
72
72
 
73
73
  def with_recording_suppressed
74
- RequestStore.store[:skip_recording_rails_pulse_activity] = true
74
+ RailsPulse::Current.skip_recording_rails_pulse_activity = true
75
75
  yield
76
76
  ensure
77
- RequestStore.store[:skip_recording_rails_pulse_activity] = false
77
+ RailsPulse::Current.skip_recording_rails_pulse_activity = false
78
78
  end
79
79
 
80
80
  def response_size_bytes(headers, response)