txray 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/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +274 -0
- data/Rakefile +9 -0
- data/exe/txray +6 -0
- data/lib/txray/analyzer.rb +109 -0
- data/lib/txray/catalog.rb +89 -0
- data/lib/txray/classifier.rb +102 -0
- data/lib/txray/cli.rb +98 -0
- data/lib/txray/client_index.rb +119 -0
- data/lib/txray/config.rb +75 -0
- data/lib/txray/error.rb +5 -0
- data/lib/txray/method_index.rb +124 -0
- data/lib/txray/monitor.rb +68 -0
- data/lib/txray/node_helpers.rb +62 -0
- data/lib/txray/offense.rb +49 -0
- data/lib/txray/railtie.rb +26 -0
- data/lib/txray/reporters/github.rb +21 -0
- data/lib/txray/reporters/json.rb +22 -0
- data/lib/txray/reporters/live.rb +179 -0
- data/lib/txray/reporters/sarif.rb +52 -0
- data/lib/txray/reporters/text.rb +47 -0
- data/lib/txray/reporters.rb +18 -0
- data/lib/txray/rule.rb +113 -0
- data/lib/txray/runtime/sink.rb +40 -0
- data/lib/txray/runtime/transaction.rb +49 -0
- data/lib/txray/runtime.rb +197 -0
- data/lib/txray/scanner.rb +70 -0
- data/lib/txray/source_file.rb +43 -0
- data/lib/txray/tail.rb +43 -0
- data/lib/txray/tasks.rake +9 -0
- data/lib/txray/transaction_scope.rb +125 -0
- data/lib/txray/version.rb +5 -0
- data/lib/txray/watch_command.rb +43 -0
- data/lib/txray.rb +37 -0
- metadata +100 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f735a624f2fabb3ab9d3b2133c2b7793d6c836e730a6d71a3a5d21217f49e3d9
|
|
4
|
+
data.tar.gz: c76ae3d2e4aa426361fc0b2a834c76d5e33e910af92e541eaecacbe80ea462e9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a10c45fade77e209f9c5bdc85bcb4453629ad9a2acf63d0067ff16a5a958512a6e4c31f9e1e54aedb60660e9c021d2513fe17b990aeb254ca607347138b73135
|
|
7
|
+
data.tar.gz: 27f2cdcd1bfaceb62be8ebc3ffedca7f90cf53104fc1f25a79af81ef48051d7e8e2d71649d354a184207ca4c7f347c4316fdd6a6795a1ea57442e5b68a35871d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Static transaction analysis over explicit transactions, row and advisory locks, save callbacks, custom validations and migrations.
|
|
6
|
+
- Follows callbacks, concerns, service objects, `delegate` targets and `define_method` bodies across files.
|
|
7
|
+
- Tracks clients held in local variables, instance variables, constants and memoized readers.
|
|
8
|
+
- Text, json, sarif and github reporters, inline `# txray:disable` directives and configurable severities.
|
|
9
|
+
- Optional runtime guard with per-transaction attribution, an ignore API and a newline delimited JSON event log.
|
|
10
|
+
- `txray watch`, a live terminal monitor for transactions, durations and findings.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Theo Wecker
|
|
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,274 @@
|
|
|
1
|
+
# txray
|
|
2
|
+
|
|
3
|
+
Static analysis that finds slow work hidden inside database transactions, plus a runtime guard and a live terminal monitor for what static analysis cannot see.
|
|
4
|
+
|
|
5
|
+
A transaction holds a connection and every row lock it has taken until it commits. Anything slow that happens in between (an HTTP request, a Stripe call, an SMTP delivery, a subprocess, a loop over a collection) extends that hold, and every writer queued behind those locks waits with it. The worst cases are the ones nobody wrote on purpose: an `after_create` callback three method calls away from a payment API.
|
|
6
|
+
|
|
7
|
+
txray parses your application with [Prism](https://github.com/ruby/prism) and follows callbacks, concerns, service objects and helper methods to find that work. It never boots your app and never runs your code, so it reports problems on paths your test suite has never executed.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
app/models/order.rb
|
|
11
|
+
13:5 high external-service-in-transaction
|
|
12
|
+
External service call `Stripe::PaymentIntent.create(amount: total_cents)` runs inside the `after_create :settle` callback
|
|
13
|
+
via Order#charge_card (app/models/order.rb:12)
|
|
14
|
+
Third party clients hold the connection and the row locks for their full round trip. Call them after commit.
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
group :development, :test do
|
|
21
|
+
gem "txray", require: false
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
bundle exec txray
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
It exits non zero when it finds something, so it drops straight into CI.
|
|
30
|
+
|
|
31
|
+
## What counts as a transaction
|
|
32
|
+
|
|
33
|
+
txray does not only look for `transaction do`:
|
|
34
|
+
|
|
35
|
+
| Scope | Example |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| Explicit blocks | `Order.transaction { ... }`, `ActiveRecord::Base.transaction { ... }` |
|
|
38
|
+
| Row and advisory locks | `order.with_lock { ... }`, `with_advisory_lock { ... }`, any method that calls `lock!` |
|
|
39
|
+
| Callbacks that run inside the save transaction | `before_save`, `after_create`, `around_update`, `after_destroy`, `after_touch`, `before_commit` |
|
|
40
|
+
| Custom validations | `validate :vat_number_is_real`, `validate { ... }` |
|
|
41
|
+
| Migrations | `change`, `up` and `down` in an `ActiveRecord::Migration`, unless it calls `disable_ddl_transaction!` |
|
|
42
|
+
|
|
43
|
+
`after_commit`, `after_create_commit`, `after_rollback` and the rest of the commit callbacks run outside the transaction, so txray deliberately leaves them alone. That distinction is the whole point: moving a call from `after_create` to `after_commit` is usually the fix.
|
|
44
|
+
|
|
45
|
+
Two of these are easy to forget. Validations run inside the transaction `save` opens, so an API call in a custom validator holds the connection exactly like one in `before_save` (the `geocoder` gem's own README suggests `after_validation :geocode`, which is a network call inside your save transaction). And a migration body runs in a DDL transaction, so a data backfill that loops over a large table holds it for the length of the backfill.
|
|
46
|
+
|
|
47
|
+
## What it looks for
|
|
48
|
+
|
|
49
|
+
| Rule | Severity |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `http-in-transaction` | high |
|
|
52
|
+
| `external-service-in-transaction` | high |
|
|
53
|
+
| `mail-in-transaction` | high |
|
|
54
|
+
| `shell-in-transaction` | high |
|
|
55
|
+
| `sleep-in-transaction` | high |
|
|
56
|
+
| `job-enqueue-in-transaction` | medium |
|
|
57
|
+
| `broadcast-in-transaction` | medium |
|
|
58
|
+
| `upload-in-transaction` | medium |
|
|
59
|
+
| `iteration-in-transaction` | medium |
|
|
60
|
+
| `blocking-io-in-transaction` | medium |
|
|
61
|
+
| `cache-in-transaction` | low |
|
|
62
|
+
| `dynamic-dispatch-in-transaction` | low |
|
|
63
|
+
|
|
64
|
+
`bundle exec txray --rules` prints the list with the suggested fix for each.
|
|
65
|
+
|
|
66
|
+
## Following the call
|
|
67
|
+
|
|
68
|
+
The interesting offenders are rarely in the transaction block itself:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
class Order < ApplicationRecord
|
|
72
|
+
include Notifiable
|
|
73
|
+
|
|
74
|
+
after_create :settle
|
|
75
|
+
|
|
76
|
+
def settle
|
|
77
|
+
charge_card
|
|
78
|
+
notify_downstream
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def charge_card
|
|
82
|
+
Stripe::PaymentIntent.create(amount: total_cents)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
txray resolves `after_create :settle` to `Order#settle`, follows `charge_card` into the same class and `notify_downstream` into the `Notifiable` concern in another file, and reports both calls with the path it took to reach them.
|
|
88
|
+
|
|
89
|
+
It follows bare calls in the same class, `self.` calls into class methods, `Constant.method` calls into another class, and `Constant.new(...).method` into a service object. A transaction that only says `Checkout.new(order).call` is still traced to the Stripe call three files away. `--depth` controls how far it follows (three levels by default).
|
|
90
|
+
|
|
91
|
+
## Clients held in variables
|
|
92
|
+
|
|
93
|
+
Most real client calls never name a risky constant:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class Gateway
|
|
97
|
+
def charge(order)
|
|
98
|
+
ApplicationRecord.transaction { client.post("/charges", order.to_json) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def client = @client ||= Faraday.new(url: ENV["API"])
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Building a client marks the binding rather than reporting it, and a call on a marked binding is the offense. txray tracks clients through local variables, instance variables, constants, memoized reader methods and `delegate` targets, and reports the request (`client.post`) rather than the harmless construction. Chained calls like `Twilio::REST::Client.new(sid, token).messages.create(...)` are reported once, at the outermost call.
|
|
106
|
+
|
|
107
|
+
Add your own wrappers with `external_clients` in the config and they are treated the same way.
|
|
108
|
+
|
|
109
|
+
## Metaprogramming
|
|
110
|
+
|
|
111
|
+
txray resolves what it can and is explicit about what it cannot:
|
|
112
|
+
|
|
113
|
+
- `send`/`public_send` with a symbol literal is followed like a direct call
|
|
114
|
+
- methods built with `define_method` are indexed and followed
|
|
115
|
+
- callbacks registered from a concern's `included do` block resolve, including when the method lives on the host class
|
|
116
|
+
- `delegate :charge, to: :gateway` follows through to the target
|
|
117
|
+
|
|
118
|
+
When the target genuinely cannot be resolved (`send("#{provider}_charge")`), txray reports `dynamic-dispatch-in-transaction` at low severity rather than passing silently, so the blind spot is visible instead of invisible.
|
|
119
|
+
|
|
120
|
+
What static analysis cannot see: a client passed in as an argument, dispatch through `method_missing`, and work buried inside a gem. That is what the runtime guard is for.
|
|
121
|
+
|
|
122
|
+
## Configuration
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
bundle exec txray --init
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`.txray.yml` is discovered by walking up from the working directory, so it keeps working from subdirectories and from an editor integration.
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
include:
|
|
132
|
+
- app
|
|
133
|
+
- lib
|
|
134
|
+
- db/migrate
|
|
135
|
+
exclude:
|
|
136
|
+
- spec
|
|
137
|
+
- test
|
|
138
|
+
- vendor
|
|
139
|
+
max_depth: 3
|
|
140
|
+
fail_level: low
|
|
141
|
+
disabled_rules:
|
|
142
|
+
- cache-in-transaction
|
|
143
|
+
severities:
|
|
144
|
+
broadcast-in-transaction: high
|
|
145
|
+
external_clients:
|
|
146
|
+
- InternalApi
|
|
147
|
+
- LegacySoapClient
|
|
148
|
+
runtime:
|
|
149
|
+
enabled: false
|
|
150
|
+
threshold_ms: 250
|
|
151
|
+
on_violation: log
|
|
152
|
+
log_path: tmp/txray.ndjson
|
|
153
|
+
ignore:
|
|
154
|
+
- LegacyImporter
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Exclusions apply to the path below the root being scanned, so `vendor` means the vendor directory in your project, not any directory named vendor anywhere above it.
|
|
158
|
+
|
|
159
|
+
### Inline suppression
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
def charge = Faraday.post(url) # txray:disable
|
|
163
|
+
|
|
164
|
+
# txray:disable http-in-transaction
|
|
165
|
+
Faraday.post(url)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
A bare `# txray:disable` disables every rule on that line; naming rules disables only those. The comment works on the offending line or the line directly above it.
|
|
169
|
+
|
|
170
|
+
## Command line
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
Usage: txray [options] [paths]
|
|
174
|
+
txray watch [options]
|
|
175
|
+
|
|
176
|
+
-f, --format FORMAT text, json, sarif or github (default: text)
|
|
177
|
+
-c, --config PATH path to a .txray.yml file
|
|
178
|
+
--fail-level low, medium, high or none (default: low)
|
|
179
|
+
--only RULES report only these rule ids
|
|
180
|
+
--except RULES skip these rule ids
|
|
181
|
+
--depth N how far to follow method calls (default: 3)
|
|
182
|
+
--rules list every rule and exit
|
|
183
|
+
--init write a default .txray.yml
|
|
184
|
+
--file PATH watch: event log written by the runtime guard
|
|
185
|
+
--threshold MS watch: slow transaction threshold
|
|
186
|
+
--from-start watch: replay the existing log first
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## CI
|
|
190
|
+
|
|
191
|
+
```yaml
|
|
192
|
+
- name: Scan for slow transactions
|
|
193
|
+
run: bundle exec txray --format github
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`--format github` writes inline annotations on the pull request. `--format sarif` uploads to GitHub code scanning:
|
|
197
|
+
|
|
198
|
+
```yaml
|
|
199
|
+
- run: bundle exec txray --format sarif > txray.sarif || true
|
|
200
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
201
|
+
with:
|
|
202
|
+
sarif_file: txray.sarif
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Runtime guard
|
|
206
|
+
|
|
207
|
+
Static analysis cannot see through metaprogramming, gem internals or a client handed in as an argument. The optional runtime guard catches what is left, from inside a running application:
|
|
208
|
+
|
|
209
|
+
```yaml
|
|
210
|
+
runtime:
|
|
211
|
+
enabled: true
|
|
212
|
+
threshold_ms: 250
|
|
213
|
+
on_violation: raise
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
It reports a transaction that stays open past the threshold, a job enqueued while a transaction is open, mail delivered while a transaction is open, and any `Net::HTTP` request made while a transaction is open (which covers Faraday, HTTParty, RestClient, Octokit and everything else built on it). Each finding is attributed to the transaction that was open, with the duration of the offending call and the application frame that caused it. `on_violation: log` warns, `raise` fails loudly. Run it as `raise` in test and `log` in development.
|
|
217
|
+
|
|
218
|
+
Known-good work can be excused:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
Txray::Runtime.ignore do
|
|
222
|
+
LegacyImporter.run!
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`ignore` in the config takes patterns matched against the message and source of each finding. `Txray::Runtime.uninstall` tears the guard back down, which test suites need when they install it per example.
|
|
227
|
+
|
|
228
|
+
## Live monitor
|
|
229
|
+
|
|
230
|
+
The guard writes newline delimited JSON to `runtime.log_path`. `txray watch` tails it and renders what your application is doing right now:
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
txray watching tmp/txray.ndjson 00:04:12
|
|
234
|
+
|
|
235
|
+
transactions 142 seen 9 slow 4 with findings
|
|
236
|
+
duration p50 12ms p95 240ms max 1.84s
|
|
237
|
+
▁▁▂▁▁▁▁▁▁▂▁▁▁▁▁▅▁▁▁▁▁▂▁▁▁▁▁▁▁▁▂█
|
|
238
|
+
|
|
239
|
+
LIVE
|
|
240
|
+
14:02:11 1.84s x app/models/order.rb:31 settle
|
|
241
|
+
| http-in-transaction POST api.stripe.com/v1/charges (1.61s)
|
|
242
|
+
| mail-in-transaction ReceiptMailer delivered mail (180ms)
|
|
243
|
+
14:02:10 268ms ! app/services/checkout.rb:12 call
|
|
244
|
+
14:02:09 11ms . app/models/photo.rb:8 save
|
|
245
|
+
|
|
246
|
+
HOTSPOTS
|
|
247
|
+
3x http-in-transaction app/models/order.rb:34
|
|
248
|
+
1x job-enqueue-in-transaction app/models/photo.rb:8
|
|
249
|
+
|
|
250
|
+
ctrl-c to stop
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Findings are grouped under the transaction that produced them, so you see the transaction that took 1.84 seconds and the two calls that account for most of it. Ctrl-C prints a summary of the session. Piped to a file or another process it emits the raw event stream instead of the dashboard, so `txray watch | jq` works.
|
|
254
|
+
|
|
255
|
+
The log is append only with an exclusive lock per write, so several Puma workers and Sidekiq processes can share one file.
|
|
256
|
+
|
|
257
|
+
## How this differs from what already exists
|
|
258
|
+
|
|
259
|
+
[isolator](https://github.com/palkan/isolator) detects the same class of problem at runtime, so it only sees code your tests actually execute. txray answers the same question statically: no boot, no database, no coverage requirement, and it works on a cold checkout of a codebase you have never run. Its runtime guard then covers the rest, and adds transaction duration and a live view, which isolator does not do. The two compose well. Nothing in `rubocop-rails` covers this.
|
|
260
|
+
|
|
261
|
+
## Roadmap
|
|
262
|
+
|
|
263
|
+
The rule registry is keyed by category so the transaction rules are the first family rather than the only one. Planned: query rules (N+1 shapes in views and loops, `count` where `size` would do, unbounded `all` loads, missing `includes`), schema rules (unindexed foreign keys and filtered columns), and job rules (Active Record objects passed as job arguments).
|
|
264
|
+
|
|
265
|
+
## Development
|
|
266
|
+
|
|
267
|
+
```sh
|
|
268
|
+
bin/setup
|
|
269
|
+
bundle exec rake
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## License
|
|
273
|
+
|
|
274
|
+
MIT
|
data/Rakefile
ADDED
data/exe/txray
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txray
|
|
4
|
+
class Analyzer
|
|
5
|
+
def initialize(index:, clients:, config:)
|
|
6
|
+
@index = index
|
|
7
|
+
@clients = clients
|
|
8
|
+
@config = config
|
|
9
|
+
@classifier = Classifier.new(config)
|
|
10
|
+
@scope_finder = ScopeFinder.new(index)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(source)
|
|
14
|
+
offenses = {}
|
|
15
|
+
@scope_finder.call(source).each do |scope|
|
|
16
|
+
walk = Walk.new(scope: scope, suppressed: Set.new, visited: Set.new)
|
|
17
|
+
explore(scope.body, scope.namespace, scope.source, walk, 0, []) do |offense|
|
|
18
|
+
current = offenses[offense.key]
|
|
19
|
+
offenses[offense.key] = offense if current.nil? || offense.trace.size < current.trace.size
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
offenses.values
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
Walk = Struct.new(:scope, :suppressed, :visited, keyword_init: true)
|
|
28
|
+
Context = Struct.new(:namespace, :path, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
def explore(node, namespace, source, walk, depth, trace, &emit)
|
|
31
|
+
NodeHelpers.each_node(node) do |current|
|
|
32
|
+
report(current, namespace, source, walk, trace, &emit)
|
|
33
|
+
follow(current, namespace, walk, depth, trace, &emit) if current.is_a?(Prism::CallNode)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def report(node, namespace, source, walk, trace, &emit)
|
|
38
|
+
return if walk.suppressed.include?(node.object_id)
|
|
39
|
+
|
|
40
|
+
rule_id = rule_for(node, namespace, source)
|
|
41
|
+
return unless rule_id && @config.rule_enabled?(rule_id)
|
|
42
|
+
return if source.disabled?(node.location.start_line, rule_id)
|
|
43
|
+
|
|
44
|
+
suppress_receivers(node, walk)
|
|
45
|
+
emit.call(Offense.new(rule: @config.rule(rule_id), path: source.path, line: node.location.start_line,
|
|
46
|
+
column: node.location.start_column + 1, snippet: NodeHelpers.snippet(node),
|
|
47
|
+
scope: walk.scope, trace: trace))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def rule_for(node, namespace, source)
|
|
51
|
+
@classifier.call(node) || client_rule(node, namespace, source) ||
|
|
52
|
+
(@classifier.iteration?(node) ? "iteration-in-transaction" : nil)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def client_rule(node, namespace, source)
|
|
56
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
57
|
+
return @clients.delegated_kind(namespace, node.name) if node.receiver.nil?
|
|
58
|
+
|
|
59
|
+
@clients.kind_of(node.receiver, Context.new(namespace: namespace, path: source.path))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def suppress_receivers(node, walk)
|
|
63
|
+
receiver = node.receiver if node.is_a?(Prism::CallNode)
|
|
64
|
+
while receiver.is_a?(Prism::CallNode)
|
|
65
|
+
walk.suppressed << receiver.object_id
|
|
66
|
+
receiver = receiver.receiver
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def follow(call, namespace, walk, depth, trace, &)
|
|
71
|
+
return unless depth < @config.max_depth
|
|
72
|
+
|
|
73
|
+
entry = resolve(call, namespace)
|
|
74
|
+
return if entry.nil? || entry.node.body.nil?
|
|
75
|
+
return unless walk.visited.add?([ entry.namespace, entry.name ])
|
|
76
|
+
|
|
77
|
+
frame = Frame.new(label: entry.label, path: entry.path, line: entry.line)
|
|
78
|
+
explore(entry.node.body, entry.namespace, entry.source, walk, depth + 1, trace + [ frame ], &)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def resolve(call, namespace)
|
|
82
|
+
name = dispatched_name(call)
|
|
83
|
+
return nil if name.nil?
|
|
84
|
+
|
|
85
|
+
case call.receiver
|
|
86
|
+
when nil then @index.lookup(namespace, name)
|
|
87
|
+
when Prism::SelfNode then @index.lookup(namespace, name, singleton: true) || @index.lookup(namespace, name)
|
|
88
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
89
|
+
@index.lookup(NodeHelpers.constant_name(call.receiver), name, singleton: true)
|
|
90
|
+
when Prism::CallNode then resolve_instance(call, name)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def dispatched_name(call)
|
|
95
|
+
return call.name unless Catalog::DISPATCH_METHODS.include?(call.name)
|
|
96
|
+
|
|
97
|
+
argument = NodeHelpers.positional_arguments(call).first
|
|
98
|
+
argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def resolve_instance(call, name)
|
|
102
|
+
receiver = call.receiver
|
|
103
|
+
return unless receiver.name == :new
|
|
104
|
+
|
|
105
|
+
owner = NodeHelpers.constant_name(receiver.receiver)
|
|
106
|
+
owner && @index.lookup(owner, name)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txray
|
|
4
|
+
module Catalog
|
|
5
|
+
HTTP_NAMESPACES = %w[
|
|
6
|
+
Net::HTTP Net::HTTPS Net::SMTP Net::IMAP Net::POP3 Net::FTP Net::SSH Net::SFTP Net::Telnet
|
|
7
|
+
Faraday HTTParty RestClient Excon Typhoeus HTTPX HTTPClient HTTP Curl Patron Mechanize Down
|
|
8
|
+
Savon GraphQL::Client OpenURI Socket TCPSocket UDPSocket
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
SERVICE_NAMESPACES = %w[
|
|
12
|
+
Stripe Braintree PayPal Recurly Chargebee Coinbase Plaid Adyen Square
|
|
13
|
+
Aws AWS Azure Google GoogleDrive Firebase Cloudinary Imgix
|
|
14
|
+
Twilio SendGrid Mailgun Postmark Mailchimp Customerio Intercom Zendesk Front
|
|
15
|
+
Slack Discordrb Octokit Gitlab Shopify Salesforce Hubspot Airtable Notion
|
|
16
|
+
Algolia Elasticsearch OpenSearch Meilisearch Typesense Searchkick Sunspot
|
|
17
|
+
Geocoder Mapbox LaunchDarkly Fog Vonage Nexmo Resend Pay Auth0 Okta Clerk
|
|
18
|
+
Pusher Ably Segment Mixpanel Amplitude Posthog
|
|
19
|
+
OpenAI Anthropic Gemini Replicate Pinecone RubyLLM Ollama Langchain
|
|
20
|
+
Onfido Persona Checkr Twitter Linkedin Zoom
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
CACHE_NAMESPACES = %w[Redis Dalli Memcached MemCache].freeze
|
|
24
|
+
|
|
25
|
+
SHELL_METHODS = %i[system spawn exec fork popen popen2 popen3 capture2 capture2e capture3 pipeline].freeze
|
|
26
|
+
SHELL_NAMESPACES = %w[Open3 Process Kernel IO PTY].freeze
|
|
27
|
+
|
|
28
|
+
MAIL_METHODS = %i[deliver_now deliver_now!].freeze
|
|
29
|
+
|
|
30
|
+
SEARCH_METHODS = %i[reindex reindex! geocode reverse_geocode].freeze
|
|
31
|
+
|
|
32
|
+
BROADCAST_METHODS = %i[
|
|
33
|
+
broadcast_to broadcast_replace_to broadcast_update_to broadcast_append_to broadcast_prepend_to
|
|
34
|
+
broadcast_remove_to broadcast_before_to broadcast_after_to broadcast_action_to broadcast_render_to
|
|
35
|
+
broadcast_refresh_to broadcast_replace broadcast_update broadcast_append broadcast_prepend
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
DISPATCH_METHODS = %i[send public_send __send__ try try!].freeze
|
|
39
|
+
|
|
40
|
+
CONSTRUCTOR_METHODS = %i[new client connection build configure resource service session].freeze
|
|
41
|
+
|
|
42
|
+
ENQUEUE_METHODS = %i[
|
|
43
|
+
perform_later deliver_later deliver_later! perform_async perform_in perform_at
|
|
44
|
+
enqueue enqueue_at broadcast_later broadcast_later_to
|
|
45
|
+
].freeze
|
|
46
|
+
|
|
47
|
+
ATTACHMENT_METHODS = %i[attach purge purge_later analyze processed].freeze
|
|
48
|
+
|
|
49
|
+
ITERATOR_METHODS = %i[
|
|
50
|
+
each each_with_object each_with_index each_slice each_entry map flat_map
|
|
51
|
+
find_each find_in_batches in_batches collect select filter reject sum times upto downto
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
PERSISTENCE_METHODS = %i[
|
|
55
|
+
save save! update update! update_attribute update_column update_columns
|
|
56
|
+
create create! destroy destroy! delete touch increment! decrement! toggle!
|
|
57
|
+
insert insert! insert_all insert_all! upsert upsert_all
|
|
58
|
+
find find_by find_by! where pluck exists? reload lock! first last count
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
MEDIA_NAMESPACES = %w[
|
|
62
|
+
CSV Tempfile Zip Rubyzip Prawn WickedPdf Grover Ferrum Roo Axlsx Caxlsx Spreadsheet
|
|
63
|
+
MiniMagick ImageProcessing Vips Magick RMagick FFMPEG Streamio Shrine
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
66
|
+
FILE_NAMESPACES = %w[File IO Pathname FileUtils].freeze
|
|
67
|
+
FILE_METHODS = %i[read write open binread binwrite readlines foreach copy_stream cp mv rm_rf].freeze
|
|
68
|
+
|
|
69
|
+
CALLBACKS = %i[
|
|
70
|
+
before_commit
|
|
71
|
+
before_validation after_validation
|
|
72
|
+
before_save around_save after_save
|
|
73
|
+
before_create around_create after_create
|
|
74
|
+
before_update around_update after_update
|
|
75
|
+
before_destroy around_destroy after_destroy
|
|
76
|
+
after_touch
|
|
77
|
+
].freeze
|
|
78
|
+
|
|
79
|
+
MIGRATION_METHODS = %i[change up down].freeze
|
|
80
|
+
|
|
81
|
+
module_function
|
|
82
|
+
|
|
83
|
+
def namespaced?(constant, namespaces)
|
|
84
|
+
return false if constant.nil?
|
|
85
|
+
|
|
86
|
+
namespaces.any? { |namespace| constant == namespace || constant.start_with?("#{namespace}::") }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txray
|
|
4
|
+
class Classifier
|
|
5
|
+
def initialize(config)
|
|
6
|
+
@config = config
|
|
7
|
+
@clients = Catalog::SERVICE_NAMESPACES + config.external_clients
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(node)
|
|
11
|
+
return "shell-in-transaction" if node.is_a?(Prism::XStringNode)
|
|
12
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
13
|
+
|
|
14
|
+
name = node.name
|
|
15
|
+
constant = NodeHelpers.constant_name(node.receiver)
|
|
16
|
+
|
|
17
|
+
return "shell-in-transaction" if shell?(node, name, constant)
|
|
18
|
+
return "sleep-in-transaction" if blocking?(node, name, constant)
|
|
19
|
+
return "mail-in-transaction" if Catalog::MAIL_METHODS.include?(name)
|
|
20
|
+
return "job-enqueue-in-transaction" if enqueue?(name, constant)
|
|
21
|
+
return "broadcast-in-transaction" if broadcast?(node, name)
|
|
22
|
+
return "upload-in-transaction" if Catalog::ATTACHMENT_METHODS.include?(name)
|
|
23
|
+
return "external-service-in-transaction" if Catalog::SEARCH_METHODS.include?(name)
|
|
24
|
+
return "dynamic-dispatch-in-transaction" if unresolvable_dispatch?(node, name)
|
|
25
|
+
return "cache-in-transaction" if rails_cache?(node)
|
|
26
|
+
return nil if constructor?(node, name)
|
|
27
|
+
|
|
28
|
+
constant_rule(constant) || implicit_http(name, constant)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def constant_rule(constant)
|
|
32
|
+
return nil if constant.nil?
|
|
33
|
+
return "http-in-transaction" if Catalog.namespaced?(constant, Catalog::HTTP_NAMESPACES)
|
|
34
|
+
return "cache-in-transaction" if Catalog.namespaced?(constant, Catalog::CACHE_NAMESPACES)
|
|
35
|
+
return "blocking-io-in-transaction" if Catalog.namespaced?(constant, Catalog::MEDIA_NAMESPACES)
|
|
36
|
+
return "external-service-in-transaction" if Catalog.namespaced?(constant, @clients)
|
|
37
|
+
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def iteration?(node)
|
|
42
|
+
return false unless node.is_a?(Prism::CallNode) && node.block && Catalog::ITERATOR_METHODS.include?(node.name)
|
|
43
|
+
|
|
44
|
+
NodeHelpers.each_node(NodeHelpers.block_body(node)) do |child|
|
|
45
|
+
return true if child.is_a?(Prism::CallNode) && Catalog::PERSISTENCE_METHODS.include?(child.name)
|
|
46
|
+
end
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def constructor?(node, name = node.name)
|
|
51
|
+
Catalog::CONSTRUCTOR_METHODS.include?(name) && !constant_rule(NodeHelpers.constant_name(node.receiver)).nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def implicit_http(name, constant)
|
|
57
|
+
return "http-in-transaction" if name == :open && %w[URI OpenURI Kernel].include?(constant)
|
|
58
|
+
return "blocking-io-in-transaction" if file_io?(name, constant)
|
|
59
|
+
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def file_io?(name, constant)
|
|
64
|
+
Catalog::FILE_NAMESPACES.include?(constant) && Catalog::FILE_METHODS.include?(name)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def shell?(node, name, constant)
|
|
68
|
+
return false unless Catalog::SHELL_METHODS.include?(name)
|
|
69
|
+
|
|
70
|
+
node.receiver.nil? || Catalog.namespaced?(constant, Catalog::SHELL_NAMESPACES)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def blocking?(node, name, constant)
|
|
74
|
+
return true if name == :sleep && (node.receiver.nil? || constant == "Kernel")
|
|
75
|
+
|
|
76
|
+
name == :timeout && constant == "Timeout"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def enqueue?(name, constant)
|
|
80
|
+
return true if Catalog::ENQUEUE_METHODS.include?(name)
|
|
81
|
+
|
|
82
|
+
name == :push && constant == "Sidekiq::Client"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def broadcast?(node, name)
|
|
86
|
+
return true if Catalog::BROADCAST_METHODS.include?(name)
|
|
87
|
+
|
|
88
|
+
name == :broadcast && NodeHelpers.receiver_name(node).to_s.start_with?("ActionCable")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unresolvable_dispatch?(node, name)
|
|
92
|
+
return false unless Catalog::DISPATCH_METHODS.include?(name)
|
|
93
|
+
|
|
94
|
+
argument = NodeHelpers.positional_arguments(node).first
|
|
95
|
+
!argument.nil? && !argument.is_a?(Prism::SymbolNode)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def rails_cache?(node)
|
|
99
|
+
NodeHelpers.receiver_name(node).to_s.start_with?("Rails.cache", "$redis", "@redis")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|