rails_vitals 0.5.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76e3b231f12c366dba7fbd5cf3e54959922fd0b8e26d6ad865d924de95523887
4
- data.tar.gz: 56e6c2d230f2fd852f6aded8773e627bbcac09b155c34e49d6b34c6453c2116a
3
+ metadata.gz: 82d62304d92259c9c248b8033948784ba6bb5075d7f7af627c445624a042901a
4
+ data.tar.gz: 6285bf97528093389e861f96bb45259d434e336042c7d23e30fab02109e690ce
5
5
  SHA512:
6
- metadata.gz: a398946361ad02e3c442c498119c952be21e4a379357f59ff674ae599961b5688cd6c0abdbdfe7b675194e94e3f21c67c2c3c276c709036975cf1ab48c52b429
7
- data.tar.gz: 60cf92ad429a1aa1d2e706cb2226b1b3637cf757bd66df3bc77ddad4f8e64a9e415d5f6709b542477f5cd0c78e58d7e49cad443729e42af325a2a8ee519b666d
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 a `limit` param (default: 10) |
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 # Base class: call + definition interface
233
- ├── get_score.rb # railsvitals_get_score
234
- └── get_n1_queries.rb # railsvitals_get_n1_queries
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)`.
@@ -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
@@ -1,3 +1,3 @@
1
1
  module RailsVitals
2
- VERSION = "0.5.1"
2
+ VERSION = "0.6.0"
3
3
  end
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.5.1
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