prometheus_exporter 0.5.3 → 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +42 -0
  3. data/.gitignore +2 -0
  4. data/.rubocop.yml +5 -0
  5. data/Appraisals +10 -0
  6. data/CHANGELOG +28 -0
  7. data/README.md +138 -16
  8. data/bin/prometheus_exporter +29 -2
  9. data/gemfiles/.bundle/config +2 -0
  10. data/gemfiles/ar_60.gemfile +5 -0
  11. data/gemfiles/ar_61.gemfile +7 -0
  12. data/lib/prometheus_exporter.rb +1 -0
  13. data/lib/prometheus_exporter/client.rb +39 -8
  14. data/lib/prometheus_exporter/instrumentation.rb +1 -0
  15. data/lib/prometheus_exporter/instrumentation/active_record.rb +19 -12
  16. data/lib/prometheus_exporter/instrumentation/delayed_job.rb +3 -2
  17. data/lib/prometheus_exporter/instrumentation/method_profiler.rb +2 -1
  18. data/lib/prometheus_exporter/instrumentation/process.rb +1 -1
  19. data/lib/prometheus_exporter/instrumentation/puma.rb +17 -5
  20. data/lib/prometheus_exporter/instrumentation/resque.rb +40 -0
  21. data/lib/prometheus_exporter/instrumentation/sidekiq.rb +44 -3
  22. data/lib/prometheus_exporter/instrumentation/sidekiq_queue.rb +13 -2
  23. data/lib/prometheus_exporter/instrumentation/unicorn.rb +1 -1
  24. data/lib/prometheus_exporter/middleware.rb +40 -17
  25. data/lib/prometheus_exporter/server.rb +1 -0
  26. data/lib/prometheus_exporter/server/active_record_collector.rb +2 -1
  27. data/lib/prometheus_exporter/server/collector.rb +1 -0
  28. data/lib/prometheus_exporter/server/delayed_job_collector.rb +9 -8
  29. data/lib/prometheus_exporter/server/puma_collector.rb +9 -1
  30. data/lib/prometheus_exporter/server/resque_collector.rb +54 -0
  31. data/lib/prometheus_exporter/server/runner.rb +13 -3
  32. data/lib/prometheus_exporter/server/sidekiq_collector.rb +2 -2
  33. data/lib/prometheus_exporter/server/web_collector.rb +2 -5
  34. data/lib/prometheus_exporter/server/web_server.rb +33 -23
  35. data/lib/prometheus_exporter/version.rb +1 -1
  36. data/prometheus_exporter.gemspec +7 -3
  37. metadata +59 -11
  38. data/.travis.yml +0 -12
@@ -0,0 +1,5 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec path: "../"
@@ -0,0 +1,7 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 6.1.0"
6
+
7
+ gemspec path: "../"
@@ -11,6 +11,7 @@ module PrometheusExporter
11
11
  DEFAULT_PREFIX = 'ruby_'
12
12
  DEFAULT_LABEL = {}
13
13
  DEFAULT_TIMEOUT = 2
14
+ DEFAULT_REALM = 'Prometheus Exporter'
14
15
 
15
16
  class OjCompat
16
17
  def self.parse(obj)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'socket'
4
4
  require 'thread'
5
+ require 'logger'
5
6
 
6
7
  module PrometheusExporter
7
8
  class Client
@@ -53,24 +54,32 @@ module PrometheusExporter
53
54
  MAX_SOCKET_AGE = 25
54
55
  MAX_QUEUE_SIZE = 10_000
55
56
 
57
+ attr_reader :logger
58
+
56
59
  def initialize(
57
60
  host: ENV.fetch('PROMETHEUS_EXPORTER_HOST', 'localhost'),
58
61
  port: ENV.fetch('PROMETHEUS_EXPORTER_PORT', PrometheusExporter::DEFAULT_PORT),
59
62
  max_queue_size: nil,
60
63
  thread_sleep: 0.5,
61
64
  json_serializer: nil,
62
- custom_labels: nil
65
+ custom_labels: nil,
66
+ logger: Logger.new(STDERR),
67
+ log_level: Logger::WARN
63
68
  )
69
+ @logger = logger
70
+ @logger.level = log_level
64
71
  @metrics = []
