rails_memory_profiler 0.1.0 → 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 +4 -4
- data/README.md +2 -0
- data/app/assets/stylesheets/rails_memory_profiler/_05_filters.css +32 -2
- data/app/assets/stylesheets/rails_memory_profiler/_06_breakdowns.css +40 -0
- data/app/assets/stylesheets/rails_memory_profiler/_07_compare.css +46 -0
- data/app/controllers/rails_memory_profiler/base_controller.rb +15 -0
- data/app/controllers/rails_memory_profiler/comparisons_controller.rb +20 -0
- data/app/controllers/rails_memory_profiler/reports_controller.rb +1 -11
- data/app/javascript/rails_memory_profiler/application.js +1 -5
- data/app/javascript/rails_memory_profiler/controllers/application.js +4 -0
- data/app/javascript/rails_memory_profiler/controllers/compare_controller.js +27 -0
- data/app/javascript/rails_memory_profiler/controllers/filter_controller.js +50 -0
- data/app/javascript/rails_memory_profiler/controllers/index.js +6 -0
- data/app/views/rails_memory_profiler/comparisons/show.html.erb +75 -0
- data/app/views/rails_memory_profiler/reports/index.html.erb +98 -43
- data/app/views/rails_memory_profiler/reports/show.html.erb +34 -0
- data/config/importmap.rb +5 -2
- data/config/routes.rb +1 -0
- data/lib/generators/rails_memory_profiler/install/templates/initializer.rb +7 -0
- data/lib/rails_memory_profiler/configuration.rb +4 -1
- data/lib/rails_memory_profiler/middleware.rb +90 -26
- data/lib/rails_memory_profiler/version.rb +1 -1
- metadata +10 -2
- data/app/javascript/rails_memory_profiler/filter_controller.js +0 -30
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f6f3720c5cac0f1ec3299a0e54a621acb6c2f5881b8b76ca9b737df70cc29cad
|
|
4
|
+
data.tar.gz: 7180cecd1e284b10521c1736c5f0aff6f4bc4811e3ac812a06d7d8e97d7bf4d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e827b8dcbb33b723b2f557e93ef6b16dcbd062e18049e9ccb98bc0df3268318155f30594b124a6166547951eee3a79c1bff2d02a931c3193de6bd86940d98f6b
|
|
7
|
+
data.tar.gz: c3522a8f0c3fe3a0d2c6fcb689f41203aa1267829509aa9ca123b185d9451f3347530724f33e17555736d91f8a5346e55a15834d4156c7f76a50030fd8100a5f
|
data/README.md
CHANGED
|
@@ -76,6 +76,8 @@ All options and their defaults:
|
|
|
76
76
|
| `min_allocated_objects` | `0` | Skip requests that allocate fewer objects than this |
|
|
77
77
|
| `ignore_paths` | `[]` | Paths to skip — strings (prefix) or regexes |
|
|
78
78
|
| `ignore_controllers` | `[]` | Controller names to skip (e.g. `"rails/health"`) |
|
|
79
|
+
| `detailed_reports` | `false` | Capture full `MemoryProfiler.report` breakdowns; requires `gem "memory_profiler"` in your Gemfile |
|
|
80
|
+
| `detailed_sample_rate` | `10` | When `detailed_reports` is enabled, capture a full report every Nth profiled request |
|
|
79
81
|
|
|
80
82
|
Example:
|
|
81
83
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
.rmp-filters {
|
|
2
2
|
display: flex;
|
|
3
3
|
align-items: center;
|
|
4
|
+
flex-wrap: wrap;
|
|
4
5
|
gap: 0.75rem;
|
|
5
|
-
margin-bottom:
|
|
6
|
+
margin-bottom: 0.75rem;
|
|
6
7
|
padding: 0.6rem 1rem;
|
|
7
8
|
background: var(--surface);
|
|
8
9
|
border-radius: var(--radius);
|
|
@@ -55,4 +56,33 @@
|
|
|
55
56
|
border-radius: 2px;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
.rmp-input-clear:hover { color: var(--text); background: var(--border); }
|
|
59
|
+
.rmp-input-clear:hover { color: var(--text); background: var(--border); }
|
|
60
|
+
|
|
61
|
+
.rmp-select {
|
|
62
|
+
font-family: inherit;
|
|
63
|
+
font-size: 13px;
|
|
64
|
+
padding: 0.3rem 0.5rem;
|
|
65
|
+
border: 1px solid var(--border);
|
|
66
|
+
border-radius: var(--radius);
|
|
67
|
+
background: var(--bg);
|
|
68
|
+
color: var(--text);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.rmp-select:focus {
|
|
72
|
+
outline: none;
|
|
73
|
+
border-color: #86b7fe;
|
|
74
|
+
box-shadow: 0 0 0 2px rgba(13,110,253,.15);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.rmp-btn-reset {
|
|
78
|
+
font-family: inherit;
|
|
79
|
+
font-size: 12px;
|
|
80
|
+
padding: 0.3rem 0.7rem;
|
|
81
|
+
border: 1px solid var(--border);
|
|
82
|
+
border-radius: var(--radius);
|
|
83
|
+
background: var(--bg);
|
|
84
|
+
color: var(--muted);
|
|
85
|
+
cursor: pointer;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.rmp-btn-reset:hover { color: var(--text); background: var(--border); }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
.rmp-breakdowns {
|
|
2
|
+
display: grid;
|
|
3
|
+
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
|
4
|
+
gap: 1.5rem;
|
|
5
|
+
margin-top: 2rem;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.rmp-breakdown {
|
|
9
|
+
background: var(--surface);
|
|
10
|
+
border: 1px solid var(--border);
|
|
11
|
+
border-radius: 6px;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.rmp-breakdown-title {
|
|
16
|
+
font-size: 0.85rem;
|
|
17
|
+
font-weight: 600;
|
|
18
|
+
text-transform: uppercase;
|
|
19
|
+
letter-spacing: 0.05em;
|
|
20
|
+
color: var(--muted);
|
|
21
|
+
padding: 0.75rem 1rem;
|
|
22
|
+
border-bottom: 1px solid var(--border);
|
|
23
|
+
margin: 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.rmp-breakdown .rmp-table {
|
|
27
|
+
margin: 0;
|
|
28
|
+
border: none;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.rmp-breakdown .rmp-table th,
|
|
32
|
+
.rmp-breakdown .rmp-table td {
|
|
33
|
+
padding: 0.4rem 1rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.rmp-breakdown .rmp-table td:last-child {
|
|
37
|
+
text-align: right;
|
|
38
|
+
font-variant-numeric: tabular-nums;
|
|
39
|
+
color: var(--muted);
|
|
40
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
.rmp-col-check { width: 2.5rem; text-align: center; }
|
|
2
|
+
.rmp-checkbox { cursor: pointer; accent-color: var(--accent); width: 15px; height: 15px; }
|
|
3
|
+
|
|
4
|
+
.rmp-compare-bar {
|
|
5
|
+
display: flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
gap: 1rem;
|
|
8
|
+
padding: 0.55rem 1rem;
|
|
9
|
+
margin-bottom: 0.75rem;
|
|
10
|
+
background: #eff6ff;
|
|
11
|
+
border: 1px solid #bfdbfe;
|
|
12
|
+
border-radius: var(--radius);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.rmp-compare-bar-hint { font-size: 13px; color: #1e40af; }
|
|
16
|
+
|
|
17
|
+
.rmp-btn {
|
|
18
|
+
display: inline-block;
|
|
19
|
+
padding: 0.3rem 0.9rem;
|
|
20
|
+
font-size: 13px;
|
|
21
|
+
font-weight: 500;
|
|
22
|
+
border-radius: var(--radius);
|
|
23
|
+
background: var(--accent);
|
|
24
|
+
color: #fff;
|
|
25
|
+
text-decoration: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.rmp-btn:hover { background: #4338ca; }
|
|
29
|
+
|
|
30
|
+
.rmp-compare { margin-top: 1.5rem; }
|
|
31
|
+
|
|
32
|
+
.rmp-compare-table .rmp-compare-field {
|
|
33
|
+
font-weight: 600;
|
|
34
|
+
color: var(--muted);
|
|
35
|
+
font-size: 12px;
|
|
36
|
+
width: 14rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.rmp-compare-table .rmp-compare-delta {
|
|
40
|
+
font-variant-numeric: tabular-nums;
|
|
41
|
+
text-align: right;
|
|
42
|
+
width: 12rem;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.rmp-delta--worse { color: #dc3545; font-weight: 600; }
|
|
46
|
+
.rmp-delta--better { color: #198754; font-weight: 600; }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module RailsMemoryProfiler
|
|
2
|
+
class BaseController < ActionController::Base
|
|
3
|
+
protect_from_forgery with: :null_session
|
|
4
|
+
layout "rails_memory_profiler/application"
|
|
5
|
+
helper RailsMemoryProfiler::ApplicationHelper
|
|
6
|
+
|
|
7
|
+
before_action :check_dashboard_enabled
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def check_dashboard_enabled
|
|
12
|
+
head :forbidden unless RailsMemoryProfiler.config.dashboard_enabled
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module RailsMemoryProfiler
|
|
2
|
+
class ComparisonsController < BaseController
|
|
3
|
+
def show
|
|
4
|
+
ids = Array(params[:ids]).first(2)
|
|
5
|
+
reports = ids.map { |id| ReportStore.find(id) }.compact
|
|
6
|
+
|
|
7
|
+
if reports.size < 2
|
|
8
|
+
redirect_to reports_path
|
|
9
|
+
return
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
@left, @right = reports
|
|
13
|
+
|
|
14
|
+
respond_to do |format|
|
|
15
|
+
format.html
|
|
16
|
+
format.json { render json: { left: @left, right: @right } }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
module RailsMemoryProfiler
|
|
2
|
-
class ReportsController <
|
|
3
|
-
protect_from_forgery with: :null_session
|
|
4
|
-
layout "rails_memory_profiler/application"
|
|
5
|
-
helper RailsMemoryProfiler::ApplicationHelper
|
|
6
|
-
|
|
7
|
-
before_action :check_dashboard_enabled
|
|
8
|
-
|
|
2
|
+
class ReportsController < BaseController
|
|
9
3
|
SORTABLE_COLUMNS = %w[path controller allocated_objects retained_objects duration_ms recorded_at].freeze
|
|
10
4
|
|
|
11
5
|
def index
|
|
@@ -42,9 +36,5 @@ module RailsMemoryProfiler
|
|
|
42
36
|
sorted = reports.sort_by { |r| r[sort.to_sym] || 0 }
|
|
43
37
|
direction == "asc" ? sorted : sorted.reverse
|
|
44
38
|
end
|
|
45
|
-
|
|
46
|
-
def check_dashboard_enabled
|
|
47
|
-
head :forbidden unless RailsMemoryProfiler.config.dashboard_enabled
|
|
48
|
-
end
|
|
49
39
|
end
|
|
50
40
|
end
|
|
@@ -1,6 +1,2 @@
|
|
|
1
1
|
import "@hotwired/turbo"
|
|
2
|
-
import
|
|
3
|
-
import FilterController from "rails_memory_profiler/filter_controller"
|
|
4
|
-
|
|
5
|
-
const application = Application.start()
|
|
6
|
-
application.register("filter", FilterController)
|
|
2
|
+
import "rails_memory_profiler/controllers"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["checkbox", "bar", "link"]
|
|
5
|
+
static values = { path: String }
|
|
6
|
+
|
|
7
|
+
toggle(event) {
|
|
8
|
+
const checked = this.checkboxTargets.filter(cb => cb.checked)
|
|
9
|
+
if (checked.length > 2) {
|
|
10
|
+
const oldest = checked.find(cb => cb !== event.target)
|
|
11
|
+
if (oldest) oldest.checked = false
|
|
12
|
+
}
|
|
13
|
+
this._update()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
_update() {
|
|
17
|
+
const checked = this.checkboxTargets.filter(cb => cb.checked)
|
|
18
|
+
const ready = checked.length === 2
|
|
19
|
+
|
|
20
|
+
this.barTarget.hidden = !ready
|
|
21
|
+
|
|
22
|
+
if (ready) {
|
|
23
|
+
const query = checked.map(cb => `ids[]=${encodeURIComponent(cb.value)}`).join("&")
|
|
24
|
+
this.linkTarget.href = `${this.pathValue}?${query}`
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["controllerInput", "controllerClear", "actionInput", "actionClear",
|
|
5
|
+
"methodSelect", "row", "clearButton"]
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this._updateClear()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
filter() {
|
|
12
|
+
const controller = this.controllerInputTarget.value.toLowerCase()
|
|
13
|
+
const action = this.actionInputTarget.value.toLowerCase()
|
|
14
|
+
const method = this.methodSelectTarget.value
|
|
15
|
+
|
|
16
|
+
this.rowTargets.forEach(row => {
|
|
17
|
+
const matchController = !controller || (row.dataset.controllerName || "").toLowerCase().includes(controller)
|
|
18
|
+
const matchAction = !action || (row.dataset.actionName || "").toLowerCase().includes(action)
|
|
19
|
+
const matchMethod = !method || (row.dataset.httpMethod || "") === method
|
|
20
|
+
row.hidden = !(matchController && matchAction && matchMethod)
|
|
21
|
+
})
|
|
22
|
+
this._updateClear()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
clearField({ params: { field } }) {
|
|
26
|
+
this[`${field}InputTarget`].value = ""
|
|
27
|
+
this.filter()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
clear() {
|
|
31
|
+
this.controllerInputTarget.value = ""
|
|
32
|
+
this.actionInputTarget.value = ""
|
|
33
|
+
this.methodSelectTarget.value = ""
|
|
34
|
+
this.rowTargets.forEach(row => { row.hidden = false })
|
|
35
|
+
this._updateClear()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_updateClear() {
|
|
39
|
+
if (this.hasControllerClearTarget)
|
|
40
|
+
this.controllerClearTarget.hidden = this.controllerInputTarget.value.length === 0
|
|
41
|
+
if (this.hasActionClearTarget)
|
|
42
|
+
this.actionClearTarget.hidden = this.actionInputTarget.value.length === 0
|
|
43
|
+
if (this.hasClearButtonTarget) {
|
|
44
|
+
const active = this.controllerInputTarget.value.length > 0 ||
|
|
45
|
+
this.actionInputTarget.value.length > 0 ||
|
|
46
|
+
this.methodSelectTarget.value.length > 0
|
|
47
|
+
this.clearButtonTarget.hidden = !active
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { application } from "rails_memory_profiler/controllers/application"
|
|
2
|
+
import FilterController from "rails_memory_profiler/controllers/filter_controller"
|
|
3
|
+
import CompareController from "rails_memory_profiler/controllers/compare_controller"
|
|
4
|
+
|
|
5
|
+
application.register("filter", FilterController)
|
|
6
|
+
application.register("compare", CompareController)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<%= link_to "← All Requests", reports_path, class: "rmp-back" %>
|
|
2
|
+
|
|
3
|
+
<div class="rmp-header">
|
|
4
|
+
<h1>Request Comparison</h1>
|
|
5
|
+
<p>
|
|
6
|
+
<%= link_to @left[:path], report_path(@left[:id]), class: "rmp-path" %>
|
|
7
|
+
vs
|
|
8
|
+
<%= link_to @right[:path], report_path(@right[:id]), class: "rmp-path" %>
|
|
9
|
+
</p>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="rmp-compare">
|
|
13
|
+
<table class="rmp-table rmp-compare-table">
|
|
14
|
+
<thead>
|
|
15
|
+
<tr>
|
|
16
|
+
<th class="rmp-compare-field">Field</th>
|
|
17
|
+
<th>
|
|
18
|
+
<%= link_to @left[:path], report_path(@left[:id]) %>
|
|
19
|
+
<br><span class="rmp-muted"><%= [@left[:controller], @left[:action]].compact.join("#") %></span>
|
|
20
|
+
</th>
|
|
21
|
+
<th>
|
|
22
|
+
<%= link_to @right[:path], report_path(@right[:id]) %>
|
|
23
|
+
<br><span class="rmp-muted"><%= [@right[:controller], @right[:action]].compact.join("#") %></span>
|
|
24
|
+
</th>
|
|
25
|
+
<th class="rmp-compare-delta">Delta (B − A)</th>
|
|
26
|
+
</tr>
|
|
27
|
+
</thead>
|
|
28
|
+
<tbody>
|
|
29
|
+
<tr>
|
|
30
|
+
<td class="rmp-compare-field">HTTP Method</td>
|
|
31
|
+
<td><%= @left[:method] %></td>
|
|
32
|
+
<td><%= @right[:method] %></td>
|
|
33
|
+
<td class="rmp-muted">—</td>
|
|
34
|
+
</tr>
|
|
35
|
+
<tr>
|
|
36
|
+
<td class="rmp-compare-field">Recorded At</td>
|
|
37
|
+
<td><%= @left[:recorded_at]&.strftime("%Y-%m-%d %H:%M:%S") %></td>
|
|
38
|
+
<td><%= @right[:recorded_at]&.strftime("%Y-%m-%d %H:%M:%S") %></td>
|
|
39
|
+
<td class="rmp-muted">—</td>
|
|
40
|
+
</tr>
|
|
41
|
+
<%
|
|
42
|
+
numeric_rows = [
|
|
43
|
+
["Allocated Objects", :allocated_objects],
|
|
44
|
+
["Retained Objects", :retained_objects],
|
|
45
|
+
["Duration (ms)", :duration_ms],
|
|
46
|
+
]
|
|
47
|
+
%>
|
|
48
|
+
<% numeric_rows.each do |label, key| %>
|
|
49
|
+
<%
|
|
50
|
+
lv = @left[key] || 0
|
|
51
|
+
rv = @right[key] || 0
|
|
52
|
+
delta = rv - lv
|
|
53
|
+
delta_class = if delta > 0 then "rmp-delta--worse"
|
|
54
|
+
elsif delta < 0 then "rmp-delta--better"
|
|
55
|
+
else nil end
|
|
56
|
+
delta_str = if delta > 0 then "+#{number_with_delimiter(delta.round(2))}"
|
|
57
|
+
elsif delta < 0 then number_with_delimiter(delta.round(2)).to_s
|
|
58
|
+
else "—" end
|
|
59
|
+
%>
|
|
60
|
+
<tr>
|
|
61
|
+
<td class="rmp-compare-field"><%= label %></td>
|
|
62
|
+
<td><%= number_with_delimiter(lv.is_a?(Float) ? lv : lv) %></td>
|
|
63
|
+
<td><%= number_with_delimiter(rv.is_a?(Float) ? rv : rv) %></td>
|
|
64
|
+
<td class="<%= delta_class %>"><%= delta_str %></td>
|
|
65
|
+
</tr>
|
|
66
|
+
<% end %>
|
|
67
|
+
<tr>
|
|
68
|
+
<td class="rmp-compare-field">Detailed Report</td>
|
|
69
|
+
<td><%= @left[:detail] ? "Yes" : "No" %></td>
|
|
70
|
+
<td><%= @right[:detail] ? "Yes" : "No" %></td>
|
|
71
|
+
<td class="rmp-muted">—</td>
|
|
72
|
+
</tr>
|
|
73
|
+
</tbody>
|
|
74
|
+
</table>
|
|
75
|
+
</div>
|
|
@@ -3,54 +3,109 @@
|
|
|
3
3
|
<p><%= @reports.size %> request<%= "s" unless @reports.size == 1 %> captured</p>
|
|
4
4
|
</div>
|
|
5
5
|
|
|
6
|
-
<div
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
<div data-controller="filter compare" data-compare-path-value="<%= comparison_path %>">
|
|
7
|
+
<div class="rmp-filters">
|
|
8
|
+
<label for="rmp-controller-filter">Controller</label>
|
|
9
|
+
<div class="rmp-input-wrapper">
|
|
10
|
+
<input id="rmp-controller-filter"
|
|
11
|
+
type="text"
|
|
12
|
+
class="rmp-input"
|
|
13
|
+
placeholder="e.g. posts"
|
|
14
|
+
data-filter-target="controllerInput"
|
|
15
|
+
data-action="input->filter#filter">
|
|
16
|
+
<button type="button"
|
|
17
|
+
class="rmp-input-clear"
|
|
18
|
+
aria-label="Clear controller filter"
|
|
19
|
+
hidden
|
|
20
|
+
data-filter-target="controllerClear"
|
|
21
|
+
data-action="click->filter#clearField"
|
|
22
|
+
data-filter-field-param="controller">✕</button>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<label for="rmp-action-filter">Action</label>
|
|
26
|
+
<div class="rmp-input-wrapper">
|
|
27
|
+
<input id="rmp-action-filter"
|
|
28
|
+
type="text"
|
|
29
|
+
class="rmp-input"
|
|
30
|
+
placeholder="e.g. index"
|
|
31
|
+
data-filter-target="actionInput"
|
|
32
|
+
data-action="input->filter#filter">
|
|
33
|
+
<button type="button"
|
|
34
|
+
class="rmp-input-clear"
|
|
35
|
+
aria-label="Clear action filter"
|
|
36
|
+
hidden
|
|
37
|
+
data-filter-target="actionClear"
|
|
38
|
+
data-action="click->filter#clearField"
|
|
39
|
+
data-filter-field-param="action">✕</button>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<label for="rmp-method-filter">Method</label>
|
|
43
|
+
<select id="rmp-method-filter"
|
|
44
|
+
class="rmp-select"
|
|
45
|
+
data-filter-target="methodSelect"
|
|
46
|
+
data-action="change->filter#filter">
|
|
47
|
+
<option value="">All</option>
|
|
48
|
+
<option value="GET">GET</option>
|
|
49
|
+
<option value="POST">POST</option>
|
|
50
|
+
<option value="PATCH">PATCH</option>
|
|
51
|
+
<option value="PUT">PUT</option>
|
|
52
|
+
<option value="DELETE">DELETE</option>
|
|
53
|
+
</select>
|
|
54
|
+
|
|
15
55
|
<button type="button"
|
|
16
|
-
class="rmp-
|
|
17
|
-
aria-label="Clear filter"
|
|
56
|
+
class="rmp-btn-reset"
|
|
18
57
|
hidden
|
|
19
58
|
data-filter-target="clearButton"
|
|
20
|
-
data-action="click->filter#clear"
|
|
59
|
+
data-action="click->filter#clear">Reset</button>
|
|
60
|
+
|
|
61
|
+
<% unless @reports.empty? %>
|
|
62
|
+
<span class="rmp-muted">Filtering is client-side — no page reload needed.</span>
|
|
63
|
+
<% end %>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="rmp-compare-bar" hidden data-compare-target="bar">
|
|
67
|
+
<span class="rmp-compare-bar-hint">2 requests selected</span>
|
|
68
|
+
<a href="#" class="rmp-btn" data-compare-target="link">Compare →</a>
|
|
21
69
|
</div>
|
|
22
70
|
|
|
23
71
|
<% if @reports.empty? %>
|
|
72
|
+
<p class="rmp-empty">No requests recorded yet. Browse your app and refresh.</p>
|
|
24
73
|
<% else %>
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<%= sort_th("Path", "path", @sort, @direction) %>
|
|
36
|
-
<%= sort_th("Controller#Action", "controller", @sort, @direction) %>
|
|
37
|
-
<%= sort_th("Allocated", "allocated_objects", @sort, @direction) %>
|
|
38
|
-
<%= sort_th("Retained", "retained_objects", @sort, @direction) %>
|
|
39
|
-
<%= sort_th("Duration (ms)", "duration_ms", @sort, @direction) %>
|
|
40
|
-
<%= sort_th("Recorded", "recorded_at", @sort, @direction) %>
|
|
41
|
-
</tr>
|
|
42
|
-
</thead>
|
|
43
|
-
<tbody>
|
|
44
|
-
<% @reports.each do |report| %>
|
|
45
|
-
<tr data-filter-target="row" data-controller-name="<%= report[:controller] %>">
|
|
46
|
-
<td><%= link_to report[:path], report_path(report[:id]), class: "rmp-path" %></td>
|
|
47
|
-
<td><%= [report[:controller], report[:action]].compact.join("#") %></td>
|
|
48
|
-
<td><%= allocation_badge(report[:allocated_objects]) %></td>
|
|
49
|
-
<td class="rmp-muted"><%= number_with_delimiter(report[:retained_objects]) %></td>
|
|
50
|
-
<td class="rmp-muted"><%= report[:duration_ms] %></td>
|
|
51
|
-
<td class="rmp-muted"><%= report[:recorded_at]&.strftime("%H:%M:%S") %></td>
|
|
74
|
+
<table class="rmp-table">
|
|
75
|
+
<thead>
|
|
76
|
+
<tr>
|
|
77
|
+
<th class="rmp-col-check"></th>
|
|
78
|
+
<%= sort_th("Path", "path", @sort, @direction) %>
|
|
79
|
+
<%= sort_th("Controller#Action", "controller", @sort, @direction) %>
|
|
80
|
+
<%= sort_th("Allocated", "allocated_objects", @sort, @direction) %>
|
|
81
|
+
<%= sort_th("Retained", "retained_objects", @sort, @direction) %>
|
|
82
|
+
<%= sort_th("Duration (ms)", "duration_ms", @sort, @direction) %>
|
|
83
|
+
<%= sort_th("Recorded", "recorded_at", @sort, @direction) %>
|
|
52
84
|
</tr>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
85
|
+
</thead>
|
|
86
|
+
<tbody>
|
|
87
|
+
<% @reports.each do |report| %>
|
|
88
|
+
<tr data-filter-target="row"
|
|
89
|
+
data-controller-name="<%= report[:controller] %>"
|
|
90
|
+
data-action-name="<%= report[:action] %>"
|
|
91
|
+
data-http-method="<%= report[:method] %>">
|
|
92
|
+
<td class="rmp-col-check">
|
|
93
|
+
<input type="checkbox"
|
|
94
|
+
class="rmp-checkbox"
|
|
95
|
+
value="<%= report[:id] %>"
|
|
96
|
+
aria-label="Select for comparison"
|
|
97
|
+
data-compare-target="checkbox"
|
|
98
|
+
data-action="change->compare#toggle">
|
|
99
|
+
</td>
|
|
100
|
+
<td><%= link_to report[:path], report_path(report[:id]), class: "rmp-path" %></td>
|
|
101
|
+
<td><%= [report[:controller], report[:action]].compact.join("#") %></td>
|
|
102
|
+
<td><%= allocation_badge(report[:allocated_objects]) %></td>
|
|
103
|
+
<td class="rmp-muted"><%= number_with_delimiter(report[:retained_objects]) %></td>
|
|
104
|
+
<td class="rmp-muted"><%= report[:duration_ms] %></td>
|
|
105
|
+
<td class="rmp-muted"><%= report[:recorded_at]&.strftime("%H:%M:%S") %></td>
|
|
106
|
+
</tr>
|
|
107
|
+
<% end %>
|
|
108
|
+
</tbody>
|
|
109
|
+
</table>
|
|
110
|
+
<% end %>
|
|
111
|
+
</div>
|
|
@@ -30,6 +30,40 @@
|
|
|
30
30
|
</div>
|
|
31
31
|
</div>
|
|
32
32
|
</div>
|
|
33
|
+
|
|
34
|
+
<% if (detail = @report[:detail]) %>
|
|
35
|
+
<div class="rmp-breakdowns">
|
|
36
|
+
<% [
|
|
37
|
+
["Allocated by Gem", detail[:allocated_by_gem]],
|
|
38
|
+
["Allocated by Class", detail[:allocated_by_class]],
|
|
39
|
+
["Allocated by File", detail[:allocated_by_file]],
|
|
40
|
+
["Allocated by Location", detail[:allocated_by_location]],
|
|
41
|
+
["Retained by Gem", detail[:retained_by_gem]],
|
|
42
|
+
["Retained by Class", detail[:retained_by_class]],
|
|
43
|
+
].each do |title, stats| %>
|
|
44
|
+
<% next if stats.blank? %>
|
|
45
|
+
<div class="rmp-breakdown">
|
|
46
|
+
<h2 class="rmp-breakdown-title"><%= title %></h2>
|
|
47
|
+
<table class="rmp-table">
|
|
48
|
+
<thead>
|
|
49
|
+
<tr>
|
|
50
|
+
<th>Name</th>
|
|
51
|
+
<th>Count</th>
|
|
52
|
+
</tr>
|
|
53
|
+
</thead>
|
|
54
|
+
<tbody>
|
|
55
|
+
<% stats.first(20).each do |stat| %>
|
|
56
|
+
<tr>
|
|
57
|
+
<td><%= stat[:name] %></td>
|
|
58
|
+
<td><%= number_with_delimiter(stat[:count]) %></td>
|
|
59
|
+
</tr>
|
|
60
|
+
<% end %>
|
|
61
|
+
</tbody>
|
|
62
|
+
</table>
|
|
63
|
+
</div>
|
|
64
|
+
<% end %>
|
|
65
|
+
</div>
|
|
66
|
+
<% end %>
|
|
33
67
|
<% else %>
|
|
34
68
|
<p class="rmp-empty">Report not found — it may have been evicted from the store.</p>
|
|
35
69
|
<% end %>
|
data/config/importmap.rb
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"
|
|
2
2
|
pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
|
|
3
|
-
pin "rails_memory_profiler",
|
|
4
|
-
pin "rails_memory_profiler/
|
|
3
|
+
pin "rails_memory_profiler", to: "rails_memory_profiler/application.js"
|
|
4
|
+
pin "rails_memory_profiler/controllers", to: "rails_memory_profiler/controllers/index.js"
|
|
5
|
+
pin "rails_memory_profiler/controllers/application", to: "rails_memory_profiler/controllers/application.js"
|
|
6
|
+
pin "rails_memory_profiler/controllers/filter_controller", to: "rails_memory_profiler/controllers/filter_controller.js"
|
|
7
|
+
pin "rails_memory_profiler/controllers/compare_controller", to: "rails_memory_profiler/controllers/compare_controller.js"
|
data/config/routes.rb
CHANGED
|
@@ -25,4 +25,11 @@ RailsMemoryProfiler.configure do |config|
|
|
|
25
25
|
|
|
26
26
|
# Controllers to skip — matched against the Rails controller name.
|
|
27
27
|
# config.ignore_controllers = ["rails/health", "rails_memory_profiler/reports"]
|
|
28
|
+
|
|
29
|
+
# Capture full MemoryProfiler.report breakdowns (by gem, class, file, location).
|
|
30
|
+
# Requires `gem "memory_profiler"` in your Gemfile. Has significant overhead — use with sampling.
|
|
31
|
+
# config.detailed_reports = false
|
|
32
|
+
|
|
33
|
+
# When detailed_reports is enabled, capture a full report every Nth profiled request.
|
|
34
|
+
# config.detailed_sample_rate = 10
|
|
28
35
|
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module RailsMemoryProfiler
|
|
2
2
|
class Configuration
|
|
3
3
|
attr_accessor :enabled, :sample_rate, :store_size, :dashboard_enabled,
|
|
4
|
-
:min_allocated_objects, :ignore_paths, :ignore_controllers
|
|
4
|
+
:min_allocated_objects, :ignore_paths, :ignore_controllers,
|
|
5
|
+
:detailed_reports, :detailed_sample_rate
|
|
5
6
|
|
|
6
7
|
def initialize
|
|
7
8
|
@enabled = Rails.env.development?
|
|
@@ -11,6 +12,8 @@ module RailsMemoryProfiler
|
|
|
11
12
|
@min_allocated_objects = 0
|
|
12
13
|
@ignore_paths = []
|
|
13
14
|
@ignore_controllers = []
|
|
15
|
+
@detailed_reports = false
|
|
16
|
+
@detailed_sample_rate = 10
|
|
14
17
|
end
|
|
15
18
|
end
|
|
16
19
|
end
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
module RailsMemoryProfiler
|
|
2
2
|
class Middleware
|
|
3
3
|
def initialize(app)
|
|
4
|
-
@app
|
|
5
|
-
@request_count
|
|
6
|
-
@
|
|
4
|
+
@app = app
|
|
5
|
+
@request_count = 0
|
|
6
|
+
@detailed_count = 0
|
|
7
|
+
@mutex = Mutex.new
|
|
7
8
|
end
|
|
8
9
|
|
|
9
10
|
def call(env)
|
|
@@ -17,40 +18,93 @@ module RailsMemoryProfiler
|
|
|
17
18
|
private
|
|
18
19
|
|
|
19
20
|
def profile(env)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
config = RailsMemoryProfiler.config
|
|
22
|
+
if config.detailed_reports && detailed_sample?
|
|
23
|
+
profile_detailed(env)
|
|
24
|
+
else
|
|
25
|
+
profile_basic(env)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def profile_basic(env)
|
|
30
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
31
|
+
before_alloc = GC.stat[:total_allocated_objects]
|
|
32
|
+
before_freed = GC.stat[:total_freed_objects]
|
|
23
33
|
|
|
24
34
|
status, headers, body = @app.call(env)
|
|
25
35
|
|
|
26
|
-
after_alloc
|
|
27
|
-
after_freed
|
|
28
|
-
duration_ms
|
|
36
|
+
after_alloc = GC.stat[:total_allocated_objects]
|
|
37
|
+
after_freed = GC.stat[:total_freed_objects]
|
|
38
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
29
39
|
|
|
30
40
|
allocated_objects = after_alloc - before_alloc
|
|
31
41
|
retained_objects = (after_alloc - after_freed) - (before_alloc - before_freed)
|
|
32
42
|
|
|
33
|
-
|
|
34
|
-
params = env["action_dispatch.request.path_parameters"] || {}
|
|
35
|
-
controller = params[:controller]
|
|
36
|
-
|
|
37
|
-
unless ignored_controller?(controller)
|
|
38
|
-
ReportStore.push(
|
|
39
|
-
controller: controller,
|
|
40
|
-
action: params[:action],
|
|
41
|
-
path: env["PATH_INFO"],
|
|
42
|
-
method: env["REQUEST_METHOD"],
|
|
43
|
-
duration_ms: duration_ms,
|
|
44
|
-
allocated_objects: allocated_objects,
|
|
45
|
-
retained_objects: [retained_objects, 0].max,
|
|
46
|
-
recorded_at: Time.current
|
|
47
|
-
)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
43
|
+
push_report(env, duration_ms, allocated_objects, retained_objects)
|
|
50
44
|
|
|
51
45
|
[status, headers, body]
|
|
52
46
|
end
|
|
53
47
|
|
|
48
|
+
def profile_detailed(env)
|
|
49
|
+
require_memory_profiler!
|
|
50
|
+
|
|
51
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
52
|
+
result = nil
|
|
53
|
+
|
|
54
|
+
memory_report = MemoryProfiler.report { result = @app.call(env) }
|
|
55
|
+
|
|
56
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
57
|
+
allocated_objects = memory_report.total_allocated
|
|
58
|
+
retained_objects = memory_report.total_retained
|
|
59
|
+
|
|
60
|
+
detail = {
|
|
61
|
+
allocated_by_gem: serialize_stats(memory_report.allocated_objects_by_gem),
|
|
62
|
+
allocated_by_file: serialize_stats(memory_report.allocated_objects_by_file),
|
|
63
|
+
allocated_by_class: serialize_stats(memory_report.allocated_objects_by_class),
|
|
64
|
+
allocated_by_location: serialize_stats(memory_report.allocated_objects_by_location),
|
|
65
|
+
retained_by_gem: serialize_stats(memory_report.retained_objects_by_gem),
|
|
66
|
+
retained_by_file: serialize_stats(memory_report.retained_objects_by_file),
|
|
67
|
+
retained_by_class: serialize_stats(memory_report.retained_objects_by_class),
|
|
68
|
+
retained_by_location: serialize_stats(memory_report.retained_objects_by_location)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
push_report(env, duration_ms, allocated_objects, retained_objects, detail)
|
|
72
|
+
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def push_report(env, duration_ms, allocated_objects, retained_objects, detail = nil)
|
|
77
|
+
return if allocated_objects < RailsMemoryProfiler.config.min_allocated_objects
|
|
78
|
+
|
|
79
|
+
params = env["action_dispatch.request.path_parameters"] || {}
|
|
80
|
+
controller = params[:controller]
|
|
81
|
+
return if ignored_controller?(controller)
|
|
82
|
+
|
|
83
|
+
payload = {
|
|
84
|
+
controller: controller,
|
|
85
|
+
action: params[:action],
|
|
86
|
+
path: env["PATH_INFO"],
|
|
87
|
+
method: env["REQUEST_METHOD"],
|
|
88
|
+
duration_ms: duration_ms,
|
|
89
|
+
allocated_objects: allocated_objects,
|
|
90
|
+
retained_objects: [retained_objects, 0].max,
|
|
91
|
+
recorded_at: Time.current
|
|
92
|
+
}
|
|
93
|
+
payload[:detail] = detail if detail
|
|
94
|
+
|
|
95
|
+
ReportStore.push(payload)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def serialize_stats(stats)
|
|
99
|
+
stats.map { |s| { name: s[:data], count: s[:count] } }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def require_memory_profiler!
|
|
103
|
+
require "memory_profiler"
|
|
104
|
+
rescue LoadError
|
|
105
|
+
raise LoadError, "Add `gem 'memory_profiler'` to your Gemfile to use config.detailed_reports = true"
|
|
106
|
+
end
|
|
107
|
+
|
|
54
108
|
def sample?
|
|
55
109
|
rate = RailsMemoryProfiler.config.sample_rate
|
|
56
110
|
return true if rate <= 1
|
|
@@ -61,6 +115,16 @@ module RailsMemoryProfiler
|
|
|
61
115
|
end
|
|
62
116
|
end
|
|
63
117
|
|
|
118
|
+
def detailed_sample?
|
|
119
|
+
rate = RailsMemoryProfiler.config.detailed_sample_rate
|
|
120
|
+
return true if rate <= 1
|
|
121
|
+
|
|
122
|
+
@mutex.synchronize do
|
|
123
|
+
@detailed_count = (@detailed_count + 1) % rate
|
|
124
|
+
@detailed_count.zero?
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
64
128
|
def ignored_path?(path)
|
|
65
129
|
RailsMemoryProfiler.config.ignore_paths.any? do |pattern|
|
|
66
130
|
pattern.is_a?(Regexp) ? pattern.match?(path) : path.start_with?(pattern.to_s)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_memory_profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -69,15 +69,23 @@ files:
|
|
|
69
69
|
- app/assets/stylesheets/rails_memory_profiler/_03_table.css
|
|
70
70
|
- app/assets/stylesheets/rails_memory_profiler/_04_badges.css
|
|
71
71
|
- app/assets/stylesheets/rails_memory_profiler/_05_filters.css
|
|
72
|
+
- app/assets/stylesheets/rails_memory_profiler/_06_breakdowns.css
|
|
73
|
+
- app/assets/stylesheets/rails_memory_profiler/_07_compare.css
|
|
72
74
|
- app/controllers/rails_memory_profiler/application_controller.rb
|
|
75
|
+
- app/controllers/rails_memory_profiler/base_controller.rb
|
|
76
|
+
- app/controllers/rails_memory_profiler/comparisons_controller.rb
|
|
73
77
|
- app/controllers/rails_memory_profiler/reports_controller.rb
|
|
74
78
|
- app/helpers/rails_memory_profiler/application_helper.rb
|
|
75
79
|
- app/javascript/rails_memory_profiler/application.js
|
|
76
|
-
- app/javascript/rails_memory_profiler/
|
|
80
|
+
- app/javascript/rails_memory_profiler/controllers/application.js
|
|
81
|
+
- app/javascript/rails_memory_profiler/controllers/compare_controller.js
|
|
82
|
+
- app/javascript/rails_memory_profiler/controllers/filter_controller.js
|
|
83
|
+
- app/javascript/rails_memory_profiler/controllers/index.js
|
|
77
84
|
- app/jobs/rails_memory_profiler/application_job.rb
|
|
78
85
|
- app/mailers/rails_memory_profiler/application_mailer.rb
|
|
79
86
|
- app/models/rails_memory_profiler/application_record.rb
|
|
80
87
|
- app/views/layouts/rails_memory_profiler/application.html.erb
|
|
88
|
+
- app/views/rails_memory_profiler/comparisons/show.html.erb
|
|
81
89
|
- app/views/rails_memory_profiler/reports/index.html.erb
|
|
82
90
|
- app/views/rails_memory_profiler/reports/show.html.erb
|
|
83
91
|
- config/importmap.rb
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { Controller } from "@hotwired/stimulus"
|
|
2
|
-
|
|
3
|
-
export default class extends Controller {
|
|
4
|
-
static targets = ["input", "row", "clearButton"]
|
|
5
|
-
|
|
6
|
-
connect() {
|
|
7
|
-
this._updateClear()
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
filter() {
|
|
11
|
-
const query = this.inputTarget.value.toLowerCase()
|
|
12
|
-
this.rowTargets.forEach(row => {
|
|
13
|
-
const name = (row.dataset.controllerName || "").toLowerCase()
|
|
14
|
-
row.hidden = query.length > 0 && !name.includes(query)
|
|
15
|
-
})
|
|
16
|
-
this._updateClear()
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
clear() {
|
|
20
|
-
this.inputTarget.value = ""
|
|
21
|
-
this.rowTargets.forEach(row => { row.hidden = false })
|
|
22
|
-
this._updateClear()
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
_updateClear() {
|
|
26
|
-
if (this.hasClearButtonTarget) {
|
|
27
|
-
this.clearButtonTarget.hidden = this.inputTarget.value.length === 0
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|