rails-profiler 0.25.0 → 0.26.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.
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "profiler/test_runner/discovery"
4
+ require "profiler/test_runner/runner"
5
+
6
+ module Profiler
7
+ module Api
8
+ class TestRunnerController < ApplicationController
9
+ include ActionController::Live
10
+
11
+ skip_before_action :verify_authenticity_token
12
+
13
+ def files
14
+ framework = params[:framework]
15
+ tree = Profiler::TestRunner::Discovery.files(framework: framework)
16
+ frameworks = Profiler::TestRunner::Discovery.frameworks
17
+
18
+ render json: {
19
+ frameworks: frameworks,
20
+ tree: tree
21
+ }
22
+ end
23
+
24
+ def create
25
+ files = Array(params[:files])
26
+ framework = params[:framework] || detect_framework
27
+
28
+ if files.empty?
29
+ return render json: { error: "No files selected" }, status: :unprocessable_entity
30
+ end
31
+
32
+ # Validate paths are within Rails root (prevent path traversal)
33
+ root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
34
+ files.each do |f|
35
+ expanded = File.expand_path(File.join(root, f))
36
+ unless expanded.start_with?(root)
37
+ return render json: { error: "Invalid file path: #{f}" }, status: :unprocessable_entity
38
+ end
39
+ end
40
+
41
+ run = Profiler::TestRunner::Runner.start(files: files, framework: framework)
42
+ render json: run.to_h, status: :created
43
+ end
44
+
45
+ def show
46
+ run = Profiler::TestRunner.run_store.find(params[:id])
47
+ return render json: { error: "Run not found" }, status: :not_found unless run
48
+
49
+ render json: run.to_h
50
+ end
51
+
52
+ # SSE endpoint — streams output chunks as server-sent events.
53
+ # Replaces polling for live test output in the frontend.
54
+ def stream
55
+ run = Profiler::TestRunner.run_store.find(params[:id])
56
+ unless run
57
+ render json: { error: "Run not found" }, status: :not_found
58
+ return
59
+ end
60
+
61
+ response.headers["Content-Type"] = "text/event-stream"
62
+ response.headers["Cache-Control"] = "no-cache"
63
+ response.headers["X-Accel-Buffering"] = "no"
64
+
65
+ sse = SSE.new(response.stream, retry: 1000, event: "output")
66
+ position = 0
67
+
68
+ begin
69
+ loop do
70
+ result = Profiler::TestRunner.run_store.wait_for_output(
71
+ params[:id], position: position, timeout: 15
72
+ )
73
+
74
+ result[:chunks].each do |chunk|
75
+ sse.write({ chunk: chunk })
76
+ end
77
+ position = result[:position]
78
+
79
+ if result[:finished]
80
+ current_run = Profiler::TestRunner.run_store.find(params[:id])
81
+ sse.write(
82
+ { status: result[:status], exit_code: current_run&.exit_code },
83
+ event: "done"
84
+ )
85
+ break
86
+ end
87
+ end
88
+ rescue ActionController::Live::ClientDisconnected, IOError
89
+ # Client navigated away — normal exit
90
+ ensure
91
+ sse.close
92
+ end
93
+ end
94
+
95
+ def destroy
96
+ killed = Profiler::TestRunner::Runner.kill(params[:id])
97
+ if killed
98
+ head :no_content
99
+ else
100
+ render json: { error: "Run not found or not running" }, status: :not_found
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def detect_framework
107
+ if Profiler::TestRunner::Discovery.rspec_available?
108
+ "rspec"
109
+ else
110
+ "minitest"
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class TestsController < ApplicationController
6
+ skip_before_action :verify_authenticity_token
7
+
8
+ def index
9
+ limit = (params[:limit] || 50).to_i
10
+ offset = (params[:offset] || 0).to_i
11
+ all = Profiler.storage.list(limit: 1000, offset: 0)
12
+ tests = all.select { |p| p.profile_type == "test" }
13
+ page = tests.drop(offset).first(limit + 1)
14
+ render json: {
15
+ profiles: page.first(limit).map(&:to_h),
16
+ limit: limit,
17
+ offset: offset,
18
+ has_more: page.size > limit
19
+ }
20
+ end
21
+
22
+ def show
23
+ profile = Profiler.storage.load(params[:id])
24
+
25
+ unless profile && profile.profile_type == "test"
26
+ return render json: { error: "Test profile not found" }, status: :not_found
27
+ end
28
+
29
+ render json: profile.to_h
30
+ end
31
+
32
+ def destroy
33
+ profile = Profiler.storage.load(params[:id])
34
+ return render json: { error: "Test profile not found" }, status: :not_found unless profile&.profile_type == "test"
35
+
36
+ Profiler.storage.delete(params[:id])
37
+ head :no_content
38
+ end
39
+
40
+ def clear
41
+ Profiler.storage.clear(type: "test")
42
+ head :no_content
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ class TestRunnerController < ApplicationController
5
+ layout "profiler/application"
6
+
7
+ def index
8
+ redirect_to profiler.root_path(section: "runner"), allow_other_host: false
9
+ end
10
+ end
11
+ end
@@ -0,0 +1 @@
1
+ <div id="profiler-test-runner"></div>
data/config/routes.rb CHANGED
@@ -22,6 +22,8 @@ Profiler::Engine.routes.draw do
22
22
  get "assets/profiler.js", to: "assets#main_js"
