rails_vitals 0.2.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/MIT-LICENSE +20 -0
- data/README.md +340 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/rails_vitals/application.css +180 -0
- data/app/controllers/rails_vitals/application_controller.rb +30 -0
- data/app/controllers/rails_vitals/associations_controller.rb +8 -0
- data/app/controllers/rails_vitals/dashboard_controller.rb +59 -0
- data/app/controllers/rails_vitals/heatmap_controller.rb +39 -0
- data/app/controllers/rails_vitals/models_controller.rb +65 -0
- data/app/controllers/rails_vitals/n_plus_ones_controller.rb +43 -0
- data/app/controllers/rails_vitals/requests_controller.rb +44 -0
- data/app/helpers/rails_vitals/application_helper.rb +63 -0
- data/app/jobs/rails_vitals/application_job.rb +4 -0
- data/app/mailers/rails_vitals/application_mailer.rb +6 -0
- data/app/models/rails_vitals/application_record.rb +5 -0
- data/app/views/layouts/rails_vitals/application.html.erb +27 -0
- data/app/views/rails_vitals/associations/index.html.erb +370 -0
- data/app/views/rails_vitals/dashboard/index.html.erb +158 -0
- data/app/views/rails_vitals/heatmap/index.html.erb +66 -0
- data/app/views/rails_vitals/models/index.html.erb +117 -0
- data/app/views/rails_vitals/n_plus_ones/index.html.erb +49 -0
- data/app/views/rails_vitals/n_plus_ones/show.html.erb +139 -0
- data/app/views/rails_vitals/requests/index.html.erb +60 -0
- data/app/views/rails_vitals/requests/show.html.erb +396 -0
- data/config/routes.rb +9 -0
- data/lib/rails_vitals/analyzers/association_mapper.rb +121 -0
- data/lib/rails_vitals/analyzers/n_plus_one_aggregator.rb +116 -0
- data/lib/rails_vitals/analyzers/sql_tokenizer.rb +240 -0
- data/lib/rails_vitals/collector.rb +78 -0
- data/lib/rails_vitals/configuration.rb +27 -0
- data/lib/rails_vitals/engine.rb +25 -0
- data/lib/rails_vitals/instrumentation/callback_instrumentation.rb +30 -0
- data/lib/rails_vitals/middleware/panel_injector.rb +75 -0
- data/lib/rails_vitals/notifications/subscriber.rb +59 -0
- data/lib/rails_vitals/panel_renderer.rb +233 -0
- data/lib/rails_vitals/request_record.rb +51 -0
- data/lib/rails_vitals/scorers/base_scorer.rb +25 -0
- data/lib/rails_vitals/scorers/composite_scorer.rb +36 -0
- data/lib/rails_vitals/scorers/n_plus_one_scorer.rb +43 -0
- data/lib/rails_vitals/scorers/query_scorer.rb +42 -0
- data/lib/rails_vitals/store.rb +34 -0
- data/lib/rails_vitals/version.rb +3 -0
- data/lib/rails_vitals.rb +33 -0
- data/lib/tasks/rails_vitals_tasks.rake +4 -0
- metadata +113 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f17a9a533950167bfbd68780d388d56e95ec38fb442958ddf7228d323190eb24
|
|
4
|
+
data.tar.gz: 165535e4b833e3f622965142d6605a7423a85a0a607131eed1a3240c4cea8fe7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0d7b5af657229031b4189605e661028ccda2f169f585acc48188d7a34a22aa0ed7422bee6291b79edf52d7cee46aedc403c0a823fa8c1f6139a4925b8a4dd4dc
|
|
7
|
+
data.tar.gz: 49d029d3b489e21656b1f750066d759611785b077f9e32c1a42a2d2d784c2abe3efa31728100250d17c651f765072b11cf9c151bdb9998f06d6677bc5752dd05
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright David Sanchez
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# β‘ RailsVitals
|
|
2
|
+
|
|
3
|
+
> **The Rails gem that made me understand performance.**
|
|
4
|
+
|
|
5
|
+
RailsVitals is a zero-dependency Rails Engine that instruments every request in your app and surfaces performance diagnostics: N+1 queries, slow SQL, fat callbacks, and health scores through an embedded admin UI and an injectable panel overlay.
|
|
6
|
+
|
|
7
|
+
It doesn't just tell you something is wrong. It shows you **why**, **where**, and **what to do about it**.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Screenshots
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
> Full admin UI at `/rails_vitals` with Dashboard, Request History,
|
|
16
|
+
> Request Detail with Query DNA, Endpoint Heatmap, Per-Model Breakdown,
|
|
17
|
+
> N+1 Patterns and Association Map.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
### π΄ Per-Request Health Score
|
|
24
|
+
Every request gets a score from 0β100 based on query volume and N+1 severity. Scores are color-coded: Healthy (90β100), Acceptable (70β89), Warning (50β69), Critical (0β49).
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
### 𧬠Query DNA β Visual SQL Fingerprinting
|
|
29
|
+
Every query in a request is decomposed into color-coded tokens: `SELECT *`, `WHERE fk =`, `IN (...)`, `JOIN`, `ORDER BY`, `OFFSET`, and more. Click any query to expand its DNA. Click any token to read an education card explaining what it means, why it matters, and how to fix it.
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
### πΊοΈ Association Map
|
|
34
|
+
A live SVG diagram of your ActiveRecord model graph, annotated with N+1 status, query counts, average query time, foreign key names, and index status on every edge. Nodes light up red when N+1 patterns are detected. Dashed edges signal missing indexes on foreign keys. Click any node to open a detail panel with fix suggestions and links to affected requests.
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
### π₯ Endpoint Heatmap
|
|
39
|
+
A ranked table of every endpoint in your app sorted by worst average health score. Columns: average score, hit count, average query count, average DB time, average callback time, N+1 frequency. The worst offenders surface immediately.
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
### π Per-Model Breakdown
|
|
44
|
+
Which ActiveRecord models are hammering your database? The model breakdown aggregates queries by table, shows total query count, total DB time, average query time, and the endpoints responsible.
|
|
45
|
+
|
|
46
|
+

