QueryWise 0.2.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 +7 -0
- data/CHANGELOG.md +45 -0
- data/CLOUD_RUN_README.md +263 -0
- data/DOCKER_README.md +327 -0
- data/Dockerfile +69 -0
- data/Dockerfile.cloudrun +76 -0
- data/Dockerfile.dev +36 -0
- data/GEM_Gemfile +16 -0
- data/GEM_README.md +421 -0
- data/GEM_Rakefile +10 -0
- data/GEM_gitignore +137 -0
- data/LICENSE.txt +21 -0
- data/PUBLISHING_GUIDE.md +269 -0
- data/README.md +392 -0
- data/app/controllers/api/v1/analysis_controller.rb +340 -0
- data/app/controllers/api/v1/api_keys_controller.rb +83 -0
- data/app/controllers/api/v1/base_controller.rb +93 -0
- data/app/controllers/api/v1/health_controller.rb +86 -0
- data/app/controllers/application_controller.rb +2 -0
- data/app/controllers/concerns/.keep +0 -0
- data/app/jobs/application_job.rb +7 -0
- data/app/mailers/application_mailer.rb +4 -0
- data/app/models/app_profile.rb +18 -0
- data/app/models/application_record.rb +3 -0
- data/app/models/concerns/.keep +0 -0
- data/app/models/optimization_suggestion.rb +44 -0
- data/app/models/query_analysis.rb +47 -0
- data/app/models/query_pattern.rb +55 -0
- data/app/services/missing_index_detector_service.rb +244 -0
- data/app/services/n_plus_one_detector_service.rb +177 -0
- data/app/services/slow_query_analyzer_service.rb +225 -0
- data/app/services/sql_parser_service.rb +352 -0
- data/app/validators/query_data_validator.rb +96 -0
- data/app/views/layouts/mailer.html.erb +13 -0
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app.yaml +109 -0
- data/cloudbuild.yaml +47 -0
- data/config/application.rb +32 -0
- data/config/boot.rb +4 -0
- data/config/cable.yml +17 -0
- data/config/cache.yml +16 -0
- data/config/credentials.yml.enc +1 -0
- data/config/database.yml +69 -0
- data/config/deploy.yml +116 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +70 -0
- data/config/environments/production.rb +87 -0
- data/config/environments/test.rb +53 -0
- data/config/initializers/cors.rb +16 -0
- data/config/initializers/filter_parameter_logging.rb +8 -0
- data/config/initializers/inflections.rb +16 -0
- data/config/locales/en.yml +31 -0
- data/config/master.key +1 -0
- data/config/puma.rb +41 -0
- data/config/puma_cloudrun.rb +48 -0
- data/config/queue.yml +18 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +28 -0
- data/config/storage.yml +34 -0
- data/config.ru +6 -0
- data/db/cable_schema.rb +11 -0
- data/db/cache_schema.rb +14 -0
- data/db/migrate/20250818214709_create_app_profiles.rb +13 -0
- data/db/migrate/20250818214731_create_query_analyses.rb +22 -0
- data/db/migrate/20250818214740_create_query_patterns.rb +22 -0
- data/db/migrate/20250818214805_create_optimization_suggestions.rb +20 -0
- data/db/queue_schema.rb +129 -0
- data/db/schema.rb +79 -0
- data/db/seeds.rb +9 -0
- data/init.sql +9 -0
- data/lib/query_optimizer_client/client.rb +176 -0
- data/lib/query_optimizer_client/configuration.rb +43 -0
- data/lib/query_optimizer_client/generators/install_generator.rb +43 -0
- data/lib/query_optimizer_client/generators/templates/README +46 -0
- data/lib/query_optimizer_client/generators/templates/analysis_job.rb +84 -0
- data/lib/query_optimizer_client/generators/templates/initializer.rb +30 -0
- data/lib/query_optimizer_client/middleware.rb +126 -0
- data/lib/query_optimizer_client/railtie.rb +37 -0
- data/lib/query_optimizer_client/tasks.rake +228 -0
- data/lib/query_optimizer_client/version.rb +5 -0
- data/lib/query_optimizer_client.rb +48 -0
- data/lib/tasks/.keep +0 -0
- data/public/robots.txt +1 -0
- data/query_optimizer_client.gemspec +60 -0
- data/script/.keep +0 -0
- data/storage/.keep +0 -0
- data/storage/development.sqlite3 +0 -0
- data/storage/test.sqlite3 +0 -0
- data/vendor/.keep +0 -0
- metadata +265 -0
@@ -0,0 +1,176 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'httparty'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module QueryOptimizerClient
|
7
|
+
class Client
|
8
|
+
include HTTParty
|
9
|
+
|
10
|
+
def initialize(config)
|
11
|
+
@config = config
|
12
|
+
@config.validate!
|
13
|
+
|
14
|
+
self.class.base_uri(@config.api_url)
|
15
|
+
self.class.default_timeout(@config.timeout)
|
16
|
+
end
|
17
|
+
|
18
|
+
def analyze_queries(queries)
|
19
|
+
return nil unless @config.enabled?
|
20
|
+
|
21
|
+
validate_queries!(queries)
|
22
|
+
|
23
|
+
with_retry do
|
24
|
+
response = self.class.post('/analyze',
|
25
|
+
headers: headers,
|
26
|
+
body: { queries: format_queries(queries) }.to_json
|
27
|
+
)
|
28
|
+
|
29
|
+
handle_response(response)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def analyze_for_ci(queries, threshold: nil)
|
34
|
+
return { 'data' => { 'passed' => true, 'score' => 100 } } unless @config.enabled?
|
35
|
+
|
36
|
+
threshold ||= @config.default_threshold
|
37
|
+
validate_queries!(queries)
|
38
|
+
validate_threshold!(threshold)
|
39
|
+
|
40
|
+
with_retry do
|
41
|
+
response = self.class.post('/analyze_ci',
|
42
|
+
headers: headers,
|
43
|
+
body: {
|
44
|
+
queries: format_queries(queries),
|
45
|
+
threshold_score: threshold
|
46
|
+
}.to_json
|
47
|
+
)
|
48
|
+
|
49
|
+
handle_response(response)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def health_check
|
54
|
+
response = self.class.get('/health', headers: basic_headers)
|
55
|
+
handle_response(response)
|
56
|
+
end
|
57
|
+
|
58
|
+
def create_api_key(app_name)
|
59
|
+
response = self.class.post('/api_keys',
|
60
|
+
headers: basic_headers,
|
61
|
+
body: { app_name: app_name }.to_json
|
62
|
+
)
|
63
|
+
|
64
|
+
handle_response(response)
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def headers
|
70
|
+
basic_headers.merge('X-API-Key' => @config.api_key)
|
71
|
+
end
|
72
|
+
|
73
|
+
def basic_headers
|
74
|
+
{ 'Content-Type' => 'application/json' }
|
75
|
+
end
|
76
|
+
|
77
|
+
def format_queries(queries)
|
78
|
+
queries.map do |query|
|
79
|
+
{
|
80
|
+
sql: extract_sql(query),
|
81
|
+
duration_ms: extract_duration(query)
|
82
|
+
}.compact
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def extract_sql(query)
|
87
|
+
case query
|
88
|
+
when Hash
|
89
|
+
query[:sql] || query['sql']
|
90
|
+
when String
|
91
|
+
query
|
92
|
+
else
|
93
|
+
query.to_s
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def extract_duration(query)
|
98
|
+
return nil unless query.is_a?(Hash)
|
99
|
+
|
100
|
+
duration = query[:duration_ms] || query['duration_ms']
|
101
|
+
duration&.to_f&.round(2)
|
102
|
+
end
|
103
|
+
|
104
|
+
def validate_queries!(queries)
|
105
|
+
raise ValidationError, "Queries must be an array" unless queries.is_a?(Array)
|
106
|
+
raise ValidationError, "At least one query is required" if queries.empty?
|
107
|
+
raise ValidationError, "Maximum #{@config.batch_size} queries allowed" if queries.length > @config.batch_size
|
108
|
+
|
109
|
+
queries.each_with_index do |query, index|
|
110
|
+
sql = extract_sql(query)
|
111
|
+
raise ValidationError, "Query #{index + 1}: SQL is required" if sql.blank?
|
112
|
+
raise ValidationError, "Query #{index + 1}: SQL too long (max 10000 chars)" if sql.length > 10000
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def validate_threshold!(threshold)
|
117
|
+
unless threshold.is_a?(Numeric) && threshold >= 0 && threshold <= 100
|
118
|
+
raise ValidationError, "Threshold must be a number between 0 and 100"
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def handle_response(response)
|
123
|
+
case response.code
|
124
|
+
when 200
|
125
|
+
JSON.parse(response.body)
|
126
|
+
when 400
|
127
|
+
raise ValidationError, parse_error_message(response)
|
128
|
+
when 401
|
129
|
+
raise AuthenticationError, "Invalid API key"
|
130
|
+
when 429
|
131
|
+
raise RateLimitError, "Rate limit exceeded"
|
132
|
+
when 500..599
|
133
|
+
raise APIError, "Server error: #{response.code}"
|
134
|
+
else
|
135
|
+
raise APIError, "Unexpected response: #{response.code}"
|
136
|
+
end
|
137
|
+
rescue JSON::ParserError => e
|
138
|
+
raise APIError, "Invalid JSON response: #{e.message}"
|
139
|
+
end
|
140
|
+
|
141
|
+
def parse_error_message(response)
|
142
|
+
body = JSON.parse(response.body)
|
143
|
+
body['error'] || body['errors']&.join(', ') || 'Unknown error'
|
144
|
+
rescue JSON::ParserError
|
145
|
+
"HTTP #{response.code}: #{response.body}"
|
146
|
+
end
|
147
|
+
|
148
|
+
def with_retry(&block)
|
149
|
+
retries = 0
|
150
|
+
|
151
|
+
begin
|
152
|
+
yield
|
153
|
+
rescue RateLimitError => e
|
154
|
+
if @config.rate_limit_retry && retries < @config.retries
|
155
|
+
wait_time = 2 ** retries
|
156
|
+
@config.logger&.warn("Rate limited, waiting #{wait_time} seconds...")
|
157
|
+
sleep(wait_time)
|
158
|
+
retries += 1
|
159
|
+
retry
|
160
|
+
else
|
161
|
+
raise e
|
162
|
+
end
|
163
|
+
rescue Net::TimeoutError, Errno::ECONNREFUSED => e
|
164
|
+
if retries < @config.retries
|
165
|
+
wait_time = 2 ** retries
|
166
|
+
@config.logger&.warn("Connection error, retrying in #{wait_time} seconds...")
|
167
|
+
sleep(wait_time)
|
168
|
+
retries += 1
|
169
|
+
retry
|
170
|
+
else
|
171
|
+
raise APIError, "Connection failed after #{@config.retries} retries: #{e.message}"
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module QueryOptimizerClient
|
4
|
+
class Configuration
|
5
|
+
attr_accessor :api_url, :api_key, :enabled, :timeout, :retries, :logger,
|
6
|
+
:rate_limit_retry, :default_threshold, :batch_size
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@api_url = ENV.fetch('QUERY_OPTIMIZER_API_URL', 'http://localhost:3000/api/v1')
|
10
|
+
@api_key = ENV['QUERY_OPTIMIZER_API_KEY']
|
11
|
+
@enabled = ENV.fetch('QUERY_OPTIMIZER_ENABLED', 'false') == 'true'
|
12
|
+
@timeout = ENV.fetch('QUERY_OPTIMIZER_TIMEOUT', '30').to_i
|
13
|
+
@retries = ENV.fetch('QUERY_OPTIMIZER_RETRIES', '3').to_i
|
14
|
+
@rate_limit_retry = ENV.fetch('QUERY_OPTIMIZER_RATE_LIMIT_RETRY', 'true') == 'true'
|
15
|
+
@default_threshold = ENV.fetch('QUERY_OPTIMIZER_THRESHOLD', '80').to_i
|
16
|
+
@batch_size = ENV.fetch('QUERY_OPTIMIZER_BATCH_SIZE', '50').to_i
|
17
|
+
@logger = defined?(Rails) ? Rails.logger : Logger.new(STDOUT)
|
18
|
+
end
|
19
|
+
|
20
|
+
def enabled?
|
21
|
+
@enabled && @api_key.present?
|
22
|
+
end
|
23
|
+
|
24
|
+
def valid?
|
25
|
+
api_key.present? && api_url.present?
|
26
|
+
end
|
27
|
+
|
28
|
+
def validate!
|
29
|
+
raise ValidationError, "API key is required" unless api_key.present?
|
30
|
+
raise ValidationError, "API URL is required" unless api_url.present?
|
31
|
+
raise ValidationError, "Invalid API URL format" unless valid_url?(api_url)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def valid_url?(url)
|
37
|
+
uri = URI.parse(url)
|
38
|
+
uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
39
|
+
rescue URI::InvalidURIError
|
40
|
+
false
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators'
|
4
|
+
|
5
|
+
module QueryOptimizerClient
|
6
|
+
module Generators
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
8
|
+
desc "Install QueryWise"
|
9
|
+
|
10
|
+
source_root File.expand_path('templates', __dir__)
|
11
|
+
|
12
|
+
def create_initializer
|
13
|
+
template 'initializer.rb', 'config/initializers/query_optimizer_client.rb'
|
14
|
+
end
|
15
|
+
|
16
|
+
def create_job
|
17
|
+
template 'analysis_job.rb', 'app/jobs/query_optimizer_client/analysis_job.rb'
|
18
|
+
end
|
19
|
+
|
20
|
+
def add_environment_variables
|
21
|
+
environment_template = <<~ENV
|
22
|
+
# Query Optimizer Client Configuration
|
23
|
+
# QUERY_OPTIMIZER_API_URL=http://localhost:3000/api/v1
|
24
|
+
# QUERY_OPTIMIZER_API_KEY=your_api_key_here
|
25
|
+
# QUERY_OPTIMIZER_ENABLED=true
|
26
|
+
# QUERY_OPTIMIZER_THRESHOLD=80
|
27
|
+
ENV
|
28
|
+
|
29
|
+
append_to_file '.env.example', environment_template
|
30
|
+
|
31
|
+
if File.exist?('.env')
|
32
|
+
append_to_file '.env', environment_template
|
33
|
+
else
|
34
|
+
create_file '.env', environment_template
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def show_readme
|
39
|
+
readme 'README'
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
===============================================================================
|
2
|
+
|
3
|
+
QueryWise has been installed!
|
4
|
+
|
5
|
+
===============================================================================
|
6
|
+
|
7
|
+
Next steps:
|
8
|
+
|
9
|
+
1. Set up your environment variables:
|
10
|
+
|
11
|
+
Add to your .env file:
|
12
|
+
QUERY_OPTIMIZER_API_URL=http://localhost:3000/api/v1
|
13
|
+
QUERY_OPTIMIZER_API_KEY=your_api_key_here
|
14
|
+
QUERY_OPTIMIZER_ENABLED=true
|
15
|
+
|
16
|
+
2. Generate an API key (if you don't have one):
|
17
|
+
|
18
|
+
rails query_optimizer:generate_key["My App Name"]
|
19
|
+
|
20
|
+
3. Test your configuration:
|
21
|
+
|
22
|
+
rails query_optimizer:check
|
23
|
+
|
24
|
+
4. Run a sample analysis:
|
25
|
+
|
26
|
+
rails query_optimizer:analyze
|
27
|
+
|
28
|
+
5. Set up CI/CD integration:
|
29
|
+
|
30
|
+
Add to your CI pipeline:
|
31
|
+
rails query_optimizer:ci[85]
|
32
|
+
|
33
|
+
6. Customize the configuration in:
|
34
|
+
|
35
|
+
config/initializers/query_optimizer_client.rb
|
36
|
+
|
37
|
+
7. Optional: Customize the analysis job in:
|
38
|
+
|
39
|
+
app/jobs/query_optimizer_client/analysis_job.rb
|
40
|
+
|
41
|
+
===============================================================================
|
42
|
+
|
43
|
+
For more information, visit:
|
44
|
+
https://github.com/yourusername/query_optimizer_client
|
45
|
+
|
46
|
+
===============================================================================
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module QueryOptimizerClient
|
4
|
+
class AnalysisJob < ApplicationJob
|
5
|
+
queue_as :default
|
6
|
+
|
7
|
+
def perform(queries, endpoint = nil)
|
8
|
+
return unless QueryOptimizerClient.enabled?
|
9
|
+
|
10
|
+
result = QueryOptimizerClient.analyze_queries(queries)
|
11
|
+
|
12
|
+
return unless result&.dig('success')
|
13
|
+
|
14
|
+
data = result['data']
|
15
|
+
score = data['summary']['optimization_score']
|
16
|
+
|
17
|
+
# Log analysis results
|
18
|
+
log_analysis_results(data, endpoint)
|
19
|
+
|
20
|
+
# Store results (optional - implement based on your needs)
|
21
|
+
store_analysis_results(data, endpoint) if respond_to?(:store_analysis_results, true)
|
22
|
+
|
23
|
+
# Send alerts if performance is poor
|
24
|
+
send_alerts(data, endpoint, score) if score < 70
|
25
|
+
|
26
|
+
rescue => e
|
27
|
+
Rails.logger.error "Query analysis failed: #{e.message}"
|
28
|
+
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def log_analysis_results(data, endpoint)
|
34
|
+
endpoint_info = endpoint ? " on #{endpoint}" : ""
|
35
|
+
|
36
|
+
Rails.logger.info "Query analysis completed#{endpoint_info}: Score #{data['summary']['optimization_score']}%, #{data['summary']['issues_found']} issues found"
|
37
|
+
|
38
|
+
# Log N+1 queries
|
39
|
+
if data['n_plus_one']['detected']
|
40
|
+
Rails.logger.warn "N+1 queries detected#{endpoint_info}:"
|
41
|
+
data['n_plus_one']['patterns'].each do |pattern|
|
42
|
+
Rails.logger.warn " - #{pattern['table']}.#{pattern['column']}: #{pattern['suggestion']}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Log slow queries
|
47
|
+
data['slow_queries'].each do |query|
|
48
|
+
Rails.logger.warn "Slow query#{endpoint_info} (#{query['duration_ms']}ms): #{query['sql'][0..100]}..."
|
49
|
+
query['suggestions'].each do |suggestion|
|
50
|
+
Rails.logger.warn " 💡 #{suggestion}"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Log missing indexes
|
55
|
+
data['missing_indexes'].each do |index|
|
56
|
+
Rails.logger.info "Missing index suggestion#{endpoint_info}: #{index['sql']}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def send_alerts(data, endpoint, score)
|
61
|
+
endpoint_info = endpoint ? " on #{endpoint}" : ""
|
62
|
+
|
63
|
+
if score < 50
|
64
|
+
Rails.logger.error "🚨 CRITICAL: Very low performance score (#{score}%)#{endpoint_info}"
|
65
|
+
# Add your critical alerting logic here (Slack, email, etc.)
|
66
|
+
elsif score < 70
|
67
|
+
Rails.logger.warn "⚠️ WARNING: Low performance score (#{score}%)#{endpoint_info}"
|
68
|
+
# Add your warning alerting logic here
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Optional: Implement this method to store analysis results in your database
|
73
|
+
# def store_analysis_results(data, endpoint)
|
74
|
+
# PerformanceAnalysis.create!(
|
75
|
+
# endpoint: endpoint,
|
76
|
+
# optimization_score: data['summary']['optimization_score'],
|
77
|
+
# issues_found: data['summary']['issues_found'],
|
78
|
+
# total_queries: data['summary']['total_queries'],
|
79
|
+
# analysis_data: data,
|
80
|
+
# analyzed_at: Time.current
|
81
|
+
# )
|
82
|
+
# end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
QueryOptimizerClient.configure do |config|
|
4
|
+
# API Configuration
|
5
|
+
config.api_url = ENV.fetch('QUERY_OPTIMIZER_API_URL', 'http://localhost:3000/api/v1')
|
6
|
+
config.api_key = ENV['QUERY_OPTIMIZER_API_KEY']
|
7
|
+
config.enabled = ENV.fetch('QUERY_OPTIMIZER_ENABLED', 'false') == 'true'
|
8
|
+
|
9
|
+
# Request Configuration
|
10
|
+
config.timeout = ENV.fetch('QUERY_OPTIMIZER_TIMEOUT', '30').to_i
|
11
|
+
config.retries = ENV.fetch('QUERY_OPTIMIZER_RETRIES', '3').to_i
|
12
|
+
config.batch_size = ENV.fetch('QUERY_OPTIMIZER_BATCH_SIZE', '50').to_i
|
13
|
+
|
14
|
+
# Analysis Configuration
|
15
|
+
config.default_threshold = ENV.fetch('QUERY_OPTIMIZER_THRESHOLD', '80').to_i
|
16
|
+
config.rate_limit_retry = ENV.fetch('QUERY_OPTIMIZER_RATE_LIMIT_RETRY', 'true') == 'true'
|
17
|
+
|
18
|
+
# Logging
|
19
|
+
config.logger = Rails.logger
|
20
|
+
end
|
21
|
+
|
22
|
+
# Optional: Configure Rails-specific settings
|
23
|
+
Rails.application.configure do
|
24
|
+
# Middleware configuration
|
25
|
+
config.query_optimizer_client.min_queries = 3
|
26
|
+
config.query_optimizer_client.min_duration = 10
|
27
|
+
config.query_optimizer_client.skip_paths = ['/assets', '/health', '/api']
|
28
|
+
config.query_optimizer_client.async = true
|
29
|
+
config.query_optimizer_client.job_class = 'QueryOptimizerClient::AnalysisJob'
|
30
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module QueryOptimizerClient
|
4
|
+
class Middleware
|
5
|
+
def initialize(app, options = {})
|
6
|
+
@app = app
|
7
|
+
@options = default_options.merge(options)
|
8
|
+
@client = QueryOptimizerClient.client
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(env)
|
12
|
+
return @app.call(env) unless should_monitor?(env)
|
13
|
+
|
14
|
+
queries = []
|
15
|
+
|
16
|
+
subscription = ActiveSupport::Notifications.subscribe('sql.active_record') do |name, start, finish, id, payload|
|
17
|
+
duration = (finish - start) * 1000
|
18
|
+
|
19
|
+
next if skip_query?(payload, duration)
|
20
|
+
|
21
|
+
queries << {
|
22
|
+
sql: payload[:sql],
|
23
|
+
duration_ms: duration.round(2)
|
24
|
+
}
|
25
|
+
end
|
26
|
+
|
27
|
+
status, headers, response = @app.call(env)
|
28
|
+
|
29
|
+
# Analyze queries asynchronously if we have enough
|
30
|
+
if queries.length >= @options[:min_queries]
|
31
|
+
analyze_queries_async(queries, extract_endpoint(env))
|
32
|
+
end
|
33
|
+
|
34
|
+
[status, headers, response]
|
35
|
+
ensure
|
36
|
+
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def default_options
|
42
|
+
{
|
43
|
+
min_queries: 3,
|
44
|
+
min_duration: 10,
|
45
|
+
skip_paths: ['/assets', '/health', '/api'],
|
46
|
+
skip_methods: ['OPTIONS', 'HEAD'],
|
47
|
+
async: true,
|
48
|
+
job_class: 'QueryOptimizerClient::AnalysisJob'
|
49
|
+
}
|
50
|
+
end
|
51
|
+
|
52
|
+
def should_monitor?(env)
|
53
|
+
return false unless QueryOptimizerClient.enabled?
|
54
|
+
|
55
|
+
request = Rack::Request.new(env)
|
56
|
+
|
57
|
+
# Skip certain paths
|
58
|
+
return false if @options[:skip_paths].any? { |path| request.path.start_with?(path) }
|
59
|
+
|
60
|
+
# Skip certain methods
|
61
|
+
return false if @options[:skip_methods].include?(request.request_method)
|
62
|
+
|
63
|
+
true
|
64
|
+
end
|
65
|
+
|
66
|
+
def skip_query?(payload, duration)
|
67
|
+
# Skip schema queries, cache queries, and very fast queries
|
68
|
+
payload[:name] =~ /SCHEMA|CACHE/ || duration < @options[:min_duration]
|
69
|
+
end
|
70
|
+
|
71
|
+
def extract_endpoint(env)
|
72
|
+
request = Rack::Request.new(env)
|
73
|
+
"#{request.request_method} #{request.path}"
|
74
|
+
end
|
75
|
+
|
76
|
+
def analyze_queries_async(queries, endpoint)
|
77
|
+
if @options[:async] && defined?(ActiveJob)
|
78
|
+
job_class = @options[:job_class].constantize
|
79
|
+
job_class.perform_later(queries, endpoint)
|
80
|
+
else
|
81
|
+
analyze_queries_sync(queries, endpoint)
|
82
|
+
end
|
83
|
+
rescue NameError
|
84
|
+
# Fallback to sync if job class doesn't exist
|
85
|
+
analyze_queries_sync(queries, endpoint)
|
86
|
+
end
|
87
|
+
|
88
|
+
def analyze_queries_sync(queries, endpoint)
|
89
|
+
result = @client.analyze_queries(queries)
|
90
|
+
|
91
|
+
if result&.dig('success')
|
92
|
+
handle_analysis_result(result['data'], endpoint)
|
93
|
+
end
|
94
|
+
rescue => e
|
95
|
+
QueryOptimizerClient.configuration.logger&.error("Query analysis failed: #{e.message}")
|
96
|
+
end
|
97
|
+
|
98
|
+
def handle_analysis_result(data, endpoint)
|
99
|
+
score = data['summary']['optimization_score']
|
100
|
+
|
101
|
+
# Log issues
|
102
|
+
if data['n_plus_one']['detected']
|
103
|
+
QueryOptimizerClient.configuration.logger&.warn(
|
104
|
+
"N+1 queries detected on #{endpoint}: #{data['n_plus_one']['patterns'].length} patterns"
|
105
|
+
)
|
106
|
+
end
|
107
|
+
|
108
|
+
data['slow_queries'].each do |query|
|
109
|
+
QueryOptimizerClient.configuration.logger&.warn(
|
110
|
+
"Slow query on #{endpoint} (#{query['duration_ms']}ms): #{query['sql'][0..100]}..."
|
111
|
+
)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Alert if score is low
|
115
|
+
if score < 60
|
116
|
+
QueryOptimizerClient.configuration.logger&.error(
|
117
|
+
"CRITICAL: Low performance score (#{score}%) on #{endpoint}"
|
118
|
+
)
|
119
|
+
elsif score < 75
|
120
|
+
QueryOptimizerClient.configuration.logger&.warn(
|
121
|
+
"WARNING: Performance score (#{score}%) on #{endpoint}"
|
122
|
+
)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/railtie'
|
4
|
+
|
5
|
+
module QueryOptimizerClient
|
6
|
+
class Railtie < Rails::Railtie
|
7
|
+
railtie_name :query_optimizer_client
|
8
|
+
|
9
|
+
config.query_optimizer_client = ActiveSupport::OrderedOptions.new
|
10
|
+
|
11
|
+
initializer "query_optimizer_client.configure" do |app|
|
12
|
+
QueryOptimizerClient.configure do |config|
|
13
|
+
# Set Rails logger
|
14
|
+
config.logger = Rails.logger
|
15
|
+
|
16
|
+
# Apply any configuration from Rails config
|
17
|
+
app.config.query_optimizer_client.each do |key, value|
|
18
|
+
config.public_send("#{key}=", value) if config.respond_to?("#{key}=")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
initializer "query_optimizer_client.middleware" do |app|
|
24
|
+
if QueryOptimizerClient.configuration.enabled?
|
25
|
+
app.config.middleware.use QueryOptimizerClient::Middleware
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
rake_tasks do
|
30
|
+
load "query_optimizer_client/tasks.rake"
|
31
|
+
end
|
32
|
+
|
33
|
+
generators do
|
34
|
+
require "query_optimizer_client/generators/install_generator"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|