lepus 0.0.1.rc2 → 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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -1
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +12 -1
  5. data/README.md +179 -0
  6. data/config.ru +14 -0
  7. data/docs/README.md +80 -0
  8. data/docs/cli.md +108 -0
  9. data/docs/configuration.md +171 -0
  10. data/docs/consumers.md +168 -0
  11. data/docs/getting-started.md +136 -0
  12. data/docs/images/lepus-web.png +0 -0
  13. data/docs/middleware.md +240 -0
  14. data/docs/producers.md +173 -0
  15. data/docs/prometheus.md +112 -0
  16. data/docs/rails.md +161 -0
  17. data/docs/supervisor.md +112 -0
  18. data/docs/testing.md +141 -0
  19. data/docs/web.md +85 -0
  20. data/examples/grafana-dashboard.json +450 -0
  21. data/gemfiles/Gemfile.rails-5.2 +1 -0
  22. data/gemfiles/Gemfile.rails-5.2.lock +59 -46
  23. data/gemfiles/Gemfile.rails-6.1 +1 -0
  24. data/gemfiles/Gemfile.rails-6.1.lock +72 -58
  25. data/gemfiles/Gemfile.rails-7.2.lock +8 -1
  26. data/gemfiles/Gemfile.rails-8.0.lock +8 -1
  27. data/lepus.gemspec +5 -1
  28. data/lib/lepus/cli.rb +24 -0
  29. data/lib/lepus/configuration.rb +42 -0
  30. data/lib/lepus/consumer.rb +12 -0
  31. data/lib/lepus/consumers/handler.rb +3 -1
  32. data/lib/lepus/consumers/stats.rb +70 -0
  33. data/lib/lepus/consumers/stats_registry.rb +29 -0
  34. data/lib/lepus/consumers/worker.rb +7 -6
  35. data/lib/lepus/process.rb +4 -4
  36. data/lib/lepus/process_registry/backend.rb +49 -0
  37. data/lib/lepus/process_registry/file_backend.rb +108 -0
  38. data/lib/lepus/process_registry/message_builder.rb +72 -0
  39. data/lib/lepus/process_registry/rabbitmq_backend.rb +153 -0
  40. data/lib/lepus/process_registry.rb +28 -67
  41. data/lib/lepus/prometheus/collector.rb +149 -0
  42. data/lib/lepus/prometheus/instrumentation.rb +168 -0
  43. data/lib/lepus/prometheus.rb +48 -0
  44. data/lib/lepus/publisher.rb +3 -1
  45. data/lib/lepus/supervisor.rb +9 -2
  46. data/lib/lepus/version.rb +1 -1
  47. data/lib/lepus/web/aggregator.rb +154 -0
  48. data/lib/lepus/web/api.rb +132 -0
  49. data/lib/lepus/web/app.rb +37 -0
  50. data/lib/lepus/web/management_api.rb +192 -0
  51. data/lib/lepus/web/respond_with.rb +28 -0
  52. data/lib/lepus/web.rb +238 -0
  53. data/lib/lepus.rb +5 -0
  54. data/test_offline.html +189 -0
  55. data/web/assets/css/styles.css +635 -0
  56. data/web/assets/js/app.js +6 -0
  57. data/web/assets/js/bootstrap.js +20 -0
  58. data/web/assets/js/controllers/connection_controller.js +44 -0
  59. data/web/assets/js/controllers/dashboard_controller.js +499 -0
  60. data/web/assets/js/controllers/queue_controller.js +17 -0
  61. data/web/assets/js/controllers/theme_controller.js +31 -0
  62. data/web/assets/js/offline-manager.js +233 -0
  63. data/web/assets/js/service-worker-manager.js +65 -0
  64. data/web/index.html +159 -0
  65. data/web/sw.js +144 -0
  66. metadata +103 -5
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Lepus
6
+ module Prometheus
7
+ # Hooks that run inside each Lepus process and forward metrics to the
8
+ # prometheus_exporter server via Lepus::Prometheus.emit.
9
+ module Instrumentation
10
+ # Tracks per-delivery outcomes and latency. Always emits a metric, even
11
+ # when the underlying consumer raises, so error rates are observable.
12
+ module HandlerExtensions
13
+ def process_delivery(delivery_info, metadata, payload)
14
+ start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
15
+ result = nil
16
+ error_class = nil
17
+ begin
18
+ result = super
19
+ rescue => e
20
+ error_class = e.class.name
21
+ raise
22
+ ensure
23
+ Lepus::Prometheus.emit(
24
+ :delivery,
25
+ consumer: @consumer_class.name,
26
+ queue: queue_name_for_metric,
27
+ result: error_class ? "error" : result.to_s,
28
+ error: error_class.to_s,
29
+ duration: ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start
30
+ )
31
+ end
32
+ result
33
+ end
34
+
35
+ private
36
+
37
+ def queue_name_for_metric
38
+ q = queue
39
+ q.respond_to?(:name) ? q.name : q.to_s
40
+ rescue
41
+ ""
42
+ end
43
+ end
44
+
45
+ # Emits process-level gauges on each heartbeat tick. rss_memory is keyed
46
+ # on (kind, name) only to avoid leaking a new series per pid; pid and
47
+ # hostname stay available on the `lepus_process_info` info gauge.
48
+ module WorkerExtensions
49
+ def heartbeat
50
+ super
51
+ ensure
52
+ Lepus::Prometheus.emit(
53
+ :process,
54
+ kind: kind.to_s,
55
+ name: name,
56
+ rss_memory: safe_rss_memory_bytes
57
+ )
58
+ Lepus::Prometheus.emit(
59
+ :process_info,
60
+ kind: kind.to_s,
61
+ name: name,
62
+ pid: pid.to_s,
63
+ hostname: safe_hostname
64
+ )
65
+ end
66
+
67
+ private
68
+
69
+ def safe_rss_memory_bytes
70
+ Lepus::Processes::MEMORY_GRABBER.call(pid) * 1024
71
+ rescue
72
+ 0
73
+ end
74
+
75
+ def safe_hostname
76
+ Socket.gethostname
77
+ rescue
78
+ ""
79
+ end
80
+ end
81
+
82
+ # Periodic poller that turns RabbitMQ queue stats into gauge events.
83
+ # Runs in a single thread inside whichever process enabled it.
84
+ class QueuePoller
85
+ @thread = nil
86
+ @mutex = Mutex.new
87
+
88
+ class << self
89
+ def start(interval:, api:)
90
+ @mutex.synchronize do
91
+ stop_locked
92
+ @thread = Thread.new { run_loop(interval, api) }
93
+ end
94
+ end
95
+
96
+ def stop
97
+ @mutex.synchronize { stop_locked }
98
+ end
99
+
100
+ def running?
101
+ @mutex.synchronize { !@thread.nil? && @thread.alive? }
102
+ end
103
+
104
+ private
105
+
106
+ def stop_locked
107
+ @thread&.kill
108
+ @thread = nil
109
+ end
110
+
111
+ def run_loop(interval, api)
112
+ loop do
113
+ poll_once(api)
114
+ sleep interval
115
+ end
116
+ end
117
+
118
+ def poll_once(api)
119
+ api.queues.each do |q|
120
+ Lepus::Prometheus.emit(
121
+ :queue,
122
+ name: q[:name],
123
+ messages: q[:messages].to_i,
124
+ messages_ready: q[:messages_ready].to_i,
125
+ messages_unacknowledged: q[:messages_unacknowledged].to_i,
126
+ consumers: q[:consumers].to_i,
127
+ memory: q[:memory].to_i
128
+ )
129
+ end
130
+ Lepus::Prometheus.emit(:queue_poll, timestamp: Time.now.to_f)
131
+ rescue => e
132
+ Lepus::Prometheus.emit(:queue_poll_error, error: e.class.name)
133
+ Lepus.logger.warn("[Lepus::Prometheus] queue poll failed: #{e.message}")
134
+ end
135
+ end
136
+ end
137
+
138
+ class << self
139
+ def install!
140
+ return if @installed
141
+
142
+ Lepus::Consumers::Handler.prepend(HandlerExtensions)
143
+ Lepus::Consumers::Worker.prepend(WorkerExtensions)
144
+ subscribe_publish_events
145
+ @installed = true
146
+ end
147
+
148
+ private
149
+
150
+ def subscribe_publish_events
151
+ return unless defined?(ActiveSupport::Notifications)
152
+
153
+ ActiveSupport::Notifications.subscribe(/\Apublish\.lepus\z/) do |*args|
154
+ event = ActiveSupport::Notifications::Event.new(*args)
155
+ Lepus::Prometheus.emit(
156
+ :publish,
157
+ exchange: event.payload[:exchange].to_s,
158
+ routing_key: event.payload[:routing_key].to_s,
159
+ duration: event.duration / 1000.0
160
+ )
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+
168
+ Lepus::Prometheus::Instrumentation.install!
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prometheus_exporter"
4
+ require "prometheus_exporter/client"
5
+
6
+ require_relative "prometheus/instrumentation"
7
+
8
+ module Lepus
9
+ # Optional integration with the prometheus_exporter gem.
10
+ # Require "lepus/prometheus" in your Lepus process to start shipping
11
+ # metrics to a running prometheus_exporter server via the default client.
12
+ #
13
+ # On the prometheus_exporter server side, load the companion collector:
14
+ # prometheus_exporter -a lepus/prometheus/collector
15
+ module Prometheus
16
+ DEFAULT_QUEUE_POLL_INTERVAL = 30
17
+
18
+ class << self
19
+ attr_writer :client
20
+
21
+ def client
22
+ @client ||= ::PrometheusExporter::Client.default
23
+ end
24
+
25
+ # Emit an opaque metric payload to the exporter server.
26
+ # Silently swallows transport errors so instrumentation cannot
27
+ # break the caller; non-transport bugs surface as debug logs.
28
+ def emit(metric, **data)
29
+ client.send_json(type: "lepus", metric: metric.to_s, **data)
30
+ rescue => e
31
+ Lepus.logger.debug { "[Lepus::Prometheus] emit(#{metric}) failed: #{e.class}: #{e.message}" }
32
+ nil
33
+ end
34
+
35
+ # Start polling the RabbitMQ management API for queue-level gauges.
36
+ # Safe to call once per process. Requires "lepus/web/management_api".
37
+ def watch_queues(interval: DEFAULT_QUEUE_POLL_INTERVAL, api: nil)
38
+ require "lepus/web/management_api"
39
+ api ||= Lepus::Web::ManagementAPI.new
40
+ Instrumentation::QueuePoller.start(interval: interval, api: api)
41
+ end
42
+
43
+ def stop_watching_queues
44
+ Instrumentation::QueuePoller.stop
45
+ end
46
+ end
47
+ end
48
+ end
@@ -42,7 +42,9 @@ module Lepus
42
42
 
