rails_memory_profiler 0.1.0 → 0.3.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +22 -0
  3. data/app/assets/stylesheets/rails_memory_profiler/_05_filters.css +32 -2
  4. data/app/assets/stylesheets/rails_memory_profiler/_06_breakdowns.css +40 -0
  5. data/app/assets/stylesheets/rails_memory_profiler/_07_compare.css +46 -0
  6. data/app/controllers/rails_memory_profiler/base_controller.rb +15 -0
  7. data/app/controllers/rails_memory_profiler/comparisons_controller.rb +20 -0
  8. data/app/controllers/rails_memory_profiler/reports_controller.rb +1 -11
  9. data/app/javascript/rails_memory_profiler/application.js +1 -5
  10. data/app/javascript/rails_memory_profiler/controllers/application.js +4 -0
  11. data/app/javascript/rails_memory_profiler/controllers/compare_controller.js +27 -0
  12. data/app/javascript/rails_memory_profiler/controllers/filter_controller.js +50 -0
  13. data/app/javascript/rails_memory_profiler/controllers/index.js +6 -0
  14. data/app/views/rails_memory_profiler/comparisons/show.html.erb +75 -0
  15. data/app/views/rails_memory_profiler/reports/index.html.erb +98 -43
  16. data/app/views/rails_memory_profiler/reports/show.html.erb +34 -0
  17. data/config/importmap.rb +5 -2
  18. data/config/routes.rb +1 -0
  19. data/lib/generators/rails_memory_profiler/install/templates/initializer.rb +21 -0
  20. data/lib/rails_memory_profiler/configuration.rb +9 -1
  21. data/lib/rails_memory_profiler/middleware.rb +107 -26
  22. data/lib/rails_memory_profiler/notifiers/console.rb +36 -0
  23. data/lib/rails_memory_profiler/notifiers/file_logger.rb +16 -0
  24. data/lib/rails_memory_profiler/notifiers/logger.rb +9 -0
  25. data/lib/rails_memory_profiler/notifiers/stdout.rb +9 -0
  26. data/lib/rails_memory_profiler/notifiers.rb +19 -0
  27. data/lib/rails_memory_profiler/rspec_matchers.rb +20 -0
  28. data/lib/rails_memory_profiler/test_helper.rb +21 -0
  29. data/lib/rails_memory_profiler/version.rb +1 -1
  30. data/lib/rails_memory_profiler.rb +3 -0
  31. metadata +17 -2
  32. 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: 358d4a7e75c7fbdc553c28acc6350da8906ea834308410f32020168bc6e31985
4
- data.tar.gz: 910ce5bc705376412f44904d6d3640ec4ad48aa5396f330dd45e8101ca77cb5c
3
+ metadata.gz: da1c19f27cafbf571c7f5fe0e724c56e305d81307c99c1d970fdc96777459888
4
+ data.tar.gz: d95173a0c57ec1afefc7a13eed43e06b5fef53a8350a3b5b2955f42ad01c382f
5
5
  SHA512:
6
- metadata.gz: ae42d9f95d0e6b01b82be8359de89a4b95379dbbfa5acb66a8ee35a3579dd98870d95b9015ee1041a2a1e43c70978c066509cead89a96c70fd5b7da15dce9fa5
7
- data.tar.gz: dab8d94f6fbd06e9d96844b1e3c1dc5d010b0aa5b6257cba15f6dbef6e8a4202fe2df0d2b3a763803509ec5d4c08443d60adb146f9f9a86ea977716db4ee1a7e
6
+ metadata.gz: c34cfa6505349cae21f6e808c55bd8c682456809ac3052ed2d67ed72099ec72f4cfd890653c36cf580d15f92c9988b1a94ce95dfa679c1cda1fb4f2fefa51747
7
+ data.tar.gz: e5e1c2f7194e48d82b0073aed382662f95ffb552e119993a25dd62a58285f5a89e31e600664928a7bcd515696a5abebc2f02d32d9a49df5a5124d7c38c5165b6
data/README.md CHANGED
@@ -76,6 +76,11 @@ 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 |
81
+ | `notifiers` | `[]` | Array of objects responding to `#call(report)`; called after each report is stored |
82
+ | `log_file` | `nil` | Path to append JSON-serialized reports (one per line); shortcut for `Notifiers::FileLogger` |
83
+ | `raise_on_allocation_spike` | `nil` | Raise `AllocationSpikeError` when `allocated_objects` exceeds this threshold |
79
84
 
80
85
  Example:
81
86
 
@@ -85,9 +90,26 @@ RailsMemoryProfiler.configure do |config|
85
90
  config.min_allocated_objects = 1_000
86
91
  config.ignore_paths = ["/rails/memory", "/up"]
87
92
  config.ignore_controllers = ["rails/health"]
93
+ config.notifiers = [RailsMemoryProfiler::Notifiers::Console.new]
94
+ config.log_file = Rails.root.join("log/memory_profiler.jsonl")
88
95
  end
