shiba 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,4 +1,12 @@
1
1
  ---
2
+ fuzzed_data:
3
+ title: Fuzzed Data
4
+ summary: Shiba doesn't know the size of <b>{{query.table}}</b>. For these purposes we set the table size to <b>{{query.table_size}}</b>.
5
+ description: |
6
+ We're not sure how much data this table will hold in the future, so we've pretended
7
+ there's 6000 rows in it. This can lead to a lot of false positives. To
8
+ improve results, please give shiba your index statistics.
9
+ level: info
2
10
  possible_key_check:
3
11
  title: Oddly reported possible keys
4
12
  description: |
@@ -7,18 +15,21 @@ possible_key_check:
7
15
  key possible. Sometimes "possible_keys" will be inaccurate and no keys were possible.
8
16
  level: info
9
17
  access_type_const:
10
- title: Constant Lookup
18
+ title: One row
19
+ summary: The database only needs to read a single row from <b>{{query.table}}</b>.
11
20
  description: |
12
21
  This query selects at *most* one row, which is about as good as things get.
13
22
  level: success
14
23
  access_type_ref:
15
- title: Index Lookup (reference)
24
+ title: Indexed
25
+ summary: The database reads {{ formattedCost }} rows in <b>{{ query.table }}</b> via the <b>{{ query.key }}</b> index ({{ key_parts }}).
16
26
  description: |
17
27
  This query uses an index to find rows that match a single value. Often this
18
28
  has very good performance, but it depends on how many rows match that value.
19
29
  level: success
20
30
  access_type_range:
21
- title: Index Lookup (range)
31
+ title: Indexed
32
+ summary: The database uses a "range scan" to read more than {{ formattedCost }} rows in {{ query.table }} via the <b>{{ query.key }}</b> index ({{ key_parts }})
22
33
  description: |
23
34
  This query uses an index to find rows that match a range of values, for instance
24
35
  `WHERE indexed_value in (1,2,5,6)` or `WHERE indexed_value >= 5 AND indexed_value <= 15`.
@@ -27,6 +38,7 @@ access_type_range:
27
38
  level: info
28
39
  access_type_tablescan:
29
40
  title: Table Scan
41
+ summary: The database reads <b>100%</b> ({{ query.table_size }}) of the rows in <b>{{ query.table }}</b>, skipping any indexes.
30
42
  description: |
31
43
  This query doesn't use any indexes to find data, meaning this query will need to evaluate
32
44
  every single row in the table. This is about the worst of all possible worlds.
@@ -35,15 +47,24 @@ access_type_tablescan:
35
47
  but be aware that if this table is not effectively tiny or constant-sized you're entering
36
48
  a world of pain.
37
49
  level: danger
38
- fuzzed_data:
39
- title: Guessed Table Size
40
- description: |
41
- We're not sure how much data this table will hold in the future, so we've pretended
42
- there's 6000 rows in it. This can lead to a lot of false positives. To
43
- improve results, please give shiba your index statistics.
44
- level: info
45
50
  ignored:
46
- title: Ignored Query
47
- description: |
48
- This query matched an "ignore" rule in shiba.yml, so we skipped any further analysis on it.
51
+ title: Ignored
52
+ summary: This query matched an "ignore" rule in shiba.yml. Any further analysis was skipped.
53
+ description:
49
54
  level: info
55
+ index_walk:
56
+ title: Index Walk
57
+ description: |
58
+ This query is sorted in the same order as the index used, which means that the database
59
+ can read the index and simply pluck {LIMIT} rows out of the index. It's a very
60
+ fast way to look up stuff by index.
61
+ level: success
62
+ retsize_bad:
63
+ title: Big Results
64
+ summary: The database returns {{ query.return_size.toLocaleString() }} rows to the client.
65
+ level: danger
66
+ retsize_good:
67
+ title: Small Results
68
+ summary: The database returns {{ query.return_size.toLocaleString() }} row(s) to the client.
69
+ level: success
70
+
@@ -1,13 +1,13 @@
1
1
  require 'shiba/query'
2
+ require 'shiba/backtrace'
2
3
  require 'json'
3
4
  require 'rails'
4
5
 
5
6
  module Shiba
6
7
  class QueryWatcher
7
- IGNORE = /\.rvm|gem|vendor\/|rbenv|seed|db|shiba|test|spec/
8
8
 
9
9
  def self.watch(file)
10
- new(file).watch
10
+ new(file).tap { |w| w.watch }
11
11
  end