43
43
  payload, opts = prepare_message(message, **options)
44
44
  exchange = channel.exchange(exchange_name, exchange_options)
45
- exchange.publish(payload, opts)
45
+ Lepus.instrument(:publish, exchange: exchange_name, routing_key: opts[:routing_key]) do
46
+ exchange.publish(payload, opts)
47
+ end
46
48
  end
47
49
 
48
50
  private
@@ -32,6 +32,8 @@ module Lepus
32
32
  @configured_processes = {}
33
33
 
34
34
  super
35
+
36
+ @name ||= hostname
35
37
  end
36
38
 
37
39
  def start
@@ -81,8 +83,6 @@ module Lepus
81
83
  end
82
84
 
83
85
  def boot
84
- ProcessRegistry.start
85
-
86
86
  Lepus.instrument(:start_process, process: self) do
87
87
  if require_file
88
88
  Kernel.require(require_file)
@@ -96,6 +96,13 @@ module Lepus
96
96
  end
97
97
  end
98
98
 
99
+ # Start the registry *after* the host app is loaded. Mounting
100
+ # `Lepus::Web` in `routes.rb` (or any late `require "lepus/web"`) flips
101
+ # the default backend to :rabbitmq; starting the registry earlier
102
+ # would lock in the FileBackend and the later flip would point the
103
+ # registry at a backend that was never started.
104
+ ProcessRegistry.start
105
+
99
106
  setup_consumers