23
23
  get "assets/profiler.css", to: "assets#main_css"
24
24
 
25
+ get "test_runner", to: "test_runner#index"
26
+
25
27
  namespace :api do
26
28
  resources :profiles, only: [:index, :show, :destroy] do
27
29
  collection { delete :clear }
@@ -32,6 +34,9 @@ Profiler::Engine.routes.draw do
32
34
  resources :console, only: [:index, :show, :destroy] do
33
35
  collection { delete :clear }
34
36
  end
37
+ resources :tests, only: [:index, :show, :destroy] do
38
+ collection { delete :clear }
39
+ end
35
40
  resources :outbound_http, only: [:index]
36
41
  get "toolbar/:token", to: "toolbar#show"
37
42
  post "ajax/link", to: "ajax#link"
@@ -40,5 +45,10 @@ Profiler::Engine.routes.draw do
40
45
  resource :env_vars, only: [:show, :update], controller: "env_vars"
41
46
  delete "env_vars/reset", to: "env_vars#reset_override"
42
47
  delete "env_vars/reset_all", to: "env_vars#reset_all"
48
+ get "test_runner/files", to: "test_runner#files"
49
+ post "test_runner/runs", to: "test_runner#create"
50
+ get "test_runner/runs/:id", to: "test_runner#show", as: :test_runner_run
51
+ get "test_runner/runs/:id/stream", to: "test_runner#stream", as: :test_runner_run_stream
52
+ delete "test_runner/runs/:id", to: "test_runner#destroy"
43
53
  end
44
54
  end
@@ -39,7 +39,7 @@ module Profiler
39
39
 
40
40
  # Skip schema queries and internal Rails queries
41
41
  next if payload[:name] == "SCHEMA"
42
- next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT)/i
42
+ next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i
43
43
 
