rails-autoscale-core 1.6.0 → 1.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffe703914e27914f16b7adb8785b6dce7a3b633196b06e11d5d31bec6ecfd749
4
- data.tar.gz: 9d2bc7f8fb8a861b385b54f9361529640eaa2c9ea156fd47705efaf94042ca13
3
+ metadata.gz: 4ab6318749147db00558b2d8a6f43a408dbfdc1984f27b67626649b451670a6f
4
+ data.tar.gz: 7d01e0e0a381efb7877449d970805fdd594c0a5c50b2adb5cca741082961474e
5
5
  SHA512:
6
- metadata.gz: e424ce46d5a9e57ffd61a75da78b0934dbcccb792c14a478a90f306f7c83ae7f769ecd067c42c27aa7a3e94e32b646ed63c2d25bdbe22b59cda006d5a108fd23
7
- data.tar.gz: f471203da9485fd7502d696ed97029b7e39329e9020566b7a9773935b9ca76e861db631e947fbbae1c571e5f40de2270a2ad8d5e985d1706fe167ab5200be48c
6
+ metadata.gz: bd8223438b2ba92be8565545c68af0adcfce0aaa58adaa632ab796009f251875a60bf8006526b1ecd0595db8937fc10f3f8bb5919852aa5560bba65bb92cd946
7
+ data.tar.gz: 808c2e0be260e5040c55eea07caa4a0206981593c48fd4a10e63bff7d60984a13d83098ea94f931e683bfe3b9abe27a25c6e00628a89ab7ffabcb617178c29e8
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-autoscale-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam McCrea
@@ -10,8 +10,22 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2024-04-26 00:00:00.000000000 Z
14
- dependencies: []
13
+ date: 2024-07-08 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: judoscale-ruby
17
+ requirement: !ruby/object:Gem::Requirement
18
+ requirements:
19
+ - - '='
20
+ - !ruby/object:Gem::Version
21
+ version: 1.7.1
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - '='
27
+ - !ruby/object:Gem::Version
28
+ version: 1.7.1
15
29
  description:
16
30
  email:
17
31
  - hello@judoscale.com
@@ -19,26 +33,7 @@ executables: []
19
33
  extensions: []
20
34
  extra_rdoc_files: []
21
35
  files:
22
- - Gemfile
23
- - Rakefile
24
- - judoscale-ruby.gemspec
25
- - lib/judoscale-ruby.rb
26
- - lib/judoscale/adapter_api.rb
27
- - lib/judoscale/config.rb
28
- - lib/judoscale/job_metrics_collector.rb
29
- - lib/judoscale/job_metrics_collector/active_record_helper.rb
30
- - lib/judoscale/logger.rb
31
- - lib/judoscale/metric.rb
32
- - lib/judoscale/metrics_collector.rb
33
- - lib/judoscale/metrics_store.rb
34
- - lib/judoscale/report.rb
35
- - lib/judoscale/reporter.rb
36
- - lib/judoscale/request_metrics.rb
37
- - lib/judoscale/request_middleware.rb
38
- - lib/judoscale/version.rb
39
- - lib/judoscale/web_metrics_collector.rb
40
36
  - lib/rails-autoscale-core.rb
41
- - rails-autoscale-core.gemspec
42
37
  homepage: https://judoscale.com
43
38
  licenses:
44
39
  - MIT
@@ -56,16 +51,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
56
51
  requirements:
57
52
  - - ">="
58
53
  - !ruby/object:Gem::Version
59
- version: 2.6.0
54
+ version: '0'
60
55
  required_rubygems_version: !ruby/object:Gem::Requirement
61
56
  requirements:
62
57
  - - ">="
63
58
  - !ruby/object:Gem::Version
64
59
  version: '0'
65
60
  requirements: []
66
- rubygems_version: 3.4.10
61
+ rubygems_version: 3.5.11
67
62
  signing_key:
68
63
  specification_version: 4
69
- summary: This gem works with the Judoscale Heroku add-on to automatically scale your
70
- web and worker dynos.
64
+ summary: Autoscaling for Ruby.
71
65
  test_files: []
