rails_vitals 0.4.2 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 277b26e5e359214e2fba9795bd3b8498101f0f834ba2c41c935af01618eeff9d
4
- data.tar.gz: 758358422faf3c52d11778ce3472940eb43008c0988bfea538d40a0814a73253
3
+ metadata.gz: 76e3b231f12c366dba7fbd5cf3e54959922fd0b8e26d6ad865d924de95523887
4
+ data.tar.gz: 56e6c2d230f2fd852f6aded8773e627bbcac09b155c34e49d6b34c6453c2116a
5
5
  SHA512:
6
- metadata.gz: 117124cfef5de4acf5aeb28f237d1e21d26e3658cb280bdc340f53e8c2a0c30573c140e0217e83a2423af56c76c5db2de7956aab4425ff0b527b236c5d1aaf0f
7
- data.tar.gz: 6939cd948d253037865299811f8f73a88c17c5805ded7a1b86018ace53a4ab3352748fa54e33806b4cbab355348f212ed4dafc53dcf5735b84261cae78b00b50
6
+ metadata.gz: a398946361ad02e3c442c498119c952be21e4a379357f59ff674ae599961b5688cd6c0abdbdfe7b675194e94e3f21c67c2c3c276c709036975cf1ab48c52b429
7
+ data.tar.gz: 60cf92ad429a1aa1d2e706cb2226b1b3637cf757bd66df3bc77ddad4f8e64a9e415d5f6709b542477f5cd0c78e58d7e49cad443729e42af325a2a8ee519b666d
data/README.md CHANGED
@@ -135,6 +135,10 @@ RailsVitals.configure do |config|
135
135
 
136
136
  # Custom auth lambda (if auth: :lambda)
137
137
  # config.auth = ->(request) { request.session[:admin] == true }
138
+
139
+ # MCP server (disabled by default, never runs in production)
140
+ config.mcp_enabled = false
141
+ config.mcp_auth_token = nil # set a token to require Authorization: Bearer <token>
138
142
  end
