rails_memory_profiler 0.1.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 (33) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +107 -0
  4. data/Rakefile +16 -0
  5. data/app/assets/stylesheets/rails_memory_profiler/_01_base.css +24 -0
  6. data/app/assets/stylesheets/rails_memory_profiler/_02_layout.css +49 -0
  7. data/app/assets/stylesheets/rails_memory_profiler/_03_table.css +39 -0
  8. data/app/assets/stylesheets/rails_memory_profiler/_04_badges.css +12 -0
  9. data/app/assets/stylesheets/rails_memory_profiler/_05_filters.css +58 -0
  10. data/app/controllers/rails_memory_profiler/application_controller.rb +4 -0
  11. data/app/controllers/rails_memory_profiler/reports_controller.rb +50 -0
  12. data/app/helpers/rails_memory_profiler/application_helper.rb +35 -0
  13. data/app/javascript/rails_memory_profiler/application.js +6 -0
  14. data/app/javascript/rails_memory_profiler/filter_controller.js +30 -0
  15. data/app/jobs/rails_memory_profiler/application_job.rb +4 -0
  16. data/app/mailers/rails_memory_profiler/application_mailer.rb +6 -0
  17. data/app/models/rails_memory_profiler/application_record.rb +5 -0
  18. data/app/views/layouts/rails_memory_profiler/application.html.erb +14 -0
  19. data/app/views/rails_memory_profiler/reports/index.html.erb +56 -0
  20. data/app/views/rails_memory_profiler/reports/show.html.erb +35 -0
  21. data/config/importmap.rb +4 -0
  22. data/config/routes.rb +3 -0
  23. data/lib/generators/rails_memory_profiler/install/install_generator.rb +25 -0
  24. data/lib/generators/rails_memory_profiler/install/templates/initializer.rb +28 -0
  25. data/lib/rails_memory_profiler/configuration.rb +16 -0
  26. data/lib/rails_memory_profiler/engine.rb +26 -0
  27. data/lib/rails_memory_profiler/middleware.rb +76 -0
  28. data/lib/rails_memory_profiler/report_store.rb +63 -0
  29. data/lib/rails_memory_profiler/request_context.rb +22 -0
  30. data/lib/rails_memory_profiler/version.rb +3 -0
  31. data/lib/rails_memory_profiler.rb +22 -0
  32. data/lib/tasks/rails_memory_profiler_tasks.rake +4 -0
  33. metadata +119 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 358d4a7e75c7fbdc553c28acc6350da8906ea834308410f32020168bc6e31985
