rails_pulse 0.4.2 → 0.5.0.pre.1

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 (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -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 +130 -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 +24 -0
  14. data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +76 -0
  15. data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
  16. data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
  17. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +114 -0
  18. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +58 -0
  19. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +98 -0
  20. data/app/controllers/rails_pulse/application_controller.rb +1 -1
  21. data/app/controllers/rails_pulse/dashboard_controller.rb +20 -20
  22. data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
  23. data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
  24. data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
  25. data/app/controllers/rails_pulse/queries_controller.rb +6 -5
  26. data/app/controllers/rails_pulse/requests_controller.rb +15 -14
  27. data/app/controllers/rails_pulse/routes_controller.rb +4 -8
  28. data/app/helpers/rails_pulse/application_helper.rb +15 -0
  29. data/app/helpers/rails_pulse/chart_helper.rb +9 -2
  30. data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
  31. data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
  32. data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
  33. data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
  34. data/app/models/concerns/rails_pulse/taggable.rb +5 -12
  35. data/app/models/rails_pulse/cards/base.rb +20 -11
  36. data/app/models/rails_pulse/charts/base.rb +2 -6
  37. data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -4
  38. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
  39. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
  40. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +1 -2
  41. data/app/models/rails_pulse/dashboard/health_summary.rb +30 -3
  42. data/app/models/rails_pulse/dashboard/needs_attention.rb +2 -3
  43. data/app/models/rails_pulse/dashboard/storage_pressure.rb +34 -4
  44. data/app/models/rails_pulse/dashboard/storage_status.rb +36 -0
  45. data/app/models/rails_pulse/deployment.rb +2 -7
  46. data/app/models/rails_pulse/event.rb +40 -0
  47. data/app/models/rails_pulse/exception_occurrence.rb +1 -1
  48. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
  49. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
  50. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
  51. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -5
  52. data/app/models/rails_pulse/job_run.rb +1 -1
  53. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
  54. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
  55. data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
  56. data/app/models/rails_pulse/jobs/charts/duration.rb +1 -1
  57. data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
  58. data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -1
  59. data/app/models/rails_pulse/like_pattern.rb +29 -0
  60. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
  61. data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
  62. data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
  63. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
  64. data/app/models/rails_pulse/queries/charts/database_load.rb +5 -6
  65. data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
  66. data/app/models/rails_pulse/request.rb +1 -1
  67. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
  68. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
  69. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
  70. data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
  71. data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
  72. data/app/models/rails_pulse/summary.rb +5 -0
  73. data/app/models/rails_pulse/tables/base.rb +5 -6
  74. data/app/models/rails_pulse/time_range.rb +309 -0
  75. data/app/models/rails_pulse/writer_heartbeat.rb +86 -0
  76. data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +20 -0
  77. data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
  78. data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
  79. data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
  80. data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
  81. data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
  82. data/app/serializers/rails_pulse/api/v1/request_serializer.rb +21 -0
  83. data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
  84. data/app/services/rails_pulse/exception_capture_service.rb +1 -1
  85. data/app/services/rails_pulse/operations/comparison.rb +1 -1
  86. data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
  87. data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
  88. data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
  89. data/app/services/rails_pulse/summary_service.rb +121 -133
  90. data/app/services/rails_pulse/tag_filter_service.rb +5 -5
  91. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
  92. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  93. data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
  94. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
  95. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
  96. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
  97. data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
  98. data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
  99. data/app/views/rails_pulse/jobs/index.html.erb +1 -1
  100. data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
  101. data/app/views/rails_pulse/queries/index.html.erb +1 -1
  102. data/app/views/rails_pulse/queries/show.html.erb +1 -1
  103. data/app/views/rails_pulse/requests/index.html.erb +1 -1
  104. data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
  105. data/app/views/rails_pulse/routes/index.html.erb +1 -1
  106. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  107. data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
  108. data/app/views/rails_pulse/storage/show.html.erb +37 -0
  109. data/config/routes.rb +19 -0
  110. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
  111. data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
  112. data/db/rails_pulse_schema.rb +20 -2
  113. data/exe/rails-pulse +7 -0
  114. data/lib/generators/rails_pulse/base_methods.rb +1 -0
  115. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
  116. data/lib/generators/rails_pulse/templates/rails_pulse.rb +36 -13
  117. data/lib/rails_pulse/cleanup_service.rb +26 -20
  118. data/lib/rails_pulse/cli/agent_files/agents.md +87 -0
  119. data/lib/rails_pulse/cli/agent_files/claude_skill.md +165 -0
  120. data/lib/rails_pulse/cli/base_command.rb +37 -0
  121. data/lib/rails_pulse/cli/client.rb +64 -0
  122. data/lib/rails_pulse/cli/config.rb +87 -0
  123. data/lib/rails_pulse/cli/configure.rb +75 -0
  124. data/lib/rails_pulse/cli/coverage.rb +91 -0
  125. data/lib/rails_pulse/cli/deployments.rb +40 -0
  126. data/lib/rails_pulse/cli/exceptions.rb +111 -0
  127. data/lib/rails_pulse/cli/formatter.rb +31 -0
  128. data/lib/rails_pulse/cli/install.rb +145 -0
  129. data/lib/rails_pulse/cli/job_runs.rb +53 -0
  130. data/lib/rails_pulse/cli/jobs.rb +71 -0
  131. data/lib/rails_pulse/cli/main.rb +72 -0
  132. data/lib/rails_pulse/cli/mcp.rb +47 -0
  133. data/lib/rails_pulse/cli/queries.rb +62 -0
  134. data/lib/rails_pulse/cli/requests.rb +49 -0
  135. data/lib/rails_pulse/cli/routes.rb +66 -0
  136. data/lib/rails_pulse/configuration.rb +23 -1
  137. data/lib/rails_pulse/current.rb +11 -0
  138. data/lib/rails_pulse/engine.rb +12 -3
  139. data/lib/rails_pulse/job_run_collector.rb +15 -15
  140. data/lib/rails_pulse/mcp/server.rb +76 -0
  141. data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
  142. data/lib/rails_pulse/mcp/tools/deployments.rb +80 -0
  143. data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
  144. data/lib/rails_pulse/mcp/tools/errors.rb +95 -0
  145. data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
  146. data/lib/rails_pulse/mcp/tools/exceptions.rb +114 -0
  147. data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
  148. data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
  149. data/lib/rails_pulse/mcp/tools/queries.rb +136 -0
  150. data/lib/rails_pulse/mcp/tools/routes.rb +97 -0
  151. data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
  152. data/lib/rails_pulse/middleware/request_collector.rb +9 -9
  153. data/lib/rails_pulse/scoped_inflector.rb +35 -0
  154. data/lib/rails_pulse/statistics.rb +27 -0
  155. data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
  156. data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
  157. data/lib/rails_pulse/tasks/status_reporter.rb +26 -0
  158. data/lib/rails_pulse/tracker.rb +75 -14
  159. data/lib/rails_pulse/version.rb +1 -1
  160. data/lib/rails_pulse.rb +0 -4
  161. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  162. data/public/rails-pulse-assets/rails-pulse.js +13 -13
  163. metadata +96 -24
  164. data/app/controllers/concerns/page_timings.rb +0 -10
  165. data/app/controllers/concerns/response_range_concern.rb +0 -45
  166. data/app/controllers/concerns/time_range_concern.rb +0 -152
  167. data/app/controllers/concerns/zoom_range_concern.rb +0 -86
@@ -0,0 +1,114 @@
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
+ limit: {
38
+ type: "integer",
39
+ description: "Maximum number of groups (1-100)",
40
+ default: 25
41
+ }
42
+ }
43
+ )
44
+
45
+ # `period: "all"` drops the time filter; groups are otherwise limited to
46
+ # those last seen inside the window.
47
+ def self.call(period: "last_7_days", status: "open", search: nil, limit: 25, server_context:, **options)
48
+ respond(server_context) do |client|
49
+ limit = limit.to_i.clamp(1, 100)
50
+ all_time = period.to_s == "all" && options[:since].nil? && options[:until].nil?
51
+ window = all_time ? nil : resolve_window(period: period, since: options[:since], until_time: options[:until])
52
+
53
+ params = { limit: limit, offset: 0 }
54
+ params.merge!(window_params(window)) if window
55
+ params[:status] = status unless status.to_s == "all"
56
+ params[:search] = search if search.to_s != ""
57
+
58
+ result = client.get("/exceptions", params)
59
+ groups = (result["data"] || []).map { |g| format_group(g) }
60
+ total = result.dig("meta", "total") || groups.size
61
+
62
+ {
63
+ window: window || { period: "all" },
64
+ status_filter: status,
65
+ total_groups: total,
66
+ groups_returned: groups.size,
67
+ groups: groups,
68
+ summary: build_summary(groups, total, status),
69
+ next_steps: build_next_steps(groups)
70
+ }
71
+ end
72
+ end
73
+
74
+ private_class_method def self.format_group(group)
75
+ {
76
+ id: group["id"],
77
+ exception_class: group["exception_class"],
78
+ location: group["location"],
79
+ message: truncate(group["message"], 300),
80
+ status: group["status"],
81
+ occurrence_count: group["occurrence_count"],
82
+ first_seen_at: group["first_seen_at"],
83
+ last_seen_at: group["last_seen_at"],
84
+ resolved_at: group["resolved_at"]
85
+ }
86
+ end
87
+
88
+ private_class_method def self.build_summary(groups, total, status)
89
+ label = status.to_s == "all" ? "" : "#{status} "
90
+ return "No #{label}exception groups found for this period." if groups.empty?
91
+
92
+ occurrences = groups.sum { |g| g[:occurrence_count].to_i }
93
+ loudest = groups.max_by { |g| g[:occurrence_count].to_i }
94
+ parts = [ "#{total} #{label}exception group(s), #{occurrences} occurrences across the #{groups.size} returned" ]
95
+ parts << "Most frequent: #{loudest[:exception_class]} at #{loudest[:location] || 'unknown location'} " \
96
+ "(#{loudest[:occurrence_count]} occurrences)"
97
+ parts.join(". ") + "."
98
+ end
99
+
100
+ private_class_method def self.build_next_steps(groups)
101
+ return [ "Nothing is raising in this window. Widen period or set status: \"all\" to see resolved and ignored groups." ] if groups.empty?
102
+
103
+ steps = []
104
+ latest = groups.max_by { |g| g[:last_seen_at].to_s }
105
+ steps << "Start with #{latest[:exception_class]} at #{latest[:location] || 'unknown location'}: it fired most recently " \
106
+ "(#{latest[:last_seen_at]}). Read the location and message, then open the file at that frame."
107
+ steps << "Call rails_pulse_deployments for the same period to see whether the first_seen_at of a group lines up with a release."
108
+ steps << "Call rails_pulse_errors to see which endpoints answered 5xx as a result, and rails_pulse_jobs for failing job classes."
109
+ steps
110
+ end
111
+ end
112
+ end
113
+ end
114
+ 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
@@ -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,136 @@
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
+ }
46
+ )
47
+
48
+ def self.call(period: "last_24_hours", limit: 10, sort: "total_duration", n_plus_one_only: false, route: nil, server_context:, **options)
49
+ respond(server_context) do |client|
50
+ window = resolve_window(period: period, since: options[:since], until_time: options[:until])
51
+ limit = limit.to_i.clamp(1, 50)
52
+ # The N+1 flag is filtered here, not by the API, so fetch the
53
+ # largest page it allows and keep the first `limit` matches.
54
+ page = n_plus_one_only ? MAX_PAGE : limit
55
+ params = window_params(window).merge(sort: sort, limit: page)
56
+ params[:route] = route if route
57
+ result = client.get("/queries", params)
58
+
59
+ queries = (result["data"] || []).map { |q| format_query(q) }
60
+ queries = queries.select { |q| q[:n_plus_one][:likely] }.first(limit) if n_plus_one_only
61
+
62
+ payload = {
63
+ window: window,
64
+ sort: sort,
65
+ total_queries: result.dig("meta", "total") || queries.size,
66
+ queries: queries,
67
+ summary: build_summary(queries),
68
+ next_steps: build_next_steps(queries)
69
+ }
70
+ payload[:route] = route if route
71
+ payload
72
+ end
73
+ end
74
+
75
+ # Transaction control repeats within a request by design; it is never
76
+ # an N+1 however often it appears.
77
+ TRANSACTION_STATEMENT = /\A(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\b/i
78
+
79
+ private_class_method def self.format_query(query)
80
+ stats = query["stats"] || {}
81
+ analysis = query["n_plus_one"] || {}
82
+ repetition = stats["max_repetition_count"].to_i
83
+ sql = query["normalized_sql"].to_s.gsub(/\s+/, " ").strip
84
+ repeated = repetition > 1 && !sql.match?(TRANSACTION_STATEMENT)
85
+
86
+ {
87
+ id: query["id"],
88
+ sql: truncate(sql, SQL_LENGTH),
89
+ executions: stats["executions"],
90
+ avg_duration_ms: stats["avg_duration_ms"],
91
+ max_duration_ms: stats["max_duration_ms"],
92
+ total_duration_ms: stats["total_duration_ms"],
93
+ n_plus_one: {
94
+ likely: analysis["likely"] == true || repeated,
95
+ confidence: analysis["confidence"],
96
+ max_repetition_count: repetition > 0 ? repetition : nil
97
+ },
98
+ issue_count: Array(query["issues"]).size,
99
+ suggestions: Array(query["suggestions"]),
100
+ # Where the query was issued from, most frequent first, so the
101
+ # investigation reaches the code rather than stopping at the SQL.
102
+ source_locations: Array(stats["source_locations"])
103
+ }
104
+ end
105
+
106
+ private_class_method def self.build_summary(queries)
107
+ return "No query activity found for this period." if queries.empty?
108
+
109
+ top = queries.first
110
+ n_plus_one = queries.count { |q| q[:n_plus_one][:likely] }
111
+ parts = [ "Top query by #{top[:executions]} executions / #{top[:total_duration_ms]}ms total: #{truncate(top[:sql], 80)}" ]
112
+ parts << "#{n_plus_one} likely N+1 quer#{n_plus_one == 1 ? 'y' : 'ies'}" if n_plus_one > 0
113
+ parts.join(". ") + "."
114
+ end
115
+
116
+ private_class_method def self.build_next_steps(queries)
117
+ steps = []
118
+ if queries.any? { |q| q[:n_plus_one][:likely] }
119
+ steps << "N+1 candidates: find the call site and preload the association (`includes`/`preload`) or batch the lookup."
120
+ end
121
+ if queries.any? { |q| q[:avg_duration_ms].to_f > 100 }
122
+ steps << "Queries averaging over 100ms: check `suggestions` for missing indexes and run EXPLAIN on the normalized SQL."
123
+ end
124
+ if queries.any? { |q| q[:executions].to_i > 1000 && q[:avg_duration_ms].to_f < 5 }
125
+ steps << "Very frequent fast queries add up — consider caching or reducing calls per request."
126
+ end
127
+ if queries.any? { |q| q[:source_locations].any? }
128
+ steps << "source_locations names the file and line each query was issued from — start there rather than searching for the SQL."
129
+ end
130
+ steps << "Use rails_pulse_endpoint to see which endpoints are slow and correlate with these queries."
131
+ steps
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,97 @@
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
+ limit: {
25
+ type: "integer",
26
+ description: "Maximum number of routes (1-100)",
27
+ default: 50
28
+ },
29
+ sort: {
30
+ type: "string",
31
+ description: "Order by 'request_count' (default), 'avg_duration', or 'error_count'",
32
+ default: "request_count"
33
+ }
34
+ }
35
+ )
36
+
37
+ def self.call(period: "last_7_days", search: nil, limit: 50, sort: "request_count", 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, 100)
41
+
42
+ params = window_params(window).merge(sort: sort, limit: limit)
43
+ params[:search] = search if search
44
+ result = client.get("/routes", params)
45
+
46
+ routes = (result["data"] || []).map { |r| format_route(r) }
47
+ routes.select! { |r| matches?(r, search) } if search
48
+
49
+ {
50
+ window: window,
51
+ sort: sort,
52
+ total_routes: result.dig("meta", "total") || routes.size,
53
+ routes: routes,
54
+ summary: build_summary(routes, search),
55
+ next_steps: [
56
+ "Pass a route's controller_action (e.g. 'CheckoutController#create') to rails_pulse_endpoint for a full profile.",
57
+ "Routes with traffic but no controller_action are usually engine or Rack endpoints."
58
+ ]
59
+ }
60
+ end
61
+ end
62
+
63
+ private_class_method def self.format_route(route)
64
+ stats = route["stats"] || {}
65
+ {
66
+ id: route["id"],
67
+ http_methods: route["http_methods"],
68
+ path: route["path"],
69
+ controller_action: route["controller_action"],
70
+ request_count: stats["request_count"],
71
+ avg_duration_ms: stats["avg_duration_ms"],
72
+ error_count: stats["error_count"],
73
+ tags: route["tags"]
74
+ }
75
+ end
76
+
77
+ private_class_method def self.matches?(route, search)
78
+ term = search.downcase
79
+ route[:path].to_s.downcase.include?(term) || route[:controller_action].to_s.downcase.include?(term)
80
+ end
81
+
82
+ private_class_method def self.build_summary(routes, search)
83
+ if routes.empty?
84
+ return search ? "No routes matching '#{search}' had traffic in this period." : "No routes had traffic in this period."
85
+ end
86
+
87
+ busiest = routes.max_by { |r| r[:request_count].to_i }
88
+ parts = [ "#{routes.size} route(s) with traffic" ]
89
+ parts << "Busiest: #{busiest[:controller_action] || busiest[:path]} (#{busiest[:request_count]} requests)"
90
+ slowest = routes.max_by { |r| r[:avg_duration_ms].to_f }
91
+ parts << "Slowest: #{slowest[:controller_action] || slowest[:path]} (avg #{slowest[:avg_duration_ms]}ms)" if slowest
92
+ parts.join(". ") + "."
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end