leopard 0.2.5 → 0.2.6
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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.version.txt +1 -1
- data/.yardopts +5 -0
- data/CHANGELOG.md +7 -0
- data/Rakefile +110 -1
- data/Readme.adoc +61 -1
- data/ci/nats/start.sh +26 -1
- data/examples/echo_endpoint.rb +6 -0
- data/examples/jetstream_endpoint.rb +44 -0
- data/lib/leopard/errors.rb +10 -0
- data/lib/leopard/message_processor.rb +86 -0
- data/lib/leopard/message_wrapper.rb +4 -0
- data/lib/leopard/metrics_server.rb +49 -0
- data/lib/leopard/nats_api_server.rb +214 -57
- data/lib/leopard/nats_jetstream_callbacks.rb +76 -0
- data/lib/leopard/nats_jetstream_consumer.rb +186 -0
- data/lib/leopard/nats_jetstream_endpoint.rb +19 -0
- data/lib/leopard/nats_request_reply_callbacks.rb +70 -0
- data/lib/leopard/version.rb +1 -1
- data/lib/leopard.rb +17 -0
- data/mise.toml +2 -0
- metadata +11 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 42beb75a4668c0874385a6d0cc8deead97def34d08a45e4c7a314ffb75697bcf
|
|
4
|
+
data.tar.gz: 3d69bfcf8942d34695514c9b70d25ccc68024ca4027ce3cd4cc3f22bee3b6986
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f0b1e3f0d13fbfdee1614f5a3af3f8ba0758d5afec9cee415c7d36fc448a11e5d8c4df4895119c87ce909ff3b0b7ab61aa0956f774565930521f946f6b27053
|
|
7
|
+
data.tar.gz: 9cbf821b916e89d469d05abfb29ba92a337c1a08394d1a66cef39e6613426d8a2f893fbe27d55e4ebaa329f8d4ae7b50c8fa1a657df8af7fec11d9c8bec8b871
|
data/.version.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
1
|
+
0.2.6
|
data/.yardopts
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.6](https://github.com/rubyists/leopard/compare/v0.2.5...v0.2.6) (2026-04-21)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add nats_jetstream_consumer to provide the feature requested in issue [#44](https://github.com/rubyists/leopard/issues/44) ([#45](https://github.com/rubyists/leopard/issues/45)) ([9372aa4](https://github.com/rubyists/leopard/commit/9372aa4ce59b130e7143905ce5e8065519778861))
|
|
9
|
+
|
|
3
10
|
## [0.2.5](https://github.com/rubyists/leopard/compare/v0.2.4...v0.2.5) (2026-04-16)
|
|
4
11
|
|
|
5
12
|
|
data/Rakefile
CHANGED
|
@@ -4,8 +4,15 @@ require 'rake'
|
|
|
4
4
|
require 'minitest/test_task'
|
|
5
5
|
require 'bundler/gem_tasks'
|
|
6
6
|
require 'rubocop/rake_task'
|
|
7
|
+
require 'net/http'
|
|
8
|
+
require 'open3'
|
|
9
|
+
require 'shellwords'
|
|
10
|
+
require 'timeout'
|
|
11
|
+
require 'yard'
|
|
12
|
+
require 'yard/rake/yardoc_task'
|
|
7
13
|
|
|
8
14
|
RuboCop::RakeTask.new
|
|
15
|
+
YARD::Rake::YardocTask.new(:yard)
|
|
9
16
|
|
|
10
17
|
Minitest::TestTask.create(:test) do |task|
|
|
11
18
|
task.libs << 'lib'
|
|
@@ -14,4 +21,106 @@ Minitest::TestTask.create(:test) do |task|
|
|
|
14
21
|
task.warning = true
|
|
15
22
|
end
|
|
16
23
|
|
|
17
|
-
|
|
24
|
+
QUICK_TEST_FILES = Dir['test/*/**/*.rb'].reject { |file| file.start_with?('test/integration/') }.sort.freeze
|
|
25
|
+
|
|
26
|
+
# Returns the local NATS JetStream health endpoint used by the CI helpers.
|
|
27
|
+
#
|
|
28
|
+
# @return [URI::HTTP] The health endpoint URI.
|
|
29
|
+
def nats_health_uri = URI('http://127.0.0.1:8222/healthz')
|
|
30
|
+
|
|
31
|
+
# Reports whether the local NATS JetStream health endpoint is currently reachable.
|
|
32
|
+
#
|
|
33
|
+
# @return [Boolean] `true` when the broker responds successfully, otherwise `false`.
|
|
34
|
+
def nats_ready?
|
|
35
|
+
Net::HTTP.get_response(nats_health_uri).is_a?(Net::HTTPSuccess)
|
|
36
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Waits for the local NATS JetStream broker to report healthy.
|
|
41
|
+
#
|
|
42
|
+
# @return [void]
|
|
43
|
+
# @raise [RuntimeError] If the broker does not become healthy within 30 seconds.
|
|
44
|
+
def wait_for_nats!
|
|
45
|
+
Timeout.timeout(30) do
|
|
46
|
+
sleep 1 until nats_ready?
|
|
47
|
+
end
|
|
48
|
+
rescue Timeout::Error
|
|
49
|
+
raise 'Timed out waiting for NATS JetStream health endpoint on http://127.0.0.1:8222/healthz'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Detects the container runtime used to manage the local NATS broker.
|
|
53
|
+
#
|
|
54
|
+
# @return [String] `podman` when available, otherwise `docker`.
|
|
55
|
+
def container_runtime
|
|
56
|
+
File.executable?('/usr/bin/podman') || system('command -v podman > /dev/null 2>&1', exception: false) ? 'podman' : 'docker'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Runs the non-integration test files directly for a fast local feedback loop.
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
62
|
+
def run_quick_tests!
|
|
63
|
+
sh "ruby -w -Ilib -Itest #{QUICK_TEST_FILES.shelljoin}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Verifies that the current YARD coverage is complete.
|
|
67
|
+
#
|
|
68
|
+
# @return [void]
|
|
69
|
+
# @raise [RuntimeError] If YARD reports anything less than 100% documentation coverage.
|
|
70
|
+
def verify_yard_coverage!
|
|
71
|
+
output, status = Open3.capture2e('bundle', 'exec', 'yard', 'stats', '--list-undoc')
|
|
72
|
+
puts output
|
|
73
|
+
raise 'yard stats failed' unless status.success?
|
|
74
|
+
return if output.include?('100.00% documented')
|
|
75
|
+
|
|
76
|
+
raise 'YARD documentation coverage is incomplete'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
namespace :nats do
|
|
80
|
+
desc 'Start the local NATS JetStream broker via ./ci/nats/start.sh'
|
|
81
|
+
task :start do
|
|
82
|
+
sh({ 'NATS_DETACH' => '1' }, './ci/nats/start.sh')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
desc 'Wait for the local NATS JetStream broker health endpoint'
|
|
86
|
+
task :wait do
|
|
87
|
+
wait_for_nats!
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
desc 'Stop the local NATS JetStream broker container'
|
|
91
|
+
task :stop do
|
|
92
|
+
name = ENV.fetch('NATS_NAME', 'leopard-nats')
|
|
93
|
+
sh(container_runtime, 'rm', '-f', name, verbose: false)
|
|
94
|
+
rescue RuntimeError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
namespace :ci do
|
|
100
|
+
desc 'Run RuboCop, YARD verification, and the non-integration test suite without managing NATS'
|
|
101
|
+
task quick: %i[rubocop yard:verify] do
|
|
102
|
+
run_quick_tests!
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
desc 'Run the full test suite against a managed local NATS JetStream broker'
|
|
106
|
+
task :test do
|
|
107
|
+
Rake::Task['nats:start'].invoke
|
|
108
|
+
Rake::Task['nats:wait'].invoke
|
|
109
|
+
Rake::Task['test'].invoke
|
|
110
|
+
ensure
|
|
111
|
+
Rake::Task['nats:stop'].reenable
|
|
112
|
+
Rake::Task['nats:stop'].invoke
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
desc 'Run RuboCop and the full test suite against a managed local NATS JetStream broker'
|
|
117
|
+
task ci: %w[rubocop yard:verify ci:test]
|
|
118
|
+
|
|
119
|
+
namespace :yard do
|
|
120
|
+
desc 'Fail if YARD reports incomplete documentation coverage'
|
|
121
|
+
task :verify do
|
|
122
|
+
verify_yard_coverage!
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
task default: :ci
|
data/Readme.adoc
CHANGED
|
@@ -12,6 +12,7 @@ minimal DSL for defining endpoints and middleware.
|
|
|
12
12
|
== Features
|
|
13
13
|
|
|
14
14
|
* Declarative endpoint definitions with `#endpoint`.
|
|
15
|
+
* Declarative JetStream pull consumers with `#jetstream_endpoint`.
|
|
15
16
|
* Grouping of endpoints with `#group`
|
|
16
17
|
* Simple concurrency via `#run` with a configurable number of instances.
|
|
17
18
|
* JSON aware message wrapper that gracefully handles parse errors.
|
|
@@ -90,13 +91,72 @@ end
|
|
|
90
91
|
EchoService.use LoggerMiddleware
|
|
91
92
|
----
|
|
92
93
|
|
|
94
|
+
== JetStream Pull Consumers
|
|
95
|
+
|
|
96
|
+
Leopard can also bind JetStream pull consumers through the same middleware and `Dry::Monads::Result`
|
|
97
|
+
handler contract used by request/reply endpoints.
|
|
98
|
+
|
|
99
|
+
[source,ruby]
|
|
100
|
+
----
|
|
101
|
+
class EventConsumer
|
|
102
|
+
include Rubyists::Leopard::NatsApiServer
|
|
103
|
+
|
|
104
|
+
jetstream_endpoint(
|
|
105
|
+
:events,
|
|
106
|
+
stream: 'EVENTS',
|
|
107
|
+
subject: 'events.created',
|
|
108
|
+
durable: 'events-created-worker',
|
|
109
|
+
consumer: { max_deliver: 5 },
|
|
110
|
+
batch: 5,
|
|
111
|
+
fetch_timeout: 1,
|
|
112
|
+
nak_delay: 2,
|
|
113
|
+
) do |msg|
|
|
114
|
+
Success(msg.data)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
----
|
|
118
|
+
|
|
119
|
+
JetStream handlers receive the same `Rubyists::Leopard::MessageWrapper` as service endpoints.
|
|
120
|
+
Leopard will:
|
|
121
|
+
|
|
122
|
+
* `ack` on `Success`
|
|
123
|
+
* `nak` on `Failure` (`nak_delay:` is optional)
|
|
124
|
+
* `term` on unhandled exceptions
|
|
125
|
+
|
|
126
|
+
Each Leopard `instances:` worker creates its own pull subscription loop, so JetStream consumers
|
|
127
|
+
scale with the same process-local concurrency model as the rest of the framework.
|
|
128
|
+
|
|
93
129
|
== Development
|
|
94
130
|
|
|
95
131
|
The project uses Minitest and RuboCop. Run tests with Rake:
|
|
96
132
|
|
|
97
133
|
[source,bash]
|
|
98
134
|
----
|
|
99
|
-
$ bundle exec rake
|
|
135
|
+
$ bundle exec rake ci
|
|
136
|
+
----
|
|
137
|
+
|
|
138
|
+
This task starts NATS JetStream through `./ci/nats/start.sh`, waits for broker health,
|
|
139
|
+
runs RuboCop and the test suite, and then stops the broker.
|
|
140
|
+
|
|
141
|
+
API documentation can be generated with:
|
|
142
|
+
|
|
143
|
+
[source,bash]
|
|
144
|
+
----
|
|
145
|
+
$ bundle exec rake yard
|
|
146
|
+
----
|
|
147
|
+
|
|
148
|
+
Documentation coverage is enforced with:
|
|
149
|
+
|
|
150
|
+
[source,bash]
|
|
151
|
+
----
|
|
152
|
+
$ bundle exec rake yard:verify
|
|
153
|
+
----
|
|
154
|
+
|
|
155
|
+
If you want to run the broker yourself, the same script can still be used directly:
|
|
156
|
+
|
|
157
|
+
[source,bash]
|
|
158
|
+
----
|
|
159
|
+
$ ./ci/nats/start.sh
|
|
100
160
|
----
|
|
101
161
|
|
|
102
162
|
=== Conventional Commits (semantic commit messages)
|
data/ci/nats/start.sh
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
|
|
3
3
|
NATS_VERSION=2
|
|
4
|
+
NATS_NAME=${NATS_NAME:-leopard-nats}
|
|
5
|
+
NATS_DETACH=${NATS_DETACH:-0}
|
|
4
6
|
|
|
5
7
|
if readlink -f . >/dev/null 2>&1 # {{{ makes readlink work on mac
|
|
6
8
|
then
|
|
@@ -29,5 +31,28 @@ else
|
|
|
29
31
|
runtime=docker
|
|
30
32
|
fi
|
|
31
33
|
|
|
34
|
+
args=(
|
|
35
|
+
run
|
|
36
|
+
--rm
|
|
37
|
+
--name "$NATS_NAME"
|
|
38
|
+
-p 4222:4222
|
|
39
|
+
-p 6222:6222
|
|
40
|
+
-p 8222:8222
|
|
41
|
+
-v ./accounts.txt:/accounts.txt
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if [ "$NATS_DETACH" = "1" ]
|
|
45
|
+
then
|
|
46
|
+
args+=(-d)
|
|
47
|
+
else
|
|
48
|
+
args+=(-it)
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
args+=(
|
|
52
|
+
"nats:$NATS_VERSION"
|
|
53
|
+
-js
|
|
54
|
+
-c /accounts.txt
|
|
55
|
+
)
|
|
56
|
+
|
|
32
57
|
set -x
|
|
33
|
-
exec "$runtime"
|
|
58
|
+
exec "$runtime" "${args[@]}" "$@"
|
data/examples/echo_endpoint.rb
CHANGED
|
@@ -8,6 +8,12 @@ class EchoService
|
|
|
8
8
|
include Rubyists::Leopard::NatsApiServer
|
|
9
9
|
|
|
10
10
|
config.logger = SemanticLogger[:EchoService]
|
|
11
|
+
|
|
12
|
+
# Builds the example service instance.
|
|
13
|
+
#
|
|
14
|
+
# @param a_var [Integer] Example initializer state passed through `instance_args`.
|
|
15
|
+
#
|
|
16
|
+
# @return [void]
|
|
11
17
|
def initialize(a_var: 1)
|
|
12
18
|
logger.info "EchoService initialized with a_var: #{a_var}"
|
|
13
19
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative '../lib/leopard/nats_api_server'
|
|
5
|
+
|
|
6
|
+
# Example JetStream worker for async event processing.
|
|
7
|
+
class EventConsumer
|
|
8
|
+
include Rubyists::Leopard::NatsApiServer
|
|
9
|
+
|
|
10
|
+
config.logger = SemanticLogger[:EventConsumer]
|
|
11
|
+
|
|
12
|
+
jetstream_endpoint(
|
|
13
|
+
:events,
|
|
14
|
+
stream: 'EVENTS',
|
|
15
|
+
subject: 'events.created',
|
|
16
|
+
durable: 'events-created-worker',
|
|
17
|
+
consumer: {
|
|
18
|
+
ack_wait: 30,
|
|
19
|
+
max_deliver: 5,
|
|
20
|
+
},
|
|
21
|
+
batch: 5,
|
|
22
|
+
fetch_timeout: 1,
|
|
23
|
+
nak_delay: 2,
|
|
24
|
+
) do |msg|
|
|
25
|
+
logger.info 'Processing event', data: msg.data
|
|
26
|
+
Success(msg.data)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
Failure(error: e.message, data: msg.data)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if __FILE__ == $PROGRAM_NAME
|
|
33
|
+
SemanticLogger.default_level = :info
|
|
34
|
+
SemanticLogger.add_signal_handler
|
|
35
|
+
SemanticLogger.add_appender(io: $stdout, formatter: :color)
|
|
36
|
+
EventConsumer.run(
|
|
37
|
+
nats_url: 'nats://localhost:4222',
|
|
38
|
+
service_opts: {
|
|
39
|
+
name: 'example.event_consumer',
|
|
40
|
+
version: '1.0.0',
|
|
41
|
+
},
|
|
42
|
+
instances: ENV.fetch('EVENT_CONSUMER_INSTANCES', '1').to_i,
|
|
43
|
+
)
|
|
44
|
+
end
|
data/lib/leopard/errors.rb
CHANGED
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module Rubyists
|
|
4
4
|
module Leopard
|
|
5
|
+
# Base Leopard exception that truncates backtraces for cleaner logs.
|
|
5
6
|
class LeopardError < StandardError
|
|
7
|
+
# Captures the original exception state while replacing the backtrace with the current call stack.
|
|
8
|
+
#
|
|
9
|
+
# @return [void]
|
|
6
10
|
def initialize(...)
|
|
7
11
|
super
|
|
8
12
|
set_backtrace(caller)
|
|
9
13
|
end
|
|
10
14
|
|
|
15
|
+
# Returns a Leopard-truncated backtrace.
|
|
16
|
+
#
|
|
17
|
+
# @return [Array<String>] Up to the first four backtrace entries, plus a truncation marker when applicable.
|
|
11
18
|
def backtrace
|
|
12
19
|
# If the backtrace is nil, return an empty array
|
|
13
20
|
orig = (super || [])[0..3]
|
|
@@ -19,8 +26,11 @@ module Rubyists
|
|
|
19
26
|
end
|
|
20
27
|
end
|
|
21
28
|
|
|
29
|
+
# Generic Leopard error superclass.
|
|
22
30
|
class Error < LeopardError; end
|
|
31
|
+
# Raised when Leopard configuration is invalid.
|
|
23
32
|
class ConfigurationError < Error; end
|
|
33
|
+
# Raised when a handler returns an unsupported object instead of a result monad.
|
|
24
34
|
class ResultError < Error; end
|
|
25
35
|
end
|
|
26
36
|
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubyists
|
|
4
|
+
module Leopard
|
|
5
|
+
# Composes middleware around Leopard handlers and routes their results to transport callbacks.
|
|
6
|
+
class MessageProcessor
|
|
7
|
+
private attr_reader :execute_handler, :logger, :middleware, :wrapper_factory
|
|
8
|
+
|
|
9
|
+
# Builds a reusable processor for request/reply and JetStream transports.
|
|
10
|
+
#
|
|
11
|
+
# @param wrapper_factory [#call] Callable that wraps a raw transport message in a {MessageWrapper}-compatible object.
|
|
12
|
+
# @param middleware [#call] Callable returning the current middleware stack.
|
|
13
|
+
# @param execute_handler [#call] Callable that executes the endpoint handler with the wrapped message.
|
|
14
|
+
# @param logger [#error] Logger used for processing failures.
|
|
15
|
+
#
|
|
16
|
+
# @return [void]
|
|
17
|
+
def initialize(wrapper_factory:, middleware:, execute_handler:, logger:)
|
|
18
|
+
@wrapper_factory = wrapper_factory
|
|
19
|
+
@middleware = middleware
|
|
20
|
+
@execute_handler = execute_handler
|
|
21
|
+
@logger = logger
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Processes a raw transport message through middleware and terminal callbacks.
|
|
25
|
+
#
|
|
26
|
+
# @param raw_msg [Object] The raw transport message from NATS.
|
|
27
|
+
# @param handler [Proc] The endpoint handler to execute.
|
|
28
|
+
# @param callbacks [Hash{Symbol => #call}] Success, failure, and error callbacks for the transport.
|
|
29
|
+
#
|
|
30
|
+
# @return [Object] The transport-specific callback result.
|
|
31
|
+
def process(raw_msg, handler, callbacks)
|
|
32
|
+
app(callbacks, handler).call(wrapper_factory.call(raw_msg))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Builds the middleware stack around the terminal application.
|
|
38
|
+
#
|
|
39
|
+
# @param callbacks [Hash{Symbol => #call}] Transport callbacks keyed by outcome.
|
|
40
|
+
# @param handler [Proc] The endpoint handler to execute at the core of the stack.
|
|
41
|
+
#
|
|
42
|
+
# @return [#call] The composed middleware application.
|
|
43
|
+
def app(callbacks, handler)
|
|
44
|
+
middleware.call.reverse_each.reduce(base_app(handler, callbacks)) do |current, (klass, args, blk)|
|
|
45
|
+
klass.new(current, *args, &blk)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Builds the terminal application that runs the handler and dispatches transport callbacks.
|
|
50
|
+
#
|
|
51
|
+
# @param handler [Proc] The endpoint handler to execute.
|
|
52
|
+
# @param callbacks [Hash{Symbol => #call}] Transport callbacks keyed by outcome.
|
|
53
|
+
#
|
|
54
|
+
# @return [Proc] The terminal application for the middleware chain.
|
|
55
|
+
def base_app(handler, callbacks)
|
|
56
|
+
lambda do |wrapper|
|
|
57
|
+
result = execute_handler.call(wrapper, handler)
|
|
58
|
+
process_result(wrapper, result, callbacks)
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
logger.error 'Error processing message: ', e
|
|
61
|
+
callbacks[:on_error].call(wrapper, e)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Routes a {Dry::Monads::Result} to the appropriate transport callback.
|
|
66
|
+
#
|
|
67
|
+
# @param wrapper [MessageWrapper] The wrapped transport message.
|
|
68
|
+
# @param result [Dry::Monads::Result] The handler result to route.
|
|
69
|
+
# @param callbacks [Hash{Symbol => #call}] Transport callbacks keyed by outcome.
|
|
70
|
+
#
|
|
71
|
+
# @return [Object] The callback return value for the routed result.
|
|
72
|
+
# @raise [ResultError] If the handler returned a non-result object.
|
|
73
|
+
def process_result(wrapper, result, callbacks)
|
|
74
|
+
case result
|
|
75
|
+
in Dry::Monads::Success
|
|
76
|
+
callbacks[:on_success].call(wrapper, result)
|
|
77
|
+
in Dry::Monads::Failure
|
|
78
|
+
callbacks[:on_failure].call(wrapper, result)
|
|
79
|
+
else
|
|
80
|
+
logger.error('Unexpected result: ', result:)
|
|
81
|
+
raise ResultError, "Unexpected Response from Handler, must respond with a Success or Failure monad: #{result}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -4,15 +4,19 @@ require 'json'
|
|
|
4
4
|
|
|
5
5
|
module Rubyists
|
|
6
6
|
module Leopard
|
|
7
|
+
# Wraps a raw NATS message with parsed payload and convenience response helpers.
|
|
7
8
|
class MessageWrapper
|
|
8
9
|
# @!attribute [r] raw
|
|
10
|
+
#
|
|
9
11
|
# @return [NATS::Message] The original NATS message.
|
|
10
12
|
#
|
|
11
13
|
# @!attribute [r] data
|
|
14
|
+
#
|
|
12
15
|
# @return [Object] The parsed data from the NATS message.
|
|
13
16
|
attr_reader :raw, :data
|
|
14
17
|
#
|
|
15
18
|
# @!attribute [w] headers
|
|
19
|
+
#
|
|
16
20
|
# @return [Hash] The headers from the NATS message.
|
|
17
21
|
attr_accessor :headers
|
|
18
22
|
|
|
@@ -5,9 +5,15 @@ require 'erb'
|
|
|
5
5
|
|
|
6
6
|
module Rubyists
|
|
7
7
|
module Leopard
|
|
8
|
+
# Adds a minimal Prometheus HTTP endpoint for Leopard worker metrics.
|
|
8
9
|
module MetricsServer
|
|
9
10
|
private
|
|
10
11
|
|
|
12
|
+
# Starts a lightweight HTTP server that exposes Leopard Prometheus metrics.
|
|
13
|
+
#
|
|
14
|
+
# @param workers [Array<Object>] Active Leopard worker instances to observe.
|
|
15
|
+
#
|
|
16
|
+
# @return [Thread] The server thread.
|
|
11
17
|
def start_metrics_server(workers)
|
|
12
18
|
port = ENV.fetch('LEOPARD_METRICS_PORT', '9394').to_i
|
|
13
19
|
Thread.new do
|
|
@@ -19,6 +25,12 @@ module Rubyists
|
|
|
19
25
|
end
|
|
20
26
|
end
|
|
21
27
|
|
|
28
|
+
# Handles an individual metrics HTTP client connection.
|
|
29
|
+
#
|
|
30
|
+
# @param client [TCPSocket] The connected HTTP client.
|
|
31
|
+
# @param workers [Array<Object>] Active Leopard worker instances to observe.
|
|
32
|
+
#
|
|
33
|
+
# @return [void]
|
|
22
34
|
def handle_metrics_client(client, workers)
|
|
23
35
|
request_line = client.gets
|
|
24
36
|
loop { break if (client.gets || '').chomp.empty? }
|
|
@@ -29,12 +41,24 @@ module Rubyists
|
|
|
29
41
|
close_client(client)
|
|
30
42
|
end
|
|
31
43
|
|
|
44
|
+
# Closes a metrics client socket, ignoring cleanup failures.
|
|
45
|
+
#
|
|
46
|
+
# @param client [TCPSocket] The connected HTTP client.
|
|
47
|
+
#
|
|
48
|
+
# @return [void]
|
|
32
49
|
def close_client(client)
|
|
33
50
|
client.close
|
|
34
51
|
rescue StandardError
|
|
35
52
|
nil
|
|
36
53
|
end
|
|
37
54
|
|
|
55
|
+
# Writes the HTTP response for a metrics request.
|
|
56
|
+
#
|
|
57
|
+
# @param client [TCPSocket] The connected HTTP client.
|
|
58
|
+
# @param request_line [String, nil] The first line of the HTTP request.
|
|
59
|
+
# @param workers [Array<Object>] Active Leopard worker instances to observe.
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
38
62
|
def write_metrics_response(client, request_line, workers)
|
|
39
63
|
if request_line&.start_with?('GET /metrics')
|
|
40
64
|
body = prometheus_metrics(workers)
|
|
@@ -46,11 +70,21 @@ module Rubyists
|
|
|
46
70
|
end
|
|
47
71
|
end
|
|
48
72
|
|
|
73
|
+
# Builds the Prometheus metrics payload for the current worker state.
|
|
74
|
+
#
|
|
75
|
+
# @param workers [Array<Object>] Active Leopard worker instances to observe.
|
|
76
|
+
#
|
|
77
|
+
# @return [String] Rendered Prometheus text exposition output.
|
|
49
78
|
def prometheus_metrics(workers)
|
|
50
79
|
metrics = collect_prometheus_metrics(workers)
|
|
51
80
|
render_metrics_template(metrics)
|
|
52
81
|
end
|
|
53
82
|
|
|
83
|
+
# Aggregates per-subject worker utilization metrics.
|
|
84
|
+
#
|
|
85
|
+
# @param workers [Array<Object>] Active Leopard worker instances to observe.
|
|
86
|
+
#
|
|
87
|
+
# @return [Hash{Symbol => Object}] Metric hashes for the Prometheus template.
|
|
54
88
|
def collect_prometheus_metrics(workers)
|
|
55
89
|
busy = Hash.new(0)
|
|
56
90
|
pending = Hash.new(0)
|
|
@@ -63,6 +97,13 @@ module Rubyists
|
|
|
63
97
|
}
|
|
64
98
|
end
|
|
65
99
|
|
|
100
|
+
# Adds one worker's endpoint saturation metrics to the aggregate hashes.
|
|
101
|
+
#
|
|
102
|
+
# @param worker [Object] A Leopard worker instance.
|
|
103
|
+
# @param busy [Hash{String => Integer}] Subject-to-busy-worker counts.
|
|
104
|
+
# @param pending [Hash{String => Integer}] Subject-to-pending-message counts.
|
|
105
|
+
#
|
|
106
|
+
# @return [void]
|
|
66
107
|
def accumulate_worker_metrics(worker, busy, pending)
|
|
67
108
|
service = worker.instance_variable_get(:@service)
|
|
68
109
|
return unless service
|
|
@@ -78,10 +119,18 @@ module Rubyists
|
|
|
78
119
|
end
|
|
79
120
|
end
|
|
80
121
|
|
|
122
|
+
# Renders the metrics ERB template with aggregated metric data.
|
|
123
|
+
#
|
|
124
|
+
# @param metrics [Hash{Symbol => Object}] Aggregated metric data for template rendering.
|
|
125
|
+
#
|
|
126
|
+
# @return [String] The rendered Prometheus payload.
|
|
81
127
|
def render_metrics_template(metrics)
|
|
82
128
|
ERB.new(File.read(metrics_template_path), trim_mode: '-').result_with_hash(metrics)
|
|
83
129
|
end
|
|
84
130
|
|
|
131
|
+
# Returns the absolute path to the Prometheus metrics template.
|
|
132
|
+
#
|
|
133
|
+
# @return [String] The metrics template path.
|
|
85
134
|
def metrics_template_path
|
|
86
135
|
File.expand_path('templates/prometheus_metrics.erb', __dir__)
|
|
87
136
|
end
|