query_owl 0.2.0 → 0.4.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: 6a44534cc84c3ea8089418405a3291098f3c35fb6ee3cb6b67d1ebea3cce4a62
4
- data.tar.gz: 40272edc4665655ae1fda469819eab1158951fc4a75ea6e417132d5ae80c6103
3
+ metadata.gz: 9dd0e38c57b1b0c94a99a0ffb22f6ecc71f0e28c6d4d6d041dac57bc6e5bf626
4
+ data.tar.gz: dd6c94c94314dc38da3a757d0e4147dc349ae8842f10e0e29f9fa7fc2e0727ed
5
5
  SHA512:
6
- metadata.gz: 8f97a828e36d1fe5a8acee9e6dc42198895e12a92a63d02dfb0b91aa35541a5062d5e439ef4035b0ca486655c2e0ea90fbe5efd31eaa8eb93c3b7d26d3184cc6
7
- data.tar.gz: 943ad7e59234e171da3831b3b4bfbdd9f43668d413cbd17efba1803f4b225e198d7e047723c4c987c6b85720ad1fc13f1574a210b31c38e6e6f051cc6a3f7c1d
6
+ metadata.gz: be09a0350d981ffcd6e2b5b4e57825daf3d4aca7a0747d0f3579188df22ddc5f26babeab619c24e1d47c0e86ebda1dc1dce99ee65ce32cc01ff1cef1f997a95e
7
+ data.tar.gz: 6d499e2d7972b3252f6eccaae8e415f742e2e28b579c08d9e53f2c7377a998a6c5b7be1a46bc3f4189d353258fe7dc0dd01f5df29eb4986d7851c07ad1179fca
data/README.md CHANGED
@@ -14,6 +14,7 @@ A leaner alternative to Bullet. QueryOwl detects N+1 queries, slow queries, and
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)
@@ -67,6 +68,8 @@ QueryOwl.configure do |config|
67
68
  config.backtrace_lines = 5 # number of backtrace frames to capture
68
69
  config.backtrace_filter = ->(line) { line.start_with?("app/") } # optional custom filter
69
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
70
73
  end
