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,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
|
|
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
|
-
|
|
30
|
-
|
|
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 =
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
74
|
+
RailsPulse::Current.skip_recording_rails_pulse_activity = true
|
|
75
75
|
yield
|
|
76
76
|
ensure
|
|
77
|
-
|
|
77
|
+
RailsPulse::Current.skip_recording_rails_pulse_activity = false
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
def response_size_bytes(headers, response)
|