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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -1
- data/README.md +15 -4
- data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
- data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
- data/app/controllers/concerns/chart_table_concern.rb +28 -69
- data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
- data/app/controllers/concerns/metric_card_concern.rb +6 -7
- data/app/controllers/rails_pulse/api/v1/base_controller.rb +133 -0
- data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
- data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
- data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +40 -0
- data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +77 -0
- data/app/controllers/rails_pulse/api/v1/insights_controller.rb +62 -0
- data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
- data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
- data/app/controllers/rails_pulse/api/v1/queries_controller.rb +123 -0
- data/app/controllers/rails_pulse/api/v1/requests_controller.rb +74 -0
- data/app/controllers/rails_pulse/api/v1/routes_controller.rb +103 -0
- data/app/controllers/rails_pulse/application_controller.rb +1 -1
- data/app/controllers/rails_pulse/dashboard_controller.rb +25 -21
- data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
- data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
- data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
- data/app/controllers/rails_pulse/queries_controller.rb +6 -5
- data/app/controllers/rails_pulse/requests_controller.rb +15 -14
- data/app/controllers/rails_pulse/routes_controller.rb +4 -8
- data/app/helpers/rails_pulse/application_helper.rb +15 -0
- data/app/helpers/rails_pulse/chart_helper.rb +9 -2
- data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
- data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
- data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
- data/app/jobs/rails_pulse/cloud_health_job.rb +11 -0
- data/app/jobs/rails_pulse/cloud_sync_job.rb +11 -0
- data/app/jobs/rails_pulse/summary_job.rb +13 -0
- data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
- data/app/models/concerns/rails_pulse/taggable.rb +5 -12
- data/app/models/rails_pulse/cards/base.rb +20 -11
- data/app/models/rails_pulse/charts/base.rb +5 -18
- data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -5
- data/app/models/rails_pulse/cleanup_run.rb +32 -0
- data/app/models/rails_pulse/cloud/buffered_batch.rb +55 -0
- data/app/models/rails_pulse/cloud/installation.rb +55 -0
- data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
- data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
- data/app/models/rails_pulse/dashboard/concerns/attention_classification.rb +104 -0
- data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +25 -2
- data/app/models/rails_pulse/dashboard/health_summary.rb +38 -13
- data/app/models/rails_pulse/dashboard/needs_attention.rb +31 -98
- data/app/models/rails_pulse/dashboard/storage_pressure.rb +39 -63
- data/app/models/rails_pulse/dashboard/storage_status.rb +130 -39
- data/app/models/rails_pulse/deployment.rb +2 -7
- data/app/models/rails_pulse/event.rb +41 -0
- data/app/models/rails_pulse/exception_occurrence.rb +1 -1
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -7
- data/app/models/rails_pulse/job_run.rb +1 -1
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
- data/app/models/rails_pulse/jobs/charts/duration.rb +1 -2
- data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -2
- data/app/models/rails_pulse/like_pattern.rb +35 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/charts/database_load.rb +5 -8
- data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/request.rb +1 -1
- data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
- data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
- data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
- data/app/models/rails_pulse/summary.rb +5 -0
- data/app/models/rails_pulse/tables/base.rb +5 -6
- data/app/models/rails_pulse/time_range.rb +309 -0
- data/app/models/rails_pulse/time_window.rb +15 -0
- data/app/models/rails_pulse/writer_heartbeat.rb +90 -0
- data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +23 -0
- data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
- data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
- data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
- data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
- data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
- data/app/serializers/rails_pulse/api/v1/request_serializer.rb +22 -0
- data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
- data/app/services/rails_pulse/cloud/batch.rb +83 -0
- data/app/services/rails_pulse/cloud/client.rb +68 -0
- data/app/services/rails_pulse/cloud/combined_metrics.rb +49 -0
- data/app/services/rails_pulse/cloud/health_item.rb +77 -0
- data/app/services/rails_pulse/cloud/path_prefix.rb +50 -0
- data/app/services/rails_pulse/cloud/preview.rb +83 -0
- data/app/services/rails_pulse/cloud/query_shape.rb +195 -0
- data/app/services/rails_pulse/cloud/record_items.rb +60 -0
- data/app/services/rails_pulse/cloud/route_pattern.rb +111 -0
- data/app/services/rails_pulse/cloud/summary_items.rb +188 -0
- data/app/services/rails_pulse/cloud/sync.rb +275 -0
- data/app/services/rails_pulse/config_recommendations.rb +132 -0
- data/app/services/rails_pulse/deployment_comparison.rb +177 -0
- data/app/services/rails_pulse/exception_capture_service.rb +1 -1
- data/app/services/rails_pulse/operations/change_point.rb +1 -1
- data/app/services/rails_pulse/operations/comparison.rb +1 -1
- data/app/services/rails_pulse/period_insights.rb +131 -0
- data/app/services/rails_pulse/sql_query_normalizer.rb +222 -122
- data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
- data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
- data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
- data/app/services/rails_pulse/summary_service.rb +121 -133
- data/app/services/rails_pulse/tag_filter_service.rb +5 -5
- data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
- data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
- data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +13 -3
- data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
- data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/jobs/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/queries/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/show.html.erb +1 -1
- data/app/views/rails_pulse/requests/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/routes/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/show.html.erb +1 -1
- data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
- data/app/views/rails_pulse/storage/show.html.erb +39 -2
- data/config/routes.rb +23 -0
- data/db/rails_pulse_cloud_schema.rb +58 -0
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
- data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
- data/db/rails_pulse_schema.rb +20 -2
- data/exe/rails-pulse +7 -0
- data/lib/generators/rails_pulse/base_methods.rb +19 -0
- data/lib/generators/rails_pulse/install_cloud_generator.rb +84 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_cloud_schema.rb +58 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_cloud_tables.rb +30 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +71 -19
- data/lib/generators/rails_pulse/upgrade_generator.rb +9 -16
- data/lib/rails_pulse/cleanup_service.rb +37 -20
- data/lib/rails_pulse/cli/agent_files/agents.md +89 -0
- data/lib/rails_pulse/cli/agent_files/claude_skill.md +182 -0
- data/lib/rails_pulse/cli/base_command.rb +37 -0
- data/lib/rails_pulse/cli/client.rb +64 -0
- data/lib/rails_pulse/cli/config.rb +87 -0
- data/lib/rails_pulse/cli/configure.rb +75 -0
- data/lib/rails_pulse/cli/coverage.rb +91 -0
- data/lib/rails_pulse/cli/deployments.rb +54 -0
- data/lib/rails_pulse/cli/exceptions.rb +116 -0
- data/lib/rails_pulse/cli/formatter.rb +31 -0
- data/lib/rails_pulse/cli/insights.rb +70 -0
- data/lib/rails_pulse/cli/install.rb +145 -0
- data/lib/rails_pulse/cli/job_runs.rb +53 -0
- data/lib/rails_pulse/cli/jobs.rb +71 -0
- data/lib/rails_pulse/cli/main.rb +76 -0
- data/lib/rails_pulse/cli/mcp.rb +47 -0
- data/lib/rails_pulse/cli/queries.rb +67 -0
- data/lib/rails_pulse/cli/requests.rb +57 -0
- data/lib/rails_pulse/cli/routes.rb +73 -0
- data/lib/rails_pulse/configuration.rb +100 -2
- data/lib/rails_pulse/current.rb +11 -0
- data/lib/rails_pulse/engine.rb +12 -3
- data/lib/rails_pulse/installers/config_updater.rb +9 -2
- data/lib/rails_pulse/job_run_collector.rb +15 -15
- data/lib/rails_pulse/mcp/server.rb +78 -0
- data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
- data/lib/rails_pulse/mcp/tools/deployments.rb +117 -0
- data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
- data/lib/rails_pulse/mcp/tools/errors.rb +104 -0
- data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
- data/lib/rails_pulse/mcp/tools/exceptions.rb +128 -0
- data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
- data/lib/rails_pulse/mcp/tools/insights.rb +99 -0
- data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
- data/lib/rails_pulse/mcp/tools/queries.rb +142 -0
- data/lib/rails_pulse/mcp/tools/routes.rb +108 -0
- data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -9
- data/lib/rails_pulse/scoped_inflector.rb +35 -0
- data/lib/rails_pulse/statistics.rb +27 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
- data/lib/rails_pulse/tasks/status_reporter.rb +201 -0
- data/lib/rails_pulse/tracker.rb +76 -14
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +0 -4
- data/lib/tasks/rails_pulse.rake +6 -0
- data/lib/tasks/rails_pulse_tasks.rake +7 -0
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +13 -13
- metadata +123 -24
- data/app/controllers/concerns/page_timings.rb +0 -10
- data/app/controllers/concerns/response_range_concern.rb +0 -45
- data/app/controllers/concerns/time_range_concern.rb +0 -152
- 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
|