|
|
47
|
+
|
|
48
|
+
### π N+1 Pattern Detector
|
|
49
|
+
Cross-request N+1 aggregation using normalized SQL fingerprinting. Each pattern shows total occurrences, affected endpoints, and a concrete fix suggestion generated by reflecting on your actual ActiveRecord associations e.g. `Post.includes(:likes)`, not a generic hint.
|
|
50
|
+
|
|
51
|
+

|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
### π₯ Impact Simulator
|
|
55
|
+
Each N+1 pattern has a detail page showing affected requests, estimated query savings, and a generated migration-ready fix. See the blast radius before you write a line of code.
|
|
56
|
+
|
|
57
|
+

|
|
58
|
+
|
|
59
|
+
### π Callback Map
|
|
60
|
+
Every ActiveRecord callback (`before_save`, `after_create`, `before_validation`, etc.) is timed and grouped by model in the Request Detail view. Expensive callbacks surface immediately β including hidden side effects like callbacks that trigger additional queries.
|
|
61
|
+
|
|
62
|
+

|
|
63
|
+
|
|
64
|
+
### π‘οΈ Injected Panel
|
|
65
|
+
A collapsible overlay injected into every HTML response showing the current request's score, query count, DB time, N+1 count, and callback time. Zero configuration. Disappears in production.
|
|
66
|
+
|
|
67
|
+

