lepus 0.0.1.beta2 → 0.1.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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/linter.yml +21 -0
  3. data/.github/workflows/specs.yml +93 -13
  4. data/.gitignore +2 -0
  5. data/.rubocop.yml +10 -0
  6. data/.tool-versions +1 -1
  7. data/Gemfile +7 -0
  8. data/Gemfile.lock +36 -9
  9. data/Makefile +19 -0
  10. data/README.md +562 -7
  11. data/bin/setup +5 -2
  12. data/config.ru +14 -0
  13. data/docker-compose.yml +5 -3
  14. data/docs/README.md +80 -0
  15. data/docs/cli.md +108 -0
  16. data/docs/configuration.md +171 -0
  17. data/docs/consumers.md +168 -0
  18. data/docs/getting-started.md +136 -0
  19. data/docs/images/lepus-web.png +0 -0
  20. data/docs/middleware.md +240 -0
  21. data/docs/producers.md +173 -0
  22. data/docs/prometheus.md +112 -0
  23. data/docs/rails.md +161 -0
  24. data/docs/supervisor.md +112 -0
  25. data/docs/testing.md +141 -0
  26. data/docs/web.md +85 -0
  27. data/examples/grafana-dashboard.json +450 -0
  28. data/gemfiles/Gemfile.rails-5.2 +7 -0
  29. data/gemfiles/{rails52.gemfile.lock → Gemfile.rails-5.2.lock} +102 -69
  30. data/gemfiles/Gemfile.rails-6.1 +7 -0
  31. data/gemfiles/{rails61.gemfile.lock → Gemfile.rails-6.1.lock} +113 -79
  32. data/gemfiles/{rails52.gemfile → Gemfile.rails-7.2} +1 -1
  33. data/gemfiles/Gemfile.rails-7.2.lock +321 -0
  34. data/gemfiles/{rails61.gemfile → Gemfile.rails-8.0} +1 -1
  35. data/gemfiles/Gemfile.rails-8.0.lock +322 -0
  36. data/lepus.gemspec +7 -1
  37. data/lib/lepus/cli.rb +35 -4
  38. data/lib/lepus/configuration.rb +107 -0
  39. data/lib/lepus/connection_pool.rb +135 -0
  40. data/lib/lepus/consumer.rb +59 -41
  41. data/lib/lepus/consumers/config.rb +183 -0
  42. data/lib/lepus/consumers/handler.rb +56 -0
  43. data/lib/lepus/consumers/middleware_chain.rb +22 -0
  44. data/lib/lepus/consumers/middlewares/exception_logger.rb +27 -0
  45. data/lib/lepus/consumers/middlewares/honeybadger.rb +33 -0
  46. data/lib/lepus/consumers/middlewares/json.rb +37 -0
  47. data/lib/lepus/consumers/middlewares/max_retry.rb +83 -0
  48. data/lib/lepus/consumers/middlewares/unique.rb +65 -0
  49. data/lib/lepus/consumers/stats.rb +70 -0
  50. data/lib/lepus/consumers/stats_registry.rb +29 -0
  51. data/lib/lepus/consumers/worker.rb +141 -0
  52. data/lib/lepus/consumers/worker_factory.rb +124 -0
  53. data/lib/lepus/consumers.rb +6 -0
  54. data/lib/lepus/message/delivery_info.rb +72 -0
  55. data/lib/lepus/message/metadata.rb +99 -0
  56. data/lib/lepus/message.rb +88 -5
  57. data/lib/lepus/middleware_chain.rb +83 -0
  58. data/lib/lepus/primitive/hash.rb +29 -0
  59. data/lib/lepus/process.rb +24 -24
  60. data/lib/lepus/process_registry/backend.rb +49 -0
  61. data/lib/lepus/process_registry/file_backend.rb +108 -0
  62. data/lib/lepus/process_registry/message_builder.rb +72 -0
  63. data/lib/lepus/process_registry/rabbitmq_backend.rb +153 -0
  64. data/lib/lepus/process_registry.rb +56 -23
  65. data/lib/lepus/processes/base.rb +0 -5
  66. data/lib/lepus/processes/callbacks.rb +3 -0
  67. data/lib/lepus/processes/interruptible.rb +4 -8
  68. data/lib/lepus/processes/procline.rb +1 -1
  69. data/lib/lepus/processes/registrable.rb +1 -1
  70. data/lib/lepus/processes/runnable.rb +1 -1
  71. data/lib/lepus/processes.rb +15 -0
  72. data/lib/lepus/producer.rb +141 -30
  73. data/lib/lepus/producers/config.rb +46 -0
  74. data/lib/lepus/producers/definition.rb +48 -0
  75. data/lib/lepus/producers/hooks.rb +170 -0
  76. data/lib/lepus/producers/middleware_chain.rb +22 -0
  77. data/lib/lepus/producers/middlewares/correlation_id.rb +37 -0
  78. data/lib/lepus/producers/middlewares/header.rb +47 -0
  79. data/lib/lepus/producers/middlewares/instrumentation.rb +30 -0
  80. data/lib/lepus/producers/middlewares/json.rb +47 -0
  81. data/lib/lepus/producers/middlewares/unique.rb +67 -0
  82. data/lib/lepus/producers.rb +7 -0
  83. data/lib/lepus/prometheus/collector.rb +149 -0
  84. data/lib/lepus/prometheus/instrumentation.rb +168 -0
  85. data/lib/lepus/prometheus.rb +48 -0
  86. data/lib/lepus/publisher.rb +67 -0
  87. data/lib/lepus/supervisor/children_pipes.rb +25 -0
  88. data/lib/lepus/supervisor/lifecycle_hooks.rb +50 -0
  89. data/lib/lepus/supervisor/pidfiled.rb +1 -1
  90. data/lib/lepus/supervisor/registry_cleaner.rb +22 -0
  91. data/lib/lepus/supervisor.rb +129 -25
  92. data/lib/lepus/testing/exchange.rb +95 -0
  93. data/lib/lepus/testing/message_builder.rb +177 -0
  94. data/lib/lepus/testing/rspec_matchers.rb +258 -0
  95. data/lib/lepus/testing.rb +210 -0
  96. data/lib/lepus/unique.rb +18 -0
  97. data/lib/lepus/version.rb +1 -1
  98. data/lib/lepus/web/aggregator.rb +154 -0
  99. data/lib/lepus/web/api.rb +132 -0
  100. data/lib/lepus/web/app.rb +37 -0
  101. data/lib/lepus/web/management_api.rb +192 -0
  102. data/lib/lepus/web/respond_with.rb +28 -0
  103. data/lib/lepus/web.rb +238 -0
  104. data/lib/lepus.rb +39 -28
  105. data/test_offline.html +189 -0
  106. data/web/assets/css/styles.css +635 -0
  107. data/web/assets/js/app.js +6 -0
  108. data/web/assets/js/bootstrap.js +20 -0
  109. data/web/assets/js/controllers/connection_controller.js +44 -0
  110. data/web/assets/js/controllers/dashboard_controller.js +499 -0
  111. data/web/assets/js/controllers/queue_controller.js +17 -0
  112. data/web/assets/js/controllers/theme_controller.js +31 -0
  113. data/web/assets/js/offline-manager.js +233 -0
  114. data/web/assets/js/service-worker-manager.js +65 -0
  115. data/web/index.html +159 -0
  116. data/web/sw.js +144 -0
  117. metadata +177 -18
  118. data/lib/lepus/consumer_config.rb +0 -149
  119. data/lib/lepus/consumer_wrapper.rb +0 -46
  120. data/lib/lepus/lifecycle_hooks.rb +0 -49
  121. data/lib/lepus/middlewares/honeybadger.rb +0 -23
  122. data/lib/lepus/middlewares/json.rb +0 -35
  123. data/lib/lepus/middlewares/max_retry.rb +0 -57
  124. data/lib/lepus/processes/consumer.rb +0 -113
  125. data/lib/lepus/supervisor/config.rb +0 -45
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Producers
5
+ # Definition class for producer-specific settings
6
+ class Definition
7
+ attr_reader :publish_options
8
+
9
+ def initialize(options = {})
10
+ opts = Lepus::Primitive::Hash.new(options).deep_symbolize_keys
11
+
12
+ # Handle exchange configuration
13
+ exchange_config = opts.delete(:exchange) || {}
14
+ @exchange_options = Lepus::Publisher::DEFAULT_EXCHANGE_OPTIONS.merge(declaration_config(exchange_config))
15
+
16
+ # Handle default publish options
17
+ @publish_options = Lepus::Publisher::DEFAULT_PUBLISH_OPTIONS.merge(opts.delete(:publish) || {})
18
+
19
+ # Store any remaining options for future use
20
+ @options = opts
21
+ end
22
+
23
+ def exchange_name
24
+ @exchange_options[:name] || raise(InvalidProducerConfigError, "Exchange name is required")
25
+ end
26
+
27
+ def exchange_options
28
+ @exchange_options.reject { |k, v| k == :name }
29
+ end
30
+
31
+ private
32
+
33
+ # Normalizes a declaration config (for exchanges) into a configuration Hash.
34
+ #
35
+ # If the given `value` is a String, convert it to a Hash with the key `:name` and the value.
36
+ # If the given `value` is a Hash, leave it as is.
37
+ def declaration_config(value)
38
+ case value
39
+ when Hash then value
40
+ when String then {name: value}
41
+ when Symbol then {name: value.to_s}
42
+ when NilClass then {}
43
+ when TrueClass then {}
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Producers
5
+ module Hooks
6
+ KEY = :lepus_producers
7
+
8
+ def self.reset!
9
+ Thread.current[KEY] = nil
10
+ end
11
+
12
+ # Global enable publishing callbacks. If no producer/exchange is specified, all producers will be enabled.
13
+ # @param targets [Array<Lepus::Producer, String, Symbol>] Producer classes, exchange names, or both
14
+ # @return [void]
15
+ def enable!(*targets)
16
+ if targets.empty?
17
+ # Enable all producers
18
+ all_producers.each { |producer| repo[:producers][producer] = true }
19
+ else
20
+ targets.each do |target|
21
+ case target
22
+ when Class
23
+ ensure_producer_class(target)
24
+ repo[:producers][target] = true
25
+ when String, Symbol
26
+ exchange_name = target.to_s
27
+ repo[:exchanges][exchange_name] = true
28
+ else
29
+ raise ArgumentError, "Invalid producer or exchange name: #{target.inspect}"
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ # Global disable publishing callbacks. If no producer/exchange is specified, all producers will be disabled.
36
+ # @param targets [Array<Lepus::Producer, String, Symbol>] Producer classes, exchange names, or both
37
+ # @return [void]
38
+ def disable!(*targets)
39
+ if targets.empty?
40
+ # Disable all producers
41
+ all_producers.each { |producer| repo[:producers][producer] = false }
42
+ else
43
+ targets.each do |target|
44
+ case target
45
+ when Class
46
+ ensure_producer_class(target)
47
+ repo[:producers][target] = false
48
+ when String, Symbol
49
+ exchange_name = target.to_s
50
+ repo[:exchanges][exchange_name] = false
51
+ else
52
+ raise ArgumentError, "Invalid producer or exchange name: #{target.inspect}"
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ # Check if the given producer is enabled for publishing. If no producer is specified, all producers will be checked.
59
+ #
60
+ # @param producers [Array<Lepus::Producer>]
61
+ # @return [Boolean]
62
+ def disabled?(*producers)
63
+ if producers.empty?
64
+ all_producers.all? { |producer| !producer_enabled?(producer) }
65
+ else
66
+ producers.all? { |producer| !producer_enabled?(producer) }
67
+ end
68
+ end
69
+
70
+ # Check if the given producer is enabled for publishing. If no producer is specified, all producers will be checked.
71
+ #
72
+ # @param producers [Array<Lepus::Producer>]
73
+ # @return [Boolean]
74
+ def enabled?(*producers)
75
+ if producers.empty?
76
+ all_producers.all? { |producer| producer_enabled?(producer) }
77
+ else
78
+ producers.all? { |producer| producer_enabled?(producer) }
79
+ end
80
+ end
81
+
82
+ # Check if the given exchange is enabled for publishing.
83
+ #
84
+ # @param exchange_name [String] The exchange name to check
85
+ # @return [Boolean]
86
+ def exchange_enabled?(exchange_name)
87
+ # Check if exchange is explicitly configured
88
+ if repo[:exchanges].key?(exchange_name)
89
+ return repo[:exchanges][exchange_name]
90
+ end
91
+
92
+ # Find all producers that use this exchange
93
+ matching_producers = all_producers.select do |producer|
94
+ producer.definition&.exchange_name == exchange_name
95
+ end
96
+
97
+ # If no producers use this exchange, consider it enabled by default
98
+ return true if matching_producers.empty?
99
+
100
+ # Check if all matching producers are enabled
101
+ matching_producers.all? { |producer| producer_enabled?(producer) }
102
+ end
103
+
104
+ # Disable publishing callbacks execution for the block execution.
105
+ # Example:
106
+ # Lepus::Producers.without_publishing { User.create! }
107
+ # Lepus::Producers.without_publishing(UsersIndex, "exchange_name") { User.create! }
108
+ def without_publishing(*targets)
109
+ state_before_disable = deep_copy_repo
110
+ disable!(*targets)
111
+
112
+ yield
113
+ ensure
114
+ restore_repo(state_before_disable)
115
+ end
116
+
117
+ # Enable the publishing callbacks execution for the block execution.
118
+ # Example:
119
+ # Lepus::Producers.with_publishing { User.create! }
120
+ # Lepus::Producers.with_publishing(UsersIndex, "exchange_name") { User.create! }
121
+ def with_publishing(*targets)
122
+ state_before_enable = deep_copy_repo
123
+ enable!(*targets)
124
+
125
+ yield
126
+ ensure
127
+ restore_repo(state_before_enable)
128
+ end
129
+
130
+ private
131
+
132
+ def all_producers
133
+ Lepus::Producer.descendants.reject(&:abstract_class?)
134
+ end
135
+
136
+ # Check if a specific producer is enabled
137
+ def producer_enabled?(producer)
138
+ repo[:producers][producer]
139
+ end
140
+
141
+ def ensure_producer_class(value)
142
+ (value <= Lepus::Producer) ? value : raise(ArgumentError, "Invalid producer class: #{value.inspect}")
143
+ end
144
+
145
+ def deep_copy_repo
146
+ {
147
+ producers: repo[:producers].dup,
148
+ exchanges: repo[:exchanges].dup
149
+ }
150
+ end
151
+
152
+ def restore_repo(saved_state)
153
+ Thread.current[KEY] = saved_state
154
+ end
155
+
156
+ # Data Structure:
157
+ #
158
+ # {
159
+ # producers: { <Lepus::Producer class> => <true|false>, ... },
160
+ # exchanges: { <String> => <true|false>, ... }
161
+ # }
162
+ def repo
163
+ Thread.current[KEY] ||= {
164
+ producers: all_producers.map { |k| [k, true] }.to_h,
165
+ exchanges: {}
166
+ }
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Producers
5
+ # Manages middleware registration and execution for producers.
6
+ # Middlewares can modify the message (payload, headers, routing_key, etc.)
7
+ # before it is published to RabbitMQ.
8
+ class MiddlewareChain < Lepus::MiddlewareChain
9
+ private
10
+
11
+ def load_middleware(name, opts)
12
+ require_relative "middlewares/#{name}"
13
+ class_name = Primitive::String.new(name.to_s).classify
14
+ class_name = "JSON" if class_name == "Json"
15
+ klass = Lepus::Producers::Middlewares.const_get(class_name)
16
+ klass.new(**opts)
17
+ rescue LoadError, NameError => e
18
+ raise ArgumentError, "Producer middleware '#{name}' not found: #{e.message}"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Producers
5
+ module Middlewares
6
+ # A middleware that auto-generates a correlation_id if missing.
7
+ class CorrelationId < Lepus::Middleware
8
+ # @param opts [Hash] The options for the middleware.
9
+ # @option opts [Proc, nil] :generator A custom generator proc.
10
+ # Defaults to SecureRandom.uuid.
11
+ def initialize(**opts)
12
+ super
13
+ @generator = opts.fetch(:generator, -> { SecureRandom.uuid })
14
+ end
15
+
16
+ def call(message, app)
17
+ if message.metadata&.correlation_id.nil? || message.metadata.correlation_id.to_s.empty?
18
+ correlation_id = generator.respond_to?(:call) ? generator.call : generator.to_s
19
+ new_metadata = update_metadata(message.metadata, correlation_id: correlation_id)
20
+ message = message.mutate(metadata: new_metadata)
21
+ end
22
+
23
+ app.call(message)
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :generator
29
+
30
+ def update_metadata(metadata, **attrs)
31
+ current = metadata&.to_h || {}
32
+ Message::Metadata.new(**current.merge(attrs))
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Producers
5
+ module Middlewares
6
+ # A middleware that adds default headers to messages.
7
+ # Headers can be static values or dynamic procs.
8
+ class Header < Lepus::Middleware
9
+ # @param opts [Hash] The options for the middleware.
10
+ # @option opts [Hash] :defaults ({}) Default headers to add.
11
+ # Values can be Procs that will be called with the message.
12
+ def initialize(**opts)
13
+ super
14
+ @defaults = opts.fetch(:defaults, {})
15
+ end
16
+
17
+ def call(message, app)
18
+ new_headers = resolve_headers(message)
19
+ existing_headers = message.metadata&.headers || {}
20
+ merged_headers = new_headers.merge(existing_headers)
21
+
22
+ new_metadata = update_metadata(message.metadata, headers: merged_headers)
23
+ app.call(message.mutate(metadata: new_metadata))
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :defaults
29
+
30
+ def resolve_headers(message)
31
+ defaults.each_with_object({}) do |(key, value), headers|
32
+ headers[key.to_s] = if value.respond_to?(:call)
33
+ (value.arity == 0) ? value.call : value.call(message)
34
+ else
35
+ value
36
+ end
37
+ end
38
+ end
39
+
40
+ def update_metadata(metadata, **attrs)
41
+ current = metadata&.to_h || {}
42
+ Message::Metadata.new(**current.merge(attrs))
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Producers
5
+ module Middlewares
6
+ # A middleware that emits instrumentation events via Lepus.instrument.
7
+ class Instrumentation < Lepus::Middleware
8
+ # @param opts [Hash] The options for the middleware.
9
+ # @option opts [String] :event_name ("publish") The event name suffix.
10
+ def initialize(**opts)
11
+ super
12
+ @event_name = opts.fetch(:event_name, "publish")
13
+ end
14
+
15
+ def call(message, app)
16
+ exchange = message.delivery_info&.exchange
17
+ routing_key = message.delivery_info&.routing_key
18
+
19
+ Lepus.instrument(event_name, exchange: exchange, routing_key: routing_key, message: message) do
20
+ app.call(message)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :event_name
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "multi_json"
4
+
5
+ module Lepus
6
+ module Producers
7
+ module Middlewares
8
+ # A middleware that serializes Hash payloads to JSON and sets the content_type.
9
+ class JSON < Lepus::Middleware
10
+ # @param opts [Hash] The options for the middleware.
11
+ # @option opts [Boolean] :only_hash (true) Only serialize Hash payloads.
12
+ def initialize(**opts)
13
+ super
14
+ @only_hash = opts.fetch(:only_hash, true)
15
+ end
16
+
17
+ def call(message, app)
18
+ payload = message.payload
19
+
20
+ if should_serialize?(payload)
21
+ serialized_payload = MultiJson.dump(payload)
22
+ new_metadata = update_metadata(message.metadata, content_type: "application/json")
23
+ message = message.mutate(payload: serialized_payload, metadata: new_metadata)
24
+ end
25
+
26
+ app.call(message)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :only_hash
32
+
33
+ def should_serialize?(payload)
34
+ return false if payload.is_a?(String)
35
+ return payload.is_a?(Hash) if only_hash
36
+
37
+ true
38
+ end
39
+
40
+ def update_metadata(metadata, **attrs)
41
+ current = metadata&.to_h || {}
42
+ Message::Metadata.new(**current.merge(attrs))
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Producers
5
+ module Middlewares
6
+ # A middleware that prevents duplicate messages from being published
7
+ # using the de-dupe gem for Redis-based distributed locking.
8
+ #
9
+ # When a lock is acquired, the middleware adds x-dedupe-lock-key and
10
+ # x-dedupe-lock-id headers to the message so that a consumer middleware
11
+ # can release the lock after successful processing.
12
+ #
13
+ # @example
14
+ # class StoryCreatedProducer < Lepus::Producer
15
+ # configure(exchange: "story_created")
16
+ # use :unique, lock_key: "story", lock_id: ->(msg) { msg.payload[:story_id].to_s }
17
+ # end
18
+ class Unique < Lepus::Middleware
19
+ HEADER_LOCK_KEY = "x-dedupe-lock-key"
20
+ HEADER_LOCK_ID = "x-dedupe-lock-id"
21
+ HEADER_LOCK_TTL = "x-dedupe-lock-ttl"
22
+
23
+ # @param lock_key [String] Shared lock namespace (e.g., "story").
24
+ # @param lock_id [Proc] Callable that extracts a unique ID from the message.
25
+ # @param ttl [Integer, nil] Lock TTL in seconds. Defaults to DeDupe configuration.
26
+ def initialize(lock_key:, lock_id:, ttl: nil)
27
+ super()
28
+ @lock_key = lock_key
29
+ @lock_id = lock_id
30
+ @ttl = ttl
31
+ end
32
+
33
+ def call(message, app)
34
+ id = @lock_id.call(message)
35
+ return app.call(message) if id.nil?
36
+
37
+ lock_opts = {}
38
+ lock_opts[:ttl] = @ttl if @ttl
39
+ lock = DeDupe::Lock.new(lock_key: @lock_key, lock_id: id.to_s, **lock_opts)
40
+ return unless lock.acquire
41
+
42
+ message = add_dedupe_headers(message, id)
43
+ app.call(message)
44
+ end
45
+
46
+ private
47
+
48
+ def add_dedupe_headers(message, lock_id)
49
+ existing_headers = message.metadata&.headers || {}
50
+ new_headers = existing_headers.merge(
51
+ HEADER_LOCK_KEY => @lock_key,
52
+ HEADER_LOCK_ID => lock_id.to_s
53
+ )
54
+ new_headers[HEADER_LOCK_TTL] = @ttl if @ttl
55
+
56
+ new_metadata = update_metadata(message.metadata, headers: new_headers)
57
+ message.mutate(metadata: new_metadata)
58
+ end
59
+
60
+ def update_metadata(metadata, **attrs)
61
+ current = metadata&.to_h || {}
62
+ Message::Metadata.new(**current.merge(attrs))
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Producers
5
+ extend Hooks
6
+ end
7
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file is intended to be loaded by the prometheus_exporter server:
4
+ #
5
+ # prometheus_exporter -a lepus/prometheus/collector
6
+ #
7
+ # It intentionally avoids requiring the rest of the Lepus gem so it can
8
+ # run standalone inside the exporter process. When Lepus is loaded in the
9
+ # same process, latency buckets fall back to Lepus.config.prometheus_buckets.
10
+
11
+ require "prometheus_exporter"
12
+ require "prometheus_exporter/server"
13
+
14
+ module Lepus
15
+ module Prometheus
16
+ class Collector < ::PrometheusExporter::Server::TypeCollector
17
+ DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10].freeze
18
+
19
+ def initialize
20
+ @metrics = {}
21
+ end
22
+
23
+ def type
24
+ "lepus"
25
+ end
26
+
27
+ def metrics
28
+ @metrics.values
29
+ end
30
+
31
+ def collect(obj)
32
+ case obj["metric"]
33
+ when "delivery" then collect_delivery(obj)
34
+ when "publish" then collect_publish(obj)
35
+ when "process" then collect_process(obj)
36
+ when "process_info" then collect_process_info(obj)
37
+ when "queue" then collect_queue(obj)
38
+ when "queue_poll" then collect_queue_poll(obj)
39
+ when "queue_poll_error" then collect_queue_poll_error(obj)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def collect_delivery(obj)
46
+ labels = {
47
+ consumer: obj["consumer"],
48
+ queue: obj["queue"],
49
+ result: obj["result"],
50
+ error: obj["error"].to_s
51
+ }
52
+ counter(
53
+ "lepus_messages_processed_total",
54
+ "Total messages delivered to Lepus consumers, labeled by result and error class."
55
+ ).observe(1, labels)
56
+
57
+ duration = obj["duration"].to_f
58
+ histogram(
59
+ "lepus_delivery_duration_seconds",
60
+ "Time spent processing a single Lepus message.",
61
+ buckets
62
+ ).observe(duration, consumer: obj["consumer"], queue: obj["queue"])
63
+ end
64
+
65
+ def collect_publish(obj)
66
+ counter(
67
+ "lepus_messages_published_total",
68
+ "Total messages published through Lepus producers."
69
+ ).observe(1, exchange: obj["exchange"], routing_key: obj["routing_key"])
70
+
71
+ duration = obj["duration"].to_f
72
+ histogram(
73
+ "lepus_publish_duration_seconds",
74
+ "Time spent publishing a single Lepus message.",
75
+ buckets
76
+ ).observe(duration, exchange: obj["exchange"], routing_key: obj["routing_key"])
77
+ end
78
+
79
+ def collect_process(obj)
80
+ labels = {kind: obj["kind"], name: obj["name"]}
81
+ gauge(
82
+ "lepus_process_rss_memory_bytes",
83
+ "Resident-set memory of a Lepus process."
84
+ ).observe(obj["rss_memory"].to_f, labels)
85
+ end
86
+
87
+ def collect_process_info(obj)
88
+ labels = {
89
+ kind: obj["kind"],
90
+ name: obj["name"],
91
+ pid: obj["pid"].to_s,
92
+ hostname: obj["hostname"].to_s
93
+ }
94
+ gauge(
95
+ "lepus_process_info",
96
+ "Info gauge for a Lepus process (always 1); use for joining pid/hostname labels."
97
+ ).observe(1, labels)
98
+ end
99
+
100
+ def collect_queue(obj)
101
+ labels = {name: obj["name"]}
102
+ gauge("lepus_queue_messages", "Total messages in a RabbitMQ queue.")
103
+ .observe(obj["messages"].to_f, labels)
104
+ gauge("lepus_queue_messages_ready", "Messages ready for delivery in a RabbitMQ queue.")
105
+ .observe(obj["messages_ready"].to_f, labels)
106
+ gauge("lepus_queue_messages_unacknowledged", "Unacknowledged messages in a RabbitMQ queue.")
107
+ .observe(obj["messages_unacknowledged"].to_f, labels)
108
+ gauge("lepus_queue_consumers", "Number of consumers attached to a RabbitMQ queue.")
109
+ .observe(obj["consumers"].to_f, labels)
110
+ gauge("lepus_queue_memory_bytes", "Memory used by a RabbitMQ queue.")
111
+ .observe(obj["memory"].to_f, labels)
112
+ end
113
+
114
+ def collect_queue_poll(obj)
115
+ gauge(
116
+ "lepus_queue_poll_last_success_timestamp_seconds",
117
+ "Unix timestamp of the last successful RabbitMQ management API poll."
118
+ ).observe(obj["timestamp"].to_f, {})
119
+ end
120
+
121
+ def collect_queue_poll_error(obj)
122
+ counter(
123
+ "lepus_queue_poll_errors_total",
124
+ "Total errors encountered while polling the RabbitMQ management API, labeled by error class."
125
+ ).observe(1, error: obj["error"].to_s)
126
+ end
127
+
128
+ def counter(name, help)
129
+ @metrics[name] ||= ::PrometheusExporter::Metric::Counter.new(name, help)
130
+ end
131
+
132
+ def gauge(name, help)
133
+ @metrics[name] ||= ::PrometheusExporter::Metric::Gauge.new(name, help)
134
+ end
135
+
136
+ def histogram(name, help, buckets)
137
+ @metrics[name] ||= ::PrometheusExporter::Metric::Histogram.new(name, help, buckets: buckets)
138
+ end
139
+
140
+ def buckets
141
+ if defined?(::Lepus) && ::Lepus.respond_to?(:config) && ::Lepus.config.respond_to?(:prometheus_buckets)
142
+ ::Lepus.config.prometheus_buckets
143
+ else
144
+ DEFAULT_BUCKETS
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end