100
107
  check_bunny_connection
101
108
 
data/lib/lepus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lepus
4
- VERSION = "0.0.1.rc2"
4
+ VERSION = "0.1.0"
5
5
  end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "concurrent"
5
+
6
+ module Lepus
7
+ module Web
8
+ # Aggregates process heartbeats from RabbitMQ into in-memory state.
9
+ # Subscribes to the lepus.heartbeat fanout exchange and maintains
10
+ # a thread-safe cache of all processes across connected Lepus apps.
11
+ class Aggregator
12
+ HEARTBEAT_EXCHANGE = ProcessRegistry::RabbitmqBackend::HEARTBEAT_EXCHANGE
13
+
14
+ attr_reader :stale_threshold
15
+
16
+ def initialize(stale_threshold: nil)
17
+ @stale_threshold = stale_threshold || Lepus.config.process_alive_threshold
18
+ @processes = Concurrent::Map.new
19
+ @connection = nil
20
+ @channel = nil
21
+ @consumer = nil
22
+ @running = Concurrent::AtomicBoolean.new(false)
23
+ @pruning_task = nil
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ def start
28
+ return if @running.true?
29
+
30
+ @mutex.synchronize do
31
+ return if @running.true?
32
+
33
+ @running.make_true
34
+ setup_subscription
35
+ start_pruning_task
36
+ end
37
+ rescue => e
38
+ Lepus.logger.error("[Web::Aggregator] Failed to start: #{e.message}")
39
+ @running.make_false
40
+ end
41
+
42
+ def stop
43
+ @mutex.synchronize do
44
+ @running.make_false
45
+ @pruning_task&.shutdown
46
+ @consumer&.cancel
47
+ @channel&.close if @channel&.open?
48
+ @connection&.close if @connection&.open?
49
+ end
50
+ rescue => e
51
+ Lepus.logger.warn("[Web::Aggregator] Error during shutdown: #{e.message}")
52
+ ensure
53
+ @pruning_task = nil
54
+ @consumer = nil
55
+ @channel = nil
56
+ @connection = nil
57
+ end
58
+
59
+ def running?
60
+ @running.true?
61
+ end
62
+
63
+ def all_processes
64
+ prune_stale_entries
65
+ @processes.values.map { |data| data[:process] }
66
+ end
67
+
68
+ def find(id)
69
+ @processes[id]&.dig(:process)
70
+ end
71
+
72
+ def count
73
+ @processes.size
74
+ end
75
+
76
+ def clear
77
+ @processes.clear
78
+ end
79
+
80
+ private
81
+
82
+ def setup_subscription
83
+ @connection = Lepus.config.create_connection(suffix: "(web-aggregator)")
84
+ @channel = @connection.create_channel
85
+
86
+ exchange = @channel.fanout(
87
+ HEARTBEAT_EXCHANGE,
88
+ durable: false,
89
+ auto_delete: false
90
+ )
91
+
92
+ queue = @channel.queue("", exclusive: true, auto_delete: true)
93
+ queue.bind(exchange)
94
+
95
+ @consumer = queue.subscribe do |_delivery_info, _metadata, payload|
96
+ handle_message(payload)
97
+ end
98
+ end
99
+
100
+ def handle_message(payload)
101
+ data = JSON.parse(payload, symbolize_names: true)
102
+
103
+ case data[:type]
104
+ when "heartbeat"
105
+ process_heartbeat(data)
106
+ when "deregister"
107
+ process_deregistration(data)
108
+ end
109
+ rescue => e
110
+ Lepus.logger.warn("[Web::Aggregator] Failed to handle message: #{e.message}")
111
+ end
112
+
113
+ def process_heartbeat(data)
114
+ process_data = data[:process]
115
+ return unless process_data && process_data[:id]
116
+
117
+ metrics = data[:metrics] || {}
118
+
119
+ flat_process = process_data.merge(
120
+ rss_memory: metrics[:rss_memory] || 0,
121
+ connections: metrics[:connections] || 0,
122
+ consumers: metrics[:consumers] || []
123
+ )
124
+
125
+ @processes[process_data[:id]] = {
126
+ process: flat_process,
127
+ received_at: Time.now
128
+ }
129
+ end
130
+
131
+ def process_deregistration(data)
132
+ process_id = data[:process_id]
133
+ @processes.delete(process_id) if process_id
134
+ end
135
+
136
+ def start_pruning_task
137
+ @pruning_task = Concurrent::TimerTask.new(
138
+ execution_interval: [@stale_threshold / 2, 30].min
139
+ ) do
140
+ prune_stale_entries
141
+ end
142
+
143
+ @pruning_task.execute
144
+ end
145
+
146
+ def prune_stale_entries
147
+ threshold = Time.now - @stale_threshold
148
+ @processes.each do |id, data|
149
+ @processes.delete(id) if data[:received_at] < threshold
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Web
5
+ class API
6
+ def initialize(aggregator: nil, management_api: nil)
7
+ @aggregator = aggregator
8
+ @management_api = management_api
9
+ end
10
+
11
+ def call(env)
12
+ req = Rack::Request.new(env)
13
+ case req.path_info
14
+ when "/health"
15
+ Web::RespondWith.json(template: :health)
16
+ when "/processes"
17
+ processes_data
18
+ when "/queues"
19
+ queues_data
20
+ when "/connections"
21
+ connections_data
22
+ when "/exchanges"
23
+ exchanges_data
24
+ else
25
+ Web::RespondWith.json(template: :not_found)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def aggregator
32
+ @aggregator || Web.aggregator
33
+ end
34
+
35
+ def management_api
36
+ @management_api || Web.management_api
37
+ end
38
+
39
+ def processes_data
40
+ if aggregator&.running?
41
+ payload = aggregator.all_processes
42
+ Web::RespondWith.json(template: :ok, body: payload)
43
+ else
44
+ Web::RespondWith.json(template: :ok, body: [])
45
+ end
46
+ end
47
+
48
+ def queues_data
49
+ if management_api
50
+ raw_queues = management_api.queues
51
+ payload = annotate_queues_with_apps(raw_queues)
52
+ Web::RespondWith.json(template: :ok, body: payload)
53
+ else
54
+ Web::RespondWith.json(template: :ok, body: [])
55
+ end
56
+ rescue => e
57
+ Lepus.logger.warn("[Web::API] Failed to fetch queues: #{e.message}")
58
+ Web::RespondWith.json(template: :ok, body: [])
59
+ end
60
+
61
+ def connections_data
62
+ if management_api
63
+ payload = management_api.connections
64
+ Web::RespondWith.json(template: :ok, body: payload)
65
+ else
66
+ Web::RespondWith.json(template: :ok, body: [])
67
+ end
68
+ rescue => e
69
+ Lepus.logger.warn("[Web::API] Failed to fetch connections: #{e.message}")
70
+ Web::RespondWith.json(template: :ok, body: [])
71
+ end
72
+
73
+ def exchanges_data
74
+ if management_api
75
+ raw_exchanges = management_api.exchanges
76
+ payload = filter_exchanges(raw_exchanges)
77
+ Web::RespondWith.json(template: :ok, body: payload)
78
+ else
79
+ Web::RespondWith.json(template: :ok, body: [])
80
+ end
81
+ rescue => e
82
+ Lepus.logger.warn("[Web::API] Failed to fetch exchanges: #{e.message}")
83
+ Web::RespondWith.json(template: :ok, body: [])
84
+ end
85
+
86
+ def annotate_queues_with_apps(queues)
87
+ return queues unless aggregator&.running?
88
+
89
+ queue_app_map = build_queue_app_map
90
+ return queues if queue_app_map.empty?
91
+
92
+ queues.map do |queue|
93
+ app = queue_app_map[queue[:name]]
94
+ app ? queue.merge(application: app) : queue
95
+ end
96
+ end
97
+
98
+ def filter_exchanges(exchanges)
99
+ return exchanges if Lepus.config.web_show_all_exchanges
100
+ return exchanges unless aggregator&.running?
101
+
102
+ known_exchanges = build_known_exchange_names
103
+ return exchanges if known_exchanges.empty?
104
+
105
+ exchanges.select { |e| known_exchanges.include?(e[:name]) }
106
+ end
107
+
108
+ def build_queue_app_map
109
+ map = {}
110
+ aggregator.all_processes.each do |process|
111
+ app_name = process[:application]
112
+ next unless app_name
113
+
114
+ (process[:consumers] || []).each do |consumer|
115
+ map[consumer[:queue]] = app_name if consumer[:queue]
116
+ end
117
+ end
118
+ map
119
+ end
120
+
121
+ def build_known_exchange_names
122
+ names = Set.new
123
+ aggregator.all_processes.each do |process|
124
+ (process[:consumers] || []).each do |consumer|
125
+ names << consumer[:exchange] if consumer[:exchange]
126
+ end
127
+ end
128
+ names
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Web
5
+ class App
6
+ def self.build
7
+ root = Web.assets_path
8
+
9
+ Rack::Builder.new do
10
+ use Rack::Static,
11
+ urls: ["/assets", "/sw.js"],
12
+ root: root.to_s
13
+
14
+ map "/api" do
15
+ run Lepus::Web::API.new
16
+ end
17
+
18
+ run lambda { |env|
19
+ req = Rack::Request.new(env)
20
+ path = req.path_info
21
+
22
+ if path == "/" || path == "/index.html"
23
+ [200, {"content-type" => "text/html"}, [Web.render_index(env)]]
24
+ else
25
+ file_path = root.join(path.sub(%r{^/}, ""))
26
+ if File.file?(file_path)
27
+ [200, {"content-type" => Web.mime_for(file_path)}, [File.binread(file_path)]]
28
+ else
29
+ [200, {"content-type" => "text/html"}, [Web.render_index(env)]]
30
+ end
31
+ end
32
+ }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end