Skip to content

Commit c6159dc

Browse files
nateberkopecclaude
andcommitted
Add custom RuboCop cop for enforcing test class inheritance
This commit message was generated with the help of LLMs. This commit introduces a new RuboCop custom cop that ensures all test classes inherit from `SpeedshopCloudwatchTest`. This helps maintain consistency in test setup and behavior across the codebase. Additionally, updates to the RuboCop configuration file enable this new cop for enforcement. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 852436b commit c6159dc

20 files changed

+284
-260
lines changed

.rubocop.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require:
44
- standard
55
- ./.rubocop/forbidden_methods.rb
6+
- ./.rubocop/test_base_class.rb
67

78
inherit_gem:
89
standard: config/base.yml
@@ -15,3 +16,6 @@ Layout/LineLength:
1516

1617
Custom/ForbiddenMethods:
1718
Enabled: true
19+
20+
Custom/TestBaseClass:
21+
Enabled: true

.rubocop/test_base_class.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
module RuboCop
2+
module Cop
3+
module Custom
4+
class TestBaseClass < Base
5+
MSG = "Test classes must inherit from SpeedshopCloudwatchTest"
6+
7+
def on_class(node)
8+
return unless in_test_file?
9+
return unless test_class?(node)
10+
return if node.parent_class&.const_name == "SpeedshopCloudwatchTest"
11+
return if node.identifier.const_name == "SpeedshopCloudwatchTest"
12+
13+
add_offense(node.identifier)
14+
end
15+
16+
private
17+
18+
def test_class?(node)
19+
node.identifier.const_name.end_with?("Test")
20+
end
21+
22+
def in_test_file?
23+
processed_source.file_path.include?("/test/") &&
24+
processed_source.file_path.end_with?("_test.rb") &&
25+
!processed_source.file_path.end_with?("test_helper.rb")
26+
end
27+
end
28+
end
29+
end
30+
end

README.md

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,27 @@ This library supports **Ruby 2.7+, Sidekiq 7+, and Puma 6+**.
1010

1111
## Metrics
1212

13-
If not configured, all metrics are enabled by default. Here are the default metric lists you can copy and customize:
13+
If not configured, all metrics are enabled by default.
1414

15-
**Puma:**
16-
```ruby
17-
config.metrics[:puma] = [:Workers, :BootedWorkers, :OldWorkers, :Running, :Backlog, :PoolCapacity, :MaxThreads]
18-
```
15+
For a full explanation of every metric, [read our docs.](./docs/metrics.md)
1916

20-
**Sidekiq:**
2117
```ruby
22-
config.metrics[:sidekiq] = [:EnqueuedJobs, :ProcessedJobs, :FailedJobs, :ScheduledJobs, :RetryJobs, :DeadJobs, :Workers, :Processes, :DefaultQueueLatency, :Capacity, :Utilization, :QueueLatency, :QueueSize]
23-
```
18+
# Defaults. Copy and modify this list to customize.
19+
config.metrics[:puma] = [
20+
:Workers, :BootedWorkers, :OldWorkers, :Running, :Backlog, :PoolCapacity, :MaxThreads
21+
]
22+
23+
config.metrics[:sidekiq] = [
24+
:EnqueuedJobs, :ProcessedJobs, :FailedJobs, :ScheduledJobs, :RetryJobs,
25+
:DeadJobs, :Workers, :Processes, :DefaultQueueLatency, :Capacity,
26+
:Utilization, :QueueLatency, :QueueSize
27+
]
2428

25-
**Rack:**
26-
```ruby
2729
config.metrics[:rack] = [:RequestQueueTime]
28-
```
2930

30-
**ActiveJob:**
31-
```ruby
3231
config.metrics[:active_job] = [:QueueLatency]
3332
```
3433

35-
For a full explanation of every metric, [read our docs.](./docs/metrics.md)
36-
3734
This gem is for **infrastructure and queue metrics**, not application performance metrics, like response times, job execution times, or error rates. Use your APM for that stuff.
3835

