lookout-query_reviewer 0.1.5

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.
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>