query_owl 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 45b067e5caa42b01df6cb84cce7541b77ee9bbee1f9f6d919ed554fb2386fd0e
4
+ data.tar.gz: 688d0b4f41edcf13e99bad7dbe6a4bdfe8ad40d458f908a06c2bc87b8ccbdbea
5
+ SHA512:
6
+ metadata.gz: 960d4332a40bd8531ccdda7b06d8e6a947b048c3c5f758fba374e62759668bbe43ae335e7d5dc4fc394785e2ea5fa6e13e1f7f8ca9c4e6f035f52b380815c982
7
+ data.tar.gz: 1764fbd57f9359a30866c2665a5698ede5bd4764fab90f10afa7984451086cc1d51fd8bf933828bfdff14a87631c753824b8cff7cd225a1474ca41b9efd5a0f3
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Chuck Smith
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # QueryOwl
2
+
3
+ [![CI](https://github.com/eclectic-coding/query_owl/actions/workflows/main.yml/badge.svg)](https://github.com/eclectic-coding/query_owl/actions/workflows/main.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/query_owl)](https://rubygems.org/gems/query_owl)
5
+ [![Downloads](https://img.shields.io/gem/dt/query_owl)](https://rubygems.org/gems/query_owl)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-ruby)](https://www.ruby-lang.org)
7
+ [![codecov](https://codecov.io/gh/eclectic-coding/query_owl/branch/main/graph/badge.svg)](https://codecov.io/gh/eclectic-coding/query_owl)
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.
10
+
11
+ ## Table of Contents
12
+
13
+ - [Features](#features)
14
+ - [Installation](#installation)
15
+ - [Configuration](#configuration)
16
+ - [Log Output](#log-output)
17
+ - [Manual Testing in the Dummy App](#manual-testing-in-the-dummy-app)
18
+ - [Roadmap](#roadmap)
19
+ - [Contributing](#contributing)
20
+ - [License](#license)
21
+
22
+ ---
23
+
24
+ ## Features
25
+
26
+ - **N+1 detection** — flags when the same SQL pattern fires 2+ times in a single request
27
+ - **Slow query detection** — flags queries exceeding a configurable threshold (default: 100ms)
28
+ - **Structured log output** — JSON-style warnings via `Rails.logger` with SQL, duration, count, and filtered backtrace
29
+ - **Zero overhead in production** — auto-enabled in development only
30
+
31
+ [↑ Back to top](#table-of-contents)
32
+
33
+ ---
34
+
35
+ ## Installation
36
+
37
+ Add to your `Gemfile`:
38
+
39
+ ```ruby
40
+ gem "query_owl"
41
+ ```
42
+
43
+ Then run:
44
+
45
+ ```sh
46
+ bundle install
47
+ ```
48
+
49
+ [↑ Back to top](#table-of-contents)
50
+
51
+ ---
52
+
53
+ ## Configuration
54
+
55
+ Create an initializer:
56
+
57
+ ```ruby
58
+ # config/initializers/query_owl.rb
59
+ QueryOwl.configure do |config|
60
+ config.enabled = Rails.env.development?
61
+ config.slow_query_threshold_ms = 100 # flag queries slower than this
62
+ config.n_plus_one_threshold = 2 # flag after this many repeated patterns
63
+ config.log_level = :warn # :warn | :info | :debug
64
+ end
65
+ ```
66
+
67
+ [↑ Back to top](#table-of-contents)
68
+
69
+ ---
70
+
71
+ ## Log Output
72
+
73
+ When a problem is detected, QueryOwl writes a structured line to `Rails.logger`:
74
+
75
+ ```
76
+ [QueryOwl] {"type":"n_plus_one","sql":"SELECT * FROM posts WHERE user_id = ?","count":10,"backtrace":["app/controllers/posts_controller.rb:12"]}
77
+ [QueryOwl] {"type":"slow_query","sql":"SELECT * FROM reports WHERE ...","duration_ms":340}
78
+ ```
79
+
80
+ [↑ Back to top](#table-of-contents)
81
+
82
+ ---
83
+
84
+ ## Manual Testing in the Dummy App
85
+
86
+ The gem ships with a minimal Rails app in `spec/dummy/` for manual verification.
87
+
88
+ **Start a console:**
89
+
90
+ ```sh
91
+ cd spec/dummy
92
+ RAILS_ENV=development bin/rails console
93
+ ```
94
+
95
+ **Trigger N+1 detection:**
96
+
97
+ ```ruby
98
+ QueryOwl.config.enabled = true
99
+ QueryOwl::QueryTracker.start!
100
+ Widget.all.each { |w| Widget.find(w.id) }
101
+ queries = QueryOwl::QueryTracker.stop!
102
+ events = QueryOwl::Detector.detect_n_plus_one(queries)
103
+ QueryOwl::Logger.log_events(events)
104
+ # => [QueryOwl] {"type":"n_plus_one","sql":"SELECT ...","count":3,...}
105
+ ```
106
+
107
+ **Trigger slow query detection:**
108
+
109
+ ```ruby
110
+ QueryOwl.config.slow_query_threshold_ms = 0 # flag everything
111
+ QueryOwl::QueryTracker.start!
112
+ Widget.all.to_a
113
+ queries = QueryOwl::QueryTracker.stop!
114
+ events = QueryOwl::Detector.detect_slow_queries(queries)
115
+ QueryOwl::Logger.log_events(events)
116
+ # => [QueryOwl] {"type":"slow_query","sql":"SELECT ...","duration_ms":...}
117
+ ```
118
+
119
+ **Full pipeline** (as it runs on every real HTTP request):
120
+
121
+ ```ruby
122
+ QueryOwl.config.slow_query_threshold_ms = 0
123
+ QueryOwl::QueryTracker.start!
124
+ 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)
128
+ QueryOwl::Logger.log_events(events)
129
+ ```
130
+
131
+ **Seed the dummy database first** (if needed):
132
+
133
+ ```sh
134
+ cd spec/dummy
135
+ RAILS_ENV=development bin/rails db:migrate
136
+ RAILS_ENV=development bin/rails runner "3.times { |i| Widget.create!(name: \"Widget #{i}\") }"
137
+ ```
138
+
139
+ [↑ Back to top](#table-of-contents)
140
+
141
+ ---
142
+
143
+ ## Roadmap
144
+
145
+ See [ROADMAP.md](ROADMAP.md) for planned releases, including unused eager load detection (0.2.0) and a `/rails/slow_queries` dashboard endpoint (0.3.0).
146
+
147
+ [↑ Back to top](#table-of-contents)
148
+
149
+ ---
150
+
151
+ ## Contributing
152
+
153
+ 1. Fork the repo and create a `feat/<name>` branch
154
+ 2. Write specs for your change
155
+ 3. Run `bundle exec rake` (lint + audit + tests) before opening a PR
156
+
157
+ [↑ Back to top](#table-of-contents)
158
+
159
+ ---
160
+
161
+ ## License
162
+
163
+ MIT — see [MIT-LICENSE](MIT-LICENSE).
164
+
165
+ [↑ Back to top](#table-of-contents)
data/Rakefile ADDED
@@ -0,0 +1,20 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
7
+ require 'rubocop/rake_task'
8
+ require 'bundler/audit/task'
9
+ require 'rspec/core/rake_task'
10
+
11
+ RuboCop::RakeTask.new(:lint)
12
+ Bundler::Audit::Task.new
13
+ RSpec::Core::RakeTask.new(:spec)
14
+
15
+ task default: [:lint, :'bundle:audit:update', 'bundle:audit:check', :spec]
16
+
17
+ desc "Run performance benchmarks"
18
+ task :benchmark do
19
+ ruby "benchmarks/benchmark.rb"
20
+ end
@@ -0,0 +1,4 @@
1
+ module QueryOwl
2
+ class ApplicationController < ActionController::API
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module QueryOwl
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module QueryOwl
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module QueryOwl
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ QueryOwl::Engine.routes.draw do
2
+ end
@@ -0,0 +1,23 @@
1
+ module QueryOwl
2
+ class Configuration
3
+ VALID_LOG_LEVELS = %i[debug info warn].freeze
4
+
5
+ attr_reader :log_level
6
+ attr_accessor :enabled, :slow_query_threshold_ms, :n_plus_one_threshold
7
+
8
+ def initialize
9
+ @enabled = Rails.env.development?
10
+ @slow_query_threshold_ms = 100
11
+ @n_plus_one_threshold = 2
12
+ @log_level = :warn
13
+ end
14
+
15
+ def log_level=(level)
16
+ unless VALID_LOG_LEVELS.include?(level)
17
+ raise ArgumentError, "log_level must be one of #{VALID_LOG_LEVELS.inspect}"
18
+ end
19
+
20
+ @log_level = level
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,52 @@
1
+ module QueryOwl
2
+ module Detector
3
+ # Matches numeric literals, single-quoted strings, and IN-list contents.
4
+ NORMALIZE_PATTERNS = [
5
+ [/'[^']*'/, "?"],
6
+ [/\b\d+\b/, "?"],
7
+ [/\s+/, " "]
8
+ ].freeze
9
+
10
+ class << self
11
+ def detect_n_plus_one(queries)
12
+ threshold = QueryOwl.config.n_plus_one_threshold
13
+
14
+ queries
15
+ .reject { |q| q[:cached] }
16
+ .group_by { |q| normalize(q[:sql]) }
17
+ .filter_map do |normalized_sql, group|
18
+ next if group.length < threshold
19
+
20
+ {
21
+ type: :n_plus_one,
22
+ sql: normalized_sql,
23
+ count: group.length,
24
+ backtrace: group.first[:backtrace]
25
+ }
26
+ end
27
+ end
28
+
29
+ def detect_slow_queries(queries)
30
+ threshold = QueryOwl.config.slow_query_threshold_ms
31
+
32
+ queries.filter_map do |q|
33
+ next if q[:cached]
34
+ next if q[:duration_ms] < threshold
35
+
36
+ {
37
+ type: :slow_query,
38
+ sql: normalize(q[:sql]),
39
+ duration_ms: q[:duration_ms],
40
+ backtrace: q[:backtrace]
41
+ }
42
+ end
43
+ end
44
+
45
+ def normalize(sql)
46
+ NORMALIZE_PATTERNS
47
+ .reduce(sql.to_s) { |s, (pattern, replacement)| s.gsub(pattern, replacement) }
48
+ .strip
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,19 @@
1
+ module QueryOwl
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace QueryOwl
4
+ config.generators.api_only = true
5
+
6
+ initializer "query_owl.subscribe" do
7
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
8
+ next unless QueryOwl.config.enabled
9
+
10
+ event = ActiveSupport::Notifications::Event.new(*args)
11
+ QueryTracker.record(event)
12
+ end
13
+ end
14
+
15
+ initializer "query_owl.request_tracking" do |app|
16
+ app.middleware.use(Middleware)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ require "json"
2
+
3
+ module QueryOwl
4
+ module Logger
5
+ PREFIX = "[QueryOwl]"
6
+
7
+ class << self
8
+ def log_events(events)
9
+ return if events.empty?
10
+
11
+ events.each { |event| write(event) }
12
+ end
13
+
14
+ private
15
+
16
+ def write(event)
17
+ Rails.logger.public_send(QueryOwl.config.log_level, "#{PREFIX} #{event.to_json}")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ module QueryOwl
2
+ class Middleware
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def call(env)
8
+ return @app.call(env) unless QueryOwl.config.enabled
9
+
10
+ QueryTracker.start!
11
+ @app.call(env)
12
+ ensure
13
+ queries = QueryTracker.stop!
14
+ events = Detector.detect_n_plus_one(queries) + Detector.detect_slow_queries(queries)
15
+ Logger.log_events(events)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,44 @@
1
+ module QueryOwl
2
+ module QueryTracker
3
+ IGNORED_PATTERNS = /^(SCHEMA|EXPLAIN|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i
4
+
5
+ class << self
6
+ def start!
7
+ Thread.current[:query_owl_queries] = []
8
+ end
9
+
10
+ def record(event)
11
+ return unless tracking?
12
+ return if event.payload[:name] == "SCHEMA"
13
+ return if event.payload[:sql].to_s.match?(IGNORED_PATTERNS)
14
+
15
+ queries << {
16
+ sql: event.payload[:sql],
17
+ duration_ms: event.duration.round(2),
18
+ cached: event.payload[:cached],
19
+ backtrace: filtered_backtrace
20
+ }
21
+ end
22
+
23
+ def queries
24
+ Thread.current[:query_owl_queries] ||= []
25
+ end
26
+
27
+ def stop!
28
+ collected = queries.dup
29
+ Thread.current[:query_owl_queries] = nil
30
+ collected
31
+ end
32
+
33
+ def tracking?
34
+ !Thread.current[:query_owl_queries].nil?
35
+ end
36
+
37
+ private
38
+
39
+ def filtered_backtrace
40
+ caller.grep_v(%r{/gems/|/rubygems/|/ruby/gems/|lib/query_owl/}).first(5)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,3 @@
1
+ module QueryOwl
2
+ VERSION = "0.1.0"
3
+ end
data/lib/query_owl.rb ADDED
@@ -0,0 +1,23 @@
1
+ require "query_owl/version"
2
+ require "query_owl/configuration"
3
+ require "query_owl/query_tracker"
4
+ require "query_owl/detector"
5
+ require "query_owl/logger"
6
+ require "query_owl/middleware"
7
+ require "query_owl/engine"
8
+
9
+ module QueryOwl
10
+ class << self
11
+ def configure
12
+ yield config
13
+ end
14
+
15
+ def config
16
+ @config ||= Configuration.new
17
+ end
18
+
19
+ def reset_config!
20
+ @config = Configuration.new
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :query_owl do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: query_owl
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Chuck Smith
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ description: A leaner alternative to Bullet. Detects N+1 queries and slow queries
27
+ in development, logging structured warnings to your Rails logger without the noise.
28
+ email:
29
+ - eclectic-coding@users.noreply.github.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - MIT-LICENSE
35
+ - README.md
36
+ - Rakefile
37
+ - app/controllers/query_owl/application_controller.rb
38
+ - app/jobs/query_owl/application_job.rb
39
+ - app/mailers/query_owl/application_mailer.rb
40
+ - app/models/query_owl/application_record.rb
41
+ - config/routes.rb
42
+ - lib/query_owl.rb
43
+ - lib/query_owl/configuration.rb
44
+ - lib/query_owl/detector.rb
45
+ - lib/query_owl/engine.rb
46
+ - lib/query_owl/logger.rb
47
+ - lib/query_owl/middleware.rb
48
+ - lib/query_owl/query_tracker.rb
49
+ - lib/query_owl/version.rb
50
+ - lib/tasks/query_owl_tasks.rake
51
+ homepage: https://github.com/eclectic-coding/query_owl
52
+ licenses:
53
+ - MIT
54
+ metadata:
55
+ homepage_uri: https://github.com/eclectic-coding/query_owl
56
+ source_code_uri: https://github.com/eclectic-coding/query_owl
57
+ changelog_uri: https://github.com/eclectic-coding/query_owl/blob/main/CHANGELOG.md
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '3.3'
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 3.6.9
73
+ specification_version: 4
74
+ summary: Structured N+1 and slow query warnings for Rails development.
75
+ test_files: []