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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/pr.md +42 -0
  3. data/.claude/commands/release.md +41 -0
  4. data/.claude/commands/rubocop.md +22 -0
  5. data/.claude/commands/test.md +28 -0
  6. data/.claude/commands/yard.md +46 -0
  7. data/CHANGELOG.md +37 -15
  8. data/CLAUDE.md +228 -0
  9. data/README.md +154 -221
  10. data/Rakefile +19 -3
  11. data/docs/concepts.md +140 -0
  12. data/docs/howto/controller.md +194 -0
  13. data/docs/howto/middleware_client.md +119 -0
  14. data/docs/howto/middleware_consumer.md +127 -0
  15. data/docs/howto/rails.md +214 -0
  16. data/docs/howto/resource.md +200 -0
  17. data/docs/howto/routing.md +133 -0
  18. data/docs/howto/testing.md +259 -0
  19. data/docs/howto/tracing.md +119 -0
  20. data/lib/bug_bunny/client.rb +41 -18
  21. data/lib/bug_bunny/configuration.rb +63 -0
  22. data/lib/bug_bunny/consumer.rb +51 -37
  23. data/lib/bug_bunny/consumer_middleware.rb +14 -5
  24. data/lib/bug_bunny/controller.rb +29 -4
  25. data/lib/bug_bunny/exception.rb +4 -0
  26. data/lib/bug_bunny/observability.rb +23 -1
  27. data/lib/bug_bunny/resource.rb +31 -21
  28. data/lib/bug_bunny/routing/route.rb +6 -1
  29. data/lib/bug_bunny/routing/route_set.rb +30 -3
  30. data/lib/bug_bunny/session.rb +18 -11
  31. data/lib/bug_bunny/version.rb +1 -1
  32. data/lib/bug_bunny.rb +1 -0
  33. data/mejoras.md +33 -0
  34. data/plan_test.txt +63 -0
  35. data/spec/integration/client_spec.rb +117 -0
  36. data/spec/integration/consumer_middleware_spec.rb +86 -0
  37. data/spec/integration/controller_spec.rb +140 -0
  38. data/spec/integration/error_handling_spec.rb +57 -0
  39. data/spec/integration/infrastructure_spec.rb +52 -0
  40. data/spec/integration/resource_spec.rb +113 -0
  41. data/spec/spec_helper.rb +70 -0
  42. data/spec/support/bunny_mocks.rb +18 -0
  43. data/spec/support/integration_helper.rb +87 -0
  44. data/spec/unit/client_session_pool_spec.rb +159 -0
  45. data/spec/unit/configuration_spec.rb +164 -0
  46. data/spec/unit/consumer_middleware_spec.rb +129 -0
  47. data/spec/unit/consumer_spec.rb +90 -0
  48. data/spec/unit/controller_after_action_spec.rb +155 -0
  49. data/spec/unit/observability_spec.rb +167 -0
  50. data/spec/unit/resource_attributes_spec.rb +69 -0
  51. data/spec/unit/session_spec.rb +98 -0
  52. metadata +36 -3
  53. 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.