3936
## Installation
@@ -46,6 +43,7 @@ gem `speedshop_cloudwatch`
4643
# config/initializers/speedshop-cloudwatch.rb
4744
Speedshop::Cloudwatch::Puma.register
4845
Speedshop::Cloudwatch::Sidekiq.register
46+
# If you're not using Rails, see the section on `Rack` below.
4947
```
5048

5149
```ruby
@@ -58,7 +56,7 @@ end
5856

5957
## Configuration
6058

61-
You can configure which integrations are enabled, which metrics are reported, and the CloudWatch namespace for each integration:
59+
You can configure which metrics are reported, the CloudWatch namespace for each integration, and other settings:
6260

6361
```ruby
6462
Speedshop::Cloudwatch.configure do |config|
@@ -68,9 +66,6 @@ Speedshop::Cloudwatch.configure do |config|
6866
# Optional: Custom logger (defaults to Rails.logger if available, otherwise STDOUT)
6967
config.logger = Logger.new(Rails.root.join("log", "cloudwatch.log"))
7068

71-
# Disable an entire integration.
72-
config.enabled[:rack] = false
73-
7469
# Customize which metrics to report (whitelist)
7570
config.metrics[:puma] = [:Workers, :BootedWorkers, :Running, :Backlog]
7671
config.metrics[:sidekiq] = [:EnqueuedJobs, :QueueLatency, :QueueSize]
@@ -197,3 +192,18 @@ Rails.application.config.middleware.insert_before 0, Speedshop::Cloudwatch::Rack
197192

198193
# At this point, the auto-disable behavior in Rake is disabled. You'll have to re-implement yourself.
199194
```
195+
196+
### Disabling Integrations
197+
198+
In general, the best way to disable an integration is to never register it in the first place.
199+
200+
* **Puma** is only enabled when you explicitly `Speedshop::Cloudwatch::Puma.register`.
201+
* **Sidekiq** is the same via `Speedshop::Cloudwatch::Sidekiq.register`.
202+
* **Rack** is included automatically with Rails. See Rails integration below for how to disable this.
203+
* **ActiveJob** is only enabled when you add `include Speedshop::Cloudwatch::ActiveJob`.
204+
205+
If for some reason you want to disable an integration _after_ this registration, you can:
206+
207+
```ruby
208+
209+
```

Rakefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ require "rake/testtask"
55
require "standard/rake"
66

77
FLOG_THRESHOLD = (ENV["FLOG_THRESHOLD"] || 50).to_i
8-
FLAY_THRESHOLD = (ENV["FLAY_THRESHOLD"] || 25).to_i
8+
FLAY_THRESHOLD = (ENV["FLAY_THRESHOLD"] || 100).to_i
99

1010
Rake::TestTask.new(:test) do |t|
1111
t.libs << "test"

lib/speedshop/cloudwatch.rb

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
require "aws-sdk-cloudwatch"
4-
require "singleton"
4+
require "monitor"
55
require "speedshop/cloudwatch/active_job"
66
require "speedshop/cloudwatch/configuration"
77
require "speedshop/cloudwatch/metric_reporter"
@@ -13,24 +13,35 @@
1313
module Speedshop
1414
module Cloudwatch
1515
class Error < StandardError; end
16-
@reporter_mutex = Mutex.new
16+
@monitor = Monitor.new
1717

1818
class << self
19-
attr_reader :reporter_mutex
19+
attr_reader :monitor
2020

2121
def configure
22-
@config ||= Configuration.new
23-
yield @config if block_given?
24-
@config
22+
@monitor.synchronize do
23+
@config ||= Configuration.new
24+
yield @config if block_given?
25+
@config
26+
end
2527
end
2628