89
96
  ```
90
97
 
98
+ ### Test helpers
99
+
100
+ ```ruby
101
+ # Minitest / plain Ruby
102
+ require "rails_memory_profiler/test_helper"
103
+
104
+ count = RailsMemoryProfiler::TestHelper.capture_allocations { MyClass.new }
105
+ RailsMemoryProfiler::TestHelper.assert_allocations_below(500) { MyClass.new }
106
+
107
+ # RSpec
108
+ require "rails_memory_profiler/rspec_matchers"
109
+
110
+ expect { MyClass.new }.to allocate_fewer_than(500)
111
+ ```
112
+
91
113
  [↑ Back to top](#railsmemoryprofiler)
92
114
 
93
115
  ---
@@ -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,25 @@ 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
35
+
36
+ # Notifiers — called after each report is stored. Each must respond to #call(report).
37
+ # Built-in options:
38
+ # RailsMemoryProfiler::Notifiers::Logger.new — writes to Rails.logger
39
+ # RailsMemoryProfiler::Notifiers::Stdout.new — writes to $stdout
40
+ # RailsMemoryProfiler::Notifiers::Console.new — colorized terminal output
41
+ # config.notifiers = [RailsMemoryProfiler::Notifiers::Logger.new]
42
+
43
+ # Append JSON-serialized reports to a file (one report per line).
44
+ # config.log_file = Rails.root.join("log/memory_profiler.log").to_s
45
+
46
+ # Raise AllocationSpikeError when a request exceeds this many allocated objects.
47
+ # Useful in test environments to catch unexpected memory spikes.
48
+ # config.raise_on_allocation_spike = nil
28
49
  end
@@ -1,7 +1,10 @@
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,
6
+ :raise_on_allocation_spike,
7
+ :notifiers, :log_file
5
8
 
6
9
  def initialize
7
10
  @enabled = Rails.env.development?
@@ -11,6 +14,11 @@ module RailsMemoryProfiler
11
14
  @min_allocated_objects = 0
12
15
  @ignore_paths = []
13
16
  @ignore_controllers = []
17
+ @detailed_reports = false
18
+ @detailed_sample_rate = 10
19
+ @raise_on_allocation_spike = nil
20
+ @notifiers = []
21
+ @log_file = nil
14
22
  end
15
23
  end
16
24
  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,110 @@ 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
+ notify!(payload)
97
+ check_spike!(allocated_objects, env)
98
+ end
99
+
100
+ def notify!(report)
101
+ RailsMemoryProfiler.config.notifiers.each { |n| n.call(report) }
102
+ if (path = RailsMemoryProfiler.config.log_file)
103
+ Notifiers::FileLogger.new(path).call(report)
104
+ end
105
+ end
106
+
107
+ def check_spike!(allocated_objects, env)
108
+ threshold = RailsMemoryProfiler.config.raise_on_allocation_spike
109
+ return unless threshold && allocated_objects > threshold
110
+
111
+ raise AllocationSpikeError,
112
+ "#{env['PATH_INFO']} allocated #{allocated_objects} objects (threshold: #{threshold})"
113
+ end
114
+
115
+ def serialize_stats(stats)
116
+ stats.map { |s| { name: s[:data], count: s[:count] } }
117
+ end
118
+
119
+ def require_memory_profiler!
120
+ require "memory_profiler"
121
+ rescue LoadError
122
+ raise LoadError, "Add `gem 'memory_profiler'` to your Gemfile to use config.detailed_reports = true"
123
+ end
124
+
54
125
  def sample?
55
126
  rate = RailsMemoryProfiler.config.sample_rate
56
127
  return true if rate <= 1
@@ -61,6 +132,16 @@ module RailsMemoryProfiler
61
132
  end
62
133
  end
63
134
 
135
+ def detailed_sample?
136
+ rate = RailsMemoryProfiler.config.detailed_sample_rate
137
+ return true if rate <= 1
138
+
139
+ @mutex.synchronize do
140
+ @detailed_count = (@detailed_count + 1) % rate
141
+ @detailed_count.zero?
142
+ end
143
+ end
144
+
64
145
  def ignored_path?(path)
65
146
  RailsMemoryProfiler.config.ignore_paths.any? do |pattern|
66
147
  pattern.is_a?(Regexp) ? pattern.match?(path) : path.start_with?(pattern.to_s)
@@ -0,0 +1,36 @@
1
+ module RailsMemoryProfiler
2
+ module Notifiers
3
+ class Console
4
+ RESET = "\e[0m"
5
+ CYAN = "\e[36m"
6
+ DIM = "\e[2m"
7
+ GREEN = "\e[32m"
8
+ YELLOW = "\e[33m"
9
+ RED = "\e[31m"
10
+
11
+ def call(report)
12
+ $stdout.puts(colorize(report))
13
+ end
14
+
15
+ private
16
+
17
+ def colorize(report)
18
+ action = [report[:controller], report[:action]].compact.join("#")
19
+ alloc = report[:allocated_objects]
20
+
21
+ "#{DIM}[RMP]#{RESET} " \
22
+ "#{CYAN}#{report[:method]} #{report[:path]}#{RESET} " \
23
+ "#{DIM}(#{action})#{RESET} " \
24
+ "#{alloc_color(alloc)}#{Notifiers.thousands(alloc)} alloc#{RESET} " \
25
+ "#{DIM}#{report[:retained_objects]} retained #{report[:duration_ms]}ms#{RESET}"
26
+ end
27
+
28
+ def alloc_color(count)
29
+ if count < 5_000 then GREEN
30
+ elsif count < 20_000 then YELLOW
31
+ else RED
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,16 @@
1
+ require "json"
2
+
3
+ module RailsMemoryProfiler
4
+ module Notifiers
5
+ class FileLogger
6
+ def initialize(path)
7
+ @path = path
8
+ end
9
+
10
+ def call(report)
11
+ serializable = report.merge(recorded_at: report[:recorded_at]&.iso8601)
12
+ File.open(@path, "a") { |f| f.puts(serializable.to_json) }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,9 @@
1
+ module RailsMemoryProfiler
2
+ module Notifiers
3
+ class Logger
4
+ def call(report)
5
+ Rails.logger.info(Notifiers.format_line(report))
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module RailsMemoryProfiler
2
+ module Notifiers
3
+ class Stdout
4
+ def call(report)
5
+ $stdout.puts(Notifiers.format_line(report))
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,19 @@
1
+ require "rails_memory_profiler/notifiers/logger"
2
+ require "rails_memory_profiler/notifiers/stdout"
3
+ require "rails_memory_profiler/notifiers/console"
4
+ require "rails_memory_profiler/notifiers/file_logger"
5
+
6
+ module RailsMemoryProfiler
7
+ module Notifiers
8
+ def self.format_line(report)
9
+ action = [report[:controller], report[:action]].compact.join("#")
10
+ "[RailsMemoryProfiler] #{report[:method]} #{report[:path]} (#{action})" \
11
+ " — #{thousands(report[:allocated_objects])} allocated," \
12
+ " #{report[:retained_objects]} retained, #{report[:duration_ms]}ms"
13
+ end
14
+
15
+ def self.thousands(n)
16
+ n.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ require "rails_memory_profiler/test_helper"
2
+
3
+ if defined?(RSpec)
4
+ RSpec::Matchers.define :allocate_fewer_than do |expected|
5
+ supports_block_expectations
6
+
7
+ match do |block|
8
+ @count = RailsMemoryProfiler::TestHelper.capture_allocations(&block)
9
+ @count < expected
10
+ end
11
+
12
+ failure_message do
13
+ "expected block to allocate fewer than #{expected} objects, but got #{@count}"
14
+ end
15
+
16
+ failure_message_when_negated do
17
+ "expected block to allocate #{expected} or more objects, but got #{@count}"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,21 @@
1
+ require "rails_memory_profiler"
2
+
3
+ module RailsMemoryProfiler
4
+ module TestHelper
5
+ extend self
6
+
7
+ def capture_allocations
8
+ GC.start
9
+ before = GC.stat(:total_allocated_objects)
10
+ yield
11
+ GC.stat(:total_allocated_objects) - before
12
+ end
13
+
14
+ def assert_allocations_below(threshold, &block)
15
+ count = capture_allocations(&block)
16
+ return if count < threshold
17
+
18
+ raise "Expected fewer than #{threshold} allocated objects but got #{count}"
19
+ end
20
+ end
21
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsMemoryProfiler
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -2,10 +2,13 @@ require "rails_memory_profiler/version"
2
2
  require "rails_memory_profiler/configuration"
3
3
  require "rails_memory_profiler/request_context"
4
4
  require "rails_memory_profiler/report_store"
5
+ require "rails_memory_profiler/notifiers"
5
6
  require "rails_memory_profiler/middleware"
6
7
  require "rails_memory_profiler/engine"
7
8
 
8
9
  module RailsMemoryProfiler
10
+ class AllocationSpikeError < StandardError; end
11
+
9
12
  class << self
10
13
  def configure
11
14
  yield config
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.3.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
@@ -88,8 +96,15 @@ files:
88
96
  - lib/rails_memory_profiler/configuration.rb
89
97
  - lib/rails_memory_profiler/engine.rb
90
98
  - lib/rails_memory_profiler/middleware.rb
99
+ - lib/rails_memory_profiler/notifiers.rb
100
+ - lib/rails_memory_profiler/notifiers/console.rb
101
+ - lib/rails_memory_profiler/notifiers/file_logger.rb
102
+ - lib/rails_memory_profiler/notifiers/logger.rb
103
+ - lib/rails_memory_profiler/notifiers/stdout.rb
91
104
  - lib/rails_memory_profiler/report_store.rb
92
105
  - lib/rails_memory_profiler/request_context.rb
106
+ - lib/rails_memory_profiler/rspec_matchers.rb
107
+ - lib/rails_memory_profiler/test_helper.rb
93
108
  - lib/rails_memory_profiler/version.rb
94
109
  - lib/tasks/rails_memory_profiler_tasks.rake
95
110
  homepage: https://github.com/eclectic-coding/rails_memory_profiler
@@ -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
- }