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,167 @@
1
+ require "json"
2
+
3
+ module RailsPulse
4
+ module Mcp
5
+ module Tools
6
+ class Endpoint < ::MCP::Tool
7
+ extend Helpers
8
+
9
+ tool_name "rails_pulse_endpoint"
10
+ description "Detailed performance profile for a single endpoint, computed from its most recent requests " \
11
+ "in the period: latency distribution, error rate and status codes. " \
12
+ "Use this to deep-dive into a specific route's performance."
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
+ endpoint: {
23
+ type: "string",
24
+ description: "Controller action (e.g. 'CheckoutController#create') or path (e.g. '/checkout'); " \
25
+ "case-insensitive substring match. If unknown, use rails_pulse_routes first."
26
+ },
27
+ **Helpers.window_properties("last_7_days"),
28
+ limit: {
29
+ type: "integer",
30
+ description: "Most recent requests of this endpoint to analyze (1-500). More data = more accurate percentiles.",
31
+ default: 200
32
+ }
33
+ },
34
+ required: [ "endpoint" ]
35
+ )
36
+
37
+ def self.call(endpoint:, period: "last_7_days", limit: 200, server_context:, **options)
38
+ respond(server_context) do |client|
39
+ window = resolve_window(period: period, since: options[:since], until_time: options[:until])
40
+ limit = limit.to_i.clamp(1, 500)
41
+
42
+ # The API matches the endpoint against the request's controller
43
+ # action and its route's path, so the page holds only this
44
+ # endpoint's requests rather than the newest across the app.
45
+ result = client.get("/requests", window_params(window).merge(route: endpoint, limit: limit, offset: 0))
46
+ matching = result["data"] || []
47
+
48
+ if matching.empty?
49
+ {
50
+ endpoint: endpoint,
51
+ window: window,
52
+ error: "No requests found matching '#{endpoint}'. Use rails_pulse_routes to see available endpoints."
53
+ }
54
+ else
55
+ build_profile(endpoint, window, matching, result.dig("meta", "total"))
56
+ end
57
+ end
58
+ end
59
+
60
+ private_class_method def self.build_profile(endpoint, window, requests, total)
61
+ durations = requests.map { |r| r["duration"].to_f }.sort
62
+ errors = requests.select { |r| r["is_error"] }
63
+ statuses = requests.map { |r| r["status"] }.tally.sort_by { |_, c| -c }
64
+ sorted_by_time = requests.sort_by { |r| r["occurred_at"].to_s }
65
+
66
+ # A substring can match several routes ('/users' also matches
67
+ # '/admin/users'). The figures then cover all of them, so the profile
68
+ # is labelled as such and offers no single route_id to drill into.
69
+ routes = requests.group_by { |r| r["route_id"] }
70
+ single = routes.size == 1
71
+
72
+ profile = {
73
+ endpoint: single ? (requests.first["controller_action"] || endpoint) : "#{routes.size} routes matching '#{endpoint}'",
74
+ # The handle for the next hop: rails_pulse_queries takes it to show
75
+ # the SQL that ran inside this endpoint.
76
+ route_id: single ? requests.first["route_id"] : nil,
77
+ window: window,
78
+ request_count: total || requests.size,
79
+ sampled_requests: requests.size,
80
+ latency: {
81
+ avg_ms: (durations.sum / durations.size).round(1),
82
+ min_ms: durations.first.round(1),
83
+ max_ms: durations.last.round(1),
84
+ p50_ms: percentile(durations, 50).round(1),
85
+ p95_ms: percentile(durations, 95).round(1),
86
+ p99_ms: percentile(durations, 99).round(1)
87
+ },
88
+ errors: {
89
+ count: errors.size,
90
+ rate: ((errors.size.to_f / requests.size) * 100).round(1)
91
+ },
92
+ status_distribution: statuses.map { |code, count| { status: code, count: count } },
93
+ time_range: {
94
+ first_request: sorted_by_time.first&.dig("occurred_at"),
95
+ last_request: sorted_by_time.last&.dig("occurred_at")
96
+ }
97
+ }
98
+
99
+ unless single
100
+ profile[:matched_routes] = routes.map do |route_id, reqs|
101
+ { route_id: route_id, controller_action: reqs.first["controller_action"], sampled_requests: reqs.size }
102
+ end.sort_by { |r| -r[:sampled_requests] }
103
+ end
104
+
105
+ # Percentiles are computed over the sampled page, which is the most
106
+ # recent requests rather than the whole window. Said beside the
107
+ # numbers so they are not read as the window's own statistics: a
108
+ # window whose older half was slow reports a much lower p95 here.
109
+ if profile[:sampled_requests] < profile[:request_count]
110
+ profile[:latency][:computed_over] =
111
+ "the #{profile[:sampled_requests]} most recent requests, not all #{profile[:request_count]} in the window"
112
+ end
113
+
114
+ # Add recent errors detail if any
115
+ if errors.any?
116
+ profile[:recent_errors] = errors.sort_by { |r| r["occurred_at"].to_s }.reverse.first(5).map do |r|
117
+ {
118
+ status: r["status"],
119
+ duration_ms: r["duration"].to_f.round(1),
120
+ occurred_at: r["occurred_at"]
121
+ }
122
+ end
123
+ end
124
+
125
+ profile[:summary] = build_summary(profile)
126
+ profile[:next_steps] = build_next_steps(profile)
127
+ profile
128
+ end
129
+
130
+ private_class_method def self.build_summary(profile)
131
+ parts = []
132
+ parts << "#{profile[:request_count]} requests (#{profile[:sampled_requests]} most recent analyzed)"
133
+ parts << "avg #{profile[:latency][:avg_ms]}ms (p95: #{profile[:latency][:p95_ms]}ms)"
134
+ parts << "#{profile[:errors][:rate]}% error rate" if profile[:errors][:count] > 0
135
+ parts.join(", ") + "."
136
+ end
137
+
138
+ private_class_method def self.build_next_steps(profile)
139
+ steps = []
140
+ if profile[:matched_routes]
141
+ steps << "'#{profile[:endpoint]}' covers several routes, so these figures mix them. Call rails_pulse_endpoint " \
142
+ "again with one controller_action from matched_routes, or pass a route_id from it to rails_pulse_queries."
143
+ end
144
+ if profile[:latency][:computed_over]
145
+ steps << "Percentiles cover only the sampled requests. Narrow the window with since/until until " \
146
+ "sampled_requests equals request_count for statistics over the whole window."
147
+ end
148
+ if profile[:latency][:p95_ms] > 1000
149
+ steps << "P95 latency is over 1s — investigate slow database queries or N+1s in the controller."
150
+ end
151
+ if profile[:latency][:p95_ms] > profile[:latency][:avg_ms] * 3
152
+ steps << "High p95/avg ratio suggests occasional very slow requests — look for intermittent issues."
153
+ end
154
+ if profile[:errors][:rate] > 5
155
+ steps << "Error rate is above 5% — check recent_errors and investigate the failure pattern."
156
+ end
157
+ steps << "Use rails_pulse_errors to see error details for this endpoint." if profile[:errors][:count] > 0
158
+ if profile[:route_id]
159
+ steps << "Call rails_pulse_queries with route: #{profile[:route_id]} for the SQL that ran inside this endpoint and the file and line each query came from."
160
+ end
161
+ steps << "Inspect the controller source code and associated SQL queries for optimization opportunities."
162
+ steps
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,104 @@
1
+ require "json"
2
+
3
+ module RailsPulse
4
+ module Mcp
5
+ module Tools
6
+ class Errors < ::MCP::Tool
7
+ extend Helpers
8
+
9
+ tool_name "rails_pulse_errors"
10
+ description "Show recent errors: HTTP 4xx/5xx responses grouped by endpoint. " \
11
+ "Use this to identify which endpoints are failing and how often."
12
+
13
+ annotations(
14
+ read_only_hint: true,
15
+ destructive_hint: false,
16
+ open_world_hint: false
17
+ )
18
+
19
+ input_schema(
20
+ properties: {
21
+ **Helpers.window_properties("last_24_hours"),
22
+ limit: {
23
+ type: "integer",
24
+ description: "Maximum number of error requests to fetch (1-500)",
25
+ default: 100
26
+ },
27
+ status: {
28
+ type: "string",
29
+ description: "Error class filter: '4xx', '5xx' (default), or exact code like '500'",
30
+ default: "5xx"
31
+ },
32
+ path_prefix: {
33
+ type: "string",
34
+ description: "Only requests whose path starts with this, e.g. '/wp-admin/'. A trailing '*' is ignored, " \
35
+ "so a Rails Pulse Cloud path_prefix such as '/wp-admin/*' can be passed as it is. " \
36
+ "Requests no route matched are listed by path; pair with status '404'."
37
+ }
38
+ }
39
+ )
40
+
41
+ def self.call(period: "last_24_hours", limit: 100, status: "5xx", path_prefix: nil, server_context:, **options)
42
+ respond(server_context) do |client|
43
+ window = resolve_window(period: period, since: options[:since], until_time: options[:until])
44
+ limit = limit.to_i.clamp(1, 500)
45
+
46
+ params = { limit: limit, offset: 0 }
47
+ params.merge!(window_params(window))
48
+ params[:status] = status
49
+ params[:path_prefix] = path_prefix if path_prefix
50
+
51
+ result = client.get("/requests", params)
52
+ requests = result["data"] || []
53
+ total = result.dig("meta", "total") || requests.size
54
+
55
+ # A request no route matched has no controller action, so it is
56
+ # grouped under its path rather than with every other one.
57
+ by_endpoint = requests.group_by { |r| r["controller_action"] || r["path"] || "unknown" }
58
+
59
+ error_groups = by_endpoint.map do |action, reqs|
60
+ sorted = reqs.sort_by { |r| r["occurred_at"].to_s }.reverse
61
+ statuses = reqs.map { |r| r["status"] }.tally.sort_by { |_, c| -c }
62
+
63
+ {
64
+ endpoint: action,
65
+ count: reqs.size,
66
+ status_codes: statuses.map { |code, count| { status: code, count: count } },
67
+ first_seen: sorted.last&.dig("occurred_at"),
68
+ last_seen: sorted.first&.dig("occurred_at"),
69
+ avg_duration_ms: (reqs.sum { |r| r["duration"].to_f } / reqs.size).round(1)
70
+ }
71
+ end
72
+
73
+ error_groups.sort_by! { |g| -g[:count] }
74
+
75
+ {
76
+ window: window,
77
+ status_filter: status,
78
+ total_errors: total,
79
+ errors_returned: requests.size,
80
+ by_endpoint: error_groups,
81
+ summary: build_summary(error_groups, total, requests.size, status)
82
+ }
83
+ end
84
+ end
85
+
86
+ # by_endpoint groups only the requests fetched, so when that is fewer
87
+ # than the total the endpoint count is said to cover the sample.
88
+ private_class_method def self.build_summary(groups, total, sampled, status)
89
+ return "No #{status} errors found for this period." if groups.empty?
90
+
91
+ worst = groups.first
92
+ parts = if sampled < total
93
+ [ "#{total} total #{status} errors; the latest #{sampled} span #{groups.size} endpoint(s)" ]
94
+ else
95
+ [ "#{total} total #{status} errors across #{groups.size} endpoint(s)" ]
96
+ end
97
+ parts << "Most errors: #{worst[:endpoint]} (#{worst[:count]} occurrences)"
98
+
99
+ parts.join(". ") + "."
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,118 @@
1
+ module RailsPulse
2
+ module Mcp
3
+ module Tools
4
+ # Named ExceptionDetail rather than Exception so nothing in this namespace
5
+ # shadows ::Exception.
6
+ class ExceptionDetail < ::MCP::Tool
7
+ extend Helpers
8
+
9
+ tool_name "rails_pulse_exception"
10
+ description "Everything recorded about one exception group: its status and counts plus its most recent " \
11
+ "occurrences, each with the full backtrace, request method, URL, filtered params, environment " \
12
+ "and deploy SHA. Use this after rails_pulse_exceptions (or when given an id) to find the cause " \
13
+ "in the code; app_frames lists the backtrace frames inside the application."
14
+
15
+ annotations(
16
+ read_only_hint: true,
17
+ destructive_hint: false,
18
+ open_world_hint: false
19
+ )
20
+
21
+ input_schema(
22
+ properties: {
23
+ id: {
24
+ type: "integer",
25
+ description: "The exception group id, from rails_pulse_exceptions or the dashboard URL"
26
+ },
27
+ occurrences: {
28
+ type: "integer",
29
+ description: "How many of the most recent occurrences to include (1-20)",
30
+ default: 3
31
+ }
32
+ },
33
+ required: [ "id" ]
34
+ )
35
+
36
+ def self.call(id:, occurrences: 3, server_context:, **_options)
37
+ respond(server_context) do |client|
38
+ count = occurrences.to_i.clamp(1, 20)
39
+ result = client.get("/exceptions/#{id.to_i}", { occurrences: count })
40
+ group = result["data"] || {}
41
+ recent = (group["occurrences"] || []).map { |o| format_occurrence(o) }
42
+ latest = recent.first
43
+
44
+ {
45
+ id: group["id"],
46
+ exception_class: group["exception_class"],
47
+ location: group["location"],
48
+ message: group["message"],
49
+ status: group["status"],
50
+ occurrence_count: group["occurrence_count"],
51
+ first_seen_at: group["first_seen_at"],
52
+ last_seen_at: group["last_seen_at"],
53
+ resolved_at: group["resolved_at"],
54
+ app_frames: latest ? latest[:app_frames] : [],
55
+ occurrences: recent,
56
+ summary: build_summary(group, latest),
57
+ next_steps: build_next_steps(group, latest)
58
+ }
59
+ end
60
+ end
61
+
62
+ private_class_method def self.format_occurrence(occurrence)
63
+ frames = Array(occurrence["backtrace"]).map { |f| format_frame(f) }
64
+ {
65
+ id: occurrence["id"],
66
+ occurred_at: occurrence["occurred_at"],
67
+ message: truncate(occurrence["message"], 500),
68
+ request: occurrence["request_url"] ? "#{occurrence["request_method"]} #{occurrence["request_url"]}".strip : nil,
69
+ request_params: occurrence["request_params"],
70
+ environment: occurrence["environment"],
71
+ deploy_sha: occurrence["deploy_sha"],
72
+ app_frames: frames.select { |f| app_frame?(f) },
73
+ backtrace: frames
74
+ }
75
+ end
76
+
77
+ private_class_method def self.format_frame(frame)
78
+ return { location: frame.to_s } unless frame.is_a?(Hash)
79
+
80
+ location = [ frame["file"], frame["line"] ].compact.join(":")
81
+ { location: location, method: frame["method"] }
82
+ end
83
+
84
+ private_class_method def self.app_frame?(frame)
85
+ frame[:location].to_s.start_with?("app/", "lib/", "config/")
86
+ end
87
+
88
+ private_class_method def self.build_summary(group, latest)
89
+ return "No exception group found." if group.empty?
90
+
91
+ parts = [ "#{group["exception_class"]} at #{group["location"] || "unknown location"}: " \
92
+ "#{group["status"]}, #{group["occurrence_count"]} occurrence(s), last #{group["last_seen_at"]}" ]
93
+ if latest
94
+ parts << (latest[:request] ? "Latest was #{latest[:request]}" : "Latest came from a background job")
95
+ top = latest[:app_frames].first
96
+ parts << "First app frame: #{top[:location]}#{top[:method] ? " in #{top[:method]}" : ""}" if top
97
+ end
98
+ parts.join(". ") + "."
99
+ end
100
+
101
+ private_class_method def self.build_next_steps(group, latest)
102
+ return [ "Check the id with rails_pulse_exceptions; nothing matched." ] if group.empty?
103
+
104
+ steps = []
105
+ top = latest && latest[:app_frames].first
106
+ if top
107
+ steps << "Open #{top[:location]} and read the code around it with the latest message in mind: #{truncate(group["message"], 160)}"
108
+ else
109
+ steps << "No application frame in the backtrace; the raise happened in a gem. Read the outermost frames to find the call site."
110
+ end
111
+ steps << "Compare the occurrences' request params and URLs to see what input triggers it." if latest && latest[:request]
112
+ steps << "Call rails_pulse_deployments with period: \"#{group["first_seen_at"]}\" to see whether a release introduced it." if group["first_seen_at"]
113
+ steps
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,128 @@
1
+ module RailsPulse
2
+ module Mcp
3
+ module Tools
4
+ class Exceptions < ::MCP::Tool
5
+ extend Helpers
6
+
7
+ tool_name "rails_pulse_exceptions"
8
+ description "Exception groups captured from web requests and background jobs: one entry per exception " \
9
+ "class and app-code location, with its status (open, resolved, ignored), occurrence count, " \
10
+ "latest message, and when it first and last fired. Use this to see what is actually raising " \
11
+ "right now; rails_pulse_errors only knows which endpoints answered 5xx."
12
+
13
+ annotations(
14
+ read_only_hint: true,
15
+ destructive_hint: false,
16
+ open_world_hint: false
17
+ )
18
+
19
+ input_schema(
20
+ properties: {
21
+ **Helpers.window_properties("last_7_days"),
22
+ period: {
23
+ type: "string",
24
+ description: "Only groups last seen in this window: 'last_hour', 'last_24_hours', 'last_7_days', " \
25
+ "or 'all' for no time filter. Ignored when 'since' or 'until' is given.",
26
+ default: "last_7_days"
27
+ },
28
+ status: {
29
+ type: "string",
30
+ description: "Lifecycle filter: 'open' (default), 'resolved', 'ignored', or 'all'",
31
+ default: "open"
32
+ },
33
+ search: {
34
+ type: "string",
35
+ description: "Substring match on the exception class or location, e.g. 'RecordNotFound' or 'app/models'"
36
+ },
37
+ fingerprint: {
38
+ type: "string",
39
+ description: "Exact fingerprint of one group, as returned by this tool or by Rails Pulse Cloud. " \
40
+ "Finds the group whatever its status or when it was last seen, so status and period are ignored."
41
+ },
42
+ limit: {
43
+ type: "integer",
44
+ description: "Maximum number of groups (1-100)",
45
+ default: 25
46
+ }
47
+ }
48
+ )
49
+
50
+ # `period: "all"` drops the time filter; groups are otherwise limited to
51
+ # those last seen inside the window. A fingerprint names one group, so
52
+ # it drops both filters: the defaults would hide a resolved group or
53
+ # one last seen a month ago, and the caller asked for that group.
54
+ def self.call(period: "last_7_days", status: "open", search: nil, fingerprint: nil, limit: 25, server_context:, **options)
55
+ respond(server_context) do |client|
56
+ limit = limit.to_i.clamp(1, 100)
57
+ if fingerprint.to_s != ""
58
+ period = "all"
59
+ status = "all"
60
+ options = options.except(:since, :until)
61
+ end
62
+ all_time = period.to_s == "all" && options[:since].nil? && options[:until].nil?
63
+ window = all_time ? nil : resolve_window(period: period, since: options[:since], until_time: options[:until])
64
+
65
+ params = { limit: limit, offset: 0 }
66
+ params.merge!(window_params(window)) if window
67
+ params[:status] = status unless status.to_s == "all"
68
+ params[:search] = search if search.to_s != ""
69
+ params[:fingerprint] = fingerprint if fingerprint.to_s != ""
70
+
71
+ result = client.get("/exceptions", params)
72
+ groups = (result["data"] || []).map { |g| format_group(g) }
73
+ total = result.dig("meta", "total") || groups.size
74
+
75
+ {
76
+ window: window || { period: "all" },
77
+ status_filter: status,
78
+ total_groups: total,
79
+ groups_returned: groups.size,
80
+ groups: groups,
81
+ summary: build_summary(groups, total, status),
82
+ next_steps: build_next_steps(groups)
83
+ }
84
+ end
85
+ end
86
+
87
+ private_class_method def self.format_group(group)
88
+ {
89
+ id: group["id"],
90
+ fingerprint: group["fingerprint"],
91
+ exception_class: group["exception_class"],
92
+ location: group["location"],
93
+ message: truncate(group["message"], 300),
94
+ status: group["status"],
95
+ occurrence_count: group["occurrence_count"],
96
+ first_seen_at: group["first_seen_at"],
97
+ last_seen_at: group["last_seen_at"],
98
+ resolved_at: group["resolved_at"]
99
+ }
100
+ end
101
+
102
+ private_class_method def self.build_summary(groups, total, status)
103
+ label = status.to_s == "all" ? "" : "#{status} "
104
+ return "No #{label}exception groups found for this period." if groups.empty?
105
+
106
+ occurrences = groups.sum { |g| g[:occurrence_count].to_i }
107
+ loudest = groups.max_by { |g| g[:occurrence_count].to_i }
108
+ parts = [ "#{total} #{label}exception group(s), #{occurrences} occurrences across the #{groups.size} returned" ]
109
+ parts << "Most frequent: #{loudest[:exception_class]} at #{loudest[:location] || 'unknown location'} " \
110
+ "(#{loudest[:occurrence_count]} occurrences)"
111
+ parts.join(". ") + "."
112
+ end
113
+
114
+ private_class_method def self.build_next_steps(groups)
115
+ return [ "Nothing is raising in this window. Widen period or set status: \"all\" to see resolved and ignored groups." ] if groups.empty?
116
+
117
+ steps = []
118
+ latest = groups.max_by { |g| g[:last_seen_at].to_s }
119
+ steps << "Start with #{latest[:exception_class]} at #{latest[:location] || 'unknown location'}: it fired most recently " \
120
+ "(#{latest[:last_seen_at]}). Read the location and message, then open the file at that frame."
121
+ steps << "Call rails_pulse_deployments for the same period to see whether the first_seen_at of a group lines up with a release."
122
+ steps << "Call rails_pulse_errors to see which endpoints answered 5xx as a result, and rails_pulse_jobs for failing job classes."
123
+ steps
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,162 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module RailsPulse
5
+ module Mcp
6
+ module Tools
7
+ module Helpers
8
+ PERIODS = {
9
+ "last_hour" => 3600,
10
+ "last_24_hours" => 86_400,
11
+ "last_7_days" => 604_800
12
+ }.freeze
13
+
14
+ # Raised for a time argument a tool cannot turn into a window. Carries
15
+ # the correction in its message, so the agent can fix the call rather
16
+ # than retry it unchanged.
17
+ class WindowError < ArgumentError; end
18
+
19
+ # A trailing Z or ±HH:MM offset.
20
+ ZONED = /(?:Z|[+-]\d{2}:?\d{2})\z/i
21
+
22
+ # ISO 8601: a date, optionally a time, optionally a zone. Anything else
23
+ # is refused rather than handed to Time.parse, which reads
24
+ # "last_30_days" as the 30th of this month and "24h" as midnight.
25
+ ISO8601 = /\A\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?(?:Z|[+-]\d{2}:?\d{2})?\z/i
26
+
27
+ # The default window when a tool is given neither a period nor bounds.
28
+ DEFAULT_PERIOD = "last_24_hours".freeze
29
+
30
+ # The time arguments every windowed tool accepts. Spliced into each
31
+ # tool's input_schema so the three stay described identically.
32
+ def self.window_properties(default_period)
33
+ {
34
+ period: {
35
+ type: "string",
36
+ description: "Relative window: 'last_hour', 'last_24_hours' or 'last_7_days'. For any other " \
37
+ "window pass 'since' and 'until'. Ignored when either is given.",
38
+ default: default_period
39
+ },
40
+ since: {
41
+ type: "string",
42
+ description: "Start of the window, ISO 8601 (e.g. '2026-09-24T12:00:00Z'). Read as UTC when no " \
43
+ "zone is given. Use with 'until' to pin an exact window that can be compared with another."
44
+ },
45
+ until: {
46
+ type: "string",
47
+ description: "End of the window, ISO 8601. Read as UTC when no zone is given. Omit to measure up to now."
48
+ }
49
+ }
50
+ end
51
+
52
+ # Turns a tool's time arguments into explicit UTC bounds.
53
+ #
54
+ # `period` names a relative window and is enough for "how are things
55
+ # right now". `since`/`until` pin an exact one, which is what makes a
56
+ # question repeatable: the 24 hours before a deploy stay the same 24
57
+ # hours however long afterwards they are asked about. Explicit bounds
58
+ # win when both are given.
59
+ #
60
+ # @return [Hash] `:since` and `:until` as ISO 8601 UTC strings (`:until`
61
+ # is nil when the window runs to now), plus `:period` describing which
62
+ # form was used.
63
+ def resolve_window(period: nil, since: nil, until_time: nil)
64
+ from = resolve_start(period, since)
65
+ to = until_time ? parse_time(until_time, "until") : nil
66
+
67
+ if to && from >= to
68
+ raise WindowError, "'since' (#{from.iso8601}) must be earlier than 'until' (#{to.iso8601})."
69
+ end
70
+ if from > Time.now.utc
71
+ raise WindowError, "'since' (#{from.iso8601}) is in the future, so nothing can have been recorded yet."
72
+ end
73
+
74
+ {
75
+ since: from.iso8601,
76
+ until: to&.iso8601,
77
+ period: since || until_time ? "custom" : (period || DEFAULT_PERIOD)
78
+ }
79
+ end
80
+
81
+ # A timestamp with no zone is read as UTC, so a window means the same
82
+ # thing on the agent's machine as on the server.
83
+ def parse_time(value, name)
84
+ string = value.to_s.strip
85
+ raise ArgumentError unless string.match?(ISO8601)
86
+
87
+ parsed = Time.parse(string)
88
+ return parsed.utc if string.match?(ZONED)
89
+
90
+ Time.utc(parsed.year, parsed.month, parsed.day, parsed.hour, parsed.min, parsed.sec)
91
+ rescue ArgumentError, TypeError
92
+ raise WindowError,
93
+ "Invalid #{name}: #{value.inspect}. Use an ISO 8601 timestamp such as 2026-09-24T12:00:00Z."
94
+ end
95
+
96
+ # The API's time arguments for a resolved window. `until` is omitted
97
+ # when the window runs to now, which the API reads as no upper bound.
98
+ def window_params(window)
99
+ params = { since: window[:since] }
100
+ params[:until] = window[:until] if window[:until]
101
+ params
102
+ end
103
+
104
+ def resolve_start(period, since)
105
+ return parse_time(since, "since") if since
106
+
107
+ seconds = PERIODS[period || DEFAULT_PERIOD]
108
+ return (Time.now.utc - seconds) if seconds
109
+
110
+ # An ISO 8601 period was the documented way to pin a start before
111
+ # `since` existed, so it is still read as one.
112
+ return parse_time(period, "period") if period.to_s.strip.match?(ISO8601)
113
+
114
+ raise WindowError,
115
+ "Unknown period #{period.inspect}. Use #{PERIODS.keys.join(', ')}, or pass 'since' and 'until' " \
116
+ "as ISO 8601 timestamps for any other window."
117
+ end
118
+
119
+ # Every row of a paginated endpoint, for a tool that ranks the rows
120
+ # itself. Bounded so an unexpectedly large table cannot keep the tool
121
+ # paging forever.
122
+ #
123
+ # @return [Array(Array<Hash>, Hash)] the rows and the first page's response
124
+ def fetch_all(client, path, params, page_size: 500, max_pages: 20)
125
+ rows = []
126
+ first = nil
127
+ max_pages.times do |page|
128
+ result = client.get(path, params.merge(limit: page_size, offset: page * page_size))
129
+ first ||= result
130
+ batch = result["data"] || []
131
+ rows.concat(batch)
132
+ total = result.dig("meta", "total")
133
+ break if batch.size < page_size || (total && rows.size >= total)
134
+ end
135
+ [ rows, first ]
136
+ end
137
+
138
+ def percentile(sorted, pct)
139
+ return 0 if sorted.empty?
140
+ k = ((pct / 100.0) * (sorted.size - 1)).ceil
141
+ sorted[k]
142
+ end
143
+
144
+ def truncate(str, length)
145
+ str = str.to_s
146
+ str.length > length ? "#{str[0, length]}..." : str
147
+ end
148
+
149
+ def respond(server_context)
150
+ payload = yield server_context[:client]
151
+ ::MCP::Tool::Response.new([ { type: "text", text: JSON.pretty_generate(payload) } ])
152
+ rescue WindowError => e
153
+ # The call is malformed rather than the server unreachable, so the
154
+ # message says how to correct it.
155
+ ::MCP::Tool::Response.new([ { type: "text", text: e.message } ], error: true)
156
+ rescue CLI::Client::ApiError => e
157
+ ::MCP::Tool::Response.new([ { type: "text", text: "Error querying Rails Pulse: #{e.message}" } ], error: true)
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end