65
72
 
66
73
  @queue = Queue.new
74
+
67
75
  @socket = nil
68
76
  @socket_started = nil
77
+ @socket_pid = nil
69
78
 
70
79
  max_queue_size ||= MAX_QUEUE_SIZE
71
80
  max_queue_size = max_queue_size.to_i
72
81
 
73
- if max_queue_size.to_i <= 0
82
+ if max_queue_size <= 0
74
83
  raise ArgumentError, "max_queue_size must be larger than 0"
75
84
  end
76
85
 
@@ -107,14 +116,23 @@ module PrometheusExporter
107
116
  end
108
117
 
109
118
  def send_json(obj)
110
- payload = @custom_labels.nil? ? obj : obj.merge(custom_labels: @custom_labels)
119
+ payload =
120
+ if @custom_labels
121
+ if obj[:custom_labels]
122
+ obj.merge(custom_labels: @custom_labels.merge(obj[:custom_labels]))
123
+ else
124
+ obj.merge(custom_labels: @custom_labels)
125
+ end
126
+ else
127
+ obj
128
+ end
111
129
  send(@json_serializer.dump(payload))
112
130
  end
113
131
 
114
132
  def send(str)
115
133
  @queue << str
116
134
  if @queue.length > @max_queue_size
117
- STDERR.puts "Prometheus Exporter client is dropping message cause queue is full"
135
+ logger.warn "Prometheus Exporter client is dropping message cause queue is full"
118
136
  @queue.pop
119
137
  end
120
138
 
@@ -132,7 +150,7 @@ module PrometheusExporter
132
150
  @socket.write(message)
133
151
  @socket.write("\r\n")
134
152
  rescue => e
135
- STDERR.puts "Prometheus Exporter is dropping a message: #{e}"
153
+ logger.warn "Prometheus Exporter is dropping a message: #{e}"
136
154
  @socket = nil
137
155
  raise
138
156
  end
@@ -157,7 +175,7 @@ module PrometheusExporter
157
175
  close_socket_if_old!
158
176
  process_queue
159
177
  rescue => e
160
- STDERR.puts "Prometheus Exporter, failed to send message #{e}"
178
+ logger.error "Prometheus Exporter, failed to send message #{e}"
161
179
  end
162
180
 
163
181
  def ensure_worker_thread!
@@ -173,11 +191,14 @@ module PrometheusExporter
173
191
  end
174
192
  end
175
193
  end
194
+ rescue ThreadError => e
195
+ raise unless e.message =~ /can't alloc thread/
196
+ logger.error "Prometheus Exporter, failed to send message ThreadError #{e}"
176
197
  end
177
198
 
178
199
  def close_socket!
179
200
  begin
180
- if @socket
201
+ if @socket && !@socket.closed?
181
202
  @socket.write("0\r\n")
182
203
  @socket.write("\r\n")
183
204
  @socket.flush
@@ -191,12 +212,20 @@ module PrometheusExporter
191
212
  end
192
213
 
193
214
  def close_socket_if_old!
194
- if @socket && ((@socket_started + MAX_SOCKET_AGE) < Time.now.to_f)
215
+ if @socket_pid == Process.pid && @socket && @socket_started && ((@socket_started + MAX_SOCKET_AGE) < Time.now.to_f)
195
216
  close_socket!
196
217
  end
197
218
  end
198
219
 
199
220
  def ensure_socket!
221
+ # if process was forked socket may be owned by parent
222
+ # leave it alone and reset
223
+ if @socket_pid != Process.pid
224
+ @socket = nil
225
+ @socket_started = nil
226
+ @socket_pid = nil
227
+ end
228
+
200
229
  close_socket_if_old!
201
230
  if !@socket
202
231
  @socket = TCPSocket.new @host, @port
@@ -207,12 +236,14 @@ module PrometheusExporter
207
236
  @socket.write("Content-Type: application/octet-stream\r\n")
208
237
  @socket.write("\r\n")
209
238
  @socket_started = Time.now.to_f
239
+ @socket_pid = Process.pid
210
240
  end
211
241
 
212
242
  nil
213
243
  rescue
214
244
  @socket = nil
