rails-autoscale-core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 957a594b6d2686be815ed71d6c84ffcc92e14a14f5a9035fb12f92d6f979a400
4
+ data.tar.gz: b8cb713fb2731acbc11724097425d497c1f48e5bacaeff5cc41410a8b031f0a3
5
+ SHA512:
6
+ metadata.gz: 4b79400a71ac5f68401be26efa1bcc9e723b37d55c23802fb0ec30a9d1963bc892ac7b0c8067f11cddc802ffbd3dffd2cc80d7895ab7a1be89d0a7aebcff5232
7
+ data.tar.gz: f8940544874532959ae2ab3d5405c7087023a742a4f2a365d6ae4a2b15a602e6a41f9c6862d3171b55e4000e7c08008d37781f77531c0c492ea69c926a0377c1
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem "rake", ">= 12.3.3"
6
+ gem "minitest"
7
+ gem "webmock"
data/Gemfile.lock ADDED
@@ -0,0 +1,36 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ rails-autoscale-core (1.0.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ addressable (2.8.0)
10
+ public_suffix (>= 2.0.2, < 5.0)
11
+ crack (0.4.5)
12
+ rexml
13
+ hashdiff (1.0.1)
14
+ minitest (5.15.0)
15
+ public_suffix (4.0.6)
16
+ rake (13.0.6)
17
+ rexml (3.2.5)
18
+ webmock (3.14.0)
19
+ addressable (>= 2.8.0)
20
+ crack (>= 0.3.2)
21
+ hashdiff (>= 0.4.0, < 2.0.0)
22
+
23
+ PLATFORMS
24
+ arm64-darwin-20
25
+ arm64-darwin-21
26
+ x86_64-darwin-21
27
+ x86_64-linux
28
+
29
+ DEPENDENCIES
30
+ minitest
31
+ rails-autoscale-core!
32
+ rake (>= 12.3.3)
33
+ webmock
34
+
35
+ BUNDLED WITH
36
+ 2.3.9
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "lib"
8
+ t.libs << "test"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_autoscale/config"
4
+ require "rails_autoscale/version"
5
+
6
+ module RailsAutoscale
7
+ # Allows configuring Rails Autoscale through a block, usually defined during application initialization.
8
+ #
9
+ # Example:
10
+ #
11
+ # RailsAutoscale.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
+ Adapter = Struct.new(:identifier, :adapter_info, :metrics_collector) do
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 :"rails-autoscale-core", {
35
+ adapter_version: VERSION,
36
+ language_version: RUBY_VERSION
37
+ }
38
+ end
39
+
40
+ Judoscale = RailsAutoscale
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "rails_autoscale/logger"
7
+
8
+ module RailsAutoscale
9
+ class AdapterApi
10
+ include Logger
11
+
12
+ SUCCESS = "success"
13
+
14
+ def initialize(config)
15
+ @config = config
16
+ end
17
+
18
+ def report_metrics(report_json)
19
+ post_json "/v3/reports", report_json
20
+ end
21
+
22
+ private
23
+
24
+ def post_json(path, data)
25
+ headers = {"Content-Type" => "application/json"}
26
+ post_raw path: path, body: JSON.dump(data), headers: headers
27
+ end
28
+
29
+ def post_raw(options)
30
+ uri = URI.parse("#{@config.api_base_url}#{options.fetch(:path)}")
31
+ ssl = uri.scheme == "https"
32
+
33
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: ssl) do |http|
34
+ request = Net::HTTP::Post.new(uri.request_uri, options[:headers] || {})
35
+ request.body = options.fetch(:body)
36
+
37
+ logger.debug "Posting #{request.body.size} bytes to #{uri}"
38
+ http.request(request)
39
+ end
40
+
41
+ case response.code.to_i
42
+ when 200...300 then SuccessResponse.new(response.body)
43
+ else FailureResponse.new([response.code, response.message].join(" - "))
44
+ end
45
+ end
46
+
47
+ class SuccessResponse < Struct.new(:body)
48
+ def data
49
+ JSON.parse(body)
50
+ rescue TypeError
51
+ {}
52
+ end
53
+ end
54
+
55
+ class FailureResponse < Struct.new(:failure_message)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "logger"
5
+
6
+ module RailsAutoscale
7
+ class Config
8
+ class Dyno
9
+ attr_reader :name, :num
10
+
11
+ def initialize(dyno_string)
12
+ @name, @num = dyno_string.to_s.split(".")
13
+ @num = @num.to_i
14
+ end
15
+
16
+ def to_s
17
+ "#{name}.#{num}"
18
+ end
19
+ end
20
+
21
+ class JobAdapterConfig
22
+ 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}/
23
+ DEFAULT_QUEUE_FILTER = ->(queue_name) { !UUID_REGEXP.match?(queue_name) }
24
+
25
+ attr_accessor :identifier, :enabled, :max_queues, :queues, :queue_filter, :track_busy_jobs
26
+
27
+ def initialize(identifier)
28
+ @identifier = identifier
29
+ reset
30
+ end
31
+
32
+ def reset
33
+ @enabled = true
34
+ @max_queues = 20
35
+ @queues = []
36
+ @queue_filter = DEFAULT_QUEUE_FILTER
37
+ @track_busy_jobs = false
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, :max_request_size_bytes, :logger, :log_tag
68
+ attr_reader :dyno, :log_level
69
+
70
+ def initialize
71
+ reset
72
+ end
73
+
74
+ def reset
75
+ # Allow the API URL to be configured - needed for testing.
76
+ @api_base_url = ENV["RAILS_AUTOSCALE_URL"] || ENV["JUDOSCALE_URL"]
77
+ @log_tag = ENV["JUDOSCALE_URL"] ? "Judoscale" : "RailsAutoscale"
78
+ self.dyno = ENV["DYNO"]
79
+ @max_request_size_bytes = 100_000 # ignore request payloads over 100k since they skew the queue times
80
+ @report_interval_seconds = 10
81
+ self.log_level = ENV["RAILS_AUTOSCALE_LOG_LEVEL"] || ENV["JUDOSCALE_LOG_LEVEL"]
82
+ @logger = ::Logger.new($stdout)
83
+
84
+ self.class.adapter_configs.each(&:reset)
85
+ end
86
+
87
+ def dyno=(dyno_string)
88
+ @dyno = Dyno.new(dyno_string)
89
+ end
90
+
91
+ def log_level=(new_level)
92
+ @log_level = new_level ? ::Logger::Severity.const_get(new_level.to_s.upcase) : nil
93
+ end
94
+
95
+ def as_json
96
+ adapter_configs_json = self.class.adapter_configs.reduce({}) { |hash, config| hash.merge!(config.as_json) }
97
+
98
+ {
99
+ log_level: log_level,
100
+ logger: logger.class.name,
101
+ report_interval_seconds: report_interval_seconds,
102
+ max_request_size_bytes: max_request_size_bytes
103
+ }.merge!(adapter_configs_json)
104
+ end
105
+
106
+ def to_s
107
+ "#{@dyno}##{Process.pid}"
108
+ end
109
+
110
+ def ignore_large_requests?
111
+ @max_request_size_bytes
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAutoscale
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
+ private
16
+
17
+ def select_rows_silently(sql)
18
+ if Config.instance.log_level && ::ActiveRecord::Base.logger.respond_to?(:silence)
19
+ ::ActiveRecord::Base.logger.silence(Config.instance.log_level) { select_rows_tagged(sql) }
20
+ else
21
+ select_rows_tagged(sql)
22
+ end
23
+ end
24
+
25
+ def select_rows_tagged(sql)
26
+ if ActiveRecord::Base.logger.respond_to?(:tagged)
27
+ ActiveRecord::Base.logger.tagged(Config.instance.log_tag) { select_rows(sql) }
28
+ else
29
+ select_rows(sql)
30
+ end
31
+ end
32
+
33
+ def select_rows(sql)
34
+ # This ensures the agent doesn't hold onto a DB connection any longer than necessary
35
+ ActiveRecord::Base.connection_pool.with_connection { |c| c.select_rows(sql) }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "rails_autoscale/metrics_collector"
5
+ require "rails_autoscale/logger"
6
+
7
+ module RailsAutoscale
8
+ class JobMetricsCollector < MetricsCollector
9
+ include RailsAutoscale::Logger
10
+
11
+ # It's redundant to report these metrics from every dyno, so only report from the first one.
12
+ def self.collect?(config)
13
+ config.dyno.num == 1 && adapter_config.enabled
14
+ end
15
+
16
+ def self.adapter_name
17
+ @_adapter_name ||= adapter_identifier.to_s.capitalize.gsub(/(?:_)(.)/i) { $1.upcase }
18
+ end
19
+
20
+ def self.adapter_identifier
21
+ adapter_config.identifier
22
+ end
23
+
24
+ def self.adapter_config
25
+ raise "Implement `self.adapter_config` in individual job metrics collectors."
26
+ end
27
+
28
+ def initialize
29
+ super
30
+
31
+ log_msg = +"#{self.class.adapter_name} enabled"
32
+ log_msg << " with busy job tracking support" if track_busy_jobs?
33
+ logger.info log_msg
34
+ end
35
+
36
+ # Track the known queues so we can continue reporting on queues that don't
37
+ # have enqueued jobs at the time of reporting.
38
+ def queues
39
+ @queues ||= Set.new([])
40
+ end
41
+
42
+ def queues=(new_queues)
43
+ @queues = filter_queues(new_queues)
44
+ end
45
+
46
+ def clear_queues
47
+ @queues = nil
48
+ end
49
+
50
+ private
51
+
52
+ def adapter_config
53
+ self.class.adapter_config
54
+ end
55
+
56
+ def filter_queues(queues)
57
+ configured_queues = adapter_config.queues
58
+
59
+ if configured_queues.empty?
60
+ configured_filter = adapter_config.queue_filter
61
+
62
+ if configured_filter.respond_to?(:call)
63
+ queues = queues.select { |queue| configured_filter.call(queue) }
64
+ end
65
+ else
66
+ queues = configured_queues
67
+ end
68
+
69
+ queues = filter_max_queues(queues)
70
+
71
+ Set.new(queues)
72
+ end
73
+
74
+ # Collect up to the configured `max_queues`, skipping the rest.
75
+ # We sort queues by name length before making the cut-off, as a simple heuristic to keep the shorter ones
76
+ # and possibly ignore the longer ones, which are more likely to be dynamically generated for example.
77
+ def filter_max_queues(queues_to_collect)
78
+ queues_size = queues_to_collect.size
79
+ max_queues = adapter_config.max_queues
80
+
81
+ if queues_size > max_queues
82
+ logger.warn "#{self.class.adapter_name} metrics reporting only #{max_queues} queues max, skipping the rest (#{queues_size - max_queues})"
83
+ queues_to_collect.sort_by(&:length).first(max_queues)
84
+ else
85
+ queues_to_collect
86
+ end
87
+ end
88
+
89
+ # Sample log line for each collection, assuming `sidekiq` as the adapter identifier:
90
+ # `sidekiq-qt.default=10ms sidekiq-qd.default=3 sidekiq-busy.default=1`
91
+ def log_collection(metrics)
92
+ return if metrics.empty?
93
+
94
+ identifier = self.class.adapter_identifier
95
+ messages = metrics.map { |metric|
96
+ "#{identifier}-#{metric.identifier}.#{metric.queue_name}=#{metric.value}#{"ms" if metric.identifier == :qt}"
97
+ }
98
+ logger.debug messages.join(" ")
99
+ end
100
+
101
+ def track_busy_jobs?
102
+ adapter_config.track_busy_jobs
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_autoscale/config"
4
+ require "logger"
5
+
6
+ module RailsAutoscale
7
+ module Logger
8
+ def logger
9
+ @logger ||= LoggerProxy.new(Config.instance.logger, Config.instance.log_level)
10
+ end
11
+ end
12
+
13
+ class LoggerProxy < Struct.new(:logger, :log_level)
14
+ %w[ERROR WARN INFO DEBUG].each do |severity_name|
15
+ severity_level = ::Logger::Severity.const_get(severity_name)
16
+
17
+ define_method(severity_name.downcase) do |*messages|
18
+ if log_level.nil?
19
+ logger.add(severity_level) { tag(messages) }
20
+ elsif severity_level >= log_level
21
+ if severity_level >= logger.level
22
+ logger.add(severity_level) { tag(messages) }
23
+ else
24
+ logger.add(logger.level) { tag(messages, tag_level: severity_name) }
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def tag(msgs, tag_level: nil)
33
+ tag = +"[#{Config.instance.log_tag}]"
34
+ tag << " [#{tag_level}]" if tag_level
35
+ msgs.map { |msg| "#{tag} #{msg}" }.join("\n")
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAutoscale
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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAutoscale
4
+ class MetricsCollector
5
+ def self.collect?(config)
6
+ true
7
+ end
8
+
9
+ def collect
10
+ []
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "rails_autoscale/metric"
5
+ require "rails_autoscale/report"
6
+
7
+ module RailsAutoscale
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
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAutoscale
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
+ dyno: config.dyno,
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
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "rails_autoscale/config"
5
+ require "rails_autoscale/logger"
6
+ require "rails_autoscale/adapter_api"
7
+ require "rails_autoscale/job_metrics_collector"
8
+ require "rails_autoscale/web_metrics_collector"
9
+
10
+ module RailsAutoscale
11
+ class Reporter
12
+ include Singleton
13
+ include Logger
14
+
15
+ def self.start(config = Config.instance, adapters = RailsAutoscale.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
23
+ logger.info "Reporter not started: RAILS_AUTOSCALE_URL is not set"
24
+ return
25
+ end
26
+
27
+ enabled_adapters = adapters.select { |adapter|
28
+ adapter.metrics_collector.nil? || adapter.metrics_collector.collect?(config)
29
+ }
30
+ metrics_collectors_classes = enabled_adapters.map(&:metrics_collector)
31
+ metrics_collectors_classes.compact!
32
+
33
+ if metrics_collectors_classes.empty?
34
+ logger.info "Reporter not started: no metrics need to be collected on this dyno"
35
+ return
36
+ end
37
+
38
+ adapters_msg = enabled_adapters.map(&:identifier).join(", ")
39
+ logger.info "Reporter starting, will report every #{config.report_interval_seconds} seconds or so. Adapters: [#{adapters_msg}]"
40
+
41
+ metrics_collectors = metrics_collectors_classes.map(&:new)
42
+
43
+ run_loop(config, metrics_collectors)
44
+ end
45
+
46
+ def run_loop(config, metrics_collectors)
47
+ @_thread = Thread.new do
48
+ loop do
49
+ run_metrics_collection(config, metrics_collectors)
50
+
51
+ # Stagger reporting to spread out reports from many processes
52
+ multiplier = 1 - (rand / 4) # between 0.75 and 1.0
53
+ sleep config.report_interval_seconds * multiplier
54
+ end
55
+ end
56
+ end
57
+
58
+ def run_metrics_collection(config, metrics_collectors)
59
+ metrics = metrics_collectors.flat_map do |metric_collector|
60
+ log_exceptions { metric_collector.collect } || []
61
+ end
62
+
63
+ log_exceptions { report(config, metrics) }
64
+ end
65
+
66
+ def started?
67
+ @pid == Process.pid
68
+ end
69
+
70
+ def stop!
71
+ @_thread&.terminate
72
+ @_thread = nil
73
+ @pid = nil
74
+ end
75
+
76
+ private
77
+
78
+ def report(config, metrics)
79
+ report = Report.new(RailsAutoscale.adapters, config, metrics)
80
+ logger.info "Reporting #{report.metrics.size} metrics"
81
+ result = AdapterApi.new(config).report_metrics(report.as_json)
82
+
83
+ case result
84
+ when AdapterApi::SuccessResponse
85
+ logger.debug "Reported successfully"
86
+ when AdapterApi::FailureResponse
87
+ logger.error "Reporter failed: #{result.failure_message}"
88
+ end
89
+ end
90
+
91
+ def log_exceptions
92
+ yield
93
+ rescue => ex
94
+ # Log the exception but swallow it to keep the thread running and processing reports.
95
+ # Note: Exceptions in threads other than the main thread will fail silently and terminate it.
96
+ # https://ruby-doc.org/core-3.1.0/Thread.html#class-Thread-label-Exception+handling
97
+ logger.error "Reporter error: #{ex.inspect}", *ex.backtrace
98
+ nil
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAutoscale
4
+ class RequestMetrics
5
+ attr_reader :request_id, :size, :network_time
6
+
7
+ def initialize(env, config = Config.instance)
8
+ @config = config
9
+ @request_id = env["HTTP_X_REQUEST_ID"]
10
+ @size = env["rack.input"].respond_to?(:size) ? env["rack.input"].size : 0
11
+ @network_time = env["puma.request_body_wait"].to_i
12
+ @request_start_header = env["HTTP_X_REQUEST_START"]
13
+ end
14
+
15
+ def ignore?
16
+ @config.ignore_large_requests? && @size > @config.max_request_size_bytes
17
+ end
18
+
19
+ def started_at
20
+ if @request_start_header
21
+ # Heroku sets the header as an integer, measured in milliseconds.
22
+ # If nginx is involved, it might be in seconds with fractional milliseconds,
23
+ # and it might be preceeded by "t=". We can all cases by removing non-digits
24
+ # and treating as milliseconds.
25
+ Time.at(@request_start_header.gsub(/\D/, "").to_i / 1000.0)
26
+ end
27
+ end
28
+
29
+ def queue_time(now = Time.now)
30
+ return if started_at.nil?
31
+
32
+ queue_time = ((now - started_at) * 1000).to_i
33
+
34
+ # Subtract the time Puma spent waiting on the request body, i.e. the network time. It's irrelevant to
35
+ # capacity-related queue time. Without this, slow clients and large request payloads will skew queue time.
36
+ queue_time -= network_time
37
+
38
+ # Safeguard against negative queue times (should not happen in practice)
39
+ queue_time > 0 ? queue_time : 0
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_autoscale/metrics_store"
4
+ require "rails_autoscale/reporter"
5
+ require "rails_autoscale/logger"
6
+ require "rails_autoscale/request_metrics"
7
+
8
+ module RailsAutoscale
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["RailsAutoscale.queue_time"] = queue_time
31
+ store.push :qt, queue_time
32
+
33
+ unless network_time.zero?
34
+ env["RailsAutoscale.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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAutoscale
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_autoscale/metrics_collector"
4
+ require "rails_autoscale/metrics_store"
5
+
6
+ module RailsAutoscale
7
+ class WebMetricsCollector < MetricsCollector
8
+ def self.collect?(config)
9
+ config.dyno.name == "web"
10
+ end
11
+
12
+ def collect
13
+ MetricsStore.instance.flush
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,27 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "rails_autoscale/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "rails-autoscale-core"
7
+ spec.version = RailsAutoscale::VERSION
8
+ spec.authors = ["Adam McCrea", "Carlos Antonio da Silva"]
9
+ spec.email = ["adam@adamlogic.com"]
10
+
11
+ spec.summary = "This gem works with the Rails Autoscale Heroku add-on to automatically scale your web and worker dynos."
12
+ spec.homepage = "https://railsautoscale.com"
13
+ spec.license = "MIT"
14
+
15
+ spec.metadata = {
16
+ "homepage_uri" => "https://railsautoscale.com",
17
+ "bug_tracker_uri" => "https://github.com/rails-autoscale/rails-autoscale-gems/issues",
18
+ "documentation_uri" => "https://railsautoscale.com/docs",
19
+ "changelog_uri" => "https://github.com/rails-autoscale/rails-autoscale-gems/blob/main/CHANGELOG.md",
20
+ "source_code_uri" => "https://github.com/rails-autoscale/rails-autoscale-gems"
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
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-autoscale-core
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Adam McCrea
8
+ - Carlos Antonio da Silva
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2022-09-07 00:00:00.000000000 Z
13
+ dependencies: []
14
+ description:
15
+ email:
16
+ - adam@adamlogic.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - Gemfile
22
+ - Gemfile.lock
23
+ - Rakefile
24
+ - lib/rails-autoscale-core.rb
25
+ - lib/rails_autoscale/adapter_api.rb
26
+ - lib/rails_autoscale/config.rb
27
+ - lib/rails_autoscale/job_metrics_collector.rb
28
+ - lib/rails_autoscale/job_metrics_collector/active_record_helper.rb
29
+ - lib/rails_autoscale/logger.rb
30
+ - lib/rails_autoscale/metric.rb
31
+ - lib/rails_autoscale/metrics_collector.rb
32
+ - lib/rails_autoscale/metrics_store.rb
33
+ - lib/rails_autoscale/report.rb
34
+ - lib/rails_autoscale/reporter.rb
35
+ - lib/rails_autoscale/request_metrics.rb
36
+ - lib/rails_autoscale/request_middleware.rb
37
+ - lib/rails_autoscale/version.rb
38
+ - lib/rails_autoscale/web_metrics_collector.rb
39
+ - rails-autoscale-core.gemspec
40
+ homepage: https://railsautoscale.com
41
+ licenses:
42
+ - MIT
43
+ metadata:
44
+ homepage_uri: https://railsautoscale.com
45
+ bug_tracker_uri: https://github.com/rails-autoscale/rails-autoscale-gems/issues
46
+ documentation_uri: https://railsautoscale.com/docs
47
+ changelog_uri: https://github.com/rails-autoscale/rails-autoscale-gems/blob/main/CHANGELOG.md
48
+ source_code_uri: https://github.com/rails-autoscale/rails-autoscale-gems
49
+ post_install_message:
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: 2.6.0
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ requirements: []
64
+ rubygems_version: 3.2.32
65
+ signing_key:
66
+ specification_version: 4
67
+ summary: This gem works with the Rails Autoscale Heroku add-on to automatically scale
68
+ your web and worker dynos.
69
+ test_files: []