sql_safety_net 1.1.11 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +32 -11
- data/Rakefile +3 -5
- data/lib/sql_safety_net/cache_store.rb +17 -7
- data/lib/sql_safety_net/configuration.rb +35 -46
- data/lib/sql_safety_net/connection_adapter.rb +49 -80
- data/lib/sql_safety_net/explain_plan/mysql.rb +33 -0
- data/lib/sql_safety_net/explain_plan/postgresql.rb +30 -0
- data/lib/sql_safety_net/explain_plan.rb +24 -0
- data/lib/sql_safety_net/formatter.rb +178 -0
- data/lib/sql_safety_net/middleware.rb +41 -0
- data/lib/sql_safety_net/query_analysis.rb +52 -49
- data/lib/sql_safety_net/query_info.rb +33 -0
- data/lib/sql_safety_net.rb +68 -17
- data/spec/cache_store_spec.rb +8 -17
- data/spec/configuration_spec.rb +63 -58
- data/spec/connection_adapter_spec.rb +109 -114
- data/spec/explain_plan/mysql_spec.rb +113 -0
- data/spec/explain_plan/postgresql_spec.rb +44 -0
- data/spec/formatter_spec.rb +46 -0
- data/spec/middleware_spec.rb +90 -0
- data/spec/query_analysis_spec.rb +92 -50
- data/spec/query_info_spec.rb +46 -0
- data/spec/spec_helper.rb +7 -26
- data/spec/sql_safety_net_spec.rb +21 -0
- metadata +112 -89
- data/lib/sql_safety_net/connection_adapter/mysql_adapter.rb +0 -43
- data/lib/sql_safety_net/connection_adapter/postgresql_adapter.rb +0 -48
- data/lib/sql_safety_net/rack_handler.rb +0 -196
- data/spec/example_database.yml +0 -14
- data/spec/mysql_connection_adapter_spec.rb +0 -32
- data/spec/postgres_connection_adapter_spec.rb +0 -43
- data/spec/rack_handler_spec.rb +0 -193
data/README.rdoc
CHANGED
@@ -1,19 +1,40 @@
|
|
1
|
-
=
|
1
|
+
= SqlSafetyNet
|
2
2
|
|
3
|
-
|
3
|
+
ActiveRecord makes it very easy and seamless to access data from a database. A downside of this is you often don't realize what kind of load you are putting on the database either by the number or the type of queries generated because "it just works." This can lead to performance problems in production because databases are notoriously hard and expensive to scale.
|
4
4
|
|
5
|
-
|
5
|
+
This gem exposes debugging information about SQL queries generated by ActiveRecord in a Rails application. It is intended to be used in development mode to allow developers to see what queries are being generated so issues can be caught before code goes to production.
|
6
6
|
|
7
|
-
|
7
|
+
It works by injecting code into the connection adapter to count and analyze SELECT queries. It does not collect any information on INSERT, UPDATE, or DELETE queries. The analysis is exposed by a Rack middleware handler in a variety of ways.
|
8
8
|
|
9
|
-
|
9
|
+
== Features
|
10
10
|
|
11
|
-
|
11
|
+
SqlSafetyNet will track data about each query in your request and analyze them individually and as a group.
|
12
12
|
|
13
|
-
|
13
|
+
* Rows returned by each query
|
14
|
+
* Estimated number of bytes returned by each query
|
15
|
+
* Time taken to execute each query
|
16
|
+
* Total number of queries
|
17
|
+
* Total number or rows returned for all queries
|
18
|
+
* Total estimated bytes returned for all queries
|
19
|
+
* Total time taken to execute all queries
|
20
|
+
* Query plan analysis for each query (if supported)
|
14
21
|
|
15
|
-
|
16
|
-
config.query_limit = 20
|
17
|
-
end
|
22
|
+
== Debugging Output
|
18
23
|
|
19
|
-
|
24
|
+
A summary of the queries will be added to all responses in the X-SqlSafetyNet header. This will include the number of queries, the number of rows returned, the approximate amount of data returned from the database, and the elapsed time to make the queries.
|
25
|
+
|
26
|
+
When issues are found with queries in a request, this information will be logged.
|
27
|
+
|
28
|
+
If the response is an HTML document and the request was not from Ajax, a debug info window will be inserted into the document if there were any queries flagged as problematic. This is the most effective way to insure that the analysis is always visible to the developers. The box can also be expanded to details about each query. The debug box will always be displayed if the request queries are flagged with issues. There is also a configuration setting to always show the debug box. The box will be green if there are no issues, red if there are issues, or orange if there are issues but the queries that generate them are cached in Rails.cache.
|
29
|
+
|
30
|
+
== Configuration
|
31
|
+
|
32
|
+
There are variety of configuration options where you can specify the thresholds which you'd consider excess database usage. See SqlSafetyNet::Config for details.
|
33
|
+
|
34
|
+
== Query Plan Analysis
|
35
|
+
|
36
|
+
If you are using MySQL or PostgreSQL, then each query will also get the query plan from the database and analyze it for problems like table scans on large tables.
|
37
|
+
|
38
|
+
The query analysis for PostgreSQL is much less detailed than MySQL because the MySQL plans are much more straightforward to understand programatically. Reading PostgreSQL query plans is more of an art. In addition, take the PostgreSQL warnings with a grain of salt. It only looks for large table scans or large number of rows examined in a query. However, PostgreSQL will only estimate these numbers on a simple EXPLAIN plan and sometimes it gets the number very wrong on small tables. For the query plan may estimate the query will do a table scan on 300 rows even though the table only has 10 rows in it.
|
39
|
+
|
40
|
+
For details on enabling query plan analysis see SqlSafteyNet::ExplainPlan.
|
data/Rakefile
CHANGED
@@ -27,12 +27,10 @@ begin
|
|
27
27
|
gem.has_rdoc = true
|
28
28
|
gem.rdoc_options << '--line-numbers' << '--inline-source' << '--main' << 'README.rdoc'
|
29
29
|
gem.extra_rdoc_files = ["README.rdoc"]
|
30
|
-
gem.add_dependency('activesupport')
|
31
|
-
gem.add_dependency('activerecord', '>=
|
32
|
-
gem.add_dependency('actionpack')
|
30
|
+
gem.add_dependency('activesupport', '>= 3.0.0')
|
31
|
+
gem.add_dependency('activerecord', '>= 3.0.0')
|
32
|
+
gem.add_dependency('actionpack', '>= 3.0.0')
|
33
33
|
gem.add_development_dependency('rspec', '>= 2.0.0')
|
34
|
-
gem.add_development_dependency('mysql')
|
35
|
-
gem.add_development_dependency('pg')
|
36
34
|
gem.add_development_dependency('sqlite3-ruby')
|
37
35
|
end
|
38
36
|
Jeweler::RubygemsDotOrgTasks.new
|
@@ -1,18 +1,28 @@
|
|
1
1
|
module SqlSafetyNet
|
2
|
-
#
|
2
|
+
# This module provides a hook into ActiveSupport::Cache::Store caches to keep
|
3
|
+
# track of when a query happens inside a cache fetch block. This will be reported
|
4
|
+
# in the analysis.
|
3
5
|
module CacheStore
|
4
|
-
|
5
|
-
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
alias_method_chain :fetch, :sql_safety_net
|
6
10
|
end
|
7
11
|
|
8
12
|
def fetch_with_sql_safety_net(*args, &block)
|
9
|
-
|
10
|
-
saved_val = analysis.caching? if analysis
|
13
|
+
save_val = Thread.current[:sql_safety_net_in_cache_store_fetch_block]
|
11
14
|
begin
|
12
|
-
|
15
|
+
Thread.current[:sql_safety_net_in_cache_store_fetch_block] = true
|
13
16
|
fetch_without_sql_safety_net(*args, &block)
|
14
17
|
ensure
|
15
|
-
|
18
|
+
Thread.current[:sql_safety_net_in_cache_store_fetch_block] = save_val
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class << self
|
23
|
+
# Return +true+ if called from within a +fetch+ block.
|
24
|
+
def in_fetch_block?
|
25
|
+
!!Thread.current[:sql_safety_net_in_cache_store_fetch_block]
|
16
26
|
end
|
17
27
|
end
|
18
28
|
end
|
@@ -1,57 +1,46 @@
|
|
1
1
|
module SqlSafetyNet
|
2
|
-
#
|
3
|
-
#
|
2
|
+
# This class provides configuration options for SQL analysis.
|
3
|
+
#
|
4
|
+
# These options specify when a warning will be triggered based on the totals from all queries
|
5
|
+
# in a single request:
|
6
|
+
#
|
7
|
+
# * query_limit - the total number of queries (default to 10)
|
8
|
+
# * returned_rows_limit - the total number of rows returned (defaults to 100)
|
9
|
+
# * result_size_limit - the number of bytes returned by all queries (defaults to 16K)
|
10
|
+
# * elapsed_time_limit - the number of seconds taken for all queries (defaults to 0.3)
|
11
|
+
#
|
12
|
+
# These options specify when a warning will be triggered on a single query. These options are only
|
13
|
+
# available when using MySQL:
|
14
|
+
#
|
15
|
+
# * table_scan_limit - the number of rows in a table scan that will trigger a warning (defaults to 100)
|
16
|
+
# * temporary_table_limit - the number of temporary table rows that will trigger a warning (defaults to 100)
|
17
|
+
# * filesort_limit - the number of rows in a filesort operation that will trigger a warning (defaults to 100)
|
18
|
+
# * examined_rows_limit - the number of rows examined in a query that will trigger a warning (defaults to 5000)
|
19
|
+
#
|
20
|
+
# These options specify details about embedding debugging info in HTML pages
|
21
|
+
#
|
22
|
+
# * always_show - set to true to always show debugging info; otherwise only shown if the request is flagged (defaults to false)
|
23
|
+
# * style - set to a hash of CSS styles used to style the debugging info; defaults to appearing in the upper right corner
|
4
24
|
class Configuration
|
5
|
-
attr_accessor :
|
25
|
+
attr_accessor :query_limit, :returned_rows_limit, :result_size_limit, :elapsed_time_limit
|
26
|
+
attr_accessor :table_scan_limit, :temporary_table_limit, :filesort_limit, :examined_rows_limit
|
27
|
+
attr_accessor :always_show, :style
|
6
28
|
|
7
29
|
def initialize
|
8
|
-
@
|
9
|
-
@
|
30
|
+
@query_limit = 10
|
31
|
+
@returned_rows_limit = 100
|
32
|
+
@result_size_limit = 16 * 1024
|
33
|
+
@elapsed_time_limit = 0.3
|
34
|
+
|
10
35
|
@table_scan_limit = 100
|
11
36
|
@temporary_table_limit = 100
|
12
37
|
@filesort_limit = 100
|
13
|
-
@
|
14
|
-
|
15
|
-
@query_limit = 10
|
38
|
+
@examined_rows_limit = 5000
|
39
|
+
|
16
40
|
@always_show = false
|
17
|
-
@
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
# Enable SqlSafetyNet on a connection. Unless this method is called, the code will not be mixed into the database
|
22
|
-
# adapter. This should normally be called only in the development environment.
|
23
|
-
def enable_on(connection_class)
|
24
|
-
connection_class = connection_class.constantize unless connection_class.is_a?(Class)
|
25
|
-
connection_class_name = connection_class.name.split('::').last
|
26
|
-
include_class = ConnectionAdapter.const_get(connection_class_name) if ConnectionAdapter.const_defined?(connection_class_name)
|
27
|
-
connection_class.send(:include, ConnectionAdapter) unless connection_class.include?(ConnectionAdapter)
|
28
|
-
connection_class.send(:include, include_class) if include_class && !connection_class.include?(include_class)
|
29
|
-
end
|
30
|
-
|
31
|
-
def debug=(val)
|
32
|
-
@debug = !!val
|
33
|
-
end
|
34
|
-
|
35
|
-
def debug?
|
36
|
-
@debug
|
37
|
-
end
|
38
|
-
|
39
|
-
def header=(val)
|
40
|
-
@header = !!val
|
41
|
-
end
|
42
|
-
|
43
|
-
def header?
|
44
|
-
@header
|
45
|
-
end
|
46
|
-
|
47
|
-
# Set a flag to always show information about queries on rendered HTML pages. If this is not set to true, the debug
|
48
|
-
# information will only be shown if the queries on the page exceed one of the limits.
|
49
|
-
def always_show=(val)
|
50
|
-
@always_show = !!val
|
51
|
-
end
|
52
|
-
|
53
|
-
def always_show?
|
54
|
-
@always_show
|
41
|
+
@style = {}
|
42
|
+
|
43
|
+
yield(self) if block_given?
|
55
44
|
end
|
56
45
|
end
|
57
46
|
end
|
@@ -1,103 +1,72 @@
|
|
1
1
|
module SqlSafetyNet
|
2
|
-
#
|
2
|
+
# This module needs to be included with the specific ActiveRecord::ConnectionAdapter class
|
3
|
+
# to collect data about all SELECT queries.
|
3
4
|
module ConnectionAdapter
|
4
|
-
|
5
|
-
autoload :PostgreSQLAdapter, File.expand_path('../connection_adapter/postgresql_adapter', __FILE__)
|
6
|
-
|
7
|
-
SELECT_STATEMENT = /^\s*SELECT\b/i
|
8
|
-
|
9
|
-
def self.included(base)
|
10
|
-
base.alias_method_chain :select, :sql_safety_net
|
11
|
-
base.alias_method_chain :select_rows, :sql_safety_net
|
12
|
-
base.alias_method_chain :columns, :sql_safety_net
|
13
|
-
base.alias_method_chain :active?, :sql_safety_net
|
14
|
-
end
|
15
|
-
|
16
|
-
def active_with_sql_safety_net?
|
17
|
-
active = disable_sql_safety_net{active_without_sql_safety_net?}
|
18
|
-
if @logger && !active
|
19
|
-
@logger.warn("#{adapter_name} connection not active")
|
20
|
-
end
|
21
|
-
active
|
22
|
-
end
|
5
|
+
extend ActiveSupport::Concern
|
23
6
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
7
|
+
SELECT_SQL_PATTERN = /\A\s*SELECT\b/im.freeze
|
8
|
+
IGNORED_PAYLOADS = %w(SCHEMA EXPLAIN CACHE).freeze
|
9
|
+
|
10
|
+
included do
|
11
|
+
alias_method_chain :select_rows, :sql_safety_net
|
12
|
+
alias_method_chain :select, :sql_safety_net
|
28
13
|
end
|
29
14
|
|
30
15
|
def select_rows_with_sql_safety_net(sql, name = nil, *args)
|
31
|
-
|
16
|
+
analyze_query(sql, name, []) do
|
32
17
|
select_rows_without_sql_safety_net(sql, name, *args)
|
33
18
|
end
|
34
19
|
end
|
35
20
|
|
36
|
-
# Disable query analysis within a block.
|
37
|
-
def disable_sql_safety_net
|
38
|
-
save_disabled = Thread.current[:sql_safety_net_disable]
|
39
|
-
begin
|
40
|
-
Thread.current[:sql_safety_net_disable] = true
|
41
|
-
yield
|
42
|
-
ensure
|
43
|
-
Thread.current[:sql_safety_net_disable] = save_disabled
|
44
|
-
end
|
45
|
-
end
|
46
|
-
|
47
|
-
def analyze_query(sql, *args)
|
48
|
-
# No op; other modules may redefine to analyze query plans
|
49
|
-
end
|
50
|
-
|
51
|
-
def select_statement?(sql)
|
52
|
-
!!sql.match(SELECT_STATEMENT)
|
53
|
-
end
|
54
|
-
|
55
21
|
protected
|
56
|
-
|
22
|
+
|
57
23
|
def select_with_sql_safety_net(sql, name = nil, *args)
|
58
|
-
|
24
|
+
binds = args.first || []
|
25
|
+
analyze_query(sql, name, binds) do
|
59
26
|
select_without_sql_safety_net(sql, name, *args)
|
60
27
|
end
|
61
28
|
end
|
62
29
|
|
63
|
-
private
|
64
30
|
|
65
|
-
def
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
31
|
+
def analyze_query(sql, name, binds)
|
32
|
+
queries = QueryAnalysis.current
|
33
|
+
if queries && sql.match(SELECT_SQL_PATTERN) && !IGNORED_PAYLOADS.include?(name)
|
34
|
+
start_time = Time.now
|
35
|
+
results = yield
|
36
|
+
elapsed_time = Time.now - start_time
|
37
|
+
|
38
|
+
expanded_sql = sql
|
39
|
+
unless binds.empty?
|
40
|
+
sql = "#{sql} #{binds.collect{|col, val| [col.name, val]}.inspect}"
|
73
41
|
end
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
if
|
79
|
-
query_info.count += 1
|
80
|
-
query_info.selects += query_results.size
|
81
|
-
end
|
82
|
-
analysis = QueryAnalysis.current
|
83
|
-
if analysis
|
84
|
-
analysis.selects += 1
|
85
|
-
analysis.rows += query_results.size
|
86
|
-
analysis.elapsed_time += elapsed_time
|
87
|
-
if SqlSafetyNet.config.debug?
|
88
|
-
flagged = (@logger ? @logger.silence{analyze_query(sql, name, *args)} : analyze_query(sql, name, *args))
|
89
|
-
if elapsed_time * 1000 >= SqlSafetyNet.config.time_limit
|
90
|
-
flagged ||= {}
|
91
|
-
flagged[:flags] ||= []
|
92
|
-
flagged[:flags] << "query time exceeded #{SqlSafetyNet.config.time_limit} ms"
|
93
|
-
end
|
94
|
-
analysis.add_query(sql, name, query_results.size, elapsed_time, flagged)
|
95
|
-
end
|
96
|
-
end
|
42
|
+
rows = results.size
|
43
|
+
result_size = 0
|
44
|
+
results.each do |row|
|
45
|
+
values = row.is_a?(Hash) ? row.values : row
|
46
|
+
values.each{|val| result_size += val.to_s.size if val}
|
97
47
|
end
|
98
|
-
|
99
|
-
|
48
|
+
cached = CacheStore.in_fetch_block?
|
49
|
+
sql_str = nil
|
50
|
+
if method(:to_sql).arity == 1
|
51
|
+
sql_str = (sql.is_a?(String) ? sql : to_sql(sql))
|
52
|
+
else
|
53
|
+
sql_str = to_sql(sql, binds)
|
54
|
+
end
|
55
|
+
query_info = QueryInfo.new(sql_str, :elapsed_time => elapsed_time, :rows => rows, :result_size => result_size, :cached => cached)
|
56
|
+
queries << query_info
|
57
|
+
|
58
|
+
# If connection includes a query plan analyzer then alert on issues in the query plan.
|
59
|
+
if respond_to?(:sql_safety_net_analyze_query_plan)
|
60
|
+
query_info.alerts.concat(sql_safety_net_analyze_query_plan(sql, binds))
|
61
|
+
end
|
62
|
+
|
63
|
+
query_info.alerts.each{|alert| ActiveRecord::Base.logger.debug(alert)} if ActiveRecord::Base.logger
|
64
|
+
|
65
|
+
results
|
66
|
+
else
|
67
|
+
yield
|
100
68
|
end
|
101
69
|
end
|
70
|
+
|
102
71
|
end
|
103
72
|
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module SqlSafetyNet
|
2
|
+
module ExplainPlan
|
3
|
+
# Include this module in your MySQL connection class to analyze the query plan generated by MySQL.
|
4
|
+
module Mysql
|
5
|
+
def sql_safety_net_analyze_query_plan(sql, binds)
|
6
|
+
alerts = []
|
7
|
+
config = SqlSafetyNet.config
|
8
|
+
explain_results = select("EXPLAIN #{sql}", "EXPLAIN", binds)
|
9
|
+
|
10
|
+
explain_results.each do |row|
|
11
|
+
select_type = (row['select_type'] || '').downcase
|
12
|
+
type = (row['type'] || '').downcase
|
13
|
+
rows = row['rows'].to_i
|
14
|
+
extra = (row['Extra'] || '').downcase
|
15
|
+
key = row['key']
|
16
|
+
possible_keys = row['possible_keys']
|
17
|
+
|
18
|
+
alerts << "table scan on #{rows} rows" if (type.include?('all') && rows > config.table_scan_limit)
|
19
|
+
alerts << "no index used" if (key.blank? && rows > config.table_scan_limit)
|
20
|
+
alerts << "no index possible" if (possible_keys.blank? && rows > config.table_scan_limit)
|
21
|
+
alerts << "dependent subquery" if select_type.include?('dependent')
|
22
|
+
alerts << "uncacheable subquery" if select_type.include?('uncacheable')
|
23
|
+
alerts << "full scan on null key" if extra.include?('full scan on null key')
|
24
|
+
alerts << "uses temporary table for #{rows} rows" if extra.include?('using temporary') && rows > config.temporary_table_limit
|
25
|
+
alerts << "uses filesort for #{rows} rows" if extra.include?('filesort') && rows > config.filesort_limit
|
26
|
+
alerts << "examined #{rows} rows" if rows > config.examined_rows_limit
|
27
|
+
end
|
28
|
+
|
29
|
+
alerts
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module SqlSafetyNet
|
2
|
+
module ExplainPlan
|
3
|
+
# Include this module in your PostgreSQL connection class to analyze the query plan. It will just
|
4
|
+
# look for excess row counts in the number of rows examined or scanned. The row counts provided by
|
5
|
+
# PostgreSQL are just estimates, so take any alerts with a grain of salt.
|
6
|
+
module Postgresql
|
7
|
+
def sql_safety_net_analyze_query_plan(sql, binds)
|
8
|
+
alerts = []
|
9
|
+
config = SqlSafetyNet.config
|
10
|
+
explain_results = select("EXPLAIN #{sql}", "EXPLAIN", binds)
|
11
|
+
query_plan = explain_results.collect{|r| r.values.first}
|
12
|
+
limit = nil
|
13
|
+
|
14
|
+
query_plan.each do |row|
|
15
|
+
row_count = row.match(/\brows=(\d+)/) ? $~[1].to_i : 0
|
16
|
+
row_count = [limit, row_count].min if limit
|
17
|
+
if row =~ /^(\s|(->))*Limit\s/
|
18
|
+
limit = row_count
|
19
|
+
elsif row =~ /^(\s|(->))*Seq Scan/
|
20
|
+
alerts << "table scan on ~#{row_count} rows" if row_count > config.table_scan_limit
|
21
|
+
elsif row_count > config.examined_rows_limit
|
22
|
+
alerts << "examined ~#{row_count} rows"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
alerts
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module SqlSafetyNet
|
2
|
+
# Query plan analysis is supported out of the box for MySQL and PostgreSQL.
|
3
|
+
#
|
4
|
+
# If you wish to implement it for another database, you'll need to create a module that defines
|
5
|
+
# the +sql_safety_net_analyze_query_plan+ method and takes arguments for the sql to execute and
|
6
|
+
# an array of bind values.
|
7
|
+
module ExplainPlan
|
8
|
+
autoload :Mysql, File.expand_path("../explain_plan/mysql.rb", __FILE__)
|
9
|
+
autoload :Postgresql, File.expand_path("../explain_plan/postgresql.rb", __FILE__)
|
10
|
+
|
11
|
+
class << self
|
12
|
+
# Enable query plan analysize on a connection adapter class. The explain_plan_analyzer argument
|
13
|
+
# can either be <tt>:mysql</tt>, <tt>:postgresql</tt> or a module that defines a
|
14
|
+
# <tt>sql_safety_net_analyze_query_plan(sql, binds)</tt> method.
|
15
|
+
def enable_on_connection_adapter!(connection_adapter_class, explain_plan_analyzer)
|
16
|
+
if explain_plan_analyzer.is_a?(Symbol)
|
17
|
+
class_name = explain_plan_analyzer.to_s.camelize
|
18
|
+
explain_plan_analyzer = ExplainPlan.const_get(class_name)
|
19
|
+
end
|
20
|
+
connection_adapter_class.send(:include, explain_plan_analyzer) unless connection_adapter_class.include?(explain_plan_analyzer)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,178 @@
|
|
1
|
+
require 'rack'
|
2
|
+
|
3
|
+
module SqlSafetyNet
|
4
|
+
# Formatter to output information from a query analysis in various formats.
|
5
|
+
class Formatter
|
6
|
+
attr_reader :analysis
|
7
|
+
|
8
|
+
def initialize(analysis)
|
9
|
+
@analysis = analysis
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_html
|
13
|
+
uncached_analysis = QueryAnalysis.new
|
14
|
+
cached_analysis = QueryAnalysis.new
|
15
|
+
analysis.queries.each{ |query| query.cached? ? cached_analysis << query : uncached_analysis << query }
|
16
|
+
|
17
|
+
ok_color = "#060"
|
18
|
+
warn_color = "#900"
|
19
|
+
cache_warn_color = "#A80"
|
20
|
+
theme_color = ok_color
|
21
|
+
theme_color = cache_warn_color if uncached_analysis.flagged?
|
22
|
+
theme_color = warn_color if analysis.flagged?
|
23
|
+
close_js = "document.getElementById('_sql_safety_net_').style.display = 'none'"
|
24
|
+
toggle_queries_js = "document.getElementById('_sql_safety_net_queries_').style.display = (document.getElementById('_sql_safety_net_queries_').style.display == 'block' ? 'none' : 'block')"
|
25
|
+
|
26
|
+
tag(:div, :id => "_sql_safety_net_", :style => div_style(SqlSafetyNet.config.style)) do
|
27
|
+
tag(:div, :style => "padding:4px; background-color:#{theme_color}; font-weight:bold; color:#FFF;") do
|
28
|
+
tag(:div) do
|
29
|
+
tag(:a, :href => "javascript:void(#{close_js})", :style => "float:right; display:block; text-decoration:none") do
|
30
|
+
tag(:span, "×", :style => "color:#FFF; text-decoration:none; font-weight:bold;")
|
31
|
+
end
|
32
|
+
tag(:a, :href => "javascript:void(#{toggle_queries_js})", :style => "text-decoration:none;") do
|
33
|
+
tag(:span, "#{analysis.flagged? ? 'SQL WARNING' : 'SQL INFO'} »", :style => "color:#FFF; text-decoration:none; font-weight:bold;")
|
34
|
+
end
|
35
|
+
end
|
36
|
+
tag(:div, summary, :style => "font-weight:normal;")
|
37
|
+
end
|
38
|
+
|
39
|
+
tag(:div, :id => "_sql_safety_net_queries_", :style => "display:none; border:1px solid #{theme_color}; background-color:#FFF; color:#000; overflow:auto; max-height:500px;") do
|
40
|
+
tag(:div, :style => "padding-left:4px; padding-right:4px;") do
|
41
|
+
if cached_analysis.total_queries > 0
|
42
|
+
tag(:div, :style => "margin-top:5px; margin-bottom:5px;") do
|
43
|
+
tag(:div, "Uncached", :style => "font-weight:bold;")
|
44
|
+
tag(:div, Formatter.new(uncached_analysis).summary)
|
45
|
+
end
|
46
|
+
|
47
|
+
tag(:div, :style => "margin-top:5px; margin-bottom:5px; color:#066;") do
|
48
|
+
tag(:div, "Cached", :style => "font-weight:bold;")
|
49
|
+
tag(:div, Formatter.new(cached_analysis).summary)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
warning_style = "color:#{warn_color}; margin-top:5px; margin-bottom:5px;"
|
54
|
+
tag(:div, "WARNING: #{analysis.total_queries} queries", :style => warning_style) if analysis.too_many_queries?
|
55
|
+
tag(:div, "WARNING: #{analysis.rows} rows returned", :style => warning_style) if analysis.too_many_rows?
|
56
|
+
tag(:div, "WARNING: #{sprintf('%0.1f', analysis.result_size / 1024.0)}K returned", :style => warning_style) if analysis.results_too_big?
|
57
|
+
tag(:div, "WARNING: queries took #{(analysis.elapsed_time * 1000).round} ms", :style => warning_style) if analysis.too_much_time?
|
58
|
+
tag(:div, "WARNING: alerts on #{analysis.alerted_queries} queries", :style => warning_style) if analysis.alerts?
|
59
|
+
end
|
60
|
+
|
61
|
+
analysis.queries.each do |query|
|
62
|
+
color = ok_color
|
63
|
+
if query.alerts?
|
64
|
+
color = (query.cached? ? cache_warn_color : warn_color)
|
65
|
+
end
|
66
|
+
tag(:div, :style => "color:#{color}; border-top:1px solid #CCC; padding:8px 4px;#{' background-color:#DEE;' if query.cached?}") do
|
67
|
+
tag(:div, "CACHED", :style => "color:#066;") if query.cached?
|
68
|
+
query_info = "#{query.rows} row#{'s' if query.rows != 1} returned (#{sprintf('%0.1f', query.result_size / 1024.0)}K) in #{(query.elapsed_time * 1000).round} ms"
|
69
|
+
tag(:div, query_info, :style => "margin-bottom:5px;")
|
70
|
+
if query.alerts?
|
71
|
+
tag(:div, :style => "margin-bottom:5px;") do
|
72
|
+
query.alerts.each do |alert|
|
73
|
+
tag(:div, alert, :style => "margin-bottom:2px;")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
tag(:div, query.sql, :style => "color:#666")
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def to_s
|
85
|
+
uncached_analysis = QueryAnalysis.new
|
86
|
+
cached_analysis = QueryAnalysis.new
|
87
|
+
analysis.queries.each{ |query| query.cached? ? cached_analysis << query : uncached_analysis << query }
|
88
|
+
lines = []
|
89
|
+
lines << "#{analysis.flagged? ? 'SQL WARNING' : 'SQL INFO'}: #{summary}"
|
90
|
+
if cached_analysis.total_queries > 0
|
91
|
+
lines << "UNCACHED: #{Formatter.new(uncached_analysis).summary}"
|
92
|
+
lines << "CACHED: #{Formatter.new(cached_analysis).summary}"
|
93
|
+
end
|
94
|
+
lines << "WARNING: #{analysis.total_queries} queries" if analysis.too_many_queries?
|
95
|
+
lines << "WARNING: #{analysis.rows} rows returned" if analysis.too_many_rows?
|
96
|
+
lines << "WARNING: #{analysis.result_size}K returned" if analysis.results_too_big?
|
97
|
+
lines << "WARNING: queries took #{(analysis.elapsed_time * 1000).round} ms" if analysis.too_much_time?
|
98
|
+
lines << "WARNING: alerts on #{analysis.alerted_queries} queries" if analysis.alerts?
|
99
|
+
analysis.queries.each do |query|
|
100
|
+
lines << "-----------------"
|
101
|
+
lines << "CACHED" if query.cached?
|
102
|
+
lines << "#{query.rows} row#{'s' if query.rows != 1} returned (#{sprintf('%0.1f', query.result_size / 1024.0)}K) in #{(query.elapsed_time * 1000).round} ms"
|
103
|
+
lines.concat(query.alerts)
|
104
|
+
lines << "#{query.sql}"
|
105
|
+
end
|
106
|
+
lines.join("\n")
|
107
|
+
end
|
108
|
+
|
109
|
+
def summary
|
110
|
+
queries = analysis.total_queries
|
111
|
+
rows = analysis.rows
|
112
|
+
kilobytes = analysis.result_size / 1024.0
|
113
|
+
"#{queries} #{queries == 1 ? 'query' : 'queries'}, #{analysis.rows} row#{rows == 1 ? '' : 's'}, #{sprintf("%0.1f", kilobytes)}K, #{(analysis.elapsed_time * 1000).round}ms"
|
114
|
+
end
|
115
|
+
|
116
|
+
# Turn a hash of styles into a proper CSS style attribute. The style will use specified defaults
|
117
|
+
# to make the div appear in the top, right corner of the page at 160px wide.
|
118
|
+
def div_style(style)
|
119
|
+
default_style = {
|
120
|
+
"font-family" => "sans-serif",
|
121
|
+
"font-size" => "10px",
|
122
|
+
"font-weight" => "normal",
|
123
|
+
"position" => "fixed",
|
124
|
+
"z-index" => "999999",
|
125
|
+
"text-align" => "left",
|
126
|
+
"line-height" => "100%",
|
127
|
+
"width" => "200px"
|
128
|
+
}
|
129
|
+
|
130
|
+
style = default_style.merge(style)
|
131
|
+
style.delete_if{|key, value| value.blank? }
|
132
|
+
|
133
|
+
# Ensure a default positioning
|
134
|
+
if %w(fixed static absolute).include?(style["position"])
|
135
|
+
style["top"] = "5px" if style["top"].blank? && style["bottom"].blank?
|
136
|
+
style["right"] = "5px" if style["right"].blank? && style["left"].blank?
|
137
|
+
end
|
138
|
+
|
139
|
+
css_style = ""
|
140
|
+
style.each do |name, value|
|
141
|
+
css_style << "#{name}:#{value};"
|
142
|
+
end
|
143
|
+
css_style
|
144
|
+
end
|
145
|
+
|
146
|
+
private
|
147
|
+
|
148
|
+
# Helper to generate an HTML tag.
|
149
|
+
def tag(name, *args)
|
150
|
+
attributes = args.extract_options!
|
151
|
+
body = args.first
|
152
|
+
|
153
|
+
@buffer ||= []
|
154
|
+
output = ""
|
155
|
+
@buffer.push(output)
|
156
|
+
output << "<#{name}"
|
157
|
+
if attributes
|
158
|
+
attributes.each do |key, value|
|
159
|
+
output << " #{key}=\"#{Rack::Utils.escape_html(value)}\""
|
160
|
+
end
|
161
|
+
end
|
162
|
+
if block_given? || body
|
163
|
+
output << ">"
|
164
|
+
if block_given?
|
165
|
+
yield
|
166
|
+
else
|
167
|
+
output << body if body
|
168
|
+
end
|
169
|
+
output << "</#{name}>"
|
170
|
+
else
|
171
|
+
output << "/>"
|
172
|
+
end
|
173
|
+
@buffer.pop
|
174
|
+
@buffer.last << output unless @buffer.empty?
|
175
|
+
output
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|