215
245
  @socket_started = nil
246
+ @socket_pid = nil
216
247
  raise
217
248
  end
218
249
 
@@ -11,3 +11,4 @@ require_relative "instrumentation/hutch"
11
11
  require_relative "instrumentation/unicorn"
12
12
  require_relative "instrumentation/active_record"
13
13
  require_relative "instrumentation/shoryuken"
14
+ require_relative "instrumentation/resque"
@@ -7,9 +7,11 @@ module PrometheusExporter::Instrumentation
7
7
 
8
8
  def self.start(client: nil, frequency: 30, custom_labels: {}, config_labels: [])
9
9
 
10
- # Not all rails versions support coonection pool stats
10
+ client ||= PrometheusExporter::Client.default
11
+
12
+ # Not all rails versions support connection pool stats
11
13
  unless ::ActiveRecord::Base.connection_pool.respond_to?(:stat)
12
- STDERR.puts("ActiveRecord connection pool stats not supported in your rails version")
14
+ client.logger.error("ActiveRecord connection pool stats not supported in your rails version")
13
15
  return
14
16
  end
15
17
 
@@ -18,8 +20,6 @@ module PrometheusExporter::Instrumentation
18
20
 
19
21
  active_record_collector = new(custom_labels, config_labels)
20
22
 
21
- client ||= PrometheusExporter::Client.default
22
-
23
23
  stop if @thread
24
24
 
25
25
  @thread = Thread.new do
@@ -28,7 +28,7 @@ module PrometheusExporter::Instrumentation
28
28
  metrics = active_record_collector.collect
29
29
  metrics.each { |metric| client.send_json metric }
30
30
  rescue => e
31
- STDERR.puts("Prometheus Exporter Failed To Collect Process Stats #{e}")
31
+ client.logger.error("Prometheus Exporter Failed To Collect Process Stats #{e}")
32
32
  ensure
33
33
  sleep frequency
34
34
  end
@@ -67,21 +67,28 @@ module PrometheusExporter::Instrumentation
67
67
  ObjectSpace.each_object(::ActiveRecord::ConnectionAdapters::ConnectionPool) do |pool|
68
68
  next if pool.connections.nil?
69
69
 
70
- labels_from_config = pool.spec.config
71
- .select { |k, v| @config_labels.include? k }
72
- .map { |k, v| [k.to_s.prepend("dbconfig_"), v] }
73
-
74
- labels = @metric_labels.merge(pool_name: pool.spec.name).merge(Hash[labels_from_config])
75
-
76
70
  metric = {
77
71
  pid: pid,
78
72
  type: "active_record",
79
73
  hostname: ::PrometheusExporter.hostname,
80
- metric_labels: labels
74
+ metric_labels: labels(pool)
81
75
  }
82
76
  metric.merge!(pool.stat)
83
77
  metrics << metric
84
78
  end
85
79
  end
80
+
81
+ private
82
+
83
+ def labels(pool)
84
+ if ::ActiveRecord.version < Gem::Version.new("6.1.0.rc1")
85
+ @metric_labels.merge(pool_name: pool.spec.name).merge(pool.spec.config
86
+ .select { |k, v| @config_labels.include? k }
87
+ .map { |k, v| [k.to_s.dup.prepend("dbconfig_"), v] }.to_h)
88
+ else
89
+ @metric_labels.merge(pool_name: pool.db_config.name).merge(
90
+ @config_labels.each_with_object({}) { |l, acc| acc["dbconfig_#{l}"] = pool.db_config.public_send(l) })
91
+ end
92
+ end
86
93
  end
87
94
  end
@@ -13,8 +13,8 @@ module PrometheusExporter::Instrumentation
13
13
  callbacks do |lifecycle|
14
14
  lifecycle.around(:invoke_job) do |job, *args, &block|
15
15
  max_attempts = Delayed::Worker.max_attempts
16
- enqueued_count = Delayed::Job.count
17
- pending_count = Delayed::Job.where(attempts: 0, locked_at: nil).count
16
+ enqueued_count = Delayed::Job.where(queue: job.queue).count
17
+ pending_count = Delayed::Job.where(attempts: 0, locked_at: nil, queue: job.queue).count
18
18
  instrumenter.call(job, max_attempts, enqueued_count, pending_count, *args, &block)