12
12
 
13
13
  attr_reader :queries
@@ -26,7 +26,7 @@ module Shiba
26
26
  if sql.start_with?("SELECT")
27
27
  fingerprint = Query.get_fingerprint(sql)
28
28
  if !@queries[fingerprint]
29
- if lines = app_backtrace
29
+ if lines = Backtrace.from_app
30
30
  @file.puts("#{sql} /*shiba#{lines}*/")
31
31
  end
32
32
  end
@@ -35,48 +35,5 @@ module Shiba
35
35
  end
36
36
  end
37
37
 
38
- protected
39
-
40
- # 8 backtrace lines starting from the app caller, cleaned of app/project cruft.
41
- def app_backtrace
42
- app_line_idx = caller_locations.index { |line| line.to_s !~ IGNORE }
43
- if app_line_idx == nil
44
- return
45
- end
46
-
47
- caller_locations(app_line_idx+1, 8).map do |loc|
48
- line = loc.to_s
49
- line.sub!(backtrace_ignore_pattern, '')
50
- line
51
- end
52
- end
53
-
54
- def backtrace_ignore_pattern
55
- @roots ||= begin
56
- paths = Gem.path
57
- paths << Rails.root.to_s if Rails.root
58
- paths << repo_root
59
- paths << ENV['HOME']
60
- paths.uniq!
61
- paths.compact!
62
- # match and replace longest path first
63
- paths.sort_by!(&:size).reverse!
64
-
65
- Regexp.new(paths.map {|r| Regexp.escape(r) }.join("|"))
66
- end
67
- end
68
-
69
- # /user/git_repo => "/user/git_repo"
70
- # /user/not_a_repo => nil
71
- def repo_root
72
- root = nil
73
- Open3.popen3('git rev-parse --show-toplevel') {|_,o,_,_|
74
- if root = o.gets
75
- root = root.chomp
76
- end
77
- }
78
- root
79
- end
80
-
81
38
  end
82
39
  end
data/lib/shiba/railtie.rb CHANGED
@@ -1,20 +1,43 @@
1
1
  require 'shiba/query_watcher'
2
2
 
3
3
  class Shiba::Railtie < Rails::Railtie
4
+ # Logging is enabled when:
5
+ # 1. SHIBA_OUT environment variable is set to an existing file path.
6
+ # 2. RSpec/MiniTest exists, in which case a fallback query log is generated at /tmp
4
7
  config.after_initialize do
5
8
  begin
6
- path = ENV['SHIBA_OUT']
7
- next if !path
9
+ path = ENV['SHIBA_OUT'] || "/tmp/shiba-query.log-#{Time.now.to_i}"
10
+ f = File.open(path, 'a')
11
+ watcher = Shiba::QueryWatcher.watch(f)
12
+ next if watcher.nil?
8
13
 
9
- if File.exist?(path)
10
- f = File.open(path, 'a')
11
- Shiba::QueryWatcher.watch(f)
12
- else
13
- $stderr.puts("Shiba could not open '#{path}' for logging.")
14
+ at_exit do
15
+ f.close
16
+ puts ""
17
+ cmd = "shiba explain #{database_args} --file #{path}"
18
+ if ENV['SHIBA_DEBUG']
19
+ $stderr.puts("running:")
20
+ $stderr.puts(cmd)
21
+ end
22
+ system(cmd)
14
23
  end
24
+
15
25
  rescue => e
16
26
  $stderr.puts("Shiba failed to load")
17
27
  $stderr.puts(e.message, e.backtrace.join("\n"))
18
28
  end
19
29
  end
