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,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "tmpdir"
5
+ require "base64"
6
+
7
+ module Lepus
8
+ class ProcessRegistry
9
+ # File-based backend for process registry storage.
10
+ # Stores process data in a file using Marshal serialization.
11
+ # This is the default backend for apps not using the web dashboard.
12
+ class FileBackend
13
+ include Backend
14
+
15
+ attr_reader :path
16
+
17
+ def initialize(path: nil)
18
+ @path = path
19
+ end
20
+
21
+ def start
22
+ @path ||= Pathname.new(Dir.tmpdir).join("lepus_process_registry.store")
23
+ end
24
+
25
+ def stop
26
+ path.delete if path&.exist?
27
+ end
28
+
29
+ def add(process, metrics: {})
30
+ transaction do |data|
31
+ data[process.id] = process.to_h
32
+ end
33
+ end
34
+
35
+ def delete(process)
36
+ transaction do |data|
37
+ data.delete(process.id)
38
+ end
39
+ end
40
+
41
+ def find(id)
42
+ raw = read.fetch(id) { raise(Lepus::Process::NotFoundError.new(id)) }
43
+ Lepus::Process.coerce(raw)
44
+ end
45
+
46
+ def exists?(id)
47
+ read.key?(id)
48
+ end
49
+
50
+ def all
51
+ read.keys.map { |id| find(id) }
52
+ end
53
+
54
+ def count
55
+ return 0 unless path
56
+
57
+ read.size
58
+ end
59
+
60
+ def clear
61
+ return unless path
62
+
63
+ write({})
64
+ end
65
+
66
+ private
67
+
68
+ def transaction
69
+ data = read
70
+ yield data
71
+ write(data)
72
+ end
73
+
74
+ def read
75
+ with_lock(File::LOCK_SH) do |f|
76
+ if f.size.zero?
77
+ {}
78
+ else
79
+ encoded = f.read
80
+ Marshal.load(Base64.strict_decode64(encoded))
81
+ end
82
+ end
83
+ end
84
+
85
+ def write(data)
86
+ with_lock(File::LOCK_EX) do |f|
87
+ f.rewind
88
+ f.truncate(0)
89
+ encoded = Base64.strict_encode64(Marshal.dump(data))
90
+ f.write(encoded)
91
+ f.flush
92
+ end
93
+ end
94
+
95
+ def with_lock(lock_type)
96
+ unless path
97
+ raise "ProcessRegistry not started. Call Lepus::ProcessRegistry.start first."
98
+ end
99
+ File.open(path, File::RDWR | File::CREAT | File::BINARY, 0o644) do |f|
100
+ f.flock(lock_type)
101
+ result = yield f
102
+ f.flock(File::LOCK_UN)
103
+ result
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lepus
6
+ class ProcessRegistry
7
+ # Builds heartbeat messages for RabbitMQ publishing.
8
+ class MessageBuilder
9
+ VERSION = "1.0"
10
+
11
+ def initialize(process, metrics: {})
12
+ @process = process
13
+ @metrics = metrics
14
+ end
15
+
16
+ def build_heartbeat
17
+ {
18
+ type: "heartbeat",
19
+ version: VERSION,
20
+ process: process_data,
21
+ metrics: metrics_data
22
+ }
23
+ end
24
+
25
+ def build_deregister
26
+ {
27
+ type: "deregister",
28
+ version: VERSION,
29
+ process_id: @process.id,
30
+ timestamp: Time.now.iso8601(6)
31
+ }
32
+ end
33
+
34
+ def to_json
35
+ JSON.generate(build_heartbeat)
36
+ end
37
+
38
+ private
39
+
40
+ def process_data
41
+ {
42
+ id: @process.id,
43
+ name: @process.name,
44
+ pid: @process.pid,
45
+ hostname: @process.hostname,
46
+ kind: @process.kind,
47
+ supervisor_id: @process.supervisor_id,
48
+ application: Lepus.config.application_name,
49
+ last_heartbeat_at: format_time(@process.last_heartbeat_at)
50
+ }
51
+ end
52
+
53
+ def metrics_data
54
+ {
55
+ rss_memory: @metrics[:rss_memory] || safe_rss_memory,
56
+ connections: @metrics[:connections] || 0,
57
+ consumers: @metrics[:consumers] || []
58
+ }
59
+ end
60
+
61
+ def safe_rss_memory
62
+ @process.rss_memory * 1024 # Convert kB to bytes (MEMORY_GRABBER returns kB)
63
+ rescue
64
+ 0
65
+ end
66
+
67
+ def format_time(time)
68
+ time&.iso8601(6)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+
6
+ module Lepus
7
+ class ProcessRegistry
8
+ # RabbitMQ-based backend for process registry.
9
+ # Publishes heartbeats to a fanout exchange for web dashboard aggregation.
10
+ # Also writes locally via FileBackend for local queries when aggregator is unavailable.
11
+ class RabbitmqBackend
12
+ include Backend
13
+
14
+ HEARTBEAT_EXCHANGE = "lepus.heartbeat"
15
+
16
+ attr_reader :fallback
17
+
18
+ def initialize(fallback: nil)
19
+ @fallback = fallback || FileBackend.new
20
+ @connection = nil
21
+ @channel = nil
22
+ @exchange = nil
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ def start
27
+ @fallback.start
28
+ setup_channel_and_exchange
29
+ end
30
+
31
+ def stop
32
+ @fallback.stop
33
+ close_channel
34
+ end
35
+
36
+ def add(process, metrics: {})
37
+ @fallback.add(process)
38
+ publish_heartbeat(process, metrics: metrics)
39
+ end
40
+
41
+ def delete(process)
42
+ @fallback.delete(process)
43
+ publish_deregister(process)
44
+ end
45
+
46
+ def find(id)
47
+ @fallback.find(id)
48
+ end
49
+
50
+ def exists?(id)
51
+ @fallback.exists?(id)
52
+ end
53
+
54
+ def all
55
+ @fallback.all
56
+ end
57
+
58
+ def count
59
+ @fallback.count
60
+ end
61
+
62
+ def clear
63
+ @fallback.clear
64
+ end
65
+
66
+ def path
67
+ @fallback.path
68
+ end
69
+
70
+ private
71
+
72
+ def setup_channel_and_exchange
73
+ return unless rabbitmq_available?
74
+
75
+ @mutex.synchronize do
76
+ return if @channel&.open?
77
+
78
+ @connection = Lepus.config.create_connection(suffix: "(registry)")
79
+ @channel = @connection.create_channel
80
+ @exchange = @channel.fanout(
81
+ HEARTBEAT_EXCHANGE,
82
+ durable: false,
83
+ auto_delete: false
84
+ )
85
+ end
86
+ rescue => e
87
+ Lepus.logger.warn("[ProcessRegistry] Failed to setup RabbitMQ channel: #{e.message}")
88
+ @connection = nil
89
+ @channel = nil
90
+ @exchange = nil
91
+ end
92
+
93
+ # Tear down the dedicated registry connection on supervisor shutdown.
94
+ # Bunny's graceful close waits up to 15s per channel for a broker
95
+ # `close-ok` continuation; during forked supervisor shutdown the broker
96
+ # sometimes never replies and SIGTERM handling blows past its 10s budget,
97
+ # timing out the integration specs. We bound the graceful attempt at 2s
98
+ # and fall back to closing the socket directly so the process can exit.
99
+ CLOSE_TIMEOUT = 2
100
+
101
+ def close_channel
102
+ @mutex.synchronize do
103
+ force_close_connection if @connection
104
+ end
105
+ ensure
106
+ @connection = nil
107
+ @channel = nil
108
+ @exchange = nil
109
+ end
110
+
111
+ def force_close_connection
112
+ Timeout.timeout(CLOSE_TIMEOUT) { @connection.close(false) } if @connection.open?
113
+ rescue => e
114
+ Lepus.logger.warn("[ProcessRegistry] Failed to close RabbitMQ connection: #{e.message}")
115
+ begin
116
+ @connection.instance_variable_get(:@transport)&.close
117
+ rescue
118
+ nil
119
+ end
120
+ end
121
+
122
+ def publish_heartbeat(process, metrics: {})
123
+ return unless @exchange
124
+
125
+ message = MessageBuilder.new(process, metrics: metrics)
126
+ @exchange.publish(
127
+ message.to_json,
128
+ content_type: "application/json"
129
+ )
130
+ rescue => e
131
+ Lepus.logger.warn("[ProcessRegistry] Failed to publish heartbeat: #{e.message}")
132
+ end
133
+
134
+ def publish_deregister(process)
135
+ return unless @exchange
136
+
137
+ message = MessageBuilder.new(process).build_deregister
138
+ @exchange.publish(
139
+ JSON.generate(message),
140
+ content_type: "application/json"
141
+ )
142
+ rescue => e
143
+ Lepus.logger.warn("[ProcessRegistry] Failed to publish deregister: #{e.message}")
144
+ end
145
+
146
+ def rabbitmq_available?
147
+ Lepus.config.rabbitmq_url.present?
148
+ rescue
149
+ true
150
+ end
151
+ end
152
+ end
153
+ end
@@ -1,24 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pathname"
4
- require "tmpdir"
5
- require "base64"
6
-
7
3
  module Lepus
