lookout-query_reviewer 0.1.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (32) hide show
  1. data/MIT-LICENSE +20 -0
  2. data/README.md +136 -0
  3. data/Rakefile +24 -0
  4. data/lib/query_reviewer.rb +66 -0
  5. data/lib/query_reviewer/array_extensions.rb +29 -0
  6. data/lib/query_reviewer/controller_extensions.rb +67 -0
  7. data/lib/query_reviewer/mysql_adapter_extensions.rb +92 -0
  8. data/lib/query_reviewer/mysql_analyzer.rb +62 -0
  9. data/lib/query_reviewer/query_warning.rb +17 -0
  10. data/lib/query_reviewer/rails.rb +37 -0
  11. data/lib/query_reviewer/sql_query.rb +131 -0
  12. data/lib/query_reviewer/sql_query_collection.rb +103 -0
  13. data/lib/query_reviewer/sql_sub_query.rb +45 -0
  14. data/lib/query_reviewer/tasks.rb +8 -0
  15. data/lib/query_reviewer/views/_box.html.erb +11 -0
  16. data/lib/query_reviewer/views/_box_ajax.js +34 -0
  17. data/lib/query_reviewer/views/_box_body.html.erb +73 -0
  18. data/lib/query_reviewer/views/_box_disabled.html.erb +2 -0
  19. data/lib/query_reviewer/views/_box_header.html.erb +1 -0
  20. data/lib/query_reviewer/views/_box_includes.html.erb +234 -0
  21. data/lib/query_reviewer/views/_explain.html.erb +30 -0
  22. data/lib/query_reviewer/views/_js_includes.html.erb +68 -0
  23. data/lib/query_reviewer/views/_js_includes_new.html.erb +68 -0
  24. data/lib/query_reviewer/views/_profile.html.erb +26 -0
  25. data/lib/query_reviewer/views/_query_sql.html.erb +8 -0
  26. data/lib/query_reviewer/views/_query_trace.html.erb +31 -0
  27. data/lib/query_reviewer/views/_query_with_warning.html.erb +54 -0
  28. data/lib/query_reviewer/views/_spectrum.html.erb +10 -0
  29. data/lib/query_reviewer/views/_warning_no_query.html.erb +8 -0
  30. data/lib/query_reviewer/views/query_review_box_helper.rb +99 -0
  31. data/query_reviewer_defaults.yml +39 -0
  32. metadata +77 -0
