bugwatch-ruby 0.5.0 → 0.6.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: c4cb56c42da0cab5b2e855deca671231a744b1624b5fcbd6dbef365f30bc9d08
4
+ data.tar.gz: 92889ce7b6cec458ee2c75c15759756acbaa32981051704c9a3534a42e58fe1d
5
5
  SHA512:
6
- metadata.gz: 9895be8ecb3c5db57f8996b1d702f75da6658d8370a470b72b88b6b12dc5da8690461208991cd14980bb5a996b70a19854c6747e4f460b9a868a75b3da0aaae8
7
- data.tar.gz: a51dd3a8af6502533474624b1c1352661e0a5ce047b25d5f30dd693810cce7fd7532af83b7071a6987760a6bd61cce485fc4b6700c8f2b5f9d3d5366ef9a1ee2
6
+ metadata.gz: 479c753810d0c158b339281f7f7c5c083134bfe4321cfe7739a28d24d14fc3c82fb26e17330de1bc92f13e41e66e2726c4fe0b42b2d5110301c3410e566fc564
7
+ data.tar.gz: 6b2a55bf2cdd3a01fd82bd96a51c75f50fbbc48a373b8102c7c6a301ab748c75f7434084402d56cbf3b2009a838648205fa95c4706eb07655a423ef290f35d49
@@ -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,94 @@
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
+ state = Thread.current[THREAD_KEY]
40
+ return unless state
41
+
42
+ name = event.payload[:name].to_s
43
+ sql = event.payload[:sql].to_s
44
+ return if IGNORED_NAMES.include?(name)
45
+ return if sql.match?(IGNORED_SQL_PATTERNS)
46
+
47
+ duration_ms = event.duration
48
+
49
+ state[:total_db_ms] += duration_ms
50
+
51
+ return unless state[:collecting]
52
+
53
+ config = Bugwatch.configuration
54
+ return if duration_ms < config.db_query_threshold_ms
55
+ return if state[:queries].size >= config.max_queries_per_request
56
+
57
+ state[:queries] << {
58
+ sql: sanitize_sql(sql),
59
+ raw_sql: sql,
60
+ duration_ms: duration_ms.round(2),
61
+ name: name.presence,
62
+ operation: extract_operation(sql),
63
+ caller_location: extract_caller
64
+ }
65
+ rescue StandardError
66
+ # Never let tracking break the app
67
+ end
68
+
69
+ def sanitize_sql(sql)
70
+ s = sql.dup
71
+ s.gsub!(/'(?:[^'\\]|\\.)*'/, "?")
72
+ s.gsub!(/"(?:[^"\\]|\\.)*"/, "?") unless s.include?(".")
73
+ s.gsub!(/\b\d+(\.\d+)?\b/, "?")
74
+ s.gsub!(/\b(TRUE|FALSE|NULL)\b/i, "?")
75
+ s.gsub!(/IN\s*\(\s*\?(?:\s*,\s*\?)*\s*\)/i, "IN (?)")
76
+ s
77
+ end
78
+
79
+ def extract_operation(sql)
80
+ sql.strip.split(/\s/, 2).first&.upcase
81
+ end
82
+
83
+ def extract_caller
84
+ caller_locations(4, 30)&.each do |loc|
85
+ path = loc.path.to_s
86
+ next if path.match?(CALLER_FILTER)
87
+ return "#{loc.path}:#{loc.lineno}"
88
+ end
89
+ nil
90
+ end
91
+
92
+ private_class_method :handle_event, :sanitize_sql, :extract_operation, :extract_caller
93
+ end
94
+ end
@@ -9,6 +9,10 @@ module Bugwatch
9
9
  BreadcrumbCollector.clear
10
10
  UserContext.clear
11
11
 
12
+ config = Bugwatch.configuration
13
+ collecting = config.enable_db_tracking && (rand < config.db_sample_rate)
14
+ DbTracker.start_request(collecting: collecting)
15
+
12
16
  begin
13
17
  status, headers, body = @app.call(env)
14
18
  record_transaction(env, status, start)
@@ -24,6 +28,8 @@ module Bugwatch
24
28
 
25
29
  record_transaction(env, 500, start)
26
30
  raise
31
+ ensure
32
+ DbTracker.clear
27
33
  end
28
34
  end
29
35
 
@@ -35,6 +41,7 @@ module Bugwatch
35
41
  return unless config.track_request?(req.path)
36
42
 
37
43
  duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
44
+ db_result = DbTracker.finish_request
38
45
 
39
46
  payload = {
40
47
  name: "#{req.request_method} #{req.path}",
@@ -46,7 +53,18 @@ module Bugwatch
46
53
  occurred_at: Time.now.utc.iso8601
47
54
  }
48
55
 
56
+ payload[:db_duration_ms] = db_result[:total_db_ms].round(1) if db_result
57
+
49
58
  Bugwatch.transaction_buffer.push(payload)
59
+
60
+ if db_result && db_result[:queries].any?
61
+ Bugwatch.db_query_buffer.push({
62
+ transaction_name: payload[:name],
63
+ environment: config.release_stage,
64
+ occurred_at: payload[:occurred_at],
65
+ queries: db_result[:queries]
66
+ })
67
+ end
50
68
  rescue StandardError
51
69
  # Never let tracking break the app
52
70
  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.6.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"
@@ -120,7 +123,14 @@ module Bugwatch
120
123
  def transaction_buffer
121
124
  @transaction_buffer ||= TransactionBuffer.new(config: configuration)
122
125
  end
126
+
127
+ def db_query_buffer
128
+ @db_query_buffer ||= DbQueryBuffer.new(config: configuration)
129
+ end
123
130
  end
124
131
  end
125
132
 
126
- at_exit { Bugwatch.transaction_buffer.shutdown rescue nil }
133
+ at_exit do
134
+ Bugwatch.transaction_buffer.shutdown rescue nil
135
+ Bugwatch.db_query_buffer.shutdown rescue nil
136
+ 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.6.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