tensorbuzz-api-rails 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: 46aa55521a8826c09ade79fb8d2810b89e91d28ba7834cd2228e13b3a477023d
4
+ data.tar.gz: bb4ed791180aa9f143ac5482d42fbb3ddd5868201164c300382c1a76266509d6
5
+ SHA512:
6
+ metadata.gz: 6ca55423d1157f06b20d16c81b31d0e079f114bdfe1ac27642656026c4fd99d7dccc4e3e52f943192b2c22d08cf8383ad72eb676856e6953becfd26768fd6c9f
7
+ data.tar.gz: 4e484cae7ad39af46a1b502d03a7b8e91eab7452270821e532f744369f448e6d18e252f7cccb16265b1a6f7dc05ffbb19364eeee52749e0afb990f388ad2e726
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kasper Stöckel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # tensorbuzz-api-rails for Ruby
2
+
3
+ Active Record slow-SQL reporting for TensorBuzz, built on the `tensorbuzz-api`
4
+ gem.
5
+
6
+ ## Installation
7
+
8
+ ```ruby
9
+ gem "tensorbuzz-api-rails"
10
+ ```
11
+
12
+ `tensorbuzz-api` is installed automatically; `activesupport` is a runtime
13
+ dependency of this gem.
14
+
15
+ ## Slow SQL
16
+
17
+ Queries over a threshold are reported to TensorBuzz as
18
+ `TensorBuzz::Api::SlowSql::ExceededThreshold`. The threshold defaults to 200 ms
19
+ and is set per process when the feature is enabled:
20
+
21
+ ```ruby
22
+ require "tensorbuzz/api/slow_sql"
23
+
24
+ TensorBuzz::SlowSql.enable(threshold_ms: 200)
25
+ ```
26
+
27
+ The report carries the full query text, duration, and the boolean `cached` and
28
+ `in_transaction` flags plus the connection adapter class name in
29
+ `parameters[:slow_sql]`, all derived from the real Active Record notification
30
+ payload, with a backtrace of the code path that issued the statement. Reports
31
+ for the same query at the same code path are suppressed for 30 seconds, so a
32
+ hot N+1 produces one report per window instead of one per statement.
33
+
34
+ Known-slow paths opt out with a thread-scoped silence block (per worker
35
+ thread — Rails workers handle one request, job, or cable connection at a time):
36
+
37
+ ```ruby
38
+ TensorBuzz::SlowSql.silence do
39
+ known_slow_path.call
40
+ end
41
+ ```
42
+
43
+ `disable` unsubscribes the listener for teardown and tests.
44
+
45
+ See the `tensorbuzz-api` README for bug reporting configuration.
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TensorBuzz
4
+ module Api
5
+ module Rails
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/notifications'
4
+ require 'tensorbuzz/api/bug_reporting'
5
+
6
+ module TensorBuzz
7
+ module Api
8
+ module SlowSql
9
+ DEFAULT_THRESHOLD_MS = 200
10
+ DEFAULT_WINDOW_SECONDS = 30
11
+ MAX_TRACKED_KEYS = 4096
12
+ SQL_MESSAGE_LENGTH = 500
13
+ SILENCED_COUNT_KEY = :__tensorbuzz_slow_sql_silenced_count
14
+
15
+ # Reported as a bug for every SQL statement over the threshold. The message
16
+ # is intentionally stable per query (no duration) so TensorBuzz groups the
17
+ # same slow query across instances and processes; the per-instance
18
+ # duration is in the payload.
19
+ class ExceededThreshold < RuntimeError
20
+ attr_reader :sql, :duration_ms
21
+
22
+ def initialize(sql, duration_ms)
23
+ @sql = sql.to_s
24
+ @duration_ms = duration_ms
25
+ super("Slow SQL query: #{truncated_message_sql(@sql)}")
26
+ end
27
+
28
+ private
29
+
30
+ def truncated_message_sql(sql)
31
+ return sql if sql.length <= SQL_MESSAGE_LENGTH
32
+
33
+ "#{sql[0...SQL_MESSAGE_LENGTH]}…"
34
+ end
35
+ end
36
+
37
+ # Receives `sql.active_record` notification events and reports those over
38
+ # the threshold through BugReporting.
39
+ class Listener
40
+ attr_reader :threshold_ms
41
+
42
+ def initialize(threshold_ms:)
43
+ @threshold_ms = threshold_ms
44
+ end
45
+
46
+ def call(event)
47
+ return if SlowSql.silenced?
48
+ return if event.duration <= threshold_ms
49
+
50
+ SlowSql.report(event)
51
+ end
52
+ end
53
+
54
+ # Installs the listener for all `sql.active_record` events. Idempotent;
55
+ # calls disable first so a changed threshold takes effect immediately.
56
+ def self.enable(threshold_ms: DEFAULT_THRESHOLD_MS)
57
+ disable
58
+ @listener = Listener.new(threshold_ms: threshold_ms)
59
+ state_mutex.synchronize { @tracked = {} }
60
+ ActiveSupport::Notifications.subscribe('sql.active_record', @listener)
61
+ end
62
+
63
+ # Unsubscribes the listener and clears dedupe state.
64
+ def self.disable
65
+ ActiveSupport::Notifications.unsubscribe(@listener) if @listener
66
+ @listener = nil
67
+ state_mutex.synchronize { @tracked = {} }
68
+ end
69
+
70
+ def self.enabled?
71
+ !@listener.nil?
72
+ end
73
+
74
+ # Suppresses reporting for the rest of this thread's execution while the
75
+ # block runs, including nested silences. Per-thread (not per-fiber)
76
+ # because this Ruby stores local variables only per thread; in a Rails
77
+ # worker (Puma request, Sidekiq job, ActionCable handler) that is the
78
+ # natural unit of work and a block never hands another task over on the
79
+ # same thread.
80
+ def self.silence
81
+ Thread.current[SILENCED_COUNT_KEY] = (Thread.current[SILENCED_COUNT_KEY] || 0) + 1
82
+ yield
83
+ ensure
84
+ Thread.current[SILENCED_COUNT_KEY] = (Thread.current[SILENCED_COUNT_KEY] || 0) - 1
85
+ end
86
+
87
+ def self.silenced?
88
+ (Thread.current[SILENCED_COUNT_KEY] || 0).positive?
89
+ end
90
+
91
+ # Returns true when a report for this key is allowed and records it, so a
92
+ # hot N+1 of the same slow query sends one report per window instead of
93
+ # one per statement. All access to the process-wide dedupe state is
94
+ # synchronized because Rails and Sidekiq emit events from multiple
95
+ # worker threads.
96
+ def self.reportable?(key)
97
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+ state_mutex.synchronize do
99
+ entry = @tracked[key]
100
+ return false if entry && now - entry[0] < DEFAULT_WINDOW_SECONDS
101
+
102
+ @tracked[key] = [now, 1]
103
+ evict_tracked(now)
104
+ true
105
+ end
106
+ end
107
+
108
+ def self.report(event)
109
+ payload = event.payload
110
+ sql = payload[:sql].to_s
111
+ return if sql.empty?
112
+
113
+ frames = caller
114
+ cleaner = defined?(::Rails) && Rails.respond_to?(:backtrace_cleaner) ? Rails.backtrace_cleaner : nil
115
+ backtrace = cleaner ? cleaner.clean(frames) : frames
116
+ location = backtrace.first
117
+
118
+ error = ExceededThreshold.new(sql, event.duration)
119
+ error.set_backtrace(backtrace)
120
+
121
+ # Dedupe on the complete SQL: error.message is truncated to
122
+ # SQL_MESSAGE_LENGTH for grouping, so distinct statements sharing a
123
+ # long prefix would otherwise collide on the same key.
124
+ return unless reportable?("#{sql}|#{location}")
125
+
126
+ BugReporting.safely_report(
127
+ error,
128
+ parameters: { slow_sql: slow_sql_parameters(event, sql) }
129
+ )
130
+ end
131
+
132
+ class << self
133
+ private
134
+
135
+ # Derives the reported metadata from the real Active Record
136
+ # notification payload: Active Record supplies the adapter as
137
+ # payload[:connection] and the user transaction as an object or nil,
138
+ # both reported as stable values rather than raw objects.
139
+ def slow_sql_parameters(event, sql)
140
+ payload = event.payload
141
+ {
142
+ duration_ms: event.duration,
143
+ sql: sql,
144
+ cached: payload[:cached] ? true : false,
145
+ in_transaction: payload[:transaction] ? true : false,
146
+ adapter: payload[:connection]&.class&.name
147
+ }
148
+ end
149
+
150
+ # @tracked is a process-wide hash shared by all worker threads.
151
+ def state_mutex
152
+ @state_mutex ||= Mutex.new
153
+ end
154
+
155
+ # Bounds the dedupe state: drop entries older than the window, and
156
+ # when every entry is still fresh evict the oldest ones so the hash
157
+ # never grows past MAX_TRACKED_KEYS.
158
+ def evict_tracked(now)
159
+ return if @tracked.size <= MAX_TRACKED_KEYS
160
+
161
+ cutoff = now - DEFAULT_WINDOW_SECONDS
162
+ @tracked.delete_if { |_, (reported_at, _)| reported_at <= cutoff }
163
+ return if @tracked.size <= MAX_TRACKED_KEYS
164
+
165
+ overflow = @tracked.size - MAX_TRACKED_KEYS
166
+ oldest = @tracked.sort_by { |_, (reported_at, _)| reported_at }.first(overflow)
167
+ oldest.each { |(key, _)| @tracked.delete(key) }
168
+ end
169
+ end
170
+ end
171
+ end
172
+ SlowSql = Api::SlowSql
173
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tensorbuzz-api-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kasper Stöckel
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: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: tensorbuzz-api
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.1.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 0.1.0
40
+ description: Subscribes to sql.active_record notification events and reports statements
41
+ over a threshold as TensorBuzz bugs.
42
+ email:
43
+ - k@spernj.org
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - LICENSE
49
+ - README.md
50
+ - lib/tensorbuzz/api/rails/version.rb
51
+ - lib/tensorbuzz/api/slow_sql.rb
52
+ homepage: https://github.com/kaspernj/tensorbuzz
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ rubygems_mfa_required: 'true'
57
+ source_code_uri: https://github.com/kaspernj/tensorbuzz/tree/master/tensorbuzz-api-rails
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.2'
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: Active Record slow-SQL reporting for TensorBuzz.
75
+ test_files: []