139
143
  ```
140
144
 
@@ -158,6 +162,86 @@ Navigate to `/rails_vitals` to access the full admin interface.
158
162
 
159
163
  ---
160
164
 
165
+ ## MCP Server — AI Tool Integration
166
+
167
+ RailsVitals includes a built-in [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that exposes your app's performance data as tools that Claude Desktop and other MCP-compatible AI clients can call directly.
168
+
169
+ Ask Claude things like:
170
+
171
+ > "What is my Rails app's health score right now?"
172
+ > "Show me the top 3 N+1 patterns and how to fix them."
173
+
174
+ ### Enabling the MCP server
175
+
176
+ ```ruby
177
+ # config/initializers/rails_vitals.rb
178
+ RailsVitals.configure do |config|
179
+ config.mcp_enabled = true
180
+ config.mcp_auth_token = "your-secret-token" # optional but recommended
181
+ end
182
+ ```
183
+
184
+ The MCP server is disabled by default and raises an error if enabled in production.
185
+
186
+ ### Connecting Claude Desktop
187
+
188
+ Install `mcp-remote`, which bridges Claude Desktop's stdio transport to the HTTP-based MCP server:
189
+
190
+ ```bash
191
+ npm install -g mcp-remote
192
+ ```
193
+
194
+ Add an entry to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
195
+
196
+ ```json
197
+ {
198
+ "mcpServers": {
199
+ "rails-vitals": {
200
+ "command": "/absolute/path/to/npx",
201
+ "args": [
202
+ "mcp-remote",
203
+ "http://localhost:3000/rails_vitals/mcp",
204
+ "--header",
205
+ "Authorization: Bearer your-secret-token"
206
+ ]
207
+ }
208
+ }
209
+ }
210
+ ```
211
+
212
+ Use an absolute path to `npx` (e.g. from `which npx`) — Claude Desktop does not inherit your shell `PATH`.
213
+
214
+ Restart Claude Desktop and look for the RailsVitals tools in the tool picker.
215
+
216
+ ### Available tools
217
+
218
+ | Tool | Description |
219
+ |------|-------------|
220
+ | `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) |
222
+
223
+ ### How it's structured
224
+
225
+ ```
226
+ lib/rails_vitals/mcp/
227
+ ├── auth.rb # Bearer token validation
228
+ ├── request_handler.rb # JSON-RPC 2.0 dispatcher
229
+ ├── response_builder.rb # MCP response formatting helpers
230
+ ├── tool_registry.rb # Declarative class-based tool registry
231
+ └── 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
235
+ ```
236
+
237
+ Each tool inherits from `Tools::Base`, implements `call(params)` returning a plain hash, and self-registers at load time via `ToolRegistry.register(ToolClass)`.
238
+
239
+ ### Example
240
+
241
+ ![Claude MCP](https://github.com/user-attachments/assets/4cf7d071-1925-4b9f-a1cb-49b61f2e30e7)
242
+
243
+ ---
244
+
161
245
  ## How Scoring Works
162
246
 
163
247
  RailsVitals scores each request using a `CompositeScorer` with two weighted dimensions:
@@ -264,8 +348,18 @@ rails_vitals/
264
348
  │ │ ├── query_scorer.rb # 40% weight
265
349
  │ │ ├── n_plus_one_scorer.rb # 60% weight
266
350
  │ │ └── composite_scorer.rb
267
- └── middleware/
268
- └── panel_injector.rb # Rack middleware for panel injection
351
+ ├── calculable.rb # Shared average/percentage helpers
352
+ ├── middleware/
353
+ │ │ └── panel_injector.rb # Rack middleware for panel injection
354
+ │ └── mcp/
355
+ │ ├── auth.rb # Bearer token validation
356
+ │ ├── request_handler.rb # JSON-RPC 2.0 dispatcher
357
+ │ ├── response_builder.rb # MCP response helpers
358
+ │ ├── tool_registry.rb # Declarative tool registry
359
+ │ └── tools/
360
+ │ ├── base.rb # Base class for all tools
361
+ │ ├── get_score.rb # railsvitals_get_score
362
+ │ └── get_n1_queries.rb # railsvitals_get_n1_queries
269
363
  └── app/
270
364
  ├── controllers/rails_vitals/
271
365
  │ ├── dashboard_controller.rb
@@ -1,5 +1,7 @@
1
1
  module RailsVitals
2
2
  class DashboardController < ApplicationController
3
+ include Calculable
4
+
3
5
  def index
4
6
  @records = RailsVitals.store.all.reverse
5
7
  @total = @records.size
@@ -14,12 +16,6 @@ module RailsVitals
14
16
 
15
17
  private
16
18
 
17
- def average(records, method)
18
- return 0 if records.empty?
19
-
20
- (records.sum(&method).to_f / records.size).round(1)
21
- end
22
-
23
19
  def top_offenders(records)
24
20
  records
25
21
  .group_by(&:endpoint)
@@ -1,5 +1,6 @@
1
1
  module RailsVitals
2
2
  class HeatmapController < ApplicationController
3
+ include Calculable
3
4
  def index
4
5
  records = RailsVitals.store.all
5
6
  @heatmap = build_heatmap(records)
@@ -25,15 +26,9 @@ module RailsVitals
25
26
  .sort_by { |row| row[:avg_score] }
26
27
  end
27
28
 
28
- def average(records, method)
29
- return 0.0 if records.empty?
30
- (records.sum { |r| r.public_send(method) }.to_f / records.size).round(1)
31
- end
32
-
33
29
  def n_plus_one_frequency(reqs)
34
30
  reqs_with_n1 = reqs.count { |r| r.n_plus_one_patterns.any? }
35
- return 0.0 if reqs.empty?
36
- ((reqs_with_n1.to_f / reqs.size) * 100).round(1)
31
+ percentage(reqs_with_n1, reqs.size)
37
32
  end
38
33
  end
39
34
  end
@@ -0,0 +1,42 @@
1
+ module RailsVitals
2
+ class McpController < ActionController::API
3
+ before_action :verify_environment
4
+ before_action :verify_auth
5
+
6
+ def call
7
+ raw_body = request.body.read
8
+ result = handler.handle(raw_body)
9
+
10
+ render json: result
11
+ end
12
+
13
+ private
14
+
15
+ def verify_environment
16
+ if Rails.env.production?
17
+ render json: ResponseBuilder.error(
18
+ nil,
19
+ ResponseBuilder::AUTH_ERROR,
20
+ "RailsVitals MCP is not available in production"
21
+ ), status: :forbidden
22
+ end
23
+ end
24
+
25
+ def verify_auth
26
+ auth = MCP::Auth.new(RailsVitals.config.mcp_auth_token)
27
+
28
+ unless auth.valid?(request)
29
+ render json: MCP::ResponseBuilder.error(
30
+ nil,
31
+ MCP::ResponseBuilder::AUTH_ERROR,
32
+ "Unauthorized",
33
+ { detail: "Valid Bearer token required" }
34
+ ), status: :unauthorized
35
+ end
36
+ end
37
+
38
+ def handler
39
+ @handler ||= MCP::RequestHandler.new
40
+ end
41
+ end
42
+ end
data/config/routes.rb CHANGED
@@ -8,4 +8,6 @@ RailsVitals::Engine.routes.draw do
8
8
  resources :playgrounds, only: [ :index, :create ]
9
9
  get "heatmap", to: "heatmap#index", as: :heatmap
10
10
  get "requests/:request_id/explain/:query_index", to: "explains#show", as: :explain
11
+
12
+ post "/mcp", to: "mcp#call" if RailsVitals.config.mcp_enabled
11
13
  end
@@ -0,0 +1,15 @@
1
+ module RailsVitals
2
+ module Calculable
3
+ def average(records, method)
4
+ return 0.0 if records.empty?
5
+
6
+ (records.sum(&method).to_f / records.size).round(1)
7
+ end
8
+
9
+ def percentage(count, total)
10
+ return 0.0 if total.zero?
11
+
12
+ (count.to_f / total * 100).round(1)
13
+ end
14
+ end
15
+ end
@@ -9,7 +9,11 @@ module RailsVitals
9
9
  :query_warn_threshold,
10
10
  :query_critical_threshold,
11
11
  :db_time_warn_ms,
12
- :db_time_critical_ms
12
+ :db_time_critical_ms,
13
+ :mcp_enabled,
14
+ :mcp_auth_token,
15
+ :mcp_max_log_size,
16
+ :mcp_slow_query_threshold_ms
13
17
 
14
18
  def initialize
15
19
  @enabled = defined?(Rails) && !Rails.env.production?
@@ -22,6 +26,12 @@ module RailsVitals
22
26
  @query_critical_threshold = 25
23
27
  @db_time_warn_ms = 100
24
28
  @db_time_critical_ms = 500
29
+
30
+ # MCP Server — disabled by default
31
+ @mcp_enabled = false
32
+ @mcp_auth_token = nil
33
+ @mcp_max_log_size = 100
34
+ @mcp_slow_query_threshold_ms = 100
25
35
  end
26
36
  end
27
37
  end
@@ -14,6 +14,20 @@ module RailsVitals
14
14
  end
15
15
  end
16
16
 
17
+ initializer "rails_vitals.mcp" do
18
+ if RailsVitals.config.mcp_enabled
19
+ raise "RailsVitals MCP cannot run in production" if Rails.env.production?
20
+
21
+ require "rails_vitals/mcp/auth"
22
+ require "rails_vitals/mcp/response_builder"
23
+ require "rails_vitals/mcp/tool_registry"
24
+ require "rails_vitals/mcp/tools/base"
25
+ require "rails_vitals/mcp/request_handler"
26
+ require "rails_vitals/mcp/tools/get_score"
27
+ require "rails_vitals/mcp/tools/get_n1_queries"
28
+ end
29
+ end
30
+
17
31
  config.to_prepare do
18
32
  if RailsVitals.config.enabled
19
33
  ActiveRecord::Base.prepend(
@@ -0,0 +1,21 @@
1
+ module RailsVitals
2
+ module MCP
3
+ class Auth
4
+ BEARER_PATTERN = /\ABearer (.+)\z/
5
+
6
+ def initialize(token)
7
+ @expected_token = token
8
+ end
9
+
10
+ def valid?(request)
11
+ return false if @expected_token.blank?
12
+
13
+ auth_header = request.get_header("HTTP_AUTHORIZATION") || ""
14
+ match = BEARER_PATTERN.match(auth_header)
15
+ return false unless match
16
+
17
+ ActiveSupport::SecurityUtils.secure_compare(match[1], @expected_token)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,101 @@
1
+ module RailsVitals
2
+ module MCP
3
+ class RequestHandler
4
+ include ResponseBuilder
5
+
6
+ SERVER_INFO = {
7
+ name: "railsvitals",
8
+ version: RailsVitals::VERSION
9
+ }.freeze
10
+
11
+ PROTOCOL_VERSION = "2025-11-25"
12
+ JSON_RPC_VERSION = "2.0"
13
+
14
+ INSTRUCTIONS = <<~TEXT.strip
15
+ RailsVitals exposes Rails app performance diagnostics.
16
+ Recommended flow: call railsvitals_get_schema_context first to understand
17
+ the data model, then railsvitals_get_score for a high-level diagnosis,
18
+ then drill down with the specific query tools.
19
+ TEXT
20
+
21
+ def handle(raw_body)
22
+ payload = parse_json(raw_body)
23
+ return payload if payload.is_a?(Hash) && payload[:error]
24
+
25
+ id = payload[:id]
26
+ method = payload[:method]
27
+
28
+ unless method.present?
29
+ return ResponseBuilder.error(id, ResponseBuilder::INVALID_REQUEST, "Missing method")
30
+ end
31
+
32
+ case method
33
+ when "initialize" then handle_initialize(id, payload[:params] || {})
34
+ when "tools/list" then handle_tools_list(id)
35
+ when "tools/call" then handle_tools_call(id, payload[:params] || {})
36
+ else
37
+ ResponseBuilder.error(id, ResponseBuilder::METHOD_NOT_FOUND, "Method not found: #{method}")
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def parse_json(raw_body)
44
+ payload = JSON.parse(raw_body, symbolize_names: true)
45
+
46
+ unless payload[:jsonrpc] == JSON_RPC_VERSION
47
+ return ResponseBuilder.error(nil, ResponseBuilder::INVALID_REQUEST, "jsonrpc must be '#{JSON_RPC_VERSION}'")
48
+ end
49
+
50
+ payload
51
+ rescue JSON::ParserError => e
52
+ ResponseBuilder.error(nil, ResponseBuilder::PARSE_ERROR, "Parse error: #{e.message}")
53
+ end
54
+
55
+ def handle_initialize(id, _params)
56
+ ResponseBuilder.success(id, {
57
+ protocolVersion: PROTOCOL_VERSION,
58
+ serverInfo: SERVER_INFO,
59
+ capabilities: { tools: {} },
60
+ instructions: INSTRUCTIONS
61
+ })
62
+ end
63
+
64
+ def handle_tools_list(id)
65
+ ResponseBuilder.success(id, {
66
+ tools: ToolRegistry.all_definitions
67
+ })
68
+ end
69
+
70
+ def handle_tools_call(id, params)
71
+ name = params[:name]
72
+ arguments = params[:arguments] || {}
73
+
74
+ unless name.present?
75
+ return ResponseBuilder.error(id, ResponseBuilder::INVALID_PARAMS, "Missing tool name")
76
+ end
77
+
78
+ tool_class = ToolRegistry.find(name)
79
+
80
+ unless tool_class
81
+ return ResponseBuilder.error(
82
+ id,
83
+ ResponseBuilder::METHOD_NOT_FOUND,
84
+ "Tool not found",
85
+ { tool: name }
86
+ )
87
+ end
88
+
89
+ result = tool_class.new.call(arguments)
90
+ ResponseBuilder.tool_success(id, result)
91
+ rescue => e
92
+ ResponseBuilder.error(
93
+ id,
94
+ ResponseBuilder::TOOL_EXEC_ERROR,
95
+ "Tool execution failed",
96
+ { tool: name, detail: e.message }
97
+ )
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,47 @@
1
+ module RailsVitals
2
+ module MCP
3
+ module ResponseBuilder
4
+ JSON_RPC_VERSION = "2.0"
5
+
6
+ def self.success(id, result)
7
+ {
8
+ jsonrpc: JSON_RPC_VERSION,
9
+ id: id,
10
+ result: result
11
+ }
12
+ end
13
+
14
+ def self.tool_success(id, data)
15
+ success(id, {
16
+ content: [
17
+ {
18
+ type: "text",
19
+ text: data.to_json
20
+ }
21
+ ]
22
+ })
23
+ end
24
+
25
+ def self.error(id, code, message, data = nil)
26
+ err = { code: code, message: message }
27
+ err[:data] = data if data.present?
28
+
29
+ {
30
+ jsonrpc: JSON_RPC_VERSION,
31
+ id: id,
32
+ error: err
33
+ }
34
+ end
35
+
36
+ # JSON-RPC 2.0 standard error codes
37
+ PARSE_ERROR = -32_700
38
+ INVALID_REQUEST = -32_600
39
+ METHOD_NOT_FOUND = -32_601
40
+ INVALID_PARAMS = -32_602
41
+
42
+ # RailsVitals custom error codes
43
+ AUTH_ERROR = -32_000
44
+ TOOL_EXEC_ERROR = -32_001
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,29 @@
1
+ module RailsVitals
2
+ module MCP
3
+ class ToolRegistry
4
+ class << self
5
+ def register(tool_class)
6
+ registry[tool_class.tool_name] = tool_class
7
+ end
8
+
9
+ def all_definitions
10
+ registry.values.map(&:definition)
11
+ end
12
+
13
+ def find(name)
14
+ registry[name]
15
+ end
16
+
17
+ def exists?(name)
18
+ registry.key?(name)
19
+ end
20
+
21
+ private
22
+
23
+ def registry
24
+ @registry ||= {}
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,31 @@
1
+ module RailsVitals
2
+ module MCP
3
+ module Tools
4
+ class Base
5
+ # Subclasses must define:
6
+ # TOOL_NAME = "railsvitals_example"
7
+ # DESCRIPTION = "What this tool does for the AI model"
8
+ # INPUT_SCHEMA = { type: "object", properties: {} }
9
+ #
10
+ # And implement:
11
+ # def call(params) → Hash
12
+
13
+ def self.tool_name
14
+ self::TOOL_NAME
15
+ end
16
+
17
+ def self.definition
18
+ {
19
+ name: self::TOOL_NAME,
20
+ description: self::DESCRIPTION,
21
+ inputSchema: self::INPUT_SCHEMA
22
+ }
23
+ end
24
+
25
+ def call(params)
26
+ raise NotImplementedError, "#{self.class}#call is not implemented"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,93 @@
1
+ module RailsVitals
2
+ module MCP
3
+ module Tools
4
+ class GetN1Queries < Base
5
+ TOOL_NAME = "railsvitals_get_n1_queries"
6
+
7
+ DESCRIPTION = <<~DESC.strip
8
+ Returns all N+1 query patterns detected across recent requests, grouped and
9
+ ranked by number of occurrences. Each pattern includes the normalized SQL
10
+ fingerprint, affected endpoints with hit counts, and a deterministic fix
11
+ suggestion (the correct includes() call) derived from ActiveRecord reflection.
12
+ Use this after railsvitals_get_score to identify which N+1 patterns to fix.
13
+ DESC
14
+
15
+ INPUT_SCHEMA = {
16
+ type: "object",
17
+ properties: {
18
+ limit: {
19
+ type: "integer",
20
+ description: "Maximum number of patterns to return, ordered by occurrences desc. Defaults to 10."
21
+ }
22
+ }
23
+ }.freeze
24
+
25
+ DEFAULT_LIMIT = 10
26
+
27
+ def call(params)
28
+ records = RailsVitals.store.all
29
+ return no_data_response if records.empty?
30
+
31
+ limit = (params[:limit] || params["limit"] || DEFAULT_LIMIT).to_i
32
+ patterns = Analyzers::NPlusOneAggregator.aggregate(records)
33
+
34
+ return no_n1_response if patterns.empty?
35
+
36
+ {
37
+ total_patterns: patterns.size,
38
+ shown: [ limit, patterns.size ].min,
39
+ patterns: patterns.first(limit).map { |p| serialize(p) }
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ def no_data_response
46
+ {
47
+ total_patterns: 0,
48
+ shown: 0,
49
+ patterns: [],
50
+ message: "No requests recorded yet. Make some requests to the app first."
51
+ }
52
+ end
53
+
54
+ def no_n1_response
55
+ {
56
+ total_patterns: 0,
57
+ shown: 0,
58
+ patterns: [],
59
+ message: "No N+1 patterns detected in recent requests."
60
+ }
61
+ end
62
+
63
+ def serialize(pattern)
64
+ {
65
+ pattern: pattern[:pattern],
66
+ occurrences: pattern[:occurrences],
67
+ table: pattern[:table],
68
+ foreign_key: pattern[:foreign_key],
69
+ affected_endpoints: serialize_endpoints(pattern[:endpoints]),
70
+ fix: serialize_fix(pattern[:fix_suggestion])
71
+ }
72
+ end
73
+
74
+ def serialize_endpoints(endpoints)
75
+ endpoints
76
+ .sort_by { |_, count| -count }
77
+ .map { |endpoint, count| { endpoint: endpoint, request_count: count } }
78
+ end
79
+
80
+ def serialize_fix(suggestion)
81
+ {
82
+ code: suggestion[:code],
83
+ description: suggestion[:description],
84
+ owner: suggestion[:owner],
85
+ association: suggestion[:association]
86
+ }
87
+ end
88
+ end
89
+
90
+ ToolRegistry.register(GetN1Queries)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,143 @@
1
+ module RailsVitals
2
+ module MCP
3
+ module Tools
4
+ class GetScore < Base
5
+ include Calculable
6
+ TOOL_NAME = "railsvitals_get_score"
7
+
8
+ DESCRIPTION = <<~DESC.strip
9
+ Returns the composite health score for the Rails app based on recent requests
10
+ recorded by RailsVitals. Includes a grade, per-component score breakdown
11
+ (query count and N+1 patterns), penalty details, and a projected score that
12
+ shows how much improvement fixing all N+1 patterns would yield.
13
+ Call this first to get a high-level diagnosis before drilling into specifics.
14
+ DESC
15
+
16
+ INPUT_SCHEMA = {
17
+ type: "object",
18
+ properties: {}
19
+ }.freeze
20
+
21
+ WEIGHTS = Scorers::CompositeScorer::WEIGHTS
22
+
23
+ def call(_params)
24
+ records = RailsVitals.store.all
25
+
26
+ return no_data_response if records.empty?
27
+
28
+ overall_score = average(records, :score)
29
+
30
+ {
31
+ overall_score: overall_score,
32
+ grade: grade_for(overall_score),
33
+ color: color_for(overall_score),
34
+ requests_analyzed: records.size,
35
+ score_breakdown: build_score_breakdown(records),
36
+ penalties: build_penalties(records),
37
+ projected_score_if_n1_fixed: projected_score(records)
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ def no_data_response
44
+ {
45
+ overall_score: nil,
46
+ grade: "No data",
47
+ color: "grey",
48
+ requests_analyzed: 0,
49
+ message: "No requests recorded yet. Make some requests to the app first."
50
+ }
51
+ end
52
+
53
+ def build_score_breakdown(records)
54
+ avg_n1 = avg_n1_score(records)
55
+ avg_query = avg_query_score(records)
56
+
57
+ {
58
+ query_component: {
59
+ weight: WEIGHTS[:query],
60
+ avg_score: avg_query,
61
+ avg_contribution: (avg_query * WEIGHTS[:query]).round(1)
62
+ },
63
+ n1_component: {
64
+ weight: WEIGHTS[:n_plus_one],
65
+ avg_score: avg_n1,
66
+ avg_contribution: (avg_n1 * WEIGHTS[:n_plus_one]).round(1)
67
+ }
68
+ }
69
+ end
70
+
71
+ def build_penalties(records)
72
+ warn_threshold = RailsVitals.config.query_warn_threshold
73
+ db_warn_ms = RailsVitals.config.db_time_warn_ms
74
+
75
+ n1_affected = records.select { |r| r.n_plus_one_patterns.any? }
76
+ total_patterns = records.sum { |r| r.n_plus_one_patterns.size }
77
+ unique_patterns = records.flat_map { |r| r.n_plus_one_patterns.keys }.uniq.size
78
+
79
+ {
80
+ n1_patterns: {
81
+ requests_affected: n1_affected.size,
82
+ percentage_requests: percentage(n1_affected.size, records.size),
83
+ unique_patterns: unique_patterns,
84
+ total_occurrences: total_patterns
85
+ },
86
+ high_query_count: {
87
+ requests_above_threshold: records.count { |r| r.total_query_count > warn_threshold },
88
+ threshold: warn_threshold
89
+ },
90
+ slow_db_time: {
91
+ requests_above_threshold: records.count { |r| r.total_db_time_ms > db_warn_ms },
92
+ threshold_ms: db_warn_ms
93
+ }
94
+ }
95
+ end
96
+
97
+ # What the average composite score would be if every request had zero N+1 patterns.
98
+ # Formula: composite_now = query_contribution + n1_contribution
99
+ # projected = composite_now - n1_contribution + (100 * WEIGHTS[:n_plus_one])
100
+ def projected_score(records)
101
+ gain_per_record = records.map do |r|
102
+ n1_now = n1_score_for(r)
103
+ ((100 - n1_now) * WEIGHTS[:n_plus_one]).round(1)
104
+ end
105
+
106
+ avg_gain = gain_per_record.sum.to_f / gain_per_record.size
107
+ (average(records, :score) + avg_gain).round.clamp(0, 100)
108
+ end
109
+
110
+ def n1_score_for(record)
111
+ Scorers::NPlusOneScorer.score_for(record.n_plus_one_patterns.size)
112
+ end
113
+
114
+ def avg_n1_score(records)
115
+ (records.sum { |r| n1_score_for(r) }.to_f / records.size).round(1)
116
+ end
117
+
118
+ # Back-calculates query score from composite and N+1 scores.
119
+ # composite = (query_score * W_query).round + (n1_score * W_n1).round
120
+ # Approximation is sufficient for AI context.
121
+ def query_score_for(record)
122
+ n1_contribution = (n1_score_for(record) * WEIGHTS[:n_plus_one]).round
123
+ query_contribution = record.score - n1_contribution
124
+ (query_contribution.to_f / WEIGHTS[:query]).round(1).clamp(0, 100)
125
+ end
126
+
127
+ def avg_query_score(records)
128
+ (records.sum { |r| query_score_for(r) }.to_f / records.size).round(1)
129
+ end
130
+
131
+ def grade_for(score)
132
+ Scorers::BaseScorer.label_for(score)
133
+ end
134
+
135
+ def color_for(score)
136
+ Scorers::BaseScorer.color_for(score)
137
+ end
138
+ end
139
+
140
+ ToolRegistry.register(GetScore)
141
+ end
142
+ end
143
+ end
@@ -199,7 +199,7 @@ module RailsVitals
199
199
  end
200
200
 
201
201
  def self.score_n1(count)
202
- [ 100 - (count * 25), 0 ].max
202
+ Scorers::NPlusOneScorer.score_for(count)
203
203
  end
204
204
 
205
205
  def self.blocked_result(message)
@@ -6,6 +6,24 @@ module RailsVitals
6
6
  WARNING = (50..69)
7
7
  CRITICAL = (0..49)
8
8
 
9
+ def self.label_for(score)
10
+ case score
11
+ when HEALTHY then "Healthy"
12
+ when ACCEPTABLE then "Acceptable"
13
+ when WARNING then "Warning"
14
+ else "Critical"
15
+ end
16
+ end
17
+
18
+ def self.color_for(score)
19
+ case score
20
+ when HEALTHY then "green"
21
+ when ACCEPTABLE then "blue"
22
+ when WARNING then "amber"
23
+ else "red"
24
+ end
25
+ end
26
+
9
27
  def initialize(collector)
10
28
  @collector = collector
11
29
  end
@@ -15,21 +15,11 @@ module RailsVitals
15
15
  end
16
16
 
17
17
  def label
18
- case score
19
- when BaseScorer::HEALTHY then "Healthy"
20
- when BaseScorer::ACCEPTABLE then "Acceptable"
21
- when BaseScorer::WARNING then "Warning"
22
- else "Critical"
23
- end
18
+ BaseScorer.label_for(score)
24
19
  end
25
20
 
26
21
  def color
27
- case score
28
- when BaseScorer::HEALTHY then "green"
29
- when BaseScorer::ACCEPTABLE then "blue"
30
- when BaseScorer::WARNING then "amber"
31
- else "red"
32
- end
22
+ BaseScorer.color_for(score)
33
23
  end
34
24
  end
35
25
  end
@@ -4,11 +4,12 @@ module RailsVitals
4
4
  # Minimum times the same query must repeat to be flagged
5
5
  REPEAT_THRESHOLD = 3
6
6
 
7
- def score
8
- return 100 if n_plus_one_count.zero?
7
+ def self.score_for(pattern_count)
8
+ [ 100 - (pattern_count * 25), 0 ].max
9
+ end
9
10
 
10
- # Each N+1 pattern costs 25 points, floored at 0
11
- clamp(100 - (n_plus_one_count * 25))
11
+ def score
12
+ self.class.score_for(n_plus_one_count)
12
13
  end
13
14
 
14
15
  def n_plus_one_patterns
@@ -1,3 +1,3 @@
1
1
  module RailsVitals
2
- VERSION = "0.4.2"
2
+ VERSION = "0.5.1"
3
3
  end
data/lib/rails_vitals.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "rails_vitals/version"
2
2
  require "rails_vitals/configuration"
3
+ require "rails_vitals/calculable"
3
4
  require "rails_vitals/store"
4
5
  require "rails_vitals/collector"
5
6
  require "rails_vitals/request_record"
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.2
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Sanchez
@@ -45,6 +45,7 @@ files:
45
45
  - app/controllers/rails_vitals/dashboard_controller.rb
46
46
  - app/controllers/rails_vitals/explains_controller.rb
47
47
  - app/controllers/rails_vitals/heatmap_controller.rb
48
+ - app/controllers/rails_vitals/mcp_controller.rb
48
49
  - app/controllers/rails_vitals/models_controller.rb
49
50
  - app/controllers/rails_vitals/n_plus_ones_controller.rb
50
51
  - app/controllers/rails_vitals/playgrounds_controller.rb
@@ -75,10 +76,18 @@ files:
75
76
  - lib/rails_vitals/analyzers/explain_analyzer.rb
76
77
  - lib/rails_vitals/analyzers/n_plus_one_aggregator.rb
77
78
  - lib/rails_vitals/analyzers/sql_tokenizer.rb
79
+ - lib/rails_vitals/calculable.rb
78
80
  - lib/rails_vitals/collector.rb
79
81
  - lib/rails_vitals/configuration.rb
80
82
  - lib/rails_vitals/engine.rb
81
83
  - lib/rails_vitals/instrumentation/callback_instrumentation.rb
84
+ - lib/rails_vitals/mcp/auth.rb
85
+ - lib/rails_vitals/mcp/request_handler.rb
86
+ - lib/rails_vitals/mcp/response_builder.rb
87
+ - lib/rails_vitals/mcp/tool_registry.rb
88
+ - lib/rails_vitals/mcp/tools/base.rb
89
+ - lib/rails_vitals/mcp/tools/get_n1_queries.rb
90
+ - lib/rails_vitals/mcp/tools/get_score.rb
82
91
  - lib/rails_vitals/middleware/panel_injector.rb
83
92
  - lib/rails_vitals/notifications/subscriber.rb
84
93
  - lib/rails_vitals/panel_renderer.rb
@@ -105,7 +114,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
105
114
  requirements:
106
115
  - - ">="
107
116
  - !ruby/object:Gem::Version
108
- version: 3.0.0
117
+ version: 3.2.0
109
118
  required_rubygems_version: !ruby/object:Gem::Requirement
110
119
  requirements:
111
120
  - - ">="