|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
Add to your Gemfile:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
gem "rails_vitals", group: :development
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Run:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
bundle install
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Mount the engine in `config/routes.rb`:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
mount RailsVitals::Engine, at: "/rails_vitals"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
That's it. Visit `/rails_vitals` and start browsing your app.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Configuration
|
|
96
|
+
|
|
97
|
+
RailsVitals works out of the box with sensible defaults. To customize, add an initializer:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
# config/initializers/rails_vitals.rb
|
|
101
|
+
RailsVitals.configure do |config|
|
|
102
|
+
# Enable/disable instrumentation entirely
|
|
103
|
+
config.enabled = !Rails.env.production?
|
|
104
|
+
|
|
105
|
+
# Ring buffer size β how many requests to keep in memory
|
|
106
|
+
config.store_size = 200
|
|
107
|
+
|
|
108
|
+
# Query count thresholds for scoring
|
|
109
|
+
config.query_warn_threshold = 10 # above this β score starts dropping
|
|
110
|
+
config.query_critical_threshold = 25 # above this β score = 0
|
|
111
|
+
|
|
112
|
+
# DB time thresholds (ms) for slow query detection
|
|
113
|
+
config.db_time_warn_ms = 100
|
|
114
|
+
config.db_time_critical_ms = 500
|
|
115
|
+
|
|
116
|
+
# Admin UI authentication
|
|
117
|
+
# :none β no auth (default for development)
|
|
118
|
+
# :basic β HTTP Basic Auth
|
|
119
|
+
# lambda β custom auth logic
|
|
120
|
+
config.auth = :none
|
|
121
|
+
|
|
122
|
+
# Basic auth credentials (if auth: :basic)
|
|
123
|
+
config.basic_auth_username = "admin"
|
|
124
|
+
config.basic_auth_password = "secret"
|
|
125
|
+
|
|
126
|
+
# Custom auth lambda (if auth: :lambda)
|
|
127
|
+
# config.auth = ->(request) { request.session[:admin] == true }
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Admin UI
|
|
134
|
+
|
|
135
|
+
Navigate to `/rails_vitals` to access the full admin interface.
|
|
136
|
+
|
|
137
|
+
| Page | Path | Description |
|
|
138
|
+
|------|------|-------------|
|
|
139
|
+
| Dashboard | `/rails_vitals` | Score distribution, health trend, query volume |
|
|
140
|
+
| Requests | `/rails_vitals/requests` | Full request history with filters |
|
|
141
|
+
| Request Detail | `/rails_vitals/requests/:id` | Queries, Query DNA, Callback Map, Score Projection |
|
|
142
|
+
| Heatmap | `/rails_vitals/heatmap` | Endpoints ranked by worst health score |
|
|
143
|
+
| Models | `/rails_vitals/models` | Per-model query breakdown |
|
|
144
|
+
| N+1 Patterns | `/rails_vitals/n_plus_ones` | Cross-request N+1 aggregation with fix suggestions |
|
|
145
|
+
| Association Map | `/rails_vitals/associations` | Live SVG model graph with N+1 and index annotations |
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## How Scoring Works
|
|
150
|
+
|
|
151
|
+
RailsVitals scores each request using a `CompositeScorer` with two weighted dimensions:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
Score = (QueryScore Γ 40%) + (N+1Score Γ 60%)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**QueryScore** (40%) β penalizes query volume:
|
|
158
|
+
- β€ `query_warn_threshold` queries β 100
|
|
159
|
+
- β₯ `query_critical_threshold` queries β 0
|
|
160
|
+
- Between thresholds β linear interpolation
|
|
161
|
+
|
|
162
|
+
**N+1Score** (60%) β penalizes detected patterns:
|
|
163
|
+
- 0 patterns β 100
|
|
164
|
+
- 1 pattern β 75
|
|
165
|
+
- 2 patterns β 50
|
|
166
|
+
- 3 patterns β 25
|
|
167
|
+
- 4+ patterns β 0
|
|
168
|
+
|
|
169
|
+
N+1 patterns are weighted more heavily because they represent an architectural problem that grows worse as data scales not just a snapshot of current query volume.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Query DNA Token Reference
|
|
174
|
+
|
|
175
|
+
| Token | Color | Risk | What it means |
|
|
176
|
+
|-------|-------|------|---------------|
|
|
177
|
+
| `SELECT *` | Blue | β Warning | Fetches all columns β consider `.select(:id, :name)` |
|
|
178
|
+
| `SELECT` | Blue | β
Healthy | Specific column selection |
|
|
179
|
+
| `COUNT(*)` | Amber | β Warning | Aggregation in a loop = N+1 variant |
|
|
180
|
+
| `AGGREGATE` | Amber | β Warning | SUM/AVG/MIN/MAX in a loop |
|
|
181
|
+
| `FROM` | Green | β
Healthy | Identifies the model being queried |
|
|
182
|
+
| `WHERE fk =` | Red | π΄ Danger | Single FK lookup β the N+1 signature |
|
|
183
|
+
| `WHERE` | Orange | β Neutral | Filter condition β check for missing index |
|
|
184
|
+
| `IN (...)` | Green | β
Healthy | Batch lookup β eager loading is working |
|
|
185
|
+
| `INNER JOIN` | Purple | β Neutral | `.joins()` β note: association not loaded |
|
|
186
|
+
| `LEFT JOIN` | Purple | β Neutral | `.eager_load()` or `.left_joins()` |
|
|
187
|
+
| `ORDER BY` | Cyan | β Warning | Ensure sort column has an index |
|
|
188
|
+
| `LIMIT` | Gray | β
Healthy | Good β always paginate |
|
|
189
|
+
| `OFFSET` | Red | β Warning | O(n) at scale β consider cursor pagination |
|
|
190
|
+
| `GROUP BY` | Cyan | β Neutral | Consider counter cache for frequent use |
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## N+1 Fix Suggestions
|
|
195
|
+
|
|
196
|
+
RailsVitals generates fix suggestions by reflecting on your actual ActiveRecord associations, not by guessing. Given a detected pattern:
|
|
197
|
+
|
|
198
|
+
```sql
|
|
199
|
+
SELECT "likes".* FROM "likes" WHERE "likes"."post_id" = ?
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
It extracts the foreign key (`post_id`), infers the owner model (`Post`), reflects on `Post.reflect_on_all_associations`, and generates:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
Post.includes(:likes)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Real associations, real fix, zero guesswork.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Association Map β Reading the Diagram
|
|
213
|
+
|
|
214
|
+
- π’ **Green node** β model is queried, no N+1 detected
|
|
215
|
+
- π΄ **Red node** β N+1 patterns detected on this model's associations
|
|
216
|
+
- β¬ **Gray node** β model exists but hasn't been queried in recent requests
|
|
217
|
+
- **Solid edge** β foreign key is indexed β
|
|
218
|
+
- **Dashed edge** β foreign key is missing an index β οΈ
|
|
219
|
+
- **Orange edge** β `has_many` / `has_one`
|
|
220
|
+
- **Purple edge** β `belongs_to`
|
|
221
|
+
- **Red edge** β association has active N+1 patterns
|
|
222
|
+
|
|
223
|
+
Click any node to open the detail panel with query stats, association breakdown, fix suggestions, and links to affected requests.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Architecture
|
|
228
|
+
|
|
229
|
+
RailsVitals is a mountable Rails Engine with zero runtime dependencies beyond Rails itself.
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
rails_vitals/
|
|
233
|
+
βββ lib/
|
|
234
|
+
β βββ rails_vitals/
|
|
235
|
+
β βββ engine.rb # Mountable engine
|
|
236
|
+
β βββ configuration.rb # Config object
|
|
237
|
+
β βββ collector.rb # Thread-local request collector
|
|
238
|
+
β βββ store.rb # Thread-safe in-memory ring buffer
|
|
239
|
+
β βββ request_record.rb # Immutable request snapshot
|
|
240
|
+
β βββ panel_renderer.rb # Injected HTML panel
|
|
241
|
+
β βββ sse_writer.rb # Server-Sent Events writer
|
|
242
|
+
β βββ notifications/
|
|
243
|
+
β β βββ subscriber.rb # AS::Notifications SQL + controller hooks
|
|
244
|
+
β βββ instrumentation/
|
|
245
|
+
β β βββ callback_instrumentation.rb # Module prepend for AR callbacks
|
|
246
|
+
β βββ analyzers/
|
|
247
|
+
β β βββ sql_tokenizer.rb # Query DNA tokenizer
|
|
248
|
+
β β βββ n_plus_one_aggregator.rb # Cross-request N+1 aggregation
|
|
249
|
+
β β βββ association_mapper.rb # AR reflection + SVG layout
|
|
250
|
+
β βββ scorers/
|
|
251
|
+
β β βββ base_scorer.rb
|
|
252
|
+
β β βββ query_scorer.rb # 40% weight
|
|
253
|
+
β β βββ n_plus_one_scorer.rb # 60% weight
|
|
254
|
+
β β βββ composite_scorer.rb
|
|
255
|
+
β βββ middleware/
|
|
256
|
+
β βββ panel_injector.rb # Rack middleware for panel injection
|
|
257
|
+
βββ app/
|
|
258
|
+
βββ controllers/rails_vitals/
|
|
259
|
+
β βββ dashboard_controller.rb
|
|
260
|
+
β βββ requests_controller.rb
|
|
261
|
+
β βββ heatmap_controller.rb
|
|
262
|
+
β βββ models_controller.rb
|
|
263
|
+
β βββ n_plus_ones_controller.rb
|
|
264
|
+
β βββ associations_controller.rb
|
|
265
|
+
β βββ live_controller.rb
|
|
266
|
+
βββ views/rails_vitals/
|
|
267
|
+
βββ dashboard/
|
|
268
|
+
βββ requests/
|
|
269
|
+
βββ heatmap/
|
|
270
|
+
βββ models/
|
|
271
|
+
βββ n_plus_ones/
|
|
272
|
+
βββ associations/
|
|
273
|
+
βββ live/
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**Key architectural decisions:**
|
|
277
|
+
|
|
278
|
+
- **Zero JS dependencies** β no Chartkick, no D3, no Chart.js. Tables for data, SVG for diagrams, vanilla JS for interactions.
|
|
279
|
+
- **Thread-local Collector** β instrumentation state is stored per-thread, never shared between concurrent requests.
|
|
280
|
+
- **In-memory ring buffer** β the Store keeps the last N requests in memory. No database writes, no schema migrations.
|
|
281
|
+
- **Module prepend for callbacks** β callback instrumentation wraps `ActiveRecord::Base#run_callbacks` via `Module#prepend`. No TracePoint, no monkey-patching.
|
|
282
|
+
- **ActiveSupport::Notifications** β SQL and controller events are captured via the standard Rails instrumentation bus.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Philosophy
|
|
287
|
+
|
|
288
|
+
Most Rails performance tools tell you what is slow. RailsVitals tells you **why it is slow** and **what the right mental model is**.
|
|
289
|
+
|
|
290
|
+
Every feature is designed around a teaching moment:
|
|
291
|
+
|
|
292
|
+
- Query DNA turns SQL into a readable fingerprint with explanations for each token
|
|
293
|
+
- The Association Map connects your data model structure to live performance data
|
|
294
|
+
- N+1 fix suggestions come from your actual associations, not generic advice
|
|
295
|
+
- Score Projection lets you understand the impact of a fix before writing code
|
|
296
|
+
- The Impact Simulator shows blast radius, how many requests are affected, how many queries would be saved
|
|
297
|
+
|
|
298
|
+
The goal is not just a faster app. The goal is a developer who understands **why** the app was slow and **how** to prevent it next time.
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Requirements
|
|
303
|
+
|
|
304
|
+
- Ruby 3.0+
|
|
305
|
+
- Rails 7.0+
|
|
306
|
+
- PostgreSQL (some internal query filters are PostgreSQL-specific)
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Development
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
git clone https://github.com/your-username/rails_vitals
|
|
314
|
+
cd rails_vitals
|
|
315
|
+
bundle install
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
To test against a real app, add to your Gemfile with a local path:
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
gem "rails_vitals", path: "../rails_vitals"
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## Contributing
|
|
327
|
+
|
|
328
|
+
Bug reports and pull requests are welcome on GitHub. This project is intended to be a safe, welcoming space for collaboration.
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## License
|
|
333
|
+
|
|
334
|
+
The gem is available as open source under the terms of the [MIT License](MIT-LICENSE).
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Author
|
|
339
|
+
|
|
340
|
+
Built by [David](https://codeando.dev/)
|
data/Rakefile
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
18
|
+
|
|
19
|
+
body {
|
|
20
|
+
background: #0f0f1a;
|
|
21
|
+
color: #e2e8f0;
|
|
22
|
+
font-family: ui-monospace, monospace;
|
|
23
|
+
font-size: 13px;
|
|
24
|
+
min-height: 100vh;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
a { color: #90cdf4; text-decoration: none; }
|
|
28
|
+
a:hover { text-decoration: underline; }
|
|
29
|
+
|
|
30
|
+
.nav {
|
|
31
|
+
background: #1a1a2e;
|
|
32
|
+
border-bottom: 1px solid #2d3748;
|
|
33
|
+
padding: 16px 24px;
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
gap: 24px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.nav-brand {
|
|
40
|
+
font-size: 20px;
|
|
41
|
+
font-weight: bold;
|
|
42
|
+
color: #90cdf4;
|
|
43
|
+
margin-right: 16px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.nav a.nav-link {
|
|
47
|
+
color: #a0aec0;
|
|
48
|
+
font-size: 12px;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.nav a:hover { color: #e2e8f0; }
|
|
52
|
+
.nav a.active { color: #90cdf4; }
|
|
53
|
+
|
|
54
|
+
.container { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
|
55
|
+
|
|
56
|
+
.page-title {
|
|
57
|
+
font-size: 18px;
|
|
58
|
+
font-weight: bold;
|
|
59
|
+
color: #e2e8f0;
|
|
60
|
+
margin-bottom: 20px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.card {
|
|
64
|
+
background: #1a1a2e;
|
|
65
|
+
border: 1px solid #2d3748;
|
|
66
|
+
border-radius: 8px;
|
|
67
|
+
padding: 16px;
|
|
68
|
+
margin-bottom: 16px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.card-title {
|
|
72
|
+
font-size: 11px;
|
|
73
|
+
text-transform: uppercase;
|
|
74
|
+
letter-spacing: 0.08em;
|
|
75
|
+
color: #a0aec0;
|
|
76
|
+
margin-bottom: 12px;
|
|
77
|
+
|
|
78
|
+
.card-title-description {
|
|
79
|
+
font-size: 10px;
|
|
80
|
+
color: #718096;
|
|
81
|
+
text-transform: none;
|
|
82
|
+
letter-spacing: normal;
|
|
83
|
+
margin-left: 8px;
|
|
84
|
+
font-weight: normal;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.badge {
|
|
89
|
+
display: inline-block;
|
|
90
|
+
padding: 2px 8px;
|
|
91
|
+
border-radius: 4px;
|
|
92
|
+
font-size: 11px;
|
|
93
|
+
font-weight: bold;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.badge-healthy { background: #276749; color: #fff; }
|
|
97
|
+
.badge-acceptable { background: #2b6cb0; color: #fff; }
|
|
98
|
+
.badge-warning { background: #b7791f; color: #fff; }
|
|
99
|
+
.badge-critical { background: #c53030; color: #fff; }
|
|
100
|
+
|
|
101
|
+
table { width: 100%; border-collapse: collapse; }
|
|
102
|
+
th {
|
|
103
|
+
text-align: left;
|
|
104
|
+
color: #a0aec0;
|
|
105
|
+
font-size: 11px;
|
|
106
|
+
text-transform: uppercase;
|
|
107
|
+
letter-spacing: 0.06em;
|
|
108
|
+
padding: 8px 12px;
|
|
109
|
+
border-bottom: 1px solid #2d3748;
|
|
110
|
+
}
|
|
111
|
+
td {
|
|
112
|
+
padding: 10px 12px;
|
|
113
|
+
border-bottom: 1px solid #1e2535;
|
|
114
|
+
color: #e2e8f0;
|
|
115
|
+
}
|
|
116
|
+
tr:hover td { background: #1e2535; }
|
|
117
|
+
|
|
118
|
+
.grid-3 {
|
|
119
|
+
display: grid;
|
|
120
|
+
grid-template-columns: repeat(3, 1fr);
|
|
121
|
+
gap: 16px;
|
|
122
|
+
margin-bottom: 16px;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.stat-card {
|
|
126
|
+
background: #1a1a2e;
|
|
127
|
+
border: 1px solid #2d3748;
|
|
128
|
+
border-radius: 8px;
|
|
129
|
+
padding: 16px;
|
|
130
|
+
text-align: center;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.stat-value {
|
|
134
|
+
font-size: 28px;
|
|
135
|
+
font-weight: bold;
|
|
136
|
+
color: #90cdf4;
|
|
137
|
+
margin-bottom: 4px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.stat-label {
|
|
141
|
+
font-size: 11px;
|
|
142
|
+
color: #a0aec0;
|
|
143
|
+
text-transform: uppercase;
|
|
144
|
+
letter-spacing: 0.06em;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.filter-bar {
|
|
148
|
+
display: flex;
|
|
149
|
+
gap: 8px;
|
|
150
|
+
margin-bottom: 16px;
|
|
151
|
+
flex-wrap: wrap;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.filter-bar a {
|
|
155
|
+
padding: 4px 12px;
|
|
156
|
+
border: 1px solid #2d3748;
|
|
157
|
+
border-radius: 4px;
|
|
158
|
+
font-size: 11px;
|
|
159
|
+
color: #a0aec0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.filter-bar a:hover,
|
|
163
|
+
.filter-bar a.active { border-color: #90cdf4; color: #90cdf4; }
|
|
164
|
+
|
|
165
|
+
.sql {
|
|
166
|
+
color: #90cdf4;
|
|
167
|
+
font-size: 11px;
|
|
168
|
+
white-space: nowrap;
|
|
169
|
+
overflow: hidden;
|
|
170
|
+
text-overflow: ellipsis;
|
|
171
|
+
max-width: 400px;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.n1-badge {
|
|
175
|
+
background: #c53030;
|
|
176
|
+
color: #fff;
|
|
177
|
+
padding: 1px 6px;
|
|
178
|
+
border-radius: 3px;
|
|
179
|
+
font-size: 10px;
|
|
180
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
class ApplicationController < ActionController::Base
|
|
3
|
+
before_action :authenticate!
|
|
4
|
+
before_action :flag_own_request
|
|
5
|
+
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def authenticate!
|
|
9
|
+
auth = RailsVitals.config.auth
|
|
10
|
+
|
|
11
|
+
case auth
|
|
12
|
+
when :none
|
|
13
|
+
true
|
|
14
|
+
when :basic
|
|
15
|
+
authenticate_or_request_with_http_basic("RailsVitals") do |username, password|
|
|
16
|
+
username == RailsVitals.config.basic_auth_username &&
|
|
17
|
+
password == RailsVitals.config.basic_auth_password
|
|
18
|
+
end
|
|
19
|
+
when Proc
|
|
20
|
+
unless auth.call(self)
|
|
21
|
+
render plain: "Unauthorized", status: :unauthorized
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def flag_own_request
|
|
27
|
+
Thread.current[:rails_vitals_own_request] = true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
class DashboardController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@records = RailsVitals.store.all.reverse
|
|
5
|
+
@total = @records.size
|
|
6
|
+
@avg_score = average(@records, :score)
|
|
7
|
+
@avg_queries = average(@records, :total_query_count)
|
|
8
|
+
@avg_db_time = average(@records, :total_db_time_ms)
|
|
9
|
+
@top_offenders = top_offenders(@records)
|
|
10
|
+
@health_trend = health_trend_data(@records)
|
|
11
|
+
@score_distribution = score_distribution_data(@records)
|
|
12
|
+
@query_volume = query_volume_data(@records)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def average(records, method)
|
|
18
|
+
return 0 if records.empty?
|
|
19
|
+
|
|
20
|
+
(records.sum(&method).to_f / records.size).round(1)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def top_offenders(records)
|
|
24
|
+
records
|
|
25
|
+
.group_by(&:endpoint)
|
|
26
|
+
.transform_values do |reqs|
|
|
27
|
+
{
|
|
28
|
+
count: reqs.size,
|
|
29
|
+
avg_score: average(reqs, :score),
|
|
30
|
+
avg_queries: average(reqs, :total_query_count),
|
|
31
|
+
avg_db_time_ms: average(reqs, :total_db_time_ms)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
.sort_by { |_, v| v[:avg_score] }
|
|
35
|
+
.first(5)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def health_trend_data(records)
|
|
39
|
+
records.first(10).map do |r|
|
|
40
|
+
[ r.endpoint, r.score ]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def score_distribution_data(records)
|
|
45
|
+
{
|
|
46
|
+
"Healthy (90-100)" => records.count { |r| r.score >= 90 },
|
|
47
|
+
"Acceptable (70-89)" => records.count { |r| (70..89).include?(r.score) },
|
|
48
|
+
"Warning (50-69)" => records.count { |r| (50..69).include?(r.score) },
|
|
49
|
+
"Critical (0-49)" => records.count { |r| r.score < 50 }
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def query_volume_data(records)
|
|
54
|
+
records.first(10).each_with_index.map do |r, i|
|
|
55
|
+
[ "##{i + 1} #{r.endpoint}", { queries: r.total_query_count, db_time: r.total_db_time_ms.round(1) } ]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
class HeatmapController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
records = RailsVitals.store.all
|
|
5
|
+
@heatmap = build_heatmap(records)
|
|
6
|
+
@total = records.size
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def build_heatmap(records)
|
|
12
|
+
records
|
|
13
|
+
.group_by(&:endpoint)
|
|
14
|
+
.map do |endpoint, reqs|
|
|
15
|
+
{
|
|
16
|
+
endpoint: endpoint,
|
|
17
|
+
hits: reqs.size,
|
|
18
|
+
avg_score: average(reqs, :score),
|
|
19
|
+
avg_queries: average(reqs, :total_query_count),
|
|
20
|
+
avg_db_time: average(reqs, :total_db_time_ms),
|
|
21
|
+
avg_callback_time: average(reqs, :total_callback_time_ms),
|
|
22
|
+
n_plus_one_freq: n_plus_one_frequency(reqs)
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
.sort_by { |row| row[:avg_score] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def average(records, method)
|
|
29
|
+
return 0.0 if records.empty?
|
|
30
|
+
(records.sum { |r| r.public_send(method) }.to_f / records.size).round(1)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def n_plus_one_frequency(reqs)
|
|
34
|
+
reqs_with_n1 = reqs.count { |r| r.n_plus_one_patterns.any? }
|
|
35
|
+
return 0.0 if reqs.empty?
|
|
36
|
+
((reqs_with_n1.to_f / reqs.size) * 100).round(1)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|