44
44
  query = Models::SqlQuery.new(
45
45
  sql: payload[:sql],
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+
5
+ module Profiler
6
+ module Collectors
7
+ class TestCollector < BaseCollector
8
+ def initialize(profile, test_name:, test_file:, test_line:, framework:)
9
+ super(profile)
10
+ @test_name = test_name
11
+ @test_file = test_file
12
+ @test_line = test_line
13
+ @framework = framework
14
+ @status = "running"
15
+ @exception_message = nil
16
+ end
17
+
18
+ def icon
19
+ "🧪"
20
+ end
21
+
22
+ def priority
23
+ 5
24
+ end
25
+
26
+ def tab_config
27
+ {
28
+ key: "test",
29
+ label: "Test",
30
+ icon: icon,
31
+ priority: priority,
32
+ enabled: true,
33
+ default_active: true
34
+ }
35
+ end
36
+
37
+ def update_status(status, exception_message = nil)
38
+ @status = status
39
+ @exception_message = exception_message
40
+ end
41
+
42
+ def update_extra(assertions: nil, skip_reason: nil)
43
+ @assertions = assertions
44
+ @skip_reason = skip_reason
45
+ end
46
+
47
+ def collect
48
+ store_data({
49
+ test_name: @test_name,
50
+ test_file: @test_file,
51
+ test_line: @test_line,
52
+ framework: @framework.to_s,
53
+ status: @status,
54
+ exception_message: @exception_message,
55
+ assertions: @assertions,
56
+ skip_reason: @skip_reason
57
+ })
58
+ end
59
+
60
+ def has_data?
61
+ true
62
+ end
63
+
64
+ def toolbar_summary
65
+ color = case @status
66
+ when "passed" then "green"
67
+ when "failed" then "red"
68
+ when "pending" then "orange"
69
+ else "gray"
70
+ end
71
+ { text: @status, color: color }
72
+ end
73
+ end
74
+ end
75
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Profiler
4
4
  class Configuration
5
- attr_accessor :enabled, :storage, :storage_options, :collectors,
5
+ attr_accessor :enabled, :storage_options, :collectors,
6
6
  :skip_paths, :slow_query_threshold, :max_queries_warning,
7
7
  :track_memory, :memory_warning_threshold,
8
8
  :mcp_enabled, :mcp_transport, :mcp_port,
@@ -12,6 +12,7 @@ module Profiler
12
12
  :track_http, :slow_http_threshold, :http_skip_hosts,
13
13
  :track_jobs,
14
14
  :track_console,
15
+ :track_tests,
15
16
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
16
17
  :compress_bodies, :compress_body_threshold
17
18
 
@@ -44,6 +45,7 @@ module Profiler
44
45
  @http_skip_hosts = []
45
46
  @track_jobs = true
46
47
  @track_console = true
48
+ @track_tests = false
47
49
  @track_mailers = true
48
50
  @capture_mail_body = false
49
51
  @sanitize_mailer_recipients = false
@@ -72,6 +74,15 @@ module Profiler
72
74
  end
73
75
  end
74
76
 
77
+ def storage
78
+ @storage
79
+ end
80
+
81
+ def storage=(value)
82
+ @storage = value
83
+ @storage_backend = nil
84
+ end
85
+
75
86
  def storage_backend
76
87
  @storage_backend ||= build_storage_backend
77
88
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Resources
6
+ class FailingTests
7
+ def self.call
8
+ profiles = Profiler.storage.list(limit: 500)
9
+ failing = profiles.select do |p|
10
+ next false unless p.profile_type == "test"
11
+ test_data = p.collector_data("test") || {}
12
+ test_data["status"] == "failed"
13
+ end.first(20)
14
+
15
+ data = failing.map do |profile|
16
+ test_data = profile.collector_data("test") || {}
17
+ db_data = profile.collector_data("database") || {}
18
+
19
+ {
20
+ token: profile.token,
21
+ test_name: test_data["test_name"] || profile.path,
22
+ framework: test_data["framework"],
23
+ file: "#{test_data["test_file"]}:#{test_data["test_line"]}",
24
+ exception: test_data["exception_message"],
25
+ duration_ms: profile.duration&.round(2),
26
+ query_count: db_data["total_queries"].to_i,
27
+ timestamp: profile.started_at&.iso8601
28
+ }
29
+ end
30
+
31
+ {
32
+ uri: "profiler://failing-tests",
33
+ mimeType: "application/json",
34
+ text: JSON.pretty_generate({ total: data.size, failing_tests: data })
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Resources
6
+ class SlowTests
7
+ def self.call
8
+ profiles = Profiler.storage.list(limit: 500)
9
+ tests = profiles.select { |p| p.profile_type == "test" }
10
+ .sort_by { |p| -p.duration }
11
+ .first(10)
12
+
13
+ data = tests.map do |profile|
14
+ test_data = profile.collector_data("test") || {}
15
+ db_data = profile.collector_data("database") || {}
16
+ queries = db_data["queries"] || []
17
+ n1 = queries.group_by { |q| normalize_sql(q["sql"].to_s) }.any? { |_, qs| qs.size >= 3 }
18
+
19
+ {
20
+ token: profile.token,
21
+ test_name: test_data["test_name"] || profile.path,
22
+ status: test_data["status"],
23
+ framework: test_data["framework"],
24
+ file: test_data["test_file"],
25
+ duration_ms: profile.duration&.round(2),
26
+ query_count: db_data["total_queries"].to_i,
27
+ n1_detected: n1,
28
+ timestamp: profile.started_at&.iso8601
29
+ }
30
+ end
31
+
32
+ {
33
+ uri: "profiler://slow-tests",
34
+ mimeType: "application/json",
35
+ text: JSON.pretty_generate({ total: data.size, slow_tests: data })
36
+ }
37
+ end
38
+
39
+ def self.normalize_sql(sql)
40
+ sql.gsub(/\$\d+/, "?").gsub(/\b\d+\b/, "?").gsub(/'[^']*'/, "?").gsub(/"[^"]*"/, "?").strip
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -71,6 +71,9 @@ module Profiler
71
71
  require_relative "tools/get_profile_http"
72
72
  require_relative "tools/query_jobs"
73
73
  require_relative "tools/query_mailers"
74
+ require_relative "tools/query_test_profiles"
75
+ require_relative "tools/get_test_profile_detail"
76
+ require_relative "tools/run_tests"
74
77
  require_relative "tools/clear_profiles"
75
78
  require_relative "tools/list_env_vars"
76
79
  require_relative "tools/set_env_var"
@@ -203,12 +206,64 @@ module Profiler
203
206
  },
204
207
  handler: Tools::QueryMailers
205
208
  ),
