rails_vitals 0.5.0 → 0.6.0
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/README.md +33 -4
- data/lib/rails_vitals/engine.rb +4 -0
- data/lib/rails_vitals/mcp/tools/explain_query.rb +104 -0
- data/lib/rails_vitals/mcp/tools/get_request_log.rb +96 -0
- data/lib/rails_vitals/mcp/tools/get_schema_context.rb +115 -0
- data/lib/rails_vitals/mcp/tools/get_slow_queries.rb +98 -0
- data/lib/rails_vitals/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 82d62304d92259c9c248b8033948784ba6bb5075d7f7af627c445624a042901a
|
|
4
|
+
data.tar.gz: 6285bf97528093389e861f96bb45259d434e336042c7d23e30fab02109e690ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0ae1ce76d1ac8c1bbf7559a119e1680f87b40faafe989f0e6c6afb9ec5f3cb8c374a1899a32ca28f04d39ee7ddb4e6f4b73251f16835c8a9587590ecc6ff6cc8
|
|
7
|
+
data.tar.gz: 48245cadf704dc9bced9103cad91485d0b9b1c1173b11b50fd79b8964259cb41eaba8af42ff86ca95c1d6ba16ddc52e1f88120159317065e36fb2a4c53d10792
|
data/README.md
CHANGED
|
@@ -168,8 +168,29 @@ RailsVitals includes a built-in [MCP (Model Context Protocol)](https://modelcont
|
|
|
168
168
|
|
|
169
169
|
Ask Claude things like:
|
|
170
170
|
|
|
171
|
+
**Health score** (`railsvitals_get_score`)
|
|
171
172
|
> "What is my Rails app's health score right now?"
|
|
173
|
+
> "How much would my score improve if I fixed all N+1 patterns?"
|
|
174
|
+
|
|
175
|
+
**N+1 patterns** (`railsvitals_get_n1_queries`)
|
|
172
176
|
> "Show me the top 3 N+1 patterns and how to fix them."
|
|
177
|
+
> "Which endpoint is responsible for the most N+1 occurrences?"
|
|
178
|
+
|
|
179
|
+
**Slow queries** (`railsvitals_get_slow_queries`)
|
|
180
|
+
> "Show me queries slower than 50ms."
|
|
181
|
+
> "What is the single slowest query across all recent requests and which endpoint fired it?"
|
|
182
|
+
|
|
183
|
+
**Request log** (`railsvitals_get_request_log`)
|
|
184
|
+
> "Which requests from FeedController had the worst score?"
|
|
185
|
+
> "Is PostsController#index consistently slow or only sometimes?"
|
|
186
|
+
|
|
187
|
+
**Schema context** (`railsvitals_get_schema_context`)
|
|
188
|
+
> "What models have foreign keys without an index?"
|
|
189
|
+
> "Show me the full schema for Post and Comment including their associations."
|
|
190
|
+
|
|
191
|
+
**EXPLAIN** (`railsvitals_explain_query`)
|
|
192
|
+
> "Run EXPLAIN on this slow query and tell me what's wrong."
|
|
193
|
+
> "Why is this query doing a sequential scan and how do I fix it?"
|
|
173
194
|
|
|
174
195
|
### Enabling the MCP server
|
|
175
196
|
|
|
@@ -218,7 +239,11 @@ Restart Claude Desktop and look for the RailsVitals tools in the tool picker.
|
|
|
218
239
|
| Tool | Description |
|
|
219
240
|
|------|-------------|
|
|
220
241
|
| `railsvitals_get_score` | Overall health score, grade, score breakdown by dimension, N+1 penalties, and projected score if all N+1 patterns were fixed |
|
|
221
|
-
| `railsvitals_get_n1_queries` | All detected N+1 patterns ranked by occurrences, with affected endpoints and concrete `includes()` fix suggestions. Accepts
|
|
242
|
+
| `railsvitals_get_n1_queries` | All detected N+1 patterns ranked by occurrences, with affected endpoints and concrete `includes()` fix suggestions. Accepts `limit` |
|
|
243
|
+
| `railsvitals_get_slow_queries` | Individual slow queries across all recent requests, ordered by duration. Accepts `threshold_ms` override and `limit` |
|
|
244
|
+
| `railsvitals_get_request_log` | Recent requests ordered most-recent-first with score, query count, DB time, and N+1 count. Accepts `controller` filter and `limit` |
|
|
245
|
+
| `railsvitals_get_schema_context` | Columns, indexes, associations, and missing FK indexes for ActiveRecord models. Accepts a `models` array to scope results |
|
|
246
|
+
| `railsvitals_explain_query` | Runs `EXPLAIN ANALYZE` on a SELECT query and returns warnings, fix suggestions with migration snippets, and a plain-English interpretation. Requires `sql` param |
|
|
222
247
|
|
|
223
248
|
### How it's structured
|
|
224
249
|
|
|
@@ -229,9 +254,13 @@ lib/rails_vitals/mcp/
|
|
|
229
254
|
├── response_builder.rb # MCP response formatting helpers
|
|
230
255
|
├── tool_registry.rb # Declarative class-based tool registry
|
|
231
256
|
└── tools/
|
|
232
|
-
├── base.rb
|
|
233
|
-
├── get_score.rb
|
|
234
|
-
|
|
257
|
+
├── base.rb # Base class: call + definition interface
|
|
258
|
+
├── get_score.rb # railsvitals_get_score
|
|
259
|
+
├── get_n1_queries.rb # railsvitals_get_n1_queries
|
|
260
|
+
├── get_slow_queries.rb # railsvitals_get_slow_queries
|
|
261
|
+
├── get_request_log.rb # railsvitals_get_request_log
|
|
262
|
+
├── get_schema_context.rb # railsvitals_get_schema_context
|
|
263
|
+
└── explain_query.rb # railsvitals_explain_query
|
|
235
264
|
```
|
|
236
265
|
|
|
237
266
|
Each tool inherits from `Tools::Base`, implements `call(params)` returning a plain hash, and self-registers at load time via `ToolRegistry.register(ToolClass)`.
|
data/lib/rails_vitals/engine.rb
CHANGED
|
@@ -25,6 +25,10 @@ module RailsVitals
|
|
|
25
25
|
require "rails_vitals/mcp/request_handler"
|
|
26
26
|
require "rails_vitals/mcp/tools/get_score"
|
|
27
27
|
require "rails_vitals/mcp/tools/get_n1_queries"
|
|
28
|
+
require "rails_vitals/mcp/tools/get_slow_queries"
|
|
29
|
+
require "rails_vitals/mcp/tools/get_request_log"
|
|
30
|
+
require "rails_vitals/mcp/tools/get_schema_context"
|
|
31
|
+
require "rails_vitals/mcp/tools/explain_query"
|
|
28
32
|
end
|
|
29
33
|
end
|
|
30
34
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module MCP
|
|
3
|
+
module Tools
|
|
4
|
+
class ExplainQuery < Base
|
|
5
|
+
TOOL_NAME = "railsvitals_explain_query"
|
|
6
|
+
|
|
7
|
+
DESCRIPTION = <<~DESC.strip
|
|
8
|
+
Runs EXPLAIN ANALYZE on a SELECT query and returns the execution plan summary:
|
|
9
|
+
total cost, actual execution time, rows examined, detected warnings (Seq Scan,
|
|
10
|
+
Sort without index, large Nested Loop), and deterministic fix suggestions with
|
|
11
|
+
migration hints. Only SELECT statements are accepted — any SQL containing DML
|
|
12
|
+
keywords (INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER) is rejected, including
|
|
13
|
+
CTEs. Use this to investigate a specific slow query from railsvitals_get_slow_queries.
|
|
14
|
+
|
|
15
|
+
When presenting results, always lead with the interpretation field as a plain-English
|
|
16
|
+
verdict. Then highlight each warning by name and explain what it means for this specific
|
|
17
|
+
query (table name, rows scanned). For each suggestion, show the body explanation first,
|
|
18
|
+
then the migration snippet in a code block, then the generator command if present.
|
|
19
|
+
Avoid listing raw numbers without context — translate total_cost and rows_examined into
|
|
20
|
+
plain language (e.g. "scanned 50,000 rows to return 3" instead of "rows_examined: 50000").
|
|
21
|
+
DESC
|
|
22
|
+
|
|
23
|
+
INPUT_SCHEMA = {
|
|
24
|
+
type: "object",
|
|
25
|
+
required: [ "sql" ],
|
|
26
|
+
properties: {
|
|
27
|
+
sql: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "The SELECT query to explain. Must not contain INSERT, UPDATE, DELETE, DROP, TRUNCATE, or ALTER — including inside CTEs."
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
DML_PATTERN = /\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE)\b/i.freeze
|
|
35
|
+
|
|
36
|
+
def call(params)
|
|
37
|
+
sql = params[:sql] || params["sql"]
|
|
38
|
+
|
|
39
|
+
return missing_sql_response if sql.nil? || sql.strip.empty?
|
|
40
|
+
return rejected_sql_response(sql) if dml_present?(sql)
|
|
41
|
+
|
|
42
|
+
result = Analyzers::ExplainAnalyzer.analyze(sql.strip)
|
|
43
|
+
|
|
44
|
+
return error_response(result.error) if result.error
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
sql: result.sql,
|
|
48
|
+
total_cost: result.total_cost,
|
|
49
|
+
actual_time_ms: result.actual_time_ms,
|
|
50
|
+
rows_examined: result.rows_examined,
|
|
51
|
+
warnings: serialize_warnings(result.warnings),
|
|
52
|
+
suggestions: serialize_suggestions(result.suggestions),
|
|
53
|
+
interpretation: result.interpretation
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def dml_present?(sql)
|
|
60
|
+
sql.match?(DML_PATTERN)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def serialize_warnings(warnings)
|
|
64
|
+
warnings.map do |w|
|
|
65
|
+
entry = { type: w[:type].to_s, severity: w[:severity].to_s }
|
|
66
|
+
entry[:table] = w[:table] if w[:table]
|
|
67
|
+
entry[:rows_scanned] = w[:rows] if w[:rows]
|
|
68
|
+
entry
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def serialize_suggestions(suggestions)
|
|
73
|
+
suggestions.map do |s|
|
|
74
|
+
entry = {
|
|
75
|
+
severity: s[:severity].to_s,
|
|
76
|
+
title: s[:title],
|
|
77
|
+
body: s[:body]
|
|
78
|
+
}
|
|
79
|
+
entry[:migration] = s[:migration] if s[:migration]
|
|
80
|
+
entry[:command] = s[:command] if s[:command]
|
|
81
|
+
entry
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def missing_sql_response
|
|
86
|
+
{ error: "Missing required parameter: sql. Provide a SELECT query to explain." }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def rejected_sql_response(sql)
|
|
90
|
+
keyword = sql.match(DML_PATTERN)&.captures&.first&.upcase
|
|
91
|
+
{ error: "SQL rejected: contains #{keyword}. Only SELECT statements are allowed. " \
|
|
92
|
+
"DML inside CTEs (WITH ... DELETE/INSERT/UPDATE) is also rejected because " \
|
|
93
|
+
"EXPLAIN ANALYZE executes the query." }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def error_response(message)
|
|
97
|
+
{ error: message }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
ToolRegistry.register(ExplainQuery)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module MCP
|
|
3
|
+
module Tools
|
|
4
|
+
class GetRequestLog < Base
|
|
5
|
+
TOOL_NAME = "railsvitals_get_request_log"
|
|
6
|
+
DEFAULT_LIMIT = 20
|
|
7
|
+
|
|
8
|
+
DESCRIPTION = <<~DESC.strip
|
|
9
|
+
Returns recent requests recorded by RailsVitals, ordered most recent first.
|
|
10
|
+
Each entry includes endpoint, health score, query count, total DB time, N+1
|
|
11
|
+
pattern count, and request duration. Use the controller param to scope results
|
|
12
|
+
to a specific controller. Use this to spot whether problems are consistent or
|
|
13
|
+
intermittent and to identify which requests to investigate further.
|
|
14
|
+
DESC
|
|
15
|
+
|
|
16
|
+
INPUT_SCHEMA = {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
controller: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Filter by controller name (case-insensitive, partial match). 'Feed' matches FeedController."
|
|
22
|
+
},
|
|
23
|
+
limit: {
|
|
24
|
+
type: "integer",
|
|
25
|
+
description: "Maximum number of requests to return, most recent first. Defaults to 20."
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
def call(params)
|
|
31
|
+
records = RailsVitals.store.all
|
|
32
|
+
return no_data_response if records.empty?
|
|
33
|
+
|
|
34
|
+
controller_filter = params[:controller] || params["controller"]
|
|
35
|
+
limit = (params[:limit] || params["limit"] || DEFAULT_LIMIT).to_i
|
|
36
|
+
|
|
37
|
+
filtered = filter_records(records, controller_filter)
|
|
38
|
+
return no_match_response(controller_filter) if filtered.empty?
|
|
39
|
+
|
|
40
|
+
shown = filtered.last(limit).reverse
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
total_requests: filtered.size,
|
|
44
|
+
shown: shown.size,
|
|
45
|
+
controller_filter: controller_filter,
|
|
46
|
+
requests: shown.map { |r| serialize(r) }
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def filter_records(records, controller_filter)
|
|
53
|
+
return records unless controller_filter
|
|
54
|
+
|
|
55
|
+
records.select { |r| r.controller.downcase.include?(controller_filter.downcase) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def serialize(record)
|
|
59
|
+
{
|
|
60
|
+
request_id: record.id,
|
|
61
|
+
endpoint: record.endpoint,
|
|
62
|
+
score: record.score,
|
|
63
|
+
grade: record.label,
|
|
64
|
+
query_count: record.total_query_count,
|
|
65
|
+
db_time_ms: record.total_db_time_ms.round(1),
|
|
66
|
+
n1_patterns: record.n_plus_one_patterns.size,
|
|
67
|
+
duration_ms: record.duration_ms&.round(1),
|
|
68
|
+
recorded_at: record.recorded_at.strftime("%H:%M:%S")
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def no_data_response
|
|
73
|
+
{
|
|
74
|
+
total_requests: 0,
|
|
75
|
+
shown: 0,
|
|
76
|
+
controller_filter: nil,
|
|
77
|
+
requests: [],
|
|
78
|
+
message: "No requests recorded yet. Make some requests to the app first."
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def no_match_response(controller_filter)
|
|
83
|
+
{
|
|
84
|
+
total_requests: 0,
|
|
85
|
+
shown: 0,
|
|
86
|
+
controller_filter: controller_filter,
|
|
87
|
+
requests: [],
|
|
88
|
+
message: "No requests matched controller '#{controller_filter}'."
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
ToolRegistry.register(GetRequestLog)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module MCP
|
|
3
|
+
module Tools
|
|
4
|
+
class GetSchemaContext < Base
|
|
5
|
+
TOOL_NAME = "railsvitals_get_schema_context"
|
|
6
|
+
|
|
7
|
+
DESCRIPTION = <<~DESC.strip
|
|
8
|
+
Returns schema context for ActiveRecord models: columns with types, indexes,
|
|
9
|
+
associations, and foreign keys missing an index. Use the models param to scope
|
|
10
|
+
to specific models; omit it to get all models. Use this to understand your data
|
|
11
|
+
model structure and spot missing indexes that could be causing slow queries or
|
|
12
|
+
N+1 patterns.
|
|
13
|
+
DESC
|
|
14
|
+
|
|
15
|
+
INPUT_SCHEMA = {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
models: {
|
|
19
|
+
type: "array",
|
|
20
|
+
items: { type: "string" },
|
|
21
|
+
description: "Model names to include (e.g. ['Post', 'User']). Omit to return all models."
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def call(params)
|
|
27
|
+
requested = Array(params[:models] || params["models"]).map(&:to_s)
|
|
28
|
+
|
|
29
|
+
all_models = Analyzers::AssociationMapper.discover_models
|
|
30
|
+
models = requested.empty? ? all_models : filter_models(all_models, requested)
|
|
31
|
+
|
|
32
|
+
return no_models_response(requested) if models.empty?
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
models_analyzed: models.size,
|
|
36
|
+
models: models.map { |m| serialize_model(m) }
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def filter_models(all_models, requested)
|
|
43
|
+
all_models.select do |m|
|
|
44
|
+
requested.any? { |r| m.name.downcase == r.downcase }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def serialize_model(model)
|
|
49
|
+
table = model.table_name
|
|
50
|
+
columns = columns_for(table)
|
|
51
|
+
indexes = indexes_for(table)
|
|
52
|
+
assocs = serialize_associations(model, indexes)
|
|
53
|
+
missing = assocs.select { |a| a[:macro] == "belongs_to" && !a[:indexed] }
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
name: model.name,
|
|
57
|
+
table: table,
|
|
58
|
+
columns: columns.map { |c| serialize_column(c) },
|
|
59
|
+
indexes: indexes.map { |i| serialize_index(i) },
|
|
60
|
+
associations: assocs,
|
|
61
|
+
missing_indexes: missing.map { |a| { foreign_key: a[:foreign_key], association: a[:name] } }
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def serialize_column(col)
|
|
66
|
+
{ name: col.name, type: col.type.to_s, nullable: col.null }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def serialize_index(idx)
|
|
70
|
+
{ name: idx.name, columns: idx.columns, unique: idx.unique }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def serialize_associations(model, own_indexes)
|
|
74
|
+
model.reflect_on_all_associations.filter_map do |assoc|
|
|
75
|
+
target = assoc.klass rescue next
|
|
76
|
+
|
|
77
|
+
fk = assoc.foreign_key.to_s
|
|
78
|
+
if assoc.macro == :belongs_to
|
|
79
|
+
indexed = own_indexes.any? { |i| i.columns.first == fk }
|
|
80
|
+
else
|
|
81
|
+
indexed = indexes_for(target.table_name).any? { |i| i.columns.first == fk }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
macro: assoc.macro.to_s,
|
|
86
|
+
name: assoc.name.to_s,
|
|
87
|
+
foreign_key: fk,
|
|
88
|
+
to: target.name,
|
|
89
|
+
indexed: indexed
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def columns_for(table)
|
|
95
|
+
ActiveRecord::Base.connection.columns(table)
|
|
96
|
+
rescue StandardError
|
|
97
|
+
[]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def indexes_for(table)
|
|
101
|
+
ActiveRecord::Base.connection.indexes(table)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
[]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def no_models_response(requested)
|
|
107
|
+
msg = requested.empty? ? "No ActiveRecord models found." : "No models matched: #{requested.join(', ')}."
|
|
108
|
+
{ models_analyzed: 0, models: [], message: msg }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
ToolRegistry.register(GetSchemaContext)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module MCP
|
|
3
|
+
module Tools
|
|
4
|
+
class GetSlowQueries < Base
|
|
5
|
+
TOOL_NAME = "railsvitals_get_slow_queries"
|
|
6
|
+
|
|
7
|
+
DESCRIPTION = <<~DESC.strip
|
|
8
|
+
Returns individual slow queries detected across recent requests, ordered by
|
|
9
|
+
duration descending. Each result includes the SQL, execution time, and the
|
|
10
|
+
endpoint that fired it. Use threshold_ms to override the default slow query
|
|
11
|
+
threshold (configured via mcp_slow_query_threshold_ms, default 100ms).
|
|
12
|
+
Use this after railsvitals_get_score to find which specific queries are
|
|
13
|
+
dragging down database performance.
|
|
14
|
+
DESC
|
|
15
|
+
|
|
16
|
+
INPUT_SCHEMA = {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
threshold_ms: {
|
|
20
|
+
type: "integer",
|
|
21
|
+
description: "Minimum query duration in ms to include. Defaults to config.mcp_slow_query_threshold_ms (100ms)."
|
|
22
|
+
},
|
|
23
|
+
limit: {
|
|
24
|
+
type: "integer",
|
|
25
|
+
description: "Maximum number of queries to return, ordered by duration desc. Defaults to 10."
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
DEFAULT_LIMIT = 10
|
|
31
|
+
|
|
32
|
+
def call(params)
|
|
33
|
+
records = RailsVitals.store.all
|
|
34
|
+
return no_data_response if records.empty?
|
|
35
|
+
|
|
36
|
+
threshold = (params[:threshold_ms] || params["threshold_ms"] || RailsVitals.config.mcp_slow_query_threshold_ms).to_i
|
|
37
|
+
limit = (params[:limit] || params["limit"] || DEFAULT_LIMIT).to_i
|
|
38
|
+
|
|
39
|
+
slow = collect_slow_queries(records, threshold)
|
|
40
|
+
return no_slow_queries_response(threshold) if slow.empty?
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
total_slow_queries: slow.size,
|
|
44
|
+
shown: [ limit, slow.size ].min,
|
|
45
|
+
threshold_ms: threshold,
|
|
46
|
+
queries: slow.first(limit).map { |q| serialize(q) }
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def collect_slow_queries(records, threshold)
|
|
53
|
+
queries = []
|
|
54
|
+
|
|
55
|
+
records.each do |record|
|
|
56
|
+
record.queries.each do |q|
|
|
57
|
+
next if q[:duration_ms] < threshold
|
|
58
|
+
|
|
59
|
+
queries << q.merge(endpoint: record.endpoint, request_id: record.id)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
queries.sort_by { |q| -q[:duration_ms] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def serialize(query)
|
|
67
|
+
{
|
|
68
|
+
sql: query[:sql],
|
|
69
|
+
duration_ms: query[:duration_ms].round(1),
|
|
70
|
+
endpoint: query[:endpoint],
|
|
71
|
+
request_id: query[:request_id]
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def no_data_response
|
|
76
|
+
{
|
|
77
|
+
total_slow_queries: 0,
|
|
78
|
+
shown: 0,
|
|
79
|
+
queries: [],
|
|
80
|
+
message: "No requests recorded yet. Make some requests to the app first."
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def no_slow_queries_response(threshold)
|
|
85
|
+
{
|
|
86
|
+
total_slow_queries: 0,
|
|
87
|
+
shown: 0,
|
|
88
|
+
threshold_ms: threshold,
|
|
89
|
+
queries: [],
|
|
90
|
+
message: "No queries exceeded #{threshold}ms in recent requests."
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
ToolRegistry.register(GetSlowQueries)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/rails_vitals/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_vitals
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Sanchez
|
|
@@ -86,8 +86,12 @@ files:
|
|
|
86
86
|
- lib/rails_vitals/mcp/response_builder.rb
|
|
87
87
|
- lib/rails_vitals/mcp/tool_registry.rb
|
|
88
88
|
- lib/rails_vitals/mcp/tools/base.rb
|
|
89
|
+
- lib/rails_vitals/mcp/tools/explain_query.rb
|
|
89
90
|
- lib/rails_vitals/mcp/tools/get_n1_queries.rb
|
|
91
|
+
- lib/rails_vitals/mcp/tools/get_request_log.rb
|
|
92
|
+
- lib/rails_vitals/mcp/tools/get_schema_context.rb
|
|
90
93
|
- lib/rails_vitals/mcp/tools/get_score.rb
|
|
94
|
+
- lib/rails_vitals/mcp/tools/get_slow_queries.rb
|
|
91
95
|
- lib/rails_vitals/middleware/panel_injector.rb
|
|
92
96
|
- lib/rails_vitals/notifications/subscriber.rb
|
|
93
97
|
- lib/rails_vitals/panel_renderer.rb
|
|
@@ -114,7 +118,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
114
118
|
requirements:
|
|
115
119
|
- - ">="
|
|
116
120
|
- !ruby/object:Gem::Version
|
|
117
|
-
version: 3.
|
|
121
|
+
version: 3.2.0
|
|
118
122
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
119
123
|
requirements:
|
|
120
124
|
- - ">="
|