bug_bunny 4.7.0 → 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 +15 -4
- data/.claude/commands/release.md +29 -18
- data/.claude/commands/service-ai-setup.md +168 -0
- data/CHANGELOG.md +13 -0
- data/CLAUDE.md +12 -0
- 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/lib/bug_bunny/client.rb +4 -3
- data/lib/bug_bunny/controller.rb +10 -14
- data/lib/bug_bunny/exception.rb +1 -1
- data/lib/bug_bunny/middleware/raise_error.rb +3 -3
- data/lib/bug_bunny/observability.rb +5 -5
- 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 +51 -21
- data/lib/bug_bunny/routing/route_set.rb +32 -21
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +3 -2
- data/lib/generators/bug_bunny/install/install_generator.rb +45 -5
- data/lib/tasks/bug_bunny.rake +50 -0
- data/skills-lock.json +10 -0
- metadata +17 -3
- data/mejoras.md +0 -33
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
## FAQ — External (Integrator / Consumer of BugBunny)
|
|
2
|
+
|
|
3
|
+
### How do I set up BugBunny in a Rails app?
|
|
4
|
+
|
|
5
|
+
Add to Gemfile: `gem 'bug_bunny'` and `gem 'connection_pool'`. Run `rails generate bug_bunny:install`. Edit `config/initializers/bug_bunny.rb` with your RabbitMQ credentials. Create a pool: `MY_POOL = ConnectionPool.new(size: 5) { BugBunny.create_connection }`. Assign it: `BugBunny::Resource.connection_pool = MY_POOL`. See `docs/howto/rails.md` for the complete setup.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
### How do I make an RPC call (blocking request)?
|
|
10
|
+
|
|
11
|
+
Use `Resource.find` / `Resource.where` / `Resource.create` — they all do RPC internally. For lower-level control: `client.request('users/1', method: :get, exchange: 'users_exchange', routing_key: 'users')`. The call blocks until the remote service replies or `rpc_timeout` expires.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
### How do I publish without waiting for a reply?
|
|
16
|
+
|
|
17
|
+
Use `client.publish('events', method: :post, exchange: 'events_exchange', routing_key: 'events', body: { ... })`. Fire-and-forget — does not block. No RPC timeout applies.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
### What happens when `find` returns nil vs raises?
|
|
22
|
+
|
|
23
|
+
`Resource.find` returns `nil` on 404 (does not raise). `Resource.where` returns `[]` on 404. Both raise `BugBunny::RequestTimeout` if the remote service does not reply within `rpc_timeout`. They raise `BugBunny::ServerError` on 5xx responses.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
### How do I handle validation errors from the remote service?
|
|
28
|
+
|
|
29
|
+
`resource.save` returns `false` on 422 and loads the remote errors into `resource.errors`. Check `resource.valid?` then `resource.errors.full_messages`. The remote service must render `{ errors: { field: ['message'] } }` for the errors to be auto-loaded.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
### How do I use `.with` to override the exchange per call?
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# Block form (preferred — thread-safe, restores after block)
|
|
37
|
+
Order.with(exchange: 'priority_exchange', routing_key: 'priority') do
|
|
38
|
+
Order.find(id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Single-call proxy
|
|
42
|
+
Order.with(exchange: 'priority_exchange').find(id)
|
|
43
|
+
# ScopeProxy is single-use — calling a second method raises BugBunny::Error
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
### How do I define a Resource with typed attributes?
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
class User < BugBunny::Resource
|
|
52
|
+
self.exchange = 'users_exchange'
|
|
53
|
+
|
|
54
|
+
attribute :id, :integer
|
|
55
|
+
attribute :name, :string
|
|
56
|
+
attribute :email, :string
|
|
57
|
+
|
|
58
|
+
validates :name, presence: true
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Typed attributes get ActiveModel coercion and dirty tracking. Attributes not declared with `attribute` are handled dynamically via `method_missing` and tracked in `@extra_attributes`.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
### How do I add client-side middleware to a Resource?
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
class Order < BugBunny::Resource
|
|
70
|
+
client_middleware do |stack|
|
|
71
|
+
stack.use MyRetryMiddleware
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Middlewares are inherited by subclasses and applied in registration order (first = outermost). `RaiseError` and `JsonResponse` are always added as the innermost middlewares by `Resource` — do not add them manually.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
### How do I run the Consumer in a Rails app?
|
|
81
|
+
|
|
82
|
+
The Consumer is a blocking loop. Run it in a separate process, not inside Puma:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# lib/tasks/rabbit.rake
|
|
86
|
+
task consumer: :environment do
|
|
87
|
+
conn = BugBunny.create_connection
|
|
88
|
+
consumer = BugBunny::Consumer.new(conn)
|
|
89
|
+
trap('TERM') { consumer.shutdown; exit }
|
|
90
|
+
trap('INT') { consumer.shutdown; exit }
|
|
91
|
+
consumer.subscribe(
|
|
92
|
+
queue_name: 'my_queue', exchange_name: 'my_exchange', routing_key: 'my_key'
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
### How do I write a Controller?
|
|
100
|
+
|
|
101
|
+
Subclass `BugBunny::Controller`. Place it in the namespace from `config.controller_namespace` (default: `BugBunny::Controllers`). Implement action methods (`index`, `show`, `create`, `update`, `destroy` or custom). Call `render(status:, json:)` to respond. The Consumer routes messages to `YourController.call(headers:, body:)`.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
### How do I propagate trace context through RabbitMQ?
|
|
106
|
+
|
|
107
|
+
On the consumer side, inject headers into replies:
|
|
108
|
+
```ruby
|
|
109
|
+
config.rpc_reply_headers = -> { { 'X-Trace-Id' => Tracer.current } }
|
|
110
|
+
```
|
|
111
|
+
On the producer side, hydrate context from the reply:
|
|
112
|
+
```ruby
|
|
113
|
+
config.on_rpc_reply = ->(headers) { Tracer.hydrate(headers['X-Trace-Id']) }
|
|
114
|
+
```
|
|
115
|
+
For consumer-side middleware (propagating from incoming request), use `ConsumerMiddleware`.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
### How do I configure health checks for Kubernetes?
|
|
120
|
+
|
|
121
|
+
Set `config.health_check_file = '/app/tmp/bug_bunny_health'`. BugBunny touches that file every `health_check_interval` seconds (default: 60) after verifying the RabbitMQ connection. Add a `livenessProbe` in your Kubernetes manifest checking for that file's existence.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
### What is the default RPC timeout and how do I change it?
|
|
126
|
+
|
|
127
|
+
Default: 10 seconds. Override globally: `config.rpc_timeout = 30`. Override per request: `client.request('users/1', method: :get, exchange: ..., timeout: 5)`. When exceeded, `BugBunny::RequestTimeout` is raised.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
### Do I need to declare exchanges and queues manually?
|
|
132
|
+
|
|
133
|
+
No. BugBunny declares them automatically when `subscribe` (consumer) or the first RPC call (producer) is made. Use `config.exchange_options` and `config.queue_options` for global defaults (e.g., `{ durable: true }`). Override per resource with `Resource.exchange_options=` or per call with `.with(exchange_options:)`.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
## FAQ — Internal (Maintainer / Developer of BugBunny)
|
|
2
|
+
|
|
3
|
+
### Why is Producer cached per connection slot instead of instantiated per request?
|
|
4
|
+
|
|
5
|
+
`Producer#initialize` calls `channel.basic_consume` on the `amq.rabbitmq.reply-to` pseudo-queue to listen for RPC replies. Creating a second `basic_consume` on the same channel raises an AMQP error. Therefore, one Producer per channel (= per connection slot) is mandatory. The cache is stored as `@_bug_bunny_producer` ivar on the Bunny connection object, which is safe because `ConnectionPool` guarantees each slot is used by one thread at a time.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
### Why is Session cached per connection slot?
|
|
10
|
+
|
|
11
|
+
Same reason as Producer — and the Session wraps the channel. Recreating a Session would create a new channel and invalidate the cached exchange/queue objects. Caching is stored as `@_bug_bunny_session` on the Bunny connection object.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
### How does the Session handle thread safety for exchange/queue declarations?
|
|
16
|
+
|
|
17
|
+
Session uses a double-checked locking pattern with a `Mutex` for its caches (`@exchange_cache`, `@queue_cache`). The read path (fast path) checks without locking; the write path acquires the mutex and checks again before writing. This avoids redundant AMQP declarations without making every read a mutex acquisition.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
### Why does Resource have two dirty tracking mechanisms?
|
|
22
|
+
|
|
23
|
+
ActiveModel::Dirty only tracks attributes declared with `attribute :name, :type`. BugBunny Resources allow dynamic attributes (unknown at class definition time) stored in `@extra_attributes`. These are tracked manually via `@dynamic_changes` (a `Set`). The `changed?` and `changed` methods merge both to produce a unified change list.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
### How does the 3-level config cascade work in Resource?
|
|
28
|
+
|
|
29
|
+
`resolve_config(key, instance_var)` checks in order:
|
|
30
|
+
1. `Thread.current["bb_#{object_id}_#{key}"]` — set by `.with(...)`.
|
|
31
|
+
2. `self.instance_variable_get(instance_var)` — class-level static config.
|
|
32
|
+
3. Walk `superclass` chain up to (but not including) `BugBunny::Resource`.
|
|
33
|
+
|
|
34
|
+
This means a subclass can override the parent's config, and `.with` overrides everything for the duration of its block.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
### How does ConsumerMiddleware::Stack protect against concurrent registration?
|
|
39
|
+
|
|
40
|
+
`Stack#use` wraps the append in a `@mutex.synchronize` block. The execution path (`call`) does not use the mutex — it reads `@middlewares` once at call time (snapshot). This is safe because Rails/Zeitwerk loads middleware registrations at boot, before any concurrent requests.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
### How does routing with namespaces work internally?
|
|
45
|
+
|
|
46
|
+
`namespace :admin { resources :users }` generates `Route` objects with:
|
|
47
|
+
- `path_prefix: 'admin'` — prepended to the path pattern.
|
|
48
|
+
- `namespace: 'Admin::Controllers'` — overrides the default namespace for `constantize`.
|
|
49
|
+
|
|
50
|
+
In `process_message`, the Consumer resolves `base_namespace = route_info[:namespace] || config.controller_namespace` before calling `constantize`.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
### What is the RCE prevention in the Consumer?
|
|
55
|
+
|
|
56
|
+
After `constantize`, the Consumer checks `controller_class < BugBunny::Controller`. This prevents an attacker from crafting a message with a `type` header pointing to an arbitrary Ruby class (e.g., `Kernel` or `File`) and triggering its methods. Any class not inheriting from `BugBunny::Controller` gets a 403 reply and the message is rejected.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
### How does `before_action` halt chain propagation?
|
|
61
|
+
|
|
62
|
+
`run_before_actions` iterates before-actions and calls each. After each call, it checks `rendered_response`. If a filter called `render(...)`, `@rendered_response` is set. The method returns `false`, and `core_execution` skips the action and `after_actions`. After-actions do not run if the before-action chain was halted.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
### How does `after_action` differ from `around_action`?
|
|
67
|
+
|
|
68
|
+
`after_action` runs after the action method returns, but only if no `before_action` halted the chain and no exception was raised. `around_action` wraps the entire execution including before/after actions and is responsible for yielding. Use `around_action` when you need cleanup regardless of exceptions.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
### How does `safe_log` prevent log failures from affecting main flow?
|
|
73
|
+
|
|
74
|
+
`safe_log` wraps every logger call in `rescue StandardError`. It also filters sensitive keys by checking if the key string matches a predefined set of patterns (`password`, `pass`, `passwd`, `secret`, `token`, `api_key`, `auth`) before emitting values. Blocks are always passed to `logger.debug` to avoid string interpolation cost at non-debug levels.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
### How is the Consumer health check implemented?
|
|
79
|
+
|
|
80
|
+
`start_health_check` creates a `Concurrent::TimerTask` that runs every `health_check_interval` seconds. Each tick calls `channel.queue_declare(queue_name, passive: true)` — a passive declare that verifies the queue exists without creating it. On failure, it calls `session.close`, which triggers the reconnect loop in `subscribe`. The health file is touched on success.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
### What triggers the reconnect loop in Consumer?
|
|
85
|
+
|
|
86
|
+
Any `StandardError` raised inside the `subscribe` rescue block. The loop uses exponential backoff: `wait = [interval * 2^(attempt-1), max_interval].min`. If `max_reconnect_attempts` is set and exceeded, the error is re-raised, killing the process.
|
data/docs/ai/glossary.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
## Glossary
|
|
2
|
+
|
|
3
|
+
**AMQP** — Advanced Message Queuing Protocol. The binary wire protocol used by RabbitMQ. BugBunny uses Bunny as its AMQP client.
|
|
4
|
+
|
|
5
|
+
**Exchange** — A RabbitMQ routing point. Publishers send messages to exchanges; exchanges route them to queues based on bindings and routing keys. BugBunny supports `direct`, `topic`, and `fanout` types.
|
|
6
|
+
|
|
7
|
+
**Queue** — A buffer that holds messages until they are consumed. A queue is bound to an exchange with a routing key pattern.
|
|
8
|
+
|
|
9
|
+
**Routing key** — A string label attached to a published message. The exchange uses it to decide which queues receive the message. In BugBunny, the routing key defaults to the pluralized resource name (e.g., `users`).
|
|
10
|
+
|
|
11
|
+
**Binding** — The link between an exchange and a queue, specifying which routing key patterns match.
|
|
12
|
+
|
|
13
|
+
**Session** — `BugBunny::Session`. A wrapper around a Bunny channel. Declares exchanges and queues, caches their AMQP objects, and handles double-checked locking for thread safety.
|
|
14
|
+
|
|
15
|
+
**Producer** — `BugBunny::Producer`. Publishes messages to RabbitMQ. Implements both RPC (blocking) and fire-and-forget (non-blocking) modes. One Producer is cached per connection slot.
|
|
16
|
+
|
|
17
|
+
**Consumer** — `BugBunny::Consumer`. The subscribe loop that runs in a worker process. Receives messages, routes them through middleware and the router, dispatches to a controller, and sends RPC replies.
|
|
18
|
+
|
|
19
|
+
**RPC (Remote Procedure Call)** — A synchronous request-response pattern over RabbitMQ. The producer publishes with `reply_to: 'amq.rabbitmq.reply-to'` and blocks on a `Concurrent::IVar` until the consumer replies to that pseudo-queue.
|
|
20
|
+
|
|
21
|
+
**Fire-and-forget** — An asynchronous publish with no reply expected. The producer does not block. Used for events, notifications, and side effects.
|
|
22
|
+
|
|
23
|
+
**Direct Reply-to** — A RabbitMQ pseudo-queue (`amq.rabbitmq.reply-to`) that allows RPC replies without declaring a temporary queue. BugBunny uses this for all RPC responses.
|
|
24
|
+
|
|
25
|
+
**Controller** — `BugBunny::Controller`. A class that handles an incoming routed message. Mirrors Rails ActionController: supports `before_action`, `around_action`, `after_action`, `rescue_from`, `params`, and `render`.
|
|
26
|
+
|
|
27
|
+
**Resource** — `BugBunny::Resource`. An ActiveRecord-like ORM over AMQP. Wraps CRUD operations as RPC calls. Used by the publishing side to interact with a remote service.
|
|
28
|
+
|
|
29
|
+
**Client** — `BugBunny::Client`. High-level API for the publisher side. Implements the Onion Middleware (Faraday-style) pattern around the Producer.
|
|
30
|
+
|
|
31
|
+
**ConsumerMiddleware** — A pipeline of middleware objects that runs before each message is dispatched to a controller. Used for cross-cutting concerns: tracing, authentication, logging.
|
|
32
|
+
|
|
33
|
+
**ConnectionPool** — A `connection_pool` gem pool of Bunny connections. Each slot holds one connection, one Session, and one Producer. `Resource.connection_pool` must be set before making AMQP calls.
|
|
34
|
+
|
|
35
|
+
**IVar (Ivar / Concurrent::IVar)** — An immutable variable from the `concurrent-ruby` gem. Used to block the calling thread until the RPC reply arrives or the timeout expires.
|
|
36
|
+
|
|
37
|
+
**health_check_file** — A file path that BugBunny touches periodically (every `health_check_interval` seconds) after verifying the RabbitMQ connection. Used as a liveness probe in Docker/Kubernetes.
|
|
38
|
+
|
|
39
|
+
**rpc_reply_headers** — A `Proc` returning a `Hash` of AMQP headers injected into every RPC reply. Used to propagate trace context from consumer back to producer.
|
|
40
|
+
|
|
41
|
+
**on_rpc_reply** — A `Proc` called on the producer's thread when an RPC reply arrives, with the reply's AMQP headers. Used to hydrate trace context in the calling service.
|
|
42
|
+
|
|
43
|
+
**Namespace routing** — A DSL feature that prefixes a group of routes with a module namespace and a path prefix. Example: `namespace :admin` generates routes under `Admin::Controllers::*Controller` with path prefix `admin/`.
|
|
44
|
+
|
|
45
|
+
**param_key** — The root key used to wrap the payload in POST/PUT requests. Defaults to the singularized resource name (e.g., `user` for `UsersResource`).
|
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,8 +110,8 @@ 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?
|
data/lib/bug_bunny/controller.rb
CHANGED
|
@@ -40,7 +40,6 @@ module BugBunny
|
|
|
40
40
|
|
|
41
41
|
# @!endgroup
|
|
42
42
|
|
|
43
|
-
|
|
44
43
|
# ==========================================
|
|
45
44
|
# INFRAESTRUCTURA DE FILTROS Y LOGS (HEREDABLES)
|
|
46
45
|
# ==========================================
|
|
@@ -102,7 +101,7 @@ module BugBunny
|
|
|
102
101
|
raise ArgumentError, "Need a handler. Supply 'with: :method' or a block." unless handler
|
|
103
102
|
|
|
104
103
|
# Duplicamos el array del padre para no mutarlo al registrar reglas en el hijo
|
|
105
|
-
new_handlers =
|
|
104
|
+
new_handlers = rescue_handlers.dup
|
|
106
105
|
|
|
107
106
|
klasses.each do |klass|
|
|
108
107
|
new_handlers.unshift([klass, handler])
|
|
@@ -133,7 +132,6 @@ module BugBunny
|
|
|
133
132
|
# Aplicamos automáticamente las etiquetas de logs a todas las acciones.
|
|
134
133
|
around_action :apply_log_tags
|
|
135
134
|
|
|
136
|
-
|
|
137
135
|
# ==========================================
|
|
138
136
|
# INICIALIZACIÓN Y CICLO DE VIDA
|
|
139
137
|
# ==========================================
|
|
@@ -170,25 +168,22 @@ module BugBunny
|
|
|
170
168
|
core_execution = lambda do
|
|
171
169
|
return unless run_before_actions(action_name)
|
|
172
170
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
raise NameError, "Action '#{action_name}' not found in #{self.class.name}"
|
|
177
|
-
end
|
|
171
|
+
raise NameError, "Action '#{action_name}' not found in #{self.class.name}" unless respond_to?(action_name)
|
|
172
|
+
|
|
173
|
+
public_send(action_name)
|
|
178
174
|
|
|
179
175
|
run_after_actions(action_name)
|
|
180
176
|
end
|
|
181
177
|
|
|
182
178
|
# Construir e invocar la cadena de responsabilidad (Middlewares/Around Actions)
|
|
183
179
|
execution_chain = current_arounds.reverse.inject(core_execution) do |next_step, method_name|
|
|
184
|
-
|
|
180
|
+
-> { send(method_name, &next_step) }
|
|
185
181
|
end
|
|
186
182
|
|
|
187
183
|
execution_chain.call
|
|
188
184
|
|
|
189
185
|
# Si no hubo renderización explícita, devuelve 204 No Content
|
|
190
186
|
rendered_response || { status: 204, headers: response_headers, body: nil }
|
|
191
|
-
|
|
192
187
|
rescue StandardError => e
|
|
193
188
|
handle_exception(e)
|
|
194
189
|
end
|
|
@@ -223,12 +218,13 @@ module BugBunny
|
|
|
223
218
|
end
|
|
224
219
|
|
|
225
220
|
# Fallback genérico si la excepción no fue mapeada
|
|
226
|
-
safe_log(:error,
|
|
221
|
+
safe_log(:error, 'controller.unhandled_exception', backtrace: exception.backtrace.first(5).join(' | '),
|
|
222
|
+
**exception_metadata(exception))
|
|
227
223
|
|
|
228
224
|
{
|
|
229
225
|
status: 500,
|
|
230
226
|
headers: response_headers,
|
|
231
|
-
body: { error:
|
|
227
|
+
body: { error: 'Internal Server Error', detail: exception.message, type: exception.class.name }
|
|
232
228
|
}
|
|
233
229
|
end
|
|
234
230
|
|
|
@@ -294,10 +290,10 @@ module BugBunny
|
|
|
294
290
|
|
|
295
291
|
# --- LÓGICA DE LOGGING ENCAPSULADA ---
|
|
296
292
|
|
|
297
|
-
def apply_log_tags
|
|
293
|
+
def apply_log_tags(&block)
|
|
298
294
|
tags = compute_tags
|
|
299
295
|
if defined?(Rails) && Rails.logger.respond_to?(:tagged) && tags.any?
|
|
300
|
-
Rails.logger.tagged(*tags)
|
|
296
|
+
Rails.logger.tagged(*tags, &block)
|
|
301
297
|
else
|
|
302
298
|
yield
|
|
303
299
|
end
|
data/lib/bug_bunny/exception.rb
CHANGED
|
@@ -67,7 +67,7 @@ module BugBunny
|
|
|
67
67
|
# Indica que la solicitud fue bien formada pero contenía errores semánticos,
|
|
68
68
|
# típicamente fallos de validación en el modelo remoto (ActiveRecord).
|
|
69
69
|
#
|
|
70
|
-
# Esta excepción es "inteligente": intenta parsear automáticamente el cuerpo
|
|
70
|
+
# Esta excepción es "inteligente": intenta parsear automáticamente el cuerpo
|
|
71
71
|
# de la respuesta para extraer y exponer los mensajes de error de forma estructurada,
|
|
72
72
|
# buscando por convención la clave `errors`.
|
|
73
73
|
class UnprocessableEntity < ClientError
|
|
@@ -35,7 +35,7 @@ module BugBunny
|
|
|
35
35
|
|
|
36
36
|
case status
|
|
37
37
|
when 200..299
|
|
38
|
-
|
|
38
|
+
nil # Flujo normal (Success)
|
|
39
39
|
when 400
|
|
40
40
|
raise BugBunny::BadRequest, format_error_message(body)
|
|
41
41
|
when 404
|
|
@@ -66,12 +66,12 @@ module BugBunny
|
|
|
66
66
|
# @param body [Hash, String, nil] El cuerpo de la respuesta.
|
|
67
67
|
# @return [String] Un mensaje de error limpio y estructurado.
|
|
68
68
|
def format_error_message(body)
|
|
69
|
-
return
|
|
69
|
+
return 'Unknown Error' if body.nil? || (body.respond_to?(:empty?) && body.empty?)
|
|
70
70
|
return body if body.is_a?(String)
|
|
71
71
|
|
|
72
72
|
# Si el worker devolvió un JSON con una key 'error' (nuestra convención en Controller)
|
|
73
73
|
if body.is_a?(Hash) && body['error']
|
|
74
|
-
detail = body['detail'] ? " - #{body['detail']}" :
|
|
74
|
+
detail = body['detail'] ? " - #{body['detail']}" : ''
|
|
75
75
|
"#{body['error']}#{detail}"
|
|
76
76
|
else
|
|
77
77
|
# Fallback: Convertir todo el Hash a JSON string para que se vea claro en Sentry/Logs
|
|
@@ -47,11 +47,11 @@ module BugBunny
|
|
|
47
47
|
when Numeric then val
|
|
48
48
|
when Hash
|
|
49
49
|
val.to_json
|
|
50
|
-
when String
|
|
51
|
-
else val.to_s.include?(
|
|
50
|
+
when String then val.include?(' ') ? val.inspect : val
|
|
51
|
+
else val.to_s.include?(' ') ? val.to_s.inspect : val
|
|
52
52
|
end
|
|
53
53
|
"#{k}=#{formatted}"
|
|
54
|
-
end.compact.join(
|
|
54
|
+
end.compact.join(' ')
|
|
55
55
|
|
|
56
56
|
@logger.send(level) { log_line }
|
|
57
57
|
rescue StandardError
|
|
@@ -89,9 +89,9 @@ module BugBunny
|
|
|
89
89
|
# @return [String] Nombre del componente en snake_case.
|
|
90
90
|
def observability_name
|
|
91
91
|
klass = is_a?(Class) ? self : self.class
|
|
92
|
-
klass.name.split(
|
|
92
|
+
klass.name.split('::').first.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
93
93
|
rescue StandardError
|
|
94
|
-
|
|
94
|
+
'unknown'
|
|
95
95
|
end
|
|
96
96
|
end
|
|
97
97
|
end
|
data/lib/bug_bunny/producer.rb
CHANGED
|
@@ -79,18 +79,16 @@ module BugBunny
|
|
|
79
79
|
begin
|
|
80
80
|
fire(request)
|
|
81
81
|
|
|
82
|
-
safe_log(:debug,
|
|
82
|
+
safe_log(:debug, 'producer.rpc_waiting', correlation_id: cid, timeout_s: wait_timeout)
|
|
83
83
|
|
|
84
84
|
# Bloqueamos el hilo aquí hasta que llegue la respuesta o expire el timeout
|
|
85
85
|
result = future.value(wait_timeout)
|
|
86
86
|
|
|
87
|
-
if result.nil?
|
|
88
|
-
raise BugBunny::RequestTimeout, "Timeout waiting for RPC: #{request.path} [#{request.method}]"
|
|
89
|
-
end
|
|
87
|
+
raise BugBunny::RequestTimeout, "Timeout waiting for RPC: #{request.path} [#{request.method}]" if result.nil?
|
|
90
88
|
|
|
91
89
|
BugBunny.configuration.on_rpc_reply&.call(result[:headers])
|
|
92
90
|
|
|
93
|
-
safe_log(:debug,
|
|
91
|
+
safe_log(:debug, 'producer.rpc_response_received', correlation_id: cid)
|
|
94
92
|
|
|
95
93
|
parse_response(result[:body])
|
|
96
94
|
ensure
|
|
@@ -113,12 +111,12 @@ module BugBunny
|
|
|
113
111
|
|
|
114
112
|
# 📊 LOGGING DE OBSERVABILIDAD: Calculamos las opciones finales para mostrarlas en consola
|
|
115
113
|
final_x_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
.merge(BugBunny.configuration.exchange_options || {})
|
|
115
|
+
.merge(request.exchange_options || {})
|
|
118
116
|
|
|
119
|
-
safe_log(:info,
|
|
120
|
-
safe_log(:debug,
|
|
121
|
-
safe_log(:debug,
|
|
117
|
+
safe_log(:info, 'producer.publish', method: verb, path: target, routing_key: rk, correlation_id: id)
|
|
118
|
+
safe_log(:debug, 'producer.publish_detail', exchange: request.exchange, exchange_opts: final_x_opts)
|
|
119
|
+
safe_log(:debug, 'producer.publish_payload', payload: payload.truncate(300)) if payload.is_a?(String)
|
|
122
120
|
end
|
|
123
121
|
|
|
124
122
|
# Serializa el mensaje para su transporte.
|
|
@@ -137,7 +135,7 @@ module BugBunny
|
|
|
137
135
|
def parse_response(payload)
|
|
138
136
|
JSON.parse(payload)
|
|
139
137
|
rescue JSON::ParserError
|
|
140
|
-
raise BugBunny::InternalServerError,
|
|
138
|
+
raise BugBunny::InternalServerError, 'Invalid JSON response'
|
|
141
139
|
end
|
|
142
140
|
|
|
143
141
|
# Inicia el consumidor de respuestas RPC de forma perezosa (Lazy Initialization).
|
|
@@ -152,7 +150,7 @@ module BugBunny
|
|
|
152
150
|
@reply_listener_mutex.synchronize do
|
|
153
151
|
return if @reply_listener_started
|
|
154
152
|
|
|
155
|
-
safe_log(:debug,
|
|
153
|
+
safe_log(:debug, 'producer.reply_listener_start')
|
|
156
154
|
|
|
157
155
|
# Consumimos sin ack (auto-ack) porque reply-to no soporta acks manuales de forma estándar
|
|
158
156
|
@session.channel.basic_consume('amq.rabbitmq.reply-to', '', true, false, nil) do |_, props, body|
|
|
@@ -160,7 +158,7 @@ module BugBunny
|
|
|
160
158
|
if (future = @pending_requests[cid])
|
|
161
159
|
future.set({ body: body, headers: props.headers || {} })
|
|
162
160
|
else
|
|
163
|
-
safe_log(:warn,
|
|
161
|
+
safe_log(:warn, 'producer.rpc_response_orphaned', correlation_id: cid)
|
|
164
162
|
end
|
|
165
163
|
end
|
|
166
164
|
@reply_listener_started = true
|
data/lib/bug_bunny/railtie.rb
CHANGED
|
@@ -14,9 +14,7 @@ module BugBunny
|
|
|
14
14
|
# 1. Configuración de Autoload
|
|
15
15
|
initializer 'bug_bunny.add_autoload_paths' do |app|
|
|
16
16
|
rabbit_path = File.join(app.root, 'app', 'rabbit')
|
|
17
|
-
if Dir.exist?(rabbit_path)
|
|
18
|
-
app.config.paths.add 'app/rabbit', eager_load: true
|
|
19
|
-
end
|
|
17
|
+
app.config.paths.add 'app/rabbit', eager_load: true if Dir.exist?(rabbit_path)
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
# 2. Gestión de Forks (Puma / Spring / otros)
|
|
@@ -25,9 +23,7 @@ module BugBunny
|
|
|
25
23
|
# el hijo empiece a trabajar, para evitar compartir el mismo socket TCP.
|
|
26
24
|
config.after_initialize do
|
|
27
25
|
# Estrategia 1: Rails 7.1+ ForkTracker (La forma estándar moderna)
|
|
28
|
-
if defined?(ActiveSupport::ForkTracker)
|
|
29
|
-
ActiveSupport::ForkTracker.after_fork { BugBunny.disconnect }
|
|
30
|
-
end
|
|
26
|
+
ActiveSupport::ForkTracker.after_fork { BugBunny.disconnect } if defined?(ActiveSupport::ForkTracker)
|
|
31
27
|
|
|
32
28
|
# Estrategia 2: Hook específico de Puma (Legacy)
|
|
33
29
|
# Solo intentamos usarlo si la API 'events' está disponible (Puma < 5).
|
|
@@ -38,7 +34,12 @@ module BugBunny
|
|
|
38
34
|
end
|
|
39
35
|
end
|
|
40
36
|
|
|
41
|
-
# 3.
|
|
37
|
+
# 3. Rake tasks (bug_bunny:sync)
|
|
38
|
+
rake_tasks do
|
|
39
|
+
load File.expand_path('../tasks/bug_bunny.rake', __dir__)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 4. Hook de Spring (Preloader)
|
|
42
43
|
if defined?(Spring)
|
|
43
44
|
Spring.after_fork do
|
|
44
45
|
BugBunny.disconnect
|
data/lib/bug_bunny/request.rb
CHANGED
|
@@ -23,20 +23,11 @@ module BugBunny
|
|
|
23
23
|
# @attr exchange_options [Hash] Opciones específicas para la declaración del Exchange en esta petición.
|
|
24
24
|
# @attr queue_options [Hash] Opciones específicas para la declaración de la Cola en esta petición.
|
|
25
25
|
class Request
|
|
26
|
-
attr_accessor :body
|
|
27
|
-
|
|
28
|
-
attr_accessor :params
|
|
29
|
-
attr_accessor :path
|
|
30
|
-
attr_accessor :method
|
|
31
|
-
attr_accessor :exchange
|
|
32
|
-
attr_accessor :exchange_type
|
|
33
|
-
attr_accessor :routing_key
|
|
34
|
-
attr_accessor :timeout
|
|
35
|
-
attr_accessor :delivery_mode
|
|
26
|
+
attr_accessor :body, :headers, :params, :path, :method, :exchange, :exchange_type, :routing_key, :timeout,
|
|
27
|
+
:delivery_mode, :queue_options
|
|
36
28
|
|
|
37
29
|
# Configuración de Infraestructura Específica
|
|
38
30
|
attr_accessor :exchange_options
|
|
39
|
-
attr_accessor :queue_options
|
|
40
31
|
|
|
41
32
|
# Metadatos AMQP Estándar
|
|
42
33
|
attr_accessor :app_id, :content_type, :content_encoding, :priority,
|
|
@@ -61,6 +52,7 @@ module BugBunny
|
|
|
61
52
|
@exchange_options = {}
|
|
62
53
|
@queue_options = {}
|
|
63
54
|
end
|
|
55
|
+
|
|
64
56
|
# Combina el path con los params como query string.
|
|
65
57
|
#
|
|
66
58
|
# @return [String] El path completo con query string si hay params, o solo el path.
|