bulletproof 0.1.3
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/.rspec +3 -0
- data/.rubocop.yml +49 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/Rakefile +12 -0
- data/exe/bulletproof +69 -0
- data/lib/bulletproof/analyzer.rb +35 -0
- data/lib/bulletproof/configuration.rb +68 -0
- data/lib/bulletproof/detectors/excessive_includes_detector.rb +158 -0
- data/lib/bulletproof/middleware.rb +128 -0
- data/lib/bulletproof/railtie.rb +11 -0
- data/lib/bulletproof/report.rb +30 -0
- data/lib/bulletproof/runtime/callstack_filter.rb +28 -0
- data/lib/bulletproof/runtime/memory_sampler.rb +14 -0
- data/lib/bulletproof/runtime/model_load_collector.rb +72 -0
- data/lib/bulletproof/runtime/model_load_event.rb +12 -0
- data/lib/bulletproof/runtime/request_monitor.rb +91 -0
- data/lib/bulletproof/runtime_warning.rb +5 -0
- data/lib/bulletproof/version.rb +5 -0
- data/lib/bulletproof.rb +35 -0
- data/sig/bulletproof.rbs +4 -0
- metadata +98 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 20e99f97d84e032e719fd33b16b11778667f2abf8d367f69a4201bdc221707ec
|
|
4
|
+
data.tar.gz: cfe6e4fabcbda428d45b3bbf88b403c67f7df6599704d3ac8bb4e45312f853cb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fe3727c8d74667fc57fd6930f65559ec0414965df3f87dc01c11d0e1d90c443d5b4dff692b9a50368f3e0b202cbee2733c632293477fd309f4499c7df2006e6e
|
|
7
|
+
data.tar.gz: d5a2a4d274e110e1ac0104390cfd6f6f3daf31ef97b1c3335829a214c959b5aa71f5df8bd7ce952d5027f001bd62746e9c8da10a9b3b5e8bcbb53ddfcc00a111
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
TargetRubyVersion: 3.0
|
|
3
|
+
NewCops: enable
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
|
|
6
|
+
Style/StringLiterals:
|
|
7
|
+
EnforcedStyle: double_quotes
|
|
8
|
+
|
|
9
|
+
Style/StringLiteralsInInterpolation:
|
|
10
|
+
EnforcedStyle: double_quotes
|
|
11
|
+
|
|
12
|
+
Style/Documentation:
|
|
13
|
+
Enabled: false
|
|
14
|
+
|
|
15
|
+
Style/OneClassPerFile:
|
|
16
|
+
Exclude:
|
|
17
|
+
- "demo/**/*"
|
|
18
|
+
- "spec/**/*"
|
|
19
|
+
|
|
20
|
+
Style/MultilineBlockChain:
|
|
21
|
+
Enabled: false
|
|
22
|
+
|
|
23
|
+
Metrics/BlockLength:
|
|
24
|
+
Exclude:
|
|
25
|
+
- "spec/**/*"
|
|
26
|
+
- "demo/**/*"
|
|
27
|
+
|
|
28
|
+
Metrics/MethodLength:
|
|
29
|
+
Max: 20
|
|
30
|
+
Exclude:
|
|
31
|
+
- "lib/bulletproof/runtime/request_monitor.rb"
|
|
32
|
+
|
|
33
|
+
Metrics/AbcSize:
|
|
34
|
+
Max: 40
|
|
35
|
+
|
|
36
|
+
Metrics/CyclomaticComplexity:
|
|
37
|
+
Max: 12
|
|
38
|
+
|
|
39
|
+
Metrics/PerceivedComplexity:
|
|
40
|
+
Max: 12
|
|
41
|
+
|
|
42
|
+
Layout/LineLength:
|
|
43
|
+
Max: 160
|
|
44
|
+
Exclude:
|
|
45
|
+
- "spec/**/*"
|
|
46
|
+
|
|
47
|
+
Lint/UnreachableLoop:
|
|
48
|
+
Exclude:
|
|
49
|
+
- "spec/**/*"
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our Pledge
|
|
4
|
+
|
|
5
|
+
We as members, contributors, and leaders pledge to make participation in our
|
|
6
|
+
community a harassment-free experience for everyone, regardless of age, body
|
|
7
|
+
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
|
8
|
+
identity and expression, level of experience, education, socio-economic status,
|
|
9
|
+
nationality, personal appearance, race, caste, color, religion, or sexual
|
|
10
|
+
identity and orientation.
|
|
11
|
+
|
|
12
|
+
We pledge to act and interact in ways that contribute to an open, welcoming,
|
|
13
|
+
diverse, inclusive, and healthy community.
|
|
14
|
+
|
|
15
|
+
## Our Standards
|
|
16
|
+
|
|
17
|
+
Examples of behavior that contributes to a positive environment for our
|
|
18
|
+
community include:
|
|
19
|
+
|
|
20
|
+
* Demonstrating empathy and kindness toward other people
|
|
21
|
+
* Being respectful of differing opinions, viewpoints, and experiences
|
|
22
|
+
* Giving and gracefully accepting constructive feedback
|
|
23
|
+
* Accepting responsibility and apologizing to those affected by our mistakes,
|
|
24
|
+
and learning from the experience
|
|
25
|
+
* Focusing on what is best not just for us as individuals, but for the overall
|
|
26
|
+
community
|
|
27
|
+
|
|
28
|
+
Examples of unacceptable behavior include:
|
|
29
|
+
|
|
30
|
+
* The use of sexualized language or imagery, and sexual attention or advances of
|
|
31
|
+
any kind
|
|
32
|
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
|
33
|
+
* Public or private harassment
|
|
34
|
+
* Publishing others' private information, such as a physical or email address,
|
|
35
|
+
without their explicit permission
|
|
36
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
|
37
|
+
professional setting
|
|
38
|
+
|
|
39
|
+
## Enforcement Responsibilities
|
|
40
|
+
|
|
41
|
+
Community leaders are responsible for clarifying and enforcing our standards of
|
|
42
|
+
acceptable behavior and will take appropriate and fair corrective action in
|
|
43
|
+
response to any behavior that they deem inappropriate, threatening, offensive,
|
|
44
|
+
or harmful.
|
|
45
|
+
|
|
46
|
+
Community leaders have the right and responsibility to remove, edit, or reject
|
|
47
|
+
comments, commits, code, wiki edits, issues, and other contributions that are
|
|
48
|
+
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
|
49
|
+
decisions when appropriate.
|
|
50
|
+
|
|
51
|
+
## Scope
|
|
52
|
+
|
|
53
|
+
This Code of Conduct applies within all community spaces, and also applies when
|
|
54
|
+
an individual is officially representing the community in public spaces.
|
|
55
|
+
Examples of representing our community include using an official email address,
|
|
56
|
+
posting via an official social media account, or acting as an appointed
|
|
57
|
+
representative at an online or offline event.
|
|
58
|
+
|
|
59
|
+
## Enforcement
|
|
60
|
+
|
|
61
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
62
|
+
reported to the community leaders responsible for enforcement at
|
|
63
|
+
[INSERT CONTACT METHOD].
|
|
64
|
+
All complaints will be reviewed and investigated promptly and fairly.
|
|
65
|
+
|
|
66
|
+
All community leaders are obligated to respect the privacy and security of the
|
|
67
|
+
reporter of any incident.
|
|
68
|
+
|
|
69
|
+
## Enforcement Guidelines
|
|
70
|
+
|
|
71
|
+
Community leaders will follow these Community Impact Guidelines in determining
|
|
72
|
+
the consequences for any action they deem in violation of this Code of Conduct:
|
|
73
|
+
|
|
74
|
+
### 1. Correction
|
|
75
|
+
|
|
76
|
+
**Community Impact**: Use of inappropriate language or other behavior deemed
|
|
77
|
+
unprofessional or unwelcome in the community.
|
|
78
|
+
|
|
79
|
+
**Consequence**: A private, written warning from community leaders, providing
|
|
80
|
+
clarity around the nature of the violation and an explanation of why the
|
|
81
|
+
behavior was inappropriate. A public apology may be requested.
|
|
82
|
+
|
|
83
|
+
### 2. Warning
|
|
84
|
+
|
|
85
|
+
**Community Impact**: A violation through a single incident or series of
|
|
86
|
+
actions.
|
|
87
|
+
|
|
88
|
+
**Consequence**: A warning with consequences for continued behavior. No
|
|
89
|
+
interaction with the people involved, including unsolicited interaction with
|
|
90
|
+
those enforcing the Code of Conduct, for a specified period of time. This
|
|
91
|
+
includes avoiding interactions in community spaces as well as external channels
|
|
92
|
+
like social media. Violating these terms may lead to a temporary or permanent
|
|
93
|
+
ban.
|
|
94
|
+
|
|
95
|
+
### 3. Temporary Ban
|
|
96
|
+
|
|
97
|
+
**Community Impact**: A serious violation of community standards, including
|
|
98
|
+
sustained inappropriate behavior.
|
|
99
|
+
|
|
100
|
+
**Consequence**: A temporary ban from any sort of interaction or public
|
|
101
|
+
communication with the community for a specified period of time. No public or
|
|
102
|
+
private interaction with the people involved, including unsolicited interaction
|
|
103
|
+
with those enforcing the Code of Conduct, is allowed during this period.
|
|
104
|
+
Violating these terms may lead to a permanent ban.
|
|
105
|
+
|
|
106
|
+
### 4. Permanent Ban
|
|
107
|
+
|
|
108
|
+
**Community Impact**: Demonstrating a pattern of violation of community
|
|
109
|
+
standards, including sustained inappropriate behavior, harassment of an
|
|
110
|
+
individual, or aggression toward or disparagement of classes of individuals.
|
|
111
|
+
|
|
112
|
+
**Consequence**: A permanent ban from any sort of public interaction within the
|
|
113
|
+
community.
|
|
114
|
+
|
|
115
|
+
## Attribution
|
|
116
|
+
|
|
117
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
|
118
|
+
version 2.1, available at
|
|
119
|
+
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
|
120
|
+
|
|
121
|
+
Community Impact Guidelines were inspired by
|
|
122
|
+
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
|
123
|
+
|
|
124
|
+
For answers to common questions about this code of conduct, see the FAQ at
|
|
125
|
+
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
|
126
|
+
[https://www.contributor-covenant.org/translations][translations].
|
|
127
|
+
|
|
128
|
+
[homepage]: https://www.contributor-covenant.org
|
|
129
|
+
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
|
130
|
+
[Mozilla CoC]: https://github.com/mozilla/diversity
|
|
131
|
+
[FAQ]: https://www.contributor-covenant.org/faq
|
|
132
|
+
[translations]: https://www.contributor-covenant.org/translations
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 essei0-0
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Bulletproof
|
|
2
|
+
|
|
3
|
+
Bulletproof detects ActiveRecord memory problems caused by over-eager `includes`.
|
|
4
|
+
|
|
5
|
+
- **Static analysis** — Scans Ruby source files and flags `includes` calls that load unbounded record sets
|
|
6
|
+
- **Runtime monitoring** — Measures actual record counts and GC pressure per request, and warns when thresholds are exceeded
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
# Gemfile
|
|
12
|
+
gem "bulletproof", group: :development
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bundle install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Static Analysis
|
|
22
|
+
|
|
23
|
+
### CLI
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
# Analyze a directory
|
|
27
|
+
bundle exec bulletproof app/
|
|
28
|
+
|
|
29
|
+
# Analyze a single file
|
|
30
|
+
bundle exec bulletproof app/models/user.rb
|
|
31
|
+
|
|
32
|
+
# Override thresholds
|
|
33
|
+
bundle exec bulletproof app/ --max-includes-depth 3 --max-associations 5
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Output (violations found):**
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
[WARNING] app/models/post.rb:12 — nesting depth 3 (limit: 2), no record-limiting method (limit / find, etc.) in chain
|
|
40
|
+
[WARNING] app/controllers/users_controller.rb:45 — 4 associations (limit: 3), no record-limiting method in chain
|
|
41
|
+
|
|
42
|
+
2 violation(s) found.
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Output (clean):**
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
No violations found.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The exit code is `0` when clean and `1` when violations are found, making it easy to integrate into CI.
|
|
52
|
+
|
|
53
|
+
### Programmatic usage
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
report = Bulletproof.analyze("app/")
|
|
57
|
+
|
|
58
|
+
if report.ok?
|
|
59
|
+
puts "No violations found."
|
|
60
|
+
else
|
|
61
|
+
report.violations.each do |v|
|
|
62
|
+
puts "[#{v.severity.upcase}] #{v.file}:#{v.line} — #{v.message}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### How detection works
|
|
68
|
+
|
|
69
|
+
A call to `includes` is only flagged when **both** conditions are true:
|
|
70
|
+
|
|
71
|
+
1. The nesting depth or association count exceeds the configured threshold
|
|
72
|
+
2. No record-limiting method (`limit`, `find`, `first`, `page`, etc.) appears anywhere in the method chain
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# Flagged — unbounded full-table load
|
|
76
|
+
User.includes(posts: { comments: :author }).all
|
|
77
|
+
Post.includes(:user, :comments, :tags, :likes)
|
|
78
|
+
|
|
79
|
+
# Safe — record count is bounded
|
|
80
|
+
User.includes(posts: { comments: :author }).limit(10)
|
|
81
|
+
User.includes(posts: { comments: :author }).page(1).per(20)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Runtime Monitoring (Rails)
|
|
87
|
+
|
|
88
|
+
### Setup
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# config/initializers/bulletproof.rb
|
|
92
|
+
Bulletproof.configure do |c|
|
|
93
|
+
c.enabled = Rails.env.development?
|
|
94
|
+
|
|
95
|
+
# ---- Thresholds ----------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
# Max records loaded at once per model (default: 1_000)
|
|
98
|
+
c.max_records_per_model = 1_000
|
|
99
|
+
|
|
100
|
+
# Max total records loaded across all models per request (default: 5_000)
|
|
101
|
+
# find_each / in_batches loads are excluded from this count
|
|
102
|
+
c.max_total_records = 5_000
|
|
103
|
+
|
|
104
|
+
# Max GC runs per request (default: nil = disabled)
|
|
105
|
+
# GC frequency varies widely between apps; set explicitly if needed
|
|
106
|
+
# c.max_gc_runs_per_request = 10
|
|
107
|
+
|
|
108
|
+
# ---- Notifiers -----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
# Log to Rails.logger.warn (default: true)
|
|
111
|
+
c.rails_logger = true
|
|
112
|
+
|
|
113
|
+
# Inject console.warn into HTML responses (default: true)
|
|
114
|
+
# Visible in the browser's Console tab (F12)
|
|
115
|
+
c.console = true
|
|
116
|
+
|
|
117
|
+
# Inject a floating overlay panel into HTML responses (default: false)
|
|
118
|
+
# Visible on the page without opening DevTools
|
|
119
|
+
c.alert = true
|
|
120
|
+
|
|
121
|
+
# Append warnings to a log file (default: nil = disabled)
|
|
122
|
+
# c.log_file = Rails.root.join("log/bulletproof.log").to_s
|
|
123
|
+
|
|
124
|
+
# Custom notifier callable — for Slack, etc. (default: nil = disabled)
|
|
125
|
+
# c.notifier = ->(w) { SlackNotifier.ping(w.message) }
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Setting `enabled = true` causes the Railtie to automatically insert the Rack middleware. You do not need to call `config.middleware.use` manually.
|
|
130
|
+
|
|
131
|
+
### Notifiers
|
|
132
|
+
|
|
133
|
+
| Key | Default | Description |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `rails_logger` | `true` | Calls `Rails.logger.warn` for each warning |
|
|
136
|
+
| `console` | `true` | Injects `console.warn` before `</body>`. Visible in the browser Console tab |
|
|
137
|
+
| `alert` | `false` | Injects a floating overlay panel before `</body>`. Dismissible with ✕ |
|
|
138
|
+
| `log_file` | `nil` (disabled) | Appends timestamped warnings to the specified file path |
|
|
139
|
+
| `notifier` | `nil` (disabled) | Callable receiving a `RuntimeWarning`. Use for Slack, webhooks, etc. |
|
|
140
|
+
|
|
141
|
+
### Warning output example
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
[Bulletproof] Post: loaded 5,200 records at once (limit: 1,000)
|
|
145
|
+
→ app/controllers/posts_controller.rb:15:in 'index'
|
|
146
|
+
|
|
147
|
+
[Bulletproof] Total records loaded in this request: 7,800 (limit: 5,000) [Post: 5,200, Comment: 2,600]
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Warning types
|
|
151
|
+
|
|
152
|
+
| Type | Condition | Related config key |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| `:mass_instantiation` | A single model loaded more than `max_records_per_model` records in one batch | `max_records_per_model` |
|
|
155
|
+
| `:high_total_records` | Total records across all models exceeded `max_total_records` (batch loads excluded) | `max_total_records` |
|
|
156
|
+
| `:gc_pressure` | GC ran more than `max_gc_runs_per_request` times during the request | `max_gc_runs_per_request` |
|
|
157
|
+
|
|
158
|
+
### `find_each` / `in_batches`
|
|
159
|
+
|
|
160
|
+
Batch processing is intentional and memory-safe, so Bulletproof does not warn on it.
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
# No warning — find_each loads records in bounded batches
|
|
164
|
+
Post.find_each(batch_size: 500) { |post| process(post) }
|
|
165
|
+
|
|
166
|
+
# Warning — all records loaded into memory at once
|
|
167
|
+
Post.all.to_a
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### How it works
|
|
171
|
+
|
|
172
|
+
Bulletproof subscribes to the `instantiation.active_record` ActiveSupport notification for the duration of each request. It accumulates record counts per model and uses `caller_locations` to identify the application code line responsible for each load. Subscription is scoped to the current thread via `Thread.current`, so parallel requests in multi-threaded servers (e.g. Puma) do not interfere with each other.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Configuration reference
|
|
177
|
+
|
|
178
|
+
| Key | Default | Description |
|
|
179
|
+
|---|---|---|
|
|
180
|
+
| `enabled` | `false` | Enable runtime monitoring |
|
|
181
|
+
| `max_includes_depth` | `2` | Static: max `includes` nesting depth |
|
|
182
|
+
| `max_associations` | `3` | Static: max associations per `includes` call |
|
|
183
|
+
| `max_records_per_model` | `1_000` | Runtime: max records loaded at once per model |
|
|
184
|
+
| `max_total_records` | `5_000` | Runtime: max total records per request (batch loads excluded) |
|
|
185
|
+
| `max_gc_runs_per_request` | `nil` (disabled) | Runtime: max GC runs per request |
|
|
186
|
+
| `rails_logger` | `true` | Notifier: output to `Rails.logger.warn` |
|
|
187
|
+
| `console` | `true` | Notifier: inject `console.warn` into HTML |
|
|
188
|
+
| `alert` | `false` | Notifier: inject overlay panel into HTML |
|
|
189
|
+
| `log_file` | `nil` (disabled) | Notifier: append to log file |
|
|
190
|
+
| `notifier` | `nil` (disabled) | Notifier: custom callable receiving `RuntimeWarning` |
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Requirements
|
|
195
|
+
|
|
196
|
+
- Ruby 3.0+
|
|
197
|
+
- Rails 6.0+ (runtime monitoring only)
|
|
198
|
+
|
|
199
|
+
## License
|
|
200
|
+
|
|
201
|
+
[MIT License](LICENSE.txt)
|
data/Rakefile
ADDED
data/exe/bulletproof
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bulletproof"
|
|
5
|
+
require "optparse"
|
|
6
|
+
|
|
7
|
+
options = {}
|
|
8
|
+
|
|
9
|
+
parser = OptionParser.new do |o|
|
|
10
|
+
o.banner = <<~BANNER
|
|
11
|
+
Usage: bulletproof [options] PATH
|
|
12
|
+
|
|
13
|
+
Static analysis for over-eager ActiveRecord includes.
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
bulletproof app/
|
|
17
|
+
bulletproof app/models/user.rb
|
|
18
|
+
bulletproof app/ --max-includes-depth 3 --max-associations 5
|
|
19
|
+
BANNER
|
|
20
|
+
|
|
21
|
+
o.on("--max-includes-depth N", Integer, "Max nesting depth of includes (default: 2)") do |n|
|
|
22
|
+
options[:max_includes_depth] = n
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
o.on("--max-associations N", Integer, "Max number of associations in one includes (default: 3)") do |n|
|
|
26
|
+
options[:max_associations] = n
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
o.on("-v", "--version", "Print version") do
|
|
30
|
+
puts Bulletproof::VERSION
|
|
31
|
+
exit
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
o.on("-h", "--help", "Print this help") do
|
|
35
|
+
puts o
|
|
36
|
+
exit
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
parser.parse!
|
|
41
|
+
|
|
42
|
+
path = ARGV.first
|
|
43
|
+
|
|
44
|
+
if path.nil?
|
|
45
|
+
warn parser.banner
|
|
46
|
+
exit 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
unless File.exist?(path)
|
|
50
|
+
warn "bulletproof: #{path}: No such file or directory"
|
|
51
|
+
exit 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Bulletproof.configure do |c|
|
|
55
|
+
c.max_includes_depth = options[:max_includes_depth] if options[:max_includes_depth]
|
|
56
|
+
c.max_associations = options[:max_associations] if options[:max_associations]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
report = Bulletproof.analyze(path)
|
|
60
|
+
|
|
61
|
+
if report.ok?
|
|
62
|
+
puts "No violations found."
|
|
63
|
+
exit 0
|
|
64
|
+
else
|
|
65
|
+
puts report
|
|
66
|
+
puts
|
|
67
|
+
puts "#{report.violations.size} violation(s) found."
|
|
68
|
+
exit 1
|
|
69
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletproof
|
|
4
|
+
class Analyzer
|
|
5
|
+
def initialize(config = Bulletproof.config)
|
|
6
|
+
@config = config
|
|
7
|
+
@detectors = [
|
|
8
|
+
Detectors::ExcessiveIncludesDetector.new(config)
|
|
9
|
+
]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @param path [String] ファイルまたはディレクトリのパス
|
|
13
|
+
# @return [Report]
|
|
14
|
+
def call(path)
|
|
15
|
+
report = Report.new
|
|
16
|
+
ruby_files(path).each do |file|
|
|
17
|
+
source = File.read(file)
|
|
18
|
+
@detectors.each do |detector|
|
|
19
|
+
detector.call(source, file: file).each { |v| report.add_violation(v) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
report
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def ruby_files(path)
|
|
28
|
+
if File.directory?(path)
|
|
29
|
+
Dir.glob(File.join(path, "**", "*.rb"))
|
|
30
|
+
else
|
|
31
|
+
[path]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletproof
|
|
4
|
+
class Configuration
|
|
5
|
+
# ---- 静的解析 -----------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
# includes のネスト深さの上限(例: posts: { comments: :author } は深さ2)
|
|
8
|
+
attr_accessor :max_includes_depth
|
|
9
|
+
|
|
10
|
+
# 1つの includes で許容するアソシエーション数の上限
|
|
11
|
+
attr_accessor :max_associations
|
|
12
|
+
|
|
13
|
+
# ---- ランタイム監視 -----------------------------------------------------
|
|
14
|
+
|
|
15
|
+
# ランタイム監視を有効にするか(デフォルト: false)
|
|
16
|
+
# Rails では config/initializers/bulletproof.rb で true に設定して使う
|
|
17
|
+
attr_accessor :enabled
|
|
18
|
+
|
|
19
|
+
# 1モデルあたりのロード件数の上限
|
|
20
|
+
# 超えると :mass_instantiation 警告を出す
|
|
21
|
+
attr_accessor :max_records_per_model
|
|
22
|
+
|
|
23
|
+
# リクエスト全体(全モデル合算)のロード件数の上限
|
|
24
|
+
# 超えると :high_total_records 警告を出す
|
|
25
|
+
attr_accessor :max_total_records
|
|
26
|
+
|
|
27
|
+
# リクエスト中に許容する GC 実行回数の上限
|
|
28
|
+
# 超えると :gc_pressure 警告を出す
|
|
29
|
+
# nil(デフォルト)のとき無効。GC 頻度はアプリや Ruby 設定に依存するため
|
|
30
|
+
# 必要な場合にのみ明示的に設定する(例: 10)
|
|
31
|
+
attr_accessor :max_gc_runs_per_request
|
|
32
|
+
|
|
33
|
+
# Rails.logger.warn に出力するか(デフォルト: true)
|
|
34
|
+
attr_accessor :rails_logger
|
|
35
|
+
|
|
36
|
+
# HTML レスポンスの </body> 直前に console.warn を注入するか(デフォルト: true)
|
|
37
|
+
# ブラウザの開発者ツールの Console タブで確認できる
|
|
38
|
+
attr_accessor :console
|
|
39
|
+
|
|
40
|
+
# HTML レスポンスにオーバーレイパネルを注入するか(デフォルト: false)
|
|
41
|
+
# 開発者ツールを開かなくても画面上で警告を確認できる
|
|
42
|
+
attr_accessor :alert
|
|
43
|
+
|
|
44
|
+
# 警告をファイルに追記するか(デフォルト: nil = 無効)
|
|
45
|
+
# ファイルパスを文字列で指定する
|
|
46
|
+
# c.log_file = Rails.root.join("log/bulletproof.log").to_s
|
|
47
|
+
attr_accessor :log_file
|
|
48
|
+
|
|
49
|
+
# カスタム通知先。Slack 等に飛ばしたいときに設定する(デフォルト: nil = 無効)
|
|
50
|
+
# callable で RuntimeWarning を引数に取る
|
|
51
|
+
# c.notifier = ->(w) { SlackNotifier.ping(w.message) }
|
|
52
|
+
attr_accessor :notifier
|
|
53
|
+
|
|
54
|
+
def initialize
|
|
55
|
+
@enabled = false
|
|
56
|
+
@max_includes_depth = 2
|
|
57
|
+
@max_associations = 3
|
|
58
|
+
@max_records_per_model = 1_000
|
|
59
|
+
@max_total_records = 5_000
|
|
60
|
+
@max_gc_runs_per_request = nil
|
|
61
|
+
@rails_logger = true
|
|
62
|
+
@console = true
|
|
63
|
+
@alert = false
|
|
64
|
+
@log_file = nil
|
|
65
|
+
@notifier = nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop-ast"
|
|
4
|
+
|
|
5
|
+
module Bulletproof
|
|
6
|
+
module Detectors
|
|
7
|
+
# Rubyソースを静的解析し、メモリ過剰消費につながるincludes呼び出しを検出する
|
|
8
|
+
#
|
|
9
|
+
# 検出の前提:
|
|
10
|
+
# includesのネスト深さ・アソシエーション数が大きくても、
|
|
11
|
+
# 件数を絞るメソッドがチェーンにあれば安全とみなす。
|
|
12
|
+
#
|
|
13
|
+
# 危険パターン(フラグを立てる):
|
|
14
|
+
# User.includes(posts: { comments: :author }).all # 全件 + 深いネスト
|
|
15
|
+
# User.includes(:a, :b, :c, :d) # 全件 + 多アソシエーション
|
|
16
|
+
#
|
|
17
|
+
# 安全パターン(フラグを立てない):
|
|
18
|
+
# User.includes(posts: { comments: :author }).limit(10) # 件数制限あり
|
|
19
|
+
# User.includes(posts: { comments: :author }).find(1) # 単件取得
|
|
20
|
+
# User.includes(posts: { comments: :author }).first # 単件取得
|
|
21
|
+
# User.includes(:a, :b, :c, :d).page(1).per(20) # ページネーションあり
|
|
22
|
+
LIMITING_METHODS = %i[
|
|
23
|
+
limit find find_by find_by!
|
|
24
|
+
first first! last last! take take!
|
|
25
|
+
page paginate per per_page
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
class ExcessiveIncludesDetector
|
|
29
|
+
def initialize(config)
|
|
30
|
+
@config = config
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param source [String] Rubyソースコード
|
|
34
|
+
# @param file [String] ファイルパス(表示用)
|
|
35
|
+
# @return [Array<Violation>]
|
|
36
|
+
def call(source, file: "(string)")
|
|
37
|
+
processed = RuboCop::AST::ProcessedSource.new(source, RUBY_VERSION.to_f, file)
|
|
38
|
+
return [] unless processed.valid_syntax?
|
|
39
|
+
|
|
40
|
+
parent_map = build_parent_map(processed.ast)
|
|
41
|
+
violations = []
|
|
42
|
+
collect_includes_nodes(processed.ast).each do |node|
|
|
43
|
+
check(node, violations, file, parent_map)
|
|
44
|
+
end
|
|
45
|
+
violations
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# ---- AST 走査 --------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def collect_includes_nodes(node, result = [])
|
|
53
|
+
return result unless node.is_a?(RuboCop::AST::Node)
|
|
54
|
+
|
|
55
|
+
result << node if includes_call?(node)
|
|
56
|
+
node.each_child_node { |child| collect_includes_nodes(child, result) }
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# 各ノードの親を記録するマップを構築する
|
|
61
|
+
def build_parent_map(node, parent = nil, map = {}.compare_by_identity)
|
|
62
|
+
return map unless node.is_a?(RuboCop::AST::Node)
|
|
63
|
+
|
|
64
|
+
map[node] = parent
|
|
65
|
+
node.each_child_node { |child| build_parent_map(child, node, map) }
|
|
66
|
+
map
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ---- チェーン解析 ----------------------------------------------------
|
|
70
|
+
|
|
71
|
+
# includes ノードから連続する send チェーンの根(最外ノード)を返す
|
|
72
|
+
def chain_root(node, parent_map)
|
|
73
|
+
current = node
|
|
74
|
+
loop do
|
|
75
|
+
parent = parent_map[current]
|
|
76
|
+
break unless parent&.send_type? && parent.receiver.equal?(current)
|
|
77
|
+
|
|
78
|
+
current = parent
|
|
79
|
+
end
|
|
80
|
+
current
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# send チェーンに含まれる全メソッド名を収集する(チェーン根から下向き)
|
|
84
|
+
def collect_chain_methods(node, methods = [])
|
|
85
|
+
return methods unless node.is_a?(RuboCop::AST::Node) && node.send_type?
|
|
86
|
+
|
|
87
|
+
methods << node.method_name
|
|
88
|
+
collect_chain_methods(node.receiver, methods)
|
|
89
|
+
methods
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ---- 検出ロジック ----------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def includes_call?(node)
|
|
95
|
+
node.send_type? && node.method_name == :includes
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def check(node, violations, file, parent_map)
|
|
99
|
+
depth = max_includes_depth(node)
|
|
100
|
+
count = count_top_level_associations(node.arguments)
|
|
101
|
+
|
|
102
|
+
deep = depth >= @config.max_includes_depth
|
|
103
|
+
wide = count > @config.max_associations
|
|
104
|
+
|
|
105
|
+
return unless deep || wide
|
|
106
|
+
|
|
107
|
+
root = chain_root(node, parent_map)
|
|
108
|
+
methods = collect_chain_methods(root)
|
|
109
|
+
|
|
110
|
+
return if (methods & LIMITING_METHODS).any?
|
|
111
|
+
|
|
112
|
+
issues = []
|
|
113
|
+
issues << "ネスト深さ #{depth}(上限: #{@config.max_includes_depth})" if deep
|
|
114
|
+
issues << "アソシエーション数 #{count}(上限: #{@config.max_associations})" if wide
|
|
115
|
+
|
|
116
|
+
violations << Violation.new(
|
|
117
|
+
file: file,
|
|
118
|
+
line: node.loc.line,
|
|
119
|
+
message: "#{issues.join("、")} かつ件数を絞るメソッド(limit / find 等)がチェーンにありません",
|
|
120
|
+
severity: :warning
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# ---- 深さ・幅の計算 -------------------------------------------------
|
|
125
|
+
|
|
126
|
+
# includes 引数のネスト深さを計算する
|
|
127
|
+
# :posts → 0
|
|
128
|
+
# { posts: :comments } → 1
|
|
129
|
+
# { posts: { comments: :author } } → 2
|
|
130
|
+
# { posts: [:comments, :likes] } → 1
|
|
131
|
+
def node_depth(node)
|
|
132
|
+
case node.type
|
|
133
|
+
when :hash then 1 + (node.each_pair.map { |pair| node_depth(pair.value) }.max || 0)
|
|
134
|
+
when :array then node.children.map { |child| node_depth(child) }.max || 0
|
|
135
|
+
else 0
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def max_includes_depth(node)
|
|
140
|
+
node.arguments.map { |arg| node_depth(arg) }.max || 0
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# トップレベルのアソシエーション数を数える
|
|
144
|
+
# includes(:a, :b) → 2
|
|
145
|
+
# includes(a: :b, c: :d) → 2(ハッシュのキー数)
|
|
146
|
+
# includes(:a, b: :c) → 2(sym + ハッシュキー)
|
|
147
|
+
def count_top_level_associations(args)
|
|
148
|
+
args.sum do |arg|
|
|
149
|
+
case arg.type
|
|
150
|
+
when :sym, :str then 1
|
|
151
|
+
when :hash then arg.keys.size
|
|
152
|
+
else 0
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Bulletproof
|
|
7
|
+
# Rack ミドルウェア。リクエストごとに AR ロード件数・GC 負荷を計測し、
|
|
8
|
+
# 閾値を超えた場合は設定済みの通知先へ RuntimeWarning を送る。
|
|
9
|
+
#
|
|
10
|
+
# 通知先:
|
|
11
|
+
# rails_logger: true → Rails.logger.warn に出力
|
|
12
|
+
# console: true → HTML に console.warn を注入(開発者ツール Console タブ)
|
|
13
|
+
# alert: true → HTML にオーバーレイパネルを注入(画面上で確認)
|
|
14
|
+
# log_file: "path" → ファイルに追記
|
|
15
|
+
# notifier: callable → Slack 等カスタム通知先
|
|
16
|
+
class Middleware
|
|
17
|
+
def initialize(app, config = Bulletproof.config)
|
|
18
|
+
@app = app
|
|
19
|
+
@config = config
|
|
20
|
+
@monitor = Runtime::RequestMonitor.new(config)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(env)
|
|
24
|
+
response, warnings = @monitor.monitor { @app.call(env) }
|
|
25
|
+
return response if warnings.empty?
|
|
26
|
+
|
|
27
|
+
notify_logger(warnings)
|
|
28
|
+
notify_log_file(warnings)
|
|
29
|
+
notify_custom(warnings)
|
|
30
|
+
inject_html(response, warnings)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# ---- ログ通知 ------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def notify_logger(warnings)
|
|
38
|
+
return unless @config.rails_logger
|
|
39
|
+
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
40
|
+
|
|
41
|
+
warnings.each { |w| Rails.logger.warn("[Bulletproof] #{w.message}") }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def notify_log_file(warnings)
|
|
45
|
+
return unless @config.log_file
|
|
46
|
+
|
|
47
|
+
File.open(@config.log_file, "a") do |f|
|
|
48
|
+
warnings.each do |w|
|
|
49
|
+
f.puts "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}] [#{w.severity.upcase}] [#{w.type}] #{w.message}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def notify_custom(warnings)
|
|
55
|
+
return unless @config.notifier
|
|
56
|
+
|
|
57
|
+
warnings.each { |w| @config.notifier.call(w) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# ---- HTML 注入 -----------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def inject_html(response, warnings)
|
|
63
|
+
return response unless @config.console || @config.alert
|
|
64
|
+
|
|
65
|
+
status, headers, body = response
|
|
66
|
+
return response unless html?(headers)
|
|
67
|
+
|
|
68
|
+
content = ""
|
|
69
|
+
content += build_console_script(warnings) if @config.console
|
|
70
|
+
content += build_alert_overlay(warnings) if @config.alert
|
|
71
|
+
|
|
72
|
+
new_body = inject_into_body(body, content)
|
|
73
|
+
[status, update_content_length(headers, new_body), new_body]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def html?(headers)
|
|
77
|
+
(headers["content-type"] || "").include?("text/html")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# console.warn を発火する <script> タグ
|
|
81
|
+
def build_console_script(warnings)
|
|
82
|
+
lines = warnings.map do |w|
|
|
83
|
+
" console.warn('[Bulletproof] ' + #{JSON.generate(w.message)});"
|
|
84
|
+
end.join("\n")
|
|
85
|
+
"\n<script>\n#{lines}\n</script>"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# 画面右下に浮かぶオーバーレイパネル
|
|
89
|
+
def build_alert_overlay(warnings)
|
|
90
|
+
items = warnings.map do |w|
|
|
91
|
+
msg = w.message
|
|
92
|
+
.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
93
|
+
.gsub("\n", "<br> ")
|
|
94
|
+
"<li style='margin-bottom:6px;'>#{msg}</li>"
|
|
95
|
+
end.join
|
|
96
|
+
|
|
97
|
+
<<~HTML
|
|
98
|
+
<div id="__bp_overlay__" style="position:fixed;bottom:16px;right:16px;max-width:500px;background:#fff3cd;border:2px solid #ffc107;border-radius:8px;padding:12px 16px;font-family:monospace;font-size:12px;line-height:1.5;z-index:999999;box-shadow:0 4px 16px rgba(0,0,0,0.2);">
|
|
99
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
|
100
|
+
<strong style="color:#856404;font-size:13px;">⚠ Bulletproof (#{warnings.size}件の警告)</strong>
|
|
101
|
+
<button onclick="document.getElementById('__bp_overlay__').remove()" style="background:none;border:none;cursor:pointer;font-size:20px;line-height:1;color:#856404;padding:0 0 0 12px;">✕</button>
|
|
102
|
+
</div>
|
|
103
|
+
<ul style="margin:0;padding:0 0 0 16px;">#{items}</ul>
|
|
104
|
+
</div>
|
|
105
|
+
HTML
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def inject_into_body(body, content)
|
|
109
|
+
chunks = []
|
|
110
|
+
body.each { |chunk| chunks << chunk } # rubocop:disable Style/MapIntoArray
|
|
111
|
+
injected = false
|
|
112
|
+
chunks.map do |chunk|
|
|
113
|
+
if !injected && chunk.include?("</body>")
|
|
114
|
+
injected = true
|
|
115
|
+
chunk.sub("</body>", "#{content}\n</body>")
|
|
116
|
+
else
|
|
117
|
+
chunk
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def update_content_length(headers, body)
|
|
123
|
+
return headers unless headers["content-length"]
|
|
124
|
+
|
|
125
|
+
headers.merge("content-length" => body.sum(&:bytesize).to_s)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletproof
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
# config/initializers が読まれた後にミドルウェアを挿入する。
|
|
6
|
+
# これにより、ユーザーが initializer 内で設定した値が反映される。
|
|
7
|
+
initializer "bulletproof.insert_middleware", after: :load_config_initializers do |app|
|
|
8
|
+
app.middleware.use Bulletproof::Middleware if Bulletproof.config.enabled
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletproof
|
|
4
|
+
Violation = Data.define(:file, :line, :message, :severity)
|
|
5
|
+
|
|
6
|
+
class Report
|
|
7
|
+
attr_reader :violations
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@violations = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def add_violation(violation)
|
|
14
|
+
@violations << violation
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def ok?
|
|
19
|
+
@violations.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_s
|
|
23
|
+
return "No violations found." if ok?
|
|
24
|
+
|
|
25
|
+
@violations.map do |v|
|
|
26
|
+
"[#{v.severity.upcase}] #{v.file}:#{v.line} — #{v.message}"
|
|
27
|
+
end.join("\n")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletproof
|
|
4
|
+
module Runtime
|
|
5
|
+
# コールスタックからアプリケーションコードのフレームを探すユーティリティ
|
|
6
|
+
#
|
|
7
|
+
# gem 内部・bulletproof 自身のフレームを除外し、
|
|
8
|
+
# 最初にヒットしたアプリケーションコードの位置を返す。
|
|
9
|
+
module CallstackFilter
|
|
10
|
+
# 除外するパスのパターン(gem・bulletproof 自身・eval を対象外にする)
|
|
11
|
+
EXCLUDE_PATTERNS = [
|
|
12
|
+
%r{/gems/}, # bundler でインストールされた gem
|
|
13
|
+
%r{lib/bulletproof}, # bulletproof 自身
|
|
14
|
+
/\A\(eval\)/ # eval されたコード
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
# @param locations [Array<Thread::Backtrace::Location>]
|
|
18
|
+
# @return [String, nil] "path/to/file.rb:42:in 'method_name'" 形式、見つからなければ nil
|
|
19
|
+
def self.app_location(locations)
|
|
20
|
+
frame = locations.find do |loc|
|
|
21
|
+
path = loc.absolute_path || loc.path || ""
|
|
22
|
+
EXCLUDE_PATTERNS.none? { |pattern| path.match?(pattern) }
|
|
23
|
+
end
|
|
24
|
+
frame&.to_s
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletproof
|
|
4
|
+
module Runtime
|
|
5
|
+
# GC 統計を計測するユーティリティ
|
|
6
|
+
# RSS 計測はオーバーヘッドと精度の問題があるため採用しない
|
|
7
|
+
module MemorySampler
|
|
8
|
+
# GC 統計のスナップショットを返す(低オーバーヘッド)
|
|
9
|
+
def self.gc_snapshot
|
|
10
|
+
GC.stat.slice(:count, :total_allocated_objects, :heap_allocated_pages)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletproof
|
|
4
|
+
module Runtime
|
|
5
|
+
# リクエスト中に ActiveRecord の instantiation.active_record イベントを購読し、
|
|
6
|
+
# モデルごとのロード件数とロード発生箇所を集計する。
|
|
7
|
+
#
|
|
8
|
+
# スレッドローカル変数を使うため、Puma 等のマルチスレッドサーバーでも
|
|
9
|
+
# 並列リクエスト間で集計が混線しない。
|
|
10
|
+
#
|
|
11
|
+
# 同一モデルが複数回ロードされた場合は件数を合算し、ロード箇所は初回を記録する。
|
|
12
|
+
module ModelLoadCollector
|
|
13
|
+
THREAD_KEY = :__bulletproof_ar_loads__
|
|
14
|
+
private_constant :THREAD_KEY
|
|
15
|
+
|
|
16
|
+
# ブロック実行中のイベントを収集し、ModelLoadEvent の配列を返す(件数降順)
|
|
17
|
+
def self.collect(&block)
|
|
18
|
+
Thread.current[THREAD_KEY] = {}
|
|
19
|
+
|
|
20
|
+
ActiveSupport::Notifications.subscribed(
|
|
21
|
+
method(:handle_event),
|
|
22
|
+
"instantiation.active_record", &block
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
build_events(Thread.current[THREAD_KEY])
|
|
26
|
+
ensure
|
|
27
|
+
Thread.current[THREAD_KEY] = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# ActiveSupport::Notifications のコールバック形式 (name, start, finish, id, payload)
|
|
31
|
+
# ActiveRecord の find_each / in_batches 経由かどうかを判定するパス
|
|
32
|
+
BATCH_PATH_PATTERN = %r{activerecord.*/relation/batches}
|
|
33
|
+
|
|
34
|
+
def self.handle_event(_name, _start, _finish, _id, payload)
|
|
35
|
+
loads = Thread.current[THREAD_KEY]
|
|
36
|
+
return unless loads
|
|
37
|
+
|
|
38
|
+
model_name = payload[:class_name]
|
|
39
|
+
record_count = payload[:record_count].to_i
|
|
40
|
+
locs = caller_locations(1)
|
|
41
|
+
batched = locs.any? { |l| (l.absolute_path || l.path || "").match?(BATCH_PATH_PATTERN) }
|
|
42
|
+
|
|
43
|
+
if loads.key?(model_name)
|
|
44
|
+
# 2回目以降: 件数を累計、1回の最大値を更新(ロード箇所・batched フラグは初回を保持)
|
|
45
|
+
loads[model_name][:count] += record_count
|
|
46
|
+
loads[model_name][:max_single_load] = [loads[model_name][:max_single_load], record_count].max
|
|
47
|
+
else
|
|
48
|
+
# 初回: アプリコードのフレームをキャプチャ
|
|
49
|
+
location = CallstackFilter.app_location(locs)
|
|
50
|
+
loads[model_name] =
|
|
51
|
+
{ count: record_count, max_single_load: record_count, batched: batched, location: location }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
private_class_method :handle_event
|
|
55
|
+
|
|
56
|
+
def self.build_events(loads)
|
|
57
|
+
loads
|
|
58
|
+
.map do |model_name, data|
|
|
59
|
+
ModelLoadEvent.new(
|
|
60
|
+
model_name: model_name,
|
|
61
|
+
record_count: data[:count],
|
|
62
|
+
max_single_load: data[:max_single_load],
|
|
63
|
+
batched: data[:batched],
|
|
64
|
+
caller_location: data[:location]
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
.sort_by { |e| -e.record_count }
|
|
68
|
+
end
|
|
69
|
+
private_class_method :build_events
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletproof
|
|
4
|
+
module Runtime
|
|
5
|
+
# 1モデルのロード集計結果
|
|
6
|
+
# record_count: リクエスト全体の累計ロード件数(find_each の複数バッチも合算)
|
|
7
|
+
# max_single_load: 1回のイベントで最大何件ロードされたか(find_each のバッチ上限に相当)
|
|
8
|
+
# batched: find_each / in_batches 経由のロードかどうか
|
|
9
|
+
# caller_location: アプリコードで最初にロードが発生した箇所("path:line:in 'method'" 形式)
|
|
10
|
+
ModelLoadEvent = Data.define(:model_name, :record_count, :max_single_load, :batched, :caller_location)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletproof
|
|
4
|
+
module Runtime
|
|
5
|
+
# リクエスト中の AR ロード件数・GC 統計を計測し、閾値超過時に RuntimeWarning を生成する
|
|
6
|
+
class RequestMonitor
|
|
7
|
+
Snapshot = Data.define(
|
|
8
|
+
:ar_loads, # Array<ModelLoadEvent> モデルごとのロード件数
|
|
9
|
+
:total_records, # Integer リクエスト全体の合計レコード数
|
|
10
|
+
:gc_count_delta, # Integer GC 実行回数の増分
|
|
11
|
+
:allocated_objects_delta # Integer 生成オブジェクト数の増分
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def initialize(config)
|
|
15
|
+
@config = config
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# ブロックを実行し、[戻り値, Array<RuntimeWarning>] を返す
|
|
19
|
+
def monitor
|
|
20
|
+
gc_before = MemorySampler.gc_snapshot
|
|
21
|
+
result = nil
|
|
22
|
+
ar_loads = ModelLoadCollector.collect { result = yield }
|
|
23
|
+
gc_after = MemorySampler.gc_snapshot
|
|
24
|
+
|
|
25
|
+
snapshot = Snapshot.new(
|
|
26
|
+
ar_loads: ar_loads,
|
|
27
|
+
total_records: ar_loads.sum(&:record_count),
|
|
28
|
+
gc_count_delta: gc_after[:count] - gc_before[:count],
|
|
29
|
+
allocated_objects_delta: gc_after[:total_allocated_objects] - gc_before[:total_allocated_objects]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
[result, build_warnings(snapshot)]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def build_warnings(snapshot)
|
|
38
|
+
warnings = []
|
|
39
|
+
|
|
40
|
+
snapshot.ar_loads.each do |event|
|
|
41
|
+
# max_single_load で判定することで find_each のバッチ処理を誤検知しない
|
|
42
|
+
# find_each(batch_size: 100) → max_single_load: 100 → 閾値以下なら警告しない
|
|
43
|
+
# Post.all.to_a (500件) → max_single_load: 500 → 閾値超えで警告
|
|
44
|
+
next unless event.max_single_load > @config.max_records_per_model
|
|
45
|
+
|
|
46
|
+
location_hint = event.caller_location ? "\n → #{event.caller_location}" : ""
|
|
47
|
+
warnings << RuntimeWarning.new(
|
|
48
|
+
type: :mass_instantiation,
|
|
49
|
+
message: "#{event.model_name} を一度に #{format_count(event.max_single_load)} 件ロードしました" \
|
|
50
|
+
"(上限: #{format_count(@config.max_records_per_model)} 件)#{location_hint}",
|
|
51
|
+
severity: :warning,
|
|
52
|
+
detail: snapshot
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# find_each / in_batches のバッチ処理は意図的な大量処理なので合計から除外する
|
|
57
|
+
non_batched_loads = snapshot.ar_loads.reject(&:batched)
|
|
58
|
+
non_batched_total = non_batched_loads.sum(&:record_count)
|
|
59
|
+
|
|
60
|
+
if non_batched_total > @config.max_total_records
|
|
61
|
+
summary = non_batched_loads
|
|
62
|
+
.map { |e| "#{e.model_name}: #{format_count(e.record_count)}" }
|
|
63
|
+
.join(", ")
|
|
64
|
+
warnings << RuntimeWarning.new(
|
|
65
|
+
type: :high_total_records,
|
|
66
|
+
message: "リクエスト全体で #{format_count(non_batched_total)} 件のレコードをロードしました" \
|
|
67
|
+
"(上限: #{format_count(@config.max_total_records)} 件)[#{summary}]",
|
|
68
|
+
severity: :warning,
|
|
69
|
+
detail: snapshot
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if @config.max_gc_runs_per_request && snapshot.gc_count_delta > @config.max_gc_runs_per_request
|
|
74
|
+
warnings << RuntimeWarning.new(
|
|
75
|
+
type: :gc_pressure,
|
|
76
|
+
message: "リクエスト中に GC が #{snapshot.gc_count_delta} 回実行されました" \
|
|
77
|
+
"(上限: #{@config.max_gc_runs_per_request} 回)",
|
|
78
|
+
severity: :warning,
|
|
79
|
+
detail: snapshot
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
warnings
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def format_count(num)
|
|
87
|
+
num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/bulletproof.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "bulletproof/version"
|
|
4
|
+
require_relative "bulletproof/configuration"
|
|
5
|
+
require_relative "bulletproof/report"
|
|
6
|
+
require_relative "bulletproof/runtime_warning"
|
|
7
|
+
require_relative "bulletproof/detectors/excessive_includes_detector"
|
|
8
|
+
require_relative "bulletproof/analyzer"
|
|
9
|
+
require_relative "bulletproof/railtie" if defined?(Rails::Railtie)
|
|
10
|
+
require_relative "bulletproof/runtime/memory_sampler"
|
|
11
|
+
require_relative "bulletproof/runtime/callstack_filter"
|
|
12
|
+
require_relative "bulletproof/runtime/model_load_event"
|
|
13
|
+
require_relative "bulletproof/runtime/model_load_collector"
|
|
14
|
+
require_relative "bulletproof/runtime/request_monitor"
|
|
15
|
+
require_relative "bulletproof/middleware"
|
|
16
|
+
|
|
17
|
+
module Bulletproof
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def config
|
|
22
|
+
@config ||= Configuration.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def configure
|
|
26
|
+
yield config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param path [String] 解析対象のファイルまたはディレクトリ
|
|
30
|
+
# @return [Report]
|
|
31
|
+
def analyze(path)
|
|
32
|
+
Analyzer.new(config).call(path)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/sig/bulletproof.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bulletproof
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.3
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- essei0-0
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-17 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activesupport
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '6.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '6.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rubocop-ast
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.0'
|
|
41
|
+
description: Bulletproof detects ActiveRecord includes that load too many records,
|
|
42
|
+
both statically via source analysis and at runtime via ActiveSupport::Notifications.
|
|
43
|
+
email:
|
|
44
|
+
- hello.essei@gmail.com
|
|
45
|
+
executables:
|
|
46
|
+
- bulletproof
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- ".rspec"
|
|
51
|
+
- ".rubocop.yml"
|
|
52
|
+
- CHANGELOG.md
|
|
53
|
+
- CODE_OF_CONDUCT.md
|
|
54
|
+
- LICENSE.txt
|
|
55
|
+
- README.md
|
|
56
|
+
- Rakefile
|
|
57
|
+
- exe/bulletproof
|
|
58
|
+
- lib/bulletproof.rb
|
|
59
|
+
- lib/bulletproof/analyzer.rb
|
|
60
|
+
- lib/bulletproof/configuration.rb
|
|
61
|
+
- lib/bulletproof/detectors/excessive_includes_detector.rb
|
|
62
|
+
- lib/bulletproof/middleware.rb
|
|
63
|
+
- lib/bulletproof/railtie.rb
|
|
64
|
+
- lib/bulletproof/report.rb
|
|
65
|
+
- lib/bulletproof/runtime/callstack_filter.rb
|
|
66
|
+
- lib/bulletproof/runtime/memory_sampler.rb
|
|
67
|
+
- lib/bulletproof/runtime/model_load_collector.rb
|
|
68
|
+
- lib/bulletproof/runtime/model_load_event.rb
|
|
69
|
+
- lib/bulletproof/runtime/request_monitor.rb
|
|
70
|
+
- lib/bulletproof/runtime_warning.rb
|
|
71
|
+
- lib/bulletproof/version.rb
|
|
72
|
+
- sig/bulletproof.rbs
|
|
73
|
+
homepage: https://github.com/essei0-0/bulletproof
|
|
74
|
+
licenses:
|
|
75
|
+
- MIT
|
|
76
|
+
metadata:
|
|
77
|
+
homepage_uri: https://github.com/essei0-0/bulletproof
|
|
78
|
+
rubygems_mfa_required: 'true'
|
|
79
|
+
post_install_message:
|
|
80
|
+
rdoc_options: []
|
|
81
|
+
require_paths:
|
|
82
|
+
- lib
|
|
83
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: 3.0.0
|
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '0'
|
|
93
|
+
requirements: []
|
|
94
|
+
rubygems_version: 3.5.11
|
|
95
|
+
signing_key:
|
|
96
|
+
specification_version: 4
|
|
97
|
+
summary: Linter and runtime monitor for expensive ActiveRecord includes
|
|
98
|
+
test_files: []
|