bug_bunny 4.6.1 → 4.7.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/.claude/commands/pr.md +42 -0
- data/.claude/commands/release.md +41 -0
- data/.claude/commands/rubocop.md +22 -0
- data/.claude/commands/test.md +28 -0
- data/.claude/commands/yard.md +46 -0
- data/CHANGELOG.md +37 -15
- data/CLAUDE.md +228 -0
- data/README.md +154 -221
- data/Rakefile +19 -3
- 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 +41 -18
- 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 +29 -4
- data/lib/bug_bunny/exception.rb +4 -0
- data/lib/bug_bunny/observability.rb +23 -1
- data/lib/bug_bunny/resource.rb +31 -21
- data/lib/bug_bunny/routing/route.rb +6 -1
- data/lib/bug_bunny/routing/route_set.rb +30 -3
- data/lib/bug_bunny/session.rb +18 -11
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +1 -0
- data/mejoras.md +33 -0
- data/plan_test.txt +63 -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 +36 -3
- data/sig/bug_bunny.rbs +0 -4
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Resource ORM
|
|
2
|
+
|
|
3
|
+
`BugBunny::Resource` provides an ActiveRecord-like interface for remote services. Each Resource class represents a resource type in another microservice, reachable via RabbitMQ.
|
|
4
|
+
|
|
5
|
+
## Defining a Resource
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class RemoteNode < BugBunny::Resource
|
|
9
|
+
# AMQP infrastructure
|
|
10
|
+
self.exchange = 'inventory_exchange'
|
|
11
|
+
self.exchange_type = 'direct' # default
|
|
12
|
+
self.resource_name = 'nodes' # used as the path prefix and routing key
|
|
13
|
+
|
|
14
|
+
# Typed attributes (ActiveModel::Attributes)
|
|
15
|
+
attribute :name, :string
|
|
16
|
+
attribute :status, :string
|
|
17
|
+
attribute :cpu_cores, :integer
|
|
18
|
+
attribute :active, :boolean
|
|
19
|
+
|
|
20
|
+
# Validations (ActiveModel::Validations)
|
|
21
|
+
validates :name, presence: true
|
|
22
|
+
validates :status, inclusion: { in: %w[pending active draining decommissioned] }
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Connection Pool
|
|
27
|
+
|
|
28
|
+
All Resource classes in a service typically share one pool:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# config/initializers/bug_bunny.rb
|
|
32
|
+
BUG_BUNNY_POOL = ConnectionPool.new(size: 5, timeout: 5) do
|
|
33
|
+
BugBunny.create_connection
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
BugBunny::Resource.connection_pool = BUG_BUNNY_POOL
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Individual classes can override:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
RemoteNode.connection_pool = OTHER_POOL
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## CRUD
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# Find by ID — returns nil on 404
|
|
51
|
+
node = RemoteNode.find('node-123')
|
|
52
|
+
|
|
53
|
+
# List all
|
|
54
|
+
nodes = RemoteNode.all
|
|
55
|
+
|
|
56
|
+
# Filter — query params forwarded to the consumer
|
|
57
|
+
nodes = RemoteNode.where(status: 'active')
|
|
58
|
+
nodes = RemoteNode.where(q: { cpu_cores: 4 }, page: 2)
|
|
59
|
+
|
|
60
|
+
# Create
|
|
61
|
+
node = RemoteNode.create(name: 'web-01', status: 'pending')
|
|
62
|
+
node.persisted? # => true if save succeeded
|
|
63
|
+
|
|
64
|
+
# Update
|
|
65
|
+
node = RemoteNode.find('node-123')
|
|
66
|
+
node.status = 'active'
|
|
67
|
+
node.save # PUT nodes/node-123
|
|
68
|
+
|
|
69
|
+
# Update (shorthand)
|
|
70
|
+
node.update(status: 'active', name: 'web-01')
|
|
71
|
+
|
|
72
|
+
# Destroy
|
|
73
|
+
node.destroy # DELETE nodes/node-123
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Typed vs Dynamic Attributes
|
|
79
|
+
|
|
80
|
+
### Typed attributes
|
|
81
|
+
|
|
82
|
+
Declared with `attribute :name, :type`. Benefit from ActiveModel coercions, dirty tracking, and validations:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
attribute :cpu_cores, :integer
|
|
86
|
+
attribute :enabled, :boolean
|
|
87
|
+
attribute :score, :decimal
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Dynamic attributes
|
|
91
|
+
|
|
92
|
+
Any key received from the remote service that is not declared as a typed attribute is stored dynamically and accessible via `method_missing`:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
node = RemoteNode.find('node-123')
|
|
96
|
+
node.docker_id # => "abc123xyz" (not declared, but present in the response)
|
|
97
|
+
node.docker_id = 'new-id'
|
|
98
|
+
node.changed? # => true
|
|
99
|
+
node.changed # => ['docker_id']
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Dynamic attributes participate in `changed?`, `changed`, and `changes_to_send` — so they are serialized correctly on `save`.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Change Tracking
|
|
107
|
+
|
|
108
|
+
BugBunny::Resource merges ActiveModel::Dirty (for typed attributes) with its own tracking for dynamic attributes.
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
node = RemoteNode.find('node-123') # changes cleared after find
|
|
112
|
+
node.changed? # => false
|
|
113
|
+
|
|
114
|
+
node.status = 'draining'
|
|
115
|
+
node.changed? # => true
|
|
116
|
+
node.changed # => ['status']
|
|
117
|
+
|
|
118
|
+
node.save # sends only changed attrs
|
|
119
|
+
node.changed? # => false (cleared after save)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`save` on a new record (not persisted) sends all attributes.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Validations
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
node = RemoteNode.new(name: '', status: 'invalid')
|
|
130
|
+
node.valid? # => false
|
|
131
|
+
node.errors.full_messages
|
|
132
|
+
# => ["Name can't be blank", "Status is not included in the list"]
|
|
133
|
+
|
|
134
|
+
node.save # => false (does not send the request)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Remote validation errors (422 responses) are loaded back into the object:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
node = RemoteNode.create(name: 'duplicate-name')
|
|
141
|
+
node.persisted? # => false
|
|
142
|
+
node.errors[:name] # => ["has already been taken"]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Callbacks
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
class RemoteNode < BugBunny::Resource
|
|
151
|
+
before_save :normalize_name
|
|
152
|
+
after_create :notify_provisioner
|
|
153
|
+
around_save :with_timing
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def normalize_name
|
|
158
|
+
self.name = name.to_s.downcase.strip
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Available callbacks: `before_save`, `after_save`, `before_create`, `after_create`, `before_update`, `after_update`, `before_destroy`, `after_destroy`.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Dynamic Exchange Configuration with `.with`
|
|
168
|
+
|
|
169
|
+
Override AMQP settings for a single operation without changing the class defaults:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
# Different exchange for a single call
|
|
173
|
+
RemoteNode.with(exchange: 'us-east-inventory').where(status: 'active')
|
|
174
|
+
|
|
175
|
+
# Different routing key
|
|
176
|
+
RemoteNode.with(routing_key: 'nodes.priority').find('node-123')
|
|
177
|
+
|
|
178
|
+
# Chain is single-use — call .with again for the next operation
|
|
179
|
+
RemoteNode.with(exchange: 'staging').create(name: 'test-node')
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`.with` sets thread-local values that are cleaned up after the single operation completes, even if an exception is raised.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Payload Wrapping
|
|
187
|
+
|
|
188
|
+
By default, `save` wraps the payload under a root key derived from the model name:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
# RemoteNode → root key: 'node'
|
|
192
|
+
node.save
|
|
193
|
+
# Sends: { "node" => { "name" => "web-01", "status" => "active" } }
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The consumer can then use `params.require(:node)` in the controller. Override the root key:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
self.param_key = 'server'
|
|
200
|
+
```
|
|
@@ -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.
|