query_owl 0.1.0 → 0.3.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 +4 -4
- data/README.md +69 -4
- data/app/assets/stylesheets/query_owl/_01_base.css +23 -0
- data/app/assets/stylesheets/query_owl/_02_layout.css +5 -0
- data/app/assets/stylesheets/query_owl/_03_table.css +29 -0
- data/app/assets/stylesheets/query_owl/_04_badges.css +13 -0
- data/app/controllers/query_owl/slow_queries_controller.rb +28 -0
- data/app/helpers/query_owl/application_helper.rb +9 -0
- data/app/views/layouts/query_owl/application.html.erb +13 -0
- data/app/views/query_owl/slow_queries/index.html.erb +34 -0
- data/config/routes.rb +1 -0
- data/lib/query_owl/configuration.rb +18 -5
- data/lib/query_owl/detector.rb +16 -2
- data/lib/query_owl/eager_load_tracker.rb +43 -0
- data/lib/query_owl/engine.rb +30 -0
- data/lib/query_owl/event_store.rb +59 -0
- data/lib/query_owl/logger.rb +12 -0
- data/lib/query_owl/middleware.rb +16 -2
- data/lib/query_owl/query_tracker.rb +3 -1
- data/lib/query_owl/version.rb +1 -1
- data/lib/query_owl.rb +4 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5db617fa85bce1a43d851809139bd43015afd92263bd203ac0114921dd94c525
|
|
4
|
+
data.tar.gz: 4ebf287c62f2779448c2277d416147d7b426332a2dd927f6a1a8485498c76638
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3ec2642c611837a83dc96fdf8fc7eb669a99c6ae140fc3ad5bb1439a5b2db4349a9038a87a4af8e61fe1156e2d6fcbe43535641a2c51dd3bbcc41f9652ec88c
|
|
7
|
+
data.tar.gz: 2b2fe57db0a34c3289ef76a6170c4ea0d36d441eb733571ce87d8b506e1772b54d1e63841a8719d009a7ffd52a708b8ce5ad19c36e22839ceb3d33111700ae8b
|
data/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://www.ruby-lang.org)
|
|
7
7
|
[](https://codecov.io/gh/eclectic-coding/query_owl)
|
|
8
8
|
|
|
9
|
-
A leaner alternative to Bullet. QueryOwl detects N+1 queries
|
|
9
|
+
A leaner alternative to Bullet. QueryOwl detects N+1 queries, slow queries, and unused eager loads in development, logging structured warnings to your Rails logger — without the noise.
|
|
10
10
|
|
|
11
11
|
## Table of Contents
|
|
12
12
|
|
|
@@ -14,6 +14,7 @@ A leaner alternative to Bullet. QueryOwl detects N+1 queries and slow queries in
|
|
|
14
14
|
- [Installation](#installation)
|
|
15
15
|
- [Configuration](#configuration)
|
|
16
16
|
- [Log Output](#log-output)
|
|
17
|
+
- [Dashboard Endpoint](#dashboard-endpoint)
|
|
17
18
|
- [Manual Testing in the Dummy App](#manual-testing-in-the-dummy-app)
|
|
18
19
|
- [Roadmap](#roadmap)
|
|
19
20
|
- [Contributing](#contributing)
|
|
@@ -25,6 +26,9 @@ A leaner alternative to Bullet. QueryOwl detects N+1 queries and slow queries in
|
|
|
25
26
|
|
|
26
27
|
- **N+1 detection** — flags when the same SQL pattern fires 2+ times in a single request
|
|
27
28
|
- **Slow query detection** — flags queries exceeding a configurable threshold (default: 100ms)
|
|
29
|
+
- **Unused eager load detection** — flags associations preloaded via `includes`/`eager_load` that are never accessed during the request
|
|
30
|
+
- **Per-request summary** — single summary line at the end of each request with totals (e.g. `Request complete — 3 N+1s, 1 slow query`)
|
|
31
|
+
- **CI-friendly raise mode** — set `raise_on_n_plus_one: true` to raise `QueryOwl::NPlusOneError` instead of logging, making N+1s fail fast in test suites
|
|
28
32
|
- **Structured log output** — JSON-style warnings via `Rails.logger` with SQL, duration, count, and filtered backtrace
|
|
29
33
|
- **Zero overhead in production** — auto-enabled in development only
|
|
30
34
|
|
|
@@ -61,6 +65,11 @@ QueryOwl.configure do |config|
|
|
|
61
65
|
config.slow_query_threshold_ms = 100 # flag queries slower than this
|
|
62
66
|
config.n_plus_one_threshold = 2 # flag after this many repeated patterns
|
|
63
67
|
config.log_level = :warn # :warn | :info | :debug
|
|
68
|
+
config.backtrace_lines = 5 # number of backtrace frames to capture
|
|
69
|
+
config.backtrace_filter = ->(line) { line.start_with?("app/") } # optional custom filter
|
|
70
|
+
config.raise_on_n_plus_one = false # set true in CI to raise instead of log
|
|
71
|
+
config.event_store_size = 100 # ring buffer capacity
|
|
72
|
+
config.dashboard_enabled = Rails.env.development? # HTML view on/off
|
|
64
73
|
end
|
|
65
74
|
```
|
|
66
75
|
|
|
@@ -75,6 +84,47 @@ When a problem is detected, QueryOwl writes a structured line to `Rails.logger`:
|
|
|
75
84
|
```
|
|
76
85
|
[QueryOwl] {"type":"n_plus_one","sql":"SELECT * FROM posts WHERE user_id = ?","count":10,"backtrace":["app/controllers/posts_controller.rb:12"]}
|
|
77
86
|
[QueryOwl] {"type":"slow_query","sql":"SELECT * FROM reports WHERE ...","duration_ms":340}
|
|
87
|
+
[QueryOwl] {"type":"unused_eager_load","model":"Widget","association":"tags"}
|
|
88
|
+
[QueryOwl] Request complete — 10 N+1s, 1 slow query, 1 unused eager load
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
[↑ Back to top](#table-of-contents)
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Dashboard Endpoint
|
|
96
|
+
|
|
97
|
+
Mount the engine in your host app's routes to enable the JSON endpoint:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
# config/routes.rb
|
|
101
|
+
mount QueryOwl::Engine => "/rails"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Then browse the HTML dashboard or query JSON at `GET /rails/slow_queries`:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
GET /rails/slow_queries # HTML dashboard (browser)
|
|
108
|
+
GET /rails/slow_queries.json # JSON array
|
|
109
|
+
GET /rails/slow_queries?type=n_plus_one
|
|
110
|
+
GET /rails/slow_queries?type=slow_query
|
|
111
|
+
GET /rails/slow_queries?type=unused_eager_load
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The HTML view is enabled when `config.dashboard_enabled` is `true` (default in development); returns `403` otherwise. The JSON endpoint is always available.
|
|
115
|
+
|
|
116
|
+
The JSON response is an array of event objects, newest first, up to `config.event_store_size` entries:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
[
|
|
120
|
+
{
|
|
121
|
+
"type": "n_plus_one",
|
|
122
|
+
"sql": "SELECT * FROM posts WHERE user_id = ?",
|
|
123
|
+
"count": 5,
|
|
124
|
+
"backtrace": ["app/controllers/posts_controller.rb:12"],
|
|
125
|
+
"recorded_at": "2026-06-15T18:00:00.000Z"
|
|
126
|
+
}
|
|
127
|
+
]
|
|
78
128
|
```
|
|
79
129
|
|
|
80
130
|
[↑ Back to top](#table-of-contents)
|
|
@@ -116,15 +166,30 @@ QueryOwl::Logger.log_events(events)
|
|
|
116
166
|
# => [QueryOwl] {"type":"slow_query","sql":"SELECT ...","duration_ms":...}
|
|
117
167
|
```
|
|
118
168
|
|
|
169
|
+
**Trigger unused eager load detection:**
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
QueryOwl.config.enabled = true
|
|
173
|
+
QueryOwl::EagerLoadTracker.start!
|
|
174
|
+
Widget.includes(:tags).map(&:name) # loads tags but never touches them
|
|
175
|
+
eager_data = QueryOwl::EagerLoadTracker.stop!
|
|
176
|
+
events = QueryOwl::Detector.detect_unused_eager_loads(eager_data)
|
|
177
|
+
QueryOwl::Logger.log_events(events)
|
|
178
|
+
# => [QueryOwl] {"type":"unused_eager_load","model":"Widget","association":"tags"}
|
|
179
|
+
```
|
|
180
|
+
|
|
119
181
|
**Full pipeline** (as it runs on every real HTTP request):
|
|
120
182
|
|
|
121
183
|
```ruby
|
|
122
184
|
QueryOwl.config.slow_query_threshold_ms = 0
|
|
123
185
|
QueryOwl::QueryTracker.start!
|
|
186
|
+
QueryOwl::EagerLoadTracker.start!
|
|
124
187
|
Widget.all.each { |w| Widget.find(w.id) }
|
|
125
|
-
queries
|
|
126
|
-
|
|
127
|
-
|
|
188
|
+
queries = QueryOwl::QueryTracker.stop!
|
|
189
|
+
eager_data = QueryOwl::EagerLoadTracker.stop!
|
|
190
|
+
events = QueryOwl::Detector.detect_n_plus_one(queries) +
|
|
191
|
+
QueryOwl::Detector.detect_slow_queries(queries) +
|
|
192
|
+
QueryOwl::Detector.detect_unused_eager_loads(eager_data)
|
|
128
193
|
QueryOwl::Logger.log_events(events)
|
|
129
194
|
```
|
|
130
195
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--bg: #f8f9fa;
|
|
5
|
+
--surface: #ffffff;
|
|
6
|
+
--border: #dee2e6;
|
|
7
|
+
--text: #212529;
|
|
8
|
+
--muted: #6c757d;
|
|
9
|
+
--radius: 6px;
|
|
10
|
+
--shadow: 0 1px 3px rgba(0,0,0,.08);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
body {
|
|
14
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
15
|
+
font-size: 14px;
|
|
16
|
+
line-height: 1.5;
|
|
17
|
+
color: var(--text);
|
|
18
|
+
background: var(--bg);
|
|
19
|
+
padding: 2rem;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.qo-monospace { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 12px; }
|
|
23
|
+
.qo-muted { color: var(--muted); font-size: 12px; }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
.qo-table {
|
|
2
|
+
width: 100%;
|
|
3
|
+
border-collapse: collapse;
|
|
4
|
+
background: var(--surface);
|
|
5
|
+
border-radius: var(--radius);
|
|
6
|
+
overflow: hidden;
|
|
7
|
+
box-shadow: var(--shadow);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.qo-table th {
|
|
11
|
+
text-align: left;
|
|
12
|
+
padding: 0.6rem 1rem;
|
|
13
|
+
background: #f0f0f0;
|
|
14
|
+
font-weight: 600;
|
|
15
|
+
font-size: 11px;
|
|
16
|
+
text-transform: uppercase;
|
|
17
|
+
letter-spacing: .04em;
|
|
18
|
+
color: var(--muted);
|
|
19
|
+
border-bottom: 1px solid var(--border);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.qo-table td {
|
|
23
|
+
padding: 0.6rem 1rem;
|
|
24
|
+
border-bottom: 1px solid #eee;
|
|
25
|
+
vertical-align: top;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.qo-table tr:last-child td { border-bottom: none; }
|
|
29
|
+
.qo-table tr:hover td { background: #fafafa; }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.qo-badge {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
padding: 2px 8px;
|
|
4
|
+
border-radius: 3px;
|
|
5
|
+
font-size: 11px;
|
|
6
|
+
font-weight: 600;
|
|
7
|
+
text-transform: uppercase;
|
|
8
|
+
white-space: nowrap;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.qo-badge--n_plus_one { background: #fde8e8; color: #c0392b; }
|
|
12
|
+
.qo-badge--slow_query { background: #fef3e2; color: #e67e22; }
|
|
13
|
+
.qo-badge--unused_eager_load { background: #e8f4fd; color: #2980b9; }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module QueryOwl
|
|
2
|
+
class SlowQueriesController < ActionController::Base
|
|
3
|
+
protect_from_forgery with: :null_session
|
|
4
|
+
layout "query_owl/application"
|
|
5
|
+
helper QueryOwl::ApplicationHelper
|
|
6
|
+
|
|
7
|
+
before_action :check_dashboard_enabled, if: -> { request.format.html? }
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
filters = request.query_parameters
|
|
11
|
+
events = EventStore.all
|
|
12
|
+
events = events.select { |e| e[:type].to_s == filters["type"] } if filters["type"].present?
|
|
13
|
+
events = events.select { |e| e[:controller] == filters["controller"] } if filters["controller"].present?
|
|
14
|
+
events = events.select { |e| e[:action] == filters["action"] } if filters["action"].present?
|
|
15
|
+
|
|
16
|
+
respond_to do |format|
|
|
17
|
+
format.json { render json: events }
|
|
18
|
+
format.html { @events = events.reverse }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def check_dashboard_enabled
|
|
25
|
+
head :forbidden unless QueryOwl.config.dashboard_enabled
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>QueryOwl</title>
|
|
7
|
+
<link rel="icon" href="data:,">
|
|
8
|
+
<%= inline_styles %>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<%= yield %>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<div class="qo-header">
|
|
2
|
+
<h1>QueryOwl</h1>
|
|
3
|
+
<p>Last <%= @events.length %> detected event<%= "s" unless @events.length == 1 %> (newest first)</p>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<% if @events.empty? %>
|
|
7
|
+
<p class="qo-empty">No events detected yet.</p>
|
|
8
|
+
<% else %>
|
|
9
|
+
<table class="qo-table">
|
|
10
|
+
<thead>
|
|
11
|
+
<tr>
|
|
12
|
+
<th>Type</th>
|
|
13
|
+
<th>SQL / Details</th>
|
|
14
|
+
<th>Info</th>
|
|
15
|
+
<th>Recorded At</th>
|
|
16
|
+
<th>Backtrace</th>
|
|
17
|
+
</tr>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody>
|
|
20
|
+
<% @events.each do |event| %>
|
|
21
|
+
<tr>
|
|
22
|
+
<td><span class="qo-badge qo-badge--<%= event[:type] %>"><%= event[:type].to_s.tr("_", " ") %></span></td>
|
|
23
|
+
<td class="qo-monospace"><%= event[:sql] || "#{event[:model]}##{event[:association]}" %></td>
|
|
24
|
+
<td class="qo-muted">
|
|
25
|
+
<% if event[:count] %>count: <%= event[:count] %><% end %>
|
|
26
|
+
<% if event[:duration_ms] %><%= event[:duration_ms] %>ms<% end %>
|
|
27
|
+
</td>
|
|
28
|
+
<td class="qo-muted"><%= event[:recorded_at]&.strftime("%H:%M:%S") %></td>
|
|
29
|
+
<td class="qo-monospace qo-muted"><%= Array(event[:backtrace]).first %></td>
|
|
30
|
+
</tr>
|
|
31
|
+
<% end %>
|
|
32
|
+
</tbody>
|
|
33
|
+
</table>
|
|
34
|
+
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
module QueryOwl
|
|
2
2
|
class Configuration
|
|
3
3
|
VALID_LOG_LEVELS = %i[debug info warn].freeze
|
|
4
|
+
DEFAULT_BACKTRACE_FILTER = ->(line) { line !~ %r{/gems/|/rubygems/|/ruby/gems/|lib/query_owl/} }
|
|
4
5
|
|
|
5
|
-
attr_reader :log_level
|
|
6
|
-
attr_accessor :enabled, :slow_query_threshold_ms, :n_plus_one_threshold
|
|
6
|
+
attr_reader :log_level, :backtrace_filter
|
|
7
|
+
attr_accessor :enabled, :slow_query_threshold_ms, :n_plus_one_threshold, :backtrace_lines,
|
|
8
|
+
:raise_on_n_plus_one, :event_store_size, :dashboard_enabled
|
|
7
9
|
|
|
8
10
|
def initialize
|
|
9
|
-
@enabled
|
|
11
|
+
@enabled = Rails.env.development?
|
|
10
12
|
@slow_query_threshold_ms = 100
|
|
11
|
-
@n_plus_one_threshold
|
|
12
|
-
@log_level
|
|
13
|
+
@n_plus_one_threshold = 2
|
|
14
|
+
@log_level = :warn
|
|
15
|
+
@backtrace_lines = 5
|
|
16
|
+
@backtrace_filter = DEFAULT_BACKTRACE_FILTER
|
|
17
|
+
@raise_on_n_plus_one = false
|
|
18
|
+
@event_store_size = 100
|
|
19
|
+
@dashboard_enabled = Rails.env.development?
|
|
13
20
|
end
|
|
14
21
|
|
|
15
22
|
def log_level=(level)
|
|
@@ -19,5 +26,11 @@ module QueryOwl
|
|
|
19
26
|
|
|
20
27
|
@log_level = level
|
|
21
28
|
end
|
|
29
|
+
|
|
30
|
+
def backtrace_filter=(filter)
|
|
31
|
+
raise ArgumentError, "backtrace_filter must respond to #call" unless filter.respond_to?(:call)
|
|
32
|
+
|
|
33
|
+
@backtrace_filter = filter
|
|
34
|
+
end
|
|
22
35
|
end
|
|
23
36
|
end
|
data/lib/query_owl/detector.rb
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
module QueryOwl
|
|
2
2
|
module Detector
|
|
3
|
-
# Matches numeric literals, single-quoted strings, and IN-list contents.
|
|
4
3
|
NORMALIZE_PATTERNS = [
|
|
5
4
|
[/'[^']*'/, "?"],
|
|
6
|
-
[/\b\
|
|
5
|
+
[/\b[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b/i, "?"],
|
|
6
|
+
[/\$\d+/, "?"],
|
|
7
|
+
[/\b\d+\.?\d*\b/, "?"],
|
|
8
|
+
[/\bIN\s*\(\s*\?(?:\s*,\s*\?)*\s*\)/i, "IN (?)"],
|
|
9
|
+
[/"([^"]+)"/, '\1'],
|
|
10
|
+
[/`([^`]+)`/, '\1'],
|
|
7
11
|
[/\s+/, " "]
|
|
8
12
|
].freeze
|
|
9
13
|
|
|
@@ -42,6 +46,16 @@ module QueryOwl
|
|
|
42
46
|
end
|
|
43
47
|
end
|
|
44
48
|
|
|
49
|
+
def detect_unused_eager_loads(eager_data)
|
|
50
|
+
preloaded = eager_data[:preloaded] || []
|
|
51
|
+
accessed = eager_data[:accessed] || Set.new
|
|
52
|
+
|
|
53
|
+
preloaded
|
|
54
|
+
.uniq { |e| "#{e[:model]}##{e[:association]}" }
|
|
55
|
+
.reject { |e| accessed.include?("#{e[:model]}##{e[:association]}") }
|
|
56
|
+
.map { |e| { type: :unused_eager_load, model: e[:model], association: e[:association] } }
|
|
57
|
+
end
|
|
58
|
+
|
|
45
59
|
def normalize(sql)
|
|
46
60
|
NORMALIZE_PATTERNS
|
|
47
61
|
.reduce(sql.to_s) { |s, (pattern, replacement)| s.gsub(pattern, replacement) }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module QueryOwl
|
|
2
|
+
module EagerLoadTracker
|
|
3
|
+
class << self
|
|
4
|
+
def start!
|
|
5
|
+
Thread.current[:query_owl_preloaded] = []
|
|
6
|
+
Thread.current[:query_owl_el_accessed] = Set.new
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def stop!
|
|
10
|
+
result = { preloaded: preloaded.dup, accessed: accessed.dup }
|
|
11
|
+
Thread.current[:query_owl_preloaded] = nil
|
|
12
|
+
Thread.current[:query_owl_el_accessed] = nil
|
|
13
|
+
result
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def tracking?
|
|
17
|
+
!Thread.current[:query_owl_preloaded].nil?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record_preload(model_name, association_name)
|
|
21
|
+
return unless tracking?
|
|
22
|
+
|
|
23
|
+
preloaded << { model: model_name.to_s, association: association_name.to_s }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def record_access(model_name, association_name)
|
|
27
|
+
return unless tracking?
|
|
28
|
+
|
|
29
|
+
accessed << "#{model_name}##{association_name}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def preloaded
|
|
35
|
+
Thread.current[:query_owl_preloaded] ||= []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def accessed
|
|
39
|
+
Thread.current[:query_owl_el_accessed] ||= Set.new
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/query_owl/engine.rb
CHANGED
|
@@ -15,5 +15,35 @@ module QueryOwl
|
|
|
15
15
|
initializer "query_owl.request_tracking" do |app|
|
|
16
16
|
app.middleware.use(Middleware)
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
config.after_initialize do
|
|
20
|
+
ActiveRecord::Associations::Preloader.prepend(Module.new do
|
|
21
|
+
def initialize(records:, associations:, **kwargs)
|
|
22
|
+
if QueryOwl::EagerLoadTracker.tracking? && records.any?
|
|
23
|
+
model_name = records.first.class.name
|
|
24
|
+
Array(associations).each do |assoc|
|
|
25
|
+
QueryOwl::EagerLoadTracker.record_preload(model_name, assoc)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
Thread.current[:query_owl_preloading] = true
|
|
33
|
+
super
|
|
34
|
+
ensure
|
|
35
|
+
Thread.current[:query_owl_preloading] = false
|
|
36
|
+
end
|
|
37
|
+
end)
|
|
38
|
+
|
|
39
|
+
ActiveRecord::Base.prepend(Module.new do
|
|
40
|
+
def association(name)
|
|
41
|
+
unless Thread.current[:query_owl_preloading]
|
|
42
|
+
QueryOwl::EagerLoadTracker.record_access(self.class.name, name)
|
|
43
|
+
end
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
end)
|
|
47
|
+
end
|
|
18
48
|
end
|
|
19
49
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module QueryOwl
|
|
2
|
+
module EventStore
|
|
3
|
+
class << self
|
|
4
|
+
def push(event)
|
|
5
|
+
mutex.synchronize do
|
|
6
|
+
ensure_buffer_size
|
|
7
|
+
buffer[@write_pos] = event.merge(recorded_at: Time.now)
|
|
8
|
+
@write_pos = (@write_pos + 1) % capacity
|
|
9
|
+
@stored = [@stored + 1, capacity].min
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def all
|
|
14
|
+
mutex.synchronize do
|
|
15
|
+
stored = @stored || 0
|
|
16
|
+
return [] if stored.zero?
|
|
17
|
+
|
|
18
|
+
if stored < capacity
|
|
19
|
+
buffer.first(stored)
|
|
20
|
+
else
|
|
21
|
+
buffer[@write_pos..] + buffer[0...@write_pos]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def clear
|
|
27
|
+
mutex.synchronize { reset! }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def size
|
|
31
|
+
mutex.synchronize { @stored || 0 }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def mutex
|
|
37
|
+
@mutex ||= Mutex.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def capacity
|
|
41
|
+
QueryOwl.config.event_store_size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ensure_buffer_size
|
|
45
|
+
reset! if @write_pos.nil? || @buffer.nil? || @buffer.size != capacity
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def buffer
|
|
49
|
+
@buffer ||= Array.new(capacity)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reset!
|
|
53
|
+
@buffer = Array.new(capacity)
|
|
54
|
+
@write_pos = 0
|
|
55
|
+
@stored = 0
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/query_owl/logger.rb
CHANGED
|
@@ -11,6 +11,18 @@ module QueryOwl
|
|
|
11
11
|
events.each { |event| write(event) }
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
def log_summary(events)
|
|
15
|
+
return if events.empty?
|
|
16
|
+
|
|
17
|
+
counts = events.group_by { |e| e[:type] }.transform_values(&:count)
|
|
18
|
+
parts = []
|
|
19
|
+
parts << "#{counts[:n_plus_one]} N+1#{"s" if counts[:n_plus_one] != 1}" if counts[:n_plus_one]
|
|
20
|
+
parts << "#{counts[:slow_query]} slow #{counts[:slow_query] == 1 ? "query" : "queries"}" if counts[:slow_query]
|
|
21
|
+
parts << "#{counts[:unused_eager_load]} unused eager load#{"s" if counts[:unused_eager_load] != 1}" if counts[:unused_eager_load]
|
|
22
|
+
|
|
23
|
+
Rails.logger.public_send(QueryOwl.config.log_level, "#{PREFIX} Request complete — #{parts.join(", ")}")
|
|
24
|
+
end
|
|
25
|
+
|
|
14
26
|
private
|
|
15
27
|
|
|
16
28
|
def write(event)
|
data/lib/query_owl/middleware.rb
CHANGED
|
@@ -4,15 +4,29 @@ module QueryOwl
|
|
|
4
4
|
@app = app
|
|
5
5
|
end
|
|
6
6
|
|
|
7
|
+
def raise_on_n_plus_one!(events)
|
|
8
|
+
event = events.find { |e| e[:type] == :n_plus_one }
|
|
9
|
+
return unless event
|
|
10
|
+
|
|
11
|
+
raise NPlusOneError, "N+1 detected: #{event[:sql]} (#{event[:count]} times) #{event[:backtrace].first}"
|
|
12
|
+
end
|
|
13
|
+
|
|
7
14
|
def call(env)
|
|
8
15
|
return @app.call(env) unless QueryOwl.config.enabled
|
|
9
16
|
|
|
10
17
|
QueryTracker.start!
|
|
18
|
+
EagerLoadTracker.start!
|
|
11
19
|
@app.call(env)
|
|
12
20
|
ensure
|
|
13
|
-
queries
|
|
14
|
-
|
|
21
|
+
queries = QueryTracker.stop!
|
|
22
|
+
eager_data = EagerLoadTracker.stop!
|
|
23
|
+
events = Detector.detect_n_plus_one(queries) +
|
|
24
|
+
Detector.detect_slow_queries(queries) +
|
|
25
|
+
Detector.detect_unused_eager_loads(eager_data)
|
|
15
26
|
Logger.log_events(events)
|
|
27
|
+
Logger.log_summary(events)
|
|
28
|
+
events.each { |e| EventStore.push(e) }
|
|
29
|
+
raise_on_n_plus_one!(events) if QueryOwl.config.raise_on_n_plus_one
|
|
16
30
|
end
|
|
17
31
|
end
|
|
18
32
|
end
|
|
@@ -37,7 +37,9 @@ module QueryOwl
|
|
|
37
37
|
private
|
|
38
38
|
|
|
39
39
|
def filtered_backtrace
|
|
40
|
-
|
|
40
|
+
filter = QueryOwl.config.backtrace_filter
|
|
41
|
+
lines = QueryOwl.config.backtrace_lines
|
|
42
|
+
caller.select { |line| filter.call(line) }.first(lines)
|
|
41
43
|
end
|
|
42
44
|
end
|
|
43
45
|
end
|
data/lib/query_owl/version.rb
CHANGED
data/lib/query_owl.rb
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
require "query_owl/version"
|
|
2
2
|
require "query_owl/configuration"
|
|
3
3
|
require "query_owl/query_tracker"
|
|
4
|
+
require "query_owl/eager_load_tracker"
|
|
5
|
+
require "query_owl/event_store"
|
|
4
6
|
require "query_owl/detector"
|
|
5
7
|
require "query_owl/logger"
|
|
6
8
|
require "query_owl/middleware"
|
|
7
9
|
require "query_owl/engine"
|
|
8
10
|
|
|
9
11
|
module QueryOwl
|
|
12
|
+
class NPlusOneError < StandardError; end
|
|
13
|
+
|
|
10
14
|
class << self
|
|
11
15
|
def configure
|
|
12
16
|
yield config
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: query_owl
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -34,15 +34,25 @@ files:
|
|
|
34
34
|
- MIT-LICENSE
|
|
35
35
|
- README.md
|
|
36
36
|
- Rakefile
|
|
37
|
+
- app/assets/stylesheets/query_owl/_01_base.css
|
|
38
|
+
- app/assets/stylesheets/query_owl/_02_layout.css
|
|
39
|
+
- app/assets/stylesheets/query_owl/_03_table.css
|
|
40
|
+
- app/assets/stylesheets/query_owl/_04_badges.css
|
|
37
41
|
- app/controllers/query_owl/application_controller.rb
|
|
42
|
+
- app/controllers/query_owl/slow_queries_controller.rb
|
|
43
|
+
- app/helpers/query_owl/application_helper.rb
|
|
38
44
|
- app/jobs/query_owl/application_job.rb
|
|
39
45
|
- app/mailers/query_owl/application_mailer.rb
|
|
40
46
|
- app/models/query_owl/application_record.rb
|
|
47
|
+
- app/views/layouts/query_owl/application.html.erb
|
|
48
|
+
- app/views/query_owl/slow_queries/index.html.erb
|
|
41
49
|
- config/routes.rb
|
|
42
50
|
- lib/query_owl.rb
|
|
43
51
|
- lib/query_owl/configuration.rb
|
|
44
52
|
- lib/query_owl/detector.rb
|
|
53
|
+
- lib/query_owl/eager_load_tracker.rb
|
|
45
54
|
- lib/query_owl/engine.rb
|
|
55
|
+
- lib/query_owl/event_store.rb
|
|
46
56
|
- lib/query_owl/logger.rb
|
|
47
57
|
- lib/query_owl/middleware.rb
|
|
48
58
|
- lib/query_owl/query_tracker.rb
|