sidekiq-memory_logger 0.1.1
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 +7 -0
- data/.custom_rubocop.yml +9 -0
- data/.github/workflows/benchmark.yml +154 -0
- data/.github/workflows/test.yml +26 -0
- data/.standard.yml +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/Rakefile +13 -0
- data/lib/rubocop/cop/sidekiq_memory_logger/no_configure_in_tests.rb +26 -0
- data/lib/rubocop/cop/sidekiq_memory_logger.rb +3 -0
- data/lib/sidekiq/memory_logger/version.rb +7 -0
- data/lib/sidekiq/memory_logger.rb +86 -0
- data/lib/sidekiq-memory_logger.rb +1 -0
- data/test/sidekiq/memory_logger/configuration_test.rb +28 -0
- data/test/sidekiq/memory_logger/configure_method_test.rb +39 -0
- data/test/sidekiq/memory_logger/memory_logger_test.rb +9 -0
- data/test/sidekiq/memory_logger/middleware_test.rb +183 -0
- data/test/sidekiq/memory_logger/rails_test.rb +76 -0
- data/test/test_helper.rb +6 -0
- metadata +90 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: d0d86186cadbe56a86c0d9f503f95bffe809038454a011ebe2878a7caf9190cc
|
4
|
+
data.tar.gz: 74d82b63659d8562d314bd0c62eb75d54a1e58933ad37894b6335882d7a2713c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 03b7ae7daf959020bf30ea8dfafb7d77b4f2cf34ea32099c3954f8a811defec17ba2ab6db8f7cdc39615bf6b88ef5598d136677ab9c6d52e27e64af34975c7ca
|
7
|
+
data.tar.gz: 7daa948bfdf7310df265b7174aad891ba0cdc7af27c5f9015602ded35971cd0df3c127ad5c5c34d120ab94ca633cc191b4c2396a227b61c08cf7703616ffe3d9
|
data/.custom_rubocop.yml
ADDED
@@ -0,0 +1,154 @@
|
|
1
|
+
name: Benchmark
|
2
|
+
|
3
|
+
on:
|
4
|
+
push:
|
5
|
+
branches: [ main ]
|
6
|
+
pull_request:
|
7
|
+
branches: [ main ]
|
8
|
+
workflow_dispatch:
|
9
|
+
|
10
|
+
jobs:
|
11
|
+
benchmark:
|
12
|
+
runs-on: ubuntu-latest
|
13
|
+
|
14
|
+
services:
|
15
|
+
redis:
|
16
|
+
image: redis:7.0
|
17
|
+
ports:
|
18
|
+
- 6379:6379
|
19
|
+
options: >-
|
20
|
+
--health-cmd "redis-cli ping"
|
21
|
+
--health-interval 10s
|
22
|
+
--health-timeout 5s
|
23
|
+
--health-retries 5
|
24
|
+
|
25
|
+
strategy:
|
26
|
+
matrix:
|
27
|
+
ruby-version: ['3.4']
|
28
|
+
|
29
|
+
steps:
|
30
|
+
- uses: actions/checkout@v4
|
31
|
+
|
32
|
+
- name: Set up Ruby ${{ matrix.ruby-version }}
|
33
|
+
uses: ruby/setup-ruby@v1
|
34
|
+
with:
|
35
|
+
ruby-version: ${{ matrix.ruby-version }}
|
36
|
+
bundler-cache: true
|
37
|
+
|
38
|
+
- name: Cache Redis CLI
|
39
|
+
uses: actions/cache@v4
|
40
|
+
id: cache-redis-tools
|
41
|
+
with:
|
42
|
+
path: /usr/bin/redis-cli
|
43
|
+
key: redis-tools-${{ runner.os }}
|
44
|
+
|
45
|
+
- name: Install Redis CLI
|
46
|
+
if: steps.cache-redis-tools.outputs.cache-hit != 'true'
|
47
|
+
run: sudo apt-get update && sudo apt-get install -y redis-tools
|
48
|
+
|
49
|
+
- name: Wait for Redis
|
50
|
+
run: |
|
51
|
+
until redis-cli ping; do
|
52
|
+
echo "Waiting for Redis..."
|
53
|
+
sleep 1
|
54
|
+
done
|
55
|
+
|
56
|
+
- name: Run benchmark without memory logger
|
57
|
+
id: benchmark_without
|
58
|
+
run: |
|
59
|
+
echo "Running benchmark WITHOUT memory logger..."
|
60
|
+
output=$(ITER=50 timeout 120 bundle exec bin/sidekiqload 2>&1 || true)
|
61
|
+
echo "$output"
|
62
|
+
|
63
|
+
# Extract performance metrics
|
64
|
+
jobs_per_sec=$(echo "$output" | grep -oE '[0-9]+ jobs/sec' | grep -oE '[0-9]+' | head -1 || echo "0")
|
65
|
+
ending_rss=$(echo "$output" | grep 'Ending RSS:' | grep -oE '[0-9]+' | head -1 || echo "0")
|
66
|
+
|
67
|
+
echo "without_jobs_per_sec=$jobs_per_sec" >> $GITHUB_OUTPUT
|
68
|
+
echo "without_ending_rss=$ending_rss" >> $GITHUB_OUTPUT
|
69
|
+
|
70
|
+
# Save full output for comparison
|
71
|
+
echo "WITHOUT_OUTPUT<<EOF" >> $GITHUB_ENV
|
72
|
+
echo "$output" >> $GITHUB_ENV
|
73
|
+
echo "EOF" >> $GITHUB_ENV
|
74
|
+
|
75
|
+
- name: Run benchmark with memory logger
|
76
|
+
id: benchmark_with
|
77
|
+
run: |
|
78
|
+
echo "Running benchmark WITH memory logger..."
|
79
|
+
output=$(MEMORY_LOGGER=1 ITER=50 timeout 120 bundle exec bin/sidekiqload 2>&1 || true)
|
80
|
+
echo "$output"
|
81
|
+
|
82
|
+
# Extract performance metrics
|
83
|
+
jobs_per_sec=$(echo "$output" | grep -oE '[0-9]+ jobs/sec' | grep -oE '[0-9]+' | head -1 || echo "0")
|
84
|
+
ending_rss=$(echo "$output" | grep 'Ending RSS:' | grep -oE '[0-9]+' | head -1 || echo "0")
|
85
|
+
|
86
|
+
echo "with_jobs_per_sec=$jobs_per_sec" >> $GITHUB_OUTPUT
|
87
|
+
echo "with_ending_rss=$ending_rss" >> $GITHUB_OUTPUT
|
88
|
+
|
89
|
+
# Save full output for comparison
|
90
|
+
echo "WITH_OUTPUT<<EOF" >> $GITHUB_ENV
|
91
|
+
echo "$output" >> $GITHUB_ENV
|
92
|
+
echo "EOF" >> $GITHUB_ENV
|
93
|
+
|
94
|
+
- name: Compare results
|
95
|
+
run: |
|
96
|
+
echo "## Benchmark Results Comparison" >> $GITHUB_STEP_SUMMARY
|
97
|
+
echo "" >> $GITHUB_STEP_SUMMARY
|
98
|
+
|
99
|
+
echo "### Without Memory Logger" >> $GITHUB_STEP_SUMMARY
|
100
|
+
echo '```' >> $GITHUB_STEP_SUMMARY
|
101
|
+
echo "$WITHOUT_OUTPUT" >> $GITHUB_STEP_SUMMARY
|
102
|
+
echo '```' >> $GITHUB_STEP_SUMMARY
|
103
|
+
echo "" >> $GITHUB_STEP_SUMMARY
|
104
|
+
|
105
|
+
echo "### With Memory Logger" >> $GITHUB_STEP_SUMMARY
|
106
|
+
echo '```' >> $GITHUB_STEP_SUMMARY
|
107
|
+
echo "$WITH_OUTPUT" >> $GITHUB_STEP_SUMMARY
|
108
|
+
echo '```' >> $GITHUB_STEP_SUMMARY
|
109
|
+
echo "" >> $GITHUB_STEP_SUMMARY
|
110
|
+
|
111
|
+
# Performance comparison
|
112
|
+
without_jps=${{ steps.benchmark_without.outputs.without_jobs_per_sec }}
|
113
|
+
with_jps=${{ steps.benchmark_with.outputs.with_jobs_per_sec }}
|
114
|
+
|
115
|
+
echo "### Performance Metrics" >> $GITHUB_STEP_SUMMARY
|
116
|
+
echo "| Metric | Without Logger | With Logger | Difference |" >> $GITHUB_STEP_SUMMARY
|
117
|
+
echo "|--------|----------------|-------------|------------|" >> $GITHUB_STEP_SUMMARY
|
118
|
+
echo "| Jobs/sec | $without_jps | $with_jps | $(($with_jps - $without_jps)) |" >> $GITHUB_STEP_SUMMARY
|
119
|
+
|
120
|
+
# Calculate milliseconds per job
|
121
|
+
if [ "$without_jps" -gt 0 ] && [ "$with_jps" -gt 0 ]; then
|
122
|
+
without_ms_per_job=$(echo "scale=3; 1000.0 / $without_jps" | bc -l)
|
123
|
+
with_ms_per_job=$(echo "scale=3; 1000.0 / $with_jps" | bc -l)
|
124
|
+
ms_per_job_diff=$(echo "scale=3; $with_ms_per_job - $without_ms_per_job" | bc -l)
|
125
|
+
echo "| Milliseconds per job | $without_ms_per_job | $with_ms_per_job | $ms_per_job_diff |" >> $GITHUB_STEP_SUMMARY
|
126
|
+
fi
|
127
|
+
|
128
|
+
- name: Output results to console
|
129
|
+
run: |
|
130
|
+
echo "=== BENCHMARK RESULTS ==="
|
131
|
+
echo "Without Memory Logger:"
|
132
|
+
echo " Jobs/sec: ${{ steps.benchmark_without.outputs.without_jobs_per_sec }}"
|
133
|
+
echo " Ending RSS: ${{ steps.benchmark_without.outputs.without_ending_rss }} KB"
|
134
|
+
echo ""
|
135
|
+
echo "With Memory Logger:"
|
136
|
+
echo " Jobs/sec: ${{ steps.benchmark_with.outputs.with_jobs_per_sec }}"
|
137
|
+
echo " Ending RSS: ${{ steps.benchmark_with.outputs.with_ending_rss }} KB"
|
138
|
+
echo ""
|
139
|
+
echo "Differences:"
|
140
|
+
echo " Jobs/sec difference: $((${{ steps.benchmark_with.outputs.with_jobs_per_sec }} - ${{ steps.benchmark_without.outputs.without_jobs_per_sec }}))"
|
141
|
+
echo " RSS difference: $((${{ steps.benchmark_with.outputs.with_ending_rss }} - ${{ steps.benchmark_without.outputs.without_ending_rss }})) KB"
|
142
|
+
echo ""
|
143
|
+
|
144
|
+
# Calculate absolute latency added per job
|
145
|
+
without_jps=${{ steps.benchmark_without.outputs.without_jobs_per_sec }}
|
146
|
+
with_jps=${{ steps.benchmark_with.outputs.with_jobs_per_sec }}
|
147
|
+
|
148
|
+
if [ "$without_jps" -gt 0 ] && [ "$with_jps" -gt 0 ]; then
|
149
|
+
# Convert jobs/sec to milliseconds per job, then calculate difference
|
150
|
+
without_ms_per_job=$(echo "scale=6; 1000.0 / $without_jps" | bc -l)
|
151
|
+
with_ms_per_job=$(echo "scale=6; 1000.0 / $with_jps" | bc -l)
|
152
|
+
latency_added=$(echo "scale=3; $with_ms_per_job - $without_ms_per_job" | bc -l)
|
153
|
+
echo "Memory logger adds $latency_added milliseconds per job"
|
154
|
+
fi
|
@@ -0,0 +1,26 @@
|
|
1
|
+
name: Test
|
2
|
+
|
3
|
+
on:
|
4
|
+
push:
|
5
|
+
branches: [ main ]
|
6
|
+
pull_request:
|
7
|
+
branches: [ main ]
|
8
|
+
|
9
|
+
jobs:
|
10
|
+
test:
|
11
|
+
runs-on: ubuntu-latest
|
12
|
+
strategy:
|
13
|
+
matrix:
|
14
|
+
ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4']
|
15
|
+
|
16
|
+
steps:
|
17
|
+
- uses: actions/checkout@v4
|
18
|
+
|
19
|
+
- name: Set up Ruby ${{ matrix.ruby-version }}
|
20
|
+
uses: ruby/setup-ruby@v1
|
21
|
+
with:
|
22
|
+
ruby-version: ${{ matrix.ruby-version }}
|
23
|
+
bundler-cache: true
|
24
|
+
|
25
|
+
- name: Run tests
|
26
|
+
run: bundle exec rake
|
data/.standard.yml
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2025 Nate Berkopec
|
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,125 @@
|
|
1
|
+
# Sidekiq Memory Logger
|
2
|
+
|
3
|
+
Have you ever seen massive memory increases in your Sidekiq workers? Well, this gem helps you figure out which job is causing it.
|
4
|
+
|
5
|
+