data/Gemfile DELETED
@@ -1,9 +0,0 @@
1
- source "https://rubygems.org"
2
-
3
- gemspec name: "judoscale-ruby"
4
-
5
- gem "rake", ">= 12.3.3"
6
- gem "minitest"
7
- gem "minitest-stub-const"
8
- gem "webmock"
9
- gem "debug"
data/Rakefile DELETED
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rake/testtask"
4
-
5
- Rake::TestTask.new(:test) do |t|
6
- t.libs << "lib"
7
- t.libs << "test"
8
- t.test_files = FileList["test/**/*_test.rb"]
9
- end
10
-
11
- task default: :test
@@ -1,27 +0,0 @@
1
- lib = File.expand_path("../lib", __FILE__)
2
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
- require "judoscale/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "judoscale-ruby"
7
- spec.version = Judoscale::VERSION
8
- spec.authors = ["Adam McCrea", "Carlos Antonio da Silva", "Jon Sullivan"]
9
- spec.email = ["hello@judoscale.com"]
10
-
11
- spec.summary = "This gem works with the Judoscale Heroku add-on to automatically scale your web and worker dynos."
12
- spec.homepage = "https://judoscale.com"
13
- spec.license = "MIT"
14
-
15
- spec.metadata = {
16
- "homepage_uri" => "https://judoscale.com",
17
- "bug_tracker_uri" => "https://github.com/judoscale/judoscale-ruby/issues",
18
- "documentation_uri" => "https://judoscale.com/docs",
19
- "changelog_uri" => "https://github.com/judoscale/judoscale-ruby/blob/main/CHANGELOG.md",
20
- "source_code_uri" => "https://github.com/judoscale/judoscale-ruby"
21
- }
22
-
23
- spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
24
- spec.require_paths = ["lib"]
25
-
26
- spec.required_ruby_version = ">= 2.6.0"
27
- end
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "net/http"
4
- require "uri"
5
- require "json"
6
- require "judoscale/logger"
7
-
8
- module Judoscale
9
- class AdapterApi
10
- include Logger
11
-
12
- SUCCESS = "success"
13
- TRANSIENT_ERRORS = [
14
- Errno::ECONNREFUSED,
15
- Errno::ECONNRESET,
16
- Net::OpenTimeout,
17
- Net::ReadTimeout,
18
- OpenSSL::SSL::SSLError
19
- ]
20
-
21
- def initialize(config)
22
- @config = config
23
- end
24
-
25
- def report_metrics(report_json)
26
- post_json "/v3/reports", report_json
27
- end
28
-
29
- private
30
-
31
- def post_json(path, data)
32
- headers = {"Content-Type" => "application/json"}
33
- post_raw path: path, body: JSON.dump(data), headers: headers
34
- end
35
-
36
- def post_raw(options)
37
- attempts ||= 1
38
- uri = URI.parse("#{@config.api_base_url}#{options.fetch(:path)}")
39
- ssl = uri.scheme == "https"
40
-
41
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: ssl) do |http|
42
- request = Net::HTTP::Post.new(uri.request_uri, options[:headers] || {})
43
- request.body = options.fetch(:body)
44
-
45
- logger.debug "Posting #{request.body.size} bytes to #{uri}"
46
- http.request(request)
47
- end
48
-
49
- case response.code.to_i
50
- when 200...300 then SuccessResponse.new(response.body)
51
- else FailureResponse.new([response.code, response.message].join(" - "))
52
- end
53
- rescue *TRANSIENT_ERRORS => ex
54
- if attempts < 3
55
- # TCP timeouts happen sometimes, but they can usually be successfully retried in a moment
56
- sleep 0.01
57
- attempts += 1
58
- retry
59
- else
60
- FailureResponse.new("Could not connect to #{uri.host}: #{ex.inspect}")
61
- end
62
- end
63
-
64
- class SuccessResponse < Struct.new(:body)
65
- def data
66
- JSON.parse(body)
67
- rescue TypeError
68
- {}
69
- end
70
- end
71
-
72
- class FailureResponse < Struct.new(:failure_message)
73
- end
74
- end
75
- end
@@ -1,121 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "singleton"
4
- require "logger"
5
-
6
- module Judoscale
7
- class Config
8
- class RuntimeContainer < String
9
- # Since Heroku exposes ordinal dyno 'numbers', we can tell if the current
10
- # instance is redundant (and thus skip collecting some metrics sometimes)
11
- # We don't have a means of determining that on Render though — so every
12
- # instance must be considered non-redundant
13
- def redundant_instance?
14
- instance_number = split(".")[1].to_i
15
- instance_number > 1
16
- end
17
- end
18
-
19
- class JobAdapterConfig
20
- UUID_REGEXP = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/
21
- DEFAULT_QUEUE_FILTER = ->(queue_name) { !UUID_REGEXP.match?(queue_name) }
22
-
23
- attr_accessor :identifier, :enabled, :max_queues, :queues, :queue_filter, :track_busy_jobs
24
-
25
- def initialize(identifier)
26
- @identifier = identifier
27
- reset
28
- end
29
-
30
- def reset
31
- @enabled = true
32
- @queues = []
33
- @queue_filter = DEFAULT_QUEUE_FILTER
34
-
35
- # Support for deprecated legacy env var configs.
36
- @max_queues = (ENV["RAILS_AUTOSCALE_MAX_QUEUES"] || 20).to_i
37
- @track_busy_jobs = ENV["RAILS_AUTOSCALE_LONG_JOBS"] == "true"
38
- end
39
-
40
- def as_json
41
- {
42
- identifier => {
43
- max_queues: max_queues,
44
- queues: queues,
45
- queue_filter: queue_filter != DEFAULT_QUEUE_FILTER,
46
- track_busy_jobs: track_busy_jobs
47
- }
48
- }
49
- end
50
- end
51
-
52
- include Singleton
53
-
54
- @adapter_configs = []
55
- class << self
56
- attr_reader :adapter_configs
57
- end
58
-
59
- def self.expose_adapter_config(config_instance)
60
- adapter_configs << config_instance
61
-
62
- define_method(config_instance.identifier) do
63
- config_instance
64
- end
65
- end
66
-
67
- attr_accessor :api_base_url, :report_interval_seconds,
68
- :max_request_size_bytes, :logger, :log_tag, :current_runtime_container
69
- attr_reader :log_level
70
-
71
- def initialize
72
- reset
73
- end
74
-
75
- def reset
76
- @api_base_url = ENV["JUDOSCALE_URL"] || ENV["RAILS_AUTOSCALE_URL"]
77
- @log_tag = "Judoscale"
78
- @max_request_size_bytes = 100_000 # ignore request payloads over 100k since they skew the queue times
79
- @report_interval_seconds = 10
80
-
81
- self.log_level = ENV["JUDOSCALE_LOG_LEVEL"] || ENV["RAILS_AUTOSCALE_LOG_LEVEL"]
82
- @logger = ::Logger.new($stdout)
83
-
84
- self.class.adapter_configs.each(&:reset)
85
-
86
- if ENV["RENDER_INSTANCE_ID"]
87
- instance = ENV["RENDER_INSTANCE_ID"].delete_prefix(ENV["RENDER_SERVICE_ID"]).delete_prefix("-")
88
- @current_runtime_container = RuntimeContainer.new instance
89
- # Allow a custom API base URL to be set for Render (for testing)
90
- @api_base_url ||= "https://adapter.judoscale.com/api"
91
- @api_base_url += "/#{ENV["RENDER_SERVICE_ID"]}"
92
- elsif ENV["DYNO"]
93
- @current_runtime_container = RuntimeContainer.new ENV["DYNO"]
94
- elsif (metadata_uri = ENV["ECS_CONTAINER_METADATA_URI"])
95
- @current_runtime_container = RuntimeContainer.new(metadata_uri.split("/").last)
96
- else
97
- # unsupported platform? Don't want to leave @current_runtime_container nil though
98
- @current_runtime_container = RuntimeContainer.new("")
99
- end
100
- end
101
-
102
- def log_level=(new_level)
103
- @log_level = new_level ? ::Logger::Severity.const_get(new_level.to_s.upcase) : nil
104
- end
105
-
106
- def as_json
107
- adapter_configs_json = self.class.adapter_configs.reduce({}) { |hash, config| hash.merge!(config.as_json) }
108
-
109
- {
110
- log_level: log_level,
111
- logger: logger.class.name,
112
- report_interval_seconds: report_interval_seconds,
113
- max_request_size_bytes: max_request_size_bytes
114
- }.merge!(adapter_configs_json)
115
- end
116
-
117
- def ignore_large_requests?
118
- @max_request_size_bytes
119
- end
120
- end
121
- end
@@ -1,56 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Judoscale
4
- class JobMetricsCollector
5
- module ActiveRecordHelper
6
- # Cleanup any whitespace characters (including new lines) from the SQL for simpler logging.
7
- # Reference: ActiveSupport's `squish!` method. https://api.rubyonrails.org/classes/String.html#method-i-squish
8
- def self.cleanse_sql(sql)
9
- sql = sql.dup
10
- sql.gsub!(/[[:space:]]+/, " ")
11
- sql.strip!
12
- sql
13
- end
14
-
15
- # This will respect a multiple-database setup, unlike the `table_exists?` method.
16
- def self.table_exists_for_model?(model)
17
- model.connection.schema_cache.data_source_exists?(model.table_name)
18
- rescue ActiveRecord::NoDatabaseError
19
- false
20
- end
21
-
22
- def self.table_exists?(table_name)
23
- ::ActiveRecord::Base.connection.table_exists?(table_name)
24
- rescue ActiveRecord::NoDatabaseError
25
- false
26
- end
27
-
28
- private
29
-
30
- def run_silently(&block)
31
- if Config.instance.log_level && ::ActiveRecord::Base.logger.respond_to?(:silence)
32
- ::ActiveRecord::Base.logger.silence(Config.instance.log_level) { yield }
33
- else
34
- yield
35
- end
36
- end
37
-
38
- def select_rows_silently(sql)
39
- run_silently { select_rows_tagged(sql) }
40
- end
41
-
42
- def select_rows_tagged(sql)
43
- if ActiveRecord::Base.logger.respond_to?(:tagged)
44
- ActiveRecord::Base.logger.tagged(Config.instance.log_tag) { select_rows(sql) }
45
- else
46
- select_rows(sql)
47
- end
48
- end
49
-
50
- def select_rows(sql)
51
- # This ensures the agent doesn't hold onto a DB connection any longer than necessary
52
- ActiveRecord::Base.connection_pool.with_connection { |c| c.select_rows(sql) }
53
- end
54
- end
55
- end
56
- end
@@ -1,104 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "set"
4
- require "judoscale/metrics_collector"
5
- require "judoscale/logger"
6
-
7
- module Judoscale
8
- class JobMetricsCollector < MetricsCollector
9
- include Judoscale::Logger
10
-
11
- def self.collect?(config)
12
- super && !config.current_runtime_container.redundant_instance? && adapter_config.enabled
13
- end
14
-
15
- def self.adapter_name
16
- @_adapter_name ||= adapter_identifier.to_s.capitalize.gsub(/(?:_)(.)/i) { $1.upcase }
17
- end
18
-
19
- def self.adapter_identifier
20
- adapter_config.identifier
21
- end
22
-
23
- def self.adapter_config
24
- raise "Implement `self.adapter_config` in individual job metrics collectors."
25
- end
26
-
27
- def initialize
28
- super
29
-
30
- log_msg = +"#{self.class.adapter_name} enabled"
31
- log_msg << " with busy job tracking support" if track_busy_jobs?
32
- logger.info log_msg
33
- end
34
-
35
- # Track the known queues so we can continue reporting on queues that don't
36
- # have enqueued jobs at the time of reporting.
37
- def queues
38
- @queues ||= Set.new([])
39
- end
40
-
41
- def queues=(new_queues)
42
- @queues = filter_queues(new_queues)
43
- end
44
-
45
- def clear_queues
46
- @queues = nil
47
- end
48
-
49
- private
50
-
51
- def adapter_config
52
- self.class.adapter_config
53
- end
54
-
55
- def filter_queues(queues)
56
- configured_queues = adapter_config.queues
57
-
58
- if configured_queues.empty?
59
- configured_filter = adapter_config.queue_filter
60
-
61
- if configured_filter.respond_to?(:call)
62
- queues = queues.select { |queue| configured_filter.call(queue) }
63
- end
64
- else
65
- queues = configured_queues
66
- end
67
-
68
- queues = filter_max_queues(queues)
69
-
70
- Set.new(queues)
71
- end
72
-
73
- # Collect up to the configured `max_queues`, skipping the rest.
74
- # We sort queues by name length before making the cut-off, as a simple heuristic to keep the shorter ones
75
- # and possibly ignore the longer ones, which are more likely to be dynamically generated for example.
76
- def filter_max_queues(queues_to_collect)
77
- queues_size = queues_to_collect.size
78
- max_queues = adapter_config.max_queues
79
-
80
- if queues_size > max_queues
81
- logger.warn "#{self.class.adapter_name} metrics reporting only #{max_queues} queues max, skipping the rest (#{queues_size - max_queues})"
82
- queues_to_collect.sort_by(&:length).first(max_queues)
83
- else
84
- queues_to_collect
85
- end
86
- end
87
-
88
- # Sample log line for each collection, assuming `sidekiq` as the adapter identifier:
89
- # `sidekiq-qt.default=10ms sidekiq-qd.default=3 sidekiq-busy.default=1`
90
- def log_collection(metrics)
91
- return if metrics.empty?
92
-
93
- identifier = self.class.adapter_identifier
94
- messages = metrics.map { |metric|
95
- "#{identifier}-#{metric.identifier}.#{metric.queue_name}=#{metric.value}#{"ms" if metric.identifier == :qt}"
96
- }
97
- logger.debug messages.join(" ")
98
- end
99
-
100
- def track_busy_jobs?
101
- adapter_config.track_busy_jobs
102
- end
103
- end
104
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "judoscale/config"
4
- require "logger"
5
-
6
- module Judoscale
7
- module Logger
8
- def logger
9
- if @logger && @logger.log_level == Config.instance.log_level
10
- @logger
11
- else
12
- @logger = LoggerProxy.new(Config.instance.logger, Config.instance.log_level)
13
- end
14
- end
15
- end
16
-
17
- class LoggerProxy < Struct.new(:logger, :log_level)
18
- %w[ERROR WARN INFO DEBUG FATAL].each do |severity_name|
19
- severity_level = ::Logger::Severity.const_get(severity_name)
20
-
21
- define_method(severity_name.downcase) do |*messages|
22
- if log_level.nil?
23
- logger.public_send(severity_name.downcase) { tag(messages) }
24
- elsif severity_level >= log_level
25
- if severity_level >= logger.level
26
- logger.public_send(severity_name.downcase) { tag(messages) }
27
- else
28
- # Our logger proxy is configured with a lower severity level than the underlying logger,
29
- # so send this message using the underlying logger severity instead of the actual severity.
30
- logger_severity_name = ::Logger::SEV_LABEL[logger.level].downcase
31
- logger.public_send(logger_severity_name) { tag(messages, tag_level: severity_name) }
32
- end
33
- end
34
- end
35
- end
36
-
37
- private
38
-
39
- def tag(msgs, tag_level: nil)
40
- tag = +"[#{Config.instance.log_tag}]"
41
- tag << " [#{tag_level}]" if tag_level
42
- msgs.map { |msg| "#{tag} #{msg}" }.join("\n")
43
- end
44
- end
45
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Judoscale
4
- class Metric < Struct.new(:identifier, :value, :time, :queue_name)
5
- # No queue_name is assumed to be a web request metric
6
- # Metrics: qt = queue time (default), qd = queue depth, busy
7
- def initialize(identifier, value, time, queue_name = nil)
8
- super(identifier, value.to_i, time.utc, queue_name)
9
- end
10
- end
11
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Judoscale
4
- class MetricsCollector
5
- def self.collect?(config)
6
- true
7
- end
8
-
9
- def collect
10
- []
11
- end
12
- end
13
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "singleton"
4
- require "judoscale/metric"
5
- require "judoscale/report"
6
-
7
- module Judoscale
8
- class MetricsStore
9
- include Singleton
10
-
11
- attr_reader :metrics, :flushed_at
12
-
13
- def initialize
14
- @metrics = []
15
- @flushed_at = Time.now
16
- end
17
-
18
- def push(identifier, value, time = Time.now, queue_name = nil)
19
- # If it's been two minutes since clearing out the store, stop collecting metrics.
20
- # There could be an issue with the reporter, and continuing to collect will consume linear memory.
21
- return if @flushed_at && @flushed_at < Time.now - 120
22
-
23
- @metrics << Metric.new(identifier, value, time, queue_name)
24
- end
25
-
26
- def flush
27
- @flushed_at = Time.now
28
- flushed_metrics = []
29
-
30
- while (metric = @metrics.shift)
31
- flushed_metrics << metric
32
- end
33
-
34
- flushed_metrics
35
- end
36
-
37
- def clear
38
- @metrics.clear
39
- end
40
- end
41
- end
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Judoscale
4
- class Report
5
- attr_reader :adapters, :config, :metrics
6
-
7
- def initialize(adapters, config, metrics = [])
8
- @adapters = adapters
9
- @config = config
10
- @metrics = metrics
11
- end
12
-
13
- def as_json
14
- {
15
- container: config.current_runtime_container,
16
- pid: Process.pid,
17
- config: config.as_json,
18
- adapters: adapters.reduce({}) { |hash, adapter| hash.merge!(adapter.as_json) },
19
- metrics: metrics.map { |metric|
20
- [
21
- metric.time.to_i,
22
- metric.value,
23
- metric.identifier,
24
- metric.queue_name
25
- ]
26
- }
27
- }
28
- end
29
- end
30
- end
@@ -1,102 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "singleton"
4
- require "judoscale/config"
5
- require "judoscale/logger"
6
- require "judoscale/adapter_api"
7
- require "judoscale/job_metrics_collector"
8
- require "judoscale/web_metrics_collector"
9
-
10
- module Judoscale
11
- class Reporter
12
- include Singleton
13
- include Logger
14
-
15
- def self.start(config = Config.instance, adapters = Judoscale.adapters)
16
- instance.start!(config, adapters) unless instance.started?
17
- end
18
-
19
- def start!(config, adapters)
20
- @pid = Process.pid
21
-
22
- if config.api_base_url.nil? || config.api_base_url.strip.empty?
23
- logger.debug "Set api_base_url to enable metrics reporting"
24
- return
25
- end
26
-
27
- enabled_adapters, skipped_adapters = adapters.partition { |adapter|
28
- adapter.metrics_collector&.collect?(config)
29
- }
30
- metrics_collectors_classes = enabled_adapters.map(&:metrics_collector)
31
- adapters_msg = enabled_adapters.map(&:identifier).concat(
32
- skipped_adapters.map { |adapter| "#{adapter.identifier}[skipped]" }
33
- ).join(", ")
34
-
35
- if metrics_collectors_classes.empty?
36
- logger.debug "No metrics need to be collected (adapters: #{adapters_msg})"
37
- return
38
- end
39
-
40
- logger.info "Reporter starting, will report every ~#{config.report_interval_seconds} seconds (adapters: #{adapters_msg})"
41
-
42
- metrics_collectors = metrics_collectors_classes.map(&:new)
43
-
44
- run_loop(config, metrics_collectors)
45
- end
46
-
47
- def run_loop(config, metrics_collectors)
48
- @_thread = Thread.new do
49
- loop do
50
- run_metrics_collection(config, metrics_collectors)
51
-
52
- # Stagger reporting to spread out reports from many processes
53
- multiplier = 1 - (rand / 4) # between 0.75 and 1.0
54
- sleep config.report_interval_seconds * multiplier
55
- end
56
- end
57
- end
58
-
59
- def run_metrics_collection(config, metrics_collectors)
60
- metrics = metrics_collectors.flat_map do |metric_collector|
61
- log_exceptions { metric_collector.collect } || []
62
- end
63
-
64
- log_exceptions { report(config, metrics) }
65
- end
66
-
67
- def started?
68
- @pid == Process.pid
69
- end
70
-
71
- def stop!
72
- @_thread&.terminate
73
- @_thread = nil
74
- @pid = nil
75
- end
76
-
77
- private
78
-
79
- def report(config, metrics)
80
- report = Report.new(Judoscale.adapters, config, metrics)
81
- logger.info "Reporting #{report.metrics.size} metrics"
82
- result = AdapterApi.new(config).report_metrics(report.as_json)
83
-
84
- case result
85
- when AdapterApi::SuccessResponse
86
- logger.debug "Reported successfully"
87
- when AdapterApi::FailureResponse
88
- logger.error "Reporter failed: #{result.failure_message}"
89
- end
90
- end
91
-
92
- def log_exceptions
93
- yield
94
- rescue => ex
95
- # Log the exception but swallow it to keep the thread running and processing reports.
96
- # Note: Exceptions in threads other than the main thread will fail silently and terminate it.
97
- # https://ruby-doc.org/core-3.1.0/Thread.html#class-Thread-label-Exception+handling
98
- logger.error "Reporter error: #{ex.inspect}", *ex.backtrace
99
- nil
100
- end
101
- end
102
- end
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Judoscale
4
- class RequestMetrics
5
- MILLISECONDS_CUTOFF = Time.new(2000, 1, 1).to_i * 1000
6
- MICROSECONDS_CUTOFF = MILLISECONDS_CUTOFF * 1000
7
- NANOSECONDS_CUTOFF = MICROSECONDS_CUTOFF * 1000
8
-
9
- attr_reader :request_id, :size, :network_time
10
-
11
- def initialize(env, config = Config.instance)
12
- @config = config
13
- @request_id = env["HTTP_X_REQUEST_ID"]
14
- @size = env["rack.input"].respond_to?(:size) ? env["rack.input"].size : 0
15
- @network_time = env["puma.request_body_wait"].to_i
16
- @request_start_header = env["HTTP_X_REQUEST_START"]
17
- end
18
-
19
- def ignore?
20
- @config.ignore_large_requests? && @size > @config.max_request_size_bytes
21
- end
22
-
23
- def started_at
24
- if @request_start_header
25
- # There are several variants of this header. We handle these:
26
- # - whole milliseconds (Heroku)
27
- # - whole microseconds (???)
28
- # - whole nanoseconds (Render)
29
- # - fractional seconds (NGINX)
30
- # - preceeding "t=" (NGINX)
31
- value = @request_start_header.gsub(/[^0-9.]/, "").to_f
32
-
33
- # `value` could be seconds, milliseconds, microseconds or nanoseconds.
34
- # We use some arbitrary cutoffs to determine which one it is.
35
-
36
- if value > NANOSECONDS_CUTOFF
37
- Time.at(value / 1_000_000_000.0)
38
- elsif value > MICROSECONDS_CUTOFF
39
- Time.at(value / 1_000_000.0)
40
- elsif value > MILLISECONDS_CUTOFF
41
- Time.at(value / 1000.0)
42
- else
43
- Time.at(value)
44
- end
45
- end
46
- end
47
-
48
- def queue_time(now = Time.now)
49
- return if started_at.nil?
50
-
51
- queue_time = ((now - started_at) * 1000).to_i
52
-
53
- # Subtract the time Puma spent waiting on the request body, i.e. the network time. It's irrelevant to
54
- # capacity-related queue time. Without this, slow clients and large request payloads will skew queue time.
55
- queue_time -= network_time
56
-
57
- # Safeguard against negative queue times (should not happen in practice)
58
- (queue_time > 0) ? queue_time : 0
59
- end
60
- end
61
- end
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "judoscale/metrics_store"
4
- require "judoscale/reporter"
5
- require "judoscale/logger"
6
- require "judoscale/request_metrics"
7
-
8
- module Judoscale
9
- class RequestMiddleware
10
- include Logger
11
-
12
- def initialize(app)
13
- @app = app
14
- end
15
-
16
- def call(env)
17
- request_metrics = RequestMetrics.new(env)
18
-
19
- unless request_metrics.ignore?
20
- queue_time = request_metrics.queue_time
21
- network_time = request_metrics.network_time
22
- end
23
-
24
- Reporter.start
25
-
26
- if queue_time
27
- store = MetricsStore.instance
28
-
29
- # NOTE: Expose queue time to the app
30
- env["judoscale.queue_time"] = queue_time
31
- store.push :qt, queue_time
32
-
33
- unless network_time.zero?
34
- env["judoscale.network_time"] = network_time
35
- store.push :nt, network_time
36
- end
37
-
38
- logger.debug "Request queue_time=#{queue_time}ms network_time=#{network_time}ms request_id=#{request_metrics.request_id} size=#{request_metrics.size}"
39
- end
40
-
41
- @app.call(env)
42
- end
43
- end
44
- end
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Judoscale
4
- VERSION = "1.6.0"
5
- end
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "judoscale/metrics_collector"
4
- require "judoscale/metrics_store"
5
-
6
- module Judoscale
7
- class WebMetricsCollector < MetricsCollector
8
- def collect
9
- MetricsStore.instance.flush
10
- end
11
- end
12
- end
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "judoscale/config"
4
- require "judoscale/version"
5
-
6
- module Judoscale
7
- # Allows configuring Judoscale through a block, usually defined during application initialization.
8
- #
9
- # Example:
10
- #
11
- # Judoscale.configure do |config|
12
- # config.logger = MyLogger.new
13
- # end
14
- def self.configure
15
- yield Config.instance
16
- end
17
-
18
- @adapters = []
19
- class << self
20
- attr_reader :adapters
21
- end
22
-
23
- class Adapter < Struct.new(:identifier, :adapter_info, :metrics_collector)
24
- def as_json
25
- {identifier => adapter_info}
26
- end
27
- end
28
-
29
- def self.add_adapter(identifier, adapter_info, metrics_collector: nil, expose_config: nil)
30
- Config.expose_adapter_config(expose_config) if expose_config
31
- @adapters << Adapter.new(identifier, adapter_info, metrics_collector)
32
- end
33
-
34
- add_adapter :"judoscale-ruby", {
35
- adapter_version: VERSION,
36
- language_version: RUBY_VERSION
37
- }
38
- end
39
-
40
- RailsAutoscale = Judoscale
@@ -1,27 +0,0 @@
1
- lib = File.expand_path("../lib", __FILE__)
2
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
- require "judoscale/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "rails-autoscale-core"
7
- spec.version = Judoscale::VERSION
8
- spec.authors = ["Adam McCrea", "Carlos Antonio da Silva", "Jon Sullivan"]
9
- spec.email = ["hello@judoscale.com"]
10
-
11
- spec.summary = "This gem works with the Judoscale Heroku add-on to automatically scale your web and worker dynos."
12
- spec.homepage = "https://judoscale.com"
13
- spec.license = "MIT"
14
-
15
- spec.metadata = {
16
- "homepage_uri" => "https://judoscale.com",
17
- "bug_tracker_uri" => "https://github.com/judoscale/judoscale-ruby/issues",
18
- "documentation_uri" => "https://judoscale.com/docs",
19
- "changelog_uri" => "https://github.com/judoscale/judoscale-ruby/blob/main/CHANGELOG.md",
20
- "source_code_uri" => "https://github.com/judoscale/judoscale-ruby"
21
- }
22
-
23
- spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
24
- spec.require_paths = ["lib"]
25
-
26
- spec.required_ruby_version = ">= 2.6.0"
27
- end