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 +7 -0
- data/LICENSE +21 -0
- data/README.md +45 -0
- data/lib/tensorbuzz/api/rails/version.rb +9 -0
- data/lib/tensorbuzz/api/slow_sql.rb +173 -0
- metadata +75 -0
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,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: []
|