4
+ data.tar.gz: 910ce5bc705376412f44904d6d3640ec4ad48aa5396f330dd45e8101ca77cb5c
5
+ SHA512:
6
+ metadata.gz: ae42d9f95d0e6b01b82be8359de89a4b95379dbbfa5acb66a8ee35a3579dd98870d95b9015ee1041a2a1e43c70978c066509cead89a96c70fd5b7da15dce9fa5
7
+ data.tar.gz: dab8d94f6fbd06e9d96844b1e3c1dc5d010b0aa5b6257cba15f6dbef6e8a4202fe2df0d2b3a763803509ec5d4c08443d60adb146f9f9a86ea977716db4ee1a7e
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Chuck Smith
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # RailsMemoryProfiler
2
+
3
+ [![CI](https://github.com/eclectic-coding/rails_memory_profiler/actions/workflows/main.yml/badge.svg)](https://github.com/eclectic-coding/rails_memory_profiler/actions/workflows/main.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/rails_memory_profiler)](https://rubygems.org/gems/rails_memory_profiler)
5
+ [![Gem Downloads](https://img.shields.io/gem/dt/rails_memory_profiler)](https://rubygems.org/gems/rails_memory_profiler)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
7
+ [![Rails](https://img.shields.io/badge/rails-%3E%3D%207.1-CC0000?logo=rubyonrails&logoColor=white)](https://rubyonrails.org)
8
+ [![codecov](https://codecov.io/gh/eclectic-coding/rails_memory_profiler/branch/main/graph/badge.svg)](https://codecov.io/gh/eclectic-coding/rails_memory_profiler)
9
+
10
+ Per-request memory allocation reports with a mountable dashboard UI. Fills the gap between the [`memory_profiler`](https://github.com/SamSaffron/memory_profiler) gem (terminal-only output, manual block wrapping) and having nowhere useful to browse results in a Rails app.
11
+
12
+ A Rack middleware captures object allocations for every request using `GC.stat` diffs. Results are stored in a thread-safe ring buffer and served through a mountable engine with a sortable, filterable dashboard.
13
+
14
+ ---
15
+
16
+ ## Table of Contents
17
+
18
+ - [Installation](#installation)
19
+ - [Dashboard](#dashboard)
20
+ - [Configuration](#configuration)
21
+ - [Contributing](#contributing)
22
+ - [License](#license)
23
+
24
+ ---
25
+
26
+ ## Installation
27
+
28
+ Add to your Gemfile:
29
+
30
+ ```ruby
31
+ gem "rails_memory_profiler", group: :development
32
+ ```
33
+
34
+ Run the install generator:
35
+
36
+ ```bash
37
+ bundle exec rails generate rails_memory_profiler:install
38
+ ```
39
+
40
+ This creates `config/initializers/rails_memory_profiler.rb` with all options documented and prints mount instructions.
41
+
42
+ Mount the dashboard in `config/routes.rb`:
43
+
44
+ ```ruby
45
+ mount RailsMemoryProfiler::Engine, at: "/rails/memory"
46
+ ```
47
+
48
+ Then visit `/rails/memory/reports` to see per-request allocation data.
49
+
50
+ [↑ Back to top](#railsmemoryprofiler)
51
+
52
+ ---
53
+
54
+ ## Dashboard
55
+
56
+ The index view shows a sortable table of captured requests. Click any row to open the detail view for that request.
57
+
58
+ Columns: **Path**, **Controller#Action**, **Allocated Objects** (colour-coded), **Retained Objects**, **Duration (ms)**, **Recorded At**.
59
+
60
+ Use the controller filter to narrow the table down to a specific controller without a page reload.
61
+
62
+ [↑ Back to top](#railsmemoryprofiler)
63
+
64
+ ---
65
+
66
+ ## Configuration
67
+
68
+ All options and their defaults:
69
+
70
+ | Option | Default | Description |
71
+ |---|---|---|
72
+ | `enabled` | `true` in development | Enable/disable request profiling |
73
+ | `sample_rate` | `1` | Profile every Nth request (`1` = every request) |
74
+ | `store_size` | `100` | Max reports in the ring buffer; oldest are evicted when full |
75
+ | `dashboard_enabled` | `true` in development | Enable the dashboard endpoint |
76
+ | `min_allocated_objects` | `0` | Skip requests that allocate fewer objects than this |
77
+ | `ignore_paths` | `[]` | Paths to skip — strings (prefix) or regexes |
78
+ | `ignore_controllers` | `[]` | Controller names to skip (e.g. `"rails/health"`) |
79
+
80
+ Example:
81
+
82
+ ```ruby
83
+ RailsMemoryProfiler.configure do |config|
84
+ config.sample_rate = 5
85
+ config.min_allocated_objects = 1_000
86
+ config.ignore_paths = ["/rails/memory", "/up"]
87
+ config.ignore_controllers = ["rails/health"]
88
+ end
89
+ ```
90
+
91
+ [↑ Back to top](#railsmemoryprofiler)
92
+
93
+ ---
94
+
95
+ ## Contributing
96
+
97
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/rails_memory_profiler).
98
+
99
+ [↑ Back to top](#railsmemoryprofiler)
100
+
101
+ ---
102
+
103
+ ## License
104
+
105
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
106
+
107
+ [↑ Back to top](#railsmemoryprofiler)
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
7
+
8
+ require 'rubocop/rake_task'
9
+ require 'bundler/audit/task'
10
+ require 'rspec/core/rake_task'
11
+
12
+ RuboCop::RakeTask.new(:lint)
13
+ Bundler::Audit::Task.new
14
+ RSpec::Core::RakeTask.new(:spec)
15
+
16
+ task default: [:lint, :'bundle:audit:update', 'bundle:audit:check', :spec]
@@ -0,0 +1,24 @@
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+
3
+ :root {
4
+ --bg: #f8f9fa;
5
+ --surface: #ffffff;
6
+ --border: #dee2e6;
7
+ --text: #212529;
8
+ --muted: #6c757d;
9
+ --accent: #4f46e5;
10
+ --radius: 6px;
11
+ --shadow: 0 1px 3px rgba(0,0,0,.08);
12
+ }
13
+
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
16
+ font-size: 14px;
17
+ line-height: 1.5;
18
+ color: var(--text);
19
+ background: var(--bg);
20
+ padding: 2rem;
21
+ }
22
+
23
+ .rmp-monospace { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 12px; }
24
+ .rmp-muted { color: var(--muted); font-size: 12px; }
@@ -0,0 +1,49 @@
1
+ .rmp-header { margin-bottom: 1.5rem; }
2
+ .rmp-header h1 { font-size: 1.4rem; font-weight: 600; }
3
+ .rmp-header p { color: var(--muted); margin-top: 0.2rem; }
4
+
5
+ .rmp-empty { color: var(--muted); font-style: italic; margin-top: 1rem; }
6
+ .rmp-summary { margin-bottom: 0.5rem; }
7
+
8
+ .rmp-back {
9
+ display: inline-block;
10
+ margin-bottom: 1rem;
11
+ color: var(--accent);
12
+ text-decoration: none;
13
+ font-size: 13px;
14
+ }
15
+ .rmp-back:hover { text-decoration: underline; }
16
+
17
+ .rmp-detail {
18
+ background: var(--surface);
19
+ border-radius: var(--radius);
20
+ box-shadow: var(--shadow);
21
+ padding: 1.5rem;
22
+ }
23
+
24
+ .rmp-detail-grid {
25
+ display: grid;
26
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
27
+ gap: 1rem;
28
+ }
29
+
30
+ .rmp-detail-item {
31
+ padding: 0.75rem 1rem;
32
+ background: var(--bg);
33
+ border-radius: var(--radius);
34
+ }
35
+
36
+ .rmp-detail-label {
37
+ font-size: 11px;
38
+ font-weight: 600;
39
+ color: var(--muted);
40
+ text-transform: uppercase;
41
+ letter-spacing: .04em;
42
+ margin-bottom: 0.25rem;
43
+ }
44
+
45
+ .rmp-detail-value {
46
+ font-size: 1.25rem;
47
+ font-weight: 600;
48
+ color: var(--text);
49
+ }
@@ -0,0 +1,39 @@
1
+ .rmp-table {
2
+ width: 100%;
3
+ border-collapse: collapse;
4
+ background: var(--surface);
5
+ border-radius: var(--radius);
6
+ overflow: hidden;
7
+ box-shadow: var(--shadow);
8
+ }
9
+
10
+ .rmp-table th {
11
+ text-align: left;
12
+ padding: 0.6rem 1rem;
13
+ background: #f0f0f0;
14
+ font-weight: 600;
15
+ font-size: 11px;
16
+ text-transform: uppercase;
17
+ letter-spacing: .04em;
18
+ color: var(--muted);
19
+ border-bottom: 1px solid var(--border);
20
+ }
21
+
22
+ .rmp-table td {
23
+ padding: 0.6rem 1rem;
24
+ border-bottom: 1px solid #eee;
25
+ vertical-align: middle;
26
+ }
27
+
28
+ .rmp-table tr:last-child td { border-bottom: none; }
29
+ .rmp-table tr:hover td { background: #fafafa; }
30
+
31
+ .rmp-sort-link {
32
+ color: inherit;
33
+ text-decoration: none;
34
+ display: block;
35
+ }
36
+ .rmp-sort-link:hover { color: var(--accent); }
37
+ .rmp-sort-active { color: var(--accent); }
38
+
39
+ .rmp-path { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 11px; opacity: .7; }
@@ -0,0 +1,12 @@
1
+ .rmp-badge {
2
+ display: inline-block;
3
+ padding: 2px 8px;
4
+ border-radius: 3px;
5
+ font-size: 11px;
6
+ font-weight: 600;
7
+ white-space: nowrap;
8
+ }
9
+
10
+ .rmp-badge--low { background: #d1fae5; color: #065f46; }
11
+ .rmp-badge--mid { background: #fef3c7; color: #92400e; }
12
+ .rmp-badge--high { background: #fee2e2; color: #991b1b; }
@@ -0,0 +1,58 @@
1
+ .rmp-filters {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 0.75rem;
5
+ margin-bottom: 1rem;
6
+ padding: 0.6rem 1rem;
7
+ background: var(--surface);
8
+ border-radius: var(--radius);
9
+ box-shadow: var(--shadow);
10
+ }
11
+
12
+ .rmp-filters label {
13
+ font-size: 11px;
14
+ font-weight: 600;
15
+ text-transform: uppercase;
16
+ letter-spacing: .04em;
17
+ color: var(--muted);
18
+ }
19
+
20
+ .rmp-input {
21
+ font-family: inherit;
22
+ font-size: 13px;
23
+ padding: 0.3rem 0.6rem;
24
+ border: 1px solid var(--border);
25
+ border-radius: var(--radius);
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ min-width: 200px;
29
+ }
30
+
31
+ .rmp-input:focus {
32
+ outline: none;
33
+ border-color: #86b7fe;
34
+ box-shadow: 0 0 0 2px rgba(13,110,253,.15);
35
+ }
36
+
37
+ .rmp-input-wrapper {
38
+ position: relative;
39
+ display: inline-flex;
40
+ align-items: center;
41
+ }
42
+
43
+ .rmp-input-wrapper .rmp-input { padding-right: 1.6rem; }
44
+
45
+ .rmp-input-clear {
46
+ position: absolute;
47
+ right: 0.4rem;
48
+ background: none;
49
+ border: none;
50
+ cursor: pointer;
51
+ color: var(--muted);
52
+ font-size: 11px;
53
+ line-height: 1;
54
+ padding: 0.15rem 0.2rem;
55
+ border-radius: 2px;
56
+ }
57
+
58
+ .rmp-input-clear:hover { color: var(--text); background: var(--border); }
@@ -0,0 +1,4 @@
1
+ module RailsMemoryProfiler
2
+ class ApplicationController < ActionController::API
3
+ end
4
+ end
@@ -0,0 +1,50 @@
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
+
9
+ SORTABLE_COLUMNS = %w[path controller allocated_objects retained_objects duration_ms recorded_at].freeze
10
+
11
+ def index
12
+ reports = ReportStore.all
13
+
14
+ respond_to do |format|
15
+ format.json { render json: reports }
16
+ format.html do
17
+ @sort = SORTABLE_COLUMNS.include?(params[:sort]) ? params[:sort] : "recorded_at"
18
+ @direction = params[:direction] == "asc" ? "asc" : "desc"
19
+ @reports = sorted(reports, @sort, @direction)
20
+ end
21
+ end
22
+ end
23
+
24
+ def show
25
+ @report = ReportStore.find(params[:id])
26
+
27
+ respond_to do |format|
28
+ format.html
29
+ format.json do
30
+ if @report
31
+ render json: @report
32
+ else
33
+ render json: { error: "Report not found" }, status: :not_found
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def sorted(reports, sort, direction)
42
+ sorted = reports.sort_by { |r| r[sort.to_sym] || 0 }
43
+ direction == "asc" ? sorted : sorted.reverse
44
+ end
45
+
46
+ def check_dashboard_enabled
47
+ head :forbidden unless RailsMemoryProfiler.config.dashboard_enabled
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,35 @@
1
+ module RailsMemoryProfiler
2
+ module ApplicationHelper
3
+ def inline_styles
4
+ dir = RailsMemoryProfiler::Engine.root.join("app/assets/stylesheets/rails_memory_profiler")
5
+ css = dir.glob("_*.css").sort.map(&:read).join("\n")
6
+ content_tag(:style, css.html_safe)
7
+ end
8
+
9
+ def sort_th(label, column, current_sort, current_dir)
10
+ active = current_sort == column.to_s
11
+ next_dir = (active && current_dir == "asc") ? "desc" : "asc"
12
+ indicator = active ? (current_dir == "asc" ? " ▲" : " ▼") : ""
13
+ params = request.query_parameters.merge("sort" => column, "direction" => next_dir)
14
+ href = "?" + params.to_query
15
+ content_tag(:th) do
16
+ link_to(
17
+ "#{label}#{indicator}".html_safe,
18
+ href,
19
+ class: ["rmp-sort-link", ("rmp-sort-active" if active)].compact.join(" ")
20
+ )
21
+ end
22
+ end
23
+
24
+ def allocation_badge(count)
25
+ css_class = if count < 5_000
26
+ "rmp-badge--low"
27
+ elsif count < 20_000
28
+ "rmp-badge--mid"
29
+ else
30
+ "rmp-badge--high"
31
+ end
32
+ content_tag(:span, number_with_delimiter(count), class: "rmp-badge #{css_class}")
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,6 @@
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)
@@ -0,0 +1,30 @@
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
+ }
@@ -0,0 +1,4 @@
1
+ module RailsMemoryProfiler
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module RailsMemoryProfiler
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module RailsMemoryProfiler
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>RailsMemoryProfiler</title>
7
+ <link rel="icon" href="data:,">
8
+ <%= inline_styles %>
9
+ <%= javascript_importmap_tags "rails_memory_profiler" %>
10
+ </head>
11
+ <body>
12
+ <%= yield %>
13
+ </body>
14
+ </html>
@@ -0,0 +1,56 @@
1
+ <div class="rmp-header">
2
+ <h1>Memory Profiler</h1>
3
+ <p><%= @reports.size %> request<%= "s" unless @reports.size == 1 %> captured</p>
4
+ </div>
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">
15
+ <button type="button"
16
+ class="rmp-input-clear"
17
+ aria-label="Clear filter"
18
+ hidden
19
+ data-filter-target="clearButton"
20
+ data-action="click->filter#clear">✕</button>
21
+ </div>
22
+
23
+ <% if @reports.empty? %>
24
+ <% 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>
52
+ </tr>
53
+ <% end %>
54
+ </tbody>
55
+ </table>
56
+ <% end %>
@@ -0,0 +1,35 @@
1
+ <%= link_to "← All Requests", reports_path, class: "rmp-back" %>
2
+
3
+ <% if @report %>
4
+ <div class="rmp-header">
5
+ <h1><%= @report[:method] %> <%= @report[:path] %></h1>
6
+ <p>
7
+ <%= [@report[:controller], @report[:action]].compact.join("#") %>
8
+ &middot;
9
+ <%= @report[:recorded_at]&.strftime("%Y-%m-%d %H:%M:%S") %>
10
+ </p>
11
+ </div>
12
+
13
+ <div class="rmp-detail">
14
+ <div class="rmp-detail-grid">
15
+ <div class="rmp-detail-item">
16
+ <div class="rmp-detail-label">Allocated Objects</div>
17
+ <div class="rmp-detail-value"><%= number_with_delimiter(@report[:allocated_objects]) %></div>
18
+ </div>
19
+ <div class="rmp-detail-item">
20
+ <div class="rmp-detail-label">Retained Objects</div>
21
+ <div class="rmp-detail-value"><%= number_with_delimiter(@report[:retained_objects]) %></div>
22
+ </div>
23
+ <div class="rmp-detail-item">
24
+ <div class="rmp-detail-label">Duration</div>
25
+ <div class="rmp-detail-value"><%= @report[:duration_ms] %> ms</div>
26
+ </div>
27
+ <div class="rmp-detail-item">
28
+ <div class="rmp-detail-label">HTTP Method</div>
29
+ <div class="rmp-detail-value"><%= @report[:method] %></div>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ <% else %>
34
+ <p class="rmp-empty">Report not found — it may have been evicted from the store.</p>
35
+ <% end %>
@@ -0,0 +1,4 @@
1
+ pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"
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"
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ RailsMemoryProfiler::Engine.routes.draw do
2
+ resources :reports, only: [:index, :show]
3
+ end
@@ -0,0 +1,25 @@
1
+ require "rails/generators"
2
+
3
+ module RailsMemoryProfiler
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates a RailsMemoryProfiler initializer in config/initializers."
9
+
10
+ def copy_initializer
11
+ template "initializer.rb", "config/initializers/rails_memory_profiler.rb"
12
+ end
13
+
14
+ def show_readme
15
+ say ""
16
+ say " Mount the dashboard in config/routes.rb:", :green
17
+ say ""
18
+ say ' mount RailsMemoryProfiler::Engine, at: "/rails/memory"'
19
+ say ""
20
+ say " Then visit /rails/memory/reports to see per-request allocation data.", :green
21
+ say ""
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ RailsMemoryProfiler.configure do |config|
2
+ # Enable or disable per-request memory profiling.
3
+ # Defaults to true in development, false elsewhere.
4
+ # config.enabled = Rails.env.development?
5
+
6
+ # Profile every Nth request. 1 = every request, 10 = every 10th, etc.
7
+ # Increase for busier apps where profiling every request adds too much overhead.
8
+ # config.sample_rate = 1
9
+
10
+ # Maximum number of reports kept in the in-memory ring buffer.
11
+ # Oldest reports are evicted when the buffer is full.
12
+ # config.store_size = 100
13
+
14
+ # Enable the HTML/JSON dashboard at /reports (relative to the engine mount point).
15
+ # Defaults to true in development, false elsewhere.
16
+ # config.dashboard_enabled = Rails.env.development?
17
+
18
+ # Skip recording requests that allocate fewer objects than this threshold.
19
+ # Useful for filtering out trivial requests (health checks, asset hits, etc.).
20
+ # config.min_allocated_objects = 0
21
+
22
+ # Paths to skip entirely — accepts strings (prefix match) or regexes.
23
+ # The engine's own mount path is a good candidate to add here.
24
+ # config.ignore_paths = ["/rails/memory", "/up", %r{^/assets/}]
25
+
26
+ # Controllers to skip — matched against the Rails controller name.
27
+ # config.ignore_controllers = ["rails/health", "rails_memory_profiler/reports"]
28
+ end
@@ -0,0 +1,16 @@
1
+ module RailsMemoryProfiler
2
+ class Configuration
3
+ attr_accessor :enabled, :sample_rate, :store_size, :dashboard_enabled,
4
+ :min_allocated_objects, :ignore_paths, :ignore_controllers
5
+
6
+ def initialize
7
+ @enabled = Rails.env.development?
8
+ @sample_rate = 1
9
+ @store_size = 100
10
+ @dashboard_enabled = Rails.env.development?
11
+ @min_allocated_objects = 0
12
+ @ignore_paths = []
13
+ @ignore_controllers = []
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,26 @@
1
+ require "turbo-rails"
2
+ require "importmap-rails"
3
+
4
+ module RailsMemoryProfiler
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace RailsMemoryProfiler
7
+ config.generators.api_only = true
8
+
9
+ initializer "rails_memory_profiler.assets" do |app|
10
+ if app.config.respond_to?(:assets)
11
+ app.config.assets.paths << root.join("app/javascript")
12
+ end
13
+ end
14
+
15
+ initializer "rails_memory_profiler.importmap", before: "importmap" do |app|
16
+ if app.config.respond_to?(:importmap)
17
+ app.config.importmap.paths << root.join("config/importmap.rb")
18
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
19
+ end
20
+ end
21
+
22
+ initializer "rails_memory_profiler.middleware" do |app|
23
+ app.middleware.use(Middleware)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,76 @@
1
+ module RailsMemoryProfiler
2
+ class Middleware
3
+ def initialize(app)
4
+ @app = app
5
+ @request_count = 0
6
+ @mutex = Mutex.new
7
+ end
8
+
9
+ def call(env)
10
+ return @app.call(env) unless RailsMemoryProfiler.config.enabled
11
+ return @app.call(env) if ignored_path?(env["PATH_INFO"])
12
+ return @app.call(env) unless sample?
13
+
14
+ profile(env)
15
+ end
16
+
17
+ private
18
+
19
+ 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]
23
+
24
+ status, headers, body = @app.call(env)
25
+
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)
29
+
30
+ allocated_objects = after_alloc - before_alloc
31
+ retained_objects = (after_alloc - after_freed) - (before_alloc - before_freed)
32
+
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
50
+
51
+ [status, headers, body]
52
+ end
53
+
54
+ def sample?
55
+ rate = RailsMemoryProfiler.config.sample_rate
56
+ return true if rate <= 1
57
+
58
+ @mutex.synchronize do
59
+ @request_count = (@request_count + 1) % rate
60
+ @request_count.zero?
61
+ end
62
+ end
63
+
64
+ def ignored_path?(path)
65
+ RailsMemoryProfiler.config.ignore_paths.any? do |pattern|
66
+ pattern.is_a?(Regexp) ? pattern.match?(path) : path.start_with?(pattern.to_s)
67
+ end
68
+ end
69
+
70
+ def ignored_controller?(controller)
71
+ return false unless controller
72
+
73
+ RailsMemoryProfiler.config.ignore_controllers.any? { |name| name.to_s == controller }
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,63 @@
1
+ module RailsMemoryProfiler
2
+ module ReportStore
3
+ class << self
4
+ def push(report)
5
+ mutex.synchronize do
6
+ ensure_buffer_size
7
+ buffer[@write_pos] = report.merge(id: SecureRandom.hex(6))
8
+ @write_pos = (@write_pos + 1) % capacity
9
+ @stored = [@stored + 1, capacity].min
10
+ end
11
+ end
12
+
13
+ def find(id)
14
+ all.find { |r| r[:id] == id }
15
+ end
16
+
17
+ def all
18
+ mutex.synchronize do
19
+ stored = @stored || 0
20
+ return [] if stored.zero?
21
+
22
+ if stored < capacity
23
+ buffer.first(stored)
24
+ else
25
+ buffer[@write_pos..] + buffer[0...@write_pos]
26
+ end
27
+ end
28
+ end
29
+
30
+ def clear
31
+ mutex.synchronize { reset! }
32
+ end
33
+
34
+ def size
35
+ mutex.synchronize { @stored || 0 }
36
+ end
37
+
38
+ private
39
+
40
+ def mutex
41
+ @mutex ||= Mutex.new
42
+ end
43
+
44
+ def capacity
45
+ RailsMemoryProfiler.config.store_size
46
+ end
47
+
48
+ def ensure_buffer_size
49
+ reset! if @write_pos.nil? || @buffer.nil? || @buffer.size != capacity
50
+ end
51
+
52
+ def buffer
53
+ @buffer ||= Array.new(capacity)
54
+ end
55
+
56
+ def reset!
57
+ @buffer = Array.new(capacity)
58
+ @write_pos = 0
59
+ @stored = 0
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,22 @@
1
+ module RailsMemoryProfiler
2
+ module RequestContext
3
+ class << self
4
+ def set(controller:, action:, path:, method:)
5
+ Thread.current[:rails_memory_profiler_context] = {
6
+ controller: controller,
7
+ action: action,
8
+ path: path,
9
+ method: method
10
+ }
11
+ end
12
+
13
+ def current
14
+ Thread.current[:rails_memory_profiler_context] || {}
15
+ end
16
+
17
+ def clear
18
+ Thread.current[:rails_memory_profiler_context] = nil
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,3 @@
1
+ module RailsMemoryProfiler
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,22 @@
1
+ require "rails_memory_profiler/version"
2
+ require "rails_memory_profiler/configuration"
3
+ require "rails_memory_profiler/request_context"
4
+ require "rails_memory_profiler/report_store"
5
+ require "rails_memory_profiler/middleware"
6
+ require "rails_memory_profiler/engine"
7
+
8
+ module RailsMemoryProfiler
9
+ class << self
10
+ def configure
11
+ yield config
12
+ end
13
+
14
+ def config
15
+ @config ||= Configuration.new
16
+ end
17
+
18
+ def reset_config!
19
+ @config = Configuration.new
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :rails_memory_profiler do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_memory_profiler
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Chuck Smith
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: importmap-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: turbo-rails
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: A Rack middleware captures object allocations per request using GC.stat
55
+ diffs and stores them in a thread-safe ring buffer. Results are served through a
56
+ mountable engine with a sortable, filterable dashboard — filling the gap between
57
+ the memory_profiler gem and having nowhere useful to view results in a Rails app.
58
+ email:
59
+ - eclectic-coding@users.noreply.github.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - MIT-LICENSE
65
+ - README.md
66
+ - Rakefile
67
+ - app/assets/stylesheets/rails_memory_profiler/_01_base.css
68
+ - app/assets/stylesheets/rails_memory_profiler/_02_layout.css
69
+ - app/assets/stylesheets/rails_memory_profiler/_03_table.css
70
+ - app/assets/stylesheets/rails_memory_profiler/_04_badges.css
71
+ - app/assets/stylesheets/rails_memory_profiler/_05_filters.css
72
+ - app/controllers/rails_memory_profiler/application_controller.rb
73
+ - app/controllers/rails_memory_profiler/reports_controller.rb
74
+ - app/helpers/rails_memory_profiler/application_helper.rb
75
+ - app/javascript/rails_memory_profiler/application.js
76
+ - app/javascript/rails_memory_profiler/filter_controller.js
77
+ - app/jobs/rails_memory_profiler/application_job.rb
78
+ - app/mailers/rails_memory_profiler/application_mailer.rb
79
+ - app/models/rails_memory_profiler/application_record.rb
80
+ - app/views/layouts/rails_memory_profiler/application.html.erb
81
+ - app/views/rails_memory_profiler/reports/index.html.erb
82
+ - app/views/rails_memory_profiler/reports/show.html.erb
83
+ - config/importmap.rb
84
+ - config/routes.rb
85
+ - lib/generators/rails_memory_profiler/install/install_generator.rb
86
+ - lib/generators/rails_memory_profiler/install/templates/initializer.rb
87
+ - lib/rails_memory_profiler.rb
88
+ - lib/rails_memory_profiler/configuration.rb
89
+ - lib/rails_memory_profiler/engine.rb
90
+ - lib/rails_memory_profiler/middleware.rb
91
+ - lib/rails_memory_profiler/report_store.rb
92
+ - lib/rails_memory_profiler/request_context.rb
93
+ - lib/rails_memory_profiler/version.rb
94
+ - lib/tasks/rails_memory_profiler_tasks.rake
95
+ homepage: https://github.com/eclectic-coding/rails_memory_profiler
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ homepage_uri: https://github.com/eclectic-coding/rails_memory_profiler
100
+ source_code_uri: https://github.com/eclectic-coding/rails_memory_profiler
101
+ changelog_uri: https://github.com/eclectic-coding/rails_memory_profiler/blob/main/CHANGELOG.md
102
+ rdoc_options: []
103
+ require_paths:
104
+ - lib
105
+ required_ruby_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '3.3'
110
+ required_rubygems_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ requirements: []
116
+ rubygems_version: 4.0.10
117
+ specification_version: 4
118
+ summary: Per-request memory allocation reports with a mountable dashboard UI for Rails.
119
+ test_files: []