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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 358d4a7e75c7fbdc553c28acc6350da8906ea834308410f32020168bc6e31985
4
- data.tar.gz: 910ce5bc705376412f44904d6d3640ec4ad48aa5396f330dd45e8101ca77cb5c
3
+ metadata.gz: f6f3720c5cac0f1ec3299a0e54a621acb6c2f5881b8b76ca9b737df70cc29cad
4
+ data.tar.gz: 7180cecd1e284b10521c1736c5f0aff6f4bc4811e3ac812a06d7d8e97d7bf4d0
5
5
  SHA512:
6
- metadata.gz: ae42d9f95d0e6b01b82be8359de89a4b95379dbbfa5acb66a8ee35a3579dd98870d95b9015ee1041a2a1e43c70978c066509cead89a96c70fd5b7da15dce9fa5
7
- data.tar.gz: dab8d94f6fbd06e9d96844b1e3c1dc5d010b0aa5b6257cba15f6dbef6e8a4202fe2df0d2b3a763803509ec5d4c08443d60adb146f9f9a86ea977716db4ee1a7e
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: 1rem;
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 < 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
-
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 { Application } from "@hotwired/stimulus"
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,4 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ const application = Application.start()
4
+ export { application }
@@ -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
+ &nbsp;vs&nbsp;
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 class="rmp-filters" data-controller="filter">
7
- <label for="rmp-controller-filter">Controller</label>
8
- <div class="rmp-input-wrapper">
9
- <input id="rmp-controller-filter"
10
- type="text"
11
- class="rmp-input"
12
- placeholder="e.g. posts"
13
- data-filter-target="input"
14
- data-action="input->filter#filter">
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-input-clear"
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">✕</button>
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
- <span class="rmp-muted">Filtering is client-side — no page reload needed.</span>
26
- <% end %>
27
- </div>
28
-
29
- <% if @reports.empty? %>
30
- <p class="rmp-empty">No requests recorded yet. Browse your app and refresh.</p>
31
- <% else %>
32
- <table class="rmp-table">
33
- <thead>
34
- <tr>
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
- <% end %>
54
- </tbody>
55
- </table>
56
- <% end %>
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", to: "rails_memory_profiler/application.js"
4
- pin "rails_memory_profiler/filter_controller", to: "rails_memory_profiler/filter_controller.js"
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
@@ -1,3 +1,4 @@
1
1
  RailsMemoryProfiler::Engine.routes.draw do
2
2
  resources :reports, only: [:index, :show]
3
+ resource :comparison, only: [:show]
3
4
  end
@@ -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 = app
5
- @request_count = 0
6
- @mutex = Mutex.new
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
- start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
- before_alloc = GC.stat[:total_allocated_objects]
22
- before_freed = GC.stat[:total_freed_objects]
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 = GC.stat[:total_allocated_objects]
27
- after_freed = GC.stat[:total_freed_objects]
28
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
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
- if allocated_objects >= RailsMemoryProfiler.config.min_allocated_objects
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)
@@ -1,3 +1,3 @@
1
1
  module RailsMemoryProfiler
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
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.1.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/filter_controller.js
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
- }