8
- # we are storing the process registry in a file using Marshal serialization
9
- # but the plan is to move to a Rabbitmq or Redis based implementation in the future
10
- # to let it available to outside services like the web dashboard.
11
- # I'll refactor this class later when we have a better idea of the requirements.
4
+ # Process registry that delegates to a configurable backend.
5
+ # Default backend is FileBackend for local file-based storage.
6
+ # Use RabbitmqBackend to share process data across apps via web dashboard.
12
7
  class ProcessRegistry
13
8
  class << self
14
- attr_reader :path
9
+ def backend
10
+ @backend ||= Lepus.config.build_process_registry_backend
11
+ end
12
+
13
+ attr_writer :backend
14
+
15
+ def reset_backend!
16
+ @backend = nil
17
+ end
15
18
 
16
19
  def start
17
- @path ||= Pathname.new(Dir.tmpdir).join("lepus_process_registry.store")
20
+ backend.start
18
21
  end
19
22
 
20
23
  def stop
21
- path.delete if path&.exist?
24
+ backend.stop
22
25
  end
23
26
 
24
27
  def reset!
@@ -27,82 +30,40 @@ module Lepus
27
30
  end
28
31
 
29
32
  def add(process)
30
- transaction do |data|
31
- data[process.id] = process.to_h
32
- end
33
+ backend.add(process)
34
+ end
35
+
36
+ def update(process, metrics: {})
37
+ backend.update(process, metrics: metrics)
33
38
  end
