rails-autoscale-core 1.6.0 → 1.7.1

Sign up to get free protection for your applications and to get access to all the features.
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