query_owl 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45b067e5caa42b01df6cb84cce7541b77ee9bbee1f9f6d919ed554fb2386fd0e
4
- data.tar.gz: 688d0b4f41edcf13e99bad7dbe6a4bdfe8ad40d458f908a06c2bc87b8ccbdbea
3
+ metadata.gz: 6a44534cc84c3ea8089418405a3291098f3c35fb6ee3cb6b67d1ebea3cce4a62
4
+ data.tar.gz: 40272edc4665655ae1fda469819eab1158951fc4a75ea6e417132d5ae80c6103
5
5
  SHA512:
6
- metadata.gz: 960d4332a40bd8531ccdda7b06d8e6a947b048c3c5f758fba374e62759668bbe43ae335e7d5dc4fc394785e2ea5fa6e13e1f7f8ca9c4e6f035f52b380815c982
7
- data.tar.gz: 1764fbd57f9359a30866c2665a5698ede5bd4764fab90f10afa7984451086cc1d51fd8bf933828bfdff14a87631c753824b8cff7cd225a1474ca41b9efd5a0f3
6
+ metadata.gz: 8f97a828e36d1fe5a8acee9e6dc42198895e12a92a63d02dfb0b91aa35541a5062d5e439ef4035b0ca486655c2e0ea90fbe5efd31eaa8eb93c3b7d26d3184cc6
7
+ data.tar.gz: 943ad7e59234e171da3831b3b4bfbdd9f43668d413cbd17efba1803f4b225e198d7e047723c4c987c6b85720ad1fc13f1574a210b31c38e6e6f051cc6a3f7c1d
data/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-ruby)](https://www.ruby-lang.org)
7
7
  [![codecov](https://codecov.io/gh/eclectic-coding/query_owl/branch/main/graph/badge.svg)](https://codecov.io/gh/eclectic-coding/query_owl)
8
8
 
9
- A leaner alternative to Bullet. QueryOwl detects N+1 queries and slow queries in development, logging structured warnings to your Rails logger — without the noise.
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
 
@@ -25,6 +25,9 @@ A leaner alternative to Bullet. QueryOwl detects N+1 queries and slow queries in
25
25
 
26
26
  - **N+1 detection** — flags when the same SQL pattern fires 2+ times in a single request
27
27
  - **Slow query detection** — flags queries exceeding a configurable threshold (default: 100ms)
28
+ - **Unused eager load detection** — flags associations preloaded via `includes`/`eager_load` that are never accessed during the request
29
+ - **Per-request summary** — single summary line at the end of each request with totals (e.g. `Request complete — 3 N+1s, 1 slow query`)
30
+ - **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
31
  - **Structured log output** — JSON-style warnings via `Rails.logger` with SQL, duration, count, and filtered backtrace
29
32
  - **Zero overhead in production** — auto-enabled in development only
30
33
 
@@ -61,6 +64,9 @@ QueryOwl.configure do |config|
61
64
  config.slow_query_threshold_ms = 100 # flag queries slower than this
62
65
  config.n_plus_one_threshold = 2 # flag after this many repeated patterns
63
66
  config.log_level = :warn # :warn | :info | :debug
67
+ config.backtrace_lines = 5 # number of backtrace frames to capture
68
+ config.backtrace_filter = ->(line) { line.start_with?("app/") } # optional custom filter
69
+ config.raise_on_n_plus_one = false # set true in CI to raise instead of log
64
70
  end
65
71
  ```
66
72
 
@@ -75,6 +81,8 @@ When a problem is detected, QueryOwl writes a structured line to `Rails.logger`:
75
81
  ```
76
82
  [QueryOwl] {"type":"n_plus_one","sql":"SELECT * FROM posts WHERE user_id = ?","count":10,"backtrace":["app/controllers/posts_controller.rb:12"]}
77
83
  [QueryOwl] {"type":"slow_query","sql":"SELECT * FROM reports WHERE ...","duration_ms":340}
84
+ [QueryOwl] {"type":"unused_eager_load","model":"Widget","association":"tags"}
85
+ [QueryOwl] Request complete — 10 N+1s, 1 slow query, 1 unused eager load
78
86
  ```
79
87
 
80
88
  [↑ Back to top](#table-of-contents)
@@ -116,15 +124,30 @@ QueryOwl::Logger.log_events(events)
116
124
  # => [QueryOwl] {"type":"slow_query","sql":"SELECT ...","duration_ms":...}
117
125
  ```
118
126
 
127
+ **Trigger unused eager load detection:**
128
+
129
+ ```ruby
130
+ QueryOwl.config.enabled = true
131
+ QueryOwl::EagerLoadTracker.start!
132
+ Widget.includes(:tags).map(&:name) # loads tags but never touches them
133
+ eager_data = QueryOwl::EagerLoadTracker.stop!
134
+ events = QueryOwl::Detector.detect_unused_eager_loads(eager_data)
135
+ QueryOwl::Logger.log_events(events)
136
+ # => [QueryOwl] {"type":"unused_eager_load","model":"Widget","association":"tags"}
137
+ ```
138
+
119
139
  **Full pipeline** (as it runs on every real HTTP request):
120
140
 
121
141
  ```ruby
122
142
  QueryOwl.config.slow_query_threshold_ms = 0
123
143
  QueryOwl::QueryTracker.start!
144
+ QueryOwl::EagerLoadTracker.start!
124
145
  Widget.all.each { |w| Widget.find(w.id) }
125
- queries = QueryOwl::QueryTracker.stop!
126
- events = QueryOwl::Detector.detect_n_plus_one(queries) +
127
- QueryOwl::Detector.detect_slow_queries(queries)
146
+ queries = QueryOwl::QueryTracker.stop!
147
+ eager_data = QueryOwl::EagerLoadTracker.stop!
148
+ events = QueryOwl::Detector.detect_n_plus_one(queries) +
149
+ QueryOwl::Detector.detect_slow_queries(queries) +
150
+ QueryOwl::Detector.detect_unused_eager_loads(eager_data)
128
151
  QueryOwl::Logger.log_events(events)
129
152
  ```
130
153
 
@@ -1,15 +1,20 @@
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
7
9
 
8
10
  def initialize
9
- @enabled = Rails.env.development?
11
+ @enabled = Rails.env.development?
10
12
  @slow_query_threshold_ms = 100
11
- @n_plus_one_threshold = 2
12
- @log_level = :warn
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
13
18
  end
14
19
 
15
20
  def log_level=(level)
@@ -19,5 +24,11 @@ module QueryOwl
19
24
 
20
25
  @log_level = level
21
26
  end
27
+
28
+ def backtrace_filter=(filter)
29
+ raise ArgumentError, "backtrace_filter must respond to #call" unless filter.respond_to?(:call)
30
+
31
+ @backtrace_filter = filter
32
+ end
22
33
  end
23
34
  end
@@ -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\d+\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
@@ -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
@@ -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)
@@ -4,15 +4,28 @@ 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 = QueryTracker.stop!
14
- events = Detector.detect_n_plus_one(queries) + Detector.detect_slow_queries(queries)
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
+ raise_on_n_plus_one!(events) if QueryOwl.config.raise_on_n_plus_one
16
29
  end
17
30
  end
18
31
  end
@@ -37,7 +37,9 @@ module QueryOwl
37
37
  private
38
38
 
39
39
  def filtered_backtrace
40
- caller.grep_v(%r{/gems/|/rubygems/|/ruby/gems/|lib/query_owl/}).first(5)
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
@@ -1,3 +1,3 @@
1
1
  module QueryOwl
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/query_owl.rb CHANGED
@@ -1,12 +1,15 @@
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"
4
5
  require "query_owl/detector"
5
6
  require "query_owl/logger"
6
7
  require "query_owl/middleware"
7
8
  require "query_owl/engine"
8
9
 
9
10
  module QueryOwl
11
+ class NPlusOneError < StandardError; end
12
+
10
13
  class << self
11
14
  def configure
12
15
  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.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -42,6 +42,7 @@ files:
42
42
  - lib/query_owl.rb
43
43
  - lib/query_owl/configuration.rb
44
44
  - lib/query_owl/detector.rb
45
+ - lib/query_owl/eager_load_tracker.rb
45
46
  - lib/query_owl/engine.rb
46
47
  - lib/query_owl/logger.rb
47
48
  - lib/query_owl/middleware.rb