209
+ define_tool(
210
+ name: "query_test_profiles",
211
+ description: "Search and filter test profiles (RSpec/Minitest) by test name, status, or duration.",
212
+ input_schema: {
213
+ properties: {
214
+ test_name: { type: "string", description: "Filter by test name (partial match)" },
215
+ status: { type: "string", description: "Filter by status: 'passed', 'failed', or 'pending'" },
216
+ min_duration: { type: "number", description: "Minimum duration in milliseconds" },
217
+ limit: { type: "number", description: "Maximum number of results (default 20)" },
218
+ fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, test_name, status, duration, queries, n1, token. Omit for all." },
219
+ cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen." }
220
+ }
221
+ },
222
+ handler: Tools::QueryTestProfiles
223
+ ),
224
+ define_tool(
225
+ name: "get_test_profile",
226
+ description: "Get detailed data for a test profile: metadata, SQL queries, N+1 patterns, cache, exception. Use 'latest' as token for the most recent test.",
227
+ input_schema: {
228
+ properties: {
229
+ token: { type: "string", description: "Test profile token, or 'latest' for the most recent test profile (required)" }
230
+ },
231
+ required: ["token"]
232
+ },
233
+ handler: Tools::GetTestProfileDetail
234
+ ),
235
+ define_tool(
236
+ name: "run_tests",
237
+ description: "Run test files and wait for results. Returns output, status, duration, and tokens of test profiles created. Synchronous with configurable timeout (default 120s).",
238
+ input_schema: {
239
+ properties: {
240
+ files: {
241
+ type: "array",
242
+ items: { type: "string" },
243
+ description: "Relative paths of test files to run (e.g. ['spec/models/user_spec.rb']). Omit to run all discovered tests."
244
+ },
245
+ framework: {
246
+ type: "string",
247
+ description: "Test framework: 'rspec' or 'minitest'. Auto-detected if omitted."
248
+ },
249
+ timeout_seconds: {
250
+ type: "number",
251
+ description: "Maximum seconds to wait for tests to finish (default: 120)."
252
+ },
253
+ max_output: {
254
+ type: "number",
255
+ description: "Maximum characters of output to return (tail). Default: 4000."
256
+ }
257
+ }
258
+ },
259
+ handler: Tools::RunTests
260
+ ),
206
261
  define_tool(
207
262
  name: "clear_profiles",
208
- description: "Clear profiler history. Omit type to clear everything, or pass 'http'/'job' to clear only requests or jobs.",
263
+ description: "Clear profiler history. Omit type to clear everything, or pass 'http'/'job'/'test' to clear only that type.",
209
264
  input_schema: {
210
265
  properties: {
211
- type: { type: "string", description: "Optional: 'http' to clear only requests, 'job' to clear only jobs" }
266
+ type: { type: "string", description: "Optional: 'http' to clear only requests, 'job' to clear only jobs, 'test' to clear only test profiles" }
212
267
  }
213
268
  },
214
269
  handler: Tools::ClearProfiles
@@ -279,12 +334,16 @@ module Profiler
279
334
  require_relative "resources/slow_queries"
280
335
  require_relative "resources/n1_patterns"
281
336
  require_relative "resources/recent_jobs"
337
+ require_relative "resources/slow_tests"
338
+ require_relative "resources/failing_tests"
282
339
 
283
340
  handlers = {
284
- "profiler://recent" => Resources::RecentRequests,
285
- "profiler://slow-queries" => Resources::SlowQueries,
286
- "profiler://n1-patterns" => Resources::N1Patterns,
287
- "profiler://recent-jobs" => Resources::RecentJobs
341
+ "profiler://recent" => Resources::RecentRequests,
342
+ "profiler://slow-queries" => Resources::SlowQueries,
343
+ "profiler://n1-patterns" => Resources::N1Patterns,
344
+ "profiler://recent-jobs" => Resources::RecentJobs,
345
+ "profiler://slow-tests" => Resources::SlowTests,
346
+ "profiler://failing-tests" => Resources::FailingTests
288
347
  }
289
348
 
290
349
  resources = [
@@ -311,6 +370,18 @@ module Profiler
311
370
  name: "Recent Jobs",
312
371
  description: "List of recently profiled background jobs",
313
372
  mime_type: "application/json"
373
+ ),
374
+ ::MCP::Resource.new(
375
+ uri: "profiler://slow-tests",
376
+ name: "Slow Tests",
377
+ description: "Top 10 slowest test profiles with query counts and N+1 detection",
378
+ mime_type: "application/json"
379
+ ),
380
+ ::MCP::Resource.new(
381
+ uri: "profiler://failing-tests",
382
+ name: "Failing Tests",
383
+ description: "Recent test profiles with status 'failed', including exception messages",
384
+ mime_type: "application/json"
314
385
  )
315
386
  ]