34
- alias_method :update, :add
35
39
 
36
40
  def delete(process)
37
- transaction do |data|
38
- data.delete(process.id)
39
- end
41
+ backend.delete(process)
40
42
  end
41
43
 
42
44
  def find(id)
43
- raw = read.fetch(id) { raise(Lepus::Process::NotFoundError.new(id)) }
44
- Lepus::Process.coerce(raw)
45
+ backend.find(id)
45
46
  end
46
47
 
47
48
  def exists?(id)
48
- read.key?(id)
49
+ backend.exists?(id)
49
50
  end
50
51
 
51
52
  def all
52
- read.keys.map { |id| find(id) }
53
+ backend.all
53
54
  end
54
55
 
55
56
  def count
56
- return 0 unless path
57
-
58
- read.size
57
+ backend.count
59
58
  end
60
59
 
61
60
  def clear
62
- return unless path
63
-
64
- write({})
65
- end
66
-
67
- private
68
-
69
- def transaction
70
- data = read
71
- yield data
72
- write(data)
73
- end
74
-
75
- def read
76
- with_lock(File::LOCK_SH) do |f|
77
- if f.size.zero?
78
- {}
79
- else
80
- encoded = f.read
81
- Marshal.load(Base64.strict_decode64(encoded))
82
- end
83
- end
84
- end
85
-
86
- def write(data)
87
- with_lock(File::LOCK_EX) do |f|
88
- f.rewind
89
- f.truncate(0)
90
- encoded = Base64.strict_encode64(Marshal.dump(data))
91
- f.write(encoded)
92
- f.flush
93
- end
61
+ backend.clear
94
62
  end
95
63
 
96
- def with_lock(lock_type)
97
- unless path
98
- raise "ProcessRegistry not started. Call Lepus::ProcessRegistry.start first."
99
- end
100
- File.open(path, File::RDWR | File::CREAT | File::BINARY, 0o644) do |f|
101
- f.flock(lock_type)
102
- result = yield f
103
- f.flock(File::LOCK_UN)
104
- result
105
- end
64
+ # For backward compatibility with tests that check @path
65
+ def path
66
+ backend.respond_to?(:path) ? backend.path : nil
106
67
  end
107
68
  end
108
69
  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