bug_bunny 4.3.0 → 4.4.1
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/CHANGELOG.md +13 -0
- data/README.md +117 -358
- data/lib/bug_bunny/configuration.rb +1 -1
- data/lib/bug_bunny/consumer.rb +24 -19
- data/lib/bug_bunny/controller.rb +3 -2
- data/lib/bug_bunny/observability.rb +71 -0
- data/lib/bug_bunny/producer.rb +14 -8
- data/lib/bug_bunny/session.rb +4 -2
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +7 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d36aa9dce0ff30df4254ca069bdf1af9dd396fffd1ac7e06e7d72e90a3b1bcd1
|
|
4
|
+
data.tar.gz: efef41ec3d46f7835dc9412e3fdaa6f78ccbb997ee3b1ca5ac6c972dd2507eb1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e43184259edd6f9c3a229a3c473a936f72465b8b9507ea946a6c35b284b213048ad181a76578e8780cae9184dd92030a46372dac48c5907d61c172be2e644a06
|
|
7
|
+
data.tar.gz: b795ed264f9238e024c225dc7ba6ce7267c804a01d4bee511faece98faacc569f34cac3aff1e8df0698aab8e34f7be8b21d6f8bd8d9f6031126ead41ad16e469
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [4.4.1] - 2026-03-25
|
|
4
|
+
|
|
5
|
+
### 🐛 Bug Fixes
|
|
6
|
+
* **Producer:** Se corrigió el valor de retorno del método `fire` para que devuelva un Hash simbólico (`{ 'status' => 202 }`) en lugar del objeto Exchange. Esto previene errores de tipo `NoMethodError: undefined method []` en el cliente al realizar publicaciones asíncronas (`:publish`).
|
|
7
|
+
|
|
8
|
+
## [4.4.0] - 2026-03-24
|
|
9
|
+
|
|
10
|
+
### 📈 Standard Observability Pattern
|
|
11
|
+
* **Unified Observability Module:** Adopción del patrón de observabilidad estándar para todas las gemas.
|
|
12
|
+
* **Semantic Event Naming:** Todos los eventos ahora siguen el formato `clase.evento` (ej: `consumer.message_processed`, `producer.publish`) para una mejor categorización.
|
|
13
|
+
* **Resilient Logging:** Implementación de `@logger` en cada componente con el método `safe_log` para garantizar que la telemetría nunca interrumpa la ejecución principal.
|
|
14
|
+
* **Full Documentation Sync:** Actualización del README con los nuevos ejemplos de uso del patrón de observabilidad.
|
|
15
|
+
|
|
3
16
|
## [4.3.0] - 2026-03-24
|
|
4
17
|
|
|
5
18
|
### 📈 Observability Alignment (ExisRay Standards)
|
data/README.md
CHANGED
|
@@ -4,201 +4,147 @@
|
|
|
4
4
|
|
|
5
5
|
**Active Record over AMQP.**
|
|
6
6
|
|
|
7
|
-
BugBunny transforma la complejidad de la mensajería asíncrona (RabbitMQ) en una arquitectura
|
|
7
|
+
BugBunny transforma la complejidad de la mensajería asíncrona (RabbitMQ) en una arquitectura familiar RESTful. Permite que tus microservicios se comuniquen como si fueran APIs locales, utilizando controladores, rutas y modelos con una interfaz idéntica a Active Record.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## ✨ Características
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- [Definición y Atributos](#1-definición-y-atributos-híbridos)
|
|
18
|
-
- [CRUD y Consultas](#2-crud-y-consultas-restful)
|
|
19
|
-
- [Contexto Dinámico (.with)](#3-contexto-dinámico-with)
|
|
20
|
-
- [Client Middleware](#4-client-middleware-interceptores)
|
|
21
|
-
- [Modo Servidor: Controladores](#-modo-servidor-controladores)
|
|
22
|
-
- [Ruteo Declarativo (Rutas)](#1-ruteo-declarativo-rutas)
|
|
23
|
-
- [El Controlador](#2-el-controlador)
|
|
24
|
-
- [Manejo de Errores](#3-manejo-de-errores-declarativo)
|
|
25
|
-
- [Observabilidad y Tracing](#-observabilidad-y-tracing)
|
|
26
|
-
- [Guía de Producción](#-guía-de-producción)
|
|
11
|
+
* **RESTful Routing:** Define tus endpoints AMQP con un DSL estilo Rails (`get`, `post`, `resources`).
|
|
12
|
+
* **Active Record Pattern:** Modela tus recursos remotos con validaciones, callbacks y tracking de cambios.
|
|
13
|
+
* **Middleware Stack:** Arquitectura de cebolla (Onion) para interceptar peticiones, manejar errores y transformar payloads.
|
|
14
|
+
* **RPC & Pub/Sub:** Soporta nativamente tanto peticiones síncronas (Request-Response) como publicaciones asíncronas.
|
|
15
|
+
* **Observabilidad de Clase Mundial:** Integración nativa con **ExisRay** (Tracing distribuido y Logs estructurados KV).
|
|
16
|
+
* **Resiliencia Enterprise:** Reconexión automática con Backoff Exponencial y Health Checks automáticos.
|
|
27
17
|
|
|
28
18
|
---
|
|
29
19
|
|
|
30
|
-
##
|
|
31
|
-
|
|
32
|
-
En lugar de pensar en "Exchanges" y "Queues", BugBunny inyecta verbos HTTP (`GET`, `POST`, `PUT`, `DELETE`) y rutas (`users/1`) en los headers de AMQP.
|
|
33
|
-
|
|
34
|
-
* **Tu código (Cliente):** `User.create(name: 'Gabi')`
|
|
35
|
-
* **Protocolo (BugBunny):** Envía `POST /users` (Header `type: users`) vía RabbitMQ.
|
|
36
|
-
* **Worker (Servidor):** Recibe el mensaje, evalúa tu mapa de rutas y ejecuta `UsersController#create`.
|
|
37
|
-
|
|
38
|
-
---
|
|
20
|
+
## 🚀 Instalación
|
|
39
21
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
Agrega la gema a tu `Gemfile`:
|
|
22
|
+
Añade esta línea al `Gemfile` de tu aplicación:
|
|
43
23
|
|
|
44
24
|
```ruby
|
|
45
|
-
gem 'bug_bunny'
|
|
25
|
+
gem 'bug_bunny'
|
|
46
26
|
```
|
|
47
27
|
|
|
48
|
-
|
|
28
|
+
Y luego ejecuta:
|
|
29
|
+
```bash
|
|
30
|
+
$ bundle install
|
|
31
|
+
```
|
|
49
32
|
|
|
33
|
+
O instálalo manualmente:
|
|
50
34
|
```bash
|
|
51
|
-
|
|
52
|
-
rails g bug_bunny:install
|
|
35
|
+
$ gem install bug_bunny
|
|
53
36
|
```
|
|
54
37
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
38
|
+
Luego, genera el inicializador (en Rails):
|
|
39
|
+
```bash
|
|
40
|
+
$ rails generate bug_bunny:install
|
|
41
|
+
```
|
|
58
42
|
|
|
59
43
|
---
|
|
60
44
|
|
|
61
|
-
##
|
|
45
|
+
## 🛠️ Configuración
|
|
62
46
|
|
|
63
|
-
|
|
47
|
+
Configura la conexión a RabbitMQ y las opciones globales:
|
|
64
48
|
|
|
65
49
|
```ruby
|
|
66
50
|
# config/initializers/bug_bunny.rb
|
|
67
|
-
|
|
68
51
|
BugBunny.configure do |config|
|
|
69
|
-
|
|
70
|
-
config.
|
|
71
|
-
config.username =
|
|
72
|
-
config.password =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
#
|
|
76
|
-
config.
|
|
77
|
-
config.network_recovery_interval = 5
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# 3. Health Checks (Opcional, para Docker Swarm / K8s)
|
|
82
|
-
config.health_check_file = '/tmp/bug_bunny_health'
|
|
83
|
-
|
|
84
|
-
# 4. Logging
|
|
85
|
-
config.logger = Rails.logger
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# 5. Connection Pool (CRÍTICO para concurrencia)
|
|
89
|
-
# Define un pool global para compartir conexiones entre hilos
|
|
90
|
-
BUG_BUNNY_POOL = ConnectionPool.new(size: ENV.fetch('RAILS_MAX_THREADS', 5).to_i, timeout: 5) do
|
|
91
|
-
BugBunny.create_connection
|
|
52
|
+
config.host = ENV['RABBITMQ_HOST'] || '127.0.0.1'
|
|
53
|
+
config.port = 5672
|
|
54
|
+
config.username = 'guest'
|
|
55
|
+
config.password = 'guest'
|
|
56
|
+
|
|
57
|
+
# Resiliencia
|
|
58
|
+
config.max_reconnect_attempts = 10 # Falla tras 10 intentos (útil en K8s)
|
|
59
|
+
config.max_reconnect_interval = 60 # Máximo 60s entre reintentos
|
|
60
|
+
config.network_recovery_interval = 5 # Intervalo base para backoff
|
|
61
|
+
|
|
62
|
+
# Infraestructura por defecto (Nivel 2)
|
|
63
|
+
config.exchange_options = { durable: true }
|
|
92
64
|
end
|
|
93
|
-
|
|
94
|
-
# Inyecta el pool en los recursos
|
|
95
|
-
BugBunny::Resource.connection_pool = BUG_BUNNY_POOL
|
|
96
65
|
```
|
|
97
66
|
|
|
98
67
|
---
|
|
99
68
|
|
|
100
|
-
##
|
|
69
|
+
## 📦 Uso como Modelo (Consumer + Producer)
|
|
101
70
|
|
|
102
|
-
BugBunny
|
|
71
|
+
BugBunny permite definir modelos que representan recursos en otros microservicios.
|
|
103
72
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
73
|
+
```ruby
|
|
74
|
+
class RemoteNode < BugBunny::Resource
|
|
75
|
+
# Configuración del canal
|
|
76
|
+
self.exchange = 'inventory_exchange'
|
|
77
|
+
self.resource_name = 'nodes' # Equivale al path de la URL
|
|
108
78
|
|
|
109
|
-
|
|
110
|
-
|
|
79
|
+
# Atributos (ActiveRecord style)
|
|
80
|
+
attribute :name, :string
|
|
81
|
+
attribute :status, :string
|
|
82
|
+
attribute :cpu_cores, :integer
|
|
111
83
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
BugBunny.configure do |config|
|
|
115
|
-
if Rails.env.test?
|
|
116
|
-
config.exchange_options = { auto_delete: true }
|
|
117
|
-
config.queue_options = { auto_delete: true }
|
|
118
|
-
end
|
|
84
|
+
# Validaciones
|
|
85
|
+
validates :name, presence: true
|
|
119
86
|
end
|
|
87
|
+
|
|
88
|
+
# Uso:
|
|
89
|
+
node = RemoteNode.find('node-123')
|
|
90
|
+
node.status = 'active'
|
|
91
|
+
node.save # Realiza un PUT a inventory_exchange con routing_key 'nodes.node-123'
|
|
120
92
|
```
|
|
121
93
|
|
|
122
94
|
---
|
|
123
95
|
|
|
124
|
-
##
|
|
96
|
+
## 🛣️ Enrutamiento y Controladores (Server side)
|
|
125
97
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
### 1. Definición y Atributos Híbridos
|
|
129
|
-
BugBunny es **Schema-less**. Soporta atributos tipados (ActiveModel) y dinámicos simultáneamente, además de definir su propia infraestructura.
|
|
98
|
+
Define cómo debe responder tu aplicación a los mensajes entrantes:
|
|
130
99
|
|
|
131
100
|
```ruby
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# Este recurso crítico sobrevivirá a reinicios del servidor RabbitMQ
|
|
140
|
-
self.exchange_options = { durable: true, auto_delete: false }
|
|
141
|
-
|
|
142
|
-
# Configuración de Ruteo (La "URL" base)
|
|
143
|
-
self.resource_name = 'services'
|
|
144
|
-
|
|
145
|
-
# A. Atributos Tipados (Opcional, para casting)
|
|
146
|
-
attribute :created_at, :datetime
|
|
147
|
-
attribute :replicas, :integer, default: 1
|
|
148
|
-
|
|
149
|
-
# B. Validaciones (Funcionan en ambos tipos)
|
|
150
|
-
validates :name, presence: true
|
|
101
|
+
# config/rabbit_routes.rb
|
|
102
|
+
BugBunny.routes.draw do
|
|
103
|
+
resources :nodes do
|
|
104
|
+
member do
|
|
105
|
+
put :drain
|
|
106
|
+
end
|
|
107
|
+
end
|
|
151
108
|
end
|
|
152
|
-
```
|
|
153
109
|
|
|
154
|
-
|
|
110
|
+
# app/controllers/bug_bunny/nodes_controller.rb
|
|
111
|
+
module BugBunny
|
|
112
|
+
module Controllers
|
|
113
|
+
class NodesController < BugBunny::Controller
|
|
114
|
+
def index
|
|
115
|
+
nodes = Node.all # Lógica local de tu app
|
|
116
|
+
render status: :ok, json: nodes
|
|
117
|
+
end
|
|
155
118
|
|
|
156
|
-
|
|
157
|
-
#
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
# Envia: GET services?q[status]=active&q[tags][]=web
|
|
165
|
-
Manager::Service.where(q: { status: 'active', tags: ['web'] })
|
|
166
|
-
|
|
167
|
-
# --- CREAR (POST) ---
|
|
168
|
-
# RPC: Envía payload y espera el objeto persistido.
|
|
169
|
-
# Payload: { "service": { "name": "nginx", "replicas": 3 } }
|
|
170
|
-
svc = Manager::Service.create(name: 'nginx', replicas: 3)
|
|
171
|
-
|
|
172
|
-
# --- ACTUALIZAR (PUT) ---
|
|
173
|
-
# Dirty Tracking: Solo envía los campos que cambiaron.
|
|
174
|
-
svc.name = 'nginx-pro'
|
|
175
|
-
svc.save
|
|
176
|
-
|
|
177
|
-
# --- ELIMINAR (DELETE) ---
|
|
178
|
-
svc.destroy
|
|
119
|
+
def drain
|
|
120
|
+
# El ID viene automáticamente en params[:id]
|
|
121
|
+
Node.find(params[:id]).start_drain_process!
|
|
122
|
+
render status: :accepted, json: { message: "Draining started" }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
179
127
|
```
|
|
180
128
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
routing_key: 'high_priority',
|
|
188
|
-
exchange_options: { durable: false } # Ignora el durable: true de la clase
|
|
189
|
-
).create(name: 'redis_temp')
|
|
190
|
-
```
|
|
129
|
+
> **Namespace de Controladores:** Por defecto BugBunny busca los controladores bajo `BugBunny::Controllers`. Podés cambiarlo en la configuración:
|
|
130
|
+
> ```ruby
|
|
131
|
+
> BugBunny.configure do |config|
|
|
132
|
+
> config.controller_namespace = 'MyApp::RabbitControllers'
|
|
133
|
+
> end
|
|
134
|
+
> ```
|
|
191
135
|
|
|
192
|
-
|
|
193
|
-
Intercepta peticiones de ida y respuestas de vuelta en la arquitectura del cliente.
|
|
136
|
+
---
|
|
194
137
|
|
|
195
|
-
|
|
196
|
-
Si usas `BugBunny::Resource`, el manejo de JSON y de errores ya está integrado automáticamente. Pero si utilizas el cliente manual (`BugBunny::Client`), puedes inyectar los middlewares incluidos para no tener que parsear respuestas manualmente:
|
|
138
|
+
## 🔌 Middlewares
|
|
197
139
|
|
|
198
|
-
|
|
199
|
-
* `BugBunny::Middleware::RaiseError`: Evalúa el código de estado (`status`) de la respuesta y lanza excepciones nativas (`BugBunny::NotFound`, `BugBunny::UnprocessableEntity`, `BugBunny::InternalServerError`, etc.).
|
|
140
|
+
Puedes extender el comportamiento del cliente globalmente o por recurso:
|
|
200
141
|
|
|
201
142
|
```ruby
|
|
143
|
+
# Globalmente en el inicializador
|
|
144
|
+
BugBunny.configure do |config|
|
|
145
|
+
# ...
|
|
146
|
+
end
|
|
147
|
+
|
|
202
148
|
# Uso con el cliente manual
|
|
203
149
|
client = BugBunny::Client.new(pool: BUG_BUNNY_POOL) do |stack|
|
|
204
150
|
stack.use BugBunny::Middleware::RaiseError
|
|
@@ -230,116 +176,47 @@ response = client.request('users/1', method: :get)
|
|
|
230
176
|
```
|
|
231
177
|
|
|
232
178
|
**Middlewares Personalizados**
|
|
233
|
-
Ideales para inyectar Auth o Headers de trazabilidad en todos los requests de un Recurso.
|
|
234
|
-
|
|
235
|
-
```ruby
|
|
236
|
-
class Manager::Service < BugBunny::Resource
|
|
237
|
-
client_middleware do |stack|
|
|
238
|
-
stack.use(Class.new(BugBunny::Middleware::Base) do
|
|
239
|
-
def on_request(env)
|
|
240
|
-
env.headers['Authorization'] = "Bearer #{ENV['API_TOKEN']}"
|
|
241
|
-
env.headers['X-App-Version'] = '1.0.0'
|
|
242
|
-
end
|
|
243
|
-
end)
|
|
244
|
-
end
|
|
245
|
-
end
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
**Personalización Avanzada de Errores**
|
|
249
|
-
Si en tu aplicación necesitas mapear códigos HTTP de negocio (ej. `402 Payment Required`) a excepciones personalizadas, la forma más limpia es usar `Module#prepend` sobre el middleware nativo en un inicializador. De esta forma inyectas tus reglas sin perder el comportamiento por defecto para los demás errores:
|
|
250
179
|
|
|
251
180
|
```ruby
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
# 1. Reglas específicas de tu negocio
|
|
258
|
-
if status == 402
|
|
259
|
-
raise MyApp::PaymentRequiredError, response['body']['message']
|
|
260
|
-
elsif status == 403 && response['body']['reason'] == 'ip_blocked'
|
|
261
|
-
raise MyApp::IpBlockedError, response['body']['detail']
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
# 2. Delegar el resto de los errores (404, 422, 500) al middleware original
|
|
265
|
-
super(response)
|
|
181
|
+
class MyCustomMiddleware < BugBunny::Middleware::Base
|
|
182
|
+
def call(request)
|
|
183
|
+
puts "Enviando mensaje a: #{request.path}"
|
|
184
|
+
app.call(request)
|
|
266
185
|
end
|
|
267
186
|
end
|
|
268
|
-
|
|
269
|
-
BugBunny::Middleware::RaiseError.prepend(CustomBugBunnyErrors)
|
|
270
187
|
```
|
|
271
188
|
|
|
272
189
|
---
|
|
273
190
|
|
|
274
|
-
##
|
|
275
|
-
|
|
276
|
-
A partir de BugBunny v4, el enrutamiento es **declarativo** y explícito, al igual que en Rails. Se utiliza un archivo de rutas centralizado para mapear los mensajes AMQP entrantes a los Controladores adecuados.
|
|
277
|
-
|
|
278
|
-
### 1. Ruteo Declarativo (Rutas)
|
|
279
|
-
Crea un inicializador en tu aplicación (ej. `config/initializers/bug_bunny_routes.rb`) para definir tu mapa de rutas. BugBunny usará este DSL para extraer automáticamente parámetros dinámicos de las URLs.
|
|
191
|
+
## 🔎 Observabilidad y Tracing
|
|
280
192
|
|
|
281
|
-
|
|
282
|
-
# config/initializers/bug_bunny_routes.rb
|
|
193
|
+
BugBunny implementa Distributed Tracing nativo y sigue los estándares de observabilidad de **ExisRay** para logs estructurados.
|
|
283
194
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
# Genera rutas para index, show y update únicamente
|
|
287
|
-
resources :services, only: [:index, :show, :update]
|
|
195
|
+
### 1. Logs Estructurados (Key-Value)
|
|
196
|
+
A partir de la v4.3.0, todos los logs internos de la gema utilizan un formato `key=value` optimizado para motores de logs (CloudWatch, Datadog, ELK).
|
|
288
197
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
member do
|
|
293
|
-
put :drain
|
|
294
|
-
post :restart
|
|
295
|
-
end
|
|
198
|
+
* **Data First:** Las unidades están en la llave (`_s`, `_ms`, `_count`), permitiendo que los valores sean números puros para agregaciones automáticas.
|
|
199
|
+
* **Reloj Monotónico:** Las duraciones (`duration_s`) se calculan con precisión de microsegundos usando el reloj monotónico del sistema.
|
|
200
|
+
* **Campos de Identidad:** Todos los logs incluyen `component=bug_bunny` y un `event` semántico.
|
|
296
201
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
end
|
|
202
|
+
**Ejemplos de Logs:**
|
|
203
|
+
```text
|
|
204
|
+
# Mensaje procesado con éxito (incluye duración y status numérico)
|
|
205
|
+
component=bug_bunny event=consumer.message_processed status=200 duration_s=0.015432 controller=UsersController action=show
|
|
302
206
|
|
|
303
|
-
|
|
304
|
-
|
|
207
|
+
# Error de ejecución (campos estandarizados)
|
|
208
|
+
component=bug_bunny event=consumer.execution_error error_class=NoMethodError error_message="undefined method..." duration_s=0.008123
|
|
305
209
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
end
|
|
210
|
+
# Reintento de conexión con backoff (sufijos de unidad)
|
|
211
|
+
component=bug_bunny event=consumer.connection_error error_message="..." attempt_count=3 retry_in_s=20
|
|
309
212
|
```
|
|
310
213
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
```ruby
|
|
316
|
-
class ServicesController < BugBunny::Controller
|
|
317
|
-
# Callbacks estándar
|
|
318
|
-
before_action :set_service, only: [:show, :update]
|
|
214
|
+
**Ventaja en Cloud:**
|
|
215
|
+
Al usar `duration_s` como un float puro, puedes realizar consultas analíticas directamente en tu motor de logs sin parsear strings:
|
|
216
|
+
`stats avg(duration_s), max(duration_s) by controller, action`
|
|
319
217
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
render status: 200, json: { id: @service.id, state: 'running' }
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
def create
|
|
326
|
-
# BugBunny envuelve los params automáticamente basándose en el resource_name
|
|
327
|
-
# params[:service] => { name: '...', replicas: ... }
|
|
328
|
-
if Service.create(params[:service])
|
|
329
|
-
render status: 201, json: { status: 'created' }
|
|
330
|
-
else
|
|
331
|
-
render status: 422, json: { errors: 'Invalid' }
|
|
332
|
-
end
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
private
|
|
336
|
-
|
|
337
|
-
def set_service
|
|
338
|
-
# params[:id] es extraído e inyectado por el BugBunny.routes
|
|
339
|
-
@service = Service.find(params[:id])
|
|
340
|
-
end
|
|
341
|
-
end
|
|
342
|
-
```
|
|
218
|
+
### 2. Distributed Tracing
|
|
219
|
+
El `correlation_id` se mantiene intacto a través de toda la cadena: `Producer -> RabbitMQ -> Consumer -> Controller`.
|
|
343
220
|
|
|
344
221
|
### 3. Manejo de Errores Declarativo
|
|
345
222
|
Captura excepciones y devuélvelas como códigos de estado AMQP/HTTP.
|
|
@@ -351,7 +228,7 @@ class ApplicationController < BugBunny::Controller
|
|
|
351
228
|
end
|
|
352
229
|
|
|
353
230
|
rescue_from StandardError do |e|
|
|
354
|
-
|
|
231
|
+
safe_log(:error, "application.crash", **exception_metadata(e))
|
|
355
232
|
render status: :internal_server_error, json: { error: "Crash" }
|
|
356
233
|
end
|
|
357
234
|
end
|
|
@@ -359,124 +236,6 @@ end
|
|
|
359
236
|
|
|
360
237
|
---
|
|
361
238
|
|
|
362
|
-
## 🔎 Observabilidad y Tracing
|
|
363
|
-
|
|
364
|
-
BugBunny implementa Distributed Tracing nativo. El `correlation_id` se mantiene intacto a través de toda la cadena: `Producer -> RabbitMQ -> Consumer -> Controller`.
|
|
365
|
-
|
|
366
|
-
### 1. Logs Automáticos (Consumer)
|
|
367
|
-
No requiere configuración. El worker envuelve la ejecución en bloques de log etiquetados con el UUID.
|
|
368
|
-
|
|
369
|
-
```text
|
|
370
|
-
[d41d8cd9...] [BugBunny::Consumer] 📥 Received PUT "/nodes/4bv445vgc158hk" | RK: 'dbu55...'
|
|
371
|
-
[d41d8cd9...] [BugBunny::Consumer] 🎯 Routed to Rabbit::Controllers::NodesController#drain
|
|
372
|
-
```
|
|
373
|
-
|
|
374
|
-
### 2. Logs de Negocio (Controller)
|
|
375
|
-
Inyecta contexto rico (Tenant, Usuario, IP) en los logs usando `log_tags`.
|
|
376
|
-
|
|
377
|
-
```ruby
|
|
378
|
-
# app/rabbit/controllers/application_controller.rb
|
|
379
|
-
class ApplicationController < BugBunny::Controller
|
|
380
|
-
self.log_tags = [
|
|
381
|
-
->(c) { c.params[:tenant_id] }, # Agrega [Tenant-55]
|
|
382
|
-
->(c) { c.headers['X-Source'] } # Agrega [Console]
|
|
383
|
-
]
|
|
384
|
-
end
|
|
385
|
-
```
|
|
386
|
-
|
|
387
|
-
### 3. Inyección en el Productor
|
|
388
|
-
Para que tus logs de Rails y Rabbit coincidan, usa un middleware global:
|
|
389
|
-
|
|
390
|
-
```ruby
|
|
391
|
-
# config/initializers/bug_bunny.rb
|
|
392
|
-
# Middleware para inyectar Current.request_id de Rails al mensaje Rabbit
|
|
393
|
-
class CorrelationInjector < BugBunny::Middleware::Base
|
|
394
|
-
def on_request(env)
|
|
395
|
-
env.correlation_id = Current.request_id if defined?(Current)
|
|
396
|
-
end
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
BugBunny::Client.prepend(Module.new {
|
|
400
|
-
def initialize(pool:)
|
|
401
|
-
super
|
|
402
|
-
@stack.use CorrelationInjector
|
|
403
|
-
end
|
|
404
|
-
})
|
|
405
|
-
```
|
|
406
|
-
|
|
407
|
-
---
|
|
408
|
-
|
|
409
|
-
## 🧵 Guía de Producción
|
|
410
|
-
|
|
411
|
-
### Connection Pooling
|
|
412
|
-
Es vital usar `ConnectionPool` si usas servidores web multi-hilo (Puma) o workers (Sidekiq). BugBunny no gestiona hilos internamente; se apoya en el pool.
|
|
413
|
-
|
|
414
|
-
### Fork Safety
|
|
415
|
-
BugBunny incluye un `Railtie` que detecta automáticamente cuando Rails hace un "Fork" (ej: Puma en modo Cluster o Spring). Desconecta automáticamente las conexiones heredadas para evitar corrupción de datos en los sockets TCP.
|
|
416
|
-
|
|
417
|
-
### RPC y "Direct Reply-To"
|
|
418
|
-
Para máxima velocidad, BugBunny usa `amq.rabbitmq.reply-to`.
|
|
419
|
-
* **Trade-off:** Si el cliente (Rails) se reinicia justo después de enviar un mensaje RPC pero antes de recibir la respuesta, esa respuesta se pierde.
|
|
420
|
-
* **Recomendación:** Diseña tus acciones RPC (`POST`, `PUT`) para que sean **idempotentes** (seguras de reintentar ante un timeout).
|
|
421
|
-
|
|
422
|
-
### Seguridad
|
|
423
|
-
El Router incluye protecciones contra **Remote Code Execution (RCE)**. El Consumer verifica estrictamente que el Controlador resuelto a través del archivo de rutas herede de `BugBunny::Controller` antes de ejecutarla, impidiendo la inyección de clases arbitrarias. Además, las llamadas a rutas no registradas fallan rápido con un `404 Not Found`.
|
|
424
|
-
|
|
425
|
-
### Reconexión con Backoff Exponencial
|
|
426
|
-
|
|
427
|
-
Cuando el Consumer pierde la conexión a RabbitMQ, reintenta automáticamente usando un backoff exponencial basado en `network_recovery_interval`:
|
|
428
|
-
|
|
429
|
-
| Intento | Espera (base = 5s, techo = 60s) |
|
|
430
|
-
|---------|----------------------------------|
|
|
431
|
-
| 1 | 5s |
|
|
432
|
-
| 2 | 10s |
|
|
433
|
-
| 3 | 20s |
|
|
434
|
-
| 4 | 40s |
|
|
435
|
-
| 5+ | 60s (cap) |
|
|
436
|
-
|
|
437
|
-
Por defecto reintenta indefinidamente (`max_reconnect_attempts: nil`). En entornos orquestados (Kubernetes, Docker Swarm), es preferible dejar que el orquestador reinicie el contenedor cuando la infraestructura no está disponible:
|
|
438
|
-
|
|
439
|
-
```ruby
|
|
440
|
-
BugBunny.configure do |config|
|
|
441
|
-
config.network_recovery_interval = 5 # Base del backoff
|
|
442
|
-
config.max_reconnect_interval = 60 # Techo máximo de espera
|
|
443
|
-
config.max_reconnect_attempts = 10 # Falla hard después de 10 intentos
|
|
444
|
-
end
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
Con esta configuración, si RabbitMQ no vuelve en ~10 reintentos el proceso levanta la excepción y el orquestador lo reinicia con su propia política de restart.
|
|
448
|
-
|
|
449
|
-
### Health Checks en Docker Swarm / Kubernetes
|
|
450
|
-
Dado que un Worker se ejecuta en segundo plano sin exponer un servidor web tradicional, orquestadores como Docker Swarm o Kubernetes no pueden usar un endpoint HTTP para verificar si el proceso está saludable.
|
|
451
|
-
|
|
452
|
-
BugBunny implementa el patrón **Touchfile**. Puedes configurar la gema para que actualice la fecha de modificación de un archivo temporal en cada latido exitoso (heartbeat) hacia RabbitMQ.
|
|
453
|
-
|
|
454
|
-
**1. Configurar la gema:**
|
|
455
|
-
```ruby
|
|
456
|
-
# config/initializers/bug_bunny.rb
|
|
457
|
-
BugBunny.configure do |config|
|
|
458
|
-
# Actualizará la fecha de este archivo si la conexión a la cola está sana
|
|
459
|
-
config.health_check_file = '/tmp/bug_bunny_health'
|
|
460
|
-
end
|
|
461
|
-
```
|
|
462
|
-
|
|
463
|
-
**2. Configurar el Orquestador (Ejemplo docker-compose.yml):**
|
|
464
|
-
Con esta configuración, Docker Swarm verificará que el archivo haya sido modificado (tocado) en los últimos 15 segundos. Si el worker se bloquea o pierde la conexión de manera irrecuperable, Docker reiniciará el contenedor automáticamente.
|
|
465
|
-
|
|
466
|
-
```yaml
|
|
467
|
-
services:
|
|
468
|
-
worker:
|
|
469
|
-
image: my_rails_app
|
|
470
|
-
command: bundle exec rake bug_bunny:work
|
|
471
|
-
healthcheck:
|
|
472
|
-
test: ["CMD-SHELL", "test $$(expr $$(date +%s) - $$(stat -c %Y /tmp/bug_bunny_health)) -lt 15 || exit 1"]
|
|
473
|
-
interval: 10s
|
|
474
|
-
timeout: 5s
|
|
475
|
-
retries: 3
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
---
|
|
479
|
-
|
|
480
239
|
## 📄 Licencia
|
|
481
240
|
|
|
482
241
|
Código abierto bajo [MIT License](https://opensource.org/licenses/MIT).
|
data/lib/bug_bunny/consumer.rb
CHANGED
|
@@ -27,6 +27,8 @@ module BugBunny
|
|
|
27
27
|
# routing_key: 'users.#'
|
|
28
28
|
# )
|
|
29
29
|
class Consumer
|
|
30
|
+
include BugBunny::Observability
|
|
31
|
+
|
|
30
32
|
# @return [BugBunny::Session] La sesión wrapper de RabbitMQ que gestiona el canal.
|
|
31
33
|
attr_reader :session
|
|
32
34
|
|
|
@@ -45,6 +47,7 @@ module BugBunny
|
|
|
45
47
|
def initialize(connection)
|
|
46
48
|
@session = BugBunny::Session.new(connection, publisher_confirms: false)
|
|
47
49
|
@health_timer = nil
|
|
50
|
+
@logger = BugBunny.configuration.logger
|
|
48
51
|
end
|
|
49
52
|
|
|
50
53
|
# Inicia la suscripción a la cola y comienza el bucle de procesamiento.
|
|
@@ -77,8 +80,8 @@ module BugBunny
|
|
|
77
80
|
.merge(BugBunny.configuration.queue_options || {})
|
|
78
81
|
.merge(queue_opts || {})
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
safe_log(:info, "consumer.start", queue: queue_name, queue_opts: final_q_opts)
|
|
84
|
+
safe_log(:info, "consumer.bound", exchange: exchange_name, exchange_type: exchange_type, routing_key: routing_key, exchange_opts: final_x_opts)
|
|
82
85
|
|
|
83
86
|
start_health_check(queue_name)
|
|
84
87
|
|
|
@@ -100,7 +103,7 @@ module BugBunny
|
|
|
100
103
|
max_attempts = BugBunny.configuration.max_reconnect_attempts
|
|
101
104
|
|
|
102
105
|
if max_attempts && attempt >= max_attempts
|
|
103
|
-
|
|
106
|
+
safe_log(:error, "consumer.reconnect_exhausted", max_attempts_count: max_attempts, **exception_metadata(e))
|
|
104
107
|
raise
|
|
105
108
|
end
|
|
106
109
|
|
|
@@ -109,7 +112,7 @@ module BugBunny
|
|
|
109
112
|
BugBunny.configuration.max_reconnect_interval
|
|
110
113
|
].min
|
|
111
114
|
|
|
112
|
-
|
|
115
|
+
safe_log(:error, "consumer.connection_error", attempt_count: attempt, max_attempts_count: max_attempts || 'infinity', retry_in_s: wait, **exception_metadata(e))
|
|
113
116
|
sleep wait
|
|
114
117
|
retry
|
|
115
118
|
end
|
|
@@ -132,7 +135,7 @@ module BugBunny
|
|
|
132
135
|
path = properties.type || (properties.headers && properties.headers['path'])
|
|
133
136
|
|
|
134
137
|
if path.nil? || path.empty?
|
|
135
|
-
|
|
138
|
+
safe_log(:error, "consumer.message_rejected", reason: :missing_type_header)
|
|
136
139
|
session.channel.reject(delivery_info.delivery_tag, false)
|
|
137
140
|
return
|
|
138
141
|
end
|
|
@@ -141,8 +144,8 @@ module BugBunny
|
|
|
141
144
|
headers_hash = properties.headers || {}
|
|
142
145
|
http_method = (headers_hash['x-http-method'] || headers_hash['method'] || 'GET').to_s.upcase
|
|
143
146
|
|
|
144
|
-
|
|
145
|
-
|
|
147
|
+
safe_log(:info, "consumer.message_received", method: http_method, path: path, routing_key: delivery_info.routing_key)
|
|
148
|
+
safe_log(:debug, "consumer.message_received_body", body: body.truncate(200))
|
|
146
149
|
|
|
147
150
|
# ===================================================================
|
|
148
151
|
# 3. Ruteo Declarativo
|
|
@@ -159,7 +162,7 @@ module BugBunny
|
|
|
159
162
|
route_info = BugBunny.routes.recognize(http_method, uri.path)
|
|
160
163
|
|
|
161
164
|
if route_info.nil?
|
|
162
|
-
|
|
165
|
+
safe_log(:warn, "consumer.route_not_found", method: http_method, path: uri.path)
|
|
163
166
|
handle_fatal_error(properties, 404, "Not Found", "No route matches [#{http_method}] \"/#{uri.path}\"")
|
|
164
167
|
session.channel.reject(delivery_info.delivery_tag, false)
|
|
165
168
|
return
|
|
@@ -178,7 +181,7 @@ module BugBunny
|
|
|
178
181
|
begin
|
|
179
182
|
controller_class = controller_class_name.constantize
|
|
180
183
|
rescue NameError
|
|
181
|
-
|
|
184
|
+
safe_log(:warn, "consumer.controller_not_found", controller: controller_class_name)
|
|
182
185
|
handle_fatal_error(properties, 404, "Not Found", "Controller #{controller_class_name} not found")
|
|
183
186
|
session.channel.reject(delivery_info.delivery_tag, false)
|
|
184
187
|
return
|
|
@@ -186,13 +189,13 @@ module BugBunny
|
|
|
186
189
|
|
|
187
190
|
# Verificación estricta de Seguridad (RCE Prevention)
|
|
188
191
|
unless controller_class < BugBunny::Controller
|
|
189
|
-
|
|
192
|
+
safe_log(:error, "consumer.security_violation", reason: :invalid_controller, controller: controller_class)
|
|
190
193
|
handle_fatal_error(properties, 403, "Forbidden", "Invalid Controller Class")
|
|
191
194
|
session.channel.reject(delivery_info.delivery_tag, false)
|
|
192
195
|
return
|
|
193
196
|
end
|
|
194
197
|
|
|
195
|
-
|
|
198
|
+
safe_log(:debug, "consumer.route_matched", controller: controller_class_name, action: route_info[:action])
|
|
196
199
|
|
|
197
200
|
request_metadata = {
|
|
198
201
|
type: path,
|
|
@@ -217,13 +220,15 @@ module BugBunny
|
|
|
217
220
|
|
|
218
221
|
session.channel.ack(delivery_info.delivery_tag)
|
|
219
222
|
|
|
220
|
-
|
|
221
|
-
|
|
223
|
+
safe_log(:info, "consumer.message_processed",
|
|
224
|
+
status: response_payload[:status],
|
|
225
|
+
duration_s: duration_s(start_time),
|
|
226
|
+
controller: controller_class_name,
|
|
227
|
+
action: route_info[:action])
|
|
222
228
|
|
|
223
229
|
rescue StandardError => e
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
BugBunny.configuration.logger.debug { "component=bug_bunny event=execution_error backtrace=#{e.backtrace.first(5).join(' | ').inspect}" }
|
|
230
|
+
safe_log(:error, "consumer.execution_error", duration_s: duration_s(start_time), **exception_metadata(e))
|
|
231
|
+
safe_log(:debug, "consumer.execution_error_backtrace", backtrace: e.backtrace.first(5).join(' | '))
|
|
227
232
|
handle_fatal_error(properties, 500, "Internal Server Error", e.message)
|
|
228
233
|
session.channel.reject(delivery_info.delivery_tag, false)
|
|
229
234
|
end
|
|
@@ -235,7 +240,7 @@ module BugBunny
|
|
|
235
240
|
# @param correlation_id [String] ID para correlacionar la respuesta con la petición original.
|
|
236
241
|
# @return [void]
|
|
237
242
|
def reply(payload, reply_to, correlation_id)
|
|
238
|
-
|
|
243
|
+
safe_log(:debug, "consumer.rpc_reply", reply_to: reply_to, correlation_id: correlation_id)
|
|
239
244
|
session.channel.default_exchange.publish(
|
|
240
245
|
payload.to_json,
|
|
241
246
|
routing_key: reply_to,
|
|
@@ -284,7 +289,7 @@ module BugBunny
|
|
|
284
289
|
# 2. Si llegamos aquí, RabbitMQ y la cola están vivos. Avisamos al orquestador actualizando el archivo.
|
|
285
290
|
touch_health_file(file_path) if file_path
|
|
286
291
|
rescue StandardError => e
|
|
287
|
-
|
|
292
|
+
safe_log(:warn, "consumer.health_check_failed", queue: q_name, **exception_metadata(e))
|
|
288
293
|
session.close
|
|
289
294
|
end
|
|
290
295
|
@health_timer.execute
|
|
@@ -299,7 +304,7 @@ module BugBunny
|
|
|
299
304
|
def touch_health_file(file_path)
|
|
300
305
|
FileUtils.touch(file_path)
|
|
301
306
|
rescue StandardError => e
|
|
302
|
-
|
|
307
|
+
safe_log(:error, "consumer.health_check_file_error", path: file_path, **exception_metadata(e))
|
|
303
308
|
end
|
|
304
309
|
end
|
|
305
310
|
end
|
data/lib/bug_bunny/controller.rb
CHANGED
|
@@ -19,6 +19,7 @@ module BugBunny
|
|
|
19
19
|
class Controller
|
|
20
20
|
include ActiveModel::Model
|
|
21
21
|
include ActiveModel::Attributes
|
|
22
|
+
include BugBunny::Observability
|
|
22
23
|
|
|
23
24
|
# @!group Atributos de Instancia
|
|
24
25
|
|
|
@@ -128,6 +129,7 @@ module BugBunny
|
|
|
128
129
|
def initialize(attributes = {})
|
|
129
130
|
super
|
|
130
131
|
@response_headers = {}
|
|
132
|
+
@logger = BugBunny.configuration.logger
|
|
131
133
|
end
|
|
132
134
|
|
|
133
135
|
# Punto de entrada principal estático llamado por el Router (`BugBunny::Consumer`).
|
|
@@ -204,8 +206,7 @@ module BugBunny
|
|
|
204
206
|
end
|
|
205
207
|
|
|
206
208
|
# Fallback genérico si la excepción no fue mapeada
|
|
207
|
-
|
|
208
|
-
BugBunny.configuration.logger.error { "component=bug_bunny event=unhandled_exception backtrace=#{exception.backtrace.first(5).join(' | ').inspect}" }
|
|
209
|
+
safe_log(:error, "controller.unhandled_exception", backtrace: exception.backtrace.first(5).join(" | "), **exception_metadata(exception))
|
|
209
210
|
|
|
210
211
|
{
|
|
211
212
|
status: 500,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BugBunny
|
|
4
|
+
# @api private
|
|
5
|
+
module Observability
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# Registra un evento estructurado. Nunca eleva excepciones.
|
|
9
|
+
#
|
|
10
|
+
# @param level [Symbol] Nivel de log (:debug, :info, :warn, :error)
|
|
11
|
+
# @param event [String] Nombre del evento en formato "clase.evento"
|
|
12
|
+
# @param metadata [Hash] Pares clave-valor adicionales
|
|
13
|
+
def safe_log(level, event, metadata = {})
|
|
14
|
+
return unless @logger
|
|
15
|
+
|
|
16
|
+
fields = { component: observability_name, event: event }.merge(metadata)
|
|
17
|
+
|
|
18
|
+
log_line = fields.map do |k, v|
|
|
19
|
+
val = %i[password token secret api_key auth].include?(k.to_sym) ? "[FILTERED]" : v
|
|
20
|
+
next if val.nil?
|
|
21
|
+
|
|
22
|
+
formatted = case val
|
|
23
|
+
when Numeric then val
|
|
24
|
+
when String then val.include?(" ") ? val.inspect : val
|
|
25
|
+
else val.to_s.include?(" ") ? val.to_s.inspect : val
|
|
26
|
+
end
|
|
27
|
+
"#{k}=#{formatted}"
|
|
28
|
+
end.compact.join(" ")
|
|
29
|
+
|
|
30
|
+
@logger.send(level) { log_line }
|
|
31
|
+
rescue StandardError
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Genera metadatos estándar para una excepción.
|
|
35
|
+
#
|
|
36
|
+
# @param error [Exception] El objeto de error capturado.
|
|
37
|
+
# @return [Hash] Hash con error_class y error_message truncado.
|
|
38
|
+
def exception_metadata(error)
|
|
39
|
+
{
|
|
40
|
+
error_class: error.class.name,
|
|
41
|
+
error_message: error.message.gsub('"', "'")[0, 200]
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Timestamp del reloj monotónico para calcular duraciones.
|
|
46
|
+
#
|
|
47
|
+
# @return [Float] Tiempo actual del reloj monotónico.
|
|
48
|
+
def monotonic_now
|
|
49
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Duración en segundos desde un tiempo de inicio.
|
|
53
|
+
#
|
|
54
|
+
# @param start [Float] Valor devuelto por monotonic_now
|
|
55
|
+
# @return [Float] Duración en segundos redondeada a 6 decimales.
|
|
56
|
+
def duration_s(start)
|
|
57
|
+
(monotonic_now - start).round(6)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Infiere el nombre del componente desde el namespace de la clase.
|
|
61
|
+
# Ejemplo: BugBunny::Consumer → "bug_bunny"
|
|
62
|
+
#
|
|
63
|
+
# @return [String] Nombre del componente en snake_case.
|
|
64
|
+
def observability_name
|
|
65
|
+
klass = is_a?(Class) ? self : self.class
|
|
66
|
+
klass.name.split("::").first.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
67
|
+
rescue StandardError
|
|
68
|
+
"unknown"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/bug_bunny/producer.rb
CHANGED
|
@@ -13,6 +13,8 @@ module BugBunny
|
|
|
13
13
|
# 3. Implementar el patrón RPC síncrono utilizando futuros (`Concurrent::IVar`).
|
|
14
14
|
# 4. Gestionar la escucha de respuestas en la cola especial de RabbitMQ.
|
|
15
15
|
class Producer
|
|
16
|
+
include BugBunny::Observability
|
|
17
|
+
|
|
16
18
|
# Inicializa el productor.
|
|
17
19
|
#
|
|
18
20
|
# Prepara las estructuras de concurrencia necesarias para manejar múltiples
|
|
@@ -21,6 +23,7 @@ module BugBunny
|
|
|
21
23
|
# @param session [BugBunny::Session] Sesión activa de Bunny (wrapper).
|
|
22
24
|
def initialize(session)
|
|
23
25
|
@session = session
|
|
26
|
+
@logger = BugBunny.configuration.logger
|
|
24
27
|
# Mapa thread-safe para correlacionar IDs de petición con sus futuros (IVars)
|
|
25
28
|
@pending_requests = Concurrent::Map.new
|
|
26
29
|
@reply_listener_mutex = Mutex.new
|
|
@@ -33,7 +36,7 @@ module BugBunny
|
|
|
33
36
|
# configuración y publica el mensaje sin esperar respuesta.
|
|
34
37
|
#
|
|
35
38
|
# @param request [BugBunny::Request] Objeto con la configuración del envío (body, exchange_options, etc).
|
|
36
|
-
# @return [
|
|
39
|
+
# @return [Hash] Un hash de éxito simbólico ({ 'status' => 202 }).
|
|
37
40
|
def fire(request)
|
|
38
41
|
# Obtenemos el exchange pasando las opciones específicas del request para la fusión en cascada
|
|
39
42
|
x = @session.exchange(
|
|
@@ -48,6 +51,9 @@ module BugBunny
|
|
|
48
51
|
log_request(request, payload)
|
|
49
52
|
|
|
50
53
|
x.publish(payload, opts.merge(routing_key: request.final_routing_key))
|
|
54
|
+
|
|
55
|
+
# Devolvemos un hash para evitar NoMethodError en el cliente (que espera una respuesta tipo Hash)
|
|
56
|
+
{ 'status' => 202, 'body' => nil }
|
|
51
57
|
end
|
|
52
58
|
|
|
53
59
|
# Envía un mensaje y bloquea el hilo actual esperando una respuesta (RPC).
|
|
@@ -73,7 +79,7 @@ module BugBunny
|
|
|
73
79
|
begin
|
|
74
80
|
fire(request)
|
|
75
81
|
|
|
76
|
-
|
|
82
|
+
safe_log(:debug, "producer.rpc_waiting", correlation_id: cid, timeout_s: wait_timeout)
|
|
77
83
|
|
|
78
84
|
# Bloqueamos el hilo aquí hasta que llegue la respuesta o expire el timeout
|
|
79
85
|
response_payload = future.value(wait_timeout)
|
|
@@ -106,9 +112,9 @@ module BugBunny
|
|
|
106
112
|
.merge(BugBunny.configuration.exchange_options || {})
|
|
107
113
|
.merge(request.exchange_options || {})
|
|
108
114
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
115
|
+
safe_log(:info, "producer.publish", method: verb, path: target, routing_key: rk, correlation_id: id)
|
|
116
|
+
safe_log(:debug, "producer.publish_detail", exchange: request.exchange, exchange_opts: final_x_opts)
|
|
117
|
+
safe_log(:debug, "producer.publish_payload", payload: payload.truncate(300)) if payload.is_a?(String)
|
|
112
118
|
end
|
|
113
119
|
|
|
114
120
|
# Serializa el mensaje para su transporte.
|
|
@@ -142,16 +148,16 @@ module BugBunny
|
|
|
142
148
|
@reply_listener_mutex.synchronize do
|
|
143
149
|
return if @reply_listener_started
|
|
144
150
|
|
|
145
|
-
|
|
151
|
+
safe_log(:debug, "producer.reply_listener_start")
|
|
146
152
|
|
|
147
153
|
# Consumimos sin ack (auto-ack) porque reply-to no soporta acks manuales de forma estándar
|
|
148
154
|
@session.channel.basic_consume('amq.rabbitmq.reply-to', '', true, false, nil) do |_, props, body|
|
|
149
155
|
cid = props.correlation_id.to_s
|
|
150
|
-
|
|
156
|
+
safe_log(:debug, "producer.rpc_response_received", correlation_id: cid)
|
|
151
157
|
if (future = @pending_requests[cid])
|
|
152
158
|
future.set(body)
|
|
153
159
|
else
|
|
154
|
-
|
|
160
|
+
safe_log(:warn, "producer.rpc_response_orphaned", correlation_id: cid)
|
|
155
161
|
end
|
|
156
162
|
end
|
|
157
163
|
@reply_listener_started = true
|
data/lib/bug_bunny/session.rb
CHANGED
|
@@ -8,6 +8,7 @@ module BugBunny
|
|
|
8
8
|
#
|
|
9
9
|
# @api private
|
|
10
10
|
class Session
|
|
11
|
+
include BugBunny::Observability
|
|
11
12
|
# @!group Opciones por Defecto (Nivel 1: Gema)
|
|
12
13
|
|
|
13
14
|
# Opciones predeterminadas de la gema para Exchanges.
|
|
@@ -31,6 +32,7 @@ module BugBunny
|
|
|
31
32
|
@connection = connection
|
|
32
33
|
@publisher_confirms = publisher_confirms
|
|
33
34
|
@channel = nil
|
|
35
|
+
@logger = BugBunny.configuration.logger
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
# Obtiene el canal actual o crea uno nuevo si es necesario.
|
|
@@ -125,10 +127,10 @@ module BugBunny
|
|
|
125
127
|
def ensure_connection!
|
|
126
128
|
return if @connection.open?
|
|
127
129
|
|
|
128
|
-
|
|
130
|
+
safe_log(:warn, "session.reconnect_attempt")
|
|
129
131
|
@connection.start
|
|
130
132
|
rescue StandardError => e
|
|
131
|
-
|
|
133
|
+
safe_log(:error, "session.reconnect_failed", **exception_metadata(e))
|
|
132
134
|
raise BugBunny::CommunicationError, "Could not reconnect to RabbitMQ: #{e.message}"
|
|
133
135
|
end
|
|
134
136
|
end
|
data/lib/bug_bunny/version.rb
CHANGED
data/lib/bug_bunny.rb
CHANGED
|
@@ -5,6 +5,7 @@ require 'logger'
|
|
|
5
5
|
require_relative 'bug_bunny/version'
|
|
6
6
|
require_relative 'bug_bunny/exception'
|
|
7
7
|
require_relative 'bug_bunny/configuration'
|
|
8
|
+
require_relative 'bug_bunny/observability'
|
|
8
9
|
require_relative 'bug_bunny/routing/route_set'
|
|
9
10
|
require_relative 'bug_bunny/middleware/base'
|
|
10
11
|
require_relative 'bug_bunny/middleware/stack'
|
|
@@ -22,6 +23,9 @@ require_relative 'bug_bunny/railtie' if defined?(Rails)
|
|
|
22
23
|
# Módulo principal de la gema BugBunny.
|
|
23
24
|
# Actúa como espacio de nombres y punto de configuración global.
|
|
24
25
|
module BugBunny
|
|
26
|
+
extend BugBunny::Observability
|
|
27
|
+
private_class_method :safe_log, :exception_metadata, :observability_name
|
|
28
|
+
|
|
25
29
|
class << self
|
|
26
30
|
# @return [BugBunny::Configuration] La configuración global actual.
|
|
27
31
|
attr_accessor :configuration
|
|
@@ -83,7 +87,9 @@ module BugBunny
|
|
|
83
87
|
|
|
84
88
|
@global_connection.close if @global_connection.open?
|
|
85
89
|
@global_connection = nil
|
|
86
|
-
|
|
90
|
+
|
|
91
|
+
@logger = configuration.logger
|
|
92
|
+
safe_log(:info, "bug_bunny.disconnect")
|
|
87
93
|
end
|
|
88
94
|
|
|
89
95
|
# @api private
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bug_bunny
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.
|
|
4
|
+
version: 4.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- gabix
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
11
|
+
date: 2026-03-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bunny
|
|
@@ -243,6 +243,7 @@ files:
|
|
|
243
243
|
- lib/bug_bunny/middleware/json_response.rb
|
|
244
244
|
- lib/bug_bunny/middleware/raise_error.rb
|
|
245
245
|
- lib/bug_bunny/middleware/stack.rb
|
|
246
|
+
- lib/bug_bunny/observability.rb
|
|
246
247
|
- lib/bug_bunny/producer.rb
|
|
247
248
|
- lib/bug_bunny/railtie.rb
|
|
248
249
|
- lib/bug_bunny/request.rb
|