316
387
 
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Tools
6
+ class GetTestProfileDetail
7
+ def self.call(params)
8
+ token = params["token"]
9
+ unless token
10
+ return [{ type: "text", text: "Error: token parameter is required" }]
11
+ end
12
+
13
+ profile = if token == "latest"
14
+ profiles = Profiler.storage.list(limit: 200)
15
+ profiles.find { |p| p.profile_type == "test" }
16
+ else
17
+ Profiler.storage.load(token)
18
+ end
19
+
20
+ unless profile && profile.profile_type == "test"
21
+ return [{ type: "text", text: "Test profile not found: #{token}" }]
22
+ end
23
+
24
+ [{ type: "text", text: format_test_detail(profile) }]
25
+ end
26
+
27
+ private
28
+
29
+ def self.format_test_detail(profile)
30
+ test_data = profile.collector_data("test") || {}
31
+ db_data = profile.collector_data("database") || {}
32
+ cache_data = profile.collector_data("cache") || {}
33
+ exc_data = profile.collector_data("exception") || {}
34
+
35
+ queries = db_data["queries"] || []
36
+ n1_count = count_n1_patterns(queries)
37
+
38
+ lines = []
39
+ lines << "# Test Profile Detail\n"
40
+
41
+ # Overview
42
+ lines << "## Overview"
43
+ lines << "| Field | Value |"
44
+ lines << "|-------|-------|"
45
+ lines << "| Token | `#{profile.token}` |"
46
+ lines << "| Test name | #{test_data["test_name"] || profile.path} |"
47
+ lines << "| Status | #{test_data["status"] || "unknown"} |"
48
+ lines << "| Framework | #{test_data["framework"]} |"
49
+ lines << "| File | #{test_data["test_file"]}:#{test_data["test_line"]} |"
50
+ lines << "| Duration | #{profile.duration&.round(2)}ms |"
51
+ lines << "| Assertions | #{test_data["assertions"] || "-"} |"
52
+ lines << "| Memory delta | #{profile.memory ? "#{(profile.memory.to_f / 1024 / 1024).round(2)} MB" : "-"} |"
53
+ lines << "| Time | #{profile.started_at&.strftime("%H:%M:%S")} |"
54
+
55
+ # Exception / skip
56
+ if test_data["exception_message"]
57
+ lines << "\n## Exception"
58
+ lines << "```\n#{test_data["exception_message"]}\n```"
59
+ elsif test_data["skip_reason"]
60
+ lines << "\n## Skip reason"
61
+ lines << test_data["skip_reason"].to_s
62
+ end
63
+
64
+ # Unhandled exception (ExceptionCollector)
65
+ if exc_data["exception_class"]
66
+ lines << "\n## Unhandled Exception"
67
+ lines << "**#{exc_data["exception_class"]}**: #{exc_data["exception_message"]}"
68
+ if (backtrace = exc_data["backtrace"]).is_a?(Array) && backtrace.any?
69
+ lines << "\n```"
70
+ backtrace.first(5).each { |l| lines << l }
71
+ lines << "```"
72
+ end
73
+ end
74
+
75
+ # Database
76
+ lines << "\n## Database (#{db_data["total_queries"].to_i} queries · #{db_data["total_duration"].to_f.round(2)}ms · #{n1_count} N+1 patterns)"
77
+ if queries.any?
78
+ slow_threshold = Profiler.configuration.slow_query_threshold
79
+ slow = queries.select { |q| q["duration"].to_f >= slow_threshold }
80
+ show = slow.any? ? slow.first(10) : queries.first(10)
81
+ caption = slow.any? ? "Slowest queries:" : "First queries:"
82
+ lines << caption
83
+ lines << "| # | Duration | SQL |"
84
+ lines << "|---|----------|-----|"
85
+ show.each_with_index do |q, i|
86
+ sql = q["sql"].to_s.gsub("|", "\\|").then { |s| s.length > 100 ? s[0, 97] + "..." : s }
87
+ lines << "| #{i + 1} | #{q["duration"].to_f.round(2)}ms | `#{sql}` |"
88
+ end
89
+
90
+ if n1_count > 0
91
+ lines << "\n### N+1 Patterns Detected"
92
+ queries.group_by { |q| normalize_sql(q["sql"].to_s) }
93
+ .select { |_, qs| qs.size >= 3 }
94
+ .each do |pattern, qs|
95
+ lines << "- `#{pattern[0, 120]}` (×#{qs.size})"
96
+ end
97
+ end
98
+ else
99
+ lines << "_No SQL queries recorded._"
100
+ end
101
+
102
+ # Cache
103
+ total_cache = cache_data["total_reads"].to_i + cache_data["total_writes"].to_i + cache_data["total_deletes"].to_i
104
+ if total_cache > 0
105
+ lines << "\n## Cache (#{total_cache} operations)"
106
+ lines << "- Reads: #{cache_data["total_reads"].to_i}"
107
+ lines << "- Writes: #{cache_data["total_writes"].to_i}"
108
+ lines << "- Deletes: #{cache_data["total_deletes"].to_i}"
109
+ lines << "- Misses: #{cache_data["total_misses"].to_i}"
110
+ end
111
+
112
+ lines.join("\n")
113
+ end
114
+
115
+ def self.count_n1_patterns(queries)
116
+ return 0 if queries.size < 3
117
+ queries.group_by { |q| normalize_sql(q["sql"].to_s) }.count { |_, qs| qs.size >= 3 }
118
+ end
119
+
120
+ def self.normalize_sql(sql)
121
+ sql.gsub(/\$\d+/, "?").gsub(/\b\d+\b/, "?").gsub(/'[^']*'/, "?").gsub(/"[^"]*"/, "?").strip
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end