@@ -0,0 +1,17 @@
1
+ module QueryReviewer
2
+ class QueryWarning
3
+ attr_reader :query, :severity, :problem, :desc, :table, :id
4
+
5
+ cattr_accessor :next_id
6
+ self.next_id = 1
7
+
8
+ def initialize(options)
9
+ @query = options[:query]
10
+ @severity = options[:severity]
11
+ @problem = options[:problem]
12
+ @desc = options[:desc]
13
+ @table = options[:table]
14
+ @id = (self.class.next_id += 1)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,37 @@
1
+ require 'query_reviewer'
2
+
3
+ module QueryReviewer
4
+ def self.inject_reviewer
5
+ # Load adapters
6
+ ActiveRecord::Base
7
+ adapter_class = ActiveRecord::ConnectionAdapters::MysqlAdapter if defined? ActiveRecord::ConnectionAdapters::MysqlAdapter
8
+ adapter_class = ActiveRecord::ConnectionAdapters::Mysql2Adapter if defined? ActiveRecord::ConnectionAdapters::Mysql2Adapter
9
+ adapter_class.send(:include, QueryReviewer::MysqlAdapterExtensions) if adapter_class
10
+ # Load into controllers
11
+ ActionController::Base.send(:include, QueryReviewer::ControllerExtensions)
12
+ Array.send(:include, QueryReviewer::ArrayExtensions)
13
+ if ActionController::Base.respond_to?(:append_view_path)
14
+ ActionController::Base.append_view_path(File.dirname(__FILE__) + "/lib/query_reviewer/views")
15
+ end
16
+ end
17
+ end
18
+
19
+ if defined?(Rails::Railtie)
20
+ module QueryReviewer
21
+ class Railtie < Rails::Railtie
22
+ rake_tasks do
23
+ load File.dirname(__FILE__) + "/tasks.rb"
24
+ end
25
+
26
+ initializer "query_reviewer.initialize" do
27
+ QueryReviewer.load_configuration
28
+
29
+ QueryReviewer.inject_reviewer if QueryReviewer.enabled?
30
+ end
31
+ end
32
+ end
33
+ else # Rails 2
34
+ QueryReviewer.load_configuration
35
+
36
+ QueryReviewer.inject_reviewer if QueryReviewer.enabled?
37
+ end
@@ -0,0 +1,131 @@
1
+ require "ostruct"
2
+
3
+ module QueryReviewer
4
+ # a single SQL SELECT query
5
+ class SqlQuery
6
+ attr_reader :sqls, :rows, :subqueries, :trace, :id, :command, :affected_rows, :profiles, :durations, :sanitized_sql
7
+
8
+ cattr_accessor :next_id
9
+ self.next_id = 1
10
+
11
+ def initialize(sql, rows, full_trace, duration = 0.0, profile = nil, command = "SELECT", affected_rows = 1, sanitized_sql = nil)
12
+ @trace = full_trace
13
+ @rows = rows
14
+ @sqls = [sql]
15
+ @sanitized_sql = sanitized_sql
16
+ @subqueries = rows ? rows.collect{|row| SqlSubQuery.new(self, row)} : []
17
+ @id = (self.class.next_id += 1)
18
+ @profiles = profile ? [profile.collect { |p| OpenStruct.new(p) }] : [nil]
19
+ @durations = [duration.to_f]
20
+ @warnings = []
21
+ @command = command
22
+ @affected_rows = affected_rows
23
+ end
24
+
25
+ def add(sql, duration, profile)
26
+ sql << sql
27
+ durations << duration
28
+ profiles << profile
29
+ end
30
+
31
+ def sql
32
+ sqls.first
33
+ end
34
+
35
+ def count
36
+ durations.size
37
+ end
38
+
39
+ def profile
40
+ profiles.first
41
+ end
42
+
43
+ def duration
44
+ durations.sum
45
+ end
46
+
47
+ def duration_stats
48
+ "TOTAL:#{'%.3f' % duration} AVG:#{'%.3f' % (durations.sum / durations.size)} MAX:#{'%.3f' % (durations.max)} MIN:#{'%.3f' % (durations.min)}"
49
+ end
50
+
51
+ def to_table
52
+ rows.qa_columnized
53
+ end
54
+
55
+ def warnings
56
+ self.subqueries.collect(&:warnings).flatten + @warnings
57
+ end
58
+
59
+ def has_warnings?
60
+ !self.warnings.empty?
61
+ end
62
+
63
+ def max_severity
64
+ self.warnings.empty? ? 0 : self.warnings.collect(&:severity).max
65
+ end
66
+
67
+ def table
68
+ @subqueries.first.table
69
+ end
70
+
71
+ def analyze!
72
+ self.subqueries.collect(&:analyze!)
73
+ if duration
74
+ if duration >= QueryReviewer::CONFIGURATION["critical_duration_threshold"]
75
+ warn(:problem => "Query took #{duration} seconds", :severity => 9)
76
+ elsif duration >= QueryReviewer::CONFIGURATION["warn_duration_threshold"]
77
+ warn(:problem => "Query took #{duration} seconds", :severity => QueryReviewer::CONFIGURATION["critical_severity"])
78
+ end
79
+ end
80
+
81
+ if affected_rows >= QueryReviewer::CONFIGURATION["critical_affected_rows"]
82
+ warn(:problem => "#{affected_rows} rows affected", :severity => 9, :description => "An UPDATE or DELETE query can be slow and lock tables if it affects many rows.")
83
+ elsif affected_rows >= QueryReviewer::CONFIGURATION["warn_affected_rows"]
84
+ warn(:problem => "#{affected_rows} rows affected", :severity => QueryReviewer::CONFIGURATION["critical_severity"], :description => "An UPDATE or DELETE query can be slow and lock tables if it affects many rows.")
85
+ end
86
+ end
87
+
88
+ def to_hash
89
+ @sql.hash
90
+ end
91
+
92
+ def relevant_trace
93
+ trace.collect(&:strip).select{|t| t.starts_with?(Rails.root.to_s) &&
94
+ (!t.starts_with?("#{Rails.root}/vendor") || QueryReviewer::CONFIGURATION["trace_includes_vendor"]) &&
95
+ (!t.starts_with?("#{Rails.root}/lib") || QueryReviewer::CONFIGURATION["trace_includes_lib"]) &&
96
+ !t.starts_with?("#{Rails.root}/vendor/plugins/query_reviewer") }
97
+ end
98
+
99
+ def full_trace
100
+ self.class.generate_full_trace(trace)
101
+ end
102
+
103
+ def warn(options)
104
+ options[:query] = self
105
+ options[:table] ||= self.table
106
+ @warnings << QueryWarning.new(options)
107
+ end
108
+
109
+ def select?
110
+ self.command == "SELECT"
111
+ end
112
+
113
+ def self.generate_full_trace(trace = Kernel.caller)
114
+ trace.collect(&:strip).select{|t| !t.starts_with?("#{Rails.root}/vendor/plugins/query_reviewer") }
115
+ end
116
+
117
+ def self.sanitize_strings_and_numbers_from_sql(sql)
118
+ new_sql = sql.clone
119
+ new_sql = new_sql.to_sql if new_sql.respond_to?(:to_sql)
120
+ new_sql.gsub!(/\b\d+\b/, "N")
121
+ new_sql.gsub!(/\b0x[0-9A-Fa-f]+\b/, "N")
122
+ new_sql.gsub!(/''/, "'S'")
123
+ new_sql.gsub!(/""/, "\"S\"")
124
+ new_sql.gsub!(/\\'/, "")
125
+ new_sql.gsub!(/\\"/, "")
126
+ new_sql.gsub!(/'[^']+'/, "'S'")
127
+ new_sql.gsub!(/"[^"]+"/, "\"S\"")
128
+ new_sql
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,103 @@
1
+ module QueryReviewer
2
+ # a collection of SQL SELECT queries
3
+ class SqlQueryCollection
4
+ COMMANDS = %w(SELECT DELETE INSERT UPDATE)
5
+
6
+ attr_reader :query_hash
7
+ attr_accessor :overhead_time
8
+ def initialize(query_hash = {})
9
+ @query_hash = query_hash
10
+ @overhead_time = 0.0
11
+ end
12
+
13
+ def queries
14
+ query_hash.values
15
+ end
16
+
17
+ def total_duration
18
+ self.queries.collect(&:durations).flatten.sum
19
+ end
20
+
21
+ def query_count
22
+ queries.collect(&:count).sum
23
+ end
24
+
25
+ def analyze!
26
+ self.queries.collect(&:analyze!)
27
+
28
+ @warnings = []
29
+
30
+ crit_severity = 9# ((QueryReviewer::CONFIGURATION["critical_severity"] + 10)/2).to_i
31
+ warn_severity = QueryReviewer::CONFIGURATION["critical_severity"] - 1 # ((QueryReviewer::CONFIGURATION["warn_severity"] + QueryReviewer::CONFIGURATION["critical_severity"])/2).to_i
32
+
33
+ COMMANDS.each do |command|
34
+ count = count_of_command(command)
35
+ if count > QueryReviewer::CONFIGURATION["critical_#{command.downcase}_count"]
36
+ warn(:severity => crit_severity, :problem => "#{count} #{command} queries on this page", :description => "Too many #{command} queries can severely slow down a page")
37
+ elsif count > QueryReviewer::CONFIGURATION["warn_#{command.downcase}_count"]
38
+ warn(:severity => warn_severity, :problem => "#{count} #{command} queries on this page", :description => "Too many #{command} queries can slow down a page")
39
+ end
40
+ end
41
+ end
42
+
43
+ def find_or_create_sql_query(sql, cols, run_time, profile, command, affected_rows)
44
+ sanitized_sql = SqlQuery.sanitize_strings_and_numbers_from_sql(sql)
45
+ trace = SqlQuery.generate_full_trace(Kernel.caller)
46
+ key = [sanitized_sql, trace]
47
+ if query_hash[key]
48
+ query_hash[key].add(sql, run_time, profile)
49
+ else
50
+ query_hash[key] = SqlQuery.new(sql, cols, trace, run_time, profile, command, affected_rows, sanitized_sql)
51
+ end
52
+ end
53
+
54
+ def warn(options)
55
+ @warnings << QueryWarning.new(options)
56
+ end
57
+
58
+ def warnings
59
+ self.queries.collect(&:warnings).flatten.sort{|a,b| b.severity <=> a.severity}
60
+ end
61
+
62
+ def without_warnings
63
+ self.queries.reject{|q| q.has_warnings?}.sort{|a,b| b.duration <=> a.duration}
64
+ end
65
+
66
+ def collection_warnings
67
+ @warnings
68
+ end
69
+
70
+ def max_severity
71
+ warnings.empty? && collection_warnings.empty? ? 0 : [warnings.empty? ? 0 : warnings.collect(&:severity).flatten.max, collection_warnings.empty? ? 0 : collection_warnings.collect(&:severity).flatten.max].max
72
+ end
73
+
74
+ def only_of_command(command, only_no_warnings = false)
75
+ qs = only_no_warnings ? self.without_warnings : self.queries
76
+ qs.select{|q| q.command == command}
77
+ end
78
+
79
+ def count_of_command(command, only_no_warnings = false)
80
+ only_of_command(command, only_no_warnings).collect(&:durations).collect(&:size).sum
81
+ end
82
+
83
+ def total_severity
84
+ warnings.collect(&:severity).sum
85
+ end
86
+
87
+ def total_with_warnings
88
+ queries.select(&:has_warnings?).length
89
+ end
90
+
91
+ def total_without_warnings
92
+ queries.length - total_with_warnings
93
+ end
94
+
95
+ def percent_with_warnings
96
+ queries.empty? ? 0 : (100.0 * total_with_warnings / queries.length).to_i
97
+ end
98
+
99
+ def percent_without_warnings
100
+ queries.empty? ? 0 : (100.0 * total_without_warnings / queries.length).to_i
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,45 @@
1
+ module QueryReviewer
2
+ # a single part of an SQL SELECT query
3
+ class SqlSubQuery < OpenStruct
4
+ include MysqlAnalyzer
5
+
6
+ delegate :sql, :to => :parent
7
+ attr_reader :cols, :warnings, :parent
8
+ def initialize(parent, cols)
9
+ @parent = parent
10
+ @warnings = []
11
+ @cols = cols.inject({}) {|memo, obj| memo[obj[0].to_s.downcase] = obj[1].to_s.downcase; memo }
12
+ @cols["query_type"] = @cols.delete("type")
13
+ super(@cols)
14
+ end
15
+
16
+ def analyze!
17
+ @warnings = []
18
+ adapter_name = ActiveRecord::Base.connection.instance_variable_get("@config")[:adapter]
19
+ adapter_name = 'mysql' if adapter_name == 'mysql2'
20
+ method_name = "do_#{adapter_name}_analysis!"
21
+ self.send(method_name.to_sym)
22
+ end
23
+
24
+ def table
25
+ @table[:table]
26
+ end
27
+
28
+ private
29
+
30
+ def warn(options)
31
+ if (options[:field])
32
+ field = options.delete(:field)
33
+ val = self.send(field)
34
+ options[:problem] = ("#{field.to_s.titleize}: #{val.blank? ? "(blank)" : val}")
35
+ end
36
+ options[:query] = self
37
+ options[:table] = self.table
38
+ @warnings << QueryWarning.new(options)
39
+ end
40
+
41
+ def praise(options)
42
+ # no credit, only pain
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,8 @@
1
+ namespace :query_reviewer do
2
+ desc "Create a default config/query_reviewer.yml"
3
+ task :setup do
4
+ defaults_path = File.join(File.dirname(__FILE__), "../..", "query_reviewer_defaults.yml")
5
+ dest_path = File.join(Rails.root.to_s, "config", "query_reviewer.yml")
6
+ FileUtils.copy(defaults_path, dest_path)
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ <div id="query_review_parent" class="query_review_parent">
2
+ <div id="query_review_0" class="query_review_container">
3
+ <%= render :partial => "/box_includes"%>
4
+ <div class="query_review <%= parent_div_class %>" id = "query_review_header_0">
5
+ <%= render :partial => "/box_header" %>
6
+ </div>
7
+ <div class="query_review_details" id="query_review_details_0" style="display: none;">
8
+ <%= render :partial => enabled_by_cookie ? "/box_body" : "/box_disabled" %>
9
+ </div>
10
+ </div>
11
+ </div>
@@ -0,0 +1,34 @@
1
+ var i = 0;
2
+ var last = null;
3
+ for(i=0; i<10; i++) {
4
+ if(document.getElementById("query_review_"+i)) {
5
+ last = document.getElementById("query_review_"+i);
6
+ } else {
7
+ break;
8
+ }
9
+ }
10
+ if(i < 10) {
11
+ var container_div = document.createElement("div");
12
+ container_div.style.left = ""+(i * 30 + 1)+"px";
13
+ container_div.setAttribute("id", "query_review_"+i);
14
+ container_div.className = "query_review_container";
15
+ var ihtml = '<%= escape_javascript(render(:partial => "/box_includes"))%>';
16
+ ihtml += '<div class="query_review <%= parent_div_class %>" id = "query_review_header_'+i+'">';
17
+ ihtml += '<%= escape_javascript(render(:partial => "/box_header"))%>';
18
+ ihtml += '</div> ';
19
+ ihtml += '<div class="query_review_details" id="query_review_details_'+i+'" style="display: none;">';
20
+ ihtml += '<%= escape_javascript(render(:partial => enabled_by_cookie ? "/box_body" : "/box_disabled")) %>';
21
+ ihtml += '</div>';
22
+
23
+ container_div.innerHTML = ihtml;
24
+
25
+ var parent_div = document.getElementById("query_review_parent")
26
+ if(!parent_div) {
27
+ parent_div = document.createElement("div");
28
+ parent_div.setAttribute("id", "query_review_parent");
29
+ parent_div.className = "query_reivew_parent";
30
+ document.getElementById("body")[0].appendChild(parent_div);
31
+ }
32
+
33
+ parent_div.appendChild(container_div);
34
+ }
@@ -0,0 +1,73 @@
1
+ <p>Total queries: <span class="number"><%= @queries.query_count %></span>&nbsp;&nbsp;
2
+ <% if @total_time %>Total time: <span class="number" title="TOTAL TIME: <%= @total_time %>s QR_OVERHEAD: <%= @queries.overhead_time %>s">
3
+ <%= '%.3f' % (@total_time - @queries.overhead_time) %></span>s&nbsp;&nbsp;
4
+ <% end %>
5
+ Database Time: <span class="number"><%= '%.3f' % @queries.total_duration %></span>s</p>
6
+ <p class="indent">With warnings: <span class="number bad"><%= @queries.total_with_warnings %></span> (<%= @queries.percent_with_warnings %>%)</p>
7
+ <p class="indent">Without warnings: <span class="number good"><%= @queries.total_without_warnings %></span> (<%= @queries.percent_without_warnings %>%)</p>
8
+ <p>Type:
9
+ <% QueryReviewer::SqlQueryCollection::COMMANDS.each do |command| %>
10
+ <% next if @queries.count_of_command(command).zero? %>
11
+ <span class="number"><%= @queries.count_of_command(command) %></span> <%= command %>s&nbsp;&nbsp;
12
+ <% end %>
13
+ </p>
14
+ <% if warnings_no_query_sorted.length + queries_with_warnings_sorted.length > 0 %>
15
+ <div class="divider"></div>
16
+ <% if warnings_no_query_sorted_nonignored.length + queries_with_warnings_sorted_nonignored.length > 0 %>
17
+ <p class="title"><%= warnings_no_query_sorted_nonignored.length + queries_with_warnings_sorted_nonignored.length %> Errors:</p>
18
+ <ul>
19
+ <%= render :partial => "/warning_no_query", :collection => warnings_no_query_sorted_nonignored %>
20
+ <%= render :partial => "/query_with_warning", :collection => queries_with_warnings_sorted_nonignored %>
21
+ </ul>
22
+ <% end %>
23
+ <% if warnings_no_query_sorted_ignored.length + queries_with_warnings_sorted_ignored.length > 0 %>
24
+ <%= warnings_no_query_sorted_ignored.length + queries_with_warnings_sorted_ignored.length %> Warnings:
25
+ <ul id="query_review_ignored_warnings">
26
+ <%= render :partial => "/warning_no_query", :collection => warnings_no_query_sorted_ignored %>
27
+ <%= render :partial => "/query_with_warning", :collection => queries_with_warnings_sorted_ignored %>
28
+ </ul>
29
+ <% end %>
30
+ <% end %>
31
+ <div class="divider"></div>
32
+ <p class="title">Safe queries:</p>
33
+ <% if @queries.queries.empty? %>
34
+ No queries to display.
35
+ <% else %>
36
+ <% QueryReviewer::SqlQueryCollection::COMMANDS.reverse.each do |command| %>
37
+ <% next if @queries.count_of_command(command, true).zero? %>
38
+ <ul class="small">
39
+ <% @queries.only_of_command(command, true).each do |query| %>
40
+ <li>
41
+ <% if QueryReviewer::CONFIGURATION["production_data"] %>
42
+ <%= duration_with_color(query) %>s
43
+ <% end %>
44
+ <% if query.count > 1 %>
45
+ <b title="<%= query.count %> queries were executed with the same stack trace and similar SQL structure">
46
+ <%= query.count %> identical queries
47
+ </b>
48
+ <% end %>
49
+ <%= render :partial => "/query_sql", :locals=>{ :query_sql => query } %>
50
+ <% if query.select? %>
51
+ <a href="#" onclick="query_review_toggle('warning_<%= query.id %>_explain')" title="show/hide sql">EXPLN</a>
52
+ <% end %>
53
+ <% if QueryReviewer::CONFIGURATION["profiling"] && query.profile %>
54
+ <a href="#" onclick="query_review_toggle('warning_<%= query.id %>_profile')" title="show/hide profile">PROF</a>
55
+ <% end %>
56
+ <a href="#" onclick="query_review_toggle('warning_<%= query.id %>_trace')" title="show/hide stack trace">TRACE</a>
57
+ <div style="display: none" id="warning_<%= query.id %>_explain" class="indent small tbpadded">
58
+ <%= render :partial => "/explain", :locals => {:query => query} %>
59
+ </div>
60
+ <% if QueryReviewer::CONFIGURATION["profiling"] && query.profile %>
61
+ <div style="display: none" id="warning_<%= query.id %>_profile" class="indent small">
62
+ <%= render :partial => "/profile", :locals => {:query => query} %>
63
+ </div>
64
+ <% end %>
65
+ <div style="display: none" id="warning_<%= query.id %>_trace" class="indent small">
66
+ <%= render :partial => "/query_trace", :locals => {:query_trace => query.relevant_trace, :query_id => query.id, :full_trace => query.full_trace} %>
67
+ </div>
68
+ </li>
69
+ <% end %>
70
+ </ul>
71
+ <% end %>
72
+ <% end %>
73
+ <p id="query_review_disable_link"><a href="#" onclick="eraseCookie('query_review_enabled'); query_review_hide('query_review_disable_link'); alert('Cookie successfully set.');">Disable analysis report</a> on next page load and from now on.</p>