20
- end
30
+
31
+ def self.database_args
32
+ c = ActiveRecord::Base.configurations['test']
33
+ options = {
34
+ 'host': c['host'],
35
+ 'database': c['database'],
36
+ 'user': c['username'],
37
+ 'password': c['password']
38
+ }
39
+
40
+ options.reject { |k,v| v.nil? }.map { |k,v| "--#{k} #{v}" }.join(" ")
41
+ end
42
+
43
+ end
@@ -0,0 +1,34 @@
1
+ require 'shiba/index_stats'
2
+ require 'shiba/fuzzer'
3
+
4
+ module Shiba
5
+ class TableStats
6
+ def initialize(dump_stats, connection, manual_stats)
7
+ @dump_stats = Shiba::IndexStats.new(dump_stats)
8
+ @db_stats = Shiba::Fuzzer.new(connection).fuzz!
9
+ @manual_stats = Shiba::IndexStats.new(manual_stats)
10
+ end
11
+
12
+
13
+ def estimate_key(table_name, key, parts)
14
+ ask_each(:estimate_key, table_name, key, parts)
15
+ end
16
+
17
+ def table_count(table)
18
+ ask_each(:table_count, table)
19
+ end
20
+
21
+ def fuzzed?(table)
22
+ !@dump_stats.tables[table] && !@manual_stats.tables[table] && @db_stats.tables[table]
23
+ end
24
+
25
+ private
26
+ def ask_each(method, *args)
27
+ [@dump_stats, @db_stats].each do |stat|
28
+ result = stat.send(method, *args)
29
+ return result unless result.nil?
30
+ end
31
+ nil
32
+ end
33
+ end
34
+ end
data/lib/shiba/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Shiba
2
- VERSION = "0.1.2"
2
+ VERSION = "0.2.0"
3
3
  end
data/shiba.gemspec CHANGED
@@ -33,6 +33,7 @@ Gem::Specification.new do |spec|
33
33
  spec.executables = ["shiba"]
34
34
  spec.require_paths = ["lib"]
35
35
 
36
+ spec.add_dependency "activesupport"
36
37
  spec.add_development_dependency "bundler", "~> 2.0"
37
38
  spec.add_development_dependency "rake", "~> 10.0"
38
39
  end
data/shiba.yml.example ADDED
@@ -0,0 +1,4 @@
1
+ ignore:
2
+ - "acts_as_searchable.rb#fetch_ranks_and_ids"
3
+ - "issue_query.rb"
4
+
data/web/main.css CHANGED
@@ -12,7 +12,37 @@
12
12
  margin: 10px
13
13
  }
14
14
 
15
+ .sql {
16
+ font-family: monospace;
17
+ }
18
+
15
19
  .badge {
16
- color: white;
17
- margin-right: 10px;
20
+ color: black;
21
+ background-color: white;
22
+ border-style: solid;
23
+ border-width: 2px;
24
+ margin-right: 5px;
25
+ width: 100px;
26
+ }
27
+
28
+ .shiba-badge-info {
29
+ border-color: #78b0ec;
30
+ }
31
+
32
+ .shiba-badge-danger {
33
+ border-color: #ff655d;
34
+ }
35
+
36
+ .shiba-badge-success {
37
+ border-color: green;
38
+ }
39
+
40
+ .shiba-badge-warning {
41
+ border-color: #ffb100;
42
+ }
43
+
44
+ .shiba-info-list {
45
+ list-style-type:none;
46
+ margin: 5px;
47
+ padding: 0;
18
48
  }
data/web/results.html.erb CHANGED
@@ -1,44 +1,84 @@
1
1
  <html>
2
2
  <head>
3
3
  <title>Shiba results for <%= Time.now %></title>
4
- <% data[:js].each do |js| %>
5
- <script type="text/javascript" src="./js/<%= js %>"></script>
6
- <% end %>
7
- <link rel="stylesheet" href="bootstrap.min.css">
8
- <link rel="stylesheet" href="main.css">
9
4
  <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet">
10
5
  </head>
11
6
  <body>
7
+ <% data[:js].each do |js| %>
8
+ <script type="text/javascript">
9
+ <%= File.read(js) %>
10
+ </script>
11
+ <% end %>
12
+
13
+ <% data[:css].each do |css| %>
14
+ <style type="text/css">
15
+ <%= File.read(css) %>
16
+ </style>
17
+ <% end %>
18
+
12
19
  <script language="javascript">
13
20
  var data = <%= data.to_json %>;
14
21
  var queriesByTable = [];
15
22
  var queriesByTableLow = [];
23
+ var queriesHaveFuzzed = false;
24
+ var severityIndexes = { high: 1, medium: 2, low: 3 };
16
25
 