|
6
|
+
|
7
|
+
## How it works
|
8
|
+
|
9
|
+
Memory measurement is handled by the [get_process_mem](https://github.com/zombocom/get_process_mem) gem, which works across all platforms (Windows, macOS, Linux) and both inside and outside of containers. Object allocation tracking uses Ruby's built-in `GC.stat[:total_allocated_objects]`.
|
10
|
+
|
11
|
+
By default, this gem just logs at `info` level for every job:
|
12
|
+
```
|
13
|
+
[MemoryLogger] job=MyJob queue=default memory_mb=15.2 objects=12345
|
14
|
+
```
|
15
|
+
|
16
|
+
You can also parse this log and create a metric (e.g. with Sumo or Datadog) or change the callback we use (see Configuration below) to create metrics.
|
17
|
+
|
18
|
+
> [!WARNING]
|
19
|
+
> Each job runs on its own thread, but all threads share the same process heap. Since memory measurement is performed at the process level, concurrent job execution can lead to inaccurate memory attribution, since the measured memory usage will include memory increases from other jobs running simultaneously. For example, two jobs running at the same time will report the same memory increase, although only one of those jobs may have allocated any memory at all.
|
20
|
+
>
|
21
|
+
> **Workaround:** To work around this limitation, collect a large enough sample size and use 95th percentile or maximum metrics along with detailed logging to identify which job classes _consistently_ reproduce memory issues. This statistical approach will help you identify problematic jobs despite the measurement noise from concurrent execution.
|
22
|
+
|
23
|
+
## Installation
|
24
|
+
|
25
|
+
```ruby
|
26
|
+
gem 'sidekiq-memory_logger'
|
27
|
+
```
|
28
|
+
|
29
|
+
## Usage
|
30
|
+
|
31
|
+
### Basic Setup
|
32
|
+
|
33
|
+
You must add the middleware to your Sidekiq server configuration:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
# config/initializers/sidekiq.rb or similar
|
37
|
+
Sidekiq.configure_server do |config|
|
38
|
+
config.server_middleware do |chain|
|
39
|
+
chain.add Sidekiq::MemoryLogger::Middleware
|
40
|
+
end
|
41
|
+
end
|
42
|
+
```
|
43
|
+
|
44
|
+
You're now ready to go.
|
45
|
+
|
46
|
+
### Configuration
|
47
|
+
|
48
|
+
It will just work out of the box, but you can change some of your behavior if you like.
|
49
|
+
|
50
|
+
```ruby
|
51
|
+
# config/initializers/sidekiq_memory_logger.rb or similar
|
52
|
+
Sidekiq::MemoryLogger.configure do |config|
|
53
|
+
# Change the logger (default uses Rails.logger if available, otherwise stdout)
|
54
|
+
config.logger = MyCustomLogger.new
|
55
|
+
|
56
|
+
# Specify which queues to monitor (empty array monitors all queues)
|
57
|
+
config.queues = ["critical", "mailers"] # Only monitor these queues
|
58
|
+
# config.queues = [] # Monitor all queues (default)
|
59
|
+
|
60
|
+
# Replace the default logging callback with custom behavior
|
61
|
+
# The callback now receives job arguments as the 5th parameter
|
62
|
+
config.callback = ->(job_class, queue, memory_diff_mb, objects_diff, args) do
|
63
|
+
# Example: Extract company_id from job arguments
|
64
|
+
# Assuming your job is called like: ProcessCompanyDataJob.perform_async(company_id, other_params)
|
65
|
+
company_id = args&.first
|
66
|
+
|
67
|
+
# StatsD example with company_id
|
68
|
+
StatsD.histogram('sidekiq.memory_usage', memory_diff_mb, tags: {
|
69
|
+
job_class: job_class,
|
70
|
+
queue: queue,
|
71
|
+
company_id: company_id
|
72
|
+
})
|
73
|
+
|
74
|
+
# Log with company context
|
75
|
+
Rails.logger.info "Job #{job_class} for company #{company_id} on queue #{queue} used #{memory_diff_mb} MB"
|
76
|
+
|
77
|
+
# Dogstatsd example
|
78
|
+
# $dogstatsd.histogram('sidekiq.memory_usage', memory_diff_mb, tags: [
|
79
|
+
# "job_class:#{job_class}",
|
80
|
+
# "queue:#{queue}",
|
81
|
+
# "company_id:#{company_id}"
|
82
|
+
# ])
|
83
|
+
|
84
|
+
# New Relic example
|
85
|
+
# NewRelic::Agent.record_metric("Custom/Sidekiq/MemoryUsage/#{queue}/#{job_class}", memory_diff_mb)
|
86
|
+
# NewRelic::Agent.add_custom_attributes({
|
87
|
+
# 'sidekiq.job_class' => job_class,
|
88
|
+
# 'sidekiq.queue' => queue,
|
89
|
+
# 'sidekiq.company_id' => company_id
|
90
|
+
# })
|
91
|
+
|
92
|
+
# Datadog tracing example - add attributes to current span
|
93
|
+
# span = Datadog::Tracing.active_span
|
94
|
+
# if span
|
95
|
+
# span.set_tag('sidekiq.memory_usage_mb', memory_diff_mb)
|
96
|
+
# span.set_tag('sidekiq.job_class', job_class)
|
97
|
+
# span.set_tag('sidekiq.queue', queue)
|
98
|
+
# span.set_tag('sidekiq.company_id', company_id)
|
99
|
+
# end
|
100
|
+
end
|
101
|
+
|
102
|
+
# The default callback logs memory usage like this:
|
103
|
+
# config.callback = ->(job_class, queue, memory_diff_mb, objects_diff, args) do
|
104
|
+
# config.logger.info("[MemoryLogger] job=#{job_class} queue=#{queue} memory_mb=#{memory_diff_mb}")
|
105
|
+
# end
|
106
|
+
|
107
|
+
# If you want custom metrics AND logging, include both in your callback:
|
108
|
+
config.callback = ->(job_class, queue, memory_diff_mb, objects_diff, args) do
|
109
|
+
# Your custom metrics collection
|
110
|
+
StatsD.histogram('sidekiq.memory_usage', memory_diff_mb, tags: {
|
111
|
+
job_class: job_class,
|
112
|
+
queue: queue
|
113
|
+
})
|
114
|
+
|
115
|
+
# Include logging if you still want it
|
116
|
+
Rails.logger.info "Job #{job_class} on queue #{queue} used #{memory_diff_mb} MB"
|
117
|
+
end
|
118
|
+
end
|
119
|
+
```
|
120
|
+
|
121
|
+
## Performance Overhead
|
122
|
+
|
123
|
+
The memory logger middleware introduces some performance overhead due to memory measurement and callback execution. We continuously benchmark this overhead using the official `sidekiqload` tool.
|
124
|
+
|
125
|
+
According to our benchmarks running in Github Actions ([view workflow](https://github.com/speedshop/sidekiq-memory_logger/actions/workflows/benchmark.yml)), the middleware **adds ~0.16ms of latency per job**. The memory footprint increase is negligible. Consider this overhead when deciding whether to enable the middleware in high-throughput production environments. Use the `queues` config setting to limit this middleware to only the queues you're trying to debug.
|
data/Rakefile
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
require "rake/testtask"
|
5
|
+
require "standard/rake"
|
6
|
+
|
7
|
+
Rake::TestTask.new(:test) do |t|
|
8
|
+
t.libs << "test"
|
9
|
+
t.libs << "lib"
|
10
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
11
|
+
end
|
12
|
+
|
13
|
+
task default: %i[standard test]
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RuboCop
|
4
|
+
module Cop
|
5
|
+
module SidekiqMemoryLogger
|
6
|
+
class NoConfigureInTests < Base
|
7
|
+
MSG = "Do not use Sidekiq::MemoryLogger.configure in test files. Use Configuration objects instead."
|
8
|
+
|
9
|
+
def on_send(node)
|
10
|
+
if sidekiq_memory_logger_configure?(node)
|
11
|
+
add_offense(node)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def sidekiq_memory_logger_configure?(node)
|
18
|
+
node.send_type? &&
|
19
|
+
node.receiver&.const_type? &&
|
20
|
+
node.receiver.const_name == "Sidekiq::MemoryLogger" &&
|
21
|
+
node.method_name == :configure
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "get_process_mem"
|
4
|
+
require "sidekiq"
|
5
|
+
require_relative "memory_logger/version"
|
6
|
+
|
7
|
+
module Sidekiq
|
8
|
+
module MemoryLogger
|
9
|
+
class Error < StandardError; end
|
10
|
+
|
11
|
+
class Configuration
|
12
|
+
attr_accessor :logger, :callback, :queues
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@logger = default_logger
|
16
|
+
@callback = default_callback
|
17
|
+
@queues = []
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def default_logger
|
23
|
+
rails_logger = defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
24
|
+
rails_logger || fallback_logger
|
25
|
+
end
|
26
|
+
|
27
|
+
def fallback_logger
|
28
|
+
require "logger"
|
29
|
+
::Logger.new($stdout)
|
30
|
+
end
|
31
|
+
|
32
|
+
def default_callback
|
33
|
+
->(job_class, queue, memory_diff_mb, objects_diff, args) do
|
34
|
+
@logger.info("[MemoryLogger] job=#{job_class} queue=#{queue} memory_mb=#{memory_diff_mb} objects=#{objects_diff}")
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
class << self
|
40
|
+
def configuration
|
41
|
+
@configuration ||= Configuration.new
|
42
|
+
end
|
43
|
+
|
44
|
+
def configure
|
45
|
+
yield configuration
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
class Middleware
|
50
|
+
include Sidekiq::ServerMiddleware
|
51
|
+
|
52
|
+
def initialize(config = nil)
|
53
|
+
@memory_logger_config = config || MemoryLogger.configuration
|
54
|
+
end
|
55
|
+
|
56
|
+
def call(worker_instance, job, queue)
|
57
|
+
return yield if should_skip_queue?(queue)
|
58
|
+
|
59
|
+
start_mem = GetProcessMem.new.mb
|
60
|
+
start_objects = GC.stat[:total_allocated_objects]
|
61
|
+
|
62
|
+
begin
|
63
|
+
yield
|
64
|
+
ensure
|
65
|
+
end_mem = GetProcessMem.new.mb
|
66
|
+
end_objects = GC.stat[:total_allocated_objects]
|
67
|
+
memory_diff = end_mem - start_mem
|
68
|
+
objects_diff = end_objects - start_objects
|
69
|
+
|
70
|
+
begin
|
71
|
+
@memory_logger_config.callback.call(job["class"], queue, memory_diff, objects_diff, job["args"])
|
72
|
+
rescue => e
|
73
|
+
@memory_logger_config.logger.error("Sidekiq memory logger callback failed: #{e.message}")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def should_skip_queue?(queue)
|
81
|
+
return false if @memory_logger_config.queues.empty?
|
82
|
+
!@memory_logger_config.queues.include?(queue)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require "sidekiq/memory_logger"
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "test_helper"
|
4
|
+
|
5
|
+
class TestSidekiqMemoryLoggerConfiguration < Minitest::Test
|
6
|
+
def setup
|
7
|
+
@config = Sidekiq::MemoryLogger::Configuration.new
|
8
|
+
end
|
9
|
+
|
10
|
+
def test_default_values
|
11
|
+
refute_nil @config.logger
|
12
|
+
assert_kind_of Logger, @config.logger
|
13
|
+
refute_nil @config.callback
|
14
|
+
assert_kind_of Proc, @config.callback
|
15
|
+
end
|
16
|
+
|
17
|
+
def test_setting_logger
|
18
|
+
logger = "test_logger"
|
19
|
+
@config.logger = logger
|
20
|
+
assert_equal logger, @config.logger
|
21
|
+
end
|
22
|
+
|
23
|
+
def test_setting_callback
|
24
|
+
callback = ->(job, queue, memory) { "test" }
|
25
|
+
@config.callback = callback
|
26
|
+
assert_equal callback, @config.callback
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "test_helper"
|
4
|
+
|
5
|
+
class TestSidekiqMemoryLoggerConfigureMethod < Minitest::Test
|
6
|
+
def teardown
|
7
|
+
Sidekiq::MemoryLogger.instance_variable_set(:@configuration, nil)
|
8
|
+
end
|
9
|
+
|
10
|
+
def test_configuration
|
11
|
+
Sidekiq::MemoryLogger.configure do |config|
|
12
|
+
config.logger = "test_logger"
|
13
|
+
config.callback = "test_callback"
|
14
|
+
end
|
15
|
+
|
16
|
+
assert_equal "test_logger", Sidekiq::MemoryLogger.configuration.logger
|
17
|
+
assert_equal "test_callback", Sidekiq::MemoryLogger.configuration.callback
|
18
|
+
end
|
19
|
+
|
20
|
+
def test_configuration_object
|
21
|
+
config = Sidekiq::MemoryLogger.configuration
|
22
|
+
assert_instance_of Sidekiq::MemoryLogger::Configuration, config
|
23
|
+
end
|
24
|
+
|
25
|
+
def test_configuration_is_singleton
|
26
|
+
config1 = Sidekiq::MemoryLogger.configuration
|
27
|
+
config2 = Sidekiq::MemoryLogger.configuration
|
28
|
+
assert_same config1, config2
|
29
|
+
end
|
30
|
+
|
31
|
+
def test_direct_configuration_access
|
32
|
+
Sidekiq::MemoryLogger.configuration.logger = "direct_logger"
|
33
|
+
assert_equal "direct_logger", Sidekiq::MemoryLogger.configuration.logger
|
34
|
+
|
35
|
+
callback = ->(job, queue, memory) { "test" }
|
36
|
+
Sidekiq::MemoryLogger.configuration.callback = callback
|
37
|
+
assert_equal callback, Sidekiq::MemoryLogger.configuration.callback
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,183 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "test_helper"
|
4
|
+
|
5
|
+
class TestSidekiqMemoryLoggerMiddleware < Minitest::Test
|
6
|
+
def setup
|
7
|
+
@job = {"class" => "TestJob", "args" => [123, "test_arg"]}
|
8
|
+
@queue = "test_queue"
|
9
|
+
end
|
10
|
+
|
11
|
+
def test_middleware_calls_callback_when_configured
|
12
|
+
callback_calls = []
|
13
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
14
|
+
config.callback = ->(job_class, queue, memory_diff, objects_diff, args) do
|
15
|
+
callback_calls << [job_class, queue, memory_diff, objects_diff, args]
|
16
|
+
end
|
17
|
+
|
18
|
+
middleware = Sidekiq::MemoryLogger::Middleware.new(config)
|
19
|
+
middleware.call(nil, @job, @queue) { sleep 0.01 }
|
20
|
+
|
21
|
+
assert_equal 1, callback_calls.length
|
22
|
+
job_class, queue, memory_diff, _objects_diff, args = callback_calls.first
|
23
|
+
assert_equal "TestJob", job_class
|
24
|
+
assert_equal "test_queue", queue
|
25
|
+
assert_kind_of Float, memory_diff
|
26
|
+
assert_equal [123, "test_arg"], args
|
27
|
+
end
|
28
|
+
|
29
|
+
def test_middleware_logs_with_default_callback
|
30
|
+
log_output = StringIO.new
|
31
|
+
test_logger = Logger.new(log_output)
|
32
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
33
|
+
config.logger = test_logger
|
34
|
+
# Reset to default callback (which logs)
|
35
|
+
config.callback = config.send(:default_callback)
|
36
|
+
|
37
|
+
middleware = Sidekiq::MemoryLogger::Middleware.new(config)
|
38
|
+
middleware.call(nil, @job, @queue) { sleep 0.01 }
|
39
|
+
|
40
|
+
log_content = log_output.string
|
41
|
+
assert_includes log_content, "[MemoryLogger] job=TestJob queue=test_queue memory_mb="
|
42
|
+
assert_includes log_content, "objects="
|
43
|
+
end
|
44
|
+
|
45
|
+
def test_middleware_handles_exceptions
|
46
|
+
callback_calls = []
|
47
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
48
|
+
config.callback = ->(job_class, queue, memory_diff, objects_diff, args) do
|
49
|
+
callback_calls << [job_class, queue, memory_diff, objects_diff, args]
|
50
|
+
end
|
51
|
+
|
52
|
+
middleware = Sidekiq::MemoryLogger::Middleware.new(config)
|
53
|
+
|
54
|
+
assert_raises(RuntimeError) do
|
55
|
+
middleware.call(nil, @job, @queue) { raise "test error" }
|
56
|
+
end
|
57
|
+
|
58
|
+
assert_equal 1, callback_calls.length
|
59
|
+
end
|
60
|
+
|
61
|
+
def test_middleware_handles_callback_exceptions
|
62
|
+
log_output = StringIO.new
|
63
|
+
test_logger = Logger.new(log_output)
|
64
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
65
|
+
config.logger = test_logger
|
66
|
+
config.callback = ->(job_class, queue, memory_diff, objects_diff, args) do
|
67
|
+
raise StandardError, "callback error"
|
68
|
+
end
|
69
|
+
|
70
|
+
middleware = Sidekiq::MemoryLogger::Middleware.new(config)
|
71
|
+
|
72
|
+
# Middleware should not raise, but should log the error
|
73
|
+
middleware.call(nil, @job, @queue) { sleep 0.01 }
|
74
|
+
|
75
|
+
log_content = log_output.string
|
76
|
+
assert_includes log_content, "Sidekiq memory logger callback failed: callback error"
|
77
|
+
end
|
78
|
+
|
79
|
+
def test_middleware_passes_job_arguments_to_callback
|
80
|
+
callback_args = nil
|
81
|
+
job_with_company_id = {
|
82
|
+
"class" => "ProcessCompanyDataJob",
|
83
|
+
"args" => [42, "Acme Corp", {"priority" => "high"}]
|
84
|
+
}
|
85
|
+
|
86
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
87
|
+
config.callback = ->(job_class, queue, memory_diff, objects_diff, args) do
|
88
|
+
callback_args = args
|
89
|
+
end
|
90
|
+
|
91
|
+
# Create middleware with custom configuration
|
92
|
+
middleware = Sidekiq::MemoryLogger::Middleware.new(config)
|
93
|
+
middleware.call(nil, job_with_company_id, @queue) { sleep 0.01 }
|
94
|
+
|
95
|
+
assert_equal [42, "Acme Corp", {"priority" => "high"}], callback_args
|
96
|
+
end
|
97
|
+
|
98
|
+
def test_middleware_works_with_empty_job_args
|
99
|
+
callback_args = nil
|
100
|
+
job_without_args = {
|
101
|
+
"class" => "CleanupJob",
|
102
|
+
"args" => []
|
103
|
+
}
|
104
|
+
|
105
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
106
|
+
config.callback = ->(job_class, queue, memory_diff, objects_diff, args) do
|
107
|
+
callback_args = args
|
108
|
+
end
|
109
|
+
|
110
|
+
# Create middleware with custom configuration
|
111
|
+
middleware = Sidekiq::MemoryLogger::Middleware.new(config)
|
112
|
+
middleware.call(nil, job_without_args, @queue) { sleep 0.01 }
|
113
|
+
|
114
|
+
assert_equal [], callback_args
|
115
|
+
end
|
116
|
+
|
117
|
+
def test_middleware_works_with_nil_job_args
|
118
|
+
callback_args = :not_set
|
119
|
+
job_with_nil_args = {
|
120
|
+
"class" => "SpecialJob"
|
121
|
+
# Note: no "args" key at all
|
122
|
+
}
|
123
|
+
|
124
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
125
|
+
config.callback = ->(job_class, queue, memory_diff, objects_diff, args) do
|
126
|
+
callback_args = args
|
127
|
+
end
|
128
|
+
|
129
|
+
# Create middleware with custom configuration
|
130
|
+
middleware = Sidekiq::MemoryLogger::Middleware.new(config)
|
131
|
+
middleware.call(nil, job_with_nil_args, @queue) { sleep 0.01 }
|
132
|
+
|
133
|
+
assert_nil callback_args
|
134
|
+
end
|
135
|
+
|
136
|
+
def test_middleware_skips_queues_not_in_config
|
137
|
+
callback_calls = []
|
138
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
139
|
+
config.queues = ["important", "critical"]
|
140
|
+
config.callback = ->(job_class, queue, memory_diff, objects_diff, args) do
|
141
|
+
callback_calls << [job_class, queue, memory_diff, objects_diff, args]
|
142
|
+
end
|
143
|
+
|
144
|
+
middleware = Sidekiq::MemoryLogger::Middleware.new(config)
|
145
|
+
middleware.call(nil, @job, "unimportant_queue") { sleep 0.01 }
|
146
|
+
|
147
|
+
assert_equal 0, callback_calls.length
|
148
|
+
end
|
149
|
+
|
150
|
+
def test_middleware_processes_queues_in_config
|
151
|
+
callback_calls = []
|
152
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
153
|
+
config.queues = ["test_queue", "critical"]
|
154
|
+
config.callback = ->(job_class, queue, memory_diff, objects_diff, args) do
|
155
|
+
callback_calls << [job_class, queue, memory_diff, objects_diff, args]
|
156
|
+
end
|
157
|
+
|
158
|
+
middleware = Sidekiq::MemoryLogger::Middleware.new(config)
|
159
|
+
middleware.call(nil, @job, "test_queue") { sleep 0.01 }
|
160
|
+
|
161
|
+
assert_equal 1, callback_calls.length
|
162
|
+
job_class, queue, _memory_diff, _objects_diff, _args = callback_calls.first
|
163
|
+
assert_equal "TestJob", job_class
|
164
|
+
assert_equal "test_queue", queue
|
165
|
+
end
|
166
|
+
|
167
|
+
def test_middleware_processes_all_queues_when_empty_config
|
168
|
+
callback_calls = []
|
169
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
170
|
+
config.queues = []
|
171
|
+
config.callback = ->(job_class, queue, memory_diff, objects_diff, args) do
|
172
|
+
callback_calls << [job_class, queue, memory_diff, objects_diff, args]
|
173
|
+
end
|
174
|
+
|
175
|
+
middleware = Sidekiq::MemoryLogger::Middleware.new(config)
|
176
|
+
middleware.call(nil, @job, "any_queue") { sleep 0.01 }
|
177
|
+
|
178
|
+
assert_equal 1, callback_calls.length
|
179
|
+
job_class, queue, _memory_diff, _objects_diff, _args = callback_calls.first
|
180
|
+
assert_equal "TestJob", job_class
|
181
|
+
assert_equal "any_queue", queue
|
182
|
+
end
|
183
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "test_helper"
|
4
|
+
require "rails"
|
5
|
+
|
6
|
+
class TestSidekiqMemoryLoggerRails < Minitest::Test
|
7
|
+
def test_rails_is_available_and_responds_to_logger
|
8
|
+
# Verify Rails constant exists and has logger method
|
9
|
+
assert defined?(Rails), "Rails should be defined"
|
10
|
+
assert Rails.respond_to?(:logger), "Rails should respond to logger"
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_configuration_uses_rails_logger_when_rails_logger_available
|
14
|
+
# Create a mock Rails with a logger
|
15
|
+
original_logger_method = Rails.method(:logger) if Rails.respond_to?(:logger)
|
16
|
+
test_logger = Logger.new(StringIO.new)
|
17
|
+
|
18
|
+
Rails.define_singleton_method(:logger) { test_logger }
|
19
|
+
|
20
|
+
# Create new configuration
|
21
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
22
|
+
|
23
|
+
# Should use our test Rails.logger
|
24
|
+
assert_equal test_logger, config.logger
|
25
|
+
ensure
|
26
|
+
# Restore original logger method
|
27
|
+
if original_logger_method
|
28
|
+
Rails.define_singleton_method(:logger, &original_logger_method)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def test_configuration_falls_back_when_rails_logger_nil
|
33
|
+
# Ensure Rails.logger returns nil (default state)
|
34
|
+
Rails.define_singleton_method(:logger) { nil }
|
35
|
+
|
36
|
+
# Create new configuration
|
37
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
38
|
+
|
39
|
+
# Should fall back to stdout logger since Rails.logger is nil
|
40
|
+
assert_instance_of Logger, config.logger
|
41
|
+
assert_equal $stdout, config.logger.instance_variable_get(:@logdev).dev
|
42
|
+
end
|
43
|
+
|
44
|
+
def test_configuration_falls_back_when_rails_not_available
|
45
|
+
# Temporarily hide Rails constant
|
46
|
+
rails_backup = Object.send(:remove_const, :Rails) if defined?(Rails)
|
47
|
+
|
48
|
+
# Create new configuration instance
|
49
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
50
|
+
|
51
|
+
# Should fall back to stdout logger
|
52
|
+
assert_instance_of Logger, config.logger
|
53
|
+
assert_equal $stdout, config.logger.instance_variable_get(:@logdev).dev
|
54
|
+
ensure
|
55
|
+
# Restore Rails constant
|
56
|
+
Object.const_set(:Rails, rails_backup) if rails_backup
|
57
|
+
end
|
58
|
+
|
59
|
+
def test_configuration_falls_back_when_rails_logger_unavailable
|
60
|
+
# Create mock Rails without logger method
|
61
|
+
rails_backup = Object.send(:remove_const, :Rails) if defined?(Rails)
|
62
|
+
rails_mock = Class.new
|
63
|
+
Object.const_set(:Rails, rails_mock)
|
64
|
+
|
65
|
+
# Create new configuration instance
|
66
|
+
config = Sidekiq::MemoryLogger::Configuration.new
|
67
|
+
|
68
|
+
# Should fall back to stdout logger since Rails doesn't respond to logger
|
69
|
+
assert_instance_of Logger, config.logger
|
70
|
+
assert_equal $stdout, config.logger.instance_variable_get(:@logdev).dev
|
71
|
+
ensure
|
72
|
+
# Restore Rails constant
|
73
|
+
Object.send(:remove_const, :Rails)
|
74
|
+
Object.const_set(:Rails, rails_backup) if rails_backup
|
75
|
+
end
|
76
|
+
end
|
data/test/test_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,90 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sidekiq-memory_logger
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Nate Berkopec
|
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: get_process_mem
|
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: sidekiq
|
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
|
+
description: A Sidekiq server middleware that tracks RSS memory usage for each job
|
41
|
+
and provides configurable logging and reporting options
|
42
|
+
email:
|
43
|
+
- nate.berkopec@speedshop.co
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- ".custom_rubocop.yml"
|
49
|
+
- ".github/workflows/benchmark.yml"
|
50
|
+
- ".github/workflows/test.yml"
|
51
|
+
- ".standard.yml"
|
52
|
+
- LICENSE.txt
|
53
|
+
- README.md
|
54
|
+
- Rakefile
|
55
|
+
- lib/rubocop/cop/sidekiq_memory_logger.rb
|
56
|
+
- lib/rubocop/cop/sidekiq_memory_logger/no_configure_in_tests.rb
|
57
|
+
- lib/sidekiq-memory_logger.rb
|
58
|
+
- lib/sidekiq/memory_logger.rb
|
59
|
+
- lib/sidekiq/memory_logger/version.rb
|
60
|
+
- test/sidekiq/memory_logger/configuration_test.rb
|
61
|
+
- test/sidekiq/memory_logger/configure_method_test.rb
|
62
|
+
- test/sidekiq/memory_logger/memory_logger_test.rb
|
63
|
+
- test/sidekiq/memory_logger/middleware_test.rb
|
64
|
+
- test/sidekiq/memory_logger/rails_test.rb
|
65
|
+
- test/test_helper.rb
|
66
|
+
homepage: https://github.com/speedshop/sidekiq-memory_logger
|
67
|
+
licenses:
|
68
|
+
- MIT
|
69
|
+
metadata:
|
70
|
+
allowed_push_host: https://rubygems.org
|
71
|
+
homepage_uri: https://github.com/speedshop/sidekiq-memory_logger
|
72
|
+
source_code_uri: https://github.com/speedshop/sidekiq-memory_logger
|
73
|
+
rdoc_options: []
|
74
|
+
require_paths:
|
75
|
+
- lib
|
76
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
77
|
+
requirements:
|
78
|
+
- - ">="
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
version: 2.7.0
|
81
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
82
|
+
requirements:
|
83
|
+
- - ">="
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
requirements: []
|
87
|
+
rubygems_version: 3.6.9
|
88
|
+
specification_version: 4
|
89
|
+
summary: Sidekiq server middleware for logging memory usage per job
|
90
|
+
test_files: []
|