19
19
  end
20
20
  end
@@ -41,6 +41,7 @@ module PrometheusExporter::Instrumentation
41
41
  @client.send_json(
42
42
  type: "delayed_job",
43
43
  name: job.handler.to_s.match(JOB_CLASS_REGEXP).to_a[1].to_s,
44
+ queue_name: job.queue,
44
45
  success: success,
45
46
  duration: duration,
46
47
  attempts: attempts,
@@ -5,6 +5,7 @@ module PrometheusExporter::Instrumentation; end
5
5
 
6
6
  class PrometheusExporter::Instrumentation::MethodProfiler
7
7
  def self.patch(klass, methods, name)
8
+ patch_source_line = __LINE__ + 3
8
9
  patches = methods.map do |method_name|
9
10
  <<~RUBY
10
11
  unless defined?(#{method_name}__mp_unpatched)
@@ -26,7 +27,7 @@ class PrometheusExporter::Instrumentation::MethodProfiler
26
27
  RUBY
27
28
  end.join("\n")
28
29
 
29
- klass.class_eval patches
30
+ klass.class_eval patches, __FILE__, patch_source_line
30
31
  end
31
32
 
32
33
  def self.transfer
@@ -27,7 +27,7 @@ module PrometheusExporter::Instrumentation
27
27
  metric = process_collector.collect
28
28
  client.send_json metric
29
29
  rescue => e
30
- STDERR.puts("Prometheus Exporter Failed To Collect Process Stats #{e}")
30
+ client.logger.error("Prometheus Exporter Failed To Collect Process Stats #{e}")
31
31
  ensure
32
32
  sleep frequency
33
33
  end
@@ -5,8 +5,8 @@ require "json"
5
5
  # collects stats from puma
6
6
  module PrometheusExporter::Instrumentation
7
7
  class Puma
8
- def self.start(client: nil, frequency: 30)
9
- puma_collector = new
8
+ def self.start(client: nil, frequency: 30, labels: {})
9
+ puma_collector = new(labels)
10
10
  client ||= PrometheusExporter::Client.default
11
11
  Thread.new do
12
12
  while true
@@ -14,7 +14,7 @@ module PrometheusExporter::Instrumentation
14
14
  metric = puma_collector.collect
15
15
  client.send_json metric
16
16
  rescue => e
17
- STDERR.puts("Prometheus Exporter Failed To Collect Puma Stats #{e}")
17
+ client.logger.error("Prometheus Exporter Failed To Collect Puma Stats #{e}")
18
18
  ensure
19
19
  sleep frequency
20
20
  end
@@ -22,13 +22,25 @@ module PrometheusExporter::Instrumentation
22
22
  end
23
23
  end
24
24
 
25
+ def initialize(metric_labels = {})
26
+ @metric_labels = metric_labels
27
+ end
28
+
25
29
  def collect
26
- metric = {}
27
- metric[:type] = "puma"
30
+ metric = {
31
+ pid: pid,
32
+ type: "puma",
33
+ hostname: ::PrometheusExporter.hostname,
34
+ metric_labels: @metric_labels
35
+ }
28
36
  collect_puma_stats(metric)
29
37
  metric
30
38
  end
31
39
 
40
+ def pid
41
+ @pid = ::Process.pid
42
+ end
43
+
32
44
  def collect_puma_stats(metric)
33
45
  stats = JSON.parse(::Puma.stats)
34
46
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # collects stats from resque
4
+ module PrometheusExporter::Instrumentation
5
+ class Resque
6
+ def self.start(client: nil, frequency: 30)
7
+ resque_collector = new
8
+ client ||= PrometheusExporter::Client.default
9
+ Thread.new do
10
+ while true
11
+ begin
12
+ client.send_json(resque_collector.collect)
13
+ rescue => e
14
+ client.logger.error("Prometheus Exporter Failed To Collect Resque Stats #{e}")
15
+ ensure
16
+ sleep frequency
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ def collect
23
+ metric = {}
24
+ metric[:type] = "resque"
25
+ collect_resque_stats(metric)
26
+ metric
27
+ end
28
+
29
+ def collect_resque_stats(metric)
30
+ info = ::Resque.info
31
+
32
+ metric[:processed_jobs_total] = info[:processed]
33
+ metric[:failed_jobs_total] = info[:failed]
34
+ metric[:pending_jobs_total] = info[:pending]
35
+ metric[:queues_total] = info[:queues]
36
+ metric[:worker_total] = info[:workers]
37
+ metric[:working_total] = info[:working]
38
+ end
39
+ end
40
+ end
@@ -1,6 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'yaml'
4
+
3
5
  module PrometheusExporter::Instrumentation
6
+ JOB_WRAPPER_CLASS_NAME = 'ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper'
7
+ DELAYED_CLASS_NAMES = [
8
+ 'Sidekiq::Extensions::DelayedClass',
9
+ 'Sidekiq::Extensions::DelayedModel',
10
+ 'Sidekiq::Extensions::DelayedMailer',
11
+ ]
12
+
4
13
  class Sidekiq
5
14
  def self.death_handler
6
15
  -> (job, ex) do
@@ -32,15 +41,47 @@ module PrometheusExporter::Instrumentation
32
41
  raise e
33
42
  ensure
34
43
  duration = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start
35
- class_name = worker.class.to_s == 'ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper' ?
36
- msg['wrapped'] : worker.class.to_s
37
44
  @client.send_json(
38
45
  type: "sidekiq",
39
- name: class_name,
46
+ name: get_name(worker, msg),
47
+ queue: queue,
40
48
  success: success,
41
49
  shutdown: shutdown,
42
50
  duration: duration
43
51
  )
44
52
  end
53
+
54
+ private
55
+
56
+ def get_name(worker, msg)
57
+ class_name = worker.class.to_s
58
+ if class_name == JOB_WRAPPER_CLASS_NAME
59
+ get_job_wrapper_name(msg)
60
+ elsif DELAYED_CLASS_NAMES.include?(class_name)
61
+ get_delayed_name(msg, class_name)
62
+ else
63
+ class_name
64
+ end
65
+ end
66
+
67
+ def get_job_wrapper_name(msg)
68
+ msg['wrapped']
69
+ end
70
+
71
+ def get_delayed_name(msg, class_name)
72
+ # fallback to class_name since we're relying on the internal implementation
73
+ # of the delayed extensions
74
+ # https://github.com/mperham/sidekiq/blob/master/lib/sidekiq/extensions/class_methods.rb
75
+ begin
76
+ (target, method_name, _args) = YAML.load(msg['args'].first)
77
+ if target.class == Class
78
+ "#{target.name}##{method_name}"
79
+ else
80
+ "#{target.class.name}##{method_name}"
81
+ end
82
+ rescue
83
+ class_name
84
+ end
85
+ end
45
86
  end
46
87
  end
@@ -11,7 +11,7 @@ module PrometheusExporter::Instrumentation
11
11
  begin
12
12
  client.send_json(sidekiq_queue_collector.collect)
13
13
  rescue StandardError => e
14
- STDERR.puts("Prometheus Exporter Failed To Collect Sidekiq Queue metrics #{e}")
14
+ client.logger.error("Prometheus Exporter Failed To Collect Sidekiq Queue metrics #{e}")
15
15
  ensure
16
16
  sleep frequency
17
17
  end
@@ -27,13 +27,24 @@ module PrometheusExporter::Instrumentation
27
27
  end
28
28
 
29
29
  def collect_queue_stats
30
+ hostname = Socket.gethostname
31
+ pid = ::Process.pid
32
+ ps = ::Sidekiq::ProcessSet.new
33
+
34
+ process = ps.find do |sp|
35
+ sp['hostname'] == hostname && sp['pid'] == pid
36
+ end
37
+
38
+ queues = process.nil? ? [] : process['queues']
39
+
30
40
  ::Sidekiq::Queue.all.map do |queue|
41
+ next unless queues.include? queue.name
31
42
  {
32
43
  backlog_total: queue.size,
33
44
  latency_seconds: queue.latency.to_i,
34
45
  labels: { queue: queue.name }
35
46
  }
36
- end
47
+ end.compact
37
48
  end
38
49
  end
39
50
  end