71
74
  ```
72
75
 
@@ -89,6 +92,45 @@ When a problem is detected, QueryOwl writes a structured line to `Rails.logger`:
89
92
 
90
93
  ---
91
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
+ ]
128
+ ```
129
+
130
+ [↑ Back to top](#table-of-contents)
131
+
132
+ ---
133
+
92
134
  ## Manual Testing in the Dummy App
93
135
 
94
136
  The gem ships with a minimal Rails app in `spec/dummy/` for manual verification.
@@ -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,5 @@
1
+ .qo-header { margin-bottom: 1.5rem; }
2
+ .qo-header h1 { font-size: 1.4rem; font-weight: 600; }
3
+ .qo-header p { color: var(--muted); margin-top: 0.2rem; }
4
+
5
+ .qo-empty { color: var(--muted); font-style: italic; margin-top: 1rem; }
@@ -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,9 @@
1
+ module QueryOwl
2
+ module ApplicationHelper
3
+ def inline_styles
4
+ dir = QueryOwl::Engine.root.join("app/assets/stylesheets/query_owl")
5
+ css = dir.glob("_*.css").sort.map(&:read).join("\n")
6
+ content_tag(:style, css.html_safe)
7
+ end
8
+ end
9
+ 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,2 +1,3 @@
1
1
  QueryOwl::Engine.routes.draw do
2
+ get "slow_queries", to: "slow_queries#index"
2
3
  end
@@ -0,0 +1,15 @@
1
+ require "rails/generators"
2
+
3
+ module QueryOwl
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates a QueryOwl initializer in config/initializers."
9
+
10
+ def copy_initializer
11
+ template "initializer.rb", "config/initializers/query_owl.rb"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,42 @@
1
+ QueryOwl.configure do |config|
2
+ # Enable or disable all QueryOwl tracking.
3
+ # Defaults to true in development, false elsewhere.
4
+ # config.enabled = Rails.env.development?
5
+
6
+ # Flag N+1 queries when the same SQL pattern fires this many times per request.
7
+ # config.n_plus_one_threshold = 2
8
+
9
+ # Flag individual queries that take longer than this (in milliseconds).
10
+ # config.slow_query_threshold_ms = 100
11
+
12
+ # Log level for QueryOwl warnings (:debug, :info, or :warn).
13
+ # config.log_level = :warn
14
+
15
+ # Number of backtrace frames captured per query.
16
+ # config.backtrace_lines = 5
17
+
18
+ # Custom backtrace filter — a callable that receives a line and returns true to keep it.
19
+ # Defaults to stripping gem paths and QueryOwl internals.
20
+ # config.backtrace_filter = ->(line) { line.start_with?("app/") }
21
+
22
+ # Raise QueryOwl::NPlusOneError instead of logging when an N+1 is detected.
23
+ # Useful in CI test suites where silent warnings are easy to miss.
24
+ # config.raise_on_n_plus_one = false
25
+
26
+ # Maximum number of events retained in the in-memory ring buffer.
27
+ # config.event_store_size = 100
28
+
29
+ # Enable the HTML dashboard at GET /slow_queries (when the engine is mounted).
30
+ # Defaults to true in development, false elsewhere.
31
+ # config.dashboard_enabled = Rails.env.development?
32
+
33
+ # Append each detected event as a JSON line to this file path.
34
+ # Disabled by default (nil). Useful for persistence across restarts.
35
+ # config.log_file = Rails.root.join("log/query_owl.log").to_s
36
+
37
+ # Notifiers receive each detected event via #call(event).
38
+ # Defaults to [QueryOwl::Notifiers::Logger] which writes to Rails.logger.
39
+ # Use Console for TTY-aware colorized output (yellow: N+1, red: slow query).
40
+ # Use Stdout for non-request contexts (jobs, Rake tasks).
41
+ # config.notifiers = [QueryOwl::Notifiers::Console.new]
42
+ end
@@ -5,7 +5,15 @@ module QueryOwl
5
5
 
6
6
  attr_reader :log_level, :backtrace_filter
7
7
  attr_accessor :enabled, :slow_query_threshold_ms, :n_plus_one_threshold, :backtrace_lines,
8
- :raise_on_n_plus_one
8
+ :raise_on_n_plus_one, :event_store_size, :dashboard_enabled, :log_file
9
+
10
+ def notifiers
11
+ @notifiers ||= [Notifiers::Logger.new]
12
+ end
13
+
14
+ def notifiers=(arr)
15
+ @notifiers = arr
16
+ end
9
17
 
10
18
  def initialize
11
19
  @enabled = Rails.env.development?
@@ -15,6 +23,9 @@ module QueryOwl
15
23
  @backtrace_lines = 5
16
24
  @backtrace_filter = DEFAULT_BACKTRACE_FILTER
17
25
  @raise_on_n_plus_one = false
26
+ @event_store_size = 100
27
+ @dashboard_enabled = Rails.env.development?
28
+ @log_file = nil
18
29
  end
19
30
 
20
31
  def log_level=(level)
@@ -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
@@ -0,0 +1,24 @@
1
+ require "json"
2
+
3
+ module QueryOwl
4
+ class FileLogger
5
+ class << self
6
+ def append(events)
7
+ return if events.empty?
8
+
9
+ path = QueryOwl.config.log_file
10
+ return unless path
11
+
12
+ File.open(path, "a") do |f|
13
+ events.each { |e| f.puts(JSON.generate(serializable(e))) }
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def serializable(event)
20
+ event.transform_values { |v| v.is_a?(Symbol) ? v.to_s : v }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -18,13 +18,20 @@ module QueryOwl
18
18
  EagerLoadTracker.start!
19
19
  @app.call(env)
20
20
  ensure
21
- queries = QueryTracker.stop!
22
- eager_data = EagerLoadTracker.stop!
23
- events = Detector.detect_n_plus_one(queries) +
21
+ params = env["action_dispatch.request.path_parameters"] || {}
22
+ RequestContext.set(controller: params[:controller], action: params[:action], path: env["PATH_INFO"])
23
+ queries = QueryTracker.stop!
24
+ eager_data = EagerLoadTracker.stop!
25
+ context = RequestContext.current
26
+ RequestContext.clear
27
+ events = (Detector.detect_n_plus_one(queries) +
24
28
  Detector.detect_slow_queries(queries) +
25
- Detector.detect_unused_eager_loads(eager_data)
26
- Logger.log_events(events)
29
+ Detector.detect_unused_eager_loads(eager_data))
30
+ .map { |e| e.merge(context) }
31
+ events.each { |event| QueryOwl.config.notifiers.each { |notifier| notifier.call(event) } }
27
32
  Logger.log_summary(events)
33
+ events.each { |e| EventStore.push(e) }
34
+ FileLogger.append(events)
28
35
  raise_on_n_plus_one!(events) if QueryOwl.config.raise_on_n_plus_one
29
36
  end
30
37
  end
@@ -0,0 +1,38 @@
1
+ module QueryOwl
2
+ module Notifiers
3
+ class Console
4
+ YELLOW = "\e[33m"
5
+ RED = "\e[31m"
6
+ RESET = "\e[0m"
7
+
8
+ def call(event)
9
+ line = format(event)
10
+ line = apply_color(event[:type], line) if $stdout.tty?
11
+ $stdout.puts line
12
+ end
13
+
14
+ private
15
+
16
+ def format(event)
17
+ case event[:type]
18
+ when :n_plus_one
19
+ "#{QueryOwl::Logger::PREFIX} n_plus_one #{event[:sql]} ×#{event[:count]}"
20
+ when :slow_query
21
+ "#{QueryOwl::Logger::PREFIX} slow_query #{event[:sql]} #{event[:duration_ms]}ms"
22
+ when :unused_eager_load
23
+ "#{QueryOwl::Logger::PREFIX} unused_eager_load #{event[:model]}##{event[:association]}"
24
+ else
25
+ "#{QueryOwl::Logger::PREFIX} #{event[:type]}"
26
+ end
27
+ end
28
+
29
+ def apply_color(type, text)
30
+ case type
31
+ when :n_plus_one then "#{YELLOW}#{text}#{RESET}"
32
+ when :slow_query then "#{RED}#{text}#{RESET}"
33
+ else text
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,9 @@
1
+ module QueryOwl
2
+ module Notifiers
3
+ class Logger
4
+ def call(event)
5
+ ::Rails.logger.public_send(QueryOwl.config.log_level, "#{QueryOwl::Logger::PREFIX} #{event.to_json}")
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module QueryOwl
2
+ module Notifiers
3
+ class Stdout
4
+ def call(event)
5
+ $stdout.puts "#{QueryOwl::Logger::PREFIX} #{event.to_json}"
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ module QueryOwl
2
+ module RequestContext
3
+ class << self
4
+ def set(controller:, action:, path:)
5
+ Thread.current[:query_owl_request_context] = { controller: controller, action: action, path: path }
6
+ end
7
+
8
+ def current
9
+ Thread.current[:query_owl_request_context] || {}
10
+ end
11
+
12
+ def clear
13
+ Thread.current[:query_owl_request_context] = nil
14
+ end
15
+ end
16
+ end
17
+ end
@@ -1,3 +1,3 @@
1
1
  module QueryOwl
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/query_owl.rb CHANGED
@@ -2,8 +2,14 @@ require "query_owl/version"
2
2
  require "query_owl/configuration"
3
3
  require "query_owl/query_tracker"
4
4
  require "query_owl/eager_load_tracker"
5
+ require "query_owl/event_store"
5
6
  require "query_owl/detector"
6
7
  require "query_owl/logger"
8
+ require "query_owl/file_logger"
9
+ require "query_owl/notifiers/logger"
10
+ require "query_owl/notifiers/stdout"
11
+ require "query_owl/notifiers/console"
12
+ require "query_owl/request_context"
7
13
  require "query_owl/middleware"
8
14
  require "query_owl/engine"
9
15
 
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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -34,19 +34,35 @@ 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
50
+ - lib/generators/query_owl/install/install_generator.rb
51
+ - lib/generators/query_owl/install/templates/initializer.rb
42
52
  - lib/query_owl.rb
43
53
  - lib/query_owl/configuration.rb
44
54
  - lib/query_owl/detector.rb
45
55
  - lib/query_owl/eager_load_tracker.rb
46
56
  - lib/query_owl/engine.rb
57
+ - lib/query_owl/event_store.rb
58
+ - lib/query_owl/file_logger.rb
47
59
  - lib/query_owl/logger.rb
48
60
  - lib/query_owl/middleware.rb
61
+ - lib/query_owl/notifiers/console.rb
62
+ - lib/query_owl/notifiers/logger.rb
63
+ - lib/query_owl/notifiers/stdout.rb
49
64
  - lib/query_owl/query_tracker.rb
65
+ - lib/query_owl/request_context.rb
50
66
  - lib/query_owl/version.rb
51
67
  - lib/tasks/query_owl_tasks.rake
52
68
  homepage: https://github.com/eclectic-coding/query_owl