2729
def config
28-
@config ||= Configuration.new
30+
return @config if defined?(@config) && @config
31+
@monitor.synchronize { @config ||= Configuration.new }
32+
end
33+
34+
def config=(value)
35+
@monitor.synchronize { @config = value }
2936
end
3037

3138
def reporter
32-
return @reporter if defined?(@reporter)
33-
@reporter_mutex.synchronize { @reporter = MetricReporter.new(config: config) }
39+
return @reporter if defined?(@reporter) && @reporter
40+
@reporter = MetricReporter.new(config: config)
41+
end
42+
43+
def reporter=(value)
44+
@monitor.synchronize { @reporter = value }
3445
end
3546

3647
def log_info(msg)

lib/speedshop/cloudwatch/configuration.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@
55
module Speedshop
66
module Cloudwatch
77
class Configuration
8-
attr_accessor :interval, :client, :enabled, :metrics, :namespaces, :logger, :sidekiq_queues, :dimensions
8+
attr_accessor :interval, :client, :metrics, :namespaces, :logger, :sidekiq_queues, :dimensions
99

1010
def initialize
1111
@interval = 60
1212
@client = nil
13-
@enabled = {puma: true, sidekiq: true, rack: true, active_job: true}
1413
@metrics = {
1514
puma: [:Workers, :BootedWorkers, :OldWorkers, :Running, :Backlog, :PoolCapacity, :MaxThreads],
1615
sidekiq: [

lib/speedshop/cloudwatch/metric_reporter.rb

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ def initialize(config:)
1111
@mutex = Mutex.new
1212
@queue = []
1313
@collectors = []
14+
@registered_integrations = Set.new
1415
@thread = @pid = nil
1516
@running = false
1617
end
@@ -20,11 +21,16 @@ def start!
2021

2122
@mutex.synchronize do
2223
return if started?
23-
return Speedshop::Cloudwatch.log_info("No integrations enabled, not starting reporter") unless @config.enabled.values.any?
24+
return Speedshop::Cloudwatch.log_info("No integrations registered, not starting reporter") if @collectors.empty?
2425
Speedshop::Cloudwatch.log_info("Starting metric reporter (interval: #{@config.interval}s)")
26+
# Puma and Sidekiq Swarm both fork. We need to safely deal with that.
27+
# After a fork, the background thread is dead, so we start a new one.
28+
# We track the pid to know if we've forked.
2529
@pid = Process.pid
2630
@running = true
2731
@thread = Thread.new do
32+
# This bit is to tell Puma that this thread is fork-safe, so it won't
33+
# log anything.
2834
Thread.current.thread_variable_set(:fork_safe, true)
2935
run_loop
3036
end
@@ -49,6 +55,11 @@ def stop!
4955

5056
def report(metric_name, value, namespace:, unit: "None", dimensions: [])
5157
integration = @config.namespaces.key(namespace)
58+
59+
if integration && [:rack, :active_job].include?(integration)
60+
@mutex.synchronize { @registered_integrations << integration }
61+
end
62+
5263
return if integration && !metric_allowed?(integration, metric_name)
5364

5465
all_dimensions = dimensions + custom_dimensions
@@ -58,11 +69,30 @@ def report(metric_name, value, namespace:, unit: "None", dimensions: [])
5869
dimensions: all_dimensions, timestamp: Time.now}
5970
end
6071

72+
# Lazy-init of the reporter thread
6173
start! unless started?
6274
end
6375

64-
def register_collector(&block)
65-
@mutex.synchronize { @collectors << block }
76+
def register_collector(integration, &block)
77+
@mutex.synchronize do
78+
@collectors << {integration: integration, block: block}
79+
@registered_integrations << integration
80+
end
81+
end
82+
83+
def unregister_collector(integration)
84+
@mutex.synchronize do
85+
@collectors.reject! { |c| c[:integration] == integration }
86+
@registered_integrations.delete(integration)
87+
end
88+
end
89+
90+
def clear_all
91+
@mutex.synchronize do
92+
@queue.clear
93+
@collectors.clear
94+
@registered_integrations.clear
95+
end
6696
end
6797

6898
private
@@ -72,7 +102,7 @@ def run_loop
72102
sleep @config.interval
73103
@collectors.each { |c|
74104
begin
75-
c.call
105+
c[:block].call
76106
rescue => e
77107
Speedshop::Cloudwatch.log_error("Collector error: #{e.message}", e)
78108
end
@@ -87,6 +117,7 @@ def flush_metrics
87117
metrics = @mutex.synchronize { @queue.empty? ? nil : @queue.dup.tap { @queue.clear } }
88118
return unless metrics
89119

120+
# We batch these up as much as we can to minimize API calls
90121
metrics.group_by { |m| m[:namespace] }.each do |namespace, ns_metrics|
91122
@config.logger.debug "Speedshop::Cloudwatch: Sending #{ns_metrics.size} metrics to namespace #{namespace}"
92123
metric_data = ns_metrics.map { |m| m.slice(:metric_name, :value, :unit, :timestamp, :dimensions) }
@@ -97,7 +128,7 @@ def flush_metrics
97128
end
98129

99130
def metric_allowed?(integration, metric_name)
100-
@config.enabled[integration] && @config.metrics[integration].include?(metric_name.to_sym)
131+
@registered_integrations.include?(integration) && @config.metrics[integration].include?(metric_name.to_sym)
101132
end
102133

103134
def custom_dimensions

lib/speedshop/cloudwatch/puma.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class << self
77
def register(namespace: nil, reporter: Speedshop::Cloudwatch.reporter)
88
@namespace = namespace || Speedshop::Cloudwatch.config.namespaces[:puma]
99
@reporter = reporter
10-
@reporter.register_collector { collect_metrics }
10+
@reporter.register_collector(:puma) { collect_metrics }
1111
end
1212

1313
private
@@ -18,6 +18,7 @@ def collect_metrics
1818

1919
if stats[:worker_status]
2020
%i[workers booted_workers old_workers].each do |m|
21+
# Submit to SnakeCase tyranny
2122
metric_name = m.to_s.split("_").map(&:capitalize).join
2223
@reporter.report(metric_name, stats[m] || 0, namespace: @namespace, unit: "Count")
2324
end

lib/speedshop/cloudwatch/rack_middleware.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ def initialize(app)
99

1010
def call(env)
1111
begin
12+
# Header contains milliseconds since epoch (with optional "t=" prefix).
1213
if (header = env["HTTP_X_REQUEST_START"] || env["HTTP_X_QUEUE_START"])
1314
queue_time = (Time.now.to_f * 1000) - header.gsub("t=", "").to_f
1415
namespace = Speedshop::Cloudwatch.config.namespaces[:rack]

lib/speedshop/cloudwatch/railtie.rb

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,8 @@ module Speedshop
44
module Cloudwatch
55
class Railtie < ::Rails::Railtie
66
initializer "speedshop.cloudwatch.insert_middleware", before: :build_middleware_stack do |app|
7-
app.config.middleware.insert_before 0, Speedshop::Cloudwatch::RackMiddleware
8-
end
9-
10-
initializer "speedshop.cloudwatch.disable_in_console_and_tasks" do
11-
config.after_initialize do
12-
if caller.any? { |c| c.include?("console_command.rb") || c.include?("runner_command.rb") } || in_rake_task?
13-
Speedshop::Cloudwatch.config.enabled.keys.each do |integration|
14-
Speedshop::Cloudwatch.config.enabled[integration] = false
15-
end
16-
end
7+
unless caller.any? { |c| c.include?("console_command.rb") || c.include?("runner_command.rb") } || self.class.in_rake_task?
8+
app.config.middleware.insert_before 0, Speedshop::Cloudwatch::RackMiddleware
179
end
1810
end
1911

0 commit comments

Comments
 (0)