judoscale-ruby 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 26e9809db642f08b7291edeae004fb8619f04bbff3130dc6f34043be85a768df
4
+ data.tar.gz: a2ff6a031395e860a238db874212d4d82e106b8bdb91c2b3d948bb94a7c7a312
5
+ SHA512:
6
+ metadata.gz: 66423294a4021d1da21f77823d1ede3fbf4204da0ec61c9e90c33fbda2e34a21b77a7d06fcf95e711912ad38ed8d17875c74a8f5b98caefcaaac82b8d26a93e1
7
+ data.tar.gz: 948b94b9b8000c7af821a0bf3cbfe1f28317ca70a052b11d46b512b01a718d7e7816fc7e2ec4a7f2a545645401585d2baba6a8927f39448c6d11321c376800ae
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
+ judoscale-ruby (1.0.0.rc1)
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
+ judoscale-ruby!
31
+ minitest
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,27 @@
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"]
9
+ spec.email = ["adam@adamlogic.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
@@ -0,0 +1,58 @@
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
+
14
+ def initialize(config)
15
+ @config = config
16
+ end
17
+
18
+ def report_metrics(report_json)
19
+ post_json "/v1/metrics", 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,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "logger"
5
+
6
+ module Judoscale
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 :enabled, :max_queues, :queues, :queue_filter, :track_busy_jobs
26
+
27
+ def initialize
28
+ @enabled = true
29
+ @max_queues = 20
30
+ @queues = []
31
+ @queue_filter = DEFAULT_QUEUE_FILTER
32
+ @track_busy_jobs = false
33
+ end
34
+
35
+ def as_json
36
+ {
37
+ max_queues: max_queues,
38
+ queues: queues,
39
+ queue_filter: queue_filter != DEFAULT_QUEUE_FILTER,
40
+ track_busy_jobs: track_busy_jobs
41
+ }
42
+ end
43
+ end
44
+
45
+ include Singleton
46
+
47
+ @adapter_configs = {}
48
+ class << self
49
+ attr_reader :adapter_configs
50
+ end
51
+
52
+ def self.add_adapter_config(identifier, config_class)
53
+ @adapter_configs[identifier] = config_class
54
+ attr_reader identifier
55
+ end
56
+
57
+ attr_accessor :api_base_url, :report_interval_seconds, :max_request_size_bytes, :logger
58
+ attr_reader :dyno, :log_level
59
+
60
+ def initialize
61
+ reset
62
+ end
63
+
64
+ def reset
65
+ # Allow the API URL to be configured - needed for testing.
66
+ @api_base_url = ENV["JUDOSCALE_URL"]
67
+ self.dyno = ENV["DYNO"]
68
+ @max_request_size_bytes = 100_000 # ignore request payloads over 100k since they skew the queue times
69
+ @report_interval_seconds = 10
70
+ self.log_level = ENV["JUDOSCALE_LOG_LEVEL"]
71
+ @logger = ::Logger.new($stdout)
72
+
73
+ self.class.adapter_configs.each do |identifier, config_class|
74
+ instance_variable_set(:"@#{identifier}", config_class.new)
75
+ end
76
+ end
77
+
78
+ def dyno=(dyno_string)
79
+ @dyno = Dyno.new(dyno_string)
80
+ end
81
+
82
+ def log_level=(new_level)
83
+ @log_level = new_level ? ::Logger::Severity.const_get(new_level.to_s.upcase) : nil
84
+ end
85
+
86
+ def as_json
87
+ adapter_configs_json = self.class.adapter_configs.each_key.with_object({}) do |identifier, hash|
88
+ hash[identifier] = public_send(identifier).as_json
89
+ end
90
+
91
+ {
92
+ log_level: log_level,
93
+ logger: logger.class.name,
94
+ report_interval_seconds: report_interval_seconds,
95
+ max_request_size_bytes: max_request_size_bytes
96
+ }.merge!(adapter_configs_json)
97
+ end
98
+
99
+ def to_s
100
+ "#{@dyno}##{Process.pid}"
101
+ end
102
+
103
+ def ignore_large_requests?
104
+ @max_request_size_bytes
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,39 @@
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
+ 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(Judoscale::LoggerProxy::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 "judoscale/metrics_collector"
5
+ require "judoscale/logger"
6
+
7
+ module Judoscale
8
+ class JobMetricsCollector < MetricsCollector
9
+ include Judoscale::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
+ raise "Implement `self.adapter_identifier` in individual job metrics collectors."
22
+ end
23
+
24
+ def self.adapter_config
25
+ Config.instance.public_send(adapter_identifier)
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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "judoscale/config"
4
+ require "logger"
5
+
6
+ module Judoscale
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
+ TAG = "Judoscale"
15
+
16
+ %w[ERROR WARN INFO DEBUG].each do |severity_name|
17
+ severity_level = ::Logger::Severity.const_get(severity_name)
18
+
19
+ define_method(severity_name.downcase) do |*messages|
20
+ if log_level.nil?
21
+ logger.add(severity_level) { tag(messages) }
22
+ elsif severity_level >= log_level
23
+ if severity_level >= logger.level
24
+ logger.add(severity_level) { tag(messages) }
25
+ else
26
+ logger.add(logger.level) { tag(messages, tag_level: severity_name) }
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def tag(msgs, tag_level: nil)
35
+ tag = +"[#{TAG}]"
36
+ tag << " [#{tag_level}]" if tag_level
37
+ msgs.map { |msg| "#{tag} #{msg}" }.join("\n")
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,41 @@
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
@@ -0,0 +1,32 @@
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
+ dyno: config.dyno,
16
+ pid: Process.pid,
17
+ config: config.as_json,
18
+ adapters: adapters.each_with_object({}) { |adapter, hash|
19
+ hash.merge!(adapter.as_json)
20
+ },
21
+ metrics: metrics.map { |metric|
22
+ [
23
+ metric.time.to_i,
24
+ metric.value,
25
+ metric.identifier,
26
+ metric.queue_name
27
+ ]
28
+ }
29
+ }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,101 @@
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
23
+ logger.info "Reporter not started: JUDOSCALE_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(Judoscale.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 Judoscale
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 "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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judoscale
4
+ VERSION = "1.0.0.rc1"
5
+ end
@@ -0,0 +1,16 @@
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 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,37 @@
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
+ 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)
30
+ @adapters << Adapter.new(identifier, adapter_info, metrics_collector)
31
+ end
32
+
33
+ add_adapter :"judoscale-ruby", {
34
+ adapter_version: VERSION,
35
+ language_version: RUBY_VERSION
36
+ }
37
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: judoscale-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0.rc1
5
+ platform: ruby
6
+ authors:
7
+ - Adam McCrea
8
+ - Carlos Antonio da Silva
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2022-04-12 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
+ - 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
+ homepage: https://judoscale.com
41
+ licenses:
42
+ - MIT
43
+ metadata:
44
+ homepage_uri: https://judoscale.com
45
+ bug_tracker_uri: https://github.com/judoscale/judoscale-ruby/issues
46
+ documentation_uri: https://judoscale.com/docs
47
+ changelog_uri: https://github.com/judoscale/judoscale-ruby/blob/main/CHANGELOG.md
48
+ source_code_uri: https://github.com/judoscale/judoscale-ruby
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: 1.3.1
63
+ requirements: []
64
+ rubygems_version: 3.2.32
65
+ signing_key:
66
+ specification_version: 4
67
+ summary: This gem works with the Judoscale Heroku add-on to automatically scale your
68
+ web and worker dynos.
69
+ test_files: []