bugwatch-ruby 0.5.0 → 0.7.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: 21032d38b9ffee6bf1dbef68e9ce7c39b56c28b0eb2369d76bdf684b4ed25df3
4
- data.tar.gz: 052521dd770495c7bd271313788d71e9b1d0fd8dede69f1d2fa86603bd25eccc
3
+ metadata.gz: 6d6a47f1c5176ec17f87160b5ab4a273e6ef68e76dee98f6e60ee5da474a61fd
4
+ data.tar.gz: 713ab0b06839f57c473a98c8f3e1cd46f2f43b2c9ccfef2b18d7ba0e3abb1076
5
5
  SHA512:
6
- metadata.gz: 9895be8ecb3c5db57f8996b1d702f75da6658d8370a470b72b88b6b12dc5da8690461208991cd14980bb5a996b70a19854c6747e4f460b9a868a75b3da0aaae8
7
- data.tar.gz: a51dd3a8af6502533474624b1c1352661e0a5ce047b25d5f30dd693810cce7fd7532af83b7071a6987760a6bd61cce485fc4b6700c8f2b5f9d3d5366ef9a1ee2
6
+ metadata.gz: cb255c2fcfe9562b06320de183bc1ee141f394eacb327eb6ad71739a662aaeb435f5da3248404a7074b45e6a397612d86fe8d243918284196cb7e3892c193f8a
7
+ data.tar.gz: 9ce11bcc466b319298cb788f51cdb1c89f00f6b3126189d02c9a7838eeb6f087291bb0864bc5115734849a1af966f2995385a627a057befbf815c63354e122ee
data/README.md CHANGED
@@ -181,6 +181,55 @@ Bugwatch.send_feedback(
181
181
  )
