bug_bunny 4.6.1 → 4.8.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.
- checksums.yaml +4 -4
- data/.agents/skills/rabbitmq-expert/SKILL.md +1555 -0
- data/.claude/commands/gem-ai-setup.md +174 -0
- data/.claude/commands/pr.md +53 -0
- data/.claude/commands/release.md +52 -0
- data/.claude/commands/rubocop.md +22 -0
- data/.claude/commands/service-ai-setup.md +168 -0
- data/.claude/commands/test.md +28 -0
- data/.claude/commands/yard.md +46 -0
- data/CHANGELOG.md +50 -15
- data/CLAUDE.md +240 -0
- data/README.md +154 -221
- data/Rakefile +19 -3
- data/docs/_index.md +50 -0
- data/docs/ai/_index.md +56 -0
- data/docs/ai/antipatterns.md +166 -0
- data/docs/ai/api.md +251 -0
- data/docs/ai/architecture.md +92 -0
- data/docs/ai/errors.md +158 -0
- data/docs/ai/faq_external.md +133 -0
- data/docs/ai/faq_internal.md +86 -0
- data/docs/ai/glossary.md +45 -0
- data/docs/concepts.md +140 -0
- data/docs/howto/controller.md +194 -0
- data/docs/howto/middleware_client.md +119 -0
- data/docs/howto/middleware_consumer.md +127 -0
- data/docs/howto/rails.md +214 -0
- data/docs/howto/resource.md +200 -0
- data/docs/howto/routing.md +133 -0
- data/docs/howto/testing.md +259 -0
- data/docs/howto/tracing.md +119 -0
- data/lib/bug_bunny/client.rb +45 -21
- data/lib/bug_bunny/configuration.rb +63 -0
- data/lib/bug_bunny/consumer.rb +51 -37
- data/lib/bug_bunny/consumer_middleware.rb +14 -5
- data/lib/bug_bunny/controller.rb +39 -18
- data/lib/bug_bunny/exception.rb +5 -1
- data/lib/bug_bunny/middleware/raise_error.rb +3 -3
- data/lib/bug_bunny/observability.rb +28 -6
- data/lib/bug_bunny/producer.rb +11 -13
- data/lib/bug_bunny/railtie.rb +8 -7
- data/lib/bug_bunny/request.rb +3 -11
- data/lib/bug_bunny/resource.rb +81 -41
- data/lib/bug_bunny/routing/route.rb +6 -1
- data/lib/bug_bunny/routing/route_set.rb +60 -22
- data/lib/bug_bunny/session.rb +18 -11
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +4 -2
- data/lib/generators/bug_bunny/install/install_generator.rb +45 -5
- data/lib/tasks/bug_bunny.rake +50 -0
- data/plan_test.txt +63 -0
- data/skills-lock.json +10 -0
- data/spec/integration/client_spec.rb +117 -0
- data/spec/integration/consumer_middleware_spec.rb +86 -0
- data/spec/integration/controller_spec.rb +140 -0
- data/spec/integration/error_handling_spec.rb +57 -0
- data/spec/integration/infrastructure_spec.rb +52 -0
- data/spec/integration/resource_spec.rb +113 -0
- data/spec/spec_helper.rb +70 -0
- data/spec/support/bunny_mocks.rb +18 -0
- data/spec/support/integration_helper.rb +87 -0
- data/spec/unit/client_session_pool_spec.rb +159 -0
- data/spec/unit/configuration_spec.rb +164 -0
- data/spec/unit/consumer_middleware_spec.rb +129 -0
- data/spec/unit/consumer_spec.rb +90 -0
- data/spec/unit/controller_after_action_spec.rb +155 -0
- data/spec/unit/observability_spec.rb +167 -0
- data/spec/unit/resource_attributes_spec.rb +69 -0
- data/spec/unit/session_spec.rb +98 -0
- metadata +50 -3
- data/sig/bug_bunny.rbs +0 -4
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Routing
|
|
2
|
+
|
|
3
|
+
Routes declare how incoming AMQP messages map to controllers and actions. They are evaluated on every message received by the Consumer.
|
|
4
|
+
|
|
5
|
+
## Drawing Routes
|
|
6
|
+
|
|
7
|
+
Define routes in an initializer (or any file loaded at boot):
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
BugBunny.routes.draw do
|
|
11
|
+
# routes here
|
|
12
|
+
end
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Call `draw` only once. Multiple calls replace the previous route set.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## HTTP Verbs
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
BugBunny.routes.draw do
|
|
23
|
+
get 'status', to: 'health#show'
|
|
24
|
+
post 'events', to: 'events#create'
|
|
25
|
+
put 'users/:id', to: 'users#update'
|
|
26
|
+
delete 'users/:id', to: 'users#destroy'
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The verb is set by the producer via the `x-http-method` AMQP header. `BugBunny::Resource` and `BugBunny::Client` set it automatically.
|
|
31
|
+
|
|
32
|
+
Dynamic segments (`:id`) are extracted from the path and available in `params` inside the controller.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Resources Macro
|
|
37
|
+
|
|
38
|
+
`resources` generates the standard seven CRUD routes in one line:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
resources :users
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Generates:
|
|
45
|
+
|
|
46
|
+
| Verb | Path | Action |
|
|
47
|
+
|--------|---------------|-----------|
|
|
48
|
+
| GET | users | index |
|
|
49
|
+
| POST | users | create |
|
|
50
|
+
| GET | users/:id | show |
|
|
51
|
+
| PUT | users/:id | update |
|
|
52
|
+
| DELETE | users/:id | destroy |
|
|
53
|
+
|
|
54
|
+
### Filtering actions
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
resources :orders, only: [:index, :show, :create]
|
|
58
|
+
resources :logs, except: [:update, :destroy]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Member and Collection Routes
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
resources :nodes do
|
|
67
|
+
member do
|
|
68
|
+
put :drain # PUT nodes/:id/drain → NodesController#drain
|
|
69
|
+
post :reboot # POST nodes/:id/reboot → NodesController#reboot
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
collection do
|
|
73
|
+
post :rebalance # POST nodes/rebalance → NodesController#rebalance
|
|
74
|
+
get :summary # GET nodes/summary → NodesController#summary
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Member routes receive `params[:id]` automatically. Collection routes do not.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Namespace Blocks
|
|
84
|
+
|
|
85
|
+
Group routes under a controller namespace. Namespaces stack: nested `namespace` blocks accumulate with `::`.
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
BugBunny.routes.draw do
|
|
89
|
+
namespace :api do
|
|
90
|
+
namespace :v1 do
|
|
91
|
+
resources :metrics # → Api::V1::MetricsController
|
|
92
|
+
resources :alerts # → Api::V1::AlertsController
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
resources :health # → Api::HealthController
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
resources :nodes # → BugBunny::Controllers::NodesController (global namespace)
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The namespace in the route takes precedence over `config.controller_namespace`. Routes without a namespace block use the global controller namespace.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Nested Resources
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
resources :clusters do
|
|
110
|
+
resources :nodes do # → nodes/:id nested under clusters/:cluster_id
|
|
111
|
+
member { put :drain }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Nested resource routes inject all parent IDs into params. Example: `PUT clusters/c1/nodes/n2/drain` → `params[:cluster_id] = 'c1'`, `params[:id] = 'n2'`.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Inspecting Routes
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
BugBunny.routes.recognize('GET', 'nodes/123')
|
|
124
|
+
# => { controller: 'nodes', action: 'show', params: { 'id' => '123' }, namespace: nil }
|
|
125
|
+
|
|
126
|
+
BugBunny.routes.recognize('POST', 'api/v1/metrics')
|
|
127
|
+
# => { controller: 'metrics', action: 'create', params: {}, namespace: 'Api::V1' }
|
|
128
|
+
|
|
129
|
+
BugBunny.routes.recognize('GET', 'unknown/path')
|
|
130
|
+
# => nil
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Useful in tests to verify route definitions without sending real messages.
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
BugBunny applications can be tested at two levels: **unit tests** (with Bunny doubles) and **integration tests** (with a full mocked AMQP stack). No real RabbitMQ server is required.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# spec/spec_helper.rb
|
|
9
|
+
require 'bug_bunny'
|
|
10
|
+
require 'rspec'
|
|
11
|
+
|
|
12
|
+
Dir[File.join(__dir__, 'support', '**', '*.rb')].each { |f| require f }
|
|
13
|
+
|
|
14
|
+
RSpec.configure do |config|
|
|
15
|
+
config.before(:each) do
|
|
16
|
+
# Reset global state between tests
|
|
17
|
+
BugBunny.instance_variable_set(:@consumer_middlewares, nil)
|
|
18
|
+
BugBunny.instance_variable_set(:@routes, nil)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Bunny Doubles
|
|
26
|
+
|
|
27
|
+
Create lightweight doubles for Bunny objects so tests never touch the network:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# spec/support/bunny_mocks.rb
|
|
31
|
+
|
|
32
|
+
def build_bunny_channel(opts = {})
|
|
33
|
+
channel = instance_double(Bunny::Channel)
|
|
34
|
+
allow(channel).to receive(:open?).and_return(true)
|
|
35
|
+
allow(channel).to receive(:prefetch)
|
|
36
|
+
allow(channel).to receive(:confirm_select)
|
|
37
|
+
allow(channel).to receive(:ack)
|
|
38
|
+
allow(channel).to receive(:reject)
|
|
39
|
+
allow(channel).to receive(:close)
|
|
40
|
+
allow(channel).to receive(:default_exchange).and_return(build_bunny_exchange)
|
|
41
|
+
allow(channel).to receive(:topic).and_return(build_bunny_exchange)
|
|
42
|
+
allow(channel).to receive(:direct).and_return(build_bunny_exchange)
|
|
43
|
+
channel
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_bunny_connection(opts = {})
|
|
47
|
+
conn = instance_double(Bunny::Session)
|
|
48
|
+
allow(conn).to receive(:open?).and_return(true)
|
|
49
|
+
allow(conn).to receive(:start)
|
|
50
|
+
allow(conn).to receive(:create_channel).and_return(build_bunny_channel)
|
|
51
|
+
conn
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_bunny_exchange
|
|
55
|
+
exchange = instance_double(Bunny::Exchange)
|
|
56
|
+
allow(exchange).to receive(:publish)
|
|
57
|
+
exchange
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Unit Testing Controllers
|
|
64
|
+
|
|
65
|
+
Test controller actions directly via `Controller.call`, bypassing AMQP entirely:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
RSpec.describe NodesController do
|
|
69
|
+
let(:node) { Node.new(id: '42', name: 'web-01', status: 'active') }
|
|
70
|
+
|
|
71
|
+
describe '#show' do
|
|
72
|
+
it 'returns the node as JSON' do
|
|
73
|
+
allow(Node).to receive(:find).with('42').and_return(node)
|
|
74
|
+
|
|
75
|
+
response = NodesController.call(
|
|
76
|
+
headers: { type: 'nodes/42', 'x-http-method' => 'GET' },
|
|
77
|
+
body: ''
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
expect(response[:status]).to eq(200)
|
|
81
|
+
expect(response[:body][:name]).to eq('web-01')
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
describe '#show with missing node' do
|
|
86
|
+
it 'returns 404' do
|
|
87
|
+
allow(Node).to receive(:find).with('99').and_return(nil)
|
|
88
|
+
|
|
89
|
+
response = NodesController.call(
|
|
90
|
+
headers: { type: 'nodes/99', 'x-http-method' => 'GET' },
|
|
91
|
+
body: ''
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
expect(response[:status]).to eq(404)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Unit Testing before_action / after_action
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
RSpec.describe NodesController do
|
|
106
|
+
describe 'before_action :authenticate!' do
|
|
107
|
+
it 'returns 401 when token is missing' do
|
|
108
|
+
response = NodesController.call(
|
|
109
|
+
headers: { type: 'nodes', 'x-http-method' => 'GET' },
|
|
110
|
+
body: ''
|
|
111
|
+
# no X-Service-Token header
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
expect(response[:status]).to eq(401)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
describe 'after_action :emit_audit_event' do
|
|
119
|
+
it 'emits an audit event after create' do
|
|
120
|
+
expect(AuditLog).to receive(:record).with(hash_including(action: 'create'))
|
|
121
|
+
|
|
122
|
+
NodesController.call(
|
|
123
|
+
headers: { type: 'nodes', 'x-http-method' => 'POST', 'X-Service-Token' => 'valid' },
|
|
124
|
+
body: '{"node":{"name":"web-01","status":"pending"}}'
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Unit Testing Consumer Middleware
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
RSpec.describe TracingMiddleware do
|
|
137
|
+
subject(:middleware) { described_class.new(terminal) }
|
|
138
|
+
|
|
139
|
+
let(:terminal) { ->(di, props, body) { :processed } }
|
|
140
|
+
let(:delivery_info) { instance_double(Bunny::DeliveryInfo, routing_key: 'nodes') }
|
|
141
|
+
let(:properties) do
|
|
142
|
+
instance_double(Bunny::MessageProperties,
|
|
143
|
+
headers: { 'X-Trace-Id' => 'trace-123' },
|
|
144
|
+
correlation_id: 'corr-456'
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'extracts the trace header and sets context' do
|
|
149
|
+
expect(MyTracer).to receive(:with_trace).with('trace-123').and_yield
|
|
150
|
+
|
|
151
|
+
middleware.call(delivery_info, properties, '{}')
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'generates a new trace id when header is absent' do
|
|
155
|
+
allow(properties).to receive(:headers).and_return({})
|
|
156
|
+
expect(MyTracer).to receive(:with_trace).with(be_a(String)).and_yield
|
|
157
|
+
|
|
158
|
+
middleware.call(delivery_info, properties, '{}')
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Integration Testing with a Mock Consumer
|
|
166
|
+
|
|
167
|
+
For tests that exercise the full routing + controller stack, use an in-process integration helper:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
# spec/support/integration_helper.rb
|
|
171
|
+
|
|
172
|
+
module IntegrationHelper
|
|
173
|
+
def process_message(method:, path:, body: '', headers: {})
|
|
174
|
+
delivery_info = instance_double(Bunny::DeliveryInfo,
|
|
175
|
+
delivery_tag: 1,
|
|
176
|
+
routing_key: 'test'
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
properties = instance_double(Bunny::MessageProperties,
|
|
180
|
+
type: path,
|
|
181
|
+
reply_to: nil,
|
|
182
|
+
correlation_id: SecureRandom.uuid,
|
|
183
|
+
headers: { 'x-http-method' => method.to_s.upcase }.merge(headers)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
consumer = BugBunny::Consumer.new
|
|
187
|
+
# Allow channel operations on the mock session
|
|
188
|
+
allow(consumer.session.channel).to receive(:ack)
|
|
189
|
+
allow(consumer.session.channel).to receive(:reject)
|
|
190
|
+
allow(consumer.session.channel).to receive(:default_exchange).and_return(build_bunny_exchange)
|
|
191
|
+
|
|
192
|
+
consumer.send(:process_message, delivery_info, properties, body.to_json)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
RSpec.configure do |config|
|
|
197
|
+
config.include IntegrationHelper, type: :integration
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
# spec/integration/nodes_spec.rb, type: :integration
|
|
203
|
+
RSpec.describe 'Nodes API', type: :integration do
|
|
204
|
+
before do
|
|
205
|
+
BugBunny.routes.draw { resources :nodes }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'routes GET nodes/:id to NodesController#show' do
|
|
209
|
+
allow(Node).to receive(:find).with('42').and_return(Node.new(id: 42, name: 'web-01'))
|
|
210
|
+
|
|
211
|
+
process_message(method: :get, path: 'nodes/42')
|
|
212
|
+
|
|
213
|
+
expect(Node).to have_received(:find).with('42')
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it 'returns 404 for unregistered routes' do
|
|
217
|
+
response = process_message(method: :get, path: 'unknown/path')
|
|
218
|
+
# response is captured via the consumer's handle_fatal_error path
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Unit Testing Configuration Validation
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
RSpec.describe BugBunny::Configuration do
|
|
229
|
+
describe '#validate!' do
|
|
230
|
+
it 'raises ConfigurationError when host is blank' do
|
|
231
|
+
config = described_class.new
|
|
232
|
+
config.host = ''
|
|
233
|
+
|
|
234
|
+
expect { config.validate! }.to raise_error(BugBunny::ConfigurationError, /host is required/)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it 'raises ConfigurationError when port is out of range' do
|
|
238
|
+
config = described_class.new
|
|
239
|
+
config.host = 'localhost'
|
|
240
|
+
config.port = 99_999
|
|
241
|
+
|
|
242
|
+
expect { config.validate! }.to raise_error(BugBunny::ConfigurationError, /port must be in/)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Running Tests
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
source /opt/homebrew/opt/chruby/share/chruby/chruby.sh && chruby ruby-3.3.8
|
|
254
|
+
|
|
255
|
+
bundle exec rspec # all tests
|
|
256
|
+
bundle exec rspec spec/unit/ # unit tests only
|
|
257
|
+
bundle exec rspec spec/integration/ # integration tests only
|
|
258
|
+
bundle exec rspec spec/unit/consumer_spec.rb # single file
|
|
259
|
+
```
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Distributed Tracing
|
|
2
|
+
|
|
3
|
+
BugBunny propagates trace context through the full RPC cycle, from the producer to the consumer and back. The mechanism is tracer-agnostic: BugBunny provides hooks and you supply the tracer-specific logic.
|
|
4
|
+
|
|
5
|
+
## What BugBunny Propagates by Default
|
|
6
|
+
|
|
7
|
+
The `correlation_id` AMQP property travels automatically from producer to consumer on every request. The Consumer wraps the entire execution in a `logger.tagged(correlation_id)` block when the logger supports tagged logging (Rails' `ActiveSupport::TaggedLogging`).
|
|
8
|
+
|
|
9
|
+
This alone connects producer and consumer log lines without any configuration.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Full Bidirectional Propagation
|
|
14
|
+
|
|
15
|
+
For distributed tracing systems (OpenTelemetry, AWS X-Ray, Datadog APM, etc.) you need to:
|
|
16
|
+
|
|
17
|
+
1. **Inject** the current trace header into outgoing requests (producer side).
|
|
18
|
+
2. **Extract** the trace header from incoming messages (consumer side) — done via Consumer Middleware.
|
|
19
|
+
3. **Re-inject** the updated trace header into the RPC reply (consumer side).
|
|
20
|
+
4. **Hydrate** the trace context from the reply headers back in the calling thread (producer side).
|
|
21
|
+
|
|
22
|
+
Steps 3 and 4 handle the case where the consumer creates a child span — the parent needs to know about it.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Configuration Hooks
|
|
27
|
+
|
|
28
|
+
### `rpc_reply_headers`
|
|
29
|
+
|
|
30
|
+
A `Proc` called in the consumer thread just before sending the RPC reply. Its return value is merged into the reply AMQP headers.
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
BugBunny.configure do |config|
|
|
34
|
+
config.rpc_reply_headers = -> {
|
|
35
|
+
{ 'X-Trace-Header' => MyTracer.outgoing_header }
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Zero overhead when not set.
|
|
41
|
+
|
|
42
|
+
### `on_rpc_reply`
|
|
43
|
+
|
|
44
|
+
A `Proc` called in the producer thread after the RPC reply arrives, with the reply headers as argument. Use it to hydrate the trace context in the calling thread.
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
BugBunny.configure do |config|
|
|
48
|
+
config.on_rpc_reply = ->(headers) {
|
|
49
|
+
MyTracer.hydrate(headers['X-Trace-Header'])
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Full Example (tracer-agnostic)
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# config/initializers/bug_bunny.rb
|
|
60
|
+
|
|
61
|
+
BugBunny.configure do |config|
|
|
62
|
+
# ... connection config ...
|
|
63
|
+
|
|
64
|
+
# Step 3: inject updated trace header into RPC reply
|
|
65
|
+
config.rpc_reply_headers = -> {
|
|
66
|
+
{ 'X-Trace-Header' => MyTracer.generate_outgoing_header }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Step 4: hydrate trace context in the producer thread after reply
|
|
70
|
+
config.on_rpc_reply = ->(headers) {
|
|
71
|
+
MyTracer.hydrate_from_header(headers['X-Trace-Header'])
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Step 1: inject trace header into outgoing requests (client middleware)
|
|
76
|
+
class TraceInjectionMiddleware < BugBunny::Middleware::Base
|
|
77
|
+
def call(request)
|
|
78
|
+
request.headers['X-Trace-Header'] = MyTracer.generate_outgoing_header
|
|
79
|
+
app.call(request)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Step 2: extract trace header from incoming messages (consumer middleware)
|
|
84
|
+
class TraceExtractionMiddleware < BugBunny::ConsumerMiddleware::Base
|
|
85
|
+
def call(delivery_info, properties, body)
|
|
86
|
+
incoming_header = properties.headers&.dig('X-Trace-Header')
|
|
87
|
+
MyTracer.with_trace_from_header(incoming_header) do
|
|
88
|
+
@app.call(delivery_info, properties, body)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Register both
|
|
94
|
+
BugBunny::Resource.client_middleware { |s| s.use TraceInjectionMiddleware }
|
|
95
|
+
BugBunny.consumer_middlewares.use TraceExtractionMiddleware
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Fire-and-Forget Tracing
|
|
101
|
+
|
|
102
|
+
For `:publish` (fire-and-forget) calls, there is no reply, so `on_rpc_reply` and `rpc_reply_headers` do not apply. Use only the client middleware to inject the outgoing trace header:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
class TraceInjectionMiddleware < BugBunny::Middleware::Base
|
|
106
|
+
def call(request)
|
|
107
|
+
request.headers['X-Trace-Header'] = MyTracer.generate_outgoing_header
|
|
108
|
+
app.call(request)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The consumer middleware extracts it on the other side regardless of whether the call was RPC or fire-and-forget.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## correlation_id
|
|
118
|
+
|
|
119
|
+
BugBunny sets `correlation_id` automatically on every RPC request (used internally to match replies). It is also forwarded as a log tag. If your tracer uses a separate header (e.g., `traceparent`), use the `X-Trace-Header` pattern above. If you want to use `correlation_id` as the trace ID, read it from `properties.correlation_id` in your consumer middleware.
|
data/lib/bug_bunny/client.rb
CHANGED
|
@@ -31,6 +31,7 @@ module BugBunny
|
|
|
31
31
|
# @raise [ArgumentError] Si no se proporciona un `pool`.
|
|
32
32
|
def initialize(pool:)
|
|
33
33
|
raise ArgumentError, "BugBunny::Client requiere un 'pool:'" if pool.nil?
|
|
34
|
+
|
|
34
35
|
@pool = pool
|
|
35
36
|
@stack = BugBunny::Middleware::Stack.new
|
|
36
37
|
@delivery_mode = :rpc
|
|
@@ -96,7 +97,7 @@ module BugBunny
|
|
|
96
97
|
req = BugBunny::Request.new(url)
|
|
97
98
|
|
|
98
99
|
# 2. Syntactic Sugar: Mapeo de argumentos a atributos del Request
|
|
99
|
-
req.delivery_mode = delivery_mode
|
|
100
|
+
req.delivery_mode = delivery_mode # Default del cliente
|
|
100
101
|
req.delivery_mode = args[:delivery_mode] if args[:delivery_mode]
|
|
101
102
|
req.method = args[:method] if args[:method]
|
|
102
103
|
req.body = args[:body] if args[:body]
|
|
@@ -109,33 +110,56 @@ module BugBunny
|
|
|
109
110
|
req.exchange_options = args[:exchange_options] if args[:exchange_options]
|
|
110
111
|
req.queue_options = args[:queue_options] if args[:queue_options]
|
|
111
112
|
|
|
112
|
-
req.params
|
|
113
|
-
req.headers.merge!(args[:headers])
|
|
113
|
+
req.params = args[:params] if args[:params]
|
|
114
|
+
req.headers.merge!(args[:headers]) if args[:headers]
|
|
114
115
|
|
|
115
116
|
# 3. Configuración del usuario (bloque específico por request)
|
|
116
117
|
yield req if block_given?
|
|
117
118
|
|
|
118
119
|
# 4. Ejecución dentro del Pool
|
|
120
|
+
# Session y Producer se reutilizan por slot de conexión (ver #session_for / #producer_for).
|
|
119
121
|
@pool.with do |conn|
|
|
120
|
-
session
|
|
121
|
-
producer =
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
app = @stack.build(final_action)
|
|
133
|
-
app.call(req)
|
|
134
|
-
ensure
|
|
135
|
-
# Aseguramos el cierre del canal pero mantenemos la conexión del pool
|
|
136
|
-
session.close
|
|
137
|
-
end
|
|
122
|
+
session = session_for(conn)
|
|
123
|
+
producer = producer_for(conn, session)
|
|
124
|
+
|
|
125
|
+
# Mapeo de delivery_mode al método del productor (:rpc o :fire)
|
|
126
|
+
method_name = req.delivery_mode == :publish ? :fire : :rpc
|
|
127
|
+
|
|
128
|
+
# Onion Architecture: La acción final es llamar al Producer real.
|
|
129
|
+
final_action = ->(env) { producer.send(method_name, env) }
|
|
130
|
+
|
|
131
|
+
# Construimos y ejecutamos la cadena de middlewares
|
|
132
|
+
app = @stack.build(final_action)
|
|
133
|
+
app.call(req)
|
|
138
134
|
end
|
|
139
135
|
end
|
|
136
|
+
|
|
137
|
+
# Recupera o crea la Session asociada al slot de conexión dado.
|
|
138
|
+
#
|
|
139
|
+
# La Session (y su canal AMQP) se almacena como ivar en el objeto `conn`.
|
|
140
|
+
# Thread-safe sin mutex adicional: ConnectionPool garantiza que cada `conn`
|
|
141
|
+
# es usado por un único thread a la vez.
|
|
142
|
+
#
|
|
143
|
+
# @param conn [Bunny::Session] Conexión activa del pool.
|
|
144
|
+
# @return [BugBunny::Session]
|
|
145
|
+
def session_for(conn)
|
|
146
|
+
conn.instance_variable_get(:@_bug_bunny_session) ||
|
|
147
|
+
conn.instance_variable_set(:@_bug_bunny_session, BugBunny::Session.new(conn))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Recupera o crea el Producer asociado al slot de conexión dado.
|
|
151
|
+
#
|
|
152
|
+
# El Producer debe cachearse junto con la Session porque registra un
|
|
153
|
+
# `basic_consume` sobre el canal para escuchar replies RPC. Si se creara
|
|
154
|
+
# un Producer nuevo por request (con el canal reutilizado), se intentaría
|
|
155
|
+
# registrar un segundo consumidor sobre el mismo canal, causando un error AMQP.
|
|
156
|
+
#
|
|
157
|
+
# @param conn [Bunny::Session] Conexión activa del pool.
|
|
158
|
+
# @param session [BugBunny::Session] Session ya resuelta para `conn`.
|
|
159
|
+
# @return [BugBunny::Producer]
|
|
160
|
+
def producer_for(conn, session)
|
|
161
|
+
conn.instance_variable_get(:@_bug_bunny_producer) ||
|
|
162
|
+
conn.instance_variable_set(:@_bug_bunny_producer, BugBunny::Producer.new(session))
|
|
163
|
+
end
|
|
140
164
|
end
|
|
141
165
|
end
|
|
@@ -14,6 +14,28 @@ module BugBunny
|
|
|
14
14
|
# config.health_check_file = '/tmp/bug_bunny_health'
|
|
15
15
|
# end
|
|
16
16
|
class Configuration
|
|
17
|
+
# Reglas de validación por atributo.
|
|
18
|
+
# Solo cubre los atributos de conexión y timeout — los demás (logger, procs, hashes)
|
|
19
|
+
# son tipos arbitrarios que no tienen sentido validar de forma genérica.
|
|
20
|
+
#
|
|
21
|
+
# Claves soportadas:
|
|
22
|
+
# - `:type` — clase que debe responder `is_a?`
|
|
23
|
+
# - `:required` — si `true`, nil o string vacío lanzan ConfigurationError
|
|
24
|
+
# - `:range` — rango válido de valores (solo para Integer)
|
|
25
|
+
VALIDATIONS = {
|
|
26
|
+
host: { type: String, required: true },
|
|
27
|
+
port: { type: Integer, required: true, range: 1..65_535 },
|
|
28
|
+
username: { type: String, required: true },
|
|
29
|
+
password: { type: String, required: true },
|
|
30
|
+
vhost: { type: String, required: true },
|
|
31
|
+
heartbeat: { type: Integer, range: 0..3_600 },
|
|
32
|
+
connection_timeout: { type: Integer, range: 1..300 },
|
|
33
|
+
read_timeout: { type: Integer, range: 1..300 },
|
|
34
|
+
write_timeout: { type: Integer, range: 1..300 },
|
|
35
|
+
rpc_timeout: { type: Integer, range: 1..3_600 },
|
|
36
|
+
channel_prefetch: { type: Integer, range: 1..10_000 }
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
17
39
|
# @return [String] Host o IP del servidor RabbitMQ (ej: 'localhost').
|
|
18
40
|
attr_accessor :host
|
|
19
41
|
|
|
@@ -163,5 +185,46 @@ module BugBunny
|
|
|
163
185
|
def url
|
|
164
186
|
"amqp://#{username}:#{password}@#{host}:#{port}/#{vhost}"
|
|
165
187
|
end
|
|
188
|
+
|
|
189
|
+
# Valida todos los atributos definidos en {VALIDATIONS}.
|
|
190
|
+
# Se invoca automáticamente al final de {BugBunny.configure}.
|
|
191
|
+
#
|
|
192
|
+
# @raise [BugBunny::ConfigurationError] Si algún atributo es inválido.
|
|
193
|
+
# @return [void]
|
|
194
|
+
def validate!
|
|
195
|
+
VALIDATIONS.each do |attr, rules|
|
|
196
|
+
value = send(attr)
|
|
197
|
+
validate_required!(attr, value, rules)
|
|
198
|
+
next if value.nil?
|
|
199
|
+
|
|
200
|
+
validate_type!(attr, value, rules)
|
|
201
|
+
validate_range!(attr, value, rules)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def validate_required!(attr, value, rules)
|
|
208
|
+
return unless rules[:required]
|
|
209
|
+
return unless value.nil? || (value.is_a?(String) && value.empty?)
|
|
210
|
+
|
|
211
|
+
raise BugBunny::ConfigurationError, "#{attr} is required"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def validate_type!(attr, value, rules)
|
|
215
|
+
return unless rules[:type]
|
|
216
|
+
return if value.is_a?(rules[:type])
|
|
217
|
+
|
|
218
|
+
raise BugBunny::ConfigurationError,
|
|
219
|
+
"#{attr} must be a #{rules[:type]}, got #{value.class}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def validate_range!(attr, value, rules)
|
|
223
|
+
return unless rules[:range]
|
|
224
|
+
return if rules[:range].cover?(value)
|
|
225
|
+
|
|
226
|
+
raise BugBunny::ConfigurationError,
|
|
227
|
+
"#{attr} must be in #{rules[:range]}, got #{value.inspect}"
|
|
228
|
+
end
|
|
166
229
|
end
|
|
167
230
|
end
|