sidekiq_insight 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: a8c2db5cf7bb0e99fe1af7f752c3ad0999cc865c7e030d9b1c78b59c183e226c
4
+ data.tar.gz: 0c5594584fb8682227d72669cd1942ff0175051582efaddcc49af15511ff877e
5
+ SHA512:
6
+ metadata.gz: 5c1be3e9a2f1101c4ecd2fe32080d4c7f42dc47f7652fd27735e93e63a181ce5b12f75bb720f9b6e061e97f148e90cf3a6dd817c7b099e42721edc6dc61fb087
7
+ data.tar.gz: ed781d6af149092780b7051ebf7d4456b8444d84424245846407294a622daa4bd57a30230633b072b47de7d2ffed9024e10b3bfd43693e8a3f283674548a2016
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in sidekiq_insight.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 mrmalvi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # SidekiqInsight
2
+
3
+ **SidekiqInsight** is a lightweight performance monitoring engine for Sidekiq.
4
+ It records CPU usage, wall time, memory (RSS), arguments, and detects memory-leak patterns — all displayed in a clean, Bootstrap-based dashboard.
5
+
6
+ ---
7
+
8
+ ## 🚀 Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ ```ruby
13
+ gem "sidekiq_insight"
14
+ ```
15
+
16
+ Then:
17
+
18
+ ```bash
19
+ bundle install
20
+ ```
21
+
22
+ ---
23
+
24
+ ## ⚙️ Configuration (Required)
25
+
26
+ Create the initializer:
27
+
28
+ ```
29
+ config/initializers/sidekiq_insight.rb
30
+ ```
31
+
32
+ Add:
33
+
34
+ ```ruby
35
+ SidekiqInsight.configure do |config|
36
+ config.redis_url = "redis://127.0.0.1:6379/0"
37
+ end
38
+ ```
39
+
40
+ SidekiqInsight uses Redis to store:
41
+
42
+ - job samples
43
+ - aggregated metrics
44
+ - leak alerts
45
+
46
+ ---
47
+
48
+ ## 🛣 Mounting the Engine
49
+
50
+ Add this to your Rails **config/routes.rb**:
51
+
52
+ ```ruby
53
+ mount SidekiqInsight::Engine => "/sidekiq_insight"
54
+ ```
55
+
56
+ Then visit:
57
+
58
+ ```
59
+ http://localhost:3000/sidekiq_insight
60
+ ```
61
+
62
+ ---
63
+
64
+ ## 📊 What SidekiqInsight Monitors
65
+
66
+ ### For every Sidekiq job run, it records:
67
+
68
+ - `started_at`
69
+ - `wall_ms`
70
+ - `cpu_ms`
71
+ - `rss_kb`
72
+ - job arguments
73
+ - execution count
74
+
75
+ ### Aggregated metrics:
76
+
77
+ - average CPU
78
+ - average memory usage
79
+ - execution counts
80
+
81
+ ### Memory leak detection:
82
+
83
+ SidekiqInsight automatically analyzes RSS trends:
84
+
85
+ ```ruby
86
+ SidekiqInsight::Metrics.detect_leak(samples)
87
+ ```
88
+
89
+ Jobs with a positive trend appear under **Leak Alerts**.
90
+
91
+ ---
92
+
93
+ ## 🖥 Dashboard Pages
94
+
95
+ ### **Top Jobs Metrics**
96
+ - `/sidekiq_insight/graphs/cpu`
97
+ - `/sidekiq_insight/graphs/rss`
98
+ - `/sidekiq_insight/graphs/wall`
99
+
100
+ Each page shows sortable metrics and Chart.js graphs.
101
+
102
+ ---
103
+
104
+ ### **Leak Alerts**
105
+ Lists jobs where memory leak patterns are detected.
106
+
107
+ ---
108
+
109
+ ### **Job Details**
110
+ View every sample recorded for a job:
111
+
112
+ - CPU chart
113
+ - Memory (RSS) chart
114
+ - Wall time chart
115
+ - Raw arguments (JSON)
116
+
117
+ ---
118
+
119
+ ## ⚡ Adding Middleware (Highly Recommended)
120
+
121
+ Inside `config/initializers/sidekiq.rb` or `sidekiq.yml`:
122
+
123
+ ```ruby
124
+ Sidekiq.configure_server do |config|
125
+ config.server_middleware do |chain|
126
+ chain.add SidekiqInsight::ServerMiddleware
127
+ end
128
+ end
129
+ ```
130
+
131
+ This ensures job metrics are captured.
132
+
133
+ ---
134
+
135
+ ## 🔧 Utility Methods
136
+
137
+ ### Clear all stored metrics:
138
+
139
+ ```ruby
140
+ SidekiqInsight.storage.clear_all
141
+ ```
142
+
143
+ Useful during development.
144
+
145
+ ---
146
+
147
+ ## 📦 Directory Structure
148
+
149
+ ```
150
+ sidekiq_insight/
151
+ app/
152
+ controllers/sidekiq_insight/
153
+ views/sidekiq_insight/
154
+ lib/
155
+ sidekiq_insight/
156
+ metrics.rb
157
+ storage.rb
158
+ server_middleware.rb
159
+ request_middleware.rb
160
+ version.rb
161
+ sidekiq_insight.rb
162
+ config/routes.rb
163
+ ```
164
+
165
+ ---
166
+
167
+ ## 🎨 Frontend
168
+
169
+ The dashboard UI uses:
170
+
171
+ - **Bootstrap 5**
172
+ - **Chart.js graphs**
173
+ - **Responsive tables/cards**
174
+ - **Leak alerts highlighting**
175
+
176
+ No configuration needed out of the box.
177
+
178
+ ---
179
+
180
+ ## 📝 Example Output
181
+
182
+ Metrics include data like:
183
+
184
+ ```json
185
+ {
186
+ "started_at": "2025-02-01T10:20:30Z",
187
+ "wall_ms": 52.0,
188
+ "cpu_ms": 13.5,
189
+ "rss_kb": 242.1,
190
+ "args": ["123", true]
191
+ }
192
+ ```
193
+
194
+ ---
195
+
196
+ ## ❤️ Contributing
197
+
198
+ Pull requests are welcome!
199
+ Please open an issue first to discuss changes.
200
+
201
+ ---
202
+
203
+ ## 📜 License
204
+
205
+ MIT License
206
+
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
@@ -0,0 +1,6 @@
1
+ module SidekiqInsight
2
+ class BaseController < ActionController::Base
3
+ protect_from_forgery with: :exception
4
+ layout "sidekiq_insight/sidekiq_insight"
5
+ end
6
+ end
@@ -0,0 +1,19 @@
1
+ module SidekiqInsight
2
+ class DashboardController < BaseController
3
+ def index
4
+ @jobs = SidekiqInsight.storage.top_jobs(50)
5
+ @alerts = SidekiqInsight.detector.recent_alerts(20)
6
+ end
7
+
8
+ def show
9
+ key = params[:job]
10
+ @samples = SidekiqInsight.storage.recent(key, 200)
11
+ @leak = SidekiqInsight::Metrics.detect_leak(@samples)
12
+ end
13
+
14
+ def clear
15
+ SidekiqInsight.storage.clear_all
16
+ redirect_to sidekiq_insight.root_path, notice: "Cleared profiler data"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,38 @@
1
+ module SidekiqInsight
2
+ class GraphsController < BaseController
3
+ def cpu
4
+ @jobs = SidekiqInsight.storage.top_jobs(50)
5
+ @cpu_chart = build_series(:cpu_ms)
6
+ end
7
+
8
+ def rss
9
+ @jobs = SidekiqInsight.storage.top_jobs(50)
10
+ @rss_chart = build_series(:rss_kb)
11
+ end
12
+
13
+ def wall
14
+ @jobs = SidekiqInsight.storage.top_jobs(50)
15
+ @wall_chart = build_series(:wall_ms)
16
+ end
17
+
18
+ def leaks
19
+ @jobs = SidekiqInsight.storage.top_jobs(50)
20
+ @leak_jobs = @jobs.select do |j|
21
+ samples = SidekiqInsight.storage.recent(j[:key], 200)
22
+ SidekiqInsight::Metrics.detect_leak(samples)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def build_series(metric_sym)
29
+ data = {}
30
+ SidekiqInsight.storage.top_jobs(20).each do |job|
31
+ samples = SidekiqInsight.storage.recent(job[:key], 200)
32
+ series = samples.reverse.map { |s| [s[:started_at], s[metric_sym].to_f] }
33
+ data[job[:key]] = series
34
+ end
35
+ data
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,48 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>Sidekiq Insight</title>
6
+
7
+ <!-- Bootstrap CDN -->
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
9
+
10
+ <!-- Chart.js (v4) + date adapter BEFORE Chartkick -->
11
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
12
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
13
+
14
+ <!-- Chartkick -->
15
+ <script src="https://cdn.jsdelivr.net/npm/chartkick@5.0.1/dist/chartkick.min.js"></script>
16
+
17
+ <style>
18
+ body { background: #f8f9fa; font-family: -apple-system, system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial; }
19
+ .container { margin-top: 20px; }
20
+ </style>
21
+ <script>
22
+ Chartkick.use(Chart);
23
+ </script>
24
+ </head>
25
+ <body>
26
+ <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
27
+ <div class="container-fluid">
28
+ <a class="navbar-brand" href="<%= sidekiq_insight.root_path %>">Sidekiq Insight</a>
29
+ <div class="collapse navbar-collapse">
30
+ <ul class="navbar-nav me-auto">
31
+ <li class="nav-item"><a class="nav-link" href="<%= sidekiq_insight.root_path %>">Overview</a></li>
32
+ <li class="nav-item"><a class="nav-link" href="<%= sidekiq_insight.cpu_path %>">CPU</a></li>
33
+ <li class="nav-item"><a class="nav-link" href="<%= sidekiq_insight.rss_path %>">Memory</a></li>
34
+ <li class="nav-item"><a class="nav-link" href="<%= sidekiq_insight.leaks_path %>">Leak Detector</a></li>
35
+ </ul>
36
+ </div>
37
+ </div>
38
+ </nav>
39
+
40
+ <div class="container">
41
+ <% if flash[:notice] %>
42
+ <div class="alert alert-success"><%= flash[:notice] %></div>
43
+ <% end %>
44
+
45
+ <%= yield %>
46
+ </div>
47
+ </body>
48
+ </html>
@@ -0,0 +1,84 @@
1
+ <div class="container py-4">
2
+
3
+ <div class="row g-4">
4
+
5
+ <!-- ================= TOP JOBS ================= -->
6
+ <div class="col-md-8">
7
+ <div class="card shadow-sm h-100">
8
+ <div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
9
+ <h4 class="mb-0">Top Jobs by Avg CPU</h4>
10
+ <span class="badge bg-light text-primary">
11
+ <%= @jobs.count %> jobs
12
+ </span>
13
+ </div>
14
+
15
+ <div class="card-body p-0">
16
+ <table class="table table-hover table-striped mb-0">
17
+ <thead class="table-light">
18
+ <tr>
19
+ <th>Job</th>
20
+ <th>Count</th>
21
+ <th>Avg CPU (ms)</th>
22
+ <th>Avg Mem (KB)</th>
23
+ <th></th>
24
+ </tr>
25
+ </thead>
26
+
27
+ <tbody>
28
+ <% @jobs.each do |j| %>
29
+ <tr>
30
+ <td class="fw-semibold"><%= j[:key] %></td>
31
+ <td><%= j[:count] %></td>
32
+ <td>
33
+ <span class="badge bg-info text-dark">
34
+ <%= number_with_precision(j[:avg_cpu_ms], precision: 2) %>
35
+ </span>
36
+ </td>
37
+ <td>
38
+ <span class="badge bg-success">
39
+ <%= number_with_precision(j[:avg_mem_kb], precision: 2) %>
40
+ </span>
41
+ </td>
42
+ <td>
43
+ <a class="btn btn-sm btn-outline-primary"
44
+ href="<%= sidekiq_insight.job_path(job: j[:key]) %>">
45
+ View
46
+ </a>
47
+ </td>
48
+ </tr>
49
+ <% end %>
50
+ </tbody>
51
+
52
+ </table>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+ <!-- ================= LEAK ALERTS ================= -->
58
+ <div class="col-md-4">
59
+ <div class="card shadow-sm h-100">
60
+ <div class="card-header bg-danger text-white">
61
+ <h4 class="mb-0">Leak Alerts</h4>
62
+ </div>
63
+
64
+ <div class="card-body">
65
+ <% if @alerts.present? %>
66
+ <ul class="list-group">
67
+ <% @alerts.each do |a| %>
68
+ <li class="list-group-item d-flex justify-content-between align-items-center">
69
+ <strong><%= a[:job] %></strong>
70
+ <span class="badge bg-danger">Leak suspected</span>
71
+ </li>
72
+ <% end %>
73
+ </ul>
74
+ <% else %>
75
+ <div class="text-center text-muted py-3">
76
+ No recent leak alerts 🔍
77
+ </div>
78
+ <% end %>
79
+ </div>
80
+ </div>
81
+ </div>
82
+
83
+ </div>
84
+ </div>
@@ -0,0 +1,87 @@
1
+ <div class="container py-4">
2
+
3
+ <!-- Page Header -->
4
+ <div class="d-flex justify-content-between align-items-center mb-4">
5
+ <h2 class="fw-bold text-primary">
6
+ Samples for <%= params[:job] %>
7
+ </h2>
8
+
9
+ <% if @leak %>
10
+ <span class="badge bg-danger fs-6 px-3 py-2 shadow-sm">Memory Leak Detected</span>
11
+ <% else %>
12
+ <span class="badge bg-success fs-6 px-3 py-2 shadow-sm">No Leak</span>
13
+ <% end %>
14
+ </div>
15
+
16
+ <!-- Charts Section -->
17
+ <div class="row g-4">
18
+
19
+ <!-- WALL TIME -->
20
+ <div class="col-md-4">
21
+ <div class="card shadow-sm h-100">
22
+ <div class="card-header bg-primary text-white">Wall Time (ms)</div>
23
+ <div class="card-body">
24
+ <%= line_chart @samples.reverse.map { |s| [s[:started_at], s[:wall_ms].to_f] },
25
+ library: { maintainAspectRatio: false } %>
26
+ </div>
27
+ </div>
28
+ </div>
29
+
30
+ <!-- CPU -->
31
+ <div class="col-md-4">
32
+ <div class="card shadow-sm h-100">
33
+ <div class="card-header bg-info text-white">CPU (ms)</div>
34
+ <div class="card-body">
35
+ <%= line_chart @samples.reverse.map { |s| [s[:started_at], s[:cpu_ms].to_f] } %>
36
+ </div>
37
+ </div>
38
+ </div>
39
+
40
+ <!-- RSS MEMORY -->
41
+ <div class="col-md-4">
42
+ <div class="card shadow-sm h-100">
43
+ <div class="card-header bg-success text-white">RSS Delta (KB)</div>
44
+ <div class="card-body">
45
+ <%= line_chart @samples.reverse.map { |s| [s[:started_at], s[:rss_kb].to_f] } %>
46
+ </div>
47
+ </div>
48
+ </div>
49
+
50
+ </div>
51
+
52
+ <!-- Raw Data Table -->
53
+ <div class="card mt-5 shadow-sm">
54
+ <div class="card-header bg-dark text-white">
55
+ Sample Details
56
+ </div>
57
+
58
+ <div class="card-body p-0">
59
+ <table class="table table-striped table-bordered mb-0">
60
+ <thead class="table-light">
61
+ <tr>
62
+ <th>Started</th>
63
+ <th>Wall (ms)</th>
64
+ <th>CPU (ms)</th>
65
+ <th>RSS (KB)</th>
66
+ <th>Args</th>
67
+ </tr>
68
+ </thead>
69
+
70
+ <tbody>
71
+ <% @samples.each do |s| %>
72
+ <tr>
73
+ <td class="text-nowrap"><%= s[:started_at] %></td>
74
+ <td><%= s[:wall_ms] %></td>
75
+ <td><%= s[:cpu_ms] %></td>
76
+ <td><%= s[:rss_kb] %></td>
77
+ <td style="max-width:300px;">
78
+ <pre class="small bg-light p-2 rounded"><%= JSON.pretty_generate(s[:args]) rescue s[:args] %></pre>
79
+ </td>
80
+ </tr>
81
+ <% end %>
82
+ </tbody>
83
+ </table>
84
+ </div>
85
+ </div>
86
+
87
+ </div>
@@ -0,0 +1,40 @@
1
+ <div class="card mb-4 shadow-sm">
2
+ <div class="card-header bg-primary text-white">
3
+ <h5 class="mb-0"><%= title %></h5>
4
+ </div>
5
+
6
+ <div class="card-body">
7
+ <canvas id="<%= dom_id %>" height="120"></canvas>
8
+ </div>
9
+ </div>
10
+
11
+ <script>
12
+ document.addEventListener("DOMContentLoaded", function () {
13
+ const ctx = document.getElementById("<%= dom_id %>").getContext("2d");
14
+
15
+ const datasets = [
16
+ <% data.each do |job_key, series| %>
17
+ {
18
+ label: "<%= job_key %>",
19
+ data: <%= series.map { |t, v| { x: t, y: v } }.to_json %>,
20
+ borderWidth: 2,
21
+ tension: 0.3,
22
+ },
23
+ <% end %>
24
+ ];
25
+
26
+ new Chart(ctx, {
27
+ type: 'line',
28
+ data: { datasets: datasets },
29
+ options: {
30
+ responsive: true,
31
+ parsing: false,
32
+ plugins: { legend: { position: "bottom" } },
33
+ scales: {
34
+ x: { type: 'time', time: { unit: 'minute' } },
35
+ y: { beginAtZero: true }
36
+ }
37
+ }
38
+ });
39
+ });
40
+ </script>
@@ -0,0 +1,33 @@
1
+ <div class="container py-4">
2
+ <h2 class="mb-4 text-primary fw-bold">CPU Usage — Top Jobs</h2>
3
+
4
+ <div class="card mb-4 shadow-sm">
5
+ <div class="card-header bg-dark text-white">
6
+ Job Statistics
7
+ </div>
8
+
9
+ <div class="card-body p-0">
10
+ <table class="table table-striped mb-0">
11
+ <thead class="table-light">
12
+ <tr>
13
+ <th>Job</th>
14
+ <th>Average CPU (ms)</th>
15
+ <th>Runs</th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <% @jobs.each do |job| %>
20
+ <tr>
21
+ <td><strong><%= job[:key] %></strong></td>
22
+ <td><%= job[:avg_cpu_ms].round(2) %></td>
23
+ <td><%= job[:count] %></td>
24
+ </tr>
25
+ <% end %>
26
+ </tbody>
27
+ </table>
28
+ </div>
29
+ </div>
30
+
31
+ <%= render partial: "chart",
32
+ locals: { title: "CPU Time (ms)", dom_id: "cpu_chart", data: @cpu_chart } %>
33
+ </div>
@@ -0,0 +1,38 @@
1
+ <div class="container py-4">
2
+ <h2 class="mb-4 text-danger fw-bold">Memory Leak Detector</h2>
3
+
4
+ <% if @leak_jobs.empty? %>
5
+ <div class="alert alert-success shadow-sm">
6
+ No leaks detected 🎉
7
+ </div>
8
+ <% else %>
9
+ <div class="card shadow-sm">
10
+ <div class="card-header bg-danger text-white">
11
+ Leaking Jobs
12
+ </div>
13
+
14
+ <div class="card-body p-0">
15
+ <table class="table table-striped mb-0">
16
+ <thead class="table-light">
17
+ <tr>
18
+ <th>Job</th>
19
+ <th>Avg RSS (KB)</th>
20
+ <th>Avg CPU (ms)</th>
21
+ <th>Runs</th>
22
+ </tr>
23
+ </thead>
24
+ <tbody>
25
+ <% @leak_jobs.each do |job| %>
26
+ <tr class="table-danger">
27
+ <td><strong><%= job[:key] %></strong></td>
28
+ <td><%= job[:avg_mem_kb].round(2) %></td>
29
+ <td><%= job[:avg_cpu_ms].round(2) %></td>
30
+ <td><%= job[:count] %></td>
31
+ </tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
35
+ </div>
36
+ </div>
37
+ <% end %>
38
+ </div>
@@ -0,0 +1,42 @@
1
+ <div class="container py-4">
2
+
3
+ <h2 class="mb-4 text-success fw-bold">
4
+ RSS Memory Usage — Top Jobs
5
+ </h2>
6
+
7
+ <!-- Jobs Table -->
8
+ <div class="card mb-4 shadow-sm">
9
+ <div class="card-header bg-success text-white">
10
+ Job Memory Summary
11
+ </div>
12
+
13
+ <div class="card-body p-0">
14
+ <table class="table table-hover table-striped mb-0">
15
+ <thead class="table-light">
16
+ <tr>
17
+ <th>Job</th>
18
+ <th>Average RSS (KB)</th>
19
+ <th>Runs</th>
20
+ </tr>
21
+ </thead>
22
+
23
+ <tbody>
24
+ <% @jobs.each do |job| %>
25
+ <tr>
26
+ <td><strong><%= job[:key] %></strong></td>
27
+ <td><%= job[:avg_mem_kb].round(2) %></td>
28
+ <td><%= job[:count] %></td>
29
+ </tr>
30
+ <% end %>
31
+ </tbody>
32
+ </table>
33
+ </div>
34
+ </div>
35
+
36
+ <!-- Chart -->
37
+ <%= render partial: "chart",
38
+ locals: { title: "RSS Memory (KB)",
39
+ dom_id: "rss_chart",
40
+ data: @rss_chart } %>
41
+
42
+ </div>
@@ -0,0 +1,31 @@
1
+ <div class="container py-4">
2
+ <h2 class="mb-4 text-info fw-bold">Wall Time</h2>
3
+
4
+ <div class="card mb-3 shadow-sm">
5
+ <div class="card-header bg-info text-white">
6
+ Job Wall Time Summary
7
+ </div>
8
+
9
+ <div class="card-body p-0">
10
+ <table class="table table-bordered mb-0">
11
+ <thead class="table-light">
12
+ <tr>
13
+ <th>Job</th>
14
+ <th>Runs</th>
15
+ </tr>
16
+ </thead>
17
+ <tbody>
18
+ <% @jobs.each do |job| %>
19
+ <tr>
20
+ <td><strong><%= job[:key] %></strong></td>
21
+ <td><%= job[:count] %></td>
22
+ </tr>
23
+ <% end %>
24
+ </tbody>
25
+ </table>
26
+ </div>
27
+ </div>
28
+
29
+ <%= render partial: "chart",
30
+ locals: { title: "Wall Time (ms)", dom_id: "wall_chart", data: @wall_chart } %>
31
+ </div>
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "sidekiq_insight"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/config/routes.rb ADDED
@@ -0,0 +1,11 @@
1
+ SidekiqInsight::Engine.routes.draw do
2
+ root to: "dashboard#index"
3
+ get "/job", to: "dashboard#show", as: :job
4
+ post "/clear", to: "dashboard#clear", as: :clear
5
+
6
+ # Graphs
7
+ get "/cpu", to: "graphs#cpu", as: :cpu
8
+ get "/rss", to: "graphs#rss", as: :rss
9
+ get "/wall", to: "graphs#wall", as: :wall
10
+ get "/leaks", to: "graphs#leaks", as: :leaks
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqInsight
4
+ class Configuration
5
+ attr_accessor :redis_url, :prefix
6
+
7
+ def initialize
8
+ @redis_url = ENV['REDIS_URL'] || 'redis://127.0.0.1:6379/0'
9
+ @prefix = 'sidekiq_insight:'
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,35 @@
1
+ require 'rails'
2
+ require "chartkick"
3
+ require "groupdate"
4
+
5
+ module SidekiqInsight
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace SidekiqInsight
8
+
9
+ initializer 'sidekiq_insight.insert_middlewares' do |app|
10
+ # insert request profiling into Rails middleware stack
11
+ app.middleware.insert_before(0, SidekiqInsight::RequestMiddleware)
12
+
13
+ # configure Sidekiq server middleware
14
+ if defined?(Sidekiq)
15
+ Sidekiq.configure_server do |config|
16
+ config.server_middleware do |chain|
17
+ chain.add SidekiqInsight::ServerMiddleware
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ config.autoload_paths << root.join("app/helpers")
24
+ config.autoload_paths << root.join("lib/sidekiq_insight")
25
+
26
+ initializer "sidekiq_insight.assets" do |app|
27
+ app.config.assets.precompile += %w[
28
+ sidekiq_insight.js
29
+ chart.js
30
+ chartjs-adapter-date-fns.js
31
+ chartkick.js
32
+ ]
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,24 @@
1
+ module SidekiqInsight
2
+ class LeakDetector
3
+ def initialize(storage:)
4
+ @storage = storage
5
+ end
6
+
7
+ # check last N samples for rising memory trend
8
+ def detect(key, window = 20)
9
+ samples = @storage.recent(key, window)
10
+ SidekiqInsight::Metrics.detect_leak(samples)
11
+ end
12
+
13
+ def recent_alerts(limit = 20)
14
+ jobs = @storage.top_jobs(100)
15
+ alerts = []
16
+ jobs.each do |j|
17
+ leak = detect(j[:key], 50)
18
+ alerts << { job: j[:key], leak: leak } if leak
19
+ break if alerts.size >= limit
20
+ end
21
+ alerts
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ module SidekiqInsight
2
+ module Metrics
3
+ def self.detect_leak(samples)
4
+ return false if samples.nil? || samples.size < 6
5
+ # use first half vs last half
6
+ half = samples.size / 2
7
+ first = samples[0, half].map { |s| s[:rss_kb].to_f }
8
+ last = samples[half, half].map { |s| s[:rss_kb].to_f }
9
+ return false if first.empty? || last.empty?
10
+ first_avg = first.sum / first.size
11
+ last_avg = last.sum / last.size
12
+ (last_avg - first_avg) > (first_avg * 0.2) # >20% increase indicates suspect
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,38 @@
1
+ require "get_process_mem"
2
+
3
+ module SidekiqInsight
4
+ class RequestMiddleware
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ pm_before = GetProcessMem.new
11
+ rss_before = pm_before.mb * 1024.0
12
+ cpu_before = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
13
+ t_before = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
+
15
+ status, headers, body = @app.call(env)
16
+
17
+ t_after = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
+ cpu_after = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
19
+ pm_after = GetProcessMem.new
20
+ rss_after = pm_after.mb * 1024.0
21
+
22
+ req = Rack::Request.new(env)
23
+ sample = {
24
+ path: req.path,
25
+ method: req.request_method,
26
+ status: status,
27
+ started_at: Time.now.utc.iso8601,
28
+ wall_ms: (t_after - t_before) * 1000.0,
29
+ cpu_ms: (cpu_after - cpu_before) * 1000.0,
30
+ rss_kb: (rss_after - rss_before)
31
+ }
32
+
33
+ SidekiqInsight.storage.push_sample("__http__#{req.path}", sample)
34
+
35
+ [status, headers, body]
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,33 @@
1
+ require "get_process_mem"
2
+
3
+ module SidekiqInsight
4
+ class ServerMiddleware
5
+ def call(worker, job, queue)
6
+ pm_before = GetProcessMem.new
7
+ rss_before = pm_before.mb * 1024.0
8
+ cpu_before = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
9
+ t_before = Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+
11
+ yield
12
+
13
+ t_after = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
+ cpu_after = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
15
+ pm_after = GetProcessMem.new
16
+ rss_after = pm_after.mb * 1024.0
17
+
18
+ sample = {
19
+ job_class: worker.class.name,
20
+ queue: queue,
21
+ args: job["args"],
22
+ started_at: Time.now.utc.iso8601,
23
+ wall_ms: (t_after - t_before) * 1000.0,
24
+ cpu_ms: (cpu_after - cpu_before) * 1000.0,
25
+ rss_kb: (rss_after - rss_before)
26
+ }
27
+
28
+ SidekiqInsight.storage.push_sample(worker.class.name, sample)
29
+ rescue => e
30
+ raise
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,44 @@
1
+ require "json"
2
+
3
+ module SidekiqInsight
4
+ class Storage
5
+ def initialize(redis:, prefix: "sidekiq_insight:")
6
+ @redis = redis
7
+ @prefix = prefix
8
+ end
9
+
10
+ def push_sample(key, sample)
11
+ redis_key = namespaced("samples:#{key}")
12
+ @redis.lpush(redis_key, sample.to_json)
13
+ @redis.ltrim(redis_key, 0, 999)
14
+ @redis.hincrby(namespaced("counts"), key, 1)
15
+ @redis.hincrbyfloat(namespaced("cpu"), key, sample[:cpu_ms].to_f)
16
+ @redis.hincrbyfloat(namespaced("mem"), key, sample[:rss_kb].to_f)
17
+ end
18
+
19
+ def top_jobs(limit = 20)
20
+ counts = @redis.hgetall(namespaced("counts"))
21
+ return [] if counts.empty?
22
+ counts.map do |k,v|
23
+ c = v.to_i
24
+ { key: k, count: c, avg_cpu_ms: @redis.hget(namespaced("cpu"), k).to_f / [c,1].max, avg_mem_kb: @redis.hget(namespaced("mem"), k).to_f / [c,1].max }
25
+ end.sort_by { |h| -h[:avg_cpu_ms] }[0, limit]
26
+ end
27
+
28
+ def recent(key, limit = 100)
29
+ arr = @redis.lrange(namespaced("samples:#{key}"), 0, limit-1)
30
+ arr.map { |j| JSON.parse(j, symbolize_names: true) }
31
+ end
32
+
33
+ def clear_all
34
+ keys = @redis.keys("#{ @prefix }*")
35
+ @redis.del(*keys) if keys.any?
36
+ end
37
+
38
+ private
39
+
40
+ def namespaced(suffix)
41
+ "#{@prefix}#{suffix}"
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqInsight
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq_insight/version"
4
+ require "sidekiq_insight/metrics"
5
+ require "sidekiq_insight/configuration"
6
+ require "sidekiq_insight/storage"
7
+ require "sidekiq_insight/server_middleware"
8
+ require "sidekiq_insight/request_middleware"
9
+ require "sidekiq_insight/leak_detector"
10
+ require "sidekiq_insight/engine" if defined?(Rails)
11
+
12
+ module SidekiqInsight
13
+ class << self
14
+ attr_accessor :configuration
15
+
16
+ def configure
17
+ self.configuration ||= Configuration.new
18
+ yield(configuration) if block_given?
19
+ end
20
+
21
+ def redis
22
+ @redis ||= Redis.new(url: configuration&.redis_url || ENV['REDIS_URL'] || 'redis://127.0.0.1:6379/0')
23
+ end
24
+
25
+ def storage
26
+ @storage ||= Storage.new(redis: redis, prefix: configuration&.prefix || 'sidekiq_insight:')
27
+ end
28
+
29
+ def detector
30
+ @detector ||= LeakDetector.new(storage: storage)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,4 @@
1
+ module SidekiqInsight
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,158 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sidekiq_insight
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - mrmalvi
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sidekiq
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '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: chartkick
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: groupdate
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rails
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ description: SidekiqInsight provides detailed CPU, memory, and wall-time profiling
97
+ for Sidekiq jobs and Rails requests, automatically detects memory leaks, stores
98
+ metrics in Redis, and displays everything in a clean, modern dashboard. Ideal for
99
+ monitoring production workload performance.
100
+ email:
101
+ - malviyak00@gmail.com
102
+ executables: []
103
+ extensions: []
104
+ extra_rdoc_files: []
105
+ files:
106
+ - Gemfile
107
+ - LICENSE.txt
108
+ - README.md
109
+ - Rakefile
110
+ - app/assets/javascripts/sidekiq_insight/sidekiq_insight.js
111
+ - app/controllers/sidekiq_insight/base_controller.rb
112
+ - app/controllers/sidekiq_insight/dashboard_controller.rb
113
+ - app/controllers/sidekiq_insight/graphs_controller.rb
114
+ - app/views/layouts/sidekiq_insight/sidekiq_insight.html.erb
115
+ - app/views/sidekiq_insight/dashboard/index.html.erb
116
+ - app/views/sidekiq_insight/dashboard/show.html.erb
117
+ - app/views/sidekiq_insight/graphs/_chart.html.erb
118
+ - app/views/sidekiq_insight/graphs/cpu.html.erb
119
+ - app/views/sidekiq_insight/graphs/leaks.html.erb
120
+ - app/views/sidekiq_insight/graphs/rss.html.erb
121
+ - app/views/sidekiq_insight/graphs/wall.html.erb
122
+ - bin/console
123
+ - bin/setup
124
+ - config/routes.rb
125
+ - lib/sidekiq_insight.rb
126
+ - lib/sidekiq_insight/configuration.rb
127
+ - lib/sidekiq_insight/engine.rb
128
+ - lib/sidekiq_insight/leak_detector.rb
129
+ - lib/sidekiq_insight/metrics.rb
130
+ - lib/sidekiq_insight/request_middleware.rb
131
+ - lib/sidekiq_insight/server_middleware.rb
132
+ - lib/sidekiq_insight/storage.rb
133
+ - lib/sidekiq_insight/version.rb
134
+ - sig/sidekiq_insight.rbs
135
+ homepage: https://github.com/mrmalvi/sidekiq_insight
136
+ licenses: []
137
+ metadata:
138
+ homepage_uri: https://github.com/mrmalvi/sidekiq_insight
139
+ allowed_push_host: https://rubygems.org
140
+ rdoc_options: []
141
+ require_paths:
142
+ - lib
143
+ required_ruby_version: !ruby/object:Gem::Requirement
144
+ requirements:
145
+ - - ">="
146
+ - !ruby/object:Gem::Version
147
+ version: 2.6.0
148
+ required_rubygems_version: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ requirements: []
154
+ rubygems_version: 3.6.9
155
+ specification_version: 4
156
+ summary: Lightweight Sidekiq & Rails performance profiler with CPU, memory, wall-time
157
+ tracking, leak detection, and a real-time dashboard.
158
+ test_files: []