memhealth 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8c0895575f5adc6767b228212ace922f4eb66eb565771c05bde602b4c4b48971
4
+ data.tar.gz: b44315dc375c9fa4108a5fb889f76dd1a0520883f9c8952a675c8fd840bc981a
5
+ SHA512:
6
+ metadata.gz: f5e263596880a31d83003ebe56700a6ed94d8bb6eafa60222b7424fbb1f8632b9a534b9952d6c892d41637a43f66df0a4d673d6e7f62c4f35c0279382a8486cc
7
+ data.tar.gz: 80ad19cf0d82d6a4f543701a82736c6837d265b0daa57ef9a709df99c550452b2fb8e77f131add2e86e2871c0978729d06e84035539b42cc1eb5c40ff6e5f2f5
data/LICENSE ADDED
@@ -0,0 +1 @@
1
+ MIT License
data/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # MemHealth
2
+
3
+ A Rails engine for monitoring memory health, detecting growth patterns (leaks & bloats) and memory swap operations. Helps you identify requests that consume high amounts of RAM and is compatible with Heroku.
4
+
5
+ ## Features
6
+
7
+ - Real-time memory usage monitoring
8
+ - Track highest memory consuming requests
9
+ - Account-level tracking for multi-tenant apps
10
+ - Redis-based data storage
11
+ - Web dashboard for viewing statistics
12
+ - Configurable thresholds and limits
13
+
14
+ <img width="1139" height="680" alt="s_2" src="https://github.com/user-attachments/assets/5e170097-77cf-4ec5-a7b0-47aeaf92135f" />
15
+
16
+ <img width="1142" height="696" alt="s_1" src="https://github.com/user-attachments/assets/68eeb503-e259-4dc0-b3b1-3438375b42d4" />
17
+
18
+ ## Heroku Memory Issues
19
+
20
+ If you're getting **R14 - Memory quota exceeded** errors, it means your application is using swap memory. Swap uses the disk to store memory instead of RAM. Disk speed is significantly slower than RAM, so page access time is greatly increased. This leads to a significant degradation in application performance. An application that is swapping will be much slower than one that is not. No one wants a slow application, so getting rid of R14 Memory quota exceeded errors on your application is very important.
21
+
22
+ <img width="909" height="272" alt="Screenshot 2025-08-30 at 18 46 41" src="https://github.com/user-attachments/assets/65bb2131-4dfd-4974-9647-805e44bc35f7" />
23
+
24
+ MemHealth helps you identify which specific requests are consuming excessive memory, allowing you to pinpoint and fix the root cause of these performance issues.
25
+
26
+ ## Installation
27
+
28
+ Add this line to your application's Gemfile:
29
+
30
+ ```ruby
31
+ gem "memhealth", git: "https://github.com/topkeyhq/memhealth"
32
+ ```
33
+
34
+ And then execute:
35
+
36
+ $ bundle install
37
+
38
+ ## Configuration
39
+
40
+ ```ruby
41
+ MemHealth.configure do |config|
42
+ config.redis_url = ENV.fetch(ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
43
+ end
44
+ ```
45
+
46
+ Configure Memhealth using environment variables:
47
+
48
+ | Environment Variable | Description | Default Value |
49
+ | --------------------------------- | ------------------------------------------------------------------------------ | ------------- |
50
+ | `MEM_HEALTH_ENABLED` | Enable/disable memory tracking | `false` |
51
+ | `MEM_HEALTH_SKIP_REQUESTS` | Number of initial requests to skip (avoids class loading overhead) | `10` |
52
+ | `MEM_HEALTH_MEMORY_THRESHOLD_MB` | Minimum memory difference (MB) to track a request | `1` |
53
+ | `MEM_HEALTH_RAM_BEFORE_THRESHOLD` | Minimum RAM usage (MB) before tracking (prevents tracking low-memory requests) | `0` |
54
+ | `MEM_HEALTH_MAX_STORED_URLS` | Maximum number of URLs to store in Redis | `20` |
55
+ | `MEM_HEALTH_REDIS_KEY` | Name of environment variable containing Redis URL (e.g., `REDISCLOUD_URL`) | `REDIS_URL` |
56
+
57
+ The gem will read the Redis connection URL from the environment variable specified in `MEM_HEALTH_REDIS_KEY`, falling back to `REDIS_URL` if not specified.
58
+
59
+ ## Usage
60
+
61
+ Mount the engine in your routes within an authenticated section:
62
+
63
+ ```ruby
64
+ # config/routes.rb
65
+ Rails.application.routes.draw do
66
+ # ... other routes ...
67
+
68
+ # Mount within authenticated admin section
69
+ authenticate :admin_user do
70
+ mount MemHealth::Engine, at: "/admin/memhealth"
71
+ end
72
+ end
73
+ ```
74
+
75
+ Enable memory tracking by setting:
76
+
77
+ ```bash
78
+ MEM_HEALTH_ENABLED=true
79
+ ```
80
+
81
+ ### ActiveAdmin Integration
82
+
83
+ To add Memhealth to your ActiveAdmin Operations menu, add this to your ActiveAdmin initializer:
84
+
85
+ ```ruby
86
+ # config/initializers/active_admin.rb
87
+ ActiveAdmin.setup do |config|
88
+ # ... other config ...
89
+
90
+ config.namespace :admin do |admin|
91
+ admin.build_menu do |menu|
92
+ # ... other menu items ...
93
+ menu.add label: "MemHealth", url: "/admin/memhealth", parent: "Operations"
94
+ end
95
+ end
96
+ end
97
+ ```
98
+
99
+ ## Console Usage
100
+
101
+ ```ruby
102
+ # View statistics
103
+ MemHealth::Tracker.print_stats
104
+
105
+ # Get top memory consuming URLs
106
+ MemHealth::Tracker.top_memory_urls
107
+
108
+ # Clear all data
109
+ MemHealth::Tracker.clear_all_data
110
+ ```
111
+
112
+ ## License
113
+
114
+ The gem is available as open source under the [MIT License](LICENSE).
@@ -0,0 +1,36 @@
1
+ module MemHealth
2
+ class DashboardController < ActionController::Base
3
+ protect_from_forgery with: :exception
4
+ layout "memhealth/application"
5
+
6
+ def index
7
+ @memory_hunter_enabled = MemHealth.configuration.enabled?
8
+
9
+ if @memory_hunter_enabled
10
+ @stats = MemHealth::Tracker.stats
11
+ @top_urls = MemHealth::Tracker.top_memory_urls
12
+ @max_memory_url = MemHealth::Tracker.max_memory_url
13
+ else
14
+ @stats = nil
15
+ @top_urls = []
16
+ @max_memory_url = nil
17
+ end
18
+ end
19
+
20
+ def clear
21
+ before_stats = MemHealth::Tracker.stats
22
+ MemHealth::Tracker.clear_all_data
23
+
24
+ redirect_to root_path, notice: "✅ All memory usage statistics have been reset successfully! Cleared #{before_stats[:stored_urls_count]} URLs and reset max memory diff from #{before_stats[:max_memory_diff]} MB to 0.0 MB."
25
+ rescue StandardError => e
26
+ redirect_to root_path, alert: "❌ Error clearing memory statistics: #{e.message}"
27
+ end
28
+
29
+ private
30
+
31
+ def authenticate_user!
32
+ # Override this method in your host app if you need authentication
33
+ # For example: authenticate_admin_user! or redirect_to login_path unless current_user&.admin?
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,213 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Memhealth</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <style>
10
+ /* Base styles */
11
+ * {
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Arial, sans-serif;
17
+ line-height: 1.6;
18
+ margin: 0;
19
+ padding: 0;
20
+ }
21
+
22
+ /* Padding utilities with better spacing */
23
+ .px-4 { padding-left: 1.5rem; padding-right: 1.5rem; }
24
+ .py-6 { padding-top: 2rem; padding-bottom: 2rem; }
25
+ .px-6 { padding-left: 2rem; padding-right: 2rem; }
26
+ .py-4 { padding-top: 1.25rem; padding-bottom: 1.25rem; }
27
+ .px-2 { padding-left: 0.75rem; padding-right: 0.75rem; }
28
+ .py-1 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
29
+ .px-3 { padding-left: 1rem; padding-right: 1rem; }
30
+ .py-2 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
31
+ .py-3 { padding-top: 1rem; padding-bottom: 1rem; }
32
+ .pb-6 { padding-bottom: 2rem; }
33
+ .p-6 { padding: 2rem; }
34
+ .p-4 { padding: 1.25rem; }
35
+ .p-3 { padding: 1rem; }
36
+
37
+ .mb-6 { margin-bottom: 2rem; }
38
+ .mb-4 { margin-bottom: 1.5rem; }
39
+ .mb-2 { margin-bottom: 0.75rem; }
40
+ .mb-1 { margin-bottom: 0.5rem; }
41
+ .mt-4 { margin-top: 1.5rem; }
42
+ .mt-3 { margin-top: 1rem; }
43
+ .mt-1 { margin-top: 0.5rem; }
44
+ .mt-6 { margin-top: 2rem; }
45
+ .ml-4 { margin-left: 1.5rem; }
46
+ .ml-0 { margin-left: 0; }
47
+
48
+ .bg-green-50 { background-color: rgb(240 253 244); }
49
+ .bg-red-50 { background-color: rgb(254 242 242); }
50
+ .bg-yellow-50 { background-color: rgb(254 252 232); }
51
+ .bg-white { background-color: rgb(255 255 255); }
52
+ .bg-blue-50 { background-color: rgb(239 246 255); }
53
+ .bg-purple-50 { background-color: rgb(250 245 255); }
54
+ .bg-gray-50 { background-color: rgb(249 250 251); }
55
+ .bg-gray-100 { background-color: rgb(243 244 246); }
56
+ .bg-gray-900 { background-color: rgb(17 24 39); }
57
+ .bg-red-100 { background-color: rgb(254 226 226); }
58
+ .bg-yellow-100 { background-color: rgb(254 249 195); }
59
+ .bg-green-100 { background-color: rgb(220 252 231); }
60
+
61
+ .border { border-width: 1px; }
62
+ .border-2 { border-width: 2px; }
63
+ .border-green-200 { border-color: rgb(187 247 208); }
64
+ .border-red-200 { border-color: rgb(254 202 202); }
65
+ .border-yellow-200 { border-color: rgb(254 240 138); }
66
+ .border-red-100 { border-color: rgb(254 226 226); }
67
+ .border-yellow-100 { border-color: rgb(254 249 195); }
68
+ .border-red-400 { border-color: rgb(248 113 113); }
69
+ .border-b { border-bottom-width: 1px; }
70
+ .border-gray-200 { border-color: rgb(229 231 235); }
71
+ .divide-y > * + * { border-top-width: 1px; }
72
+ .divide-gray-200 > * + * { border-color: rgb(229 231 235); }
73
+
74
+ .rounded-lg { border-radius: 0.75rem; }
75
+ .rounded-md { border-radius: 0.5rem; }
76
+ .rounded { border-radius: 0.375rem; }
77
+ .rounded-full { border-radius: 9999px; }
78
+
79
+ .text-lg { font-size: 1.125rem; line-height: 1.75rem; }
80
+ .text-sm { font-size: 0.875rem; line-height: 1.25rem; }
81
+ .text-xs { font-size: 0.75rem; line-height: 1rem; }
82
+ .text-2xl { font-size: 1.5rem; line-height: 2rem; }
83
+
84
+ .font-semibold { font-weight: 600; }
85
+ .font-medium { font-weight: 500; }
86
+ .font-bold { font-weight: 700; }
87
+ .font-mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; }
88
+
89
+ .text-green-800 { color: rgb(22 101 52); }
90
+ .text-green-700 { color: rgb(21 128 61); }
91
+ .text-red-800 { color: rgb(153 27 27); }
92
+ .text-gray-900 { color: rgb(17 24 39); }
93
+ .text-gray-600 { color: rgb(75 85 99); }
94
+ .text-gray-500 { color: rgb(107 114 128); }
95
+ .text-blue-600 { color: rgb(37 99 235); }
96
+ .text-blue-900 { color: rgb(30 58 138); }
97
+ .text-green-600 { color: rgb(22 163 74); }
98
+ .text-green-900 { color: rgb(20 83 45); }
99
+ .text-yellow-600 { color: rgb(202 138 4); }
100
+ .text-yellow-900 { color: rgb(113 63 18); }
101
+ .text-purple-600 { color: rgb(147 51 234); }
102
+ .text-purple-900 { color: rgb(88 28 135); }
103
+ .text-purple-500 { color: rgb(168 85 247); }
104
+ .text-yellow-800 { color: rgb(146 64 14); }
105
+ .text-gray-700 { color: rgb(55 65 81); }
106
+ .text-green-400 { color: rgb(74 222 128); }
107
+ .text-red-900 { color: rgb(127 29 29); }
108
+ .text-red-800 { color: rgb(153 27 27); }
109
+
110
+ .shadow { box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); }
111
+ .shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); }
112
+
113
+ .flex { display: flex; }
114
+ .inline-flex { display: inline-flex; }
115
+ .grid { display: grid; }
116
+ .items-start { align-items: flex-start; }
117
+ .items-center { align-items: center; }
118
+ .justify-between { justify-content: space-between; }
119
+ .flex-1 { flex: 1 1 0%; }
120
+ .flex-shrink-0 { flex-shrink: 0; }
121
+ .gap-4 { gap: 1.5rem; }
122
+ .space-y-3 > * + * { margin-top: 1rem; }
123
+ .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
124
+
125
+ .min-w-0 { min-width: 0px; }
126
+ .min-w-full { min-width: 100%; }
127
+ .max-w-xs { max-width: 20rem; }
128
+ .max-w-md { max-width: 28rem; }
129
+
130
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
131
+ .whitespace-nowrap { white-space: nowrap; }
132
+ .text-center { text-align: center; }
133
+ .text-left { text-align: left; }
134
+ .uppercase { text-transform: uppercase; }
135
+ .tracking-wider { letter-spacing: 0.05em; }
136
+
137
+ .overflow-hidden { overflow: hidden; }
138
+ .cursor-pointer { cursor: pointer; }
139
+
140
+ .hover\\:bg-red-100:hover { background-color: rgb(254 226 226); }
141
+ .focus\\:ring-2:focus { box-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); }
142
+ .focus\\:ring-red-500:focus { --tw-ring-color: rgb(239 68 68); }
143
+
144
+ /* Table styles */
145
+ table { border-collapse: collapse; width: 100%; }
146
+ th, td { text-align: left; }
147
+ th { font-weight: 500; }
148
+
149
+ /* Compact table cell padding */
150
+ table th.px-6 { padding-left: 1rem !important; padding-right: 1rem !important; }
151
+ table td.px-6 { padding-left: 1rem !important; padding-right: 1rem !important; }
152
+ table th.py-3 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; }
153
+ table td.py-4 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; }
154
+
155
+ /* Responsive grid */
156
+ @media (min-width: 768px) {
157
+ .md\\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
158
+ }
159
+
160
+ /* Container styles */
161
+ .container { max-width: 1200px; margin: 0 auto; }
162
+
163
+ /* Header styles */
164
+ .header {
165
+ background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
166
+ color: white;
167
+ padding: 1.5rem 0;
168
+ margin-bottom: 2rem;
169
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
170
+ }
171
+
172
+ .header h1 {
173
+ font-size: 2.5rem;
174
+ font-weight: 700;
175
+ margin: 0;
176
+ letter-spacing: -0.025em;
177
+ }
178
+
179
+ .header p {
180
+ margin: 1rem 0 0 0;
181
+ opacity: 0.95;
182
+ font-size: 1.125rem;
183
+ font-weight: 400;
184
+ }
185
+
186
+ </style>
187
+ </head>
188
+
189
+ <body class="bg-gray-50">
190
+ <div class="header">
191
+ <div class="container px-6">
192
+ <h1>MemHealth</h1>
193
+ </div>
194
+ </div>
195
+
196
+
197
+ <div class="container">
198
+ <% if notice %>
199
+ <div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
200
+ <div class="text-sm text-green-800"><%= notice %></div>
201
+ </div>
202
+ <% end %>
203
+
204
+ <% if alert %>
205
+ <div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
206
+ <div class="text-sm text-red-800"><%= alert %></div>
207
+ </div>
208
+ <% end %>
209
+
210
+ <%= yield %>
211
+ </div>
212
+ </body>
213
+ </html>
@@ -0,0 +1,181 @@
1
+ <div class="px-6 pb-6">
2
+ <% if @memory_hunter_enabled %>
3
+ <!-- Memory Hunter Enabled Status -->
4
+ <div class="bg-green-50 border border-green-200 rounded-lg px-6 py-4 mb-6">
5
+ <h3 class="text-lg font-semibold text-green-800 mb-2">✅ MemHealth Enabled</h3>
6
+ <p class="text-sm text-green-700">Memory profiling is active and tracking requests. To disable, set <code class="bg-green-100 px-1 rounded">ENV["MEM_HEALTH_ENABLED"]=false</code></p>
7
+ </div>
8
+
9
+ <!-- Highest Memory Usage URL - Prominent display -->
10
+ <% if @max_memory_url %>
11
+ <div class="bg-red-50 border border-red-200 rounded-lg px-6 py-6 mb-6">
12
+ <h3 class="text-lg font-semibold text-red-800 mb-4">🚨 Highest Memory Usage URL</h3>
13
+
14
+ <div class="bg-white rounded-md px-6 py-4 border border-red-100">
15
+ <!-- URL Section -->
16
+ <div class="mb-4">
17
+ <dt class="text-sm font-medium text-gray-500 mb-1">URL</dt>
18
+ <dd class="text-sm text-gray-900 font-mono bg-gray-100 px-2 py-1 rounded truncate ml-0">
19
+ <%= @max_memory_url["url"] %>
20
+ </dd>
21
+ </div>
22
+
23
+ <!-- Four column layout using flex -->
24
+ <div class="flex gap-4">
25
+ <% if @max_memory_url["ram_before"] && @max_memory_url["ram_after"] %>
26
+ <div class="flex-1 text-left">
27
+ <dt class="text-sm font-medium text-gray-500 mb-1">RAM Usage</dt>
28
+ <dd class="text-sm text-gray-600 ml-0">
29
+ <%= @max_memory_url["ram_before"] %> MB → <%= @max_memory_url["ram_after"] %> MB
30
+ </dd>
31
+ </div>
32
+ <% end %>
33
+
34
+ <div class="flex-1 text-left">
35
+ <dt class="text-sm font-medium text-gray-500 mb-1">Recorded At</dt>
36
+ <dd class="text-sm text-gray-600 ml-0"><%= Time.parse(@max_memory_url["recorded_at"]).utc.strftime("%Y-%m-%d %H:%M") %></dd>
37
+ </div>
38
+
39
+ <% if @max_memory_url["account_id"].present? %>
40
+ <div class="flex-1 text-left">
41
+ <dt class="text-sm font-medium text-gray-500 mb-1">Account ID</dt>
42
+ <dd class="text-sm text-gray-600 ml-0"><%= @max_memory_url["account_id"] %></dd>
43
+ </div>
44
+ <% end %>
45
+
46
+ <div class="flex-1 text-left">
47
+ <div class="bg-red-100 border border-red-300 rounded-md px-4 py-3">
48
+ <dt class="text-sm font-medium text-red-700 mb-1">Memory Diff</dt>
49
+ <dd class="text-lg font-bold text-red-800 ml-0">
50
+ <%= @max_memory_url["memory_diff"] %> MB
51
+ </dd>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ <% end %>
58
+
59
+ <!-- Stats summary card -->
60
+ <div class="bg-white rounded-lg shadow px-6 py-6 mb-6">
61
+ <h3 class="text-lg font-medium text-gray-900 mb-4">Memory Usage Statistics</h3>
62
+
63
+ <div class="flex gap-4">
64
+ <div class="bg-blue-50 p-4 rounded-md flex-1 text-left">
65
+ <dt class="text-sm font-medium text-blue-600">Max Memory Difference</dt>
66
+ <dd class="mt-1 text-2xl font-semibold text-blue-900 text-left ml-0"><%= @stats[:max_memory_diff] %> MB</dd>
67
+ </div>
68
+
69
+ <div class="bg-green-50 p-4 rounded-md flex-1 text-left">
70
+ <dt class="text-sm font-medium text-green-600">Stored URLs</dt>
71
+ <dd class="mt-1 text-2xl font-semibold text-green-900 text-left ml-0"><%= @stats[:stored_urls_count] %>/<%= @stats[:max_stored_urls] %></dd>
72
+ </div>
73
+
74
+ <div class="bg-yellow-50 p-4 rounded-md flex-1 text-left">
75
+ <dt class="text-sm font-medium text-yellow-600">Requests Tracked</dt>
76
+ <dd class="mt-1 text-2xl font-semibold text-yellow-900 text-left ml-0"><%= @stats[:tracked_requests_count] %></dd>
77
+ </div>
78
+
79
+ <div class="bg-purple-50 p-4 rounded-md flex-1 text-left">
80
+ <dt class="text-sm font-medium text-purple-600">Total Requests on current dyno/Rails instance</dt>
81
+ <dd class="mt-1 text-2xl font-semibold text-purple-900 text-left ml-0"><%= @stats[:total_requests_count] %></dd>
82
+ <% if @stats[:skipped_requests_count] > 0 %>
83
+ <p class="text-xs text-purple-500 mt-1"><%= @stats[:skipped_requests_count] %> skipped</p>
84
+ <% end %>
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ <!-- Top URLs table -->
90
+ <% if @top_urls.any? %>
91
+ <div class="bg-white rounded-lg shadow overflow-hidden">
92
+ <div class="px-6 py-4 border-b border-gray-200">
93
+ <h3 class="text-lg font-medium text-gray-900">Top <%= MemHealth.configuration.max_stored_urls %> URLs by Memory Usage</h3>
94
+ <p class="text-sm text-gray-600 mt-1">URLs ordered by memory difference (highest first) - maximum <%= MemHealth.configuration.max_stored_urls %> URLs stored</p>
95
+ </div>
96
+
97
+ <table class="min-w-full divide-y divide-gray-200">
98
+ <thead class="bg-gray-50">
99
+ <tr>
100
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Memory Diff</th>
101
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">RAM Before</th>
102
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">RAM After</th>
103
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">URL</th>
104
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Account ID</th>
105
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Recorded At</th>
106
+ </tr>
107
+ </thead>
108
+
109
+ <tbody class="bg-white divide-y divide-gray-200">
110
+ <% @top_urls.each do |url_data| %>
111
+ <tr>
112
+ <td class="px-6 py-4 whitespace-nowrap">
113
+ <%
114
+ memory_color = if url_data["memory_diff"] > 10
115
+ "bg-red-100 text-red-800"
116
+ elsif url_data["memory_diff"] > 5
117
+ "bg-yellow-100 text-yellow-800"
118
+ else
119
+ "bg-green-100 text-green-800"
120
+ end
121
+ %>
122
+ <span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full <%= memory_color %>">
123
+ <%= url_data["memory_diff"] %> MB
124
+ </span>
125
+ </td>
126
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
127
+ <%= url_data["ram_before"] ? "#{url_data["ram_before"]} MB" : "/" %>
128
+ </td>
129
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
130
+ <%= url_data["ram_after"] ? "#{url_data["ram_after"]} MB" : "/" %>
131
+ </td>
132
+ <td class="px-6 py-4 text-sm text-gray-900">
133
+ <div class="max-w-xs truncate">
134
+ <code class="bg-gray-100 px-2 py-1 rounded text-xs"><%= url_data["url"] %></code>
135
+ </div>
136
+ </td>
137
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
138
+ <%= url_data["account_id"].present? ? url_data["account_id"] : "/" %>
139
+ </td>
140
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
141
+ <%= Time.parse(url_data["recorded_at"]).utc.strftime("%Y-%m-%d %H:%M") %>
142
+ </td>
143
+ </tr>
144
+ <% end %>
145
+ </tbody>
146
+ </table>
147
+ </div>
148
+ <% else %>
149
+ <div class="bg-white rounded-lg shadow px-6 py-4 text-center">
150
+ <h3 class="text-lg font-medium text-gray-900 mb-2">No URLs Tracked Yet</h3>
151
+ <p class="text-gray-600">No URLs have been recorded yet.</p>
152
+ <p class="text-sm text-gray-500 mt-2">Memory tracking will begin after the first <%= MemHealth.configuration.skip_requests %> requests to your application.</p>
153
+ </div>
154
+ <% end %>
155
+
156
+ <!-- Action buttons -->
157
+ <div class="mt-6 space-y-3">
158
+ <div class="flex gap-4">
159
+ <%= form_with url: clear_path, method: :post, local: true, class: "inline" do |form| %>
160
+ <%= form.button "🗑️ Reset Stats",
161
+ type: "submit",
162
+ onclick: "return confirm('Are you sure you want to reset all memory usage statistics? This will clear all data including max memory difference and URLs.')",
163
+ class: "inline-flex items-center px-6 py-3 border-2 border-red-400 text-sm font-semibold rounded-md text-red-800 bg-red-50 hover:bg-red-100 focus:ring-2 focus:ring-red-500 shadow-sm cursor-pointer" %>
164
+ <% end %>
165
+ </div>
166
+ </div>
167
+ <% else %>
168
+ <!-- Memory Hunter Disabled Status -->
169
+ <div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 mb-6">
170
+ <h3 class="text-lg font-semibold text-yellow-800 mb-4">⚠️ MemHealth Disabled</h3>
171
+
172
+ <div class="bg-white rounded-md p-4 border border-yellow-100">
173
+ <p class="text-sm text-gray-700 mb-3">Memory profiling is currently disabled. To enable memory growth tracking:</p>
174
+
175
+ <div class="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm mb-3">
176
+ Set ENV['MEM_HEALTH_ENABLED'] = true
177
+ </div>
178
+ </div>
179
+ </div>
180
+ <% end %>
181
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ MemHealth::Engine.routes.draw do
2
+ root "dashboard#index"
3
+ post "/clear", to: "dashboard#clear", as: :clear
4
+ end
@@ -0,0 +1,32 @@
1
+ require "redis"
2
+
3
+ module MemHealth
4
+ class Configuration
5
+ attr_accessor :enabled,
6
+ :skip_requests,
7
+ :memory_threshold_mb,
8
+ :ram_before_threshold_mb,
9
+ :max_stored_urls,
10
+ :redis_url,
11
+ :redis_key_prefix
12
+
13
+ def initialize
14
+ @enabled = ENV.fetch("MEM_HEALTH_ENABLED", "false") == "true"
15
+ @skip_requests = ENV.fetch("MEM_HEALTH_SKIP_REQUESTS", "10").to_i
16
+ @memory_threshold_mb = ENV.fetch("MEM_HEALTH_MEMORY_THRESHOLD_MB", "1").to_i
17
+ @ram_before_threshold_mb = ENV.fetch("MEM_HEALTH_RAM_BEFORE_THRESHOLD", "0").to_i
18
+ @max_stored_urls = ENV.fetch("MEM_HEALTH_MAX_STORED_URLS", "20").to_i
19
+ redis_env_key = ENV.fetch("MEM_HEALTH_REDIS_KEY", "REDIS_URL")
20
+ @redis_url = ENV.fetch(redis_env_key, "redis://localhost:6379/0")
21
+ @redis_key_prefix = "memhealth"
22
+ end
23
+
24
+ def enabled?
25
+ @enabled
26
+ end
27
+
28
+ def redis
29
+ @redis ||= Redis.new(url: redis_url)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,40 @@
1
+ module MemHealth
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace MemHealth
4
+
5
+ # Set specific autoload paths and exclude others
6
+ config.autoload_paths += %W(#{root}/app/controllers #{root}/app/views)
7
+ config.eager_load_paths += %W(#{root}/app/controllers #{root}/app/views)
8
+
9
+ # Configure Zeitwerk to ignore config and lib directories
10
+ initializer "mem_health.zeitwerk_ignore", before: :set_autoload_paths do
11
+ config.autoload_paths.delete("#{root}/config")
12
+ config.autoload_paths.delete("#{root}/lib")
13
+
14
+ if defined?(Rails.autoloaders)
15
+ Rails.autoloaders.main.ignore("#{root}/config")
16
+ Rails.autoloaders.main.ignore("#{root}/lib")
17
+ end
18
+ end
19
+
20
+ config.generators do |g|
21
+ g.test_framework :rspec
22
+ end
23
+
24
+ initializer "mem_health.load_controllers", after: :load_config_initializers do
25
+ # Manually require the controller to ensure it loads
26
+ begin
27
+ require File.join(root, "app", "controllers", "mem_health", "dashboard_controller")
28
+ rescue => e
29
+ Rails.logger.warn "Could not load MemHealth controller: #{e.message}" if defined?(Rails.logger)
30
+ end
31
+ end
32
+
33
+ initializer "mem_health.middleware", before: :build_middleware_stack do |app|
34
+ if MemHealth.configuration.enabled?
35
+ require "get_process_mem"
36
+ app.config.middleware.use MemHealth::Middleware
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,154 @@
1
+ require "get_process_mem"
2
+
3
+ module MemHealth
4
+ class Middleware
5
+ @@request_count = 0
6
+ @@skipped_requests_count = 0
7
+
8
+ def self.redis_tracked_requests_key
9
+ "#{MemHealth.configuration.redis_key_prefix}:tracked_requests"
10
+ end
11
+
12
+ def initialize(app)
13
+ @app = app
14
+ end
15
+
16
+ def call(env)
17
+ @@request_count += 1
18
+
19
+ before = GetProcessMem.new.mb
20
+ status, headers, response = @app.call(env)
21
+ after = GetProcessMem.new.mb
22
+
23
+ memory_diff = (after - before).round(2)
24
+ request_url = build_request_url(env)
25
+ account_info = extract_account_info(env)
26
+
27
+ # Skip the first few requests as they have large memory jumps due to class loading
28
+ if @@request_count > config.skip_requests
29
+ redis.incr(redis_tracked_requests_key)
30
+ should_track = memory_diff > config.memory_threshold_mb && before.round(2) > config.ram_before_threshold_mb
31
+ track_memory_usage(memory_diff, request_url, before.round(2), after.round(2), account_info) if should_track
32
+ else
33
+ @@skipped_requests_count += 1
34
+ end
35
+
36
+ [status, headers, response]
37
+ end
38
+
39
+ def self.reset_data
40
+ @@request_count = 0
41
+ @@skipped_requests_count = 0
42
+ MemHealth.configuration.redis.del(redis_tracked_requests_key)
43
+ end
44
+
45
+ def self.tracked_requests_count
46
+ MemHealth.configuration.redis.get(redis_tracked_requests_key)&.to_i || 0
47
+ end
48
+
49
+ def self.total_requests_count
50
+ @@request_count
51
+ end
52
+
53
+ def self.skipped_requests_count
54
+ @@skipped_requests_count
55
+ end
56
+
57
+ private
58
+
59
+ def config
60
+ MemHealth.configuration
61
+ end
62
+
63
+ def redis
64
+ config.redis
65
+ end
66
+
67
+ def redis_max_diff_key
68
+ "#{config.redis_key_prefix}:max_diff"
69
+ end
70
+
71
+ def redis_max_diff_url_key
72
+ "#{config.redis_key_prefix}:max_diff_url"
73
+ end
74
+
75
+ def redis_high_usage_urls_key
76
+ "#{config.redis_key_prefix}:high_usage_urls"
77
+ end
78
+
79
+ def redis_tracked_requests_key
80
+ self.class.redis_tracked_requests_key
81
+ end
82
+
83
+ def track_memory_usage(memory_diff, request_url, ram_before, ram_after, account_info = {})
84
+ # Update max memory diff seen so far
85
+ current_max = redis.get(redis_max_diff_key)&.to_f || 0.0
86
+ if memory_diff > current_max
87
+ redis.set(redis_max_diff_key, memory_diff)
88
+
89
+ # Store the URL data that caused the maximum memory usage
90
+ max_url_data = {
91
+ url: request_url,
92
+ memory_diff: memory_diff,
93
+ ram_before: ram_before,
94
+ ram_after: ram_after,
95
+ timestamp: Time.current.to_i,
96
+ recorded_at: Time.current.iso8601
97
+ }.merge(account_info)
98
+ redis.set(redis_max_diff_url_key, max_url_data.to_json)
99
+ end
100
+
101
+ # Store all URLs
102
+ timestamp = Time.current.to_i
103
+ url_data = {
104
+ url: request_url,
105
+ memory_diff: memory_diff,
106
+ ram_before: ram_before,
107
+ ram_after: ram_after,
108
+ timestamp: timestamp,
109
+ recorded_at: Time.current.iso8601
110
+ }.merge(account_info)
111
+
112
+ # Add URL to sorted set (score = memory_diff for DESC ordering)
113
+ redis.zadd(redis_high_usage_urls_key, memory_diff, url_data.to_json)
114
+
115
+ # Keep only top N URLs by removing lowest scores
116
+ current_count = redis.zcard(redis_high_usage_urls_key)
117
+ if current_count > config.max_stored_urls
118
+ # Remove the lowest scoring URLs (keep top max_stored_urls)
119
+ redis.zremrangebyrank(redis_high_usage_urls_key, 0, current_count - config.max_stored_urls - 1)
120
+ end
121
+ end
122
+
123
+ def build_request_url(env)
124
+ request = Rack::Request.new(env)
125
+ url = "#{request.request_method} #{request.fullpath}"
126
+
127
+ # Truncate very long URLs
128
+ (url.length > 600) ? "#{url[0..650]}..." : url
129
+ end
130
+
131
+ def extract_account_info(env)
132
+ account_info = {}
133
+
134
+ begin
135
+ # Try to get account info from various sources
136
+ if defined?(ActsAsTenant) && ActsAsTenant.current_tenant
137
+ account = ActsAsTenant.current_tenant
138
+ account_info[:account_id] = account.id
139
+ elsif env["warden"]&.user&.respond_to?(:account)
140
+ # Try to get from authenticated user
141
+ account = env["warden"].user.account
142
+ account_info[:account_id] = account.id
143
+ elsif env["HTTP_X_ACCOUNT_ID"]
144
+ # Fallback to header if available
145
+ account_info[:account_id] = env["HTTP_X_ACCOUNT_ID"]
146
+ end
147
+ rescue StandardError => _e
148
+ # Silently fail if account extraction fails
149
+ end
150
+
151
+ account_info
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,104 @@
1
+ module MemHealth
2
+ class Tracker
3
+ class << self
4
+ # Get the maximum memory difference recorded
5
+ def max_memory_diff
6
+ redis.get(redis_max_diff_key)&.to_f || 0.0
7
+ end
8
+
9
+ # Get all stored URLs, ordered by memory usage (highest first)
10
+ def top_memory_urls(limit: config.max_stored_urls)
11
+ redis.zrevrange(redis_high_usage_urls_key, 0, limit - 1, with_scores: true).map do |json_data, score|
12
+ data = JSON.parse(json_data)
13
+ data.merge("memory_diff" => score)
14
+ end
15
+ end
16
+
17
+ # Alias for backward compatibility
18
+ alias_method :high_usage_urls, :top_memory_urls
19
+
20
+ # Get URLs that used more than a specific threshold
21
+ def urls_above_threshold(threshold_mb)
22
+ redis.zrangebyscore(redis_high_usage_urls_key, threshold_mb, "+inf", with_scores: true).map do |json_data, score|
23
+ data = JSON.parse(json_data)
24
+ data.merge("memory_diff" => score)
25
+ end
26
+ end
27
+
28
+ # Get the URL with the highest memory usage (the one that set the max diff)
29
+ def max_memory_url
30
+ json_data = redis.get(redis_max_diff_url_key)
31
+ return nil if json_data.nil?
32
+
33
+ JSON.parse(json_data)
34
+ end
35
+
36
+ # Clear all memory tracking data
37
+ def clear_all_data
38
+ redis.del(redis_max_diff_key, redis_max_diff_url_key, redis_high_usage_urls_key)
39
+ MemHealth::Middleware.reset_data
40
+ end
41
+
42
+ # Get stats summary
43
+ def stats
44
+ max_diff = max_memory_diff
45
+ stored_urls_count = redis.zcard(redis_high_usage_urls_key)
46
+ tracked_count = MemHealth::Middleware.tracked_requests_count
47
+ total_count = MemHealth::Middleware.total_requests_count
48
+ skipped_count = MemHealth::Middleware.skipped_requests_count
49
+
50
+ {
51
+ max_memory_diff: max_diff,
52
+ stored_urls_count: stored_urls_count,
53
+ max_stored_urls: config.max_stored_urls,
54
+ tracked_requests_count: tracked_count,
55
+ total_requests_count: total_count,
56
+ skipped_requests_count: skipped_count,
57
+ requests_tracked: "#{tracked_count} requests tracked (#{skipped_count} skipped) out of #{total_count} total requests"
58
+ }
59
+ end
60
+
61
+ # Pretty print stats for console use
62
+ def print_stats
63
+ stats_data = stats
64
+ puts "\n=== Memory Usage Stats ==="
65
+ puts "Max memory difference: #{stats_data[:max_memory_diff]} MB"
66
+ puts "Stored URLs: #{stats_data[:stored_urls_count]}/#{stats_data[:max_stored_urls]}"
67
+ puts "Tracking: #{stats_data[:requests_tracked]}"
68
+
69
+ if stats_data[:stored_urls_count] > 0
70
+ puts "\nTop 10 memory usage URLs:"
71
+ top_memory_urls(limit: 10).each_with_index do |url_data, index|
72
+ if url_data["ram_before"] && url_data["ram_after"]
73
+ puts "#{index + 1}. #{url_data["memory_diff"]} MB (#{url_data["ram_before"]} → #{url_data["ram_after"]} MB) - #{url_data["url"]} (#{url_data["recorded_at"]})"
74
+ else
75
+ puts "#{index + 1}. #{url_data["memory_diff"]} MB - #{url_data["url"]} (#{url_data["recorded_at"]})"
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def config
84
+ MemHealth.configuration
85
+ end
86
+
87
+ def redis
88
+ config.redis
89
+ end
90
+
91
+ def redis_max_diff_key
92
+ "#{config.redis_key_prefix}:max_diff"
93
+ end
94
+
95
+ def redis_max_diff_url_key
96
+ "#{config.redis_key_prefix}:max_diff_url"
97
+ end
98
+
99
+ def redis_high_usage_urls_key
100
+ "#{config.redis_key_prefix}:high_usage_urls"
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,3 @@
1
+ module MemHealth
2
+ VERSION = "0.1.0"
3
+ end
data/lib/memhealth.rb ADDED
@@ -0,0 +1,20 @@
1
+ require "mem_health/version"
2
+ require "mem_health/configuration"
3
+ require "mem_health/middleware"
4
+ require "mem_health/tracker"
5
+ require "mem_health/engine"
6
+
7
+ module MemHealth
8
+ class << self
9
+ attr_writer :configuration
10
+
11
+ def configuration
12
+ @configuration ||= Configuration.new
13
+ end
14
+
15
+ def configure
16
+ yield(configuration)
17
+ end
18
+ end
19
+ end
20
+
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: memhealth
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Klemen Nagode
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: '6.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: redis
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: get_process_mem
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
+ - !ruby/object:Gem::Dependency
55
+ name: rspec-rails
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ description: A Rails engine for monitoring memory health and detecting growth patterns
69
+ in production applications
70
+ email:
71
+ - klemen@topkey.io
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - LICENSE
77
+ - README.md
78
+ - app/controllers/mem_health/dashboard_controller.rb
79
+ - app/views/layouts/memhealth/application.html.erb
80
+ - app/views/mem_health/dashboard/index.html.erb
81
+ - config/routes.rb
82
+ - lib/mem_health/configuration.rb
83
+ - lib/mem_health/engine.rb
84
+ - lib/mem_health/middleware.rb
85
+ - lib/mem_health/tracker.rb
86
+ - lib/mem_health/version.rb
87
+ - lib/memhealth.rb
88
+ homepage: https://github.com/topkeyhq/memhealth
89
+ licenses:
90
+ - MIT
91
+ metadata:
92
+ homepage_uri: https://github.com/topkeyhq/memhealth
93
+ source_code_uri: https://github.com/topkeyhq/memhealth
94
+ rdoc_options: []
95
+ require_paths:
96
+ - lib
97
+ required_ruby_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ requirements: []
108
+ rubygems_version: 3.6.7
109
+ specification_version: 4
110
+ summary: Rails memory health monitoring and tracking
111
+ test_files: []