17
-
18
- function sortByTable(a, b) {
19
- if ( a.table < b.table )
20
- return -1;
21
- else if ( b.table < a.table )
22
- return 1;
23
- else {
24
- return b.cost - a.cost;
26
+ function sortByFunc(fields) {
27
+ return function(a, b) {
28
+ for ( var i = 0 ; i < fields.length; i++ ) {
29
+ if ( a[fields[i]] < b[fields[i]] )
30
+ return -1;
31
+ else if ( a[fields[i]] > b[fields[i]] )
32
+ return 1;
33
+ }
34
+ return 0;
25
35
  }
26
36
  }
27
37
 
38
+ function Query(obj) {
39
+ Object.assign(this, obj);
40
+ this.severityIndex = severityIndexes[this.severity];
41
+ this.splitSQL();
42
+ };
43
+
44
+ Query.prototype = {
45
+ hasTag: function(tag) {
46
+ this.tags.includes(tag);
47
+ },
48
+ splitSQL: function() {
49
+ this.sqlFragments = this.sql.match(/(SELECT\s)(.*?)(\s+FROM .*)/i);
50
+ },
51
+ select: function () {
52
+ return this.sqlFragments[1];
53
+ },
54
+ fields: function () {
55
+ return this.sqlFragments[2];
56
+ },
57
+ rest: function(index) {
58
+ return this.sqlFragments.slice(index).join('');
59
+ }
60
+ };
61
+
28
62
  data.queries.forEach(function(query) {
29
- if ( query.cost < 100 ) {
30
- queriesByTableLow.push(query);
63
+ var q = new Query(query);
64
+
65
+ if ( q.cost < 100 ) {
66
+ queriesByTableLow.push(q);
31
67
  } else {
32
- queriesByTable.push(query);
68
+ queriesByTable.push(q);
33
69
  }
34
70
 
35
- if ( !query.tags.includes("fuzzed_data") )
36
- query.costAvailable = true;
71
+ if ( q.hasTag("fuzzed_data" ) )
72
+ queriesHaveFuzzed = true;
73
+
74
+ q.expandSelect = false;
37
75
  });
38
76
 
39
- queriesByTable = queriesByTable.sort(sortByTable);
40
- queriesByTableLow = queriesByTableLow.sort(sortByTable);
77
+ var f = sortByFunc(['severityIndex', 'table']);
78
+ queriesByTable = queriesByTable.sort(f);
79
+ queriesByTableLow = queriesByTableLow.sort(f);
41
80
  </script>
81
+
42
82
  <script type="text/x-template" id="query-template">
43
83
  <div class="query">
44
84
  <div class="row">
@@ -50,38 +90,94 @@
50
90
  </div>
51
91
  <div class="col-5">{{ truncate(query.sql, 50) }}</div>
52
92
  <div class="col-3" v-html="makeURL(query.backtrace[0], shortLocation(query))"></div>
53
- <div class="col-1">{{ severity(query) }}</div>
93
+ <div class="col-1">{{ query.severity }}</div>
54
94
  </div>
55
95
  <div class="row" v-if="expanded">
56
96
  <div class="col-12">
57
97
  <div class="query-info-box">
58
- <pre style="white-space: pre-wrap;">{{ query.sql }}</pre>
98
+ <query-sql v-bind:query="query"></query-sql>
59
99
  <div v-if="query.backtrace && query.backtrace.length > 0">
60
100
  Stack Trace:<br>
61
101
  <div class="backtrace">
62
102
  <div v-for="backtrace in query.backtrace" v-html="makeURL(backtrace, backtrace)"></div>
63
103
  </div>
64
104
  </div>
65
- Key: {{ castNull(query.key) }}<br>
66
- <div v-if="query.possible_keys && query.possible_keys.length > 0 || !query.key">
67
- Possible keys: {{ castNull(query.possible_keys) }}
105
+ <ul class="shiba-info-list">
106
+ <li v-for="tag in query.tags">
107
+ <component v-bind:is="'tag-' + tag" v-bind:query="query"></component>
108
+ </li>
109
+ </ul>
110
+ <div v-if="!rawExpanded">
111
+ <a href="#" v-on:click.prevent="rawExpanded = !rawExpanded">See full EXPLAIN</a>
112
+ </div>
113
+ <div v-else>
114
+ <a href="#" v-on:click.prevent="rawExpanded = !rawExpanded">hide EXPLAIN</a>
115
+ <pre class="backtrace">{{ JSON.stringify(query.raw_explain, null, 2) }}</pre>
68
116
  </div>
69
- Cost: {{ query.cost }}<br>
70
- <a class="badge"
71
- v-for="tag in query.tags"
72
- href="#"
73
- v-bind:class="tagClass(tag)"
74
- v-on:click="expandInfo(tag, $event)">{{ tagTitle(tag) }}</a>
75
117
  </div>
76
118
  </div>
77
119
  </div>
78
120
  </div>
79
121
  </script>
80
122
 
123
+ <% data[:tags].each do |tag, h| %>
124
+ <script type="text/x-template" id="tag-<%= tag %>-template">
125
+ <span><a class="badge shiba-badge-<%= h['level'] %>"><%= h['title'] %></a><%= h['summary'] %></span>
126
+ </script>
127
+ <script>
128
+ Vue.component('tag-<%= tag %>', {
129
+ template: '#tag-<%= tag %>-template',
130
+ props: ['query'],
131
+ computed: {
132
+ key_parts: function() {
133
+ if ( this.query.used_key_parts && this.query.used_key_parts.length > 0 )
134
+ return this.query.used_key_parts.join(',');
135
+ else
136
+ return "";
137
+ },
138
+ formattedCost: function() {
139
+ var costPercentage = (this.query.cost / this.query.table_size) * 100.0;
140
+ if ( this.query.cost > 100 && costPercentage > 1 ) // todo: make better
141
+ return `${costPercentage.toFixed()}% (${this.query.cost.toLocaleString()}) of the`;
142
+ else
143
+ return this.query.cost.toLocaleString();
144
+ }
145
+ }
146
+ });
147
+ </script>
148
+ <% end %>
149
+
150
+
151
+ <script type="text/x-template" id="sql-template">
152
+ <div class="sql">
153
+ <span>{{ query.select() }}</span>
154
+ <span v-if="!expandFields && query.fields().length > 80">
155
+ <a href="#" v-on:click.prevent="expandFields = !expandFields">...</a>
156
+ </span>
157
+ <span v-else>{{ query.fields() }}</span>
158
+ <span>{{ query.rest(3) }}</span>
159
+ </div>
160
+ </script>
161
+
162
+ <script>
163
+ Vue.component('query-sql', {
164
+ template: '#sql-template',
165
+ props: ['query'],
166
+ data: function () {
167
+ return { expandFields: false }
168
+ }
169
+ });
170
+ </script>
171
+
81
172
  <div id="app">
82
173
  <v-dialog :width="600"></v-dialog>
83
174
  <div class="container" style="">
84
175
  <div class="row">
176
+ <div v-if="hasFuzzed" class="alert alert-warning" role="alert">
177
+ This query analysis was generated using estimated table sizes.
178
+ <a href="https://github.com/burrito-brothers/shiba/blob/master/README.md#going-beyond-table-scans">Find out how to get a more accurate analysis by feeding Shiba index stats</a>
179
+ </div>
180
+
85
181
  <div class="col-12">We found {{ queries.length }} queries that deserve your attention: </div>
86
182
  </div>
87
183
  <div class="row">
@@ -110,7 +206,8 @@
110
206
  props: ['query', 'tags', 'github'],
111
207
  data: function () {
112
208
  return {
113
- expanded: false
209
+ expanded: false,
210
+ rawExpanded: false
114
211
  };
115
212
  },
116
213
  methods: {
@@ -137,18 +234,6 @@
137
234
  if (event) event.preventDefault()
138
235
  this.expanded = !this.expanded;
139
236
  },
140
- severity: function(query) {
141
- if ( query.cost > 1 )
142
- return "high";
143
- else
144
- return "low";
145
- },
146
- castNull: function (string) {
147
- if ( !string )
148
- return "(none)";
149
- else
150
- return string;
151
- },
152
237
  shortLocation: function(query) {
153
238
  if ( !query.backtrace )
154
239
  return null;
@@ -164,24 +249,12 @@
164
249
  var line = matches[2];
165
250
 
166
251
  return `<a href='${data.url}/${file}#L${line}' target='_new'>${content}</a>`;
167
- },
168
- tagTitle: function(tag) {
169
- if ( !this.tags[tag] )
170
- return tag;
171
- else
172
- return this.tags[tag].title;
173
- },
174
- tagClass: function(tag) {
175
- if ( !this.tags[tag] )
176
- return '';
177
- else
178
- return "badge-" + this.tags[tag].level;
179
252
  }
180
253
  },
181
254
  computed: {
182
255
  expandText: function() {
183
256
  return this.expanded ? "-" : "+";
184
- },
257
+ }
185
258
  }
186
259
  });
187
260
 
@@ -191,7 +264,8 @@
191
264
  queries: queriesByTable,
192
265
  queriesLow: queriesByTableLow,
193
266
  tags: data.tags,
194
- lowExpanded: false
267
+ lowExpanded: false,
268
+ hasFuzzed: queriesHaveFuzzed
195
269
  },
196
270
  methods: { }
197
271
  });