182
182
  ```
183
183
 
184
+ ## Database query tracking
185
+
186
+ The gem automatically subscribes to `sql.active_record` notifications and reports query performance data to your BugWatch instance. DB tracking is **enabled by default** — no extra setup required.
187
+
188
+ ### Configuration
189
+
190
+ ```ruby
191
+ # config/initializers/bugwatch.rb
192
+ Bugwatch.configure do |c|
193
+ # ... existing config ...
194
+
195
+ c.enable_db_tracking = true # default: true — set false to disable
196
+ c.db_sample_rate = 1.0 # 0.0–1.0 — fraction of requests to track (default: 1.0)
197
+ c.db_query_threshold_ms = 0 # only collect queries slower than this (default: 0 = all)
198
+ c.max_queries_per_request = 200 # cap per request to limit overhead (default: 200)
199
+ end
200
+ ```
201
+
202
+ | Option | Default | Description |
203
+ |--------|---------|-------------|
204
+ | `enable_db_tracking` | `true` | Master switch for DB tracking |
205
+ | `db_sample_rate` | `1.0` | Fraction of requests whose queries are collected (0.0–1.0) |
206
+ | `db_query_threshold_ms` | `0` | Minimum query duration (ms) to collect — set higher to focus on slow queries |
207
+ | `max_queries_per_request` | `200` | Maximum queries stored per request |
208
+
209
+ Schema operations and transaction bookkeeping (`BEGIN`, `COMMIT`, `ROLLBACK`) are automatically excluded. SQL literals are sanitized before sending, so no user data leaves your app.
210
+
211
+ ### Skipping tracking
212
+
213
+ Wrap any code block with `Bugwatch.without_tracking` to suppress all performance tracking (DB queries and transaction recording) for queries executed inside it. This is useful when BugWatch monitors itself, or for any internal bookkeeping queries you don't want tracked.
214
+
215
+ ```ruby
216
+ Bugwatch.without_tracking do
217
+ SomeModel.insert_all(records) # not tracked
218
+ end
219
+ ```
220
+
221
+ In a controller:
222
+
223
+ ```ruby
224
+ around_action :skip_tracking
225
+
226
+ private
227
+
228
+ def skip_tracking(&block)
229
+ Bugwatch.without_tracking(&block)
230
+ end
231
+ ```
232
+
184
233
  ## How it works
185
234
 
186
235
  1. `Bugwatch::Middleware` wraps your entire Rack stack.
@@ -9,7 +9,11 @@ module Bugwatch
9
9
  :logger,
10
10
  :enable_performance_tracking,
11
11
  :sample_rate,
12
- :ignore_request_paths
12
+ :ignore_request_paths,
13
+ :enable_db_tracking,
14
+ :db_sample_rate,
15
+ :db_query_threshold_ms,
16
+ :max_queries_per_request
13
17
 
14
18
  def initialize
15
19
  @endpoint = nil
@@ -20,6 +24,10 @@ module Bugwatch
20
24
  @enable_performance_tracking = true
21
25
  @sample_rate = 1.0
22
26
  @ignore_request_paths = []
27
+ @enable_db_tracking = true
28
+ @db_sample_rate = 1.0
29
+ @db_query_threshold_ms = 0.0
30
+ @max_queries_per_request = 200
23
31
  end
24
32
 
25
33
  def notify_for_release_stage?
@@ -0,0 +1,61 @@
1
+ module Bugwatch
2
+ class DbQueryBuffer
3
+ BATCH_SIZE = 50
4
+ FLUSH_INTERVAL = 15 # seconds
5
+
6
+ def initialize(config: Bugwatch.configuration)
7
+ @config = config
8
+ @sender = DbQuerySender.new(config: config)
9
+ @mutex = Mutex.new
10
+ @buffer = []
11
+ start_flusher
12
+ end
13
+
14
+ def push(payload)
15
+ should_flush = false
16
+
17
+ @mutex.synchronize do
18
+ @buffer << payload
19
+ should_flush = @buffer.size >= BATCH_SIZE
20
+ end
21
+
22
+ flush if should_flush
23
+ end
24
+
25
+ def flush
26
+ batch = @mutex.synchronize do
27
+ items = @buffer
28
+ @buffer = []
29
+ items
30
+ end
31
+
32
+ @sender.send_batch(batch) unless batch.empty?
33
+ rescue StandardError
34
+ # Never let flushing break the app
35
+ end
36
+
37
+ def shutdown
38
+ stop_flusher
39
+ flush
40
+ end
41
+
42
+ private
43
+
44
+ def start_flusher
45
+ @flusher = Thread.new do
46
+ loop do
47
+ sleep FLUSH_INTERVAL
48
+ flush
49
+ end
50
+ rescue StandardError
51
+ # Silently handle thread errors
52
+ end
53
+ @flusher.abort_on_exception = false
54
+ @flusher.daemon = true if @flusher.respond_to?(:daemon=)
55
+ end
56
+
57
+ def stop_flusher
58
+ @flusher&.kill
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,66 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module Bugwatch
6
+ class DbQuerySender
7
+ TIMEOUT = 3
8
+
9
+ def initialize(config: Bugwatch.configuration)
10
+ @config = config
11
+ end
12
+
13
+ def send_batch(grouped_payloads)
14
+ return if grouped_payloads.empty?
15
+ return unless @config.api_key
16
+ return unless @config.endpoint
17
+
18
+ Thread.new do
19
+ post_batch(grouped_payloads)
20
+ rescue StandardError
21
+ # Fire-and-forget: swallow all errors
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def post_batch(grouped_payloads)
28
+ records = grouped_payloads.flat_map do |group|
29
+ group[:queries].map do |q|
30
+ {
31
+ transaction_name: group[:transaction_name],
32
+ environment: group[:environment],
33
+ occurred_at: group[:occurred_at],
34
+ sql: q[:sql],
35
+ raw_sql: q[:raw_sql],
36
+ duration_ms: q[:duration_ms],
37
+ name: q[:name],
38
+ operation: q[:operation],
39
+ caller_location: q[:caller_location]
40
+ }
41
+ end
42
+ end
43
+
44
+ return if records.empty?
45
+
46
+ uri = URI.parse("#{@config.endpoint.chomp("/")}/api/v1/db_queries/batch")
47
+
48
+ http = Net::HTTP.new(uri.host, uri.port)
49
+ http.use_ssl = uri.scheme == "https"
50
+ http.open_timeout = TIMEOUT
51
+ http.read_timeout = TIMEOUT
52
+ http.write_timeout = TIMEOUT
53
+
54
+ request = Net::HTTP::Post.new(uri.path)
55
+ request["Content-Type"] = "application/json"
56
+ request["X-Api-Key"] = @config.api_key
57
+ request["X-BugWatch-Ruby"] = Bugwatch::VERSION
58
+
59
+ request.body = JSON.generate({ queries: records })
60
+
61
+ http.request(request)
62
+ rescue StandardError
63
+ # Silently discard network errors
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,96 @@
1
+ require "active_support/notifications"
2
+
3
+ module Bugwatch
4
+ module DbTracker
5
+ THREAD_KEY = :bugwatch_db_tracker
6
+ IGNORED_NAMES = %w[SCHEMA EXPLAIN].freeze
7
+ IGNORED_SQL_PATTERNS = /\A\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE SAVEPOINT)/i
8
+
9
+ CALLER_FILTER = %r{/(active_record|bugwatch|ruby/gems)/}
10
+
11
+ module_function
12
+
13
+ def subscribe!
14
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
15
+ event = ActiveSupport::Notifications::Event.new(*args)
16
+ handle_event(event)
17
+ end
18
+ end
19
+
20
+ def start_request(collecting:)
21
+ Thread.current[THREAD_KEY] = {
22
+ queries: [],
23
+ total_db_ms: 0.0,
24
+ collecting: collecting
25
+ }
26
+ end
27
+
28
+ def finish_request
29
+ state = Thread.current[THREAD_KEY]
30
+ Thread.current[THREAD_KEY] = nil
31
+ state
32
+ end
33
+
34
+ def clear
35
+ Thread.current[THREAD_KEY] = nil
36
+ end
37
+
38
+ def handle_event(event)
39
+ return if Bugwatch.skip_tracking?
40
+
41
+ state = Thread.current[THREAD_KEY]
42
+ return unless state
43
+
44
+ name = event.payload[:name].to_s
45
+ sql = event.payload[:sql].to_s
46
+ return if IGNORED_NAMES.include?(name)
47
+ return if sql.match?(IGNORED_SQL_PATTERNS)
48
+
49
+ duration_ms = event.duration
50
+
51
+ state[:total_db_ms] += duration_ms
52
+
53
+ return unless state[:collecting]
54
+
55
+ config = Bugwatch.configuration
56
+ return if duration_ms < config.db_query_threshold_ms
57
+ return if state[:queries].size >= config.max_queries_per_request
58
+
59
+ state[:queries] << {
60
+ sql: sanitize_sql(sql),
61
+ raw_sql: sql,
62
+ duration_ms: duration_ms.round(2),
63
+ name: name.presence,
64
+ operation: extract_operation(sql),
65
+ caller_location: extract_caller
66
+ }
67
+ rescue StandardError
68
+ # Never let tracking break the app
69
+ end
70
+
71
+ def sanitize_sql(sql)
72
+ s = sql.dup
73
+ s.gsub!(/'(?:[^'\\]|\\.)*'/, "?")
74
+ s.gsub!(/"(?:[^"\\]|\\.)*"/, "?") unless s.include?(".")
75
+ s.gsub!(/\b\d+(\.\d+)?\b/, "?")
76
+ s.gsub!(/\b(TRUE|FALSE|NULL)\b/i, "?")
77
+ s.gsub!(/IN\s*\(\s*\?(?:\s*,\s*\?)*\s*\)/i, "IN (?)")
78
+ s
79
+ end
80
+
81
+ def extract_operation(sql)
82
+ sql.strip.split(/\s/, 2).first&.upcase
83
+ end
84
+
85
+ def extract_caller
86
+ caller_locations(4, 30)&.each do |loc|
87
+ path = loc.path.to_s
88
+ next if path.match?(CALLER_FILTER)
89
+ return "#{loc.path}:#{loc.lineno}"
90
+ end
91
+ nil
92
+ end
93
+
94
+ private_class_method :handle_event, :sanitize_sql, :extract_operation, :extract_caller
95
+ end
96
+ end
@@ -5,10 +5,16 @@ module Bugwatch
5
5
  end
6
6
 
7
7
  def call(env)
8
+ return @app.call(env) if Bugwatch.skip_tracking?
9
+
8
10
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
9
11
  BreadcrumbCollector.clear
10
12
  UserContext.clear
11
13
 
14
+ config = Bugwatch.configuration
15
+ collecting = config.enable_db_tracking && (rand < config.db_sample_rate)
16
+ DbTracker.start_request(collecting: collecting)
17
+
12
18
  begin
13
19
  status, headers, body = @app.call(env)
14
20
  record_transaction(env, status, start)
@@ -24,6 +30,8 @@ module Bugwatch
24
30
 
25
31
  record_transaction(env, 500, start)
26
32
  raise
33
+ ensure
34
+ DbTracker.clear
27
35
  end
28
36
  end
29
37
 
@@ -35,6 +43,7 @@ module Bugwatch
35
43
  return unless config.track_request?(req.path)
36
44
 
37
45
  duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
46
+ db_result = DbTracker.finish_request
38
47
 
39
48
  payload = {
40
49
  name: "#{req.request_method} #{req.path}",
@@ -46,7 +55,18 @@ module Bugwatch
46
55
  occurred_at: Time.now.utc.iso8601
47
56
  }
48
57
 
58
+ payload[:db_duration_ms] = db_result[:total_db_ms].round(1) if db_result
59
+
49
60
  Bugwatch.transaction_buffer.push(payload)
61
+
62
+ if db_result && db_result[:queries].any?
63
+ Bugwatch.db_query_buffer.push({
64
+ transaction_name: payload[:name],
65
+ environment: config.release_stage,
66
+ occurred_at: payload[:occurred_at],
67
+ queries: db_result[:queries]
68
+ })
69
+ end
50
70
  rescue StandardError
51
71
  # Never let tracking break the app
52
72
  end
@@ -25,6 +25,12 @@ module Bugwatch
25
25
  end
26
26
  end
27
27
 
28
+ initializer "bugwatch.db_tracking" do
29
+ ActiveSupport.on_load(:active_record) do
30
+ Bugwatch::DbTracker.subscribe! if Bugwatch.configuration.enable_db_tracking
31
+ end
32
+ end
33
+
28
34
  initializer "bugwatch.error_subscriber" do
29
35
  if defined?(Rails.error) && Rails.error.respond_to?(:subscribe)
30
36
  Rails.error.subscribe(Bugwatch::ErrorSubscriber.new)
@@ -1,3 +1,3 @@
1
1
  module Bugwatch
2
- VERSION = "0.5.0"
2
+ VERSION = "0.7.0"
3
3
  end
data/lib/bugwatch.rb CHANGED
@@ -11,6 +11,9 @@ require_relative "bugwatch/report_builder"
11
11
  require_relative "bugwatch/notification"
12
12
  require_relative "bugwatch/transaction_sender"
13
13
  require_relative "bugwatch/transaction_buffer"
14
+ require_relative "bugwatch/db_tracker"
15
+ require_relative "bugwatch/db_query_sender"
16
+ require_relative "bugwatch/db_query_buffer"
14
17
  require_relative "bugwatch/feedback_sender"
15
18
  require_relative "bugwatch/feedback_helper"
16
19
  require_relative "bugwatch/middleware"
@@ -117,10 +120,28 @@ module Bugwatch
117
120
  end
118
121
  end
119
122
 
123
+ def without_tracking
124
+ Thread.current[:bugwatch_skip_tracking] = true
125
+ yield
126
+ ensure
127
+ Thread.current[:bugwatch_skip_tracking] = false
128
+ end
129
+
130
+ def skip_tracking?
131
+ Thread.current[:bugwatch_skip_tracking] == true
132
+ end
133
+
120
134
  def transaction_buffer
121
135
  @transaction_buffer ||= TransactionBuffer.new(config: configuration)
122
136
  end
137
+
138
+ def db_query_buffer
139
+ @db_query_buffer ||= DbQueryBuffer.new(config: configuration)
140
+ end
123
141
  end
124
142
  end
125
143
 
126
- at_exit { Bugwatch.transaction_buffer.shutdown rescue nil }
144
+ at_exit do
145
+ Bugwatch.transaction_buffer.shutdown rescue nil
146
+ Bugwatch.db_query_buffer.shutdown rescue nil
147
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bugwatch-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - BugWatch
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-04-13 00:00:00.000000000 Z
10
+ date: 2026-04-14 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties
@@ -52,6 +52,9 @@ files:
52
52
  - lib/bugwatch/backtrace_cleaner.rb
53
53
  - lib/bugwatch/breadcrumb_collector.rb
54
54
  - lib/bugwatch/configuration.rb
55
+ - lib/bugwatch/db_query_buffer.rb
56
+ - lib/bugwatch/db_query_sender.rb
57
+ - lib/bugwatch/db_tracker.rb
55
58
  - lib/bugwatch/error_builder.rb
56
59
  - lib/bugwatch/error_subscriber.rb
57
60
  